关于 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,且只包含按需加载的包装器——所有功能代码都应按需加载。
目标: 总计约 15–25 个 JS 分块。更少意味着拆分不足;多得多(50 个以上)则意味着过度设计。
风险等级
并非所有拆分机会的风险都相同。请按以下顺序实施:
| 等级 | 内容 | 风险 | 影响 |
|---|---|---|---|
| 安全 | module.tsx 懒加载包装器(优先级 1) | 极低——不改变行为 | 最高——module.js 缩小 90% 以上 |
| 安全 | 路由级 lazy()(优先级 2) | 低——每个路由都是独立的 | 高——每个路由对应一个分块 |
| 安全 | 扩展级 lazy()(优先级 3) | 低——扩展彼此隔离 | 中——每个扩展对应独立分块 |
| 中等 | 组件注册表/标签页面板(优先级 4) | 中——需确认 Suspense 的位置 | 中——进一步拆分大型页面 |
| 不要改动 | 供应商库(@grafana/scenes、@reduxjs/toolkit) | 不适用 | 不适用——webpack 会自动拆分这些库 |
| 不要改动 | 在许多文件中使用的共享实用组件(Markdown、Spinner) | 变更频繁,调用点众多 | 低——已位于共享供应商分块中 |
如有疑问,在优先级 2 后停止。仅拆分路由通常就能让 module.js 缩小 95% 以上。
步骤 1:添加包体积 CI 报告(推荐)
添加 grafana/plugin-actions/bundle-size action,以便在每个 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
子目录插件(例如,位于 monorepo 中的 plugin/):
该 action 的安装步骤会在仓库根目录运行,无法在子目录中找到 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 生成首个基线。
步骤 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
记录基线。拆分前的插件通常有一个 1–3 MB 的 module.js,且没有其他 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.config.ts,会使用 webpack-merge 合并基础配置,请检查合并配置是否存在冲突。
步骤 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 });
关键细节:
- 使用
import type导入 props,可防止 webpack 跟踪该导入并将其打包进 eager bundle - 如果 App 使用
AppRootProps<JsonData>,请使用new AppPlugin<JsonData>()——否则,setRootPage()的类型将不匹配 - 删除任何
App as unknown as ComponentClass<AppRootProps>类型转换——lazy wrapper 是有效的函数组件
预期影响: 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;
绕过 barrel 文件: 在 import() 中指定实际的组件文件,而不是重新导出多个内容的 index.ts barrel:
// 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 中保持 eager 加载,则对其渲染的重型组件进行 lazy 加载:
const HeavyInner = lazy(() => import('components/features/HeavyInner'));
export function MyExtension() {
return <Suspense fallback={<LoadingPlaceholder text="" />}><HeavyInner /></Suspense>;
}
优先级 4:组件注册表和标签页面板
对于包含 React 组件的对象数组(例如标签页面板),应对每个条目进行 lazy 加载。**关键:**确保组件渲染的位置存在 <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:如果拆分过细,则对相关代码块进行分组
如果构建生成了超过约 25 个 JS 文件,请使用 webpack 魔法注释:
const FleetList = lazy(() => import(/* webpackChunkName: "fleet" */ '../pages/FleetList'));
const FleetDetail = lazy(() => import(/* webpackChunkName: "fleet" */ '../pages/FleetDetail'));
每个逻辑功能区域使用一个 webpackChunkName。不要将无关页面分组。
步骤 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:确认 lazy 代码块在导航时加载,而不是一开始就全部加载
- 检查控制台中是否有错误
- 测试其他 Grafana 应用中的任何
exposeComponent扩展
有关常见问题的排查方法,请参阅 references/troubleshooting.md。
参考资料
- grafana-collector-app — 应用插件参考实现
- grafana/plugin-actions — Grafana 官方插件 CI 操作
- Web.dev — 使用 lazy 和 Suspense 进行代码拆分
- SurviveJS — webpack 代码拆分
- webpack 魔法注释