
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.mdimport { createContext } from react; const ProfilePageContext createContext(); export default ProfilePageContext;展示组件通过useContext取出容器组件并渲染见 mock-context-in-use.mdimport { 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.jsimport 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.mdimport 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 were 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|jsximport * 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|tsximport * 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.jsimport { 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.tsimport { 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/useContextSolid 用solid-js的对应 APIProvider 的 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.mdxdefinePreview的类型定义与框架重新导出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),仅供参考