首页
/ Storybook Core-Client 架构解析:框架如何接入浏览器端预览系统(renderToCanvas / render / decorateStory)

Storybook Core-Client 架构解析:框架如何接入浏览器端预览系统(renderToCanvas / render / decorateStory)

2026-09-06 16:00:59作者:农烁颖Land

本文围绕 Storybook 仓库中 Core-Client 包的官方说明文档 展开,讲解这个浏览器端共享层的历史定位、核心契约(start(renderToCanvas, { render, decorateStory }) 三要素)以及 configure() 返回值的设计意图,并结合当前仓库中 React 渲染器与 preview-api 的源码实现,说明这套契约在今天的代码里以什么形态存在、如何工作。读完后,你将理解框架适配层与核心预览系统之间的职责边界,以及 renderToCanvasdecorateStory 等关键概念在源码中的真实调用关系。

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.mdREADME-preview-web.mdREADME-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 renderToCanvas function, which tells Storybook how to render the result of a story function to the DOM
  • The render function, which is a default mapping of args to a story result in CSFv3
  • The decorateStory function, 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';

同时它导出项目级注解(decoratorsparametersbeforeAll),例如 parameters: { renderer: 'react' } 声明了渲染器身份,beforeAll 中配置了与 storybook/test 集成的 asyncWrapper / eventWrapper(基于 React act)。从源码结构看,这正是文档所述"框架向 Storybook 提供渲染能力"契约在当前代码中的落点:框架不再通过 start() 注册,而是直接以模块导出的形式暴露这三类函数。

具体到 renderToCanvas.tsx 的实现,可以看到一个框架适配层需要承担的完整细节:

  1. 接收统一的 RenderContext:签名是 renderToCanvas({ storyContext, unboundStoryFn, showMain, showException, forceRemount }, canvasElement)。核心层把"未绑定的故事函数"和渲染上下文交出来,框架决定怎么用。
  2. ErrorBoundary 包裹:非 portable story 会被包进 ErrorBoundary 组件,componentDidCatch 时调用 showException(err),正常挂载时调用 showMain()——这就是核心层"出错时如何在界面上展示"的回调约定。
  3. StrictMode 支持const Wrapper = FRAMEWORK_OPTIONS?.strictMode ? StrictMode : Fragment;,说明框架选项(FRAMEWORK_OPTIONS)会直接影响渲染行为。
  4. act 队列串行化actQueue + processActQueue 保证多个并发的 act() 调用被串行处理,渲染本身在 act(async () => renderElement(element, canvasElement, ...)) 中执行;注释中说明 docs 视图下会禁用 act(对应 issue 30356 的行为)。
  5. forceRemount 语义:切换故事时需要先 unmountElement(canvasElement) 再挂载,否则"React 不会为每次故事运行重建实例";但改变 args/globals 时则走更新路径而不重挂载。
  6. 返回 cleanup 函数return async () => { await act(() => { unmountElement(canvasElement); }); }——核心层拿到的是一个卸载回调,用于下一次渲染前的清理。这个"渲染函数返回清理函数"的模式是框架契约的隐含约定。

同样的契约在 Vue 3、Svelte、Preact、Web Components、Angular、Ember 等渲染器中都有对应实现,例如 vue3 的 render.tsAngular 客户端的 render.tsconfig.tsEmber 的 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 of args to a story result in CSFv3"。CSFv3 的核心简化是:用户只声明 args,不必手写 render 函数;只有需要自定义时才显式提供 render。因此框架提供的 render默认兜底实现——React 渲染器导出 render(见 render.tsx),其典型行为是把 args 展开为组件 props。当用户未提供自定义 render 时,核心预览流程就会落到这个框架默认映射上;当用户提供了 render(例如渲染插槽、组合多个组件或返回非组件值),用户版本会覆盖默认映射。

这一点与 renderToCanvas 形成两级抽象的分工:

  • renderargs → 故事结果(框架相关,React 中通常是 JSX 元素);
  • renderToCanvas:故事结果 → DOM 画布(框架相关,涉及挂载/卸载/错误处理)。

核心层两者都不实现,只定义调用时机与数据形状。

start() 的返回值:configure() 与 storiesOf 历史

文档最后一段描述了 start 的返回值:

The start function will return a configure() function, which can be re-exported to be used in preview.js (deprecated), or automatically by the main.js:stories field to:

  • return a list of CSF files
  • deprecated make calls to the storiesOf API.

这里包含三层信息:

  1. configure() 曾是 preview 入口:早期(v6 之前)用户需要在 preview.js 中手动调用 configure(require.context('../stories', true, /\.stories\.(js|tsx?)$/)) 来注册故事文件;
  2. main.jsstories 字段取代:后来改为在 main.js 中声明 stories glob,构建管线自动完成注册,preview.js 中手写 configure() 变为弃用路径;
  3. storiesOf API 整体弃用storiesOf().add() 的旧式 API 与 CSF 文件模式并存后被淘汰,文档中直接标注了 deprecated

从当前源码结构看,start() 这一入口签名已不再出现在代码树中(检索不到 start(renderToCanvas, ...) 的调用点),框架改为通过 entry-preview.tsx 这类模块直接导出 render / renderToCanvas / 装饰器等,浏览器端预览实例则演化为 PreviewWeb.tsx 中的 PreviewWeb 类(继承自 PreviewWithSelection,构造时接收 importFngetProjectAnnotations,并挂到 global.__STORYBOOK_PREVIEW__)。可以推断,start()configure() 是 v6 及更早版本的引导(bootstrap)机制,如今其职责被"框架模块导出 + PreviewWeb 实例化"取代,但三要素契约(渲染、args 映射、装饰器组合)本身被完整继承了下来。

当前代码中的对应关系速查

文档概念(v6 契约) 当前仓库中的形态 关键文件
renderToCanvas 各渲染器直接导出的同名函数 reactvue3angular
render(args 默认映射) 各渲染器导出的 render code/renderers/react/src/render.tsx
decorateStory(装饰器组合) store 层 prepareStory 做语义组合 + 框架侧 applyDecorators 做语法落地 prepareStory.tsapplyDecorators.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.tsxrenderToCanvas.tsx,就是最直接的参考实现。

登录后查看全文
热门项目推荐
相关项目推荐