plugin-bundle-size について
Grafana アプリプラグインの起動バンドルを縮小する実践ガイドです。プラグイン開発者向けに、分割箇所の選定、ビルド結果の測定、オンデマンド読み込みの検証を説明します。
主な機能
- 01優先度に沿った遅延読み込みの案内
- 02ルートと拡張機能のコード分割
- 03バンドルサイズ CI レポートの設定
- 04ビルドサイズとチャンクの分析
- 05実行時の読み込み確認
活用シーン
- 01プラグインのブロッキングエントリーポイントの縮小
- 02大きなルートの個別チャンク化
- 03プルリクエストでのバンドル変化の追跡
- 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:実行中のプラグインをテストする
- Grafana インスタンスでプラグインを開きます
- すべてのルートに移動します。移動するたびに新しいチャンクがダウンロードされます
- DevTools → Network → JS:すべてが最初にまとめて読み込まれるのではなく、移動時に遅延チャンクが読み込まれることを確認します
- エラーがないかコンソールを確認します
- 他の Grafana アプリからの
exposeComponent拡張をテストします
一般的な問題のトラブルシューティングについては、references/troubleshooting.md を参照してください。
参考資料
- grafana-collector-app — アプリプラグインのリファレンス実装
- grafana/plugin-actions — Grafana 公式プラグイン CI アクション
- Web.dev — lazy と Suspense によるコード分割
- SurviveJS — webpack のコード分割
- webpack のマジックコメント