Relay 性能最佳实践
本技能提供面向 Relay 应用的性能优化指导。关于正确性、命名和架构模式,请参阅 relay-best-practices 技能。
详细的 API 文档请阅读 <llm-docs>/ 中的相关页面(v20.1.1 之后可在 node_modules/relay-runtime/llm-docs/ 中找到)。
每个屏幕一个查询
每个屏幕或路由应有一个(或极少数)根查询。Relay 会将所有片段的数据需求合并为每个查询的单次网络请求。同一屏幕上多个根查询会破坏这一优化——浏览器会发出多个并行请求,每个请求都携带冗余开销(HTTP 头、连接建立、响应解析)。
GOOD: Route → 1 query ├─ Header (fragment) ├─ Content (fragment) └─ Sidebar (fragment)
BAD: Route → 3 queries ├─ Header (query #1) ├─ Content (query #2) └─ Sidebar (query #3)
在渲染根组件前预加载
在调用 createRoot().render() 之前获取初始查询。这样可以将网络请求与 React 的初始化重叠,最小化首次有意义绘制时间。
// 立即开始获取——甚至在 React 初始化之前
const queryRef = loadQuery(environment, AppQuery, initialVariables);
// 然后渲染——数据可能已经可用
const root = createRoot(document.getElementById('root'));
root.render(
<RelayEnvironmentProvider environment={environment}>
<Suspense fallback={<AppSkeleton />}>
<App queryRef={queryRef} />
</Suspense>
</RelayEnvironmentProvider>
);
对非关键内容使用 @defer
延迟加载次要或首屏以下的内容,使主要 UI 渲染更快。Relay 通过 Suspense 渐进式流式传输延迟数据——初始响应更小,关键路径更早渲染。
query ProfileScreenQuery($id: ID!) {
user(id: $id) {
...ProfileHeader_user
...ProfileDetails_user @defer
...ProfileComments_user @defer
}
}
function ProfileScreen({ queryRef }) {
const data = usePreloadedQuery(ProfileScreenQuery, queryRef);
return (
<ScrollView>
<ProfileHeader user={data.user} />
<Suspense fallback={<DetailsSkeleton />}>
<ProfileDetails user={data.user} />
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<ProfileComments user={data.user} />
</Suspense>
</ScrollView>
);
}
适合使用 @defer 的候选内容:
- 侧边栏内容
- 首屏以下的部分
- 初始加载时不可见的标签页和手风琴
- 分页列表中的重型条目详情
Fetch Policies
store-or-network(默认值)在大多数情况下是正确的——它复用缓存数据,仅对缺失或过期的数据访问网络。
| Policy | 何时使用 |
|---|---|
| store-or-network | 默认。速度与新鲜度的最佳平衡。 |
| store-and-network | 立即显示缓存数据,后台更新。 |
| network-only | 新鲜度至关重要(例如,具有广泛副作用的 mutation 之后)。 |
| store-only | 离线优先或读取已保证在 store 中的数据。 |
将 network-only 保留给罕见情况。过度使用会使 Relay 变成无缓存客户端,并消除规范化 store 的优势。
配置垃圾回收
在 Relay Store 上设置 gcReleaseBufferSize,以在组件卸载后保留最近使用的查询。默认值为 10。这使得导航回之前访问过的屏幕变得即时(数据仍在 store 中),而不是触发新的网络请求。
const store = new Store(new RecordSource(), {
gcReleaseBufferSize: 20,
});
对于屏幕众多或导航频繁的应用,增加缓冲区。对于内存受限的环境(移动端),保持保守。
在服务端过滤和排序
使用 GraphQL 字段参数在服务端过滤和排序数据,而不是获取所有内容并在 JavaScript 中处理。
// BAD:获取所有任务,在客户端过滤
const data = useFragment(graphql`
fragment TaskList_user on User {
tasks { id, title, status }
}
`, user);
const active = data.tasks.filter(t => t.status === 'ACTIVE');
// GOOD:通过字段参数在服务端过滤
const data = useFragment(graphql`
fragment TaskList_user on User {
tasks(status: ACTIVE) { id, title }
}
`, user);
服务端过滤可减少负载大小、避免不必要的网络传输,并降低客户端内存使用。
绝不获取无界集合
始终使用 @connection + usePaginationFragment 对列表字段进行分页。一次性获取整个集合可能导致传输数兆字节的数据、在规范化期间阻塞 UI,并耗尽设备内存。
从适合视口的分页大小开始(例如 10–20 项),滚动时加载更多。
# BAD:获取每一项——无界
fragment NotificationList_user on User {
notifications {
id
message
}
}
# GOOD:分页,首页有界
fragment NotificationList_user on User
@argumentDefinitions(
count: { type: "Int", defaultValue: 10 }
cursor: { type: "String" }
)
@refetchable(queryName: "NotificationListPaginationQuery") {
notifications(first: $count, after: $cursor)
@connection(key: "NotificationList_notifications") {
edges {
node {
id
message
}
}
}
}
保持片段粒度细小
将大片段拆分为更小的、组件作用域的片段,以便 Relay 只重新渲染数据实际发生变化的组件。由多个组件共享的单一整体片段会导致其中任何字段变化时所有组件都重新渲染。
// BAD:一个大片段,任何字段变化时所有子组件都重新渲染
function PostCard({ post }) {
const data = useFragment(graphql`
fragment PostCard_post on Post {
title
body
author { name, profilePicture { uri } }
likeCount
commentCount
}
`, post);
return (
<>
<PostHeader title={data.title} author={data.author} />
<PostBody body={data.body} />
<PostFooter likes={data.likeCount} comments={data.commentCount} />
</>
);
}
// GOOD:每个子组件拥有自己的片段,独立重新渲染
function PostHeader({ post }: { post: PostHeader_post$key }) {
const data = useFragment(graphql`
fragment PostHeader_post on Post {
title
author { name }
}
`, post);
// 仅在 title 或 author.name 变化时重新渲染
}
function PostFooter({ post }: { post: PostFooter_post$key }) {
const data = useFragment(graphql`
fragment PostFooter_post on Post {
likeCount
commentCount
}
`, post);
// 仅在点赞/评论数变化时重新渲染
}
每个组件一个连接
每个组件使用单个 usePaginationFragment。一个组件中有多个连接会使分页状态更难推理——游标跟踪、加载状态和 hasNext 标志会变得纠缠不清。应将每个连接拆分到各自的组件中。
避免不必要的重新获取
在 mutation 之后,让 Relay 的规范化 store 通过在 mutation 响应中展开相关片段来自动更新组件。当 store 更新足够时,不要调用 refetch() 或 fetchQuery()。
# GOOD:更新后的数据随 mutation 响应返回
mutation UpdateUserMutation($input: UpdateUserInput!) {
updateUser(input: $input) {
user {
...UserProfile_user
...UserAvatar_user
}
}
}
# BAD:mutation 后需要单独的往返请求
mutation UpdateUserMutation($input: UpdateUserInput!) {
updateUser(input: $input) {
user { id }
}
}
谨慎使用 fetchKey——更改它会强制进行完整的网络往返。将 refetchQueries / 手动 refetch 保留给 mutation 的副作用过于广泛、无法在响应负载中捕获的情况。
只获取你需要的内容
每个片段应只请求组件实际渲染的字段。不要“以防万一”添加字段——未使用的字段会增加负载大小并减慢解析和规范化。Relay 的 unused-fields lint 规则会捕获此问题。
如果子组件需要更多数据,请向子组件添加片段并在父组件中展开它——不要扩大父组件的片段。