Storybook 插件全局状态联动实战:useGlobals 与 updateGlobals 深入解析
useGlobals 是 Storybook 面向插件(Addon)开发者的核心 Hook,它让运行在管理器(Manager)界面中的插件 UI 能够读取并更新 Storybook 的全局状态(Globals),从而驱动工具栏、面板、装饰器等跨 story 共享的交互状态。本文以 storybook-addons-api-useglobal.md 示例为骨架,结合 manager-api 与 preview-api 的源码实现,讲解如何用该 Hook 读写 Globals、理解其事件闭环原理,并规避高频重渲染问题。读完你将掌握一个可直接落地的插件面板开关方案,并能读懂 Storybook 管理端与预览端同步全局状态的底层调用链。
定位:为何插件 UI 需要读写 Globals
在开始写代码之前,先厘清这个 Hook 在 Addon API 生态中的位置。官方 API 参考文档 addons-api.mdx 中明确说明:
useGlobals —— Extremely useful hook for addons that rely on Storybook Globals. It allows you to obtain and update
globalvalues.
Globals 与单篇 story 的 args 不同:args 属于某一条具体 story,而 globals 是项目级、跨 story 的全局值(例如主题明暗、语言 locale、viewport 之类),它们通常由工具栏、URL 查询参数或 story 级覆盖共同决定。插件如果依赖这类全局状态(例如背景色插件读取当前主题、或一个面板需要根据某个全局开关显隐),就需要在管理器侧订阅并修改它,这正是 useGlobals 的职责。
在 addons-api.mdx 中,useGlobals 与 useChannel、useAddonState、useParameter、useArgs 等并列,属于插件可用的 React Hooks 集合。它们全部需要从 storybook/manager-api 导入,即用于“与 Storybook 管理器 UI 交互或访问 Storybook API”的那一层(另一包 storybook/preview-api 则用于控制插件的运行行为)。这一点决定了本文示例的使用场景:在 manager 侧渲染的面板、工具栏、Tab 组件中读取与修改全局值。
完整示例:用 useGlobals 做一个可切换显隐的面板
以下是 storybook-addons-api-useglobal.md 中给出的完整可运行示例(源文件位于 docs/_snippets/storybook-addons-api-useglobal.md,文件名标注为 my-addon/manager.js|ts,适用于 JS 与 TS 两种写法):
import React from 'react';
import { AddonPanel, Button } from 'storybook/internal/components';
import { useGlobals } from 'storybook/manager-api';
export const Panel = () => {
const [globals, updateGlobals] = useGlobals();
const isActive = globals['my-param-key'] || false; // 👈 Sets visibility based on the global value.
return (
<AddonPanel key="custom-panel" active={isActive}>
<Button onClick={() => updateGlobals({ ['my-param-key']: !isActive })}>
{isActive ? 'Hide the addon panel' : 'Show the panel'}
</Button>
</AddonPanel>
);
};
这段代码虽短,但包含了 useGlobals 的全部核心用法,逐行拆解如下:
- 导入来源:
useGlobals从storybook/manager-api导入;面板容器与按钮从storybook/internal/components导入。注意这段代码属于插件“管理端”代码,它运行在 Storybook 的 manager(主界面进程)中,而不是在渲染 story 的 iframe 预览区内。 - 解构取值:
const [globals, updateGlobals] = useGlobals();。第一个返回值是当前全局状态对象(形如{ 'my-param-key': true }的字典),第二个是更新函数。 - 按 key 读取:
globals['my-param-key'] || false把读取到的值规整为布尔值。这里体现了 Globals 的扁平字典形态:插件一般只关心自己命名空间下的 key(官方惯例是用前缀命名空间,例如my-param-key,避免与工具栏等既有全局 key 冲突)。 - 反向写入:按钮
onClick调用updateGlobals({ ['my-param-key']: !isActive })完成状态翻转,传入的是一个只包含自己 key 的局部对象。preview 端会对该对象执行合并更新(详见下文源码分析),因此不会清掉其他 key。 - 驱动 UI 联动:
<AddonPanel active={isActive}>的显隐完全由 globals 推导而来,按钮文案也随状态切换。点击后,状态更新会广播回管理器,触发使用useGlobals的组件重新渲染,形成“读取 → 更新 → 界面联动”的闭环。
值得一提的是,useGlobals 的真实返回值比示例中解构的更多(下文会展开),示例只取前两个就已经足够覆盖绝大多数用法,这也说明该 Hook 的设计对调用方足够宽容。
返回值拆解:globals / updateGlobals 之外还有什么
顺着解构的写法查看 manager 侧的真实实现,位于 root.tsx:
export function useGlobals(): [
globals: Globals,
updateGlobals: (newGlobals: Globals) => void,
storyGlobals: Globals,
userGlobals: Globals,
] {
const api = useStorybookApi();
return [api.getGlobals(), api.updateGlobals, api.getStoryGlobals(), api.getUserGlobals()];
}
也就是说,useGlobals 实际返回一个四元组:
| 顺序 | 返回值 | 语义 | 底层实现 |
|---|---|---|---|
| 1 | globals |
当前生效的全局状态,即“用户设置的 globals 与 story 级 globals 叠加”后的结果 | api.getGlobals(),读取 store 中的 globals 状态 |
| 2 | updateGlobals(newGlobals) |
更新全局状态并触发 preview 重新渲染 | api.updateGlobals,通过事件通道发送给 preview |
| 3 | storyGlobals |
当前 story 自己声明的 globals 覆盖值 | api.getStoryGlobals() |
| 4 | userGlobals |
用户/工具栏设置的 globals(可被 story 覆盖) | api.getUserGlobals() |
关于这三个 globals 变体之间的差异,globals.ts 的 SubState 给出了清晰的分层:state 中同时维护 globals、userGlobals、storyGlobals 三份数据,其中 getGlobals 的 JSDoc 注释明确指出:“Returns the current globals, which is the user globals overlaid with the story globals”。因此日常插件只需消费第一项 globals 即可获得“真正对当前 story 生效”的状态;只有当你需要区分“这个值是用户点的还是某个 story 声明覆盖的”时,才需要用到第 3、4 项。
此外源码中还有一个值得留意的细节:预览端存在另一个同名但不同环境的 Hook —— hooks.ts 中的 useGlobals,它面向 preview 上下文(例如在 story 渲染环境内运行的代码),返回 [globals, updateGlobals],且更新动作通过 channel.emit(UPDATE_GLOBALS, ...) 发出。本文及示例使用的都是 manager 侧的 storybook/manager-api 版本,在编写插件 UI 组件时请确认导入路径正确。
底层原理:从按钮点击到预览 iframe 重渲染的事件闭环
updateGlobals 绝不是简单改一个本地变量,它是一条跨 iframe 的消息链。结合源码可以完整还原整个事件闭环。
第一步:manager 向 preview 发送 UPDATE_GLOBALS
manager 侧的 updateGlobals 实现位于 globals.ts:
updateGlobals(newGlobals) {
// Only emit the message to the local ref
provider.channel?.emit(UPDATE_GLOBALS, {
globals: newGlobals,
options: {
target: 'storybook-preview-iframe',
},
});
}
它并不直接修改任何本地状态,而是把新值打包进 UPDATE_GLOBALS 事件,并通过 target: 'storybook-preview-iframe' 指定投递目标为预览 iframe。manager 与 preview 运行在不同的 iframe/进程中,这条通道正是两者通信的桥梁。这一点在单元测试 globals.test.ts 中也有印证:测试断言调用 api.updateGlobals({ a: 'b' }) 后会在 channel 上发出 UPDATE_GLOBALS。
第二步:preview 接收并合并,随后广播 GLOBALS_UPDATED
预览端在 Preview.tsx 中订阅了该事件:
setupListeners() {
this.channel.on(STORY_INDEX_INVALIDATED, this.onStoryIndexChanged.bind(this));
this.channel.on(UPDATE_GLOBALS, this.onUpdateGlobals.bind(this));
// ...
}
真正的处理逻辑在 onUpdateGlobals(Preview.tsx)中:
this.storyStoreValue.userGlobals.update(updatedGlobals)—— 把传入的局部 globals 合并进 userGlobals,因此前文强调“按 key 局部更新不会丢失其他 key”。- 若当前存在选中的 story,则基于 story 上下文重新计算
{ initialGlobals, userGlobals, storyGlobals, globals }四份值并channel.emit(GLOBALS_UPDATED, ...)广播回 manager;若没有选中 story(例如处于 docs 模式),则用空的storyGlobals构造同样的回执。 await Promise.all(this.storyRenders.map((r) => r.rerender()))—— 让所有正在渲染的 story 全部重渲染,使新 globals 真正作用于组件。
第三步:manager 收到广播并落库,触发 Hook 消费方重渲染
manager 侧 globals.ts 对 GLOBALS_UPDATED 事件做了监听:它用 dequal 深度比较新旧值,只有发生变化时才把 globals / userGlobals / storyGlobals 写入 store。此外 preview 在初始化阶段还会通过 SET_GLOBALS(见 Preview.tsx)把初始 globals 与 globalTypes 推送给 manager,manager 则在 globals.ts 中落库并反向校准用户值。preview 端还记录了因收到 UPDATE_GLOBALS 而最终发出 GLOBALS_UPDATED 的行为测试(见 PreviewWeb.test.ts)。
把三步连起来看,一次 updateGlobals 调用对应的完整调用链是:
插件面板 onClick
→ manager: updateGlobals(newGlobals)
→ channel.emit(UPDATE_GLOBALS, { globals, options: { target: 'storybook-preview-iframe' } })
→ preview: onUpdateGlobals
→ storyStoreValue.userGlobals.update(updatedGlobals) // 合并更新
→ 依据当前 story 计算 globals = userGlobals + storyGlobals
→ channel.emit(GLOBALS_UPDATED, { initialGlobals, userGlobals, storyGlobals, globals })
→ 所有 story 触发 rerender()
→ manager: GLOBALS_UPDATED 监听器
→ 深比较后写入 store 的 globals / userGlobals / storyGlobals
→ 所有 useGlobals() 组件读取新值并重渲染
这就是为什么点击一次按钮,preview 里的组件与 manager 里的插件 UI 会“同时”变化——它们都由同一份全局状态驱动。
高频重渲染风险与官方性能建议
理解了上述广播机制后,一个性能隐患也随之浮现:GLOBALS_UPDATED 每次都会把 state 变更广播给 manager 端所有订阅方,凡是调用 useGlobals 的组件都会重新渲染。官方在 addons-api.mdx 的 useGlobals 小节中给出了明确建议:
We also recommend optimizing your addon to rely on
React.memo, or the following hooks;useMemo,useCallbackto prevent a high volume of re-render cycles.
落实到工程实践中,通常建议:
- 组件级:用
React.memo包裹面板/工具栏组件,仅当 props 或自身读取的 globals 变化时才重渲染; - 计算级:对由 globals 派生的中间值使用
useMemo(例如基于globals['my-param-key']计算出的显隐布尔值); - 回调级:对传给子组件的事件处理器使用
useCallback,避免每次渲染都生成新函数引用、拖累React.memo的浅比较优化; - 读取面最小化:与示例一致,尽量只读取自己命名空间下的 key(
globals['my-param-key'])用于 UI 判断,而不是把整个globals对象散播给大量子组件,从而把重渲染影响范围控制在必要区域。
对于需要持久化更复杂、跨多种 UI 形态(如同时存在工具栏与面板)状态的需求,官方文档建议改用 useAddonState(同样见 addons-api.mdx),因为它是 addon 自己的状态通道,广播面比 Globals 小得多。
接入实战:把它注册成真正的 Storybook Addon
上文示例中的 Panel 只是一个组件,要让它在 Storybook UI 中真正出现,还需要经过 addons.register 与 addons.add 两步注册(注册机制的说明位于 addons-api.mdx 的 addons.add() / addons.register() 小节)。在 my-addon/manager.js|ts(即插件的 manager 入口文件)中大致编排如下:
import { addons, types } from 'storybook/manager-api';
import { Panel } from './Panel'; // 包含上述 useGlobals 的组件
addons.register('my-addon', () => {
addons.add('my-addon/panel', {
type: types.PANEL,
title: 'My addon panel',
render: Panel,
});
});
几个可以结合示例核对的要点:
render: Panel的渲染函数中,Storybook 默认会以{ active }等参数调用它;示例中的Panel组件并未解构 props,而是把显隐完全交给globals['my-param-key']控制,并在updateGlobals中翻转该值——这是一种“用全局状态驱动插件自身 UI”的典型模式。你也可以在 storybook-addon-panel-example.md 等官方片段中对照不同面板写法的差异。- manager 入口文件需要在插件预设的
managerEntries中被加载(参见 writing-addons.mdx 中关于 addon 结构的说明);而useGlobals只能出现在 manager 侧渲染的组件中,preview 侧请改用前文提到的 preview 版 Hook 或装饰器方案。 - Globals 的“来源”即 globalTypes/globals 的项目级声明,统一在
.storybook/preview中配置;两者如何定义、工具栏如何与之联动,可查阅 toolbars-and-globals.mdx。若需要同时“消费并更新某个 GlobalType”,官方片段 addon-consume-and-update-globaltype.md 与 addon-consume-globaltype.md 提供了配套的读写参考。
小结
useGlobals 是连接插件 UI 与 Storybook 全局状态的“双向阀门”:通过 globals 读取当前生效值,通过 updateGlobals 发出局部更新,并由 manager → preview → manager 的事件闭环保证两端一致。实际使用中,请记住三点:导入自 storybook/manager-api、只按自己的 key 读写以避免状态互相污染、用 React.memo/useMemo/useCallback 抵御高频重渲染。沿着本文给出的源码路径(root.tsx、globals.ts、Preview.tsx),你就能在调试插件时快速定位状态在两端传递中的每一个环节。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00