处理「用户改一份服务端数据」的全流程决策时使用:谁能写、什么时候算改完、什么时候算确认、失败怎么退、多个视图怎么同步。覆盖表单编辑草稿、提交与确认、乐观更新、并发与失败恢复、实时回流、临时 ID 和删除计数;避免旧响应覆盖新编辑、多处重复写同一字段、错误回滚和视图长期不同步。不覆盖多人同时编辑同一字段的协同(需要 OT / CRDT),以及服务端用乐观锁主动拒绝冲突的场景
Scanned 9/4/2026
Install to Claude Code
npx -y skills add beixiyo/dotfiles --skill data-mutation-flow --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Data Mutation Flow?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/beixiyo-data-mutation-flow)More formats (shields.io, HTML) on the badges page.
---
name: data-mutation-flow
description: 处理「用户改一份服务端数据」的全流程决策时使用:谁能写、什么时候算改完、什么时候算确认、失败怎么退、多个视图怎么同步。覆盖表单编辑草稿、提交与确认、乐观更新、并发与失败恢复、实时回流、临时 ID 和删除计数;避免旧响应覆盖新编辑、多处重复写同一字段、错误回滚和视图长期不同步。不覆盖多人同时编辑同一字段的协同(需要 OT / CRDT),以及服务端用乐观锁主动拒绝冲突的场景
---
# 前端数据修改规范
## 目标
一次用户修改必须满足:
- 用户看到的值有唯一来源
- 同一实体的各处视图不会长期不一致
- 任何旧请求、旧响应或失败回滚都不能把更新后的值改回去
## 一、术语
| 术语 | 含义 |
| ---------- | ---------------------------------------------------------- |
| 已确认值 | 由 GET 响应或写请求的完整回执写入的值,代表服务端事实 |
| 预测值 | 提交前先写进缓存的乐观值,随时可能被回滚 |
| 编辑草稿 | 当前编辑会话中由用户输入驱动的业务值 |
| 临时交互态 | 下拉、日期面板、输入法组合等尚未确认的选择 |
| 读模型 | 同一实体的各处展示:详情、列表、搜索结果、计数、徽标、分页 |
| dirty | 用户改过且尚未被确认,由操作产生,不由值比较推断 |
| 确认 | 服务端明确接受了本次修改(见第四章) |
**缓存里的值不一定是已确认值。** 做过乐观更新后,缓存中混有预测值,读出来的可能是还没落库、甚至即将被回滚的数据
## 二、数据所有权
同一个原子组在同一时刻只能有一个可写方
| 数据层 | 谁写 | 不可以做 |
| ---------- | ------------------- | ---------------------------- |
| 已确认值 | 读请求、完整回执 | 直接覆盖 dirty 的草稿 |
| 编辑草稿 | 用户输入 | 由多个子组件各自保存同一字段 |
| 临时交互态 | 组件内部 session | 跨关闭 / 切换实体持久化 |
| 预测值 | 提交流程 | 成为草稿的初始化来源 |
| 读模型 | 投影逻辑 / 读请求 | 保存独立可写的业务副本 |
规则:
- 表单渲染和提交都读草稿,不混读缓存
- 草稿可以由缓存初始化,但**初始化来源必须明确是已确认值**。缓存中可能有未确认预测值时,改用读请求结果,或等本次写入确认后再初始化
- 表单打开期间屏蔽了远端变化。需要感知他人修改时,用提示 + 「使用远端值」入口,不静默合并
- `null`、空字符串、空数组都可能是用户明确的修改,不能当成「没有值」
## 三、原子组
判据:**跨字段校验、需要同请求提交、由同一个确认动作产生**,任一满足即同组
例如「开始时间 / 结束时间」是一组,不能拆到多个 state 或多个组件各自提交
原子组是 **dirty、提交和回滚的最小单位**:
- 组内任一字段被改,整组 dirty
- 提交按整组发出,不拆成多个请求
- 回滚按整组还原,不留半组新值
反例:全天开关和结束时间分开提交,第二个请求失败后,服务端永久停在「全天为真却带具体时分」的非法状态
## 四、草稿与提交
### 初始化
- 只在「新实体 / 新编辑会话」时用已确认值原子初始化整组
- 切换实体时先销毁旧草稿再初始化新草稿,旧实体的异步结果不得写入新实体
- 旧草稿有未提交修改时,按产品策略选一种并保持一致:**提交后再切、提示用户确认、按实体暂存待回来继续、直接丢弃**。不要静默丢数据,也不要默认强制提交
### 提交时机
默认**在确认动作时提交**:失焦、点击确定、回车、关闭面板
- 逐字提交只在明确需要实时协同或搜索联想时使用
- 临时面板保存打开期间的 session,确认才原子写回草稿,取消则丢弃 session
- 即时预览类控件(滑块、颜色)可以边动边写草稿,但仍在确认动作时才提交
- 有自动保存时,防抖只是兜底,确认动作应立即冲刷,卸载 / 关闭前也要冲刷
### dirty 与确认
- dirty 来自用户操作,不从「当前值和远端值是否相等」推断
- 确认依据按优先级取第一个可用的:
1. 本次写请求的成功回执(最可靠,且回执带完整实体时可直接写回已确认值)
2. 服务端 revision / 版本号 / `updatedAt` 前进到本次写入产生的版本
3. 都没有时,见第五章的降级方案
- 值相等只能作为「可以安全丢弃草稿」的辅助判断,不是通用确认规则。服务端会 trim、转时区、补默认值时,值比较会给出错误结论
- 确认后清除该组 dirty;未确认前,远端回流一律不覆盖该组
### 失败
- 保留草稿和 dirty,允许显式重试、下次提交或关闭前冲刷
- 按语义区分错误类型,不按状态码段一刀切:
- 校验错误(字段不合法、跨字段冲突等):停止自动重试,把错误暴露给用户
- 鉴权失效(`401`/`403`):触发重新登录或刷新凭证,不当作校验错误展示
- 限流(`429`)、请求超时(`408`):按退避重试,不因为落在 4xx 区间就停止
- 网络 / 服务端错误:按退避重试
- 不允许吞掉异常让上层当成功
## 五、并发与失败恢复
### 顺序
同一实体的写请求默认**串行化处理**,不并发发出
前提说明:排队只保证本端的发出顺序,不解决其他客户端、其他端的并发。确认字段互不相交且服务端做字段级合并时,可以放宽为并发
取消进行中的读请求(如 `cancelQueries`)是 **best-effort 的竞态窗口收缩**:
- 底层请求没有消费取消信号时,请求照发,只是结果被丢弃
- 取消之后,焦点回归重新拉取、组件重挂载仍可能立刻发起新的读请求
- 服务端读写延迟时,新读到的照样是旧数据
**取消降低闪烁概率,顺序正确性要靠排队或版本判断,两者不能互相替代**
### 回滚
- 串行执行时用提交前的快照回滚即可
- 存在排队或并发时,只撤销本次操作自己的改动,不用全量快照。旧请求失败时不得还原掉后续已经成功的写入
- 无法精确撤销时(数组插入、重排、计数增减等不可逆操作),**直接失效相关读模型重新拉取权威值**,接受一次刷新闪烁。这比错误回滚安全
最小反例:A、B 连续提交,A 失败时用全量快照回滚,把已经成功的 B 一起抹掉,界面与服务端从此不一致
### 无 revision 时的降级
不要求所有接口都提供版本号。没有时按这套走:
1. 同一实体的写请求串行化处理,杜绝乱序
2. 提交期间不接受该实体的远端回流
3. 确认以写请求成功回执为准
4. 回执不完整或无法精确回滚时,失效相关读模型重新拉取
## 六、读模型同步
同一实体的每个读模型,**要么被乐观投影,要么在成功后被显式失效**,二选一
不允许出现既不投影也不失效的第三态,那会长期不同步直到用户手动刷新
选择依据:
| 情况 | 做法 |
| ---------------------------------------- | ------------------------ |
| 当前可见、需要立即反馈 | 乐观投影 |
| 当前不可见,或用户下次访问才看到 | 成功后失效,更简单不易错 |
| 涉及分页、排序、总数、权限、服务端派生值 | 必须失效,不要本地推算 |
其他要求:
- 多个读模型复用同一个投影逻辑,不在详情、列表、候选项里各写一遍字段合并
- 服务端返回的总数、分页信息只能失效重拉,不能本地加减
- 失效应在该实体没有进行中的写请求时执行,否则新拉的数据会和排队中的写入竞争
## 七、字段语义
`undefined`、`null`、空字符串、空数组代表什么,**由接口契约决定,不要默认按 merge-patch 理解**
改前先确认:
- 请求是全量替换还是字段级更新?
- 缺字段代表「不改」还是「清空」?
- 清空该用 `null`、空字符串还是省略?
然后落到实现:
- 请求体严格按契约保留清空语义,禁止 truthy 过滤、`value || oldValue`、默认值兜底把清空操作吃掉
- 字段级更新合并时,用「键是否存在」判断(`key in patch`),不要用 `patch[key] !== undefined`。JS 里显式写 `undefined` 和不写这个键,在展开合并中行为不同
## 八、实时回流
实时消息属于远端回流,同样不得覆盖 dirty 的原子组
- 消息带可靠的版本 / 序列号时:只接受比本地已确认版本更新的消息
- 没有可靠顺序依据时:**只当作失效信号**,触发一次受控重新拉取,由读请求结果作权威。禁止直接把消息负载写进本地状态
只有时间戳、没有序列号时,按「没有可靠顺序依据」处理。原因:
- 两条消息时间戳可能完全相同,分不出先后
- 服务端多机部署时各机器时钟有偏差
## 九、临时 ID
乐观新增时:
- 临时 ID 只用于本地投影
- 正式 ID 到达前,针对该实体的写请求必须排队或禁用入口
- 正式 ID 到达后按本次操作关联替换,不能按标题等模糊字段猜测
最小反例:乐观新增后用户立刻改标题,写请求带着临时 ID 打过去返回 404,回滚把新建的行一起抹掉
## 十、禁止项
- 草稿、缓存、弹层 session 同时直接写同一个字段
- 用全量旧快照无条件回滚
- 捕获保存异常后吞掉,让上层当成功
- 存在既不投影也不失效的读模型
- `value || oldValue`、truthy 过滤或默认值合并导致空值被回填
- 用定时器、一次性忽略标记、偶发重试替代排队、确认和所有权设计(输入法组合、第三方组件的一次性事件抑制属于 UI 层手段,不在此列)
## 十一、验证矩阵
按改动涉及的路径挑选验证:
| 场景 | 预期 |
| --------------------------- | -------------------------------------- |
| 连续修改 A → B | A 的成功、失败和旧读响应都不能覆盖 B |
| 设置 → 清空 → 再设置 | 清空被真实提交,最终值在所有视图一致 |
| 慢读请求与写请求交叉 | 先发后到的旧响应不得写回 |
| 提交失败 → 重试 | 草稿不丢失,重试仍带原草稿 |
| 校验类失败 | 停止自动重试,错误对用户可见 |
| 切换实体 | 旧实体的结果不写入新实体,未保存有交代 |
| 乐观新增后立即编辑 / 删除 | 写请求不带临时 ID 发出 |
| 多读模型 | 详情、列表、搜索和计数一致 |
| 删除或状态转换 | 分页、排序、总数最终与服务端一致 |
| 实时消息乱序 | 新状态不被旧消息回退 |
自动测试直接运行真实 hook、请求层和缓存客户端。构造乱序用可控的 deferred promise 编排,不要用定时器,否则测试自身不稳定
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!