项目 CI 的执行时间从稳定的 15 分钟悄然爬升到了 25 分钟,偶尔甚至触及 30 分钟的阈值,这是我们必须正视的问题。在一个包含多个前端应用、共享组件库和一个中心化 GraphQL schema 的 pnpm monorepo 中,根本的瓶颈并非测试执行本身,而是前置的 relay-compiler 步骤。每一次微小的代码提交,无论是否触及数据获取逻辑,都会触发对整个项目范围的 Relay 代码生成,这不仅耗时,而且冗余。更棘手的是,一旦 schema.graphql 文件发生变更,下游所有依赖该 schema 的组件测试都可能因类型不匹配而连锁失败,定位问题变得异常困难。
我们需要一个更智能的工作流。这个工作流必须能够区分代码变更和 Schema 变更,并据此执行不同的策略:
- 对于纯应用代码变更: 最大化利用缓存,跳过不必要的
relay-compiler,只运行受影响项目的单元测试。 - 对于 GraphQL Schema 变更: 识别这是一个高风险操作,强制重新执行
relay-compiler,更新所有相关的类型定义,并运行一个完整的、覆盖所有应用的集成测试套件,确保契约没有被破坏。
这个工作流的核心将围绕 GitHub Actions 构建,利用其缓存和条件执行能力,结合 pnpm 对 monorepo 的高效管理,以及 Jest 对 Relay 环境的模拟测试能力,打造一个真正为现代前端工程服务的 CI 管道。
项目结构与基础设定
我们的起点是一个典型的 pnpm monorepo 结构。这种结构对于依赖管理和代码复用至关重要,但同时也给 CI 带来了复杂性。
# 文件结构概览
.
├── .github/
│ └── workflows/
│ └── ci.yml # 我们将要构建的核心工作流
├── packages/
│ ├── app-alpha/ # 第一个前端应用
│ │ ├── src/
│ │ │ └── components/
│ │ │ └── UserProfile.tsx
│ │ │ └── __generated__/
│ │ ├── package.json
│ │ └── jest.config.js
│ ├── app-beta/ # 第二个前端应用
│ │ ├── ...
│ ├── design-system/ # 共享UI组件库
│ │ ├── ...
│ └── graphql-schema/ # 中心化的GraphQL Schema定义
│ ├── package.json
│ └── schema.graphql
├── package.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
└── relay.config.js
pnpm-workspace.yaml 定义了工作区的范围:
# pnpm-workspace.yaml
packages:
- 'packages/*'
relay.config.js 是 relay-compiler 的心脏,它指定了 schema 的位置和生成的代码存放路径。
// relay.config.js
module.exports = {
// ...
src: './packages',
schema: './packages/graphql-schema/schema.graphql',
exclude: ['**/node_modules/**', '**/__mocks__/**', '**/__generated__/**'],
language: 'typescript',
artifactDirectory: './__generated__', // 在每个组件目录下生成
};
在根 package.json 中,我们定义了全局脚本。注意 relay 脚本,它是我们优化的主要目标。
// package.json
{
"scripts": {
"relay": "pnpm -r --filter './packages/**' exec relay-compiler",
"test": "pnpm -r test",
"build": "pnpm -r build"
},
"devDependencies": {
"relay-compiler": "^15.0.0",
"typescript": "^5.0.0",
"jest": "^29.0.0",
// ... 其他依赖
}
}
第一阶段:一个朴素但低效的CI工作流
在优化之前,我们先看一下当前的工作流。它很简单,也很低效,但它是我们改进的基准。
# .github/workflows/ci-naive.yml
name: Naive CI
on:
push:
branches:
- main
pull_request:
jobs:
build_and_test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup PNPM
uses: pnpm/action-setup@v2
with:
version: 8
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Run Relay Compiler
run: pnpm relay
- name: Run tests
run: pnpm test
这个工作流的问题显而易见:
- 无差别编译: 每次
push都会执行pnpm relay,即使没有任何 GraphQL 查询或 Schema 变更。在一个大型项目中,这一步可能耗时数分钟。 - 无差别测试:
pnpm test会运行所有包的测试,即便改动只发生在一个独立的包里。 - 缓存利用不足: 虽然
setup-node提供了pnpm的依赖缓存,但它没有缓存relay-compiler的产物。
第二阶段:引入智能缓存与路径过滤
优化的第一步是引入更精细的缓存机制,并根据文件变更路径来决定执行哪些任务。我们将工作流拆分为两个核心作业:detect-changes 和 build-and-test。
detect-changes 作业的唯一目标是识别出变更的文件类型。我们将特别关注 schema.graphql 的变化。
# .github/workflows/ci.yml (片段 1 - 变更检测)
name: Smart CI for Relay Monorepo
on:
push:
branches:
- main
pull_request:
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
schema_changed: ${{ steps.filter.outputs.schema_changed }}
# 可以扩展输出更多变更类型,比如 design-system_changed
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # 获取完整历史以进行比较
# 使用 dorny/paths-filter 来检测文件路径变更
# 这是一个非常强大的 action,可以定义多个过滤规则
- name: Check for file changes
id: filter
uses: dorny/paths-filter@v2
with:
filters: |
schema_changed:
- 'packages/graphql-schema/schema.graphql'
现在,build-and-test 作业可以利用 detect-changes 的输出结果来决定其行为。这里的关键是 relay-compiler 产物的缓存策略。缓存的 key 不仅要依赖 pnpm-lock.yaml,更要依赖 schema.graphql 文件的内容哈希。这样,只有在 schema 变化时,缓存才会失效。
# .github/workflows/ci.yml (片段 2 - 构建与测试作业)
build-and-test:
needs: detect-changes
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup PNPM
uses: pnpm/action-setup@v2
with:
version: 8
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
# 缓存 Relay 编译产物
# key 的设计是这里的核心
- name: Cache Relay artifacts
id: cache-relay
uses: actions/cache@v3
with:
path: '**/__generated__'
key: ${{ runner.os }}-relay-${{ hashFiles('**/schema.graphql') }}-${{ hashFiles('**/relay.config.js') }}
restore-keys: |
${{ runner.os }}-relay-${{ hashFiles('**/schema.graphql') }}-
# 只有在 Schema 变更或缓存未命中时,才运行 relay-compiler
# 这是一个重要的优化点
- name: Run Relay Compiler
if: needs.detect-changes.outputs.schema_changed == 'true' || steps.cache-relay.outputs.cache-hit != 'true'
run: |
echo "Schema changed or cache miss. Running relay-compiler..."
pnpm relay
# 这里的错误处理很重要,如果编译失败,必须终止工作流
continue-on-error: false
# 如果 schema 没变且缓存命中,我们跳过编译,这能节省大量时间
- name: Skip Relay Compiler
if: needs.detect-changes.outputs.schema_changed == 'false' && steps.cache-relay.outputs.cache-hit == 'true'
run: echo "Schema unchanged and cache hit. Skipping relay-compiler."
# 测试阶段可以进一步优化,比如只测试变更的包
# pnpm 提供了这样的能力: pnpm --filter "...[<since>]" test
- name: Run tests for changed packages
run: pnpm --filter "...[origin/main]" --filter '!./packages/graphql-schema' test
# 如果 Schema 发生变化,我们强制运行所有测试,作为一种安全保障
- name: Run all tests on schema change (Safety Net)
if: needs.detect-changes.outputs.schema_changed == 'true'
run: pnpm test
这个版本的 CI 已经智能很多。它理解了 schema.graphql 的重要性,并围绕它构建了缓存和执行策略。下面是这个工作流的逻辑图:
graph TD
A[Start: Push/PR] --> B{Detect Changes};
B -- Schema Changed --> C[Invalidate Relay Cache];
B -- Code Changed Only --> D[Use Relay Cache];
C --> E[Run relay-compiler];
E --> F[Run ALL Tests];
D --> G[Skip relay-compiler];
G --> H[Run tests for CHANGED packages only];
F --> I[End];
H --> I;
第三阶段:在Jest中可靠地测试Relay组件
CI 流程优化后,测试本身的可靠性成为下一个关键点。测试依赖 Relay 的组件(使用 useFragment, useLazyLoadQuery 等 hooks)时,不能发起真实的网络请求。我们需要一个稳定、可控的测试环境。relay-test-utils 提供了 createMockEnvironment,这是编写健壮测试的基石。
首先,确保 Jest 配置正确,能够处理 Relay 生成的文件和 GraphQL 标签。
// packages/app-alpha/jest.config.js
module.exports = {
// ...
testEnvironment: 'jsdom',
transform: {
'^.+\\.(ts|tsx)$': 'ts-jest',
},
// Jest 需要知道如何处理 `graphql` 标签
moduleNameMapper: {
'\\.(css|less)$': 'identity-obj-proxy',
// 确保能找到 Relay 运行时
'^relay-runtime$': '<rootDir>/node_modules/relay-runtime',
},
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
};
接下来是核心的测试环境搭建。我们创建一个辅助函数来简化 RelayMockEnvironment 的创建和使用。
// packages/app-alpha/src/test-utils/createTestEnvironment.ts
import {
commitLocalUpdate,
createOperationDescriptor,
getRequest,
GraphQLResponse,
IEnvironment,
RelayMockEnvironment,
} from 'relay-test-utils';
import { ConcreteRequest } from 'relay-runtime';
/**
* 创建一个用于测试的 Relay 环境
* @returns RelayMockEnvironment 实例
*/
export function createTestEnvironment(): RelayMockEnvironment {
const { createMockEnvironment } = require('relay-test-utils');
return createMockEnvironment();
}
/**
* 模拟一个 GraphQL 操作的成功响应
* @param environment Relay Mock 环境
* @param operationName 操作名称
* @param data 模拟的返回数据
*/
export function mockQuerySuccess(
environment: RelayMockEnvironment,
operationName: string,
data: object
) {
environment.mock.resolveMostRecentOperation(operation => {
// 验证操作是否是期望的查询
expect(operation.request.node.operation.name).toBe(operationName);
return { data };
});
}
/**
* 模拟一个 GraphQL 操作的失败响应
* @param environment Relay Mock 环境
* @param operationName 操作名称
* @param error 模拟的错误对象
*/
export function mockQueryError(
environment: RelayMockEnvironment,
operationName: string,
error: Error
) {
environment.mock.rejectMostRecentOperation(operation => {
expect(operation.request.node.operation.name).toBe(operationName);
return error;
});
}
现在,我们可以为一个使用 useFragment 的 UserProfile 组件编写测试。
假设有这样一个组件和 fragment 定义:
# UserProfile.tsx
fragment UserProfile_user on User {
id
name
email
}
// packages/app-alpha/src/components/UserProfile.tsx
import React from 'react';
import { useFragment, graphql } from 'react-relay';
import { UserProfile_user$key } from './__generated__/UserProfile_user.graphql';
interface Props {
user: UserProfile_user$key;
}
export const UserProfile: React.FC<Props> = ({ user }) => {
const data = useFragment(
graphql`
fragment UserProfile_user on User {
id
name
email
}
`,
user
);
return (
<div>
<h1>{data.name}</h1>
<p>ID: {data.id}</p>
<p>Email: {data.email}</p>
</div>
);
};
对应的测试文件将如下所示。这里的关键是使用 RelayEnvironmentProvider 将 mock environment 注入到组件树中。
// packages/app-alpha/src/components/UserProfile.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import { RelayEnvironmentProvider } from 'react-relay';
import { UserProfile } from './UserProfile';
import { createTestEnvironment } from '../test-utils/createTestEnvironment';
import { UserProfile_user$key } from './__generated__/UserProfile_user.graphql';
describe('UserProfile', () => {
let environment: ReturnType<typeof createTestEnvironment>;
beforeEach(() => {
// 每个测试用例都使用一个全新的 mock 环境
// 避免测试用例之间的状态污染
environment = createTestEnvironment();
});
it('renders user information correctly', () => {
// 伪造一个 fragment reference。
// 在真实应用中,这个对象是由父组件的查询返回的。
// 在测试中,我们只需要一个包含正确 $fragmentType 的对象即可。
const mockUserFragmentRef: UserProfile_user$key = {
// @ts-ignore - The fragment type is the only thing that matters for the hook
' $fragmentType': 'UserProfile_user',
id: 'user-123',
name: 'John Doe',
email: 'john.doe@example.com',
};
render(
<RelayEnvironmentProvider environment={environment}>
<UserProfile user={mockUserFragmentRef} />
</RelayEnvironmentProvider>
);
// 断言组件是否正确渲染了数据
expect(screen.getByRole('heading', { name: 'John Doe' })).toBeInTheDocument();
expect(screen.getByText('ID: user-123')).toBeInTheDocument();
expect(screen.getByText('Email: john.doe@example.com')).toBeInTheDocument();
});
// 这里的坑在于,useFragment 的数据是同步可用的,它假设数据已经被父查询加载。
// 因此,测试 useFragment 非常直接。
// 测试 useLazyLoadQuery 则需要模拟异步操作,如下面的例子。
});
// 假设我们有一个使用 useLazyLoadQuery 的页面级组件
// const UserPage = () => {
// const data = useLazyLoadQuery(graphql`...`, {});
// return <UserProfile user={data.user} />;
// }
// 它的测试会是这样的:
/*
it('handles loading and error states for a query', async () => {
render(
<RelayEnvironmentProvider environment={environment}>
<React.Suspense fallback={<div>Loading...</div>}>
<UserPage />
</React.Suspense>
</RelayEnvironmentProvider>
);
// 1. 初始状态应该是 loading
expect(screen.getByText('Loading...')).toBeInTheDocument();
// 2. 模拟一个错误的响应
mockQueryError(environment, 'UserPageQuery', new Error('Network Failed'));
// 3. 等待并断言错误边界捕获了错误
// (假设你有一个 ErrorBoundary 组件)
// const errorDisplay = await screen.findByText(/Network Failed/);
// expect(errorDisplay).toBeInTheDocument();
});
*/
这段测试代码展示了如何在隔离环境中验证 Relay 组件的行为,确保其逻辑的正确性,而这一切都可以在 CI 中快速、可靠地运行。
最终的工作流集成
现在,我们将所有部分整合在一起,形成最终的 ci.yml 文件。
# .github/workflows/ci.yml
name: Smart CI for Relay Monorepo
on:
push:
branches:
- main
pull_request:
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
schema_changed: ${{ steps.filter.outputs.schema_changed }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check for file changes
id: filter
uses: dorny/paths-filter@v2
with:
filters: |
schema_changed:
- 'packages/graphql-schema/schema.graphql'
build-and-test:
needs: detect-changes
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup PNPM
uses: pnpm/action-setup@v2
with:
version: 8
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Cache Relay artifacts
id: cache-relay
uses: actions/cache@v3
with:
path: '**/__generated__'
key: ${{ runner.os }}-relay-v2-${{ hashFiles('**/schema.graphql') }}-${{ hashFiles('**/relay.config.js') }}
restore-keys: |
${{ runner.os }}-relay-v2-${{ hashFiles('**/schema.graphql') }}-
- name: Run Relay Compiler
if: needs.detect-changes.outputs.schema_changed == 'true' || steps.cache-relay.outputs.cache-hit != 'true'
run: |
echo "Schema changed or cache miss. Running relay-compiler..."
pnpm relay
continue-on-error: false
- name: Skip Relay Compiler
if: needs.detect-changes.outputs.schema_changed == 'false' && steps.cache-relay.outputs.cache-hit == 'true'
run: echo "Schema unchanged and cache hit. Skipping relay-compiler."
- name: Lint and Type-check
run: |
pnpm -r lint
pnpm -r typecheck
# 只有在非 Schema 变更时,才执行增量测试
- name: Run tests for changed packages
if: needs.detect-changes.outputs.schema_changed == 'false'
run: pnpm --filter "...[origin/${{ github.base_ref || 'main' }}]" --filter '!./packages/graphql-schema' test
# 如果 Schema 发生变化,我们强制运行所有测试,这是最安全的策略
- name: Run all tests on schema change (Safety Net)
if: needs.detect-changes.outputs.schema_changed == 'true'
run: pnpm test
- name: Build all packages
run: pnpm build
这套工作流通过感知 schema.graphql 的变化,实现了 CI 资源的智能调度。它在确保代码质量和契约一致性的同时,大幅缩短了非 schema 变更场景下的反馈周期,将开发者的等待时间从近半小时缩短到了几分钟。
局限与未来展望
当前方案并非完美。当 schema.graphql 变更时,我们采取了“运行所有测试”这一相对保守的策略。在超大型项目中,这依然可能是一个时间瓶颈。未来的迭代可以探索更精细的依赖分析:通过解析 GraphQL Schema 变更的具体内容(例如,哪个 Type 或 Field 被修改),并结合代码静态分析,来确定哪些组件的查询真正受到了影响,从而实现更小范围的“影响面测试”。这需要引入更复杂的工具链,比如 GraphQL Inspector 结合自定义脚本,来生成一个精确的测试子集。
此外,对于 relay-compiler 本身,虽然我们缓存了其产物,但当缓存失效时,全量编译的耗时问题依然存在。探索 Relay 团队未来可能推出的增量编译功能,或者在大型项目中考虑将 schema 按领域拆分为多个子 schema(如果业务允许),可能是进一步优化的方向。