首页
/ Storybook 构建页面级组件:用 React/Solid Context 注入容器组件,避免层层 Mock 依赖

Storybook 构建页面级组件:用 React/Solid Context 注入容器组件,避免层层 Mock 依赖

2026-09-07 11:03:51作者:钟日瑜

导读

在 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 任何容器,而是用 useContextProfilePageContext 中取出两个容器组件 UserPostsContainerUserFriendsContainer,再像使用普通组件一样把它们渲染出来。完整代码见 mock-context-in-use.md

React 版本(使用 reactuseContext,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-jsuseContext,通过统一的 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) 解构出来的是 UserPostsContainerUserFriendsContainer 这样的组件引用,随后被直接放到 JSX 里渲染。这与传统"Context 提供主题色/用户对象"的用法不同,是这套方案的精髓。
  • 展示组件只关心"接口契约"ProfilePage 只知道容器暴露的 props 签名(这里仅需 userId),并不关心容器的真实来源。无论上层注入的是真实实现还是 Story 里的演示实现,它都能渲染。
  • 页面仍保持 presentational 特性nameuserId 作为普通 props 传入,让页面数据部分依旧可控、可复用 args composition 等 Storybook 工具链能力。
  • 附带说明:这两段代码在仓库中带 renderer="react" / renderer="solid" 元信息,Storybook 文档系统会按渲染器为不同技术栈的读者自动挑选对应版本,因此两个变体必须保持行为一致、语法各自贴合框架规范(例如 Solid 中访问 props.name)。

第三步:在 Storybook 里用"Story 版容器"替换真实容器

编写 ProfilePage.stories.js 时,不再需要为 UserPostsContainerUserFriendsContainer 的内部数据请求做任何 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 之类的动态参数时,用这种函数形式最灵活;userIdProfilePage 在 JSX 里传进来,可读可复用。
  • 直接引用现成 StoryUserFriendsContainer: UserFriendsNormal。如果某个容器已有内容合适的 Story(此处是 UserFriends.stories 中导出的 Normal 故事),绝大多数情况下可以直接把整个 Story 当作容器的 Mock 实现,无需重写。

两点实用提示:

  1. 若同一个 Context 对 ProfilePage 的所有 Story 都适用,可以考虑把 Provider 提升为 decorator,避免在每个 Story 里重复包裹,详见 mock-context-container-global.md
  2. 由于 ProfilePage 只依赖 props 与 Context,controls 面板等基于 args 的工具都能正常工作,页面级组件的调试体验与小组件一致。

第四步:在真实应用中注入真实容器

Storybook 里注入的是 Mock 容器,真实应用里则要注入 ProfilePageContainer 及其所依赖的真实容器。在 Next.js 中,这通常发生在 pages/profile.js 路由组件里,用 ProfilePageContext.ProviderProfilePage(或直接使用 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.storiesNormal 导出(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 及其配套代码片段(createin-usecontainerproviderglobal)。将这套文件结构、Context 注入与"Story 即 Mock"的组合应用到你的下一个页面级组件上,即可显著降低 Storybook 中连接型组件的 Mock 成本。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
594
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
916
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
516
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388