STORES でフロントエンドエンジニアをしていますwattanxです。
先日下記のようなツイートを見かけました。
プルリクにbundle sizeの増減をコメントしてくれるGitHub Actions導入したんだけどめちゃくちゃ良い pic.twitter.com/7JV3M3zlcW
— catnose (@catnose99) March 17, 2022
上記のツイートを見て、Nuxt.js でもバンドルサイズを PR に添付したいなーと思い、それが実現できるスクリプトを作成したので紹介します。
実際にPR上にコメントされるとこんな感じになります。
Next.js Bundle Analysis の仕組み
先ほどのツイートで紹介されていたのは下記の Next.js Bundle Analysis です。
Next.js でビルド後に生成されるbuild-manifest.json
の情報をもとにバンドルサイズの比較を行い、 PR に添付しています。
build-manifest.json
にはビルドで生成されたファイルが記載されており、そのファイルに対して gzip を行い gzip 後のファイルサイズを用いて比較しています。
mainブランチへのマージまたはPR それぞれのタイミングで下記のように Actions が実行されます。
mainブランチへのマージのタイミングで実行
main ブランチのバンドルサイズを計算
1
のバンドルサイズを GitHub Actions の Artifact に保存しておく。
PR のタイミングで実行
- PR を作成したときに作業ブランチのバンドルサイズを計算
- 前回のマージのタイミングで実行した Actions で保存した Artifact のバンドルサイズをダウンロード。
1
のバンドルサイズと2
のバンドルサイズを比較して差分を算出3
の結果をPR にコメント
Nuxt.js 版も作ってみよう
バンドルサイズの取得
バンドルサイズの計算をするために、ビルド後にどのファイルが生成されたかを知る必要があります。
Nuxt.js では、build
オプションに下記のような設定をすることでビルド後に生成されたファイルを知ることができます。
// nuxt.config.js module.exports = { ... build: { analyze: { generateStatsFile: true, analyzeMode: "disabled", openAnalyzer: false, // ブラウザで開かないようにする }, } }
上記の設定をした状態でビルドすると、.nuxt/stats/client.json
が生成されます。
この JSON ファイル内のnamedChunkGroups
のassets
がページごとのJSファイル(chunks)です。
// .nuxt/stats/client.json { ... "namedChunkGroups": { "app": { ..., "assets": [ "runtime.285b253.js", "commons/app.0578609.js", "vendors/app.d01f9b6.js", "app.065a95b.js" ], ... }, "pages/index": { ..., "assets": [ "vendors/hoge/4d31b976.3082787.js", "pages/index.d06eaa8.js" ], ... }, ... } }
ページごとに出力されたファイルがわかったので、それぞれのファイルをgzip化してファイルサイズを取得します。
const fs = require('fs'); const path = require('path'); const zlib = require('zlib'); const mkdirp = require('mkdirp'); // 複数のページで使われるchunksがあるので一回計算したファイルはキャッシュする const memoryCache = {}; const allPageSizes = Object.entries(statsFile.namedChunkGroups).map( ([key, value]) => { const size = value.assets .map((x) => { // `assets`に記載されているファイルは`.nuxt/dist/client`に生成されている const scriptPath = path.join('.nuxt', 'dist/client', x); if (Object.keys(memoryCache).includes(scriptPath)) { return memoryCache[scriptPath]; } const bytes = fs.readFileSync(scriptPath, 'utf8'); const gzipSize = zlib.gzipSync(bytes).byteLength; memoryCache[scriptPath] = gzipSize; return gzipSize; }) .reduce((s, b) => s + b, 0); return { path: key, size }; } ); const rawData = JSON.stringify(allPageSizes); // JSONファイルとして出力しておく mkdirp.sync(path.join(buildOutputDir, 'analyze/')); fs.writeFileSync( path.join(buildOutputDir, 'analyze/__bundle_analysis.json'), rawData );
バンドルサイズの比較
前提として、.nuxt/analyze/base/bundle/__bundle_analysis.json
が存在している必要があります。
(実際に Actions で実行する際には、main ブランチにて上記のロジックでバンドルサイズを計算・保存し、.nuxt/analyze/base/bundle
にダウンロードしています。)
const filesize = require('filesize'); const fs = require('fs'); const path = require('path'); const options = require(path.join(process.cwd(), 'package.json')).nuxtBundleAnalysis; const buildOutputDir = path.join(process.cwd(), '.nuxt'); const outdir = path.join(buildOutputDir, 'analyze'); // 最終的にPRにコメントするためのテキストを保存する const outfile = path.join(outdir, '__bundle_analysis_comment.txt'); const currentBundle = require(path.join( buildOutputDir, 'analyze/__bundle_analysis.json', )); const baseBundle = require(path.join( buildOutputDir, 'analyze/base/bundle/__bundle_analysis.json', )); const removedSizes = baseBundle .filter(({ path }) => !currentBundle.find((x) => x.path === path)) .map(({ path }) => `| \`${path}\` | removed |`); const sizes = currentBundle .map(({ path, size }) => { const basefile = baseBundle.find((x) => x.path === path); if (!basefile) { return createTableRow(path, size, 'added'); } const diffSize = size - basefile.size; if (diffSize === 0) { return ''; } const diffStr = filesize(diffSize); const increased = Math.sign(diffSize) > 0; const statusIndicator = increased ? '🔴' : '🟢'; return createTableRow(path, size, `${statusIndicator} ${diffStr}`); }) .filter((x) => x) .concat(removedSizes) .join('\n'); if (sizes === '') { // 変更がない場合はActions側でバンドルサイズに差がない旨のメッセージを生成 process.exit(); } const output = `# Bundle Size | Route | Size (gzipped) | | --- | --- | ${sizes}`; try { fs.mkdirSync(outdir); } catch (err) { // すでに存在している場合エラーが出るけどスルーする } fs.writeFileSync(outfile, output); function createTableRow(path, size, diffStr) { return `| \`${path}\` | ${filesize(size)} (${diffStr}) |`; }
PR にコメントする
Next.js Bundle Analysis と同じような方法を使って PR にコメントします。
mainブランチへのマージのタイミングで実行
- main ブランチのバンドルサイズを計算
1
のバンドルサイズを GitHub Actions の Artifact に保存しておく。
PR のタイミングで実行
- PR を作成したときに作業ブランチのバンドルサイズを計算
- 前回のマージのタイミングで実行した Actions で保存した Artifact のバンドルサイズをダウンロード。
(
.nuxt/analyze/base/bundle/__bundle_analysis.json
としてダウンロードされる) 1
のバンドルサイズと2
のバンドルサイズを比較して差分を算出3
の結果をPR にコメント
PR にコメントされると下記のようになります。
最後に
ここまでの実装を下記リポジトリに作成しました。
カスタマイズが不要でサクッと使いたい場合は、npx -p nuxt-bundle-analysis generate
を実行すると、.github/workflows/nuxt-bundle-analysis.yml
が生成されます。
カスタマイズしたい場合は、メインロジックであるhttps://github.com/wattanx/nuxt-bundle-analysis/tree/main/src
と Actions のhttps://github.com/wattanx/nuxt-bundle-analysis/blob/main/actions-template/nuxt-bundle-analysis.yml
を自分の Project に入れてカスタマイズすると、バンドルサイズを PR 上にコメントできます。
Nuxt.js を使っている方はぜひ使っていただけると嬉しいです。