Storybook 容器组件 Mock 指南:借助 GlobalContainerContext 与 preview 全局装饰器统一替换页面容器
【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook
在 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 // 页面级 ContextProfilePageContext.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
【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考