首页
/ Storybook 参数化全局装饰器:在 preview.ts 中依据 parameters 动态切换页面布局

Storybook 参数化全局装饰器:在 preview.ts 中依据 parameters 动态切换页面布局

2026-09-07 11:39:57作者:滕妙奇

本篇技术指南聚焦 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(如 useArgsuseGlobals),在装饰器与 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.tspreview.decorators 的数组会成为作用于全部 story 的全局装饰器列表,而其 context 里拿到的 parameters 已是三层合并后的最终值。

结合 Decorators 指南 中对装饰器继承顺序的说明,一条 story 渲染时装饰器按如下次序执行:

  1. 全局装饰器(按其在 preview 中定义的顺序);
  2. 组件级装饰器(meta 层,按定义顺序);
  3. 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 会自动落入装饰器 switchdefault 分支——即"不套布局,原样渲染",从而保证装饰器对存量 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/nextjsnextjs-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(如 useArgsuseGlobals)时同样建议经由 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 时,可返回含 Componentprops 键的对象,从而基于 story context 定制装饰器行为。若你的项目使用 Svelte 5 runes 的 JS 版本,PageLayout.sveltepreview.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 版本片段仓库中也保留以支撑两种语法并存期间的双轨支持。

源码验证点小结

若要进一步在仓库中追查本文涉及的机制,可重点阅读以下路径:

小结

.storybook/preview.ts 中定义参数化全局装饰器,是将"布局策略"从单个组件中抽离、全项目复用的高效手段:装饰器只写一次,布局诉求通过 parameters.pageLayout 在组件 meta 或 story 级声明,Storybook 的三层 combineParameters 合并机制保证全局装饰器总能读到每条 story 最终生效的参数值。把握 switchdefault 分支原样放行这一细节,即可让该装饰器平滑覆盖已有 story,做到"新增即受益、个案可覆写",也是构建可配置主题、可切换响应式预览等进阶能力的基础范式。

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

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388