mcpField
P
plugin-bundle-size

plugin-bundle-size

github.com/grafana開発2954.2k 回インストール1 日前に更新
リポジトリ

plugin-bundle-size について

Grafana アプリプラグインの起動バンドルを縮小する実践ガイドです。プラグイン開発者向けに、分割箇所の選定、ビルド結果の測定、オンデマンド読み込みの検証を説明します。

主な機能

  1. 01優先度に沿った遅延読み込みの案内
  2. 02ルートと拡張機能のコード分割
  3. 03バンドルサイズ CI レポートの設定
  4. 04ビルドサイズとチャンクの分析
  5. 05実行時の読み込み確認

活用シーン

  1. 01プラグインのブロッキングエントリーポイントの縮小
  2. 02大きなルートの個別チャンク化
  3. 03プルリクエストでのバンドル変化の追跡
  4. 04ルート遷移時のチャンク読み込み確認

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/grafana/skills --skill plugin-bundle-size

インストール後、Claude Code で/plugin-bundle-sizeを入力して読み込まれたか確認します。

同梱ファイルreferences/datasource-plugins.mdreferences/troubleshooting.md

詳細

Grafana プラグインのバンドルサイズ最適化

module.js は、すべての Grafana アプリプラグインでレンダリングをブロックするエントリーポイントです。サイズが小さいほど、プラグインが Grafana 全体の起動時間に与える影響も小さくなります。適切に分割されたプラグインでは、module.js は約 200 KB 未満で、遅延読み込みされるラッパーのみを含みます。すべての機能コードは、必要に応じて読み込まれます。

目標: JS チャンクの合計数を約 15~25 個にします。これより少ない場合は分割が不十分で、かなり多い場合(50 個以上)は過剰な設計を意味します。

リスクレベル

分割の機会はすべて同じリスクを伴うわけではありません。次の順に適用してください。

レベル内容リスク効果
安全module.tsx の遅延読み込みラッパー(優先度 1)非常に低い — 動作の変更なし最高 — module.js を 90% 以上削減
安全ルートレベルの lazy()(優先度 2)低い — 各ルートが独立している高い — ルートごとに 1 チャンク
安全拡張機能の lazy()(優先度 3)低い — 拡張機能は分離されている中程度 — 拡張機能ごとに独立したチャンク
中程度コンポーネントレジストリ/タブパネル(優先度 4)中程度 — Suspense の配置を確認中程度 — 重いページをさらに分割
変更しないベンダーライブラリ(@grafana/scenes、@reduxjs/toolkit)該当なし該当なし — webpack が自動的に分割
変更しない多数のファイルで使われる共有ユーティリティコンポーネント(Markdown、Spinner)変更範囲が広く、多数の呼び出し元がある低い — すでに共有ベンダーチャンクに含まれている

迷った場合は、優先度 2 で止めてください。通常、ルートだけで module.js を 95% 以上削減できます。


ステップ 1:バンドルサイズの CI レポートを追加する(推奨)

grafana/plugin-actions/bundle-size アクションを追加すると、すべての PR でバンドルサイズの比較コメントを自動的に投稿できます。エントリーポイントのサイズ変化、ファイル数の差分、バンドル全体への影響を示す表が投稿されます。

リポジトリルートにあるプラグイン(リポジトリのルートにあるプラグイン):

# .github/workflows/bundle-size.yml
name: Bundle Size
on:
  pull_request:
  push:
    branches: [main]
  workflow_dispatch:

jobs:
  bundle-size:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      id-token: write
      pull-requests: write
      actions: read
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version-file: .nvmrc
      - name: Install and build
        run: yarn install
      - name: Bundle Size
        uses: grafana/plugin-actions/bundle-size@a66a1c96cdbb176f9cccf10cf23593e250db7cce # bundle-size/v1.1.0

サブディレクトリにあるプラグイン(モノレポ内の plugin/ など):

アクションのインストール手順はリポジトリのルートで実行されるため、サブディレクトリにある yarn.lock を見つけられません。依存関係を自分でインストールし、ルートにシンボリックリンクを作成して対処します。

jobs:
  bundle-size:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      id-token: write
      pull-requests: write
      actions: read
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version-file: ./plugin/.nvmrc
      - name: Install dependencies
        working-directory: ./plugin
        run: yarn install
      - name: Symlink plugin to root for bundle-size action
        run: |
          ln -s plugin/yarn.lock yarn.lock
          ln -s plugin/package.json package.json
          ln -s plugin/.yarnrc.yml .yarnrc.yml
          ln -s plugin/node_modules node_modules
      - name: Bundle Size
        uses: grafana/plugin-actions/bundle-size@a66a1c96cdbb176f9cccf10cf23593e250db7cce # bundle-size/v1.1.0
        with:
          working-directory: ./plugin

仕組み: main へのプッシュ時にビルドしてベースラインのアーティファクトをアップロードします。PR ではベースラインと比較し、差分のコメントを投稿します。最初のベースラインを生成するには workflow_dispatch を使用します。

参考: grafana-k8s-plugin のワークフロー


ステップ 2:プラグインのコンテキストを確認する

# Confirm this is an app plugin (type: "app" — datasource/panel plugins have different needs)
jq -r '"\(.id) — \(.type)"' src/plugin.json

# Locate the entry point
ls src/module.ts src/module.tsx 2>/dev/null

# Measure the current PRODUCTION bundle size BEFORE making any changes
# Dev builds are unminified and much larger — always measure production
yarn build 2>/dev/null || npm run build
echo "=== module.js ===" && ls -lah dist/module.js
echo "=== all JS chunks ===" && ls -lah dist/*.js | sort -k5 -rh | head -20
echo "=== chunk count ===" && ls dist/*.js | wc -l

ベースラインを記録します。分割前のプラグインでは、module.js のサイズが 1~3 MB で、ほかに JS チャンクがないことが一般的です。


ステップ 3:create-plugin を確認して更新する

@grafana/create-plugin ツールは、.config/webpack/、.config/jest/、その他のビルド用の足場を管理します。更新すると、SWC によるコンパイルの高速化や、より適切なチャンク出力が可能になることがあります。

cat .config/.cprc.json 2>/dev/null || grep '"@grafana/create-plugin"' package.json
npm view @grafana/create-plugin version
npx @grafana/create-plugin@latest update

更新後は差分(特に .config/webpack/webpack.config.ts)を確認し、テストビルドを実行します。プラグインのルートレベルにベース設定を webpack-merge する webpack.config.ts がある場合は、設定のマージで競合が起きていないか確認してください。


ステップ 4:コードベースを分析する — 分割する箇所を見つける

以下のすべてを読んでから実装を開始してください。

# Entry point — look for direct (non-lazy) imports of App, ConfigPage, exposeComponent targets
cat src/module.ts 2>/dev/null || cat src/module.tsx

# Root App component — look for direct page/route imports that should be lazy
cat src/App.tsx src/components/App.tsx src/feature/app/components/App.tsx 2>/dev/null | head -80

# Extension registrations — each should become an independent chunk
grep -r "exposeComponent\|addComponent\|addLink" src/ --include="*.ts" --include="*.tsx" -n

# Exported side-effect singletons (Faro, analytics) — must be extracted before splitting
grep -n "^export const\|^export let" src/module.ts src/module.tsx 2>/dev/null
grep -rn "from '.*module'" src/ --include="*.ts" --include="*.tsx" | grep -v node_modules

# Heavy synchronous imports
grep -rn "from 'monaco-editor\|@codemirror\|d3\b\|recharts\|chart\.js" \
  src/ --include="*.ts" --include="*.tsx" | grep -v node_modules

重要なルール: ファイルが module.ts から直接(または間接的に)インポートされると、そのファイルは module.js に含まれます。遅延読み込みの境界からたどれるものはすべて、独立したチャンクになります。


ステップ 5:優先順位に沿って分割を実装する

名前付きエクスポートとデフォルトエクスポート: React.lazy() には default エクスポートが必要です。Grafana プラグインのコンポーネントの多くは名前付きエクスポートを使用しています。.then() を使って再マッピングしてください。

// 名前付きエクスポート
const LazyMyComp = lazy(() => import('./MyComponent').then(m => ({ default: m.MyComponent })));
// デフォルトエクスポート
const LazyMyComp = lazy(() => import('./MyComponent'));
優先度 1:module.tsx(最も効果が大きいため、必ず最初に実施してください)

エントリポイントが module.ts の場合は、名前を変更します:git mv src/module.ts src/module.tsx

module.tsx では、lazy() 経由以外で機能コードから何もインポートしないようにします。

import React, { lazy, Suspense } from 'react';
import { AppPlugin, AppRootProps } from '@grafana/data';
import { LoadingPlaceholder } from '@grafana/ui';

import type { MyExtensionProps } from './extensions/MyExtension';  // import type — erased at compile time
import type { JsonData } from './features/app/state/slice';

// Lazy Faro init — keeps @grafana/faro-react out of module.js
let faroInitialized = false;
async function initFaro() {
  if (faroInitialized) { return; }
  faroInitialized = true;
  const { initializeFaro } = await import('faro');
  initializeFaro();
}

const LazyApp = lazy(async () => {
  await initFaro();
  return import('./features/app/App').then(m => ({ default: m.App }));
});

function App(props: AppRootProps<JsonData>) {
  return <Suspense fallback={<LoadingPlaceholder text="" />}><LazyApp {...props} /></Suspense>;
}

const LazyMyExtension = lazy(() =>
  import('./extensions/MyExtension').then(m => ({ default: m.MyExtension }))
);
function MyExtension(props: MyExtensionProps) {
  return <Suspense fallback={<LoadingPlaceholder text="" />}><LazyMyExtension {...props} /></Suspense>;
}

export const plugin = new AppPlugin<JsonData>().setRootPage(App);
plugin.exposeComponent({ id: 'my-plugin/my-extension/v1', title: 'My Extension', component: MyExtension });

重要な詳細:

  • props に import type を使用すると、webpack がインポートをたどって即時バンドルに含めるのを防げます
  • App が AppRootProps<JsonData> を使用する場合は、new AppPlugin<JsonData>() を使用します。ジェネリック型を指定しないと、setRootPage() の型が一致しません
  • App as unknown as ComponentClass<AppRootProps> キャストは削除します。lazy ラッパーは有効な関数コンポーネントです

期待される効果: module.js のサイズが MB 単位から約 50~200 KB に減少します。

シングルトン(例:Faro): module.ts に export const faro = initializeFaro() がある場合は、トップレベルのインポートとして残さないでください。src/faro.ts に切り出し、'*/module' から '*/faro' へすべての内部インポートを更新してから、前述の動的な initFaro() パターンを使用します。


優先度 2:App.tsx でのルートベースの分割
import React, { lazy, Suspense } from 'react';
import { Route, Routes } from 'react-router-dom';
import { LoadingPlaceholder } from '@grafana/ui';

const HomePage     = lazy(() => import('../pages/Home'));
const SettingsPage = lazy(() => import('../pages/Settings'));
const DetailPage   = lazy(() => import('../pages/Detail'));

function App(props: AppRootProps) {
  return (
    <Suspense fallback={<LoadingPlaceholder text="" />}>
      <Routes>
        <Route path="home"       element={<HomePage />} />
        <Route path="settings"   element={<SettingsPage />} />
        <Route path="detail/:id" element={<DetailPage />} />
        <Route path=""           element={<HomePage />} />
      </Routes>
    </Suspense>
  );
}
export default App;

バレルファイルを回避する: 複数のものを再エクスポートする index.ts のバレルファイルではなく、import() で実際のコンポーネントファイルを指定します。

// Risky — barrel may pull in other heavy modules
const Catalog = lazy(() => import('features/catalog'));
// Better — only pulls in Catalog's tree
const Catalog = lazy(() => import('features/catalog/Catalog').then(m => ({ default: m.Catalog })));
優先度 3:拡張コンポーネント

各拡張では、コンポーネントを export default します。すぐに読み込まれる拡張には fallback={null} を使用します。

// src/extensions/MyExtension.tsx
export default function MyExtension(props: MyExtensionProps) {
  return <AppProviders><MyExtensionContent {...props} /></AppProviders>;
}

部分的な分割: 拡張のラッパーを module.tsx で即時読み込みする必要がある場合は、ラッパーがレンダリングする重いコンポーネントを遅延読み込みします。

const HeavyInner = lazy(() => import('components/features/HeavyInner'));
export function MyExtension() {
  return <Suspense fallback={<LoadingPlaceholder text="" />}><HeavyInner /></Suspense>;
}
優先度 4:コンポーネントレジストリとタブパネル

React コンポーネントを含むオブジェクトの配列(例:タブパネル)では、各エントリを遅延読み込みします。重要: コンポーネントをレンダリングする場所に <Suspense> 境界があることを確認してください。

const ConfigDetails = lazy(() => import('./ConfigDetails/ConfigDetails').then(m => ({ default: m.ConfigDetails })));
const Overview      = lazy(() => import('./Overview/Overview').then(m => ({ default: m.Overview })));

const tabs = [
  { id: 'overview', component: Overview },
  { id: 'config',   component: ConfigDetails },
];

// In the parent that renders the active tab:
<Suspense fallback={<LoadingPlaceholder text="" />}>
  {ActiveTab && <ActiveTab />}
</Suspense>

データソースプラグイン(setConfigEditor、setQueryEditor、VariableSupport、AnnotationSupport)については、references/datasource-plugins.md を参照してください。


ステップ 6:分割しすぎている場合は、関連するチャンクをまとめる

ビルドで JS ファイルが約 25 個を超えて生成される場合は、webpack のマジックコメントを使用します。

const FleetList   = lazy(() => import(/* webpackChunkName: "fleet" */ '../pages/FleetList'));
const FleetDetail = lazy(() => import(/* webpackChunkName: "fleet" */ '../pages/FleetDetail'));

論理的に関連する機能領域ごとに webpackChunkName を 1 つ指定します。無関係なページをまとめないでください。


ステップ 7:測定と検証

yarn build 2>/dev/null || npm run build
echo "=== module.js ===" && ls -lah dist/module.js
echo "=== all JS chunks (largest first) ===" && ls -lah dist/*.js | sort -k5 -rh | head -30
echo "=== chunk count ===" && ls dist/*.js | wc -l
指標目標
module.js のサイズ< 200 KB
JS チャンクの合計数15~25
最大の単一チャンク< 1 MB
# Analyse bundle composition if a chunk is unexpectedly large
npx webpack-bundle-analyzer dist/stats.json 2>/dev/null

ステップ 8:実行中のプラグインをテストする

  1. Grafana インスタンスでプラグインを開きます
  2. すべてのルートに移動します。移動するたびに新しいチャンクがダウンロードされます
  3. DevTools → Network → JS:すべてが最初にまとめて読み込まれるのではなく、移動時に遅延チャンクが読み込まれることを確認します
  4. エラーがないかコンソールを確認します
  5. 他の Grafana アプリからの exposeComponent 拡張をテストします

一般的な問題のトラブルシューティングについては、references/troubleshooting.md を参照してください。


参考資料

コメント