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,且只包含按需加载的包装器——所有功能代码都应按需加载。

目标: 总计约 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 生成首个基线。

参考: 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

记录基线。拆分前的插件通常有一个 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:测试运行中的插件

  1. 在 Grafana 实例中打开插件
  2. 导航到每个路由——每个路由都会触发一次新的代码块下载
  3. DevTools → Network → JS:确认 lazy 代码块在导航时加载,而不是一开始就全部加载
  4. 检查控制台中是否有错误
  5. 测试其他 Grafana 应用中的任何 exposeComponent 扩展

有关常见问题的排查方法,请参阅 references/troubleshooting.md。


参考资料

评论

支持 Markdown