👨‍💻

Nuxt でハイパフォーマンスを意識したアプリケーション開発

2024/02/13に公開

はじめに

フロントエンジニア3年目(総エンジニア歴6年)を迎えるにあたり、Nuxt / パフォーマンス / SEO など、Web アプリケーション開発について、たくさん学んできました。
今回は、Nuxt におけるパフォーマンスにフォーカスした話を記事にしたいと思います。
きっかけとしては、下記の記事を読んだ時でした。

医療のマスターDBを爆速で検索するWebサービスを爆速で作った

こちら、本当に素晴らしい記事でした。。。
上記記事を読んだ時、「Nuxt でも同じように爆速で稼働するアプリケーション開発が可能では?」と思い、すぐに手をつけました。

本記事で伝えたいこと

こちらに関しては、下記となります。

  • パフォーマンスを良くするためのキーワードをしっかり抑えることで、どのようなライブラリやフレームワークでもパフォーマンスを向上することは可能

上記で言っているキーワードとは、例えば、「事前に遷移先のページのコンテンツを読み込む」や「最初に表示するコンテンツ量を少なくする」「コンポーネントの読み込みを非同期にする」など、「こうやったらパフォーマンス良くなる」についてのことです。
パフォーマンスを良くするために、それぞれのライブラリやフレームワークが大体のことはサポートしています。
また、一見当たり前のことを言っているようですが、Vue / Nuxt / React / Next と、ライブラリやフレームワークにはパフォーマンスを良くするためのヒントが公式には色々散りばめられています。
パフォーマンスを良くするためのキーワードとそれらの情報を組み合わせることで、いくらでもパフォーマンスを向上させることはでき、そこが重要であるので、そのように書かせていただきました。

成果物

アプリケーションは至ってシンプルで、Qiita / Zenn の技術に関する記事一覧を閲覧することができるアプリケーションです。

https://shiminori-tec-articles.onrender.com

URL のドメインを確認していただくとわかる方もいると思いますが、デプロイ先は Render というサービスを使っています。(フリープランを使用しているので、アクセスがないとサーバがダウンするらしく、アクセスした際になかなか閲覧できないのはフリープランのせいだと思っていただければです笑)
Render に関する情報は、最後のおまけで簡単に説明させていただきます。

パフォーマンスで意識したこと

極力、ライブラリはインストールしない

こちらは、最終的なバンドルサイズの軽量化のためのものです。
たった1つの画面でしか使用しないようなライブラリや独自で実装できそうなものに関しては、極力入れないようにしました。(成果物における dependencies はたった5つのみにしています)
JavaScript で使用されるユーティリティライブラリである lodash なども便利ではありますが、やろうと思えば独自で実装できたりするので、そう言ったものなどもインストールしないようにしました。
UI ライブラリもあれば簡単にアプリケーション作成できるメリットはありますが、実際複雑なものでなければ独自で実装するようにしています。

バンドルサイズまたは Nuxt アプリケーションを分析するためによく analyze コマンドを使用すると思うので、そちらも使用して分析するのもおすすめです。

$ npx nuxi analyze [--log-level] [rootDir]

Nuxt で、パフォーマンス向上を意識したソースを組む

基本的にリンクのタグは a タグの使用は避けました。
というのも、その a タグの最適化を行なってくれるのが NuxtLink だからになります。
また、NuxtLink の props にて prefetch というものがあったりします。

prefetch: When enabled will prefetch middleware, layouts and payloads (when using payloadExtraction) of links in the viewport. Used by the experimental crossOriginPrefetch config.

要は、ビューポート内のリンクのミドルウェア、レイアウト、およびペイロードをプリフェッチしてくれるものです。
こう言ったパラメータを使用して、スムーズな画面遷移を行うように設定しました。

NuxtImage の使用

こちらに関しては、簡単に説明すると img タグの最適化を行なってくれる Nuxt モジュールになります。
パフォーマンス向上に関しては、スピードが上がるというより、LCP / CLS に影響が出るようなものになるので、SEO 評価が向上するといった効果が期待できるものになります。

preloadRouteComponents の使用

ユーザーが将来ナビゲートするかもしれない、与えられたルートのコンポーネントをロードします。
これにより、コンポーネントがより早く利用可能になり、ナビゲーションを妨げる可能性が低くなり、パフォーマンスが向上します。

公式にある通り、ボタンクリックや submit 等で画面遷移する場合に用いる便利なユーティリティとなります。

// we don't await this async function, to avoid blocking rendering
// this component's setup function
preloadRouteComponents('/dashboard')

const submit = async () => {
  const results = await $fetch('/api/authentication')

  if (results.token) {
    await navigateTo('/dashboard')
  }
}

preloadComponents の使用

レンダリングライフサイクルの早い段階でロードを開始したい、ページがすぐに必要とするコンポーネントをロードします。
これにより、コンポーネントが早期に利用可能となり、ページのレンダリングがブロックされにくくなります。

pages において、早期にロードを開始したいコンポーネントを登録するよう修正しました。
このようにすることで、レンダリングがスピーディーになることが期待できます。(使用方法があってなかったら申し訳ないのですが笑)

pages/index.vue
<script setup lang="ts">
await preloadComponents('PageContentsTopContainer')
</script>

<template>
  <PageContentsTopContainer />
</template>

prerenderRoutes の使用

プリレンダリングするとき、たとえその URL が生成されたページの HTML に表示されなくても、追加のパスをプリレンダリングするように Nitro サーバにヒントを与えることができます。
正直、NuxtLink の prefetch との違いを細かく把握できなかったのですが、こちらもある分にはパフォーマンスが向上できるので、配置させていただきました。

<script setup lang="ts">
prerenderRoutes(['/page-path'])
</script>

<template>
  <NuxtLink to="/page-path" prefetch>遷移</NuxtLink>
</template>

LazyComponent の使用

初期レンダリング時に、表示されないコンポーネントなど、コンポーネント名に Lazy のプレフィックスを付与すると、そのコンポーネントを遅延ロードしてくれます。
データが空だった場合のコンポーネントやモーダル、ツールチップなど、そのようなコンポーネントには Lazy を付与することで遅延ロードさせるように制御できます。

Dynamic Imports

上記をうまく使用し、初期画面のロード時間の短縮を実現できるようにしました。

表示数の多いリスト表示に関しては、1つ1つのレコードに対し、コンポーネントの抽象化を避ける

こちらは Vue の公式にそのような記載があります。

不必要なコンポーネントの抽象化を避ける

時には、より良い抽象化やコード構成のために、レンダーレスコンポーネントや高階コンポーネント(つまり、追加の props で他のコンポーネントをレンダリングするコンポーネント)を作ることもあります。これは悪いことではありませんが、コンポーネントインスタンスはプレーンな DOM ノードよりもはるかに高価であり、抽象化パターンによりそれらを大量に生成すると、パフォーマンスコストが発生することを覚えておいてください。

数個のインスタンスを減らすだけでは顕著な効果はないため、アプリ内で数回しかレンダリングされないコンポーネントなら頑張る必要はないことに注意してください。この最適化を検討するのにふさわしい場面は、やはり大きなリストです。100 のアイテムからなり、各アイテムのコンポーネントが多数の子コンポーネントを含んでいるリストを想像してみてください。ここで不要なコンポーネントの抽象化をひとつ削除すると、何百ものコンポーネントインスタンスを削減することができます。

本アプリケーションでは、無限スクロールの実装をしたため、なるべくレコードのコンポーネントは細かいコンポーネントに分割しないよう意識しました。
ただ、必要最低限のコンポーネント分割は実施しています笑

v-once ディレクティブの使用

こちらは、要素やコンポーネントを一度だけレンダリングし、その後の更新はスキップするディレクティブになります。
あまり使う機会は少ないのですが、自分は pages の definePageMeta に key 属性をつけたものに関して、一度だけレンダリングしたいものだけに使用しました。

pages/index.vue
definePageMeta({
  key (route) {
    return route.fullPath
  }
})
<h2 v-once>タイトル</h2>

computed の安定性を担保する

こちらは、 Vue v3.4 から算出プロパティのパフォーマンス向上におけるものになります。

算出プロパティの安定性

今までは、算出値が少しでも変更されたら算出プロパティのコールバックが実行されていましたが、算出値が以前の値から変更された場合にのみトリガーするようになったようです。
ただ、オブジェクトが算出値である場合は、書き方を工夫する必要があるようです。(オブジェクトである場合いは、算出値が以前の値から変更された場合を検知できないようです)
こちらは公式にある通りで、オブジェクトの中の値が変更されたかどうかの条件式を加えるとうまく動くとのことです。(もし違ったらコメントいただけるとです笑)

const computedObj = computed((oldValue) => {
  const newValue = {
    isEven: count.value % 2 === 0
  }
  if (oldValue && oldValue.isEven === newValue.isEven) {
    return oldValue
  }
  return newValue
})

パフォーマンス測定結果

画面はたった3画面ですが、それぞれの画面の Lighthouse の計測結果を添付します。
計測では、本アプリケーションはモバイルには対応していないので Desktop での集計を実施しました。

  • トップページ
    スクリーンショット 2024-02-11 15.56.28.png

  • Qiita 記事一覧
    スクリーンショット 2024-02-11 15.57.03.png

  • Zenn Tech 記事一覧
    スクリーンショット 2024-02-11 15.57.33.png

トップページに関しては、めちゃくちゃシンプルなページではあるので、まあ 100% であることには何も違和感がなかったです笑
Qiita / Zenn の記事一覧ページに関しては、色々試しましたが 100% にすることはできませんでした。。。
Ready のサーバよしなにパフォーマンスよく SSR 時の HTML を返却してくれてはいるものの、Qiita / Zenn の API アクセスに時間がかかったということが影響しているのか、Qiita / Zenn 記事一覧ページでは、不要な JS が読み込まれてしまっているとの結果にもなったので、もっと削れるソースがあるのかも?と思ったりしています。

まとめ

パフォーマンスを良くするために、Nuxt で試行錯誤したお話でした。
いかがったでしょうか?
自分はパフォーマンスを良くするための方法に関して、参考書等で基本的なことは知っていましたが、そのライブラリ・フレームワークでパフォーマンスを良くする部分に関しては新しい情報が多く、気がついたら結構のめり込んでました。
色々調べていくうちに思ったこととしては、僕らが開発でパフォーマンスに課題を抱えているのと同じように、そのライブラリ・フレームワーク自身もパフォーマンスに課題を抱えているということ、また、使う側も工夫してソースを書かないといけないということです。
そのため、使っているライブラリ・フレームワークのバージョンを常に追従して、よりパフォーマンスを良くできるよう戦う必要があるなと思っています。
(なので、今後も諦めずに戦っていこうと思います)

おまけ

Ready が最高という話

今回の成果物では Ready というクラウドインフラを使用しました。
こちら、本当に素晴らしかったので、少し共有させていただきます。

セットアップが簡単すぎる

調べてみると Nuxt の公式に、Ready でのデプロイ方法に関する記事も見つけられました。
こちらの通りに実施したら、あっという間にデプロイ完了して、最初は感動しました笑
SPA ではなく SSR フレンドリーなアプリケーションをデプロイするには、前だったら heroku を使って無料で運用していたんですが、現時点において無料で運用できるクラウドインフラはないのかと結構探している時でもあったので、本当に助かりました。

PR 作成時のプレビュー環境構築機能が最高

こちらは Ready 側の設定で下記の設定項目を Enable にするだけで PR を作成した際に、勝手に PR の修正におけるプレビュー環境を構築してくれます笑

スクリーンショット 2024-02-11 16.43.23.png

実際に PR では下記のコメントが来ます。

スクリーンショット 2024-02-11 16.45.22.png

無料でここまでできちゃうってすごいですね。。。笑
確か Firebase v7 系でも同じような機能があったりしてそれも感動した記憶があったんですが、今はそれが当たり前になりつつあるんですかね?笑

本アプリケーションのディレクトリ構成

こちらも少し工夫したところにはなるので、載せておこうと思います。
本アプリケーションのディレクトリ構成は下記になります。(一部省略しています)

./
├── assets
│   ├── style           // グローバルスタイル
│   └── svg             // SVG ファイル
├── components
│   ├── layout-contents // レイアウトに関するコンポーネント (ビジネスロジック有)
│   ├── page-contents   // ページに関するコンポーネント (ビジネスロジック有)
│   ├── projects        // レイアウト・ページどちらでも使用されるコンポーネント(ビジネスロジック有)
│   └── ui-parts        // 汎用的なコンポーネント群 (UI ライブラリにあるようなコンポーネント群)
├── composables
├── constants
│   ├── business        // ビジネスロジックで扱う定数ファイル群
│   ├── common          // 共通で扱う定数ファイル群
│   └── component       // コンポーネントのロジック等で扱う定数ファイル群
├── enums
│   ├── business        // ビジネスロジックで扱う列挙型ファイル群
│   ├── common          // 共通で扱う列挙型ファイル群
│   └── component       // コンポーネントのロジック等で扱う列挙型ファイル群
├── functions
│   ├── business        // ビジネスロジックファイル群
│   ├── common          // 共通のロジックファイル群
│   └── component       // コンポーネントのロジックファイル群
├── infrastructures
│   └── rest            // REST API とやり取りを行う (repository 層)
├── layouts
├── pages
├── plugins
├── public
├── server
├── store
│   └── page            // ページ単位でのデータ永続層
├── types
│   ├── core            // 共通で使用する型ファイル群
│   └── lib             // サードパーティの型拡張を行うファイル群 (*.d.ts)
├── utils
├── app.vue
└── error.vue

フロントでは、ディレクトリ構成をどうするかなどで、Atomic Design など色々模索がされていました。
結局のところ、上記のディレクトリ構成が Nuxt で一番扱いやすいものとなったので、何かの参考になればと思います。
(全てふわっとしか書いていないので、なかなか伝わりづらいと思いますが笑)
もし、気になるという方は、実際のリポジトリソースを確認していただいてイメージを掴んでいただければです。

逆に、他の人たちはどのようなディレクトリ構成にしているのかも気になっているので、ここは気が向けば別記事にして、コメントを募集したいなと思っています。

Discussion