0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ここのえAdvent Calendar 2023

Day 23

JSONを読んでトップページのニュースを更新する (with Vue)

Last updated at Posted at 2023-12-22

この記事は ここのえ Advent Calendar 2023 Day 23 の記事です。

JSON形式の新着情報を、整形してVueで表示する

個人サイト・会社サイト問わず大抵のWebサイトでは、「お知らせ」や「プレスリリース」といったセクションを置きがちです(要出展)。

そんなお知らせセクションですが、他のWebサイトのコンテンツと異なり、何かイベントが発生するたびに頻繁に更新したいので、可能なら JSON で書いておいて、すぐ修正できるようにしておきたいです。

そんな訳で、Vue ベースの自分のサイトに乗せることにしました。
折角自分のサイトなので、以下2点を満たすように実装してみます。

  • <v-html>は使わない
    • 今回の用途では問題ないといえばそうだが、やはりHTMLを直書きできるのはXSSなどの心配もあり、避けたい気持ちがある
  • sanitize-html も避けたい
    • 極力ライブラリを大きくしたくないし、ユーザの入力を表示するわけでもないから大げさすぎてしまう

という事で、VueでJSONを読んで作る例です。

デザインのサンプル

こんな感じのデザインを目指します。

  • Desktop

image.png

  • Smartphone

image.png

実装

Vue + Typescript の構成になります。
実際には Pinia を使っていますが、なくても大丈夫なように簡略化します。
デザインは各自CSSを用意してください。

以下、全体の流れです。

  • LaravelやRailsなどから、Vueのpropsにデータが飛んでくる (NewsProp)
  • Vueの表示系で使うときの形式に変換 (NewsArticle)
  • 日付を基準として、降順に並び替え
  • 飛んできたデータのうち、未来の日付のニュースは表示しない

用意するJSONと各種データ構造

使用するデータの型は、以下の3つを使います。

NewsStore.ts
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が飛んできます。

news.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を出力します。

sample.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を変換して入れています。先程作ったデザインサンプルを再現するために、yearmonth, 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コンポーネントの実装

ここからが本題です。

記事の表示部分に焦点を絞って紹介します。
完成系のサンプルを再掲しておきます。
image.png

<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>

ジャンルによってテキストもアイコンも変更したいので、NewsArticletype, iconを見て生成させます。
同じようにvue-fontawesomeを使っている場合は、使うアイコンをimportしてlibrary.addするように気を付けます。

@fortawesome/vue-fontawesome の導入は FontAwesomeのドキュメント を参考にしてください。
アイコンのスタイリングに関する詳細は こちら

本筋と外れますが、Font Awesomeのアイコンは 微妙に同一サイズになっていません
CSSを取り扱うときは、アイコンのwidth/heightを固定にしたり、コンテナのサイズを固定幅にしたりする工夫が必要です。

tailwindのsample.sass
.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を投げる

SampleController.php
return view("sample", [
    "news" => json_decode(file_get_contents(public_path("json/news.json"), true))
]);

sample.blade.php
<div id="app">
    <index-page :news="@js($news)" />
</div>

publicフォルダ内のJSONをVueに渡す方法です。

json_decodeしたものをbladeの中で、@jsディレクティブを使って渡すだけです。シンプル。

  1. https://v2.ja.vuejs.org/v2/guide/list#lt-template-gt-%E3%81%A7%E3%81%AE-v-for

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?