Storybook 容器组件 Mock 指南:借助 GlobalContainerContext 与 preview 全局装饰器统一替换页面容器
在 Storybook 中构建页面级(page/screen)组件时,最大的挑战往往不是"怎么写故事",而是"如何处理组件依赖的容器与外部数据"。对于依赖 React/Solid Context 提供容器组件的应用,Storybook 官方推荐一套基于"上下文注入 + Story 复用"的实践:先通过 ProfilePageContext、GlobalContainerContext 这类自定义 Context 解耦容器与展示组件,再在 .storybook/preview 中为所有故事统一注册一个全局装饰器(global decorator),把真实容器替换成从 .stories 文件中导入的故事组件。读完本文,你将掌握:为什么要用 Context 承载容器组件、如何用 GlobalContainerContext 组织"全站级"容器、如何写出覆盖 CSF 3 与 CSF Next 的跨渲染器(React/Solid)全局装饰器,以及其中涉及的类型约束与装饰器执行顺序等底层机制。
从"逐层 Mock 依赖"到"上下文提供容器"
页面组件通常是典型的"连接组件"(connected component):它依赖网络请求、业务模块或全局 Provider。Storybook 文档按依赖的载体把 Mock 场景分成三类:
- 依赖模块导入:参考 Mocking imports;
- 依赖API 服务/网络请求:参考 Mocking API services;
- 依赖 Context Provider 提供的数据与配置:参考 Mocking providers。
对第三种场景,还有一个更彻底的解法——不 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>
);
};
注意这里 UserPostsContainer、UserFriendsContainer 并未直接 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.md(docs/_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-vite、nextjs、nextjs-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-js 的 createContext(参见 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() 来声明 decorators。definePreview 的核心类型定义位于 code/core/src/csf/csf-factories.ts,并在 nextjs、nextjs-vite、tanstack-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),包含 args、argTypes、globals、parameters、viewMode 等字段。若你的全局容器 Mock 需要随故事元数据(如 parameters)变化,完全可以在 AppDecorator 内基于上下文做条件分支,这与 Mocking providers 中的参数化配置 思路一致。
实战要点与常见误区
把上面的片段真正落地时,以下几点值得特别留意:
- Context value 的引用稳定性:
context对象应在装饰器外定义(如各代码块所示),避免每次渲染重建 value 导致 Provider 子树不必要的重渲染。 - 只放全局必需的容器:
GlobalContainerContext的设计意图是承载"每个页面都会渲染"的容器(全站导航、页脚、认证栏等)。把所有容器都塞进全局上下文虽然技术上可行,却会让上下文难以维护、也拖慢每次渲染——这正是它与页面级ProfilePageContext的分工边界。 - 跨渲染器的 API 差异:React 用
createContext/useContext,Solid 用solid-js的对应 API;Provider 的 value 语义与组件树渲染方式各自遵循其框架约定,复制代码时不要跨框架混用。 - TS 包名与类型:
Meta、StoryObj、Preview、definePreview的导入源必须换成项目实际使用的框架包,否则类型无法解析。 - 实验性 API 标注:CSF Next 分支在源码片段中标记为 🧪 实验特性,其
definePreview由框架入口导出(如 code/frameworks/nextjs/src/index.ts),上生产项目前请核对当前版本是否稳定支持。 - 复用故事即复用数据:被借用的故事导出(如
normal)本身就是一份带 args 的可渲染数据,因此 Mock 容器不仅"看起来像",而且与真实组件的 Storybook 交互(Controls、Actions)行为保持一致。
小结与相关资源
本方案的完整链路可以概括为四步:
- 为每页/每区块建立页面级 Context(如
ProfilePageContext),为全站必需容器建立GlobalContainerContext; - 展示组件通过
useContext消费容器,应用入口(如 Next.jspages/*)在 Provider 中注入真实 Container; - 在
.stories中,把故事的 Provider value 换成直接从故事文件导入的 Mock 组件; - 对覆盖全部故事的
GlobalContainerContext,直接在.storybook/preview中注册全局装饰器——这正是 mock-context-container-global.md 演示的场景。
本主题相关的仓库资源(均可对照源码继续深入研究):
- 构建页面/屏幕的整体方法论:docs/writing-stories/build-pages-with-storybook.mdx
- 本方案用到的一系列配套片段:mock-context-create.md、mock-context-in-use.md、mock-context-container.md、mock-context-container-provider.md
- 装饰器的层级与执行顺序:docs/writing-stories/decorators.mdx
definePreview的类型定义与框架重新导出:code/core/src/csf/csf-factories.ts、code/frameworks/nextjs/src/index.ts- 同属"Mock 连接组件"主题的相邻文档:Mocking modules、Mocking API services、Mocking providers
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