从样板到源码:在 Storybook 中打造自定义 Panel 插件(addon)的完整指南
【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook
在 Storybook 中,Panel(面板)是最常见的 UI 类插件形态——它是出现在 Storybook 预览区下方或侧边的可交互区域,承载了无障碍检查(a11y)、Actions 事件日志、组件属性检查等一系列官方与社区功能。本文以 Storybook 官方 addon 文档中用于在 Storybook UI 中新增一个 Panel 的样板代码为骨架,逐行讲解其运行机制,并结合当前仓库中的真实源码(a11y 插件实现、AddonPanel 组件与类型定义)解释addons.register、types.PANEL、AddonPanel等核心概念,帮助你写出可复用、可交互、可发布的自定义面板插件。
一、先理解概念:Panel 在 Storybook 插件体系中的位置
Storybook 将插件(addon)划分为两大类,这一分类在 docs/addons/addon-types.mdx 中有明确说明:
- 基于 UI 的插件(UI-based addons):负责定制界面、为常见操作提供快捷键或在 UI 中展示附加信息,可进一步细分为Panels(面板)、Toolbars(工具栏)与Tabs(标签页);
- Preset 插件:一组预配置的
babel、webpack、addons配置集合,用于将 Storybook 与其他技术栈集成。
Panel addon 正是 UI 类插件中最普遍的一种。文档明确指出:面板插件允许你在 Storybook 的 addon 面板中加入自己的 UI,这是插件生态中数量最多的类型。文档以官方@storybook/addon-a11y作为该模式的代表案例。当你需要展示与本条 story 相关的额外信息(如测试结果、可访问性报告、数据日志、辅助操作表单)时,最自然的落点就是面板。
二、面板样板代码逐行精解
官方文档为"向 Storybook UI 添加一个新的 Panel"提供了一段最小可运行的样板代码,完整内容位于 docs/_snippets/storybook-addon-panel-example.md,也是storybook内置类型面板的标准写法:
import React from 'react'; import { AddonPanel } from 'storybook/internal/components'; import { useGlobals, addons, types } from 'storybook/manager-api'; addons.register('my/panel', () => { addons.add('my-panel-addon/panel', { title: 'Example Storybook panel', //👇 Sets the type of UI element in Storybook type: types.PANEL, render: ({ active }) => ( <AddonPanel active={active}> <h2>I'm a panel addon in Storybook</h2> </AddonPanel> ), }); });虽然只有不到二十行,但它涵盖了面板插件的全部关键要素,下面逐段拆解:
1. 三个 import 各自负责什么
import React from 'react':Panel 的render是 JSX 组件,显然需要 React。import { AddonPanel } from 'storybook/internal/components':Storybook 暴露的内部 UI 组件库。AddonPanel是面板内容的标准外壳,它处理了面板的滚动容器、active激活态切换等公共行为。import { useGlobals, addons, types } from 'storybook/manager-api':这是 manager(Storybook 的界面管理端)的公共 API。addons对象负责注册与添加插件;types枚举声明 UI 元素的类型;useGlobals是读取/更新全局状态(globals)的 React Hook,用于实现插件开关、联动等交互逻辑。
值得注意:样例代码同时引入了
AddonPanel与useGlobals。在最小示例里useGlobals并未被真正使用,但从源码实现来看,绝大多数真实面板都靠它与 Storybook 的全局状态交互——官方 a11y 插件的面板与 Vision Simulator 工具栏正是通过useGlobals、useAddonState等 Hook 维持同一份运行状态。
2. addons.register:注册插件的入口
addons.register('my/panel', () => { // ... });addons.register的第一个参数是全局唯一标识符(id),用于在 Storybook 中标识整个插件;第二个参数是初始化回调,在 Storybook manager 启动时执行。这里的 id'my/panel'只是字符串约定,并不强制带/,但用命名空间式写法(scope/name)有助于避免与其他插件冲突。注册回调里应完成该插件所需 UI 元素(panel、tool、tab)的逐一添加。
3. addons.add + types.PANEL:声明一个"面板"类型条目
addons.add('my-panel-addon/panel', { title: 'Example Storybook panel', type: types.PANEL, render: ({ active }) => ( <AddonPanel active={active}> <h2>I'm a panel addon in Storybook</h2> </AddonPanel> ), });addons.add(id, config):为插件注册一个 UI 条目。这里的 id'my-panel-addon/panel'同样需要唯一,且习惯上以"插件 id 前缀"命名,方便后续定位。title:显示在面板页签上的标题文本。type: types.PANEL:声明该条目属于面板。types来自storybook/manager-api,其真实取值定义在 code/core/src/types/modules/addons.ts 的Addon_TypesEnum枚举中,完整类型包括:TAB = 'tab':画布上方的自定义标签页;PANEL = 'panel':侧边栏中的插件面板,本文所述类型;TOOL = 'tool':画布上方工具栏左侧的工具项;TOOLEXTRA = 'toolextra':画布上方工具栏右侧的工具项;PREVIEW = 'preview':包裹画布/iframe 的包装组件;experimental_PAGE、experimental_TEST_PROVIDER等实验性类型。
render: ({ active }) => ...:渲染函数,Storybook 会向它传入当前面板是否处于激活/选中状态(active)。当你切换面板页签时,active随之变化,使组件可以据此决定挂载或卸载内容。
4. AddonPanel:面板的官方"外壳"
<AddonPanel active={active}> <h2>I'm a panel addon in Storybook</h2> </AddonPanel>AddonPanel是面板内容的容器组件,实际实现位于 code/core/src/components/components/addon-panel/addon-panel.tsx。从源码可以确认其接口与行为:
export interface AddonPanelProps { active: boolean; children: ReactElement; /** Whether the panel has a vertical scrollbar, `true` by default. */ hasScrollbar?: boolean; /** Whether the panel has an horizontal scrollbar, `false` by default */ hasHorizontalScrollbar?: boolean; }实现要点包括:
- 通过
<Div hidden={!active}>控制显隐——源码注释特别说明使用 HTML 标准的hidden属性而非单纯的 CSS 隐藏,是为了保证可访问性(accessible)与可索引性,同时仍能视觉上隐藏内容; hasScrollbar默认true,hasHorizontalScrollbar默认false。当任一滚动条开启时,内容会被包裹进ScrollArea(code/core/src/components/components/ScrollArea)获得滚动能力;- 内部使用
useUpdate/usePrevious缓存子元素:仅当active更新时替换children引用,避免面板在未激活时因父组件重渲染而丢失内部状态。这也解释了为何需要始终把面板内容放在<AddonPanel>内——它负责维持内容在失活再激活后的连续性。
因此,面板里的render不应只返回裸的<h2>,而应返回被AddonPanel包裹的内容,这样才会获得一致的样式、滚动与激活态处理。
三、把它放进真实的插件工程:文件结构与注册入口
面板代码通常会位于插件的src/Panel.tsx(对应工具栏Tool.tsx、标签页Tab.tsx),然后在 manager 入口文件中完成注册。官方的 Writing addons 指南 明确说明了这条工程路径:UI 类插件的代码默认放在src/Tool.tsx、src/Panel.tsx或src/Tab.tsx之一;若用 Addon Kit 脚手架创建插件,发布时的package.json会通过bundler字段声明构建入口(参见 docs/addons/writing-addons.mdx):
"bundler": { "exportEntries": ["src/index.ts"], "managerEntries": ["src/manager.ts"], "previewEntries": ["src/preview.ts"] }面板在 manager 端运行,因此对应的managerEntries构建产物会被注入 Storybook 的 manager 环境。Storybook 的 manager 环境会以全局作用域形式提供部分包,插件无需(也不应)将它们打入产物或声明为依赖——这也是上节三个 import 直接从storybook/internal/components、storybook/manager-api导入的原因。
四、源码佐证:官方 a11y 插件是如何使用同一套 API 的
样板代码只是骨架,官方文档特意点名 @storybook/addon-a11y 作为 Panel 模式的真实案例。查看其 manager 入口 code/addons/a11y/src/manager.tsx,可以看到同样的骨架在真实插件中如何被扩展:
import { addons, types, useAddonState, useStorybookApi } from 'storybook/manager-api'; const Title = () => { const api = useStorybookApi(); const selectedPanel = api.getSelectedPanel(); // ...根据 useAddonState 统计违规数量并渲染 Badge 徽标 }; addons.register(ADDON_ID, (api) => { addons.add(PANEL_ID, { title: Title, // title 可以是一个 React 组件(此处动态显示违规数量徽标) type: types.PANEL, render: ({ active = true }) => ( <A11yContextProvider>{active ? <A11YPanel /> : null}</A11yContextProvider> ), paramKey: PARAM_KEY, // 将该面板与某个 story 参数绑定 }); });与样板对比,真实插件的增量主要在三点,你可以据此扩展自己的面板:
title不限于字符串,可以是组件:a11y 用Title函数组件 +useAddonState(面板级私有状态)实时读取违规数量,并把Badge徽标嵌在页签标题上,实现"标题上的动态角标"。paramKey与 story 参数联动:paramKey把面板与同名的 story/preview 参数关联起来,Storybook 借此判断该面板在什么情况下可展示。- 在 manager 注册回调中同时添加多个 UI 元素:a11y 在同一个
addons.register(ADDON_ID, ...)内既addons.add了一个TOOL条目(Vision Simulator),又addons.add了一个PANEL条目。这印证了"一个插件 = 一次 register + 多个 UI 元素 add"的组织方式。
另外,该插件的单元测试 code/addons/a11y/src/manager.test.tsx 展示了如何对面板注册结果做断言——测试通过find(({ type }) => type === api.types.PANEL)在注册列表中筛选出面板条目并校验其渲染行为,这可以作为你为自己面板编写测试的参照。
五、把面板做成可交互、可感知上下文
样板中的面板是静态内容,真实场景通常还需要读取当前 story 的信息、与全局工具联动。官方文档把下列 API 列入了 addon 开发者的基础工具箱(见 docs/addons/addons-api.mdx 等参考资料):
useGlobals():读取并更新全局状态(globals)。比如某个工具栏按钮切换的"开关",面板内可用它读取同一状态从而展示联动结果;这也解释了样板中为何引入该 Hook。useStorybookApi():拿到 Storybook 的 API 对象,可调用getSelectedPanel()判断当前选中了哪个面板(a11y 的Title就靠它决定徽标的 active 外观)。useAddonState:面板自己的持久化状态,切换 story、收起再展开面板都不会丢失。match属性:控制条目在什么视图模式下展示(story/docs 画布,或特定自定义 tab),详见 writing-addons.mdx。
一个典型的扩展思路是:把样板中的<AddonPanel>内部替换成读取 story 参数的表单、把用户操作结果写入globals,再配合一个 toolbar 按钮控制启停——这正是 a11y、themes 等大量官方插件的运作模式。
六、同源扩展:Tool、Tab 与预设类插件
了解 Panel 后,应知晓其余同类 UI 元素的样板差异(完整对照见 docs/addons/addon-types.mdx):
- Toolbar(工具栏):把代码块放在 storybook-addon-toolbar-example.md,条目
type使用types.TOOL,并为match、icon配置条件渲染与图标; - Tab(标签页):对应 storybook-addon-tab-example.md,用于创建画布上方的自定义标签页;
- Preset 插件:非 UI 类,属于
babel/webpack/addons配置的集合,官方文档以preset-create-react-app为例。
若想系统掌握插件开发,官方文档提供了一条完整的学习链:从 Writing addons(理解 addon 的解剖结构、本地调试与发布)出发,接着阅读 addon-types.mdx 与 writing-presets.mdx,最后以 addons-api.mdx 作为 API 速查手册。
小结
- 最小骨架:
addons.register(唯一id, () => addons.add(id, { type: types.PANEL, render: ({active}) => <AddonPanel active={active}>...)即构成一个可用面板。 - 必须用
AddonPanel包裹:它提供滚动容器、hidden显隐与基于active的状态保持(addon-panel.tsx)。 - 从样板到真实:通过
useAddonState、useStorybookApi、paramKey与动态title组件,可以复现官方 a11y 插件的面板模式(manager.tsx)。 - 入口位置:把代码放入插件的 manager 入口,由打包配置将 manager 产物注入 Storybook;面板运行在 manager 端,属于 UI 类插件的标准形态。
【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考