STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

バンドルサイズの比較をPR上にコメントする

STORES でフロントエンドエンジニアをしていますwattanxです。

先日下記のようなツイートを見かけました。

上記のツイートを見て、Nuxt.js でもバンドルサイズを PR に添付したいなーと思い、それが実現できるスクリプトを作成したので紹介します。

github.com

実際にPR上にコメントされるとこんな感じになります。 PRコメント

Next.js Bundle Analysis の仕組み

先ほどのツイートで紹介されていたのは下記の Next.js Bundle Analysis です。

github.com

Next.js でビルド後に生成されるbuild-manifest.jsonの情報をもとにバンドルサイズの比較を行い、 PR に添付しています。

build-manifest.jsonにはビルドで生成されたファイルが記載されており、そのファイルに対して gzip を行い gzip 後のファイルサイズを用いて比較しています。

mainブランチへのマージまたはPR それぞれのタイミングで下記のように Actions が実行されます。

  • mainブランチへのマージのタイミングで実行

    1. main ブランチのバンドルサイズを計算

    2. 1のバンドルサイズを GitHub Actions の Artifact に保存しておく。

  • PR のタイミングで実行

    1. PR を作成したときに作業ブランチのバンドルサイズを計算
    2. 前回のマージのタイミングで実行した Actions で保存した Artifact のバンドルサイズをダウンロード。
    3. 1のバンドルサイズと2のバンドルサイズを比較して差分を算出
    4. 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 ファイル内のnamedChunkGroupsassetsがページごとの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ブランチへのマージのタイミングで実行

    1. main ブランチのバンドルサイズを計算
    2. 1のバンドルサイズを GitHub Actions の Artifact に保存しておく。
  • PR のタイミングで実行

    1. PR を作成したときに作業ブランチのバンドルサイズを計算
    2. 前回のマージのタイミングで実行した Actions で保存した Artifact のバンドルサイズをダウンロード。 (.nuxt/analyze/base/bundle/__bundle_analysis.jsonとしてダウンロードされる)
    3. 1のバンドルサイズと2のバンドルサイズを比較して差分を算出
    4. 3の結果をPR にコメント

PR にコメントされると下記のようになります。

PRコメント

最後に

ここまでの実装を下記リポジトリに作成しました。

github.com

カスタマイズが不要でサクッと使いたい場合は、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 を使っている方はぜひ使っていただけると嬉しいです。