この記事は ここのえ Advent Calendar 2023 Day 23 の記事です。
JSON形式の新着情報を、整形してVueで表示する
個人サイト・会社サイト問わず大抵のWebサイトでは、「お知らせ」や「プレスリリース」といったセクションを置きがちです(要出展)。
そんなお知らせセクションですが、他のWebサイトのコンテンツと異なり、何かイベントが発生するたびに頻繁に更新したいので、可能なら JSON で書いておいて、すぐ修正できるようにしておきたいです。
そんな訳で、Vue ベースの自分のサイトに乗せることにしました。
折角自分のサイトなので、以下2点を満たすように実装してみます。
-
<v-html>
は使わない- 今回の用途では問題ないといえばそうだが、やはりHTMLを直書きできるのはXSSなどの心配もあり、避けたい気持ちがある
-
sanitize-html
も避けたい- 極力ライブラリを大きくしたくないし、ユーザの入力を表示するわけでもないから大げさすぎてしまう
という事で、VueでJSONを読んで作る例です。
デザインのサンプル
こんな感じのデザインを目指します。
- Desktop
- Smartphone
実装
Vue + Typescript
の構成になります。
実際には Pinia
を使っていますが、なくても大丈夫なように簡略化します。
デザインは各自CSSを用意してください。
以下、全体の流れです。
- LaravelやRailsなどから、Vueの
props
にデータが飛んでくる (NewsProp
) - Vueの表示系で使うときの形式に変換 (
NewsArticle
) - 日付を基準として、降順に並び替え
- 飛んできたデータのうち、未来の日付のニュースは表示しない
用意するJSONと各種データ構造
使用するデータの型は、以下の3つを使います。
export interface NewsProp {
type: 'notice' | 'event' | 'release' // ニュースのタイプ
date: string // 日付 ex."2023/12/34"
contents: NewsArticleContent[]
}
export interface NewsArticle {
type: string // ニュースのタイプ(表示用に変換後。"お知らせ"など)
icon: string[] // <font-awesome-icon>に使う
date: Date // ソート比較用
date_texts: { // 表示用のテキスト
year: string
month_day: string
}
contents: NewsArticleContent[]
}
export interface NewsArticleContent {
text: string // 内容
utils?: string // Tailwind class
line?: boolean // 改行
link?: string // <a>のhref
}
-
NewsProp
-
prop
で渡されるJSONデータの形式
-
-
NewsArticle
- 表示に使用するデータ形式
-
NewsArticleContent
- ニュースに表示するテキストの中身。
NewsArticleContent
ですが、ここにHTMLを入れてしまうとv-html
を使うのであればそのまま書き出せますが、今回はなしで実装したいので <span>
タグ別に配列として入れる形にしています。
またlink
がある場合は<a>
タグとして取り扱いたいですが、これは要素の有無を見てVue側で判断するようにします(後述)。
このデータ型を踏まえたうえで、例えば以下のようなJSONが飛んできます。
[
{
"type": "event",
"date": "2023/12/01",
"contents": [
{
"text": "QiitaでAdvent Calendar 2023に参加しています。",
"line": true
},
{
"text": "カレンダーは "
},
{
"text": "こちら",
"link": "https://qiita.com/advent-calendar/2023/99no_exit"
},
{
"text": " 。"
}
]
}
]
この contents
は、最終的に以下のHTMLを出力します。
<span>QiitaでAdvent Calendar 2023に参加しています。</span>
<br/>
<span>カレンダーは </span>
<a href="https://qiita.com/advent-calendar/2023/99no_exit">こちら</a>
<span> 。</span>
JSONを表示系で使える形式に変換
const articles = [] as NewsArticle[]
const addNews = (newsData: NewsProp[]) => {
for (const article of newsData) {
let type: string
let icon: string[]
if (article.type === 'notice') {
type = 'お知らせ'
icon = ['fas', 'rss']
} else if (article.type === 'event') {
type = 'イベント'
icon = ['fas', 'calendar']
} else if (article.type === 'release') {
type = 'リリース'
icon = ['fas', 'star']
}
const date = new Date(article.date)
const date_str = article.date.split('/')
articles.push({
type,
icon,
date,
date_texts: {
year: date_str[0],
month_day: `${date_str[1]}.${date_str[2]}`,
},
contents: article.contents,
})
}
}
JSON.parse()
したものをaddNews
の引数として取ることを想定します。
articles
配列を用意して、飛んできたJSONを変換して入れています。先程作ったデザインサンプルを再現するために、year
とmonth, day
を分けて、予めstringにして入れています。
icon
は<font-awesome-icon>
を使っているので、これに対応した配列を用意しています。
<!-- RSSアイコンの例 -->
<font-awesome-icon :icon="['fas', 'rss']" />
ソート・時刻によるフィルタ
特に解説することもないのですが、Array.sortでソートしていきます。
this.articles.sort((a, b) => {
const n = a.date.getTime() - b.date.getTime()
if (n > 0) {
return -1
} else if (n < 0) {
return 1
} else {
return 0
}
})
時刻によってフィルターをかけ、未来の日付のものを取得します。
厳密にはデータとして飛んできている時点でバレてしまうのですが……今回はそこまで厳密である必要はないので、クライアント側だけで表示を処理しています。
必要に応じて、Laravel等のフレームワーク側で送る前に消すようにしてください。
const getNews = (): NewsArticle[] => {
const now = new Date()
return articles.filter((article: NewsArticle) => article.date <= now)
},
Vueコンポーネントの実装
ここからが本題です。
記事の表示部分に焦点を絞って紹介します。
完成系のサンプルを再掲しておきます。
<div v-for="(article, index) in articles" :key="index" class="article">
<div class="badge">
<span>{{ article.type }}</span>
<font-awesome-icon :icon="article.icon" size="xl" />
</div>
<div class="date">
<div class="y">{{ article.date_texts.year }}.</div>
<div class="md">{{ article.date_texts.month_day }}</div>
</div>
<div class="divider">
<svg viewBox="0 0 10 50">
<line x1="10" y1="0" x2="0" y2="50" />
</svg>
</div>
<div class="text">
<template v-for="text in article.contents" :key="text">
<a v-if="text.link !== undefined" :href="text.link" :class="text.utils">{{ text.text }}</a>
<span v-else :class="text.utils">{{ text.text }}</span>
<br v-if="text.line" />
</template>
</div>
</div>
全体
<div v-for="(article, index) in articles" :key="index" class="article">
articles
配列をv-for
で回しています。
keyをindexにするかどうか?については各種論争があると思いますが、今回のケースではアクセスした際に一緒にpropsが降ってきて、それ以降は一切変動しないのでindex
をそのまま使っています。
axios
などを使って動的に取得するのであれば、各ニュースにIDを振っておきkeyにした方が良いです。
badge
<script setup lang="ts">
import { faRss, faCalendar, faStar } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(faRss, faCalendar, faStar)
</script>
...
<div class="badge">
<span>{{ article.type }}</span>
<font-awesome-icon :icon="article.icon" size="xl" />
</div>
ジャンルによってテキストもアイコンも変更したいので、NewsArticle
のtype
, icon
を見て生成させます。
同じようにvue-fontawesome
を使っている場合は、使うアイコンをimportしてlibrary.add
するように気を付けます。
@fortawesome/vue-fontawesome
の導入は FontAwesomeのドキュメント を参考にしてください。
アイコンのスタイリングに関する詳細は こちら。
本筋と外れますが、Font Awesomeのアイコンは 微妙に同一サイズになっていません。
CSSを取り扱うときは、アイコンのwidth/heightを固定にしたり、コンテナのサイズを固定幅にしたりする工夫が必要です。
.badge
@apply flex justify-center items-center w-[135px]
テキストの表示部
<div class="text">
<template v-for="text in article.contents" :key="text">
<a v-if="text.link !== undefined" :href="text.link" :class="text.utils">{{ text.text }}</a>
<span v-else :class="text.utils">{{ text.text }}</span>
<br v-if="text.line" />
</template>
</div>
中心となるポイントです。NewsArticle.contents
の分だけループして、<a>
や<span>
要素を生成します。
中身を見るまで何のタグを当てるのか判断がつかないので、<template v-for>
で回す1ことで余分なDOM要素が生成されないように工夫をしています。
<a>
タグと<span>
タグの判定については、先述の通りlink
のURLの有無で判断してタグをつけます。linkはOptionalになっているので、存在しない場合はundefinedになるため、上記のような判定が通ります。
Tailwindを使っているので、text.utils
でCSSユーティリティを当てるための仕組みも一応用意しています。
普通にクラスを当てたい場合も同じ原理で出来ますが、分かりやすいように変数名を変えておいた方が良いと思います。ただclass
は予約語なので使えないのが、毎度頭を悩ませるところですね……
改行の処理については素直な感じで、line:true
であれば<br/>
を書き込むようにしています。
あとがき
表示用のデータ構造については、デザインによる都合もあると思います。
その辺りは用途に応じていい感じにしてみてください。
今回の例ではテキストを載せるだけだったのでこういった実装にしました。
これ以上の規模になってくるのであれば普通にサニタイザを使った方が賢明ですね。
おまけ Laravel → Vueにpublicフォルダ内のJSONを投げる
return view("sample", [
"news" => json_decode(file_get_contents(public_path("json/news.json"), true))
]);
<div id="app">
<index-page :news="@js($news)" />
</div>
public
フォルダ内のJSONをVueに渡す方法です。
json_decode
したものをblade
の中で、@js
ディレクティブを使って渡すだけです。シンプル。