Storybook 构建页面级组件:用 React/Solid Context 注入容器组件,避免层层 Mock 依赖
导读
在 Storybook 中渲染"连接型组件"(依赖数据请求、上下文、浏览器环境的页面级容器组件)是组件测试与开发中最棘手的环节之一。本文以 Storybook 文档仓库中的代码片段 mock-context-in-use.md 为核心,讲解一种被官方推荐的架构方案:把容器组件注入到 React Context / Solid 的 Context 中,让展示型页面组件通过 useContext 消费它们,从而在编写 Story 时只需把容器替换成"来自 Story 的演示实现",不必再为每个容器的内部依赖逐一 Mock。读完本文,你将掌握一套从组件拆分、Context 设计到 Storybook 与真实应用双端注入的完整实操模板。
问题的根源:连接型组件在 Storybook 里为什么难渲染
在组件层级中越往上走(从原子组件到页面级组件),组件就越可能依赖外部世界。官方文档 build-pages-with-storybook.mdx 把常见的页面构建方式划分为两类:
- 纯展示型页面(Pure presentational pages):页面只通过 props 接收数据,渲染与外部完全解耦,Storybook 无需任何特殊处理即可展示。
- 连接型组件(Connected components):组件直接发网络请求、读取 Context、依赖浏览器环境,渲染这类组件时必须 Mock 其所依赖的数据或模块。
针对后者,Storybook 提供了三个不同层面的 Mock 手段,可以按需组合使用:
| 层面 | 解决什么 | 对应文档 |
|---|---|---|
| 模块导入 | 组件文件里 import 的第三方包或项目内部模块 |
Mocking imports |
| API 服务 | 组件内发起的 REST / GraphQL 网络请求 | Mocking API Services |
| Context 提供者 | 组件从 Context 读取主题、用户态、业务数据等 | Mocking providers |
如果页面代码把"数据获取逻辑"和"DOM 渲染"写在同一个组件里,无论用上面哪种方式 Mock 都会很繁琐;尤其当容器组件内部还使用了本地 state 时,Mock 的代价会迅速膨胀。因此需要从组件结构设计上绕开这个问题。
核心理念:用 Context 分发容器组件,而不是直接 import 容器
官方推荐的思路是:不要让展示型组件直接 import 并嵌入容器组件,而是创建一个专用的 React / Solid Context,把"页面所需的一组容器组件"通过 Provider 注入下去。展示型组件在任何层级都可以自由嵌入这些容器,而渲染 Story 时只需要把 Context 里的真实容器整体替换成它们的 Mock 演示版,完全不需要关心容器内部还依赖了什么。
具体到一个 ProfilePage 页面,官方给出的推荐文件结构是:
ProfilePage.js // 展示型页面组件(本文核心)
ProfilePage.stories.js // 页面的 Story 文件
ProfilePageContainer.js // 负责数据获取/业务逻辑的容器
ProfilePageContext.js // 分发容器组件的 Context
也就是说,把容器在页面/视图粒度上进行划分:ProfilePageContainer.js 负责数据逻辑,ProfilePage.js 只负责呈现,二者通过 ProfilePageContext.js 解耦。若某个容器组件几乎每个页面都要用(例如导航栏),还可以额外建立一个全局的 GlobalContainerContext,只把真正全局必需的容器放进去,并挂在应用顶层。
第一步:创建一个只导出 Context 的文件
ProfilePageContext.js 的内容极其精简,它不携带任何业务数据,职责只有一个——成为传递容器组件的通道。示例见 mock-context-create.md:
import { createContext } from 'react';
const ProfilePageContext = createContext();
export default ProfilePageContext;
import { createContext } from 'solid-js';
const ProfilePageContext = createContext();
export default ProfilePageContext;
从源码片段可以看到,React 与 Solid 两个版本在结构上完全对称,只是 createContext 的导入来源不同。这里没有传默认值(createContext() 不接收参数),意味着 Context 的实际内容完全由上层的 Provider 在运行时注入。
第二步:展示型页面通过 useContext 消费容器(核心代码)
这是整个方案的主角。ProfilePage.js 是纯展示组件,它不 import 任何容器,而是用 useContext 从 ProfilePageContext 中取出两个容器组件 UserPostsContainer 与 UserFriendsContainer,再像使用普通组件一样把它们渲染出来。完整代码见 mock-context-in-use.md。
React 版本(使用 react 的 useContext,props 以解构方式接收):
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 版本(使用 solid-js 的 useContext,通过统一的 props 对象访问参数):
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>
);
};
逐行理解这段代码的关键点:
- Context 中存的是"组件"而非"数据":
useContext(ProfilePageContext)解构出来的是UserPostsContainer、UserFriendsContainer这样的组件引用,随后被直接放到 JSX 里渲染。这与传统"Context 提供主题色/用户对象"的用法不同,是这套方案的精髓。 - 展示组件只关心"接口契约":
ProfilePage只知道容器暴露的 props 签名(这里仅需userId),并不关心容器的真实来源。无论上层注入的是真实实现还是 Story 里的演示实现,它都能渲染。 - 页面仍保持 presentational 特性:
name、userId作为普通 props 传入,让页面数据部分依旧可控、可复用 args composition 等 Storybook 工具链能力。 - 附带说明:这两段代码在仓库中带
renderer="react"/renderer="solid"元信息,Storybook 文档系统会按渲染器为不同技术栈的读者自动挑选对应版本,因此两个变体必须保持行为一致、语法各自贴合框架规范(例如 Solid 中访问props.name)。
第三步:在 Storybook 里用"Story 版容器"替换真实容器
编写 ProfilePage.stories.js 时,不再需要为 UserPostsContainer、UserFriendsContainer 的内部数据请求做任何 Mock,直接把它们替换成演示实现即可。更妙的是,这些 Mock 版容器往往可以直接借用它们自己的 Story。参考 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>
),
};
这段代码展示了两种注入 Mock 容器的典型写法:
- 以函数形式内联:
UserPostsContainer({ userId }) { return <UserPosts {...UserPostsProps} />; }。当容器在渲染时需要访问userId之类的动态参数时,用这种函数形式最灵活;userId由ProfilePage在 JSX 里传进来,可读可复用。 - 直接引用现成 Story:
UserFriendsContainer: UserFriendsNormal。如果某个容器已有内容合适的 Story(此处是UserFriends.stories中导出的Normal故事),绝大多数情况下可以直接把整个 Story 当作容器的 Mock 实现,无需重写。
两点实用提示:
- 若同一个 Context 对
ProfilePage的所有 Story 都适用,可以考虑把 Provider 提升为 decorator,避免在每个 Story 里重复包裹,详见 mock-context-container-global.md。 - 由于
ProfilePage只依赖 props 与 Context,controls 面板等基于 args 的工具都能正常工作,页面级组件的调试体验与小组件一致。
第四步:在真实应用中注入真实容器
Storybook 里注入的是 Mock 容器,真实应用里则要注入 ProfilePageContainer 及其所依赖的真实容器。在 Next.js 中,这通常发生在 pages/profile.js 路由组件里,用 ProfilePageContext.Provider 把 ProfilePage(或直接使用 ProfilePageContainer)包裹起来。参考 mock-context-container-provider.md:
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)。因此 context 对象被定义在组件外部(模块顶层)作为常量复用,而不是在渲染函数内联创建对象字面量,否则每次渲染都会生成新引用,可能触发不必要的重渲染或子组件状态丢失。Solid 版本的结构与之相同,差别仅在于导入来源(solid-js)与组件签名。
由此也印证了整套方案的运行时形态:ProfilePageContext 本身是"空"的(无默认值),真正决定页面看到哪一组容器的是外层 Provider——Storybook 环境注入 Story 版,生产应用注入真实版。展示组件、Story、生产代码三端解耦,各自独立演进。
进阶:为所有 Story 统一注入全局容器
像导航栏这类几乎出现在所有页面上的容器,若每个页面的每个 Story 都手动包一次 Provider 显然冗余。官方建议把它们收敛到一个 GlobalContainerContext,并在 Storybook 的 .storybook/preview.js 中通过全局 decorator 一次性提供。参考 mock-context-container-global.md:
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] };
要点拆解:
- 与页面级 Context 相同的套路:导航容器的 Mock 直接取自
Navigation.stories的Normal导出(Storybook 的 CSF 导出天然是可复用组件,这是整套方案零额外成本的根源)。 - decorator 必须包裹
storyFn(),保证所有 Story 都在该 Provider 的作用域内渲染。 - 原文档同时给出了
preview.ts(CSF 3 类型化写法)与基于definePreview的 CSF Next 实验性写法等变体;在.ts环境下,@storybook/your-framework需要替换为实际框架包(如@storybook/react-vite、@storybook/nextjs),且preview.ts需要从 CSF 导出preview配置对象。global context 的使用原则是"宁缺毋滥"——它只应承载真正全局必需的容器,其余容器仍建议按页面粒度分发,避免把所有容器堆进一个巨型全局 Context。
收益与适用前提小结
| 维度 | 说明 |
|---|---|
| 收益一 | 无需再为容器组件内部的数据请求、本地 state 编写 Mock,容器可被整体替换 |
| 收益二 | Story 中的 Mock 容器多数可直接复用组件自身的 Story,遵循 DRY(Don't-Repeat-Yourself)原则 |
| 收益三 | 展示组件保持 presentational 形态,完整享受 args、controls 等 Storybook 工具链能力 |
| 适用前提 | 要求严格的"容器 / 展示"逻辑拆分:数据获取与渲染 DOM 混合在同一个组件里时,本方案不适用,仍需按前文的三种 Mock 手段处理 |
| 注意事项 | Context value 需保证引用相等;仅将全局必需容器放入 GlobalContainerContext |
本文所有示例与说明均出自 build-pages-with-storybook.mdx 及其配套代码片段(create、in-use、container、provider、global)。将这套文件结构、Context 注入与"Story 即 Mock"的组合应用到你的下一个页面级组件上,即可显著降低 Storybook 中连接型组件的 Mock 成本。
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 StartedRust0630
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
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