Nuxt.jsとQiita APIで、自分が投稿した記事を取得・SSGでレンダリングするまでにやったこと

..

Qiitaから自分の記事を取ってきて表示する技術ブログを作るシリーズです。
環境構築と初回デプロイを行なった記事はこちら

今回作るページ

  • 記事を個別に表示するページ
  • 記事一覧(ページネーションできるようにする)

要件

  • パスはドメイン/articles/Qiitaで設定された記事IDにする
  • 自分のものではない記事のIDを入力されたら、404ページへリダイレクトさせる
  • 記事は1ページにつき10件表示する

個別の記事ページ

APIへリクエスト

pages配下に、articlesディレクトリと_id.vueというコンポーネントを追加。

その中で、Axiosを使ってAPIへリクエストを送るスクリプトを書く。

<template>
</template>

<script>
import axios from 'axios'

export default {
  async asyncData({ params, $config: { apiSecret, apiURL }, redirect }) {
    const {data} = await axios.get(
      `${apiURL}/items/${params.id}`,
      {
        headers: { Authorization: `Bearer ${apiSecret}` }
      }
    )
    return (data.user.id === 'inarikawa') ?
      { article: data }:
      redirect({ path: '/404'});
  }
}
</script>

環境変数から、APIのURL内の共通部分やアクセストークンを取り出して渡す。設定周りについては環境構築の記事で書いてるので割愛します。
paramsでこのページのURL(ドメイン/articles/記事ID)を受け取り、params.idで記事IDを取得。Qiitaから該当する記事を取得します。

dataにAPIからの戻り値を受けると、こんな感じの配列が返ってくるので、まずはユーザーIDを確認。僕のIDはinarikawaなので、そうじゃなかったらコンポーネントのdata.articleへ戻り値をバインドせずに、404ページへリダイレクトさせています。リダイレクト周りの公式ドキュメント

404ページはとりあえず空っぽのコンポーネントをpagesに入れておいて、あとで好きなように作り込むつもりです。

スクリーンショット 2022-03-31 12.48.47.png

記事の表示

タイトルと本文は別で値が入っているので、それぞれをテンプレート内に呼び出し。

<template>
  <div>
    <article>
      <CommonArticleHeader>
        <template #title>{{article.title}}</template>
      </CommonArticleHeader>
    </article>
    <CommonArticleAside />
  </div>
</template>

こういうページを参考にしながら最適なマークアップを行おうと思いましたので、記事はarticle要素の中に展開することにします。
その中でもタイトルや投稿日などの情報はheaderに書くものらしいので、header要素をラップしたコンポーネントを追加。自動インポートを使えるので、components/Common/Article/Header.vueをこの記述で呼び出せます。

<CommonArticleTitle>
  <slot name="title" />
</CommonArticleTitle>

Headerコンポーネント内でこのように値を受け取り、さらにTitleコンポーネントへパス。

<template>
  <v-container>
    <h1>
      <slot />
    </h1>
  </v-container>
</template>

<script>
export default {
}
</script>

<style lang="scss" scoped>
  h1 {
    font-size: 32px;
    font-weight: bold;
    line-height: 1.4;
    margin-top: 8px;
    word-break: break-all;
  }
</style>

スクリプトを書くコンポーネントとスタイルを書くコンポーネントを分けて管理したいので、こんな感じにしています。
Titleコンポーネントでは、とりあえずQiitaと同じCSSを当てました。あとでいい感じにします。

あとは記事の本文ですが、マークダウンとレンダリング済みHTMLの両方で送ってもらえるので、手っ取り早くHTMLの方をv-htmlで展開することにします。
XSSに対する脆弱性が心配になりますが、少なくともQiita APIの投稿は十分に入力値検証なりエスケープなりされてると思うので、とりあえず信用して展開します。何より不特定多数のユーザーの入力値をそのまま受け取るわけではないですし。

<CommonArticleSection v-html='article.rendered_body' />

renderd_bodyというキーで格納されているので、それをsection要素をラップしたコンポーネントへ渡します。
Section.vueはこんなとりあえずこんな感じです。

<template>
  <v-container>
    <section>
      <div v-bind="$attrs" />
    </section>
  </v-container>
</template>

これで、とりあえず記事を表示できるようになりました。

スタイルを当てる

v-htmlで展開したHTMLに対してはディープセレクタを使うことでスタイルを当てられるようなのですが、やってみてもよくわからなかったので、スクリプトでごりごりDOM操作を行うことにしました。noteのフロントエンド改善の発表でも「document APIを使ってるところが云々かんぬん」という話をしていたので、似たようなことをやってるのかもしれません。

暫定的に、スタイルが当たるべき要素をクラス名ごとに取ってきて、スタイル属性に対してQiitaと同じCSSをぶちこむという力技的な方法を取りました。

<script>
export default {
  computed: {
    styling() {
      return (elements, styles) => {
          Array.prototype.forEach.call(elements, function(el) {
          el.style = styles;
        });
      };
    },
  },
  mounted() {
    /**
     * 
     * Qiitaからコピってきたスタイルを当てる
     * 
     */
    this.styling(
      document.getElementsByClassName('code-frame'), 
      `
        background-color: #364549;
        color: #e3e3e3;
        margin: 1.5em -32px;
        padding: 1em 32px;
        font-size: .9em;
        position: relative;
      `
    );

    // ▲ こんなのが延々続きます

}
</script>

やたらといっぱいあるのですが、ほとんどはコードブロック内に書かれるコードの色を指定するCSSです。あれ、色が違うところは全部spanタグで分けて、colorごとにクラス属性を指定してたんですね。

とりあえず必要な部分のスタイルは全部この方法で当ててしまい、Qiitaで見た時と同じような見た目にしました。
スタイルは全て、あとから自分なりに考えます。完パクもどうかと思うし。

本番環境の設定を整える

ここまでの手順を踏んでも、NuxtLinkがないとSSGにページを生成してもらえないので本番環境では記事を見ることができません。どこかから記事の個別ページへ内部リンクがはられている必要があります。

また、Netlifyの404を出さずに自前の404ページを表示するための設定も必要です。

export default {
  generate: {
    fallback: true,
  },

nuxt.config.jsgenerateという項目を付けると、不明なリンク(SSGが生成したページ以外へのリンク)を叩かれてもNuxt内で解決し、生成済みの404ページへ飛ばしてくれます。(参考記事

あとは、どこでもいいので/articles/記事IDへ通じるnuxt-linkを設置します。

<NuxtLink to="/articles/1f7684ee918f8908f8c9">
  記事
</NuxtLink>

すると、ちゃんとドメイン/articles/1f7684ee918f8908f8c9のページをレンダリングしてくれて、アクセスすれば記事を表示できるようになります。めちゃめちゃ楽です。
リンクを付けるという点ではaタグもNuxtLinkもできることは変わりませんが、アプリケーション内部の「自分で生成して欲しい」リンクはNuxtLinkで通すというイメージです。

記事一覧ページ(ページネーション)

今回ブログを作るに当たっては、microCMSの公式ブログなども参考にはしていたのですが、microCMS + Nuxt で作る場合と比べ、Qiita API + Nuxt で作る場合は少し工夫が必要そうでした。(いまデプロイしているソースも大いに改善の余地があります)

とりあえず作ります。

設定を追加する

router: {
  extendRoutes(routes, resolve) {
    routes.push({
      path: '/articles/page/:p',
      component: resolve(__dirname, 'pages/articles/index.vue'),
      name: 'page',
    })
  },
},

/articles/page/ページ番号にアクセスがあったら、resolveの第二引数に渡したコンポーネントを使ってね、という設定です。

コンポーネントを作る

なんとなくmilieuのような構造にしたかったので、ページネーション時に使用するコンポーネントを作ります。

結果から書くとこんなテンプレートになっています。

<template>
  <v-container>
    <CommonArticleListContainer :articles="articles" />
    <div v-if="is_paginated">
      <NuxtLink v-for="number in length" :key="number" :to="`/articles/page/${number}`">
        {{number}}
      </NuxtLink>
    </div>
  </v-container>
</template>

1ページあたり10件表示したいのですが、仮に記事数が10件を下回る場合はページネーションボタンそのものを出すべきではありません。なのでv-ifで真偽値を受け取り、条件付きレンダリングを行うことにしました。

スクリプトはこんな感じです。

<script>
  computed: {
    article_total_count() {
      return this.user_data.items_count;
    },
    is_paginated() {
      return this.article_total_count > this.page_articles_count;
    },
    length() {
      return Math.ceil(((this.article_total_count) / this.page_articles_count));
    }
  },
  async asyncData({ params, $config: { apiSecret, apiURL } }) {
    const page_number = params.p || 1;
    const page_articles_count = 10;
    const token = { Authorization: `Bearer ${apiSecret}` };

    const response = await Promise.all([
      axios.get(`${apiURL}/users/inarikawa/items?page=${page_number}&per_page=${page_articles_count}`, { headers: token     	}),
      axios.get(`${apiURL}/users/inarikawa`, { headers: token }),
    ]);
    return { articles: response[0].data, user_data: response[1].data, page_articles_count: page_articles_count };
  },
</script>

/articles/page/ページ番号へアクセスがあると、paramsから取り出したページ番号をpageパラメータへ渡し、Qiita APIへリクエストします。
記事だけではなくユーザー情報を取得するAPIも一緒に叩いていますが、これは1ページあたり10件表示する場合に何ページできるかを計算するため、投稿した記事の総数が欲しいからです。

QIita APIは、どうもmicroCMSのようなすべての記事を一気に取得するURIがないようで、一度に最大20件しか取ってこれません。
具体的には、URIのクエリでpage(ページネーションした場合のページ番号)とper_page(一ページあたりの取得数)を渡さないといけません。このパラメータを省略すると、勝手に1ページ目が指定されて最初の20件を取得してきます。なので、事前に記事の総数を10で割ったら何ページできるかを計算するためには、ユーザー情報のitems_countを取得する必要があるわけです。
複数APIを一緒に叩くのでPromise.allを使ってます。

const response = await Promise.all([
      axios.get(`${apiURL}/users/inarikawa/items?page=${page_number}&per_page=${page_articles_count}`, { headers: token     	}),
      axios.get(`${apiURL}/users/inarikawa`, { headers: token }),
    ]);
    return { articles: response[0].data, user_data: response[1].data, page_articles_count: page_articles_count };

そうやって記事情報をarticles、ユーザー情報をuser_dataに入れ、一ページあたりの件数である10という数字も持っておきます。これはあとで定数化とかしたほうがいいかも。(読む人が任意で変えられるようにしたければコンポーネント内で持たせておけばいいけど)

computed: {
    article_total_count() {
      return this.user_data.items_count;
    },
    is_paginated() {
      return this.article_total_count > this.page_articles_count;
    },
    length() {
      return Math.ceil(((this.article_total_count) / this.page_articles_count));
    }
  },

仮に記事数が0〜10件であれば、ページネーション自体できないのでボタンも必要ありません。なので算出プロパティis_paginatedで、article_total_countが返す記事の総数を10と比較して、ボタンを出すべきかどうか判定します。lengthが記事一覧ページの総数を計算し、3ページなら3つページネーションボタンを作り、それぞれに/articles/page/ページ番号のリンクを付けます。

ほんとはVuetifyのv-paginationコンポーネントを使ってサクッと実装できたらよかったのですけど、SSGでよしなにやってもらいたい場合はNuxtLinkにする以外のやり方がよくわからなかったので、自分で作ってしまうことにしました。Vuetifyはほんとにブラックボックス的で、初めて使うコンポーネントは勝手が全くわからんです。

ちなみに記事の配列を受け取るコンポーネントは、とりあえずこんな感じになってます。

<template>
  <div>
    <div v-for="article in articles" :key="article.id">
      <NuxtLink :to="`/articles/${article.id}`">
        <CommonArticleListCard>
          {{ article.title }}
        </CommonArticleListCard>
      </NuxtLink>
    </div>
  </div>
</template>

<script>
export default{
  props: {
    articles: {
      type: Array
    },
  },
}
</script>
<template>
  <v-card>
    <v-card-title>
      <slot />
    </v-card-title>
  </v-card>
</template>

<script>
export default {
}
</script>

シンプルにぶん回して、v-cardに渡したタイトルにリンクを付けています。
あとでQiitaやZennみたいな「記事カード」風のデザイン作り込もうと思っています。

ちなみにmilieuみたいな構造というのは、

  • トップページに最新記事10件
  • 記事一覧ページが1〜nページ

という感じで、つまり最新の記事10件がトップページとarticles/page/1の両方に表示されるということです。

ここが最大の要改善ポイントなのですが、いまのところは全く同じコードをindex.vue/articles/index.vueの両方に書いています。
トップページと記事一覧ページのマークアップを明確に分けたかったためにこういう構造にしたわけですが、Nuxtのmiddleware機能やVuexを使うような形で一箇所に統一する方法を後で模索する予定です。

完成&mainへマージしてデプロイ

というわけで、個別の記事ページと記事一覧ページ(ページネーション対応)の基本的な実装がひとまず完了しました。
本番環境でもご覧の通り。

スクリーンショット 2022-03-31 13.08.45.png
スクリーンショット 2022-03-31 13.09.01.png
スクリーンショット 2022-03-31 13.09.15.png

25記事しかないので3ページだけ。

ここが重要ですが、記事一覧ページのコンポーネント内で値を持っているので、リロードしても同じページを表示することができています。最初に値を取ってきてVuexに入れる方法だと、リロードしたらそれらが消えてしまったりするので、このあとからはその課題に向き合う予定。

でもいったんスタイルをゴリゴリ書いていく方に浮気したいと思います。