Storybook Core-Client 架构解析:框架如何接入浏览器端预览系统(renderToCanvas / render / decorateStory)
【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook
本文围绕 Storybook 仓库中 Core-Client 包的官方说明文档 展开,讲解这个浏览器端共享层的历史定位、核心契约(start(renderToCanvas, { render, decorateStory })三要素)以及configure()返回值的设计意图,并结合当前仓库中 React 渲染器与 preview-api 的源码实现,说明这套契约在今天的代码里以什么形态存在、如何工作。读完后,你将理解框架适配层与核心预览系统之间的职责边界,以及renderToCanvas、decorateStory等关键概念在源码中的真实调用关系。
Core-Client 是什么:v6 时代遗留的浏览器端共享层
README-core-client.md 开篇即点明了这个包的身份:
This package contains browser-side functionality shared amongst all the frameworks (React, RN, Vue 3, Ember, Angular, etc) in the old "v6" story store back-compatibility layer.
也就是说,Core-Client(原独立包@storybook/core-client)承担的是所有框架(React、React Native、Vue 3、Ember、Angular 等)在浏览器端共享的功能层。它存在的前提是 v6 时代的故事存储(story store)向后兼容需求——不同 UI 框架对"把故事渲染到画布"这件事的实现方式完全不同,但 Storybook 核心需要一套统一的接口来屏蔽这种差异。
需要注意的是,这个包在仓库演进中已经被合并。preview-api 包的 README 明确记录了这一变迁:
This package used to be multiple packages (they have been combined into this one):
@storybook/addons@storybook/core-client@storybook/preview-web@storybook/store
因此,README-core-client.md现在作为 preview-api 包下的历史子包文档保留(同级还有 README-addons.md、README-preview-web.md、README-store.md),对应源码分别落在code/core/src/preview-api/modules/下的addons/、preview-web/、store/三个目录中。理解这一点是读懂后续内容的关键:文档描述的是历史契约,而当前源码展示的是同一契约的现代化形态。
核心契约:start() 与三个由框架提供的函数
文档的核心内容定义了框架接入 Storybook 的调用约定:
A framework calls the
start(renderToCanvas, { render, decorateStory })function and provides:
- The
renderToCanvasfunction, which tells Storybook how to render the result of a story function to the DOM- The
renderfunction, which is a default mapping ofargsto a story result in CSFv3- The
decorateStoryfunction, which tells Storybook how to combine decorators in the framework.
这条调用约定确立了框架适配层的三个职责,每个都对应一个清晰的边界:
| 函数 | 职责 | 解决的问题 |
|---|---|---|
renderToCanvas | 把 story 函数的结果渲染到 DOM 画布 | 不同框架(React / Vue / Svelte / Angular / Ember)挂载、卸载、更新组件的方式完全不同 |
render | CSFv3 中args到 story 结果的默认映射 | 用户不再手写function() { return <Button /> },而是声明args,框架负责默认把args展开成组件调用 |
decorateStory | 在框架内部组合(compose)decorators | 装饰器最终要变成该框架的"嵌套组件"(如 React 的<Wrapper><Story/></Wrapper>),核心层无法代劳 |
其中renderToCanvas是最关键的抽象:Storybook 核心只关心"故事函数被调用后产出了一个可渲染对象",至于这个对象如何落到 canvas 元素上(是否要 ErrorBoundary、是否要act()包裹、如何卸载旧组件),完全交给框架实现。
用 React 渲染器看 renderToCanvas 的真实实现
当前仓库中 React 渲染器的入口 entry-preview.tsx 保留了与文档契约完全对应的导出:
export { render } from './render.tsx'; export { renderToCanvas } from './renderToCanvas.tsx'; export { mount } from './mount.ts'; export { applyDecorators } from './applyDecorators.ts';同时它导出项目级注解(decorators、parameters、beforeAll),例如parameters: { renderer: 'react' }声明了渲染器身份,beforeAll中配置了与storybook/test集成的asyncWrapper/eventWrapper(基于 Reactact)。从源码结构看,这正是文档所述"框架向 Storybook 提供渲染能力"契约在当前代码中的落点:框架不再通过start()注册,而是直接以模块导出的形式暴露这三类函数。
具体到 renderToCanvas.tsx 的实现,可以看到一个框架适配层需要承担的完整细节:
- 接收统一的
RenderContext:签名是renderToCanvas({ storyContext, unboundStoryFn, showMain, showException, forceRemount }, canvasElement)。核心层把"未绑定的故事函数"和渲染上下文交出来,框架决定怎么用。 - ErrorBoundary 包裹:非 portable story 会被包进
ErrorBoundary组件,componentDidCatch时调用showException(err),正常挂载时调用showMain()——这就是核心层"出错时如何在界面上展示"的回调约定。 - StrictMode 支持:
const Wrapper = FRAMEWORK_OPTIONS?.strictMode ? StrictMode : Fragment;,说明框架选项(FRAMEWORK_OPTIONS)会直接影响渲染行为。 - act 队列串行化:
actQueue+processActQueue保证多个并发的act()调用被串行处理,渲染本身在act(async () => renderElement(element, canvasElement, ...))中执行;注释中说明 docs 视图下会禁用 act(对应 issue 30356 的行为)。 - forceRemount 语义:切换故事时需要先
unmountElement(canvasElement)再挂载,否则"React 不会为每次故事运行重建实例";但改变 args/globals 时则走更新路径而不重挂载。 - 返回 cleanup 函数:
return async () => { await act(() => { unmountElement(canvasElement); }); }——核心层拿到的是一个卸载回调,用于下一次渲染前的清理。这个"渲染函数返回清理函数"的模式是框架契约的隐含约定。
同样的契约在 Vue 3、Svelte、Preact、Web Components、Angular、Ember 等渲染器中都有对应实现,例如 vue3 的 render.ts、Angular 客户端的 render.ts 与 config.ts、Ember 的 render.ts。多框架各自实现renderToCanvas,是这套契约存在的根本原因。
decorateStory:装饰器组合为什么必须交给框架
文档对decorateStory的定义是"告诉 Storybook 如何在该框架内组合 decorators"。这句话的深意在于:装饰器在语义上是"包裹",但包裹的语法是框架相关的——在 React 里是嵌套 JSX,在 Vue 里是组件包裹,在 Angular 里可能是模板嵌套。核心层只能传递"装饰器列表 + 上下文",无法生成最终 AST。
在当前的 store 实现中,这一职责体现在 prepareStory.ts:
// Combine all the metadata about a story (both direct and inherited from the // component/global scope) into a "render-able" story function, with all // decorators applied, parameters passed as context etc export function prepareStory<TRenderer extends Renderer>( storyAnnotations: NormalizedStoryAnnotations<TRenderer>, componentAnnotations: NormalizedComponentAnnotations<TRenderer>, projectAnnotations: NormalizedProjectAnnotations<TRenderer> ): PreparedStory<TRenderer> { ... }prepareStory把故事级、组件级、项目级三层注解合并,并在其中调用 loaders、beforeEach等钩子,最终产出一个"可渲染的故事函数"。注释明确说明这个函数是无状态的——它不跟踪 args 或 globals,而是期望这些值在每次调用时从外部传入。装饰器在这一阶段被组合进故事函数,而真正"把组合结果翻译成框架语法"的,就是框架侧的decorateStory/applyDecorators(React 侧对应 applyDecorators.ts)。这解释了为什么文档把"组合 decorators"列为框架职责而非核心职责:核心做语义层组合,框架做语法层落地。
render:CSFv3 中 args 到 story 结果的默认映射
文档中render的定义是"a default mapping ofargsto a story result in CSFv3"。CSFv3 的核心简化是:用户只声明args,不必手写 render 函数;只有需要自定义时才显式提供render。因此框架提供的render是默认兜底实现——React 渲染器导出render(见 render.tsx),其典型行为是把args展开为组件 props。当用户未提供自定义render时,核心预览流程就会落到这个框架默认映射上;当用户提供了render(例如渲染插槽、组合多个组件或返回非组件值),用户版本会覆盖默认映射。
这一点与renderToCanvas形成两级抽象的分工:
render:args→ 故事结果(框架相关,React 中通常是 JSX 元素);renderToCanvas:故事结果 → DOM 画布(框架相关,涉及挂载/卸载/错误处理)。
核心层两者都不实现,只定义调用时机与数据形状。
start() 的返回值:configure() 与 storiesOf 历史
文档最后一段描述了start的返回值:
The
startfunction will return aconfigure()function, which can be re-exported to be used inpreview.js(deprecated), or automatically by themain.js:storiesfield to:
- return a list of CSF files
deprecatedmake calls to thestoriesOfAPI.
这里包含三层信息:
configure()曾是 preview 入口:早期(v6 之前)用户需要在preview.js中手动调用configure(require.context('../stories', true, /\.stories\.(js|tsx?)$/))来注册故事文件;- 被
main.js的stories字段取代:后来改为在main.js中声明 stories glob,构建管线自动完成注册,preview.js中手写configure()变为弃用路径; storiesOfAPI 整体弃用:storiesOf().add()的旧式 API 与 CSF 文件模式并存后被淘汰,文档中直接标注了deprecated。
从当前源码结构看,start()这一入口签名已不再出现在代码树中(检索不到start(renderToCanvas, ...)的调用点),框架改为通过 entry-preview.tsx 这类模块直接导出render/renderToCanvas/ 装饰器等,浏览器端预览实例则演化为 PreviewWeb.tsx 中的PreviewWeb类(继承自PreviewWithSelection,构造时接收importFn与getProjectAnnotations,并挂到global.__STORYBOOK_PREVIEW__)。可以推断,start()→configure()是 v6 及更早版本的引导(bootstrap)机制,如今其职责被"框架模块导出 + PreviewWeb 实例化"取代,但三要素契约(渲染、args 映射、装饰器组合)本身被完整继承了下来。
当前代码中的对应关系速查
| 文档概念(v6 契约) | 当前仓库中的形态 | 关键文件 |
|---|---|---|
renderToCanvas | 各渲染器直接导出的同名函数 | react、vue3、angular |
render(args 默认映射) | 各渲染器导出的render | code/renderers/react/src/render.tsx |
decorateStory(装饰器组合) | store 层prepareStory做语义组合 + 框架侧applyDecorators做语法落地 | prepareStory.ts、applyDecorators.ts |
start()返回值 /configure() | PreviewWeb实例 +main.js:stories自动注册 | PreviewWeb.tsx |
| v6 back-compat 共享层本体 | 合并进 preview-api 的store/addons/preview-web子模块 | code/core/src/preview-api/README.md |
小结
README-core-client.md虽然篇幅不长,但它定义了 Storybook 框架适配层与核心预览系统之间最本质的接口契约:
- 核心层不碰 DOM:它只做注解归一化、装饰器语义组合(
prepareStory)、args/globals 状态管理; - 框架层负责三个不可跨框架复用的动作:把
args映射成框架对象(render)、把框架对象挂到画布并处理挂载/卸载/错误(renderToCanvas)、把装饰器列表翻译成框架语法(decorateStory); - 历史引导机制已退役:
start()→configure()→storiesOf的 v6 引导链被"模块导出 +main.js:stories+PreviewWeb实例化"取代,但三要素契约在 code/renderers 各渲染器与 code/core/src/preview-api 源码中仍可一一对应地找到。
如果你在为新框架编写 Storybook 渲染器,这份文档加上当前 React 渲染器的 entry-preview.tsx 与 renderToCanvas.tsx,就是最直接的参考实现。
【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考