Storybook 参数化全局装饰器:在 preview.ts 中依据 parameters 动态切换页面布局
本篇技术指南聚焦 Storybook 中一类高频实战场景:在 .storybook/preview.ts|tsx(预览配置文件)里定义一个可复用的全局装饰器,通过读取每条 story 的 parameters 元数据(如 pageLayout: 'page' | 'page-mobile')来决定是否为该 story 套用对应的页面布局外壳。读完本文,你将掌握装饰器函数第二个参数 StoryContext 的用法、parameters 在全局/组件/单个 story 三层之间的合并规则(源码级验证),并能在 Angular、React、Solid、Vue、Svelte、Web Components(Lit)以及 CSF Next definePreview 体系中落地同一套"参数驱动布局"模式。
本文内容围绕仓库中的 decorator-parameterized-in-preview.md 代码片段展开,它被内嵌于官方指南 Decorators(装饰器) 的 "Context for mocking" 小节中,是理解装饰器与 story context 协作关系的核心示例。
场景:为什么需要"参数化"的全局装饰器
Storybook 中,装饰器(decorator)是一种把 story 包进"额外渲染能力"中的机制,许多插件依赖它增强渲染或采集渲染细节。最常见的用法是给组件套一层外层标记(markup)或模拟上下文(context mocking)。例如某些组件会渲染到自身边缘,你会希望用装饰器为它的所有 story 统一加上内边距(padding)。
但当项目存在多种页面形态(如桌面端 page、移动端 page-mobile)时,一个"一刀切"的全局装饰器是不够的:不同的 story 需要不同的外层布局。此时的关键技巧是从装饰器的 story context 中读取 parameters,让同一条装饰器逻辑能根据每条 story 携带的静态元数据切换行为——这就是"参数化装饰器"。
将装饰器定义在 preview 文件中(全局装饰器)意味着它对所有 story 生效;再结合 parameters 逐条定制,即可做到"默认统一、个案可覆写"。
装饰器接收的 story context:第二个参数的含义
正如 docs/writing-stories/decorators.mdx 中说明的,装饰器函数的第二个参数是 story context(story 上下文),它携带以下关键字段:
args—— 当前 story 的参数(arguments),你可以在装饰器中消费部分 args,而不必把它们下放到 story 实现内部;argTypes—— Storybook 的 argTypes 元数据,用于刻画并微调args的输入形态;globals—— 全局状态(Storybook-wide),通常配合工具栏(toolbars)在 UI 中动态切换;hooks—— Storybook 的 API hooks(如useArgs、useGlobals),在装饰器与 story 渲染函数中均可用;当与框架 hooks(如 React 的useState/useEffect)混用时,为避免重复渲染出错,应改用storybook/preview-api提供的等价 hooks;parameters—— 该 story 的静态元数据,最常用于控制 Storybook 功能与插件行为,也是本文示例的驱动源;viewMode—— Storybook 当前激活的视图窗口(如 canvas、docs)。
以 parameters 驱动布局的写法则是在具体 story(或组件 meta)中声明 parameters.pageLayout = 'page'(或 'page-mobile'),装饰器据此在运行时决定渲染哪种外壳。仓库指南还提示:同样的技术也可用于切换提供给组件的主题等场景,相关展开见 mocking-data-and-modules/mocking-providers.mdx 中 "configuring the mock provider" 一节。
三层 parameters 合并规则:为什么全局装饰器能"看见" story 的参数
要让全局装饰器读到某条 story 的 pageLayout,Storybook 必须先把 story 最终生效的 parameters 计算出来。从源码看,这一合并发生在 prepareStory 阶段。在 code/core/src/preview-api/modules/store/csf/prepareStory.ts 中:
const parameters: Parameters = combineParameters(
projectAnnotations.parameters, // 全局:来自 .storybook/preview 等
componentAnnotations.parameters, // 组件级:来自 CSF 默认导出 meta
storyAnnotations?.parameters // story 级:来自具体命名导出
);
即最终参数按全局(project)→ 组件(component/meta)→ 单个 story 的优先级合并,后者的同名键覆盖前者。合并工具 combineParameters 位于 code/core/src/preview-api/modules/store/parameters.ts:只有两侧都是普通对象(plain object)时才递归深合并,数组或标量直接以后面集合的值为准。
同时,全局装饰器本身由 composeConfigs 从各个配置文件导出中收集。在 code/core/src/preview-api/modules/store/csf/composeConfigs.ts 中,decorators 通过 getArrayField 抽取所有模块导出里的 decorators 字段并归一化为数组,parameters 则通过 combineParameters 合并。由此,.storybook/preview.ts 中 preview.decorators 的数组会成为作用于全部 story 的全局装饰器列表,而其 context 里拿到的 parameters 已是三层合并后的最终值。
结合 Decorators 指南 中对装饰器继承顺序的说明,一条 story 渲染时装饰器按如下次序执行:
- 全局装饰器(按其在 preview 中定义的顺序);
- 组件级装饰器(meta 层,按定义顺序);
- story 级装饰器(从最内层向外、按层级向上推进)。
因此把"按 parameters.pageLayout 布局"的装饰器放在 preview(全局层),再让每个 story/meta 通过 parameters 表达自己的布局诉求,是层次最清晰的做法。
如何在 story 中声明 pageLayout 参数
参数化装饰器成立的前提是 story 侧提供了参数。你可以在组件级 meta 中统一声明(作用于该组件的所有 story):
// src/components/Page.stories.tsx
import type { Meta } from '@storybook/react-vite';
const meta: Meta = {
title: 'Layout/Page',
component: Page,
parameters: {
pageLayout: 'page', // 该组件所有 story 默认套用桌面布局
},
};
export default meta;
也可以在单个命名导出中覆盖,实现"同一组件在不同 story 下呈现不同页面形态":
// 移动端预览:仅此 story 生效
export const Mobile: Story = {
parameters: {
pageLayout: 'page-mobile',
},
};
未设置 pageLayout 的 story 会自动落入装饰器 switch 的 default 分支——即"不套布局,原样渲染",从而保证装饰器对存量 story 完全透明、向后兼容。
完整实现:各渲染框架下的参数化全局装饰器
下面按渲染器给出可直接复制进 .storybook/preview.ts|tsx|js 的完整实现。核心骨架一致:读取 parameters.pageLayout,用 switch 分发到不同布局外壳,default 分支原样放行。差异只在于各框架"包裹一段渲染内容"的语法(JSX 节点、模板字符串、渲染选项对象、组件 + props 对象或 lit 的 html 模板)。
Angular(CSF 3):使用 componentWrapperDecorator
Angular 通过 componentWrapperDecorator 把 story(字符串模板)包进一段 HTML 模板中。它本质上接受一个"模板字符串变换函数",因此可以直接基于 parameters 返回不同包裹:
import { type Preview, componentWrapperDecorator } from '@storybook/angular';
const preview: Preview = {
decorators: [
// 👇 Defining the decorator in the preview file applies it to all stories
componentWrapperDecorator((story, { parameters }) => {
// 👇 Make it configurable by reading from parameters
const { pageLayout } = parameters;
switch (pageLayout) {
case 'page':
// Your page layout is probably a little more complex than this
return `<div class="page-layout">${story}</div>`;
case 'page-mobile':
return `<div class="page-mobile-layout">${story}</div>`;
default:
// In the default case, don't apply a layout
return story;
}
}),
],
};
export default preview;
从实现看,componentWrapperDecorator 位于 code/frameworks/angular/src/client/decorators.ts,它会读取当前 story 已计算好的 story.template,调用传入的 (story) => string 变换函数生成新模板,并把(可选的)props 合并进渲染上下文;除模板字符串函数外,它同样支持传入一个 Angular 组件类型 Type<unknown> 作为包装壳。
React / Solid(CSF 3):JSX 包装
React 与 Solid 的用法一致——装饰器接收可渲染的 Story 组件并以 JSX 形式包裹。TypeScript 版本的 Preview 类型从你实际使用的框架包导入(如 @storybook/react-vite、@storybook/nextjs、nextjs-vite 等;下方占位符 your-framework 需替换为实际包名):
import React from 'react';
// Replace your-framework with the framework you are using (e.g., react-vite, nextjs, nextjs-vite)
import type { Preview } from '@storybook/your-framework';
const preview: Preview = {
decorators: [
// 👇 Defining the decorator in the preview file applies it to all stories
(Story, { parameters }) => {
// 👇 Make it configurable by reading from parameters
const { pageLayout } = parameters;
switch (pageLayout) {
case 'page':
return (
// Your page layout is probably a little more complex than this
<div className="page-layout">
<Story />
</div>
);
case 'page-mobile':
return (
<div className="page-mobile-layout">
<Story />
</div>
);
default:
// In the default case, don't apply a layout
return <Story />;
}
},
],
};
export default preview;
.storybook/preview.jsx(纯 JavaScript)版本仅去掉类型标注,逻辑完全一致:
import React from 'react';
export default {
decorators: [
(Story, { parameters }) => {
const { pageLayout } = parameters;
switch (pageLayout) {
case 'page':
return (
<div className="page-layout">
<Story />
</div>
);
case 'page-mobile':
return (
<div className="page-mobile-layout">
<Story />
</div>
);
default:
return <Story />;
}
},
],
};
Solid 的写法与 React 完全同构(renderer="solid" 版本的 preview.tsx 从 storybook-solidjs-vite 导入 Preview 类型),此处不再重复粘贴同一段 JSX。
Vue 3(CSF 3):返回渲染选项对象
Vue 装饰器通过返回包含 template 的渲染选项对象工作,模板内用 <story/> 占位渲染被包裹的内容:
import type { Preview } from '@storybook/vue3-vite';
const preview: Preview = {
decorators: [
// 👇 Defining the decorator in the preview file applies it to all stories
(_, { parameters }) => {
// 👇 Make it configurable by reading from parameters
const { pageLayout } = parameters;
switch (pageLayout) {
case 'page':
// Your page layout is probably a little more complex than this ;)
return { template: '<div class="page-layout"><story/></div>' };
case 'page-mobile':
return { template: '<div class="page-mobile-layout"><story/></div>' };
default:
// In the default case, don't apply a layout
return { template: '<story/>' };
}
},
],
};
export default preview;
纯 JS 版本(.storybook/preview.js)只是去掉 Preview 类型导入与标注,逻辑同上。顺带一提,Decorators 指南 针对 Vue 还有两点进阶提醒:若需在装饰器中使用响应式的 globals,应通过 setup 函数透传并用 computed 派生;调用 Storybook hooks(如 useArgs、useGlobals)时同样建议经由 setup 配合 storybook/preview-api 的 hooks,以保证响应式与重渲染正确性。
Svelte:布局组件 + 返回 { Component, props }
Svelte 需要额外建一个真正的布局组件 .storybook/PageLayout.svelte,再把"布局组件 + 入参 props + 被包裹的 story"一并返回给装饰器。
布局组件本体:
<script lang="ts">
interface Props {
layout?: 'page' | 'page-mobile' | 'default';
children?: import('svelte').Snippet;
}
let { layout = 'default', children }: Props = $props();
</script>
<!-- Your page layout is probably a little more complex than this -->
<div class={layout}>
{@render children?.()}
</div>
preview 中的装饰器则读取 parameters.pageLayout,把它映射为布局组件的 layout prop,并把 story(Svelte 的 snippet)作为 children 传入:
// Replace your-framework with svelte-vite or sveltekit
import type { Preview } from '@storybook/your-framework';
import PageLayout from './PageLayout.svelte';
const preview: Preview = {
decorators: [
// 👇 Defining the decorator in the preview file applies it to all stories
(story, { parameters }) => {
// 👇 Make it configurable by reading from parameters
const { pageLayout } = parameters;
return {
Component: PageLayout,
props: {
layout: pageLayout || 'default',
children: story,
},
};
},
],
};
export default preview;
这正是 Decorators 指南 中特别指出的 Svelte 模式:当装饰器需要向被返回的组件传 props 时,可返回含 Component 与 props 键的对象,从而基于 story context 定制装饰器行为。若你的项目使用 Svelte 5 runes 的 JS 版本,PageLayout.svelte 与 preview.js 只需去掉 lang="ts" 与 interface Props 类型声明,其余保持不变。
Web Components / Lit(CSF 3):html 模板与函数式 story
Web Components 渲染器使用 lit 的 html 模板标签;此处 story 表现为可调用的函数 story(),包裹时直接内插模板结果:
import type { Preview } from '@storybook/web-components-vite';
import { html } from 'lit';
const preview: Preview = {
decorators: [
// 👇 Defining the decorator in the preview file applies it to all stories
(story, { parameters }) => {
// 👇 Make it configurable by reading from parameters
const { pageLayout } = parameters;
switch (pageLayout) {
case 'page':
// Your page layout is probably a little more complex than this
return html`<div class="page-layout">${story()}</div>`;
case 'page-mobile':
return html`<div class="page-mobile-layout">${story()}</div>`;
default:
// In the default case, don't apply a layout
return story();
}
},
],
};
export default preview;
纯 JS 版本同样只是省略 Preview 类型导入。
实验性方案:CSF Next 中的 definePreview
仓库代码片段中还展示了正在实验的 CSF Next(代码中标注 🧪)写法:不再 export default 一个对象,而是从框架包导入 definePreview 并用它声明配置:
import React from 'react';
// Replace your-framework with the framework you are using (e.g., react-vite, nextjs, nextjs-vite)
import { definePreview } from '@storybook/your-framework';
export default definePreview({
decorators: [
// 👇 Defining the decorator in the preview file applies it to all stories
(Story, { parameters }) => {
// 👇 Make it configurable by reading from parameters
const { pageLayout } = parameters;
switch (pageLayout) {
case 'page':
return (
<div className="page-layout">
<Story />
</div>
);
case 'page-mobile':
return (
<div className="page-mobile-layout">
<Story />
</div>
);
default:
return <Story />;
}
},
],
});
definePreview 同样适用于 Vue(@storybook/vue3-vite)、Angular(配合 componentWrapperDecorator,写法为 import { definePreview, componentWrapperDecorator } from '@storybook/angular')以及 Web Components(@storybook/web-components-vite)。除包装函数变为 definePreview({ decorators: [...] }) 之外,装饰器函数体与上文 CSF 3 各版本完全一致;Angular 与 web-components 的 JS 版本片段仓库中也保留以支撑两种语法并存期间的双轨支持。
源码验证点小结
若要进一步在仓库中追查本文涉及的机制,可重点阅读以下路径:
- code/core/src/preview-api/modules/store/csf/prepareStory.ts:
project → component → story三层parameters的合并顺序,装饰器 context 中的parameters即来源于此; - code/core/src/preview-api/modules/store/parameters.ts:
combineParameters递归合并与"对象深合并、其余覆盖"的启发式规则; - code/core/src/preview-api/modules/store/csf/composeConfigs.ts:从 preview 配置导出中收集全局
decorators与parameters; - code/frameworks/angular/src/client/decorators.ts:
componentWrapperDecorator的模板字符串包装实现,其配套测试位于 code/frameworks/angular/src/client/decorateStory.test.ts; - docs/writing-stories/decorators.mdx:官方装饰器指南(story context 字段清单、装饰器继承顺序、Svelte/Vue 专项用法),本文代码片段的宿主页面;
- docs/_snippets/decorator-parameterized-in-preview.md:本篇对应的多框架代码片段原文。
小结
在 .storybook/preview.ts 中定义参数化全局装饰器,是将"布局策略"从单个组件中抽离、全项目复用的高效手段:装饰器只写一次,布局诉求通过 parameters.pageLayout 在组件 meta 或 story 级声明,Storybook 的三层 combineParameters 合并机制保证全局装饰器总能读到每条 story 最终生效的参数值。把握 switch 的 default 分支原样放行这一细节,即可让该装饰器平滑覆盖已有 story,做到"新增即受益、个案可覆写",也是构建可配置主题、可切换响应式预览等进阶能力的基础范式。
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 StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00