在一个大型前端项目中,维护一个庞大且一致的组件库是确保产品质量与开发效率的基石。Storybook 在这方面提供了无与伦比的组件可视化与隔离开发环境。然而,当组件变得复杂,开始深度依赖后端数据时,一个棘手的难题浮出水面:如何为这些组件提供稳定、真实且易于维护的数据模拟?
传统的方案,例如在 Storybook 故事中硬编码 JSON 对象,或者使用 msw
(Mock Service Worker) 在客户端拦截请求,都有其明显的局限性。
方案A:静态 JSON 与客户端拦截
- 优势:
- 实现简单,无需任何后端基础设施。
- 对于简单、数据结构固定的组件,响应速度极快。
- 劣势:
- 数据同步地狱: API 的 GraphQL Schema 一旦变更,所有相关的静态 JSON Mock 都需要手动更新,这在快速迭代的团队中几乎是灾难性的。
- 逻辑缺失: 无法模拟真实的后端业务逻辑,例如分页、排序、搜索,或者基于输入返回不同结构的数据。
- 状态模拟困难: 难以模拟真实的网络延迟、loading 状态以及各种边界错误(如401、403、500)。这导致大量在生产环境中才会暴露的 UI bug。
- 团队协作障碍: Mock 数据散落在各个 Story 文件中,无法复用,不同开发者可能为同一个组件创建不同的 Mock,导致不一致。
在真实项目中,这些劣势很快就会压垮开发体验。我们需要一个更接近生产环境,但又足够轻量、低成本的解决方案。
方案B:专用于开发的 Serverless GraphQL 模拟层
这个方案的核心思想是为 Storybook 专门部署一个独立的、无状态的 Serverless GraphQL 端点。这个端点使用与生产环境完全相同的 GraphQL Schema,但其解析器 (Resolver) 返回的是由 faker.js
等库生成的、结构稳定但内容随机的模拟数据。
优势:
- Schema 单一事实源: 模拟端点与生产环境共享同一份
schema.graphql
。任何对 Schema 的破坏性变更都会在 CI/CD 阶段的类型检查中被发现,从根本上解决了数据同步问题。 - 高保真模拟: Serverless 函数可以轻松模拟网络延迟、随机抛出错误、实现复杂的数据生成逻辑,让组件在开发阶段就经历更严酷的考验。
- 零运维成本: 基于 AWS Lambda 或其他云厂商的函数计算,按需付费。在无人使用 Storybook 时(例如夜晚或周末),成本几乎为零。
- 环境一致性: 所有开发者都连接到同一个模拟端点,确保了开发环境的一致性,也为 UI 自动化测试(如视觉回归测试)提供了稳定的数据源。
- Schema 单一事实源: 模拟端点与生产环境共享同一份
劣势:
- 初始设置成本: 需要配置
serverless.yml
,编写 GraphQL 解析器,并建立部署流水线。 - 冷启动延迟: Serverless 函数的首次调用可能存在冷启动问题,但这对于开发环境通常是可以接受的。
- 初始设置成本: 需要配置
权衡之下,对于一个追求长期可维护性和高质量开发者体验的团队而言,方案 B 的前期投入是完全值得的。它将组件开发从“猜测数据结构”的泥潭中解放出来,使其聚焦于 UI 逻辑本身。
架构决策与实现概览
我们的最终架构选择是方案 B。其核心工作流程如下:
graph TD subgraph "开发者环境" A[开发者] --> B{Storybook UI}; B --> C[React 组件]; C --> D[Apollo Client]; end subgraph "云端 (AWS)" E[API Gateway] --> F[AWS Lambda]; F -- "执行 GraphQL 解析器" --> G[Mocking Logic / Faker.js]; G -- "返回模拟数据" --> F; F -- "响应" --> E; end D -- "GraphQL Query/Mutation" --> E; subgraph "代码仓库 (Git)" H[schema.graphql] --> F; H --> I[生产环境 GraphQL Server]; H --> J[前端代码类型生成]; end style F fill:#f9f,stroke:#333,stroke-width:2px style H fill:#bbf,stroke:#333,stroke-width:2px
这个架构的关键在于 schema.graphql
文件,它是连接生产与开发的桥梁。前端、生产后端和模拟后端都依赖于它,确保了类型安全与接口契约的一致性。
核心实现:Serverless GraphQL 模拟端点
我们将使用 apollo-server-lambda
和 serverless
框架在 AWS Lambda 上快速部署一个 GraphQL 端点。
1. 项目结构
storybook-mock-server/
├── mocks/ # Mock 数据生成逻辑
│ └── user.mocks.ts
├── resolvers/ # GraphQL 解析器
│ └── user.resolvers.ts
├── serverless.yml # Serverless 框架配置
├── src/
│ └── handler.ts # Lambda 入口文件
├── package.json
├── tsconfig.json
└── schema.graphql # 共享的 GraphQL Schema
2. schema.graphql
这是我们整个架构的基石。
type Query {
"""
获取当前用户信息
"""
currentUser: User
"""
根据 ID 获取用户,如果找不到会模拟一个错误
"""
user(id: ID!): User
}
type User {
id: ID!
name: String!
email: String!
avatarUrl: String
status: UserStatus!
createdAt: String!
}
enum UserStatus {
ACTIVE
INACTIVE
PENDING
}
3. serverless.yml
配置
此文件定义了我们的服务、函数、触发器和环境变量。
service: storybook-mock-service
frameworkVersion: '3'
provider:
name: aws
runtime: nodejs18.x
stage: ${opt:stage, 'dev'}
region: us-east-1
# 配置 Lambda 函数的内存和超时
memorySize: 256
timeout: 10
# IAM 角色权限,如果需要访问其他 AWS 服务
# iam:
# role:
# statements:
# - Effect: 'Allow'
# Action:
# - 's3:GetObject'
# Resource: 'arn:aws:s3:::my-bucket/*'
functions:
graphql:
handler: src/handler.graphqlHandler
events:
- http:
path: graphql
method: post
cors: true
- http:
path: graphql
method: get
cors: true # 允许 OPTIONS 请求以进行 CORS 预检
plugins:
- serverless-webpack
- serverless-offline # 用于本地开发测试
custom:
webpack:
webpackConfig: ./webpack.config.js
includeModules: true
4. 模拟数据与解析器
我们使用 faker-js
来生成逼真的模拟数据。
mocks/user.mocks.ts
:
import { faker } from '@faker-js/faker';
// 定义 User 类型,与 GraphQL Schema 对应
export interface GQLUser {
id: string;
name: string;
email: string;
avatarUrl: string;
status: 'ACTIVE' | 'INACTIVE' | 'PENDING';
createdAt: string;
}
// 工厂函数,用于生成单个用户的 mock 数据
export const createMockUser = (): GQLUser => ({
id: faker.string.uuid(),
name: faker.person.fullName(),
email: faker.internet.email(),
avatarUrl: faker.image.avatar(),
status: faker.helpers.arrayElement(['ACTIVE', 'INACTIVE', 'PENDING']),
createdAt: faker.date.past().toISOString(),
});
resolvers/user.resolvers.ts
:
import { GraphQLError } from 'graphql';
import { createMockUser, GQLUser } from '../mocks/user.mocks';
// 模拟一个轻量的内存数据库
const mockUserDatabase = new Map<string, GQLUser>();
const currentUser = createMockUser();
mockUserDatabase.set(currentUser.id, currentUser);
mockUserDatabase.set('known-user-id', createMockUser());
export const userResolvers = {
Query: {
currentUser: async (): Promise<GQLUser> => {
// 模拟 300ms 的网络延迟
await new Promise(resolve => setTimeout(resolve, 300));
console.log('Fetching current user...');
return currentUser;
},
user: async (_: any, { id }: { id: string }): Promise<GQLUser> => {
// 模拟 150ms 的网络延迟
await new Promise(resolve => setTimeout(resolve, 150));
// 模拟一个特定的错误场景
if (id === 'not-found') {
console.error(`User with id ${id} not found.`);
throw new GraphQLError('User not found in mock database', {
extensions: {
code: 'NOT_FOUND',
http: { status: 404 },
},
});
}
const user = mockUserDatabase.get(id);
if (user) {
return user;
}
// 如果数据库中没有,动态生成一个并存入,以保证多次请求 ID 不变时数据一致
const newUser = createMockUser();
mockUserDatabase.set(id, newUser);
return newUser;
},
},
// 如果有复杂的字段需要解析,可以在这里添加
// User: {
// ...
// }
};
5. Lambda 入口 src/handler.ts
这是将所有部分粘合在一起的地方。
import { ApolloServer } from '@apollo/server';
import { startServerAndCreateLambdaHandler, handlers } from '@as-integrations/aws-lambda';
import { readFileSync } from 'fs';
import { userResolvers } from '../resolvers/user.resolvers';
// 从文件中读取 Schema,确保与代码仓库中的源文件一致
const typeDefs = readFileSync('./schema.graphql', 'utf-8');
const resolvers = {
Query: {
...userResolvers.Query,
},
};
const server = new ApolloServer({
typeDefs,
resolvers,
// 在非生产环境开启内省是安全的,便于调试
introspection: process.env.NODE_ENV !== 'production',
});
// 使用 startServerAndCreateLambdaHandler 创建 Lambda handler
// 这是一个推荐的、更现代的集成方式
export const graphqlHandler = startServerAndCreateLambdaHandler(
server,
{
// 可以在这里传递 context,例如从请求头中获取认证信息
context: async ({ event }) => {
return {
lambdaEvent: event,
};
},
},
);
部署这个 Serverless 应用后 (sls deploy --stage dev
),我们将得到一个可用的 GraphQL API 端点,例如 https://xxxx.execute-api.us-east-1.amazonaws.com/dev/graphql
。
核心实现:Storybook 与 Apollo Client 的集成
现在,我们需要让 Storybook 中的组件能够消费这个云端模拟 API。
1. 安装依赖
npm install @apollo/client graphql
2. 配置 Apollo Client Provider
我们需要在 Storybook 的全局 preview.js
中创建一个装饰器 (Decorator),它会将所有 Story 都包裹在 ApolloProvider
之中。
.storybook/preview.js
:
import React from 'react';
import {
ApolloClient,
InMemoryCache,
ApolloProvider,
HttpLink,
} from '@apollo/client';
// 从环境变量中获取 API 地址,这样可以在不同环境(本地、CI、Review App)中使用不同的 mock server
// STORYBOOK_GRAPHQL_API_URL=.env 文件中设置
const graphqlApiUrl = process.env.STORYBOOK_GRAPHQL_API_URL || 'http://localhost:4000/graphql';
// 这里的配置需要根据实际项目来,比如添加 auth link 等
// 这是一个生产级的配置,包含了错误处理和自定义 header
const createApolloClient = () => {
const httpLink = new HttpLink({
uri: graphqlApiUrl,
headers: {
// 模拟认证 token
'Authorization': `Bearer mock-token-for-storybook`,
},
});
return new ApolloClient({
link: httpLink,
cache: new InMemoryCache(),
// 默认查询策略,避免在 Storybook 中出现缓存问题
defaultOptions: {
watchQuery: {
fetchPolicy: 'no-cache',
},
query: {
fetchPolicy: 'no-cache',
},
},
});
};
// 全局装饰器
export const decorators = [
(Story) => {
// 每次 story 渲染时都创建一个新的 client 实例
// 避免不同 story 之间的缓存污染
const client = createApolloClient();
return (
<ApolloProvider client={client}>
<Story />
</ApolloProvider>
);
},
];
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
};
这里的关键点是,每次 Story 渲染时都创建一个新的 Apollo Client 实例。这是一个常见的陷阱,如果不这样做,一个 Story 中的缓存数据可能会泄露到另一个 Story 中,导致难以调试的渲染问题。
3. 编写数据驱动的组件和 Story
components/UserProfile.tsx
:
import React from 'react';
import { useQuery, gql } from '@apollo/client';
const GET_USER_QUERY = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
avatarUrl
status
}
}
`;
interface UserProfileProps {
userId: string;
}
export const UserProfile: React.FC<UserProfileProps> = ({ userId }) => {
const { loading, error, data } = useQuery(GET_USER_QUERY, {
variables: { id: userId },
});
if (loading) {
return <div className="profile-card skeleton">Loading profile...</div>;
}
if (error) {
// 生产级的错误处理,应该展示一个用户友好的错误信息
return (
<div className="profile-card error">
<h3>Error loading profile</h3>
<pre>{error.message}</pre>
</div>
);
}
const { user } = data;
return (
<div className={`profile-card status-${user.status.toLowerCase()}`}>
<img src={user.avatarUrl} alt={user.name} />
<div className="details">
<h2>{user.name}</h2>
<p>{user.email}</p>
<span>Status: {user.status}</span>
</div>
</div>
);
};
components/UserProfile.stories.tsx
:
import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { UserProfile } from './UserProfile';
export default {
title: 'Components/UserProfile',
component: UserProfile,
} as ComponentMeta<typeof UserProfile>;
const Template: ComponentStory<typeof UserProfile> = (args) => <UserProfile {...args} />;
export const Default = Template.bind({});
Default.args = {
// 我们只需要传递 ID,数据由组件自己通过 Apollo 获取
userId: 'known-user-id',
};
export const LoadingState = Template.bind({});
LoadingState.args = {
userId: 'known-user-id',
};
// Storybook 的 Mock Provider 可以用来覆盖全局的 Apollo Client
// 以便精确控制单个 story 的状态,比如强制 loading
LoadingState.parameters = {
apolloClient: {
// `mocked` 选项会使 Apollo Client 永远处于加载状态
mocked: true,
},
};
export const NotFoundError = Template.bind({});
NotFoundError.args = {
// 我们在 resolver 中定义了 'not-found' 会抛出错误
userId: 'not-found',
};
注意看这些 Story。它们极其简洁,没有一行是关于模拟数据的。开发者只需关心向组件传递什么 props
(在这里是 userId
),组件的行为(加载、错误、成功)则完全由后端的模拟层决定。这使得 Story 的编写和维护成本大大降低。
架构的扩展性与局限性
扩展路径
- 动态模拟代理: Serverless 模拟层可以增加一个“代理模式”。通过一个特定的 HTTP Header 或 GraphQL context 参数,它可以选择性地将请求转发到真实的 Staging 或 QA 环境的 GraphQL 服务器,并将结果返回。这允许开发者在 Storybook 中无缝切换真实数据与模拟数据,以进行更深入的调试。
- 集成 CI/CD: 这个 Serverless Mock Server 可以被集成到 CI 流水线中。在每次提交时,运行针对 Storybook 的视觉回归测试(例如用 Chromatic 或 Percy)。由于数据源稳定且高保真,这些测试会变得非常可靠,能有效防止 UI 回归。
- 快照式 Mock: 对于需要绝对确定性数据的测试场景,可以增强 Mock Server,使其能够记录一次真实 API 的响应,并将其作为快照保存。后续请求可以配置为返回这个快照,而不是每次都动态生成。
当前方案的局限性
- 基础设施依赖: 该方案引入了对云服务(如 AWS Lambda)的依赖,增加了项目初始化的复杂性。对于小型项目或个人开发者来说,这可能是过度设计。
- 冷启动问题: 虽然对开发环境影响不大,但 Lambda 的冷启动确实会使第一次加载某个组件时有可感知的延迟(通常是几百毫秒到一两秒)。
- 状态管理: 我们的模拟层是无状态的。执行一个
mutation
后,后续的query
不会反映这个变化。要实现有状态的模拟会极大地增加其复杂性,通常需要引入一个轻量级数据库(如 Redis 或 DynamoDB),这可能超出了“轻量级模拟层”的范畴。在真实项目中,这通常通过在 Storybook 的控件 (controls) 中提供一个“重置状态”按钮来解决,该按钮会重新加载组件,获取全新的模拟数据。