ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

在 Storybook 中以 Provider 形式注入真实容器组件:Container/Context 分离模式的页面层 Mock 实战

在 Storybook 中以 Provider 形式注入真实容器组件:Container/Context 分离模式的页面层 Mock 实战 在 Storybook 中以 Provider 形式注入真实容器组件Container/Context 分离模式的页面层 Mock 实战【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook导读当组件从原子组件上升到页面Page层级时往往伴随着数据请求、浏览器 API、全局状态等已连接connected依赖直接放进 Storybook 渲染就必须挨个 Mock成本极高。Storybook 官方文档给出的一种解法是用 React/Solid 的 Context 作为容器组件的中转站——Storybook 里用故事的 Mock 版本替换容器应用里则通过ProfilePageContext.Provider注入真实实现。本文以docs/writing-stories/build-pages-with-storybook.mdx第 9 节为主线完整演示创建 Context → 展示组件消费容器 → Storybook 中 Mock → 应用侧 Provider 注入真实容器的端到端流程并补充对应的仓库代码片段作为可运行参考。为什么要在页面层避开直接 Mock 容器组件Storybook 支持构建从原子组件到组合页面的任何组件见 docs/writing-stories/build-pages-with-storybook.mdx。页面通常不会单独请求数据而是由内部各区块container完成。要渲染一个页面级 Story常见做法是对其依赖逐层 MockMocking modulesMock 组件文件中import进来的模块Mocking API services拦截 REST/GraphQL 网络请求Mocking providers用 Decorator 包裹并伪造 Context Provider 提供的值如主题、Redux 数据。这些方案各自有效但存在两个现实痛点繁琐容器组件可能内嵌在页面组件树的任意深度逐层传递数据、逐层 Mock import 会让工作量快速膨胀困难带有本地状态的容器组件本身很难被模拟Mock 往往无从下手。因此官方文档推荐一种结构性解法该段落在docs/writing-stories/build-pages-with-storybook.mdx#L62-L70先把页面做容器/展示的严格拆分再通过 Context 把需要使用哪些容器这件事从展示层解耦出来——页面组件以 Props/Context 拿到容器而非直接 import 它们。这样无论页面嵌套多深都可以在渲染前整体替换容器无需关心容器内部的数据请求与状态逻辑。模式总览应用侧一份真实、Storybook 侧一份 Mock整个模式由四个文件组成官方建议为每个页面或视图单独划分目录结构ProfilePage.js # 展示组件只负责布局通过 useContext 拿容器 ProfilePage.stories.js # 页面级 Story ProfilePageContainer.js # 容器组件真实数据逻辑网络请求等 ProfilePageContext.js # React/Solid Context 定义关键思想同一份ProfilePageContext在两侧提供不同实现。应用侧ProfilePageContext.Provider提供真实的UserPostsContainer、UserFriendsContainer见本主题关联片段 mock-context-container-provider.md在应用入口如pages/profile.js中使用Storybook 侧Provider 提供来自各子组件 Story 的Mock 版本组件往往本身就是理想的可替换实现见 mock-context-container.md若某容器几乎出现在每个页面可上升为全局容器 ContextGlobalContainerContext在.storybook/preview.js的全局 Decorator 中注入见 mock-context-container-global.md。第一步定义 Contextmock-context-create.mdContext 文件本身极其简单——它只导出一个空的 Context不包含任何业务逻辑。React 版本// ProfilePageContext.js import { createContext } from react; const ProfilePageContext createContext(); export default ProfilePageContext;Solid 版本仅换用solid-js的createContext// ProfilePageContext.js import { createContext } from solid-js; const ProfilePageContext createContext(); export default ProfilePageContext;该文件对应片段 mock-context-create.md。第二步展示组件通过 useContext 消费容器mock-context-in-use.mdProfilePage是纯展示组件它不做任何数据请求而是通过useContext从ProfilePageContext中取出两个容器组件再把来自上层 Props 的userId传交给它们// ProfilePage.jsReact 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 ); };Solid 版本几乎一致仅 Props 读取方式遵循 Solid 的props风格// ProfilePage.jsSolid import { useContext } from solid-js; import ProfilePageContext from ./ProfilePageContext; export const ProfilePage (props) { const { UserPostsContainer, UserFriendsContainer } useContext(ProfilePageContext); return ( div h1{props.name}/h1 UserPostsContainer userId{props.userId} / UserFriendsContainer userId{props.userId} / /div ); };可以看到只要 Provider 提供了容器页面组件就能像普通组件一样自由渲染它们。展示层完全不感知容器的真实实现是网络请求、Redux 连接还是本地状态这正是后续可以整容器替换的前提。对应片段见 mock-context-in-use.md。第三步在 Storybook 中提供 Mock 容器mock-context-container.md在 Story 文件中我们不再构造真实容器而是为ProfilePageContext.Provider传入Mock 实现。其中最常见的技巧Mock 版本直接复用子组件自身 Stories 里的导出。既然 Story 本身就是给定 Props 渲染该组件的场景它天然就是一个可复用的 React 元素工厂多数情况下足以充当容器的替身。// ProfilePage.stories.js import React from react; import { ProfilePage } from ./ProfilePage; import { UserPosts } from ./UserPosts; // 从故事文件中引入某个具体 story import { Normal as UserFriendsNormal } from ./UserFriends.stories; export default { component: ProfilePage, }; const ProfilePageProps { name: Jimi Hendrix, userId: 1, }; const context { // 如果需要在这里可以访问到 userId prop UserPostsContainer({ userId }) { return UserPosts {...ProfilePageProps} /; }, // 大多数情况下直接把 story 传进来即可。 // 这里传入的是 UserFriends 组件故事中的 normal story 导出。 UserFriendsContainer: UserFriendsNormal, }; export const Normal { render: () ( ProfilePageContext.Provider value{context} ProfilePage {...ProfilePageProps} / /ProfilePageContext.Provider ), };两种 Mock 形态各有适用场景Mock 方式写法适用情况直接传入 story 导出UserFriendsContainer: UserFriendsNormal容器 Mock 不需要感知页面级 Props最简单、最常用函数形式包裹UserPostsContainer({ userId }) { return UserPosts .../ }需要根据页面 Props如userId动态拼接数据或注入其他 Props 时完整片段见 mock-context-container.md。如果同一个 Provider 值适用于ProfilePage的所有 Story则不必在每个 Story 里重复包裹 Provider官方建议改用Decorator收敛逻辑参见 docs/writing-stories/decorators.mdx。第四步在应用中通过 Provider 注入真实容器本主题核心片段页面在 Storybook 中可渲染之后还需要保证应用真实运行时能拿到真实的容器组件。做法与第三步对称在应用渲染ProfilePageContainer的入口处用ProfilePageContext.Provider把真实容器放入 Context。本主题关联的代码片段即此场景见 docs/_snippets/mock-context-container-provider.md。以 Next.js 为例这通常位于pages/profile.js。React 版本// pages/profile.js import React from react; import ProfilePageContext from ./ProfilePageContext; import { ProfilePageContainer } from ./ProfilePageContainer; import { UserPostsContainer } from ./UserPostsContainer; import { UserFriendsContainer } from ./UserFriendsContainer; // 确保你的 context 值在每次渲染之间保持引用相等referentially equal。 const context { UserPostsContainer, UserFriendsContainer, }; export const AppProfilePage () { return ( ProfilePageContext.Provider value{context} ProfilePageContainer / /ProfilePageContext.Provider ); };Solid 版本// pages/profile.js import ProfilePageContext from ./ProfilePageContext; import { ProfilePageContainer } from ./ProfilePageContainer; import { UserPostsContainer } from ./UserPostsContainer; import { UserFriendsContainer } from ./UserFriendsContainer; // 确保你的 context 值在每次渲染之间保持引用相等referentially equal。 const context { UserPostsContainer, UserFriendsContainer, }; export const AppProfilePage () { return ( ProfilePageContext.Provider value{context} ProfilePageContainer / /ProfilePageContext.Provider ); };关键细节context 值为何必须模块级定义片段中特别用注释强调了一行容易忽略的最佳实践// Ensure that your context value remains referentially equal between each render. const context { UserPostsContainer, UserFriendsContainer, };context对象被定义在组件函数之外模块作用域而不是AppProfilePage内部。原因在于 React/Solid 中 Context 的性能语义若在组件体内创建对象{ UserPostsContainer, UserFriendsContainer }每一次渲染都会产生一个新的对象引用Provider 的value引用一旦变化所有订阅该 Context 的消费者组件都会触发重渲染页面级 Provider 包裹着整棵子树这将导致每次父组件重渲染都连带整棵页面子树不必要的重渲染。把对象提升到模块顶层就能保证每次渲染之间引用相等referentially equal从而避免上述连锁重渲染。若某容器集合需要在渲染期内变动则应当用useMemoReact等机制在合适的依赖前提下缓存该值而不是随手内联。第五步可选全局容器 Context 与 preview 的 Decorator如果某些容器几乎出现在应用的每个页面上如NavigationContainer为其逐一在页面入口创建 Provider 会很啰嗦。官方建议另建一个全局容器 Context如GlobalContainerContext放到应用顶层只提供全局必需的容器具体做法参见 mock-context-container-global.md。在 Storybook 侧则在.storybook/preview.js中导出一个全局 Decorator把来自Navigation.stories.js的normalstory 作为NavigationContainer注入所有 Story// .storybook/preview.jsReactCSF 3 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] };TypeScript ReactCSF 3版本会额外标注组件类型// .storybook/preview.tsReactCSF 3 import * as React from react; // 将 your-framework 替换为你所用的框架如 react-vite、nextjs、nextjs-vite 等。 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;其中storybook/your-framework为文档中的占位写法实际项目中应替换为具体框架入口如storybook/react-vite、storybook/nextjs。使用实验性 CSF Next 语法时用definePreview包装相同的 Decorator 配置见 mock-context-container-global.md。与Mocking Providers路径的对比与取舍本模式本质上是对 Mocking providers 的一种结构性延伸。常规 Provider Mock如主题 Provider只需用 Decorator 包裹组件并伪造一个静态值若需要按 Story 差异化取值可借助 Decorator 的第二个context参数读取该 Story 的parameters从而一次定义、按 Story 调参见 mock-provider-in-preview.md 与 mocking-providers.mdx。而 Container Context 模式更进一步Context 中存放的不是值而是组件本身。它要解决的不是给组件喂一份假数据而是让页面在任意深度都能无痛地换掉数据与状态逻辑所在的那一层组件。二者相辅相成Container Context 负责把真实/虚假实现的选择权上移Provider Mock 负责在选定实现后控制其内部数据。小结这套模式带来的实际收益回到 docs/writing-stories/build-pages-with-storybook.mdx 的上下文Container/Context 模式的核心收益可归纳为页面级 Story 无需关心数据依赖——只需在 Story 中把容器替换为各自的故事即可渲染真实页面布局复用而非重写——Mock 版本大多直接取自子组件 Stories遵循 DRYStory 维护成本低深度嵌入无压力——容器以 Props/Context 传递无论在页面组件树多深处都可整体替换应用侧对称注入——真实入口pages/profile.js用 Provider 包住真实容器即可展示组件代码零改动配合 Decorator 进一步收敛——重复性 Provider 包裹可提升为 Story 级或全局级 Decorator。从 mock-context-create.mdContext 定义、mock-context-in-use.md展示组件消费、mock-context-container.mdStorybook 侧 Mock、mock-context-container-provider.md应用侧真实 Provider到 mock-context-container-global.md全局容器五段片段即构成该模式的最小可运行闭环可直接对照实现到自己的 React 或 Solid 项目中。【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进