Nuxt.jsの初期化処理について

はじめに

こんにちは、メルペイフロントエンドチームの @tokuda109 です。

この記事は、メルペイフロントエンドチームで取り組んでいるパフォーマンス改善について紹介するシリーズ記事の第3弾の記事で、ブラウザ上の Nuxt.js アプリケーションの初期化処理について紹介します。

前回の記事のおさらい

前回の記事で JavaScript がブラウザ上でどのように処理されるのかを紹介しました。

マイクロタスクは、Promise の then 等のハンドラに実装されたコールバック関数を指します。
JavaScript のランタイムの振る舞いとして、マイクロタスクキューに積まれたマイクロタスクは1サイクル内で空になるまで JavaScript エンジンで優先して実行されます。

図1の Run Microtasks という処理ブロックは、Promise の処理が行われていて、他の処理をブロックしていることを示しています。

ボトルネックになっている部分のアクティビティ
図1: ボトルネックになっている部分のアクティビティ

そして、このタスクを改善するには、Evaluate Script と Run Microtasks の両方を改善する必要があります。

前提条件

調査・計測環境を固定するために、以下の環境の元に調査を行っています。

  • Node.js: 16.13.1
  • Nuxt.js: 2.15.8
  • TypeScript: 4.5.5
  • Google Chrome: 最新版

また、調査に用いるソースコードは GitHub で公開しています。
内容は「Hello world!」という文字を表示するだけの簡易な Nuxt.js のデモアプリケーション(以下、デモアプリ)です。

ソースコードには、.nuxt-build ディレクトリと、.nuxt-dev ディレクトリが存在します。
.nuxt-build ディレクトリには nuxt build を、.nuxt-dev ディレクトリには nuxt dev を実行した時に生成される成果物が含まれています。

この記事で行った調査は、.nuxt-build ディレクトリの成果物を用いて計測を行っていて、記事の説明には .nuxt-dev ディレクトリの成果物を使っています。
(これらのディレクトリは説明するために用意したもので、実際には .nuxt ディレクトリに成果物がビルドされるので注意して下さい)

パフォーマンスの計測方法

ローカルサーバーの起動とパフォーマンス計測

リポジトリを手元にダウンロードして、依存パッケージをインストールします。

$ git pull git@github.com:tokuda109/nuxt-client-investigation.git
$ cd nuxt-client-investigation
$ npm ci

インストールが終わったら、開発サーバーを起動します。

$ npm run build
$ npm start

http://localhost:3000 にブラウザでアクセスすると、Hello world!という文字があるだけの画面が表示されます。
HTMLは、概ね次のようなものが返却されます。

<!doctype html>
<html>
  <head>
    <link rel="preload" href="/_nuxt/8055793.js" as="script">
    <link rel="preload" href="/_nuxt/2ab1c10.js" as="script">
    <link rel="preload" href="/_nuxt/60982e3.js" as="script">
    <link rel="preload" href="/_nuxt/4fd6200.js" as="script">
  </head>
  <body>
    <div data-server-rendered="true" id="__nuxt">
      <div id="__layout">
        <p>Hello world!</p>
      </div>
    </div>
    <script>
      window.__NUXT__=(function(a,b){
        return {
          layout: "default",
          config: {}
        }
      }(null,"u002F"));
    </script>
    <script src="/_nuxt/8055793.js" defer></script>
    <script src="/_nuxt/2ab1c10.js" defer></script>
    <script src="/_nuxt/60982e3.js" defer></script>
    <script src="/_nuxt/4fd6200.js" defer></script>
  </body>
</html>

Chrome DevTools を起動し、Performance タブに切り替えます。
デモアプリを計測すると次のような計測結果を得ることができます。(図2)

Chrome DevTools を起動した状態のブラウザ
図2: Chrome DevTools を起動した状態のブラウザ

図1で見た結果と同じような内容が表示され、その上に横長の青いブロックもあります。これは、この部分の処理が他の処理をブロックしていることを示しています。
ブロックしている時間が50ミリ秒を超えるものをロングタスクと呼び、青い斜線部分は50ミリ秒を超えている時間を示していて、パフォーマンス指標の低下を招きます。

1行のテキストを表示するだけの小さなアプリですが、ロングタスクが発生し、お客様体験を損なっていることが分かります。

ビルド内容

処理内容の調査に入る前に、どのようなファイルがビルド時に生成されているのか調べました。

npm run build をすると次のファイルが成果物として生成され、ブラウザで画面を表示する時に評価され、実行されます。これらのファイルは、ソースコードの .nuxt-build/dist/client にあります。

ファイル名 ファイルサイズ 内容 (Chunk name)
8055793.js 2.29 KB webpack の実装が含まれていて、モジュールのダウンロード機構が含まれています。
ページ別にビルドされたファイルの辞書が定義されていて、ページ遷移をする際にルーティングに必要なページのファイル名が解決され、ダウンロードすることでページ遷移が行われています。
2ab1c10.js 169 KB vue, vue-router, vue-meta 等の実装が含まれています。主に依存パッケージがこちらに含まれます。
60982e3.js 50.9 KB Nuxt.js アプリケーションの主要な機能が含まれています。
.nuxt-dev ディレクトリの client.jsmiddleware.js も含まれていて、nuxt.config.ts の内容を変更したり、新しくミドルウェアを追加実装すると内容が変わります。
4fd6200.js 282 B src/pages/index.vue のビルド結果を含んでいます。

アクセスしたページのモジュール以外はダウンロードされないようになっていて、デモアプリは1ページしかないため、4fd6200.js のみが 8055793.js に定義されています。Nuxt.js のアプリケーションの戦略的なモジュール分割方法を窺い知ることができます。

ハイドレーション

ブラウザでデモアプリにアクセスすると、デモアプリのサーバーサイドがリクエストを受け取り、HTML をレスポンスとして返却します。
返却された HTML は、サーバーサイドが組み立てたもので、ブラウザ上で Vue.js によって組み立てられ描画された内容ではありません。

ブラウザがレスポンスとして受け取った HTML と、Vue.js が内部的に保持する DOM 構造(仮想 DOM)の突き合わせが必要になります。
この処理をハイドレーションと呼び、実際の DOM と仮想 DOM のデータの突き合わせが一致することで、ユーザーによる操作を処理し、適切な応答をすることが可能になります。

デモアプリの初期化

ここからは、ブラウザ上のデモアプリの処理を見ていきます。

前述しましたが、サーバーサイドから返却された HTML は、ただの静的なドキュメントでしかありません。ハイドレーションを行い、インタラクティブな状態にするために Nuxt.js のアプリケーションの初期化を行う必要があります。

client.js

ブラウザ上で Nuxt.js のアプリケーションの初期化を行うのが、client.js です。
このファイルは、.nuxt-dev/client.js にあり、ビルドをすると 60982e3.js に含まれます。

ここからは、コードと一緒に説明しやすいことから、.nuxt-dev/client.js を使って説明します。

.nuxt-dev/client.js の説明に必要なものだけに絞ると次のようなコードになります。
ビルド前のテンプレートファイルはこちらのGitHub上で見ることができます。nuxt.config.ts の設定に従って、必要な実装のみがビルド時に含まれるようなテンプレート形式になっています。

import Vue from 'vue'
import middleware from './middleware.js'
import { applyAsyncData, ... } from './utils.js'
import { createApp } from './index.js'

const NUXT = window.__NUXT__ || {}

createApp(null, NUXT.config).then(mountApp)

async function mountApp (__app) {
}

.nuxt-dev/client.js は初期化に必要な実装をインポートし、サーバーサイドから HTML に出力された window.__NUXT__ オブジェクトを設定として受け取ります。

その設定を createApp にパラメータとして渡して実行します。関数名からアプリケーションの作成と初期化をしていることが推測できます。
それが終わると mountApp がハンドラとして実行され、作成したアプリケーションが DOM にマウントされていることが関数名から推測できます。

この処理を図3の Chrome DevTools のアクティビティに当てはめて説明します。この図は処理を追いやすくするために、npm run dev を実行して起動したものに対して計測をしたものになります。

Nuxt.js のアプリケーションの初期化処理の詳細
図3: Nuxt.js のアプリケーションの初期化処理の詳細

①は .nuxt-dev/client.js を実行した後、実行が完了するまでが一連のタスクであることを示しています。
②は core-jsvue.runtime.esm.js./.nuxt-dev/middleware.js./.nuxt/utils.js./.nuxt/index.js のインポートをしています。
②の部分でインポートされるファイルの内容を紹介します:

  • core-js のインポート
    • core-js/modules/es6.array.from.jscore-js/modules/es6.array.iterator.js 等の必要なポリフィル実装がインポートされる
  • Vue.js のインポート
    • vue/dist/vue.runtime.esm.js がインポートされる
  • Nuxt.js のアプリケーションで必要なものをインポート
    • .nuxt/middleware.js がインポートされる。ミドルウェアを使っていない場合は省略される
    • .nuxt/App.js がインポートされる。返却された HTML の id 属性が #__nuxt の div 要素にマウントされるコンポーネントです
    • .nuxt/utils.js がインポートされる
    • .nuxt/index.js がインポートされる
    • vue-meta/dist/vue-meta.esm.browser.js.nuxt/router.js 等がインポートされる
    • ストア機能を使っている場合は .nuxt/store.js がインポートされる
    • src/plugins ディレクトリにあるプラグインがインポートされる

core-jsvue/dist/vue.runtime.esm.js は、アプリケーションのコア部分なので、基本的には改善余地がない部分ですが、.nuxt-dev/middleware.js やプラグインの部分がボトルネックになっている場合は、設定する側の問題なので改善できる可能性があります。
メルペイのフロントエンドでは、インポートするだけで10ミリ秒くらい必要になるライブラリが複数インポートされている例があったので、初期化コストの高いライブラリが含まれていないか確認することをお勧めします。

createApp & mountApp

ここでは図3の③の部分について説明します。
図3の③から④の処理までに createAppmountApp の一部が実行されます。
まずは createApp がどのようなことをしているのか説明するために、簡略化したコードを用意しました。元のコードはこちらにあります。

async function createApp(ssrContext, config = {}) {
  const router = await createRouter(ssrContext, config)

  const app = {};

  return {
    app,
    router
  }
}

やっていることは簡単で、app オブジェクトを作成し、router オブジェクトを作成して返却しているだけです。ストア機能を使う場合は、store オブジェクトも作成され、同様に返却されます。
コードは省略しましたが、createApp は返り値を返す前にルーティングの処理も行っていて、URL と表示するページを紐付け、紐付けられたページのモジュールが読み込まれます。
ルーティング処理が完了すると、createAppapprouter を返し、続いて mountApp の処理が開始されます。
mountApp の簡略化したコードは次のようになっていて、元のコードはこちらにあります。

async function mountApp (__app) {
  app = __app.app
  router = __app.router

  const _app = new Vue(app)

  const mount = () => {
    _app.$mount('#__nuxt')
  }

  // 追記: トップページはサーバーが描画したページで、
  //      サーバーサイドから返却されたルートのパスと
  //      ブラウザ上で評価したルートのパスが同じになるため、
  //      以下の条件が真になり、mount の呼び出しが行われます。
  if (
    NUXT.serverRendered &&
    isSamePath(NUXT.routePath, _app.context.route.path)
  ) {
    return mount()
  }
}

mountAppcreateApp で作成した approuter を受け取ります。受け取った app を使って、Vue.js のインスタンスを作成します。
そして、これを id 属性が __nuxt の要素に対してマウントすることで Vue.js が有効になります。
図3の③は、Vue.js のインスタンスをマウントするところまでを指します。

$mount(‘#__nuxt’)

最後に図3の④の部分について説明します。
この部分の処理は mountAppmount() が実行され、_app.$mount が呼び出されることで開始される処理になります。
data-server-rendered 属性が付与された要素に対してアプリケーションをマウントすることでハイドレーションモードでマウントすることになります。実際の DOM に対する操作をするわけではなく、Vue.js の内部的な DOM 構造を構築するためだけに計算されます。ハイドレーションは要素を辿って評価していくため、全体の要素の量に比例して処理時間がかかります。

今回のデモアプリでは、要素が少ないため処理時間は膨大ではありませんが、一般的なアプリケーションにおいては無視できないくらいの処理時間をコストとして支払うことになると思います。
この処理はマイクロタスクとして実行されるため、マイクロタスクキューが空になる、つまり全ての要素に対するハイドレーション処理が完了するまで、他の処理をブロックすることになります。

ハイドレーションにかかる処理時間は、DOM の絶対量に比例して線形で増えていくため、何かしらの対策が必要です。
ハイドレーションを遅延させることが可能な vue-lazy-hydration というライブラリがあり、これを使うとブロッキングタイムのスコアが改善されました。
しかし、このライブラリはメンテナンスがされていないため、パフォーマンス改善の検証としてのみ使っています。

最後に

この記事では、Nuxt.js のアプリケーションが共通して抱えるパフォーマンス上のボトルネックになる原因について説明しました。

次回の記事では、シリーズ記事を通して紹介した知識や改善支援ツールを使って、本番環境で実際に運用されている Nuxt.js のアプリケーションの改善を試みる予定なので、是非ご期待下さい。

長大な記事になってしまいましたが、ここまで読んでいただきありがとうございます。何か間違いや認識違いがあれば @tokuda109 までDMいただければ助かります。

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加