Storybook Addon 开发入门:manager.ts 初始化与工具栏 TOOL 注册机制详解
【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook
本文以 Storybook 官方文档中 编写 Addon 指南 的src/manager.ts初始状态代码片段为骨架,讲解 UI 型 Addon 是如何在 Storybook 的 manager(管理端界面)中注册为一个工具栏工具的。读完本文,你将掌握manager.ts入口文件的标准写法、addons.register与addons.add两个核心 API 的职责分工、match条件渲染的精确控制方法,以及 Storybook 内部是如何消费这些注册信息的。
先看这段代码的出处与定位
在 Storybook 官方文档《Write an addon》教程中(参见 docs/addons/writing-addons.mdx),作者以最流行的 Outline(描边)型工具栏 Addon 为原型,逐步讲解如何搭建一个完整的 UI Addon。教程先创建了src/Tool.tsx作为 Addon 的 UI 组件入口(包含开关逻辑与快捷键注册),紧接着在Register the addon一节中,需要把 Addon 的名字和 UI 组件正式“告诉”Storybook——这一步正是通过本文的主角src/manager.ts完成的。教程原文通过<CodeSnippets path="storybook-addon-manager-initial-state.md" />将代码片段注入,即本仓库中的 storybook-addon-manager-initial-state.md。
要理解这段代码,必须先建立两个关键概念:
- manager 与 preview 是两个独立运行时:manager 指 Storybook 的整个 UI 壳(工具栏、侧边栏、面板),运行在主页面中;preview 指渲染 stories 的 iframe。UI 型 Addon 的代码跑在manager 侧,因此它的注册入口被命名为
manager.ts。 - UI Addon 可以创建三种界面元素:panel(面板)、tab(标签页)、tool(工具栏按钮)。本教程构建的是工具栏 Addon,所以示例只注册了一个
types.TOOL元素。
完整的初始 manager.ts 代码
按官方教程,当你删除了 Addon Kit 模板中与 Panel、Tab 相关的文件后,src/manager.ts应被精简为如下初始状态(即关联代码片段全文,逐字保留):
import { addons, types } from 'storybook/manager-api'; import { ADDON_ID, TOOL_ID } from './constants'; import { Tool } from './Tool'; // Register the addon addons.register(ADDON_ID, () => { // Register the tool addons.add(TOOL_ID, { type: types.TOOL, title: 'My addon', match: ({ tabId, viewMode }) => !tabId && viewMode === 'story', render: Tool, }); });这段代码只有十几行,却完整覆盖了 UI Addon 注册的全部四个要素:导入 API → 注册 Addon → 添加 UI 元素 → 描述元素的类型/标题/显隐条件/渲染内容。下面逐段拆解。
从storybook/manager-api导入注册 API
import { addons, types } from 'storybook/manager-api';storybook/manager-api是 Storybook 提供给 Addon 作者操作 manager 状态的公共包,它导出的addons是一个全局单例的 AddonStore 实例。在仓库源码 code/core/src/manager-api/lib/addons.ts 中可以确认:
export const addons = getAddonsStore();而getAddonsStore通过挂载在globalThis上的__STORYBOOK_ADDONS_MANAGER键强制只创建一份单例(见同文件第 L154-L162 行),保证无论多少个 Addon bundle 被加载,它们共享同一个注册表与同一条消息 Channel:
const KEY = '__STORYBOOK_ADDONS_MANAGER'; function getAddonsStore(): AddonStore { if (!globalThis[KEY]) { globalThis[KEY] = new AddonStore(); } return globalThis[KEY]; }同一条导入语句中的types则是元素类型枚举的别名。源码中(code/core/src/manager-api/lib/addons.ts):
export type { Addon_Type as Addon }; export { Addon_TypesEnum as types };Addon_TypesEnum的完整定义位于 code/core/src/types/modules/addons.ts,它枚举了 manager 侧可注册的全部元素类型:
| 枚举值 | 内部字符串 | 作用 |
|---|---|---|
types.TAB | 'tab' | 在画布上方的工具栏区域添加自定义标签页(API 标记为 unstable) |
types.PANEL | 'panel' | 在下方 addons 侧面板添加面板 |
types.TOOL | 'tool' | 在画布上方工具栏左侧添加按钮 |
types.TOOLEXTRA | 'toolextra' | 在画布上方工具栏右侧添加按钮 |
types.PREVIEW | 'preview' | 添加包裹 canvas/iframe 的 wrapper 组件(unstable) |
types.TOOL对应字符串'tool',在源码注释中明确写着"在画布上方的工具栏左侧添加条目"——这正是 Outline 按钮出现的位置。类型系统上,type字段要求必须是Addon_TypesEnum的成员(Addon_BaseType 中的定义),因此误写成自定义字符串会导致类型报错。
随后导入的是本地常量与 UI 组件:
import { ADDON_ID, TOOL_ID } from './constants'; import { Tool } from './Tool';./constants:集中存放 ID 字符串的文件。ADDON_ID是Addon 整体的唯一标识,TOOL_ID是这个 Addon 内具体 UI 元素的唯一标识。Addon Kit 模板的约定如下:
export const ADDON_ID = 'my-storybook-addon'; export const TOOL_ID = `${ADDON_ID}/tool`;官方类型注释(Addon_BaseType)特别提醒:ID 必须全局唯一,建议用组织名或 npm 包名做前缀,但不要以storybook开头——该前缀为 Storybook 核心功能与官方 Addon 保留。
./Tool:前面在Tool.tsx中编写的 UI 组件,即工具栏上真正渲染出来的 React 组件。
addons.register:登记 Addon 的加载回调
// Register the addon addons.register(ADDON_ID, () => { // ... });register(id, callback)接收两个参数:Addon 的全局唯一 ID,以及一个延迟到 Storybook 启动时才执行的回调函数。理解这一点很重要——register本身并不会立刻向 UI 添加任何东西,它只是把这个回调登记为 Addon 的 "loader"。
对照源码实现(code/core/src/manager-api/lib/addons.ts)可以看到它内部维护一张loaders注册表:
register = (id: string, callback: (api: API) => void): void => { if (this.loaders[id]) { logger.warn(`${id} was loaded twice, this could have bad side-effects`); } this.loaders[id] = callback; }; loadAddons = (api: any) => { Object.values(this.loaders).forEach((value: any) => value(api)); };值得注意的细节:同一个id重复调用register不会覆盖而是触发logger.warn警告("was loaded twice, this could have bad side-effects"),提醒开发者可能存在重复加载。全部 Addon 的 loader 会由loadAddons在 manager 初始化时统一遍历执行,回调收到的api参数即完整的 Storybook manager API(可通过useStorybookApi钩子同源获取)。因此register回调内部可以安全地调用addons.add,这正是示例代码的组织方式。
addons.add:向 Store 注入具体的 UI 元素
addons.add(TOOL_ID, { type: types.TOOL, title: 'My addon', match: ({ tabId, viewMode }) => !tabId && viewMode === 'story', render: Tool, });add(id, addon)才是真正把元素加入注册表的动作。看 lib/addons.ts 的实现:
add(id: string, addon): void { const { type } = addon; const collection = this.getElements(type); collection[id] = { ...addon, id }; }它先从配置对象中取出type,按类型找到(必要时创建)对应的集合elements[type],再把整个配置对象连同id一起存入该集合。也就是说,manager 内部是按类型(tool / panel / tab …)分桶存储所有 Addon 元素的,getElements(types.TOOL)即可取到该类型下的全部注册项。一个 Addon 完全可以调用多次add,注册多个不同type的元素,只需使用彼此独立的元素 ID。
add接受的元素对象对应Addon_BaseType类型(code/core/src/types/modules/addons.ts),本示例用到的四个字段含义如下:
type:必填,元素类型,值为types枚举成员。title:元素的标题,类型注释指出它可以是普通字符串,也可以是 React 函数组件或元素(类型定义 L330-L333)。本例直接给了一个字符串'My addon'。match:可选函数,签名是(matchOptions: RouterData & { tabId?: string }) => boolean(类型定义 L371)。它的返回值决定当前 UI 状态是否“命中”该元素,进而控制其可见性,也会作为active值传给render组件(见下文)。render:必填,真正的渲染内容,签名要求返回一个 JSX 元素,因此要使用 React Hooks 时可以在函数体内返回封装好的组件(类型定义 L372-L381)。此处直接把Tool组件传入。
类型的官方注释还说明:route/match这套机制的设计目标是让 Addon 即便未处于屏幕上,也能保持自己的状态并持续监听事件(L362-L368),渲染与否只影响 UI 展示,不影响逻辑运行。
match:精确控制 Addon 在哪些场景显示
示例中的match是许多初学者最容易疑惑的一行:
match: ({ tabId, viewMode }) => !tabId && viewMode === 'story',官方《Write an addon》指南给出了完整的写法对照(见 docs/addons/writing-addons.mdx),此处整理为速查表:
match条件 | 行为 |
|---|---|
({ tabId }) => tabId === 'my-addon/tab' | 仅当查看 ID 为my-addon/tab的自定义 tab 时显示 |
({ viewMode }) => viewMode === 'story' | 当在画布中查看某个 story 时显示 |
({ viewMode }) => viewMode === 'docs' | 当查看某个组件的文档页时显示 |
({ tabId, viewMode }) => !tabId && viewMode === 'story' | 在画布查看 story且不在任何自定义 tab 中(即tabId === undefined)时显示 |
示例取最后一种写法,效果是:默认的 story 画布视图下显示该工具栏按钮,而进入自定义 tab 或 docs 模式时隐藏。回调收到的入参结构是RouterData & { tabId?: string }——viewMode可取'story'|'docs'等,tabId仅在处于自定义 tab 时存在。
若省略match,元素在任何视图下都会渲染。Toolbar 的 Addon 通常还会配合api.setAddonShortcut注册快捷键(这一部分在Tool.tsx中完成,本初始文件不涉及)。
Storybook 如何消费这些注册信息(源码链路)
从注册到渲染,整条链路的消费方都在仓库核心代码中可查:
AddonStore按类型分桶存储(code/core/src/manager-api/lib/addons.ts):getElements(type)惰性创建elements[type]空对象并返回,供add写入、供渲染器读取。- manager 各 UI 模块通过 API 获取元素:以 code/core/src/manager-api/modules/addons.ts 的
init模块为例,它把provider.getElements(type)包装为公开的getElements,并按需维护selectedPanel等订阅状态。工具栏、面板等 UI 在启动后调用对应 type 的getElements遍历渲染。其中ensurePanel(同文件 L81-L96)还负责在已注册面板中兜底选中一个当前可用面板——这正是addons.add后新面板能立刻出现在 UI 上的直接原因。 register与add分离设计:register的 loader 被延迟执行、add的元素被立即入桶,使 Storybook 能统一控制 Addon 的加载时机,并对重复 ID、重复加载等边界情况给出警告。
打包配置:manager.ts 如何进入 Storybook 构建
manager 侧的 Addon 代码并不在用户浏览器的 Node 环境执行,而是被打包进 Storybook 的manager bundle中运行。官方文档强调(docs/addons/writing-addons.mdx):Addon 生态普遍基于tsup + esbuild构建,且manager与preview(以及 Node 环境运行的 preset)面向不同运行时,因此需要分别输出产物。
要让本示例中的src/manager.ts生效,必须在 Addon 的package.json中声明 manager 入口。官方《Packaging and publishing》一节给出了标准配置(docs/addons/writing-addons.mdx),核心两处:
{ "exports": { "./manager": "./dist/manager.mjs" }, "bundler": { "exportEntries": ["src/index.ts"], "managerEntries": ["src/manager.ts"], "previewEntries": ["src/preview.ts"] } }exports["./manager"]:让 Storybook 在解析 Addon 时能找到 manager 侧产物;bundler.managerEntries:告诉打包工具src/manager.ts是要打进 manager bundle 的入口文件。
如果缺少 manager 出口或忘记把文件列入managerEntries,最典型的症状就是代码执行不报错,但工具栏上完全看不到你的 Addon——因为这段注册代码根本没有被打进 manager bundle。
在真实仓库中验证:官方 Addon 同样遵循此模式
这套"一个文件、一次 register、若干 add"的组织方式并非示例专属,Storybook 仓库自带的大量官方 Addon 的 manager 入口都采用相同写法,可以作为实战参照:
- code/addons/a11y/src/manager.tsx:无障碍检查 Addon 的注册入口;
- code/addons/docs/src/manager.tsx:Docs Addon 的注册入口;
- code/addons/links/src/manager.ts:链接 Addon 的注册入口;
- code/addons/onboarding/src/manager.tsx:引导 Addon 的注册入口;
- code/addons/pseudo-states/src/manager.ts:伪状态 Addon 的注册入口。
以 a11y Addon 为例,其 manager 入口同样是先addons.register(ADDON_ID, (api) => {...}),再在回调内使用addons.add注册面板/工具;不同之处只在于它在回调中拿api做了更多初始化(如配置持久化、状态同步),印证了register回调可收到完整API的设计。
验证与常见问题排查
在 Addon Kit 项目中,运行以下命令可在开发模式(watch)下启动一个用于调试的 Storybook:
npm run start若 Addon 注册正确,工具栏上会出现标题为 "My addon" 的按钮(见文首截图),点击它即可触发Tool.tsx中预设的开关逻辑。
结合源码,实际开发中常见的问题可对照排查:
- 按钮完全不显示:检查
package.json是否声明了exports["./manager"]且把src/manager.ts列入bundler.managerEntries;再检查addons.register(ADDON_ID, ...)是否确实执行(例如被条件编译或死代码移除)。 - 控制台出现
was loaded twice警告:同一ADDON_ID被register了两次(例如重复引入 Addon 或 manager 入口被加载两遍),见 register 实现。 - 在 docs 模式或自定义 tab 下仍显示/不显示:优先核对
match条件。viewMode === 'story'与viewMode === 'docs'互斥,tabId是否存在代表是否处于自定义 tab。 - 类型报错:
type字段必须使用types枚举值而非裸字符串,元素对象需满足Addon_BaseType(类型定义)。
小结
src/manager.ts虽短,却是 UI 型 Addon 的"注册中枢":addons.register登记 Addon 级别的加载回调(支持重复 ID 检测、延迟执行),addons.add把type / title / match / render描述的元素按类型写入全局单例注册表,match提供视图级显隐控制,而这一切要生效的前提是文件被正确声明为 Addon 的 manager 入口并打进 manager bundle。理解了这条链路,你就能在此基础上自由扩展——注册面板、标签页甚至同时注册多个工具,模式与本文完全一致。
【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考