首页
/ Storybook 容器组件 Mock 指南:借助 GlobalContainerContext 与 preview 全局装饰器统一替换页面容器

Storybook 容器组件 Mock 指南:借助 GlobalContainerContext 与 preview 全局装饰器统一替换页面容器

2026-09-07 10:29:46作者:农烁颖Land

在 Storybook 中构建页面级(page/screen)组件时,最大的挑战往往不是"怎么写故事",而是"如何处理组件依赖的容器与外部数据"。对于依赖 React/Solid Context 提供容器组件的应用,Storybook 官方推荐一套基于"上下文注入 + Story 复用"的实践:先通过 ProfilePageContextGlobalContainerContext 这类自定义 Context 解耦容器与展示组件,再在 .storybook/preview 中为所有故事统一注册一个全局装饰器(global decorator),把真实容器替换成从 .stories 文件中导入的故事组件。读完本文,你将掌握:为什么要用 Context 承载容器组件、如何用 GlobalContainerContext 组织"全站级"容器、如何写出覆盖 CSF 3 与 CSF Next 的跨渲染器(React/Solid)全局装饰器,以及其中涉及的类型约束与装饰器执行顺序等底层机制。

从"逐层 Mock 依赖"到"上下文提供容器"

页面组件通常是典型的"连接组件"(connected component):它依赖网络请求、业务模块或全局 Provider。Storybook 文档按依赖的载体把 Mock 场景分成三类:

对第三种场景,还有一个更彻底的解法——不 Mock 依赖,而是绕开依赖:在 构建页面与屏幕 中,Storybook 建议把"负责数据获取的容器组件"与"纯展示组件"严格拆分,然后把容器组件放进 Context 向下传递,而不是在展示组件里直接 import。这样展示组件始终可以在 Storybook 中"纯净"渲染,需要替换的只是 Context value 里提供的容器实现。

具体到代码结构,官方给出的一个页面上下文拆分示例是:

ProfilePage.js            // 展示组件
ProfilePage.stories.js    // 故事文件
ProfilePageContainer.js   // 真实容器组件(应用运行时使用)
ProfilePageContext.js     // 页面级 Context

ProfilePageContext.js 只做一件事——导出一个 createContext 创建的 Context(见 mock-context-create.md):

import { createContext } from 'react';

const ProfilePageContext = createContext();

export default ProfilePageContext;

展示组件通过 useContext 取出容器组件并渲染(见 mock-context-in-use.md):

import { useContext } from 'react';

import ProfilePageContext from './ProfilePageContext';

export const ProfilePage = ({ name, userId }) => {
  const { UserPostsContainer, UserFriendsContainer } = useContext(ProfilePageContext);

  return (
    <div>
      <h1>{name}</h1>
      <UserPostsContainer userId={userId} />
      <UserFriendsContainer userId={userId} />
    </div>
  );
};

注意这里 UserPostsContainerUserFriendsContainer 并未直接 import,而是来自 Context——这正是本方案的关键:Storybook 里不需要去 Mock 容器的内部依赖

两种"提供容器"的位置:Story 级与页面级

容器组件从哪里来?答案是应用侧和 Storybook 侧分别提供

应用运行时:在真实页面入口提供真实容器

在应用里,页面入口需要把真实的 Container 放进 Provider(见 mock-context-container-provider.md),例如 Next.js 的 pages/profile.js

import React from 'react';

import ProfilePageContext from './ProfilePageContext';
import { ProfilePageContainer } from './ProfilePageContainer';
import { UserPostsContainer } from './UserPostsContainer';
import { UserFriendsContainer } from './UserFriendsContainer';

//👇 Ensure that your context value remains referentially equal between each render.
const context = {
  UserPostsContainer,
  UserFriendsContainer,
};

export const AppProfilePage = () => {
  return (
    <ProfilePageContext.Provider value={context}>
      <ProfilePageContainer />
    </ProfilePageContext.Provider>
  );
};

代码注释点出了一个极易被忽视的细节:context value 必须在每次渲染之间保持引用相等(referentially equal),否则 Provider 每次渲染都会用新对象触发整棵子树重渲染。

Storybook:用故事组件充当容器替身

在 Storybook 里,Provider 提供的容器被替换成直接从 .stories 文件导入的故事导出。绝大多数情况下,容器组件的 Mock 版本可以直接借用它们自己的故事——因为这些故事已经封装好了一份自洽的渲染数据与参数(见 mock-context-container.md):

import React from 'react';

import { ProfilePage } from './ProfilePage';
import { UserPosts } from './UserPosts';

//👇 Imports a specific story from a story file
import { Normal as UserFriendsNormal } from './UserFriends.stories';

export default {
  component: ProfilePage,
};

const ProfilePageProps = {
  name: 'Jimi Hendrix',
  userId: '1',
};

const context = {
  //👇 We can access the `userId` prop here if required:
  UserPostsContainer({ userId }) {
    return <UserPosts {...UserPostsProps} />;
  },
  // Most of the time we can simply pass in a story.
  // In this case we're passing in the `normal` story export
  // from the `UserFriends` component stories.
  UserFriendsContainer: UserFriendsNormal,
};

export const Normal = {
  render: () => (
    <ProfilePageContext.Provider value={context}>
      <ProfilePage {...ProfilePageProps} />
    </ProfilePageContext.Provider>
  ),
};

这段代码展示了两种替身写法:需要透传 props(如 userId)时用内联函数包裹 UserPosts;不需要额外逻辑时,直接把故事导出(UserFriendsNormal)作为组件使用。官方建议将页面级容器 Context 按"具体页面/视图"划分,从而让每个 Context 的职责保持最小。

若同一个 Context 要应用到该组件(如 ProfilePage)的所有故事,可以进一步把它提升为 Decorator,而不是在每个故事里重复写 Provider。

全局容器上下文:GlobalContainerContext 的定位

官方提示:对"可能渲染在应用每个页面上"的容器组件,建立一个全局容器上下文(通常命名为 GlobalContainerContext)并放到应用顶层也很有帮助。虽然理论上可以把所有容器都塞进这个全局 Context,但它只应提供全局必需的容器——全站导航、登录态、主题入口这类组件,而不是某个页面特有的业务容器。

这个定位直接决定了本文主角 mock-context-container-global.mddocs/_snippets/mock-context-container-global.md)的价值:既然 GlobalContainerContext 覆盖全站,那么在 Storybook 中就应该让所有故事默认拿到替换后的全局容器,而不是每个故事手动包裹。

覆盖全局的唯一正确位置:.storybook/preview 的全局装饰器

Storybook 对"应用到所有故事"的配置约定在 .storybook/preview.ts|tsx(参见 Configure Story rendering)。通过导出 decorators 数组(或 CSF Next 中的 definePreview 配置)添加全局装饰器,即可让 Provider 包裹每一个故事。

以"每页都有导航栏容器 NavigationContainer"为例,NavigationContainer 的真实实现负责数据获取与路由联动,其故事文件 Navigation.stories 里导出了一个名为 normal 的故事。在 Storybook 里,我们希望所有故事共享"把 NavigationContainer 替换为 NavigationNormal"这一行为。

React / CSF 3:.storybook/preview.js|jsx

import * as React from 'react';

import { normal as NavigationNormal } from '../components/Navigation.stories';
import GlobalContainerContext from '../components/lib/GlobalContainerContext';

const context = {
  NavigationContainer: NavigationNormal,
};

const AppDecorator = (storyFn) => {
  return (
    <GlobalContainerContext.Provider value={context}>{storyFn()}</GlobalContainerContext.Provider>
  );
};

export default { decorators: [AppDecorator] };

React + TypeScript / CSF 3:.storybook/preview.ts|tsx

import * as React from 'react';

// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc.
import type { Meta, StoryObj } from '@storybook/your-framework';

import { normal as NavigationNormal } from '../components/Navigation.stories';
import GlobalContainerContext from '../components/lib/GlobalContainerContext';

const context = {
  NavigationContainer: NavigationNormal,
};

const AppDecorator = (storyFn) => {
  return (
    <GlobalContainerContext.Provider value={context}>{storyFn()}</GlobalContainerContext.Provider>
  );
};

const preview: Preview = {
  decorators: [AppDecorator],
};

export default preview;

TS 变体中有一个注释需要替换为你的真实框架包名:react-vitenextjsnextjs-vite@storybook/react 等;Preview 类型同样由对应框架模块提供。

Solid / CSF 3:.storybook/preview.js

import { normal as NavigationNormal } from '../components/Navigation.stories';
import GlobalContainerContext from '../components/lib/GlobalContainerContext';

const context = {
  NavigationContainer: NavigationNormal,
};

const AppDecorator = (storyFn) => {
  return (
    <GlobalContainerContext.Provider value={context}>{storyFn()}</GlobalContainerContext.Provider>
  );
};
export const decorators = [AppDecorator];

Solid 渲染器下,Provider 与上下文创建对应改为 solid-jscreateContext(参见 mock-context-create.md 中的 Solid 分支)。

Solid + TypeScript / CSF 3:.storybook/preview.ts

import { normal as NavigationNormal } from '../components/Navigation.stories';
import GlobalContainerContext from '../components/lib/GlobalContainerContext';

const context = {
  NavigationContainer: NavigationNormal,
};

const AppDecorator = (storyFn) => {
  return (
    <GlobalContainerContext.Provider value={context}>{storyFn()}</GlobalContainerContext.Provider>
  );
};

const preview: Preview = {
  decorators: [AppDecorator],
};

export default preview;

CSF Next(实验性):definePreview 变体

Storybook 还在实验性推进 CSF Next 预览 API——不再导出普通对象,而是通过框架导出 definePreview() 来声明 decoratorsdefinePreview 的核心类型定义位于 code/core/src/csf/csf-factories.ts,并在 nextjsnextjs-vitetanstack-react 等框架入口中重新导出(例如 code/frameworks/nextjs/src/index.ts)。React 与 Solid 用户均有 .tsx/.jsx 两种写法:

import * as 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';

import { normal as NavigationNormal } from '../components/Navigation.stories';
import GlobalContainerContext from '../components/lib/GlobalContainerContext';

const context = {
  NavigationContainer: NavigationNormal,
};

const AppDecorator = (storyFn) => {
  return (
    <GlobalContainerContext.Provider value={context}>{storyFn()}</GlobalContainerContext.Provider>
  );
};

export default definePreview({
  decorators: [AppDecorator],
});
import * as 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';

import { normal as NavigationNormal } from '../components/Navigation.stories';
import GlobalContainerContext from '../components/lib/GlobalContainerContext';

const context = {
  NavigationContainer: NavigationNormal,
};

const AppDecorator = (storyFn) => {
  return (
    <GlobalContainerContext.Provider value={context}>{storyFn()}</GlobalContainerContext.Provider>
  );
};

export default definePreview({
  decorators: [AppDecorator],
});

背后的机制:装饰器如何"包住"每个故事

理解这套写法为什么可行,需要知道 Storybook 装饰器的执行模型。在 Decorators 文档 中明确写到:与 story 相关的装饰器按以下顺序运行:

  • 全局装饰器(按定义顺序)→ 组件级装饰器(按定义顺序)→ 故事级装饰器(从最内层向外、自下而上)。

全局装饰器因此拥有"最外层包裹"的位置,天然适合放入 GlobalContainerContext.Provider。故事渲染时 storyFn() 返回的正是被该 Provider 包裹的组件树,于是每个故事内部读取 useContext(GlobalContainerContext) 时都会命中替换后的容器。

同一份文档还点明了 Decorator 的"第二参数"是故事上下文(story context),包含 argsargTypesglobalsparametersviewMode 等字段。若你的全局容器 Mock 需要随故事元数据(如 parameters)变化,完全可以在 AppDecorator 内基于上下文做条件分支,这与 Mocking providers 中的参数化配置 思路一致。

实战要点与常见误区

把上面的片段真正落地时,以下几点值得特别留意:

  1. Context value 的引用稳定性context 对象应在装饰器外定义(如各代码块所示),避免每次渲染重建 value 导致 Provider 子树不必要的重渲染。
  2. 只放全局必需的容器GlobalContainerContext 的设计意图是承载"每个页面都会渲染"的容器(全站导航、页脚、认证栏等)。把所有容器都塞进全局上下文虽然技术上可行,却会让上下文难以维护、也拖慢每次渲染——这正是它与页面级 ProfilePageContext 的分工边界。
  3. 跨渲染器的 API 差异:React 用 createContext/useContext,Solid 用 solid-js 的对应 API;Provider 的 value 语义与组件树渲染方式各自遵循其框架约定,复制代码时不要跨框架混用。
  4. TS 包名与类型MetaStoryObjPreviewdefinePreview 的导入源必须换成项目实际使用的框架包,否则类型无法解析。
  5. 实验性 API 标注:CSF Next 分支在源码片段中标记为 🧪 实验特性,其 definePreview 由框架入口导出(如 code/frameworks/nextjs/src/index.ts),上生产项目前请核对当前版本是否稳定支持。
  6. 复用故事即复用数据:被借用的故事导出(如 normal)本身就是一份带 args 的可渲染数据,因此 Mock 容器不仅"看起来像",而且与真实组件的 Storybook 交互(Controls、Actions)行为保持一致。

小结与相关资源

本方案的完整链路可以概括为四步:

  1. 为每页/每区块建立页面级 Context(如 ProfilePageContext),为全站必需容器建立 GlobalContainerContext
  2. 展示组件通过 useContext 消费容器,应用入口(如 Next.js pages/*)在 Provider 中注入真实 Container;
  3. .stories 中,把故事的 Provider value 换成直接从故事文件导入的 Mock 组件;
  4. 对覆盖全部故事的 GlobalContainerContext,直接在 .storybook/preview 中注册全局装饰器——这正是 mock-context-container-global.md 演示的场景。

本主题相关的仓库资源(均可对照源码继续深入研究):

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

项目优选

收起
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