在一个使用 GraphQL + Relay 的项目中,我们遇到了一个非常隐蔽的问题:
页面没有报错,接口返回也看起来正常,但 UI 上的数据却偶尔不对。
后来发现,问题根本不在接口,而是在 Relay 的 store 机制。
业务背景
先说下业务场景。
系统里有「部门」的概念,一个用户可以同时属于多个部门。
在不同部门中,我们用同一个字段表示用户权限,比如 role。
但这个字段的值是和部门强相关的。
User A
- 在部门 X:role = admin
- 在部门 Y:role = viewer
从业务角度看,这非常合理。
初始的 Schema 设计
一开始,我们的 GraphQL Schema 是这样设计的:
type Department {
id: ID!
name: String!
users: [User!]!
}
type User {
id: ID!
name: String!
role: String!
}
查询部门列表时,直接把用户一起带出来:
query {
departments {
id
name
users {
id
name
role
}
}
}
这个设计在非 Relay 项目里其实完全没问题,
但在 Relay 下,隐患已经埋好了。
问题现象
问题并不是一开始就出现的。
随着需求增加:
-
页面开始同时展示多个部门
-
同一个用户在不同部门下都会出现
这时开始出现一些很诡异的情况:
-
A 部门里明明是 admin
-
切到 B 部门后再回来
-
A 部门里的角色变成了 viewer
刷新页面,有时又恢复正常。
排查过程
最开始大家都在怀疑:
-
后端是不是缓存有问题
-
接口是不是返回顺序不稳定
-
前端是不是 setState 写错了
后来直接打 log 看 Relay store,才发现真相。
问题根因:Relay 的归一化机制
Relay 会基于 id 对数据做归一化存储。
简单说就是:
同一个 id,在整个应用里只能有一份数据
于是发生了下面的事情:
-
请求部门 X
- 返回 User A (role = admin)
-
请求部门 Y
- 返回 User A (role = viewer)
-
Relay 认为这是同一个 User
-
后一次直接覆盖前一次
-
从 Relay 的角度看,它没做错。
错的是我们告诉它:这两个对象是同一个 Entity。
一个看似合理但无效的尝试
当时前端的第一反应是:
那我不请求 id 行不行?
于是尝试把查询改成:
users {
name
role
}
但很快发现问题依旧。
原因是:
只要 Schema 里这个类型有 id,Relay 还是会自动把 id 加进请求里。
也就是说,你根本绕不开它。
回头看:其实是建模问题
后来我们意识到一个关键点:
“用户在某个部门下的身份 + 权限”
并不是一个纯粹的 User。*
它是一个强上下文相关的数据结构。
但我们却把它建模成了一个全局 Entity。
最终方案:引入上下文对象
最终的解决方案其实很简单,但需要转一下思路。
不再直接返回 User
我们新增了一个类型:
type DepartmentUser {
name: String!
role: String!
}
并调整 Department:
type Department {
id: ID!
name: String!
users: [DepartmentUser!]!
}
关键点只有一个:****
DepartmentUser 没有 id
为什么这个方案能解决问题
因为在 Relay 里:
-
没有 id → 不会被归一化
-
不会进全局 store
-
每次查询的数据都是独立的
即使是同一个用户:
- 在不同部门下
- 返回的数据也不会互相覆盖
一点额外收益
这个改动还有一个副作用(是好的那种):
- Schema 语义更清晰了
- 后端也更容易理解
- 不再纠结「User 的 role 到底是谁的」
最后的经验总结
这次问题之后,我们在团队里约定了一条规则:
只给真正的全局实体加 id
如果一个对象:
-
强依赖上下文
-
离开父节点就没有意义
那它大概率不应该是 Relay Entity。
这个坑本身不复杂,
但如果你刚好踩到,真的会浪费不少时间。 希望这篇文章能帮到你