Storybook 实战:用 Svelte 编写 MarginDecorator 装饰器组件,为 Story 渲染添加外壳与画布间距
本指南基于 Storybook 官方文档体系中的
docs/_snippets/margindecorator.md代码片段展开。该片段演示了在Svelte 渲染器下如何编写一个名为MarginDecorator.svelte的装饰器组件,用于给紧贴画布边缘的 Story 包裹一层带外边距的 "harness"(外壳)。读完本文,你将掌握 Svelte 5 runes 语法下装饰器组件的写法、在*.stories.svelte与 CSF 3 两种文件形态中挂接装饰器的方法,以及向装饰器传参、借助 story context 与 Svelte context API 定制渲染的进阶能力。
装饰器(Decorator)是 Storybook 中"给 Story 额外套一层渲染"的标准手段:许多插件通过它增强渲染或采集渲染信息,而在手工编写 Story 时,它最常用于补充标记结构或注入 context(上下文)。当某个组件"顶着画布边缘渲染"、难以观察整体效果时,最经典的解法就是为它包一层带留白的容器。
上面对比来自宿主文档 docs/writing-stories/decorators.mdx:左侧组件与画布边缘零距离,视觉上十分局促;右侧组件被套进带有间距的容器后,边界清晰、更便于审阅与截图。要实现右侧效果,Svelte 渲染器需要比 JSX 渲染器多做一步——先创建一个独立的 Svelte 组件来充当装饰器,这就是MarginDecorator.svelte的由来。
一、创建 MarginDecorator.svelte:装饰器组件本体
原文档 docs/_snippets/margindecorator.md 提供了同一组件的 JavaScript 与 TypeScript 两个版本,二者都使用 Svelte 5 的 runes 语法:
<script> let { children } = $props(); </script> <div> {@render children()} </div> <style> div { margin: 3em; } </style><script> import type { Snippet } from 'svelte'; let { children }: { children: Snippet } = $props(); </script> <div> {@render children()} </div> <style> div { margin: 3em; } </style>这段代码逐行拆解如下:
let { children } = $props():Svelte 5 runes 中声明组件 props 的标准方式。装饰器组件接收一个名为children的 prop,它将在后续被 Storybook 注入"真正要渲染的 Story 内容"。- TS 版本额外用
import type { Snippet } from 'svelte'标注children的类型。Snippet是 Svelte 5 引入的"代码片段"类型,代表可被{@render ...}调用的一整段模板。 {@render children()}:Svelte 5 中渲染 snippet 的专用指令,等价于旧版 Slots 体系下<slot />的角色。<style>中的div { margin: 3em }:为内部 div 设置 3em 外边距,从而把真正被渲染的 Story 组件与画布边缘"推开"。em是相对单位,会随根字号缩放;若希望间距与字号解耦,可改用rem或px。
一个值得注意的设计点是:装饰器组件本身不关心被装饰的是谁。它只声明children片段并渲染它,实际内容由 Storybook 在渲染期注入——这是装饰器组件与普通组件最本质的区别。
二、把装饰器接进 Story 文件
有了组件本体,下一步是让 Storybook 在渲染该组件的所有 Story 时,都把内容包进MarginDecorator。根据 docs/_snippets/your-component-with-decorator.md,Svelte 渲染器支持两种等价写法。
Svelte CSF(*.stories.svelte,配合@storybook/addon-svelte-csf)——把decorators放进defineMeta:
<script module> import { defineMeta } from '@storybook/addon-svelte-csf'; import YourComponent from './YourComponent.svelte'; import MarginDecorator from './MarginDecorator.svelte'; const { Story } = defineMeta({ component: YourComponent, decorators: [() => MarginDecorator], }); </script>CSF 3(普通*.stories.js|ts)——把decorators作为默认导出的键:
import YourComponent from './YourComponent.svelte'; import MarginDecorator from './MarginDecorator.svelte'; export default { component: YourComponent, decorators: [() => MarginDecorator], };两个例子中的写法decorators: [() => MarginDecorator]需要特别留意:数组元素是一个返回组件本身的箭头函数,而不是直接写MarginDecorator。这样既保持了装饰器 API 的统一(所有渲染器都约定 decorator 是一个可接收 story 的函数),也为后续在函数体内读取 story context、返回{ Component, props }形态留出了扩展空间。
三、需要传 props 时:返回 { Component, props } 对象
上面的MarginDecorator写死了margin: 3em。若想让间距可配置——例如根据 story 的parameters决定用 "small" 还是 "medium"——装饰器函数可以直接返回一个对象。这一点在宿主文档中被单独强调,其配套片段见 docs/_snippets/your-component-with-decorator-with-props.md:
import YourComponent from './YourComponent.svelte'; import MarginDecorator from './MarginDecorator.svelte'; export default { component: YourComponent, decorators: [ (story, { parameters }) => ({ Component: MarginDecorator, // 👇 Pass props to the MarginDecorator component props: { size: parameters.smallMargin ? 'small' : 'medium' }, }), ], };要点:
- 装饰器函数的第二个参数是 story context,此处解构出了
parameters(story 的静态元数据)。 - 返回值中
Component指定要渲染的装饰器组件,props则会被透传给该组件。此时你的MarginDecorator.svelte就需要配套声明sizeprop 并据此切换间距值——它的接收端本质上就是普通 Svelte 组件传参。 - 把
props绑定到parameters之后,你可以在任意 story 上通过parameters: { smallMargin: true }零侵入地单独控制该 story 的边距;也可以在.storybook/preview.js的全局parameters里统一配置。 - 更进一步,可以让 props 完全跟随 story 的
args(例如props: args),从而让size、color等成为可在 Storybook UI 的 Controls 面板中实时调节的控件。这一点由仓库渲染器测试所证实,参见下文第五节。
同样的传参思路也适用于结合全局工具(globals/toolbars)动态切换:读取globals.marginSize,再把它换算成组件 props 或通过 Svelte context 下发。
四、story context 里有什么:装饰器函数第二参数
装饰器函数的第二参数是story context。根据 docs/writing-stories/decorators.mdx 的完整清单,它包含以下核心字段:
| 字段 | 说明 |
|---|---|
args | 该 story 的参数,可在装饰器中消费一部分 args,使 story 实现本身更"纯净" |
argTypes | Storybook 的 argTypes 配置,用于细调 story 的 args 及其控件 |
globals | Storybook 全局变量,尤其可配合 Toolbars 功能在 UI 中实时切换取值 |
hooks | Storybook 的 API hooks(如useArgs、useGlobals),装饰器与渲染函数中均可用 |
parameters | story 的静态元数据,常用于控制 Storybook 特性与插件行为 |
viewMode | Storybook 当前激活的视图窗口(如 canvas、docs) |
这些字段就是装饰器实现"按需定制"的依据。例如装饰器可以读取parameters.pageLayout === 'page'来动态决定是否应用整页布局,而不是一味包固定容器(详见片段 docs/_snippets/decorator-parameterized-in-preview.md)。
对 Svelte 渲染器而言,story context 还有一个特殊用途:它让装饰器在运行期调用 Svelte 自身的 context API,实现"上下文注入"。
五、从源码看 Svelte 装饰器返回值的三种归一化
decorators: [() => MarginDecorator]返回的是裸组件,而第三节返回的是{ Component, props }对象——这两种形态是如何被统一处理的?答案在 Svelte 渲染器的核心实现 code/renderers/svelte/src/decorators.ts。
prepareStory函数(同文件第 40–80 行)对 decorator 返回值做了清晰的归一化(源码注释第 26–39 行原样概括了三类输入):
() => ({ Component: MyComponent, props: ... })——已经准备好的形态,原样保留;() => MyComponent——被转换为() => ({ Component: MyComponent });- 若返回空值或空对象,则退回到使用 story context 中的
component。
随后,源码第 65–76 行是关键一环:当存在内层 story(即装饰器链不是最后一层)时,渲染器会构造一个DecoratorHandler组件,把内层 story 作为普通组件、当前装饰器作为decoratorprop一并注入:
return { Component: DecoratorHandler, props: { ...innerStory, decorator: preparedStory, }, };也就是说,无论你写的是() => MarginDecorator还是() => ({ Component: MarginDecorator, props }),最终都会被包进@storybook/svelte/internal/DecoratorHandler.svelte,由它负责把被装饰的 Story 作为childrensnippet 渲染进你的装饰器组件。这正是MarginDecorator.svelte中children+{@render children()}能收到 Story 内容的底层原因。
渲染器自带的验收测试 code/renderers/svelte/template/stories/decorators.stories.js 恰好覆盖了上文讨论的所有形态,可作为"正确姿势"的可运行样例:
- 第 10 行
decorators: [() => BorderDecoratorRed]——组件级装饰器,返回裸组件; - 第 19–24 行
WithPreparedBlueBorder——显式返回{ Component: BorderDecoratorBlue },验证归一化等价性; - 第 26–32 行
WithPropsBasedBorder——props: { color: 'green' }的静态传参形态; - 第 33–42 行
WithArgsBasedBorder——props: args,把控件面板的 args 直接喂给装饰器组件; - 第 46–55 行
DecoratorsRunOnce——用play函数断言装饰器恰好执行一次,验证渲染期语义。
如果你的组件在.stories.svelte中编写,还需在.storybook/main.js的addons中注册@storybook/addon-svelte-csf与配套的 CSF 解析器(参考片段 docs/_snippets/main-config-svelte-csf-register.md),否则defineMeta与Story组件无法被识别。
六、更进一步:装饰器 + Svelte context / globals
除了包一层带边距的 div,装饰器组件最常见的进阶用法是充当 context 提供者。宿主文档专门指出:可以借助 story context 在装饰器中调用 Svelte 的setContext/getContextAPI,把 Storybook 的 globals 桥接给组件树。
配套片段 docs/_snippets/your-component-with-decorator-with-context.md 展示了把上文边距场景升级为 context 版本的做法——装饰器读取globals.marginSize,调用setContext('marginSize', ...)下发:
import { setContext } from 'svelte'; import YourComponent from './YourComponent.svelte'; import MarginDecorator from './MarginDecorator.svelte'; export default { component: YourComponent, decorators: [ (story, { globals }) => { const marginSize = globals.marginSize === 'small' ? 'small' : 'medium'; setContext('marginSize', marginSize); return { Component: MarginDecorator }; }, ], };配套的装饰器组件随之改为从 context 读取尺寸,并用style属性动态设置边距:
<script lang="ts"> import { getContext, type Snippet } from 'svelte'; interface Props { children?: Snippet; } let { children }: Props = $props(); const size = getContext<'small' | 'medium'>('marginSize') ?? 'medium'; const margin = size === 'small' ? '1rem' : '3rem'; </script> <div style="margin: {margin};"> {@render children?.()} </div>注意两点细节:
- TS 版本用
getContext<'small' | 'medium'>('marginSize')标注了 context 值的联合类型,并通过?? 'medium'提供缺省值,避免空渲染。 - 使用
{@render children?.()}的可选调用可以容忍children尚未就绪的场景,比原版强制调用更健壮。这与 Svelte 5 中 snippet prop 为可选的约定一致。
这样,MarginDecorator就从"写死 3em 边距的固定外壳"进化成了由 Storybook 全局工具驱动、可实时切换布局密度的渲染组件:UI 层切换marginSizeglobal,Story 画布随之重排。
七、控制装饰器的生效范围与执行顺序
MarginDecorator既可以按上文放在组件的默认导出(作用于该组件全部 Story),也可以按需放在不同层级。基于 docs/writing-stories/decorators.mdx,Svelte 渲染器支持三种层级:
- Story 级:作用于单个 story。Svelte CSF 中在
<Story>组件的decoratorsprop 声明,或在 CSF 具名导出上加decorators键。 - 组件级:作用于某组件全部 story。Svelte CSF 中放进
defineMeta的decorators属性,CSF 3 中作为默认导出的decorators键——前文两节示例均属此类。 - 全局级:作用于所有story。在
.storybook/preview.js的decorators导出中声明(写法参考片段 docs/_snippets/storybook-preview-global-decorator.md)。
这三层可以叠加使用。Story 一旦渲染,所有相关装饰器按以下顺序依次执行:
- 全局装饰器(按声明顺序);
- 组件级装饰器(按声明顺序);
- story 级装饰器(按声明顺序,从最内层开始向外逐层包裹)。
对MarginDecorator这类"通用外壳"而言,最省事的做法是放到.storybook/preview.js作为全局装饰器——但要注意,全局装饰器会作用于包括插件、Docs 页在内的所有渲染,因此若只想影响某组件的预览布局,组件级是更稳妥的选择。
八、最佳实践:让 Story 保持"纯净渲染"
使用装饰器的核心理念,正如宿主文档结尾所强调的:尽量让 story 保持对被测组件的"纯净渲染",所有额外 HTML 或辅助组件都应只作为装饰器存在。以本文为例,边距 div、context 注入都属于"查看方式",不该侵入YourComponent的实现或每个 story 的定义。
这一实践还有一个实际收益:Storybook 的 Source Doc Block 只有在 story 本身不被多余标记污染时,生成的源代码示例才最干净、最可复用。把装饰职责收敛到MarginDecorator一处,等于同时获得可维护的"外壳"与高质量的示例代码。
小结
本指南完整走通了 Svelte 渲染器下装饰器组件从"编写"到"接入"再到"定制"的链路:MarginDecorator.svelte用 Svelte 5 runes 的$props()+{@render children()}定义一个可被注入 Story 内容的外壳组件;通过decorators数组以() => Component或() => ({ Component, props })形态接入故事文件;利用 story context 的parameters、globals等字段按需定制;而 decorators.ts 中prepareStory+DecoratorHandler的实现则解释了这些形态如何被统一归一化、渲染为嵌套组件树。
如需查阅本文全部配套代码片段与后续的 context mocking、数据注入等主题,可直接深入本仓库的 docs/writing-stories/decorators.mdx 及其_snippets目录,并对照渲染器测试 code/renderers/svelte/template/stories/decorators.stories.js 验证各种装饰器写法的真实行为。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考