跳到主要内容

拆解 OpenWrite 小说助手:把「写作 Agent」产品化,它到底在架构上做了什么

· 阅读需 17 分钟

上一篇文章盘点了一圈开源的写作 Agent(InkOS、Webnovel Writer、oh-story、chinese-novelist)——它们的共同特点是:能力强,但要么要命令行、要么要懂 prompt 工程,是为「愿意折腾的人」准备的。

这次换一个样本:OpenWrite 小说助手,一款面向普通网文作者的商业客户端。它把「AI 长篇连载」做成了下载即用、点鼠标就走的桌面产品,同时还对外开放了一套可自定义的 Skill 技能系统。这篇先花一小节带过它是什么、怎么用,然后从功能的角度逆推它的架构思路——不是搬运官方文档,而是基于教程里能看到的线索(内置 novel-writer skill 的完整 prompt、FAQ 对记忆机制的自述、各个操作流程),反推它做了哪些关键设计决策。

声明:本文非 OpenWrite 官方技术文档,属「产品架构读解」。凡教程里明确写到的我标注为事实;推断处会说明理由。依据的内部源文档:《OpenWrite 小说助手 - 使用教程》(见仓库 source/)。

盘点 4 个 AI 小说写作 Agent / Skill:从 InkOS 到 chinese-novelist

· 阅读需 8 分钟

AI 写小说这件事,已经从「让大模型一次性吐一章」进化成了「一套能长期连载、记得住设定、守得住伏笔的工程系统」。围绕这个需求,社区长出了一批 写作 Agent / Skill——有的是一套多 Agent 接力管线,有的是 Claude Code 的一键技能包。

这篇盘点 4 个有代表性的:InkOS、Webnovel Writer、oh-story-claudecode、chinese-novelist-skill。它们各自回答了「AI 写长篇」里不同维度的问题。

不用框架,自己管好前端请求:缓存、竞态与取消的一般解法

· 阅读需 11 分钟

上一篇《TanStack Query 实战》里,一个收藏星标靠 useQuery / useMutation 把「缓存、竞态、取消、同步」全接管了,一行都没手写。但那篇留下一个反向的问题:如果不引入框架,这些事自己怎么写?

「库帮你做了」和「你知道它做了什么」是两回事。这篇把前端请求里最头疼的几件事——状态管理、缓存、去重、竞态、取消、重试——用纯手写的方式逐层拆开,讲清每一种的一般解法。理解了这些,再回头看 TanStack Query / ahooks,你就能看懂它们「为什么这么设计」。

前端 RUM 自建方案调研:从采集到报表的每一步

· 阅读需 13 分钟

上一篇《前端 RUM:真实用户性能监测》讲清了 RUM 是什么、怎么用浏览器的 Performance API 采数据。但「采集」只是开始——真正的工程难题在后面:数据怎么不丢地送出去、怎么存得又快又省、怎么从海量原始数据里榨出结论、怎么把结论变成报表和告警

这篇把「自建 RUM」当成一条完整流水线来拆,深入每一个环节:采集 → 传输 → 接入 → 存储 → 聚合 → 分析 → 报表。每一层都给出取舍和可落地的方案。

useEffect 三种用法,在 commit 阶段的挂载时机(源码级辨析)

· 阅读需 7 分钟

useEffect 是渲染后异步执行的副作用」——这句话对,但不完整。同样是 useEffectuseEffect(fn)useEffect(fn, [])useEffect(fn, [a])commit 阶段触发的时机和条件完全不同;再叠上 useLayoutEffect,时机又变了。

本文从 React 源码(open/react)出发,把「不同使用方式 → 不同 flag → commit 阶段不同挂载时机」这条链讲清楚。

React diff 算法源码拆解:子节点调和的四大步骤

· 阅读需 12 分钟

上一篇我们讲了 React 19 把属性级 diffprepareUpdate/updatePayload)挪进了 commit 阶段。但大家常说的"React diff 算法",其实指的是另一个完全不同的东西——子节点调和(reconciliation):比较同一层级的 children,决定哪些 fiber 复用、哪些删除、哪些新建、哪些移动。

本文从源码角度拆解这个算法,核心是 reconcileChildrenArray四大步骤(这四步直接写在 React 源码 ReactChildFiber.js 的注释里)。先概述全局,再逐步骤分论。


1. 概述:diff 到底 diff 什么

触发链

beginWork
└─ reconcileChildren(current, workInProgress, nextChildren)
├─ current === null → mountChildFibers (shouldTrackSideEffects = false)
└─ current !== null → reconcileChildFibers (shouldTrackSideEffects = true)
├─ 单个元素 → reconcileSingleElement
├─ 数组 → reconcileChildrenArray ★ 本文主角(四大步骤)
└─ 可迭代 → reconcileChildrenIteratable

关键点:diff 不在 commit 阶段,也不直接操作 DOM。它发生在渲染阶段,是纯 JS 的结构计算——产出的是新的 fiber 树 + 副作用标记(flags),真正的 DOM 增删改要等 commit 阶段才落地:

三个设计前提

React 的 diff 不是"通用 diff",它做了三个刻意的简化,才把复杂度压到 O(n):

  1. 只比较同一层级的兄弟节点——树形结构天然分层,不做跨层比较(跨层移动 = 先删后建);
  2. key 识别"同一个节点"——key 相同才认为可以复用,这是复用的唯一依据;
  3. 类型(type)不同直接销毁重建——一旦 type 变了(比如 <div><span>),就不再深入 diff,整个旧 fiber 作废。

这三个前提决定了 diff 的行为边界,也是面试里"为什么 React diff 是 O(n)"的标准答案。


2. 四大步骤总览

reconcileChildrenArray 从头到尾就是四步——它对应源码里 reconcileChildrenArray 的四个结构分支:

1. Reconcile the children in the same order with the same key → 前缀扫描复用
2. Delete the remaining old children when the new children are exhausted → 删除
3. Create new fibers for the remaining new children when the old children are exhausted → 新建
4. Reconcile the remaining children and clean up the old children → map 移动 + 清理

整体决策流程:

四个步骤互相衔接:能省则省。前两步(前缀复用、整段删除)是最常见的场景,走的都是"不建 Map"的快速路径;只有真正出现乱序/中间插入时才落到第 4 步建 Map。


3. Step 1:前缀扫描——同序同 key 复用

这是 diff 的快速路径。同时从左到右遍历旧 fiber 链表和新 children,只要 key 匹配就复用旧 fiber(updateElement 原地更新 props),key 一旦不匹配立即 break

// ReactChildFiber.js(简化示意)
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
const newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx]);
if (newFiber === null) {
break; // ★ key 不匹配,停止前缀扫描
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// ... 链接 sibling
}

updateSlot 的匹配逻辑(ReactChildFiber.js:811)——key 相等才继续,否则返回 null 表示"这一位对不上"

function updateSlot(returnFiber, oldFiber, newChild, lanes) {
const key = oldFiber !== null ? oldFiber.key : null;
// ...文本节点:key 必须为 null 才可复用
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE: {
if (newChild.key === key) { // ★ key 匹配 → 进入 updateElement
return updateElement(returnFiber, oldFiber, newChild, lanes);
} else {
return null; // ★ key 不匹配 → break
}
}
}
}
// ...
}

例子[A, B, C, D][A, B, C, X]

A 匹配 → 复用 | B 匹配 → 复用 | C 匹配 → 复用 | X 与 D 的 key 不同 → break

前三项零成本复用,只有 X 进入后面的步骤。append / 前缀删除这类最常见的操作,在 Step 1 就结束了,这也是 React 刻意保留"先正向扫描"的原因(源码注释里提到:先走 forward-only 路径,只有发现需要大量前瞻时才用 Map)。


4. Step 2:删除——新 children 耗尽,剩余旧的全删

前缀扫描 break 时,如果 newIdx 已经等于新 children 长度,说明新列表走完了但旧列表还剩——剩下的旧 fiber 全部无用,一次性删除:

// ReactChildFiber.js(简化示意)
if (newIdx === newChildren.length) {
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}

deleteRemainingChildren → 逐个 deleteChild

function deleteChild(returnFiber, childToDelete) {
if (!shouldTrackSideEffects) return; // mount 阶段不追踪
const deletions = returnFiber.deletions;
if (deletions === null) {
returnFiber.deletions = [childToDelete]; // 挂在父 fiber 上
returnFiber.flags |= ChildDeletion; // ★ 打 ChildDeletion 标记
} else {
deletions.push(childToDelete);
}
}

注意删除是"记账"不是真删:被删的 fiber 收集到 returnFiber.deletions 数组 + 打 ChildDeletion flag,commit 阶段才真正卸载 DOM 并执行 unmount effect。

例子[A, B, C][A]:A 复用,B、C 在 Step 2 被标记删除。


5. Step 3:新建——旧 children 耗尽,剩余新的全建

反过来,如果 break 时 oldFiber === null(旧链表走完了但新 children 还有),说明剩下的全是新增,走快路径批量 createChild

// ReactChildFiber.js(简化示意)
if (oldFiber === null) {
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = createChild(returnFiber, newChildren[newIdx]);
if (newFiber === null) continue;
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// ... 链接 sibling
}
return resultingFirstChild;
}

createChild 按元素类型生成全新 fiber(createFiberFromElement / createFiberFromFragment / createFiberFromText),placeChild 会给它打 Placement 标记(commit 时插入 DOM)。

例子[A][A, B, C]:A 复用,B、C 在 Step 3 新建并打 Placement。


6. Step 4:map 移动 + 清理——乱序 / 中间差异的核心

走到这里说明新旧都还有剩余——要么 key 顺序乱了,要么中间插了东西。React 不能再线性对齐,于是把剩余旧 fiber 塞进一个 Map(按 key 索引),再逐个去"认领"

// ReactChildFiber.js(简化示意)—— Step 4
const existingChildren = mapRemainingChildren(oldFiber); // key → fiber(无 key 用 index)
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = updateFromMap(existingChildren, returnFiber, newIdx, newChildren[newIdx]);
if (newFiber === null) continue;
if (shouldTrackSideEffects) {
if (newFiber.alternate !== null) {
existingChildren.delete(newFiber.key == null ? newFiber.index : newFiber.key); // 认领后从 Map 删
}
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// ... 链接 sibling
}
// 遍历完,Map 里剩下的都是"新列表里已经不要了"的旧 fiber → 全部删除
existingChildren.forEach(child => deleteChild(returnFiber, child));

mapRemainingChildren 建 Map(ReactChildFiber.js:463):有 key 用 key,没 key 用 index 兜底。

updateFromMap 负责"认领"(ReactChildFiber.js:941):按 key(或 index)在 Map 里找匹配的旧 fiber → 找到且 type 匹配就 updateElement 原地复用;找不到就 createChild 新建。

例子[A, B, C][C, A, D]

前缀扫描:A 对 C,key 不匹配 → break(进 Step 4)
建 Map:{A, B, C}
遍历新列表:
C → Map 命中 → 复用 C,删 Map 里的 C,placeChild 判定移动
A → Map 命中 → 复用 A,删 Map 里的 A,placeChild 判定移动
D → Map 未命中 → createChild 新建
Map 剩余 {B} → B 已不在新列表 → deleteChild(B)

Step 4 用 O(1) 的 Map 查询替代了 O(n) 的线性查找,这是乱序场景下 diff 依然保持 O(n) 均摊的关键。


7. 移动判定:placeChildlastPlacedIndex(最精妙的一步)

四大步骤反复调用 placeChild,它决定一个复用节点是"待在原位"还是"要移动"。判据是 lastPlacedIndex——已放置的最右位置

// ReactChildFiber.js(简化示意)
function placeChild(newFiber, lastPlacedIndex, newIndex) {
newFiber.index = newIndex;
const current = newFiber.alternate;
if (current !== null) {
const oldIndex = current.index; // 旧列表里的位置
if (oldIndex < lastPlacedIndex) {
newFiber.flags |= Placement; // ★ 旧位置比"最右已放置"还靠左 → 相对右移了 → 移动
return lastPlacedIndex;
} else {
return oldIndex; // 顺序保持 → 不动,更新最右位置
}
} else {
newFiber.flags |= Placement; // 全新 → 插入
return lastPlacedIndex;
}
}

直觉:我们从左到右扫描新列表,lastPlacedIndex 记录"已经就位的最靠右的旧位置"。如果一个节点旧的 index 比这个还小,说明它原本在左边、现在排到了右边——相对顺序变了,必须移动

例子[a, b, c, d][d, a, b, c]

d:oldIndex=3, lastPlacedIndex=0 → 3≥0 → 不动,lastPlacedIndex=3
a:oldIndex=0, lastPlacedIndex=3 → 0<3 → 移动(打 Placement)
b:oldIndex=1, lastPlacedIndex=3 → 1<3 → 移动
c:oldIndex=2, lastPlacedIndex=3 → 2<3 → 移动

结果:d 原位,a/b/c 各移动一次。这是 "只向前移动"的贪心(源码注释明确写了这个 limitation):[d, a, b, c] 明明最优只需移 1 次(把 d 挪到末尾),React 却选择移 3 次(a/b/c 挪到 d 后面)。代价是"移动次数可能不是最优",换来的是单次遍历、无需回溯的 O(n) 复杂度。源码注释:

"we only support moving a fiber forward, not backward… So we have to move 3 times to place a, b, c instead of moving 1 time to place d."


8. 单节点情况:reconcileSingleElement

当新 children 是单个元素(不是数组)时走 reconcileSingleElementReactChildFiber.js:1634)。逻辑更直接——遍历旧链表找"对的那个":

旧 child 的 key 与 type处理
key 匹配 type 匹配useFiber 复用,删除其余 sibling
key 匹配 type 不同整个旧链表不可复用 → 全部删除 → 新建
key 不匹配删掉当前 child,继续找下一个 sibling
遍历完没找到创建全新 fiber

单节点不用建 Map,O(n) 线性扫一遍即可(旧链表通常很短)。


9. key 的意义与注意事项

Step 1 和 Step 4 都依赖 key,key 是整套算法的"身份标识"。两条铁律:

  1. key 要稳定且唯一——同一列表里不能重复,跨渲染不能变;
  2. 别用 index 当 key——一旦在中间插入/删除元素,index 会整体位移,Step 1 的"同序同 key"匹配会认错节点,导致复用错乱的 fiber(state/DOM 对不上)

反例:[A, B][X, A, B],若用 index 当 key:

Step 1:X(index0) 对 A(index0) → key 都是 0 → 复用 A 的 fiber 渲染 X!❌ 状态错乱
A(index1) 对 B(index1) → key 都是 1 → 复用 B 的 fiber 渲染 A!

而用稳定 key 时:X 找不到匹配 → 新建;A、B 的 key 匹配 → 正确复用,只有 X 一次插入。


10. 复杂度分析

场景走哪条路复杂度
前缀一致(append / 头部删除)Step 1(可能 + Step 2/3)O(n),不建 Map
乱序 / 中间插入Step 4O(n) 均摊(Map 构建 O(m) + 认领 O(k),m/k 都是剩余量)
单节点reconcileSingleElementO(n)(旧链表长度)

代价是 Step 1 的前缀扫描在"开头就乱序"的场景下白扫一部分,以及移动是贪心(非最优步数)——但换来的是绝对的单次遍历,这正是 React diff 敢声称 O(n) 的原因。


11. 一句话总结

React 的 diff = 子节点调和:渲染阶段用 reconcileChildFibers 比较同层兄弟,通过前缀扫描复用 → 整段删除 → 整段新建 → Map 认领移动四大步骤,产出新的 fiber 树 + Placement/ChildDeletion 标记,commit 阶段才落地 DOM。O(n) 的秘密是"单次遍历 + key 的 O(1) 匹配 + 只向前移动的贪心",而这一切都建立在三个前提上:只比同层、key 定身份、type 不同即重建


参考

  • React 19.2.7 ReactChildFiber.jsreconcileChildrenArray(1124) / reconcileSingleElement(1634) / updateSlot(811) / placeChild(492) / mapRemainingChildren(463) / updateFromMap(941)

OpenAI Codex 三大开源组件:exec、SDK、app-server 用法全解

· 阅读需 12 分钟

很多人以为"强大的 AI Agent = 好模型 + 好 Prompt"。但实际上,模型之上还叠着一整套底层执行系统(Harness):理解任务、维护对话记忆、调用工具、实时展示进度、处理失败、请求人类审批。这部分决定了 Agent 能不能真正"干活",也决定了把 Agent 嵌进产品时的体验上限。

OpenAI 在开源仓库 openai/codex 里把这套 harness 的三个组件开放了出来,分层恰好对应三种不同的接入方式:

组件形态一句话适合场景
codex execCLI 子命令非交互式跑完一个任务脚本、CI、一次性后台任务
Codex SDKTypeScript / Python 库在应用代码里启动/恢复/流式监听任务CI/CD、内部工具、应用集成
Codex app-server本地 JSON-RPC 服务把 Agent 变成你产品的"一等公民"持久对话、实时事件、打断、自定义工具、审批

下面逐个过用法,代码片段可以直接跑。

用 D2 画流程图:另一种「文本即图表」的打开方式

· 阅读需 3 分钟

Mermaid 大家都很熟了,但「文本即图表」还有另一个后起之秀——D2(d2lang.com),一个用 Go 写的声明式图表语言。它把布局交给引擎(dagre / ELK)自动排版,写图就像写数据一样:节点、连线、容器,全部是声明。

这篇博客本身就在用 D2 画图——下面的流程图和应用架构图都是 ```d2 代码块在构建时编译成的 SVG。你可以直接看效果,再决定要不要入坑。

把性能指标和转化率绑在一起:一个实习生终结"开发运营甩锅"的监测系统

· 阅读需 10 分钟

先讲一个传闻(真假不重要,思路值得抄):据说某大厂有个实习生,在周会上被夹在中间——运营指着大盘说"转化率跌了 5%,是不是你们前端最近改版改坏的?",开发指着另一块屏说"LCP 1.8 秒,性能数据没退化啊,是不是你们投放渠道变了?"。两边各拿一套指标,谁都无法证明自己的清白,也谁都无法证明对方的锅。

那个实习生的做法很朴素:他花了几周,把"性能指标"和"业务指标"绑进了同一张表、同一个会话、同一块大盘——从此"性能到底影不影响业务"不再靠吵,靠查。

这篇文章聊聊这套思路:为什么性能真的影响转化(有数据)、怎么把两者"绑"起来、系统的架构长什么样,以及——怎么用数据终止甩锅,而不是制造新的甩锅


1. 先看甩锅的本质:两套指标,两个世界

运营和开发看的是不同的数据

运营看的开发看的
指标转化率、GMV、跳出率、下单率LCP、INP、CLS、TTFB、JS 错误率
数据来源业务埋点 / 数仓RUM / 性能平台
时间粒度天 / 渠道分桶 / 页面
背后团队运营策略、投放前端、基建

问题不在于谁对谁错,而在于这两套数据没有打通——所以"转化率跌了"和"性能没退化"可以同时成立,谁也反驳不了谁。甩锅的本质,是没有一个裁判能同时看到场上两边的牌


2. "性能影响转化"不是玄学:先摆数据

在动手绑之前,先确认"性能→业务"这条链路是真实存在的。业界公开数据(老但常被引用):

案例结论
Walmart每提升 1s 加载时间,转化率 +2%;每 100ms 带来 1% 收入增长;速度优化后移动端转化率 +20%
Mobify首页每降 100ms,会话转化率 +1.11%、年收入 +38 万美元;结账页每降 100ms 转化率 +1.55%
Amazon100ms 延迟 ≈ 1% 收入损失,1s 差异每年约 16 亿美元
Pinterest感知等待时间 -40% → 搜索流量和注册 +15%
通用规律1s 延迟 → 转化率约 -7%

直观对照(加载时间 vs 平均转化率):

加载时间平均转化率
2.4s1.90%
3.3s1.50%
4.2s低于 1%
5.7s+0.6%

传导链:加载慢 → 首屏等得久/交互卡 → 用户流失/跳出 → 转化率降 → 收入降。它不是玄学,是一条能被观测到的因果链。而大厂做法(也见本博客合成监控)里的"性能预算"本质就是提前在 CI 拦住这条链;实习生这套则是事后在线上把这条链"看见"


3. 核心设计一:会话级对齐——把性能和业务绑进同一行

绑定的关键,是让性能数据和业务事件落到同一条"会话记录"上

  • 前端埋 RUM 点:每个 session 记下页面级 LCP / INP / CLS / TTFB / JS错误数
  • 业务埋点:同一个 session 里发生的 加购 / 下单 / 注册 / 转化
  • 两者用 session_id(或 trace_id) 打通——性能是"这个用户这次访问的性能",转化是"这个用户这次访问的转化",天然是一对。
-- 会话级表:一个 session 一行,性能列 + 转化列共存
SELECT
session_id,
date,
lcp, inp, cls, ttfb, js_errors, -- 性能列
is_converted, gmv -- 业务列
FROM session_perf
WHERE date = '2026-08-15'
LIMIT 5;

这一步看起来简单,但它改变了一个根本问题:之前"性能"和"转化"是两张孤立的表,现在它们可以放进同一个 GROUP BY、同一个散点、同一条趋势线里了。


4. 核心设计二:分桶与对比——用数据说话

4.1 按性能分桶,看各桶转化率

把访问按性能表现分桶,直接对比转化率差异:

SELECT
CASE
WHEN lcp < 2500 THEN '快(<2.5s)'
WHEN lcp < 4000 THEN '中(2.5~4s)'
ELSE '慢(>4s)'
END AS perf_bucket,
COUNT(DISTINCT session_id) AS sessions,
COUNT(DISTINCT CASE WHEN is_converted THEN session_id END) AS converted,
ROUND(COUNT(DISTINCT CASE WHEN is_converted THEN session_id END)
/ COUNT(DISTINCT session_id) * 100, 2) AS conv_rate
FROM session_perf
WHERE date = '2026-08-15'
GROUP BY perf_bucket
ORDER BY conv_rate DESC;

结果大概长这样——这就是"性能影响转化"最直接、最难反驳的证据:

perf_bucketsessionsconv_rate
快(<2.5s)12,0002.31%
中(2.5~4s)8,0001.62%
慢(>4s)3,0000.87%

4.2 时间序列叠加:看走势是否同向

再按天把"性能指标 P75"和"转化率"画在同一条时间轴上——当两者同向走,相关性一目了然;当它们背离,往往意味着变量在别处

SELECT
date,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY lcp) AS lcp_p75,
SUM(CASE WHEN is_converted THEN 1 ELSE 0 END) / COUNT(*) * 100 AS conv_rate
FROM session_perf
WHERE date BETWEEN '2026-08-01' AND '2026-08-15'
GROUP BY date;

4.3 拆环节归因:到底是"哪个"慢

光说"性能影响转化"还不够,要防甩锅得进一步拆到环节——性能退化的责任通常是明确的:

指标退化环节责任方向验证方式
TTFB 上升网络 / 后端 / CDN基建 / 后端拆服务端耗时、CDN 命中率
LCP 上升首屏资源 / 图片 / JS前端合成监控回归
INP 上升主线程长任务 / 第三方脚本前端长任务 / JS 分析
JS 错误率上升前端代码 / 发布前端堆栈聚类
性能平稳,转化率仍降运营策略 / 投放 / 竞品运营对照投放节奏、活动日历

这一行是最关键的——"性能平稳但转化下降"也被系统显式地呈现出来,意味着这套系统不偏袒开发:它既会证明"确实是前端慢导致的转化下降",也会证明"性能没问题、是运营投放的锅"。裁判要公正,两边才都服气。


5. 系统架构

四个模块的要点:

  • 采集:RUM 和业务埋点都要带 session_id;性能指标选跟业务最相关的几个(LCP/INP/CLS/TTFB),别贪多;
  • 打通:事件总线按 session 归并——这是整个系统的地基;
  • 分析:分桶转化率 + 时间序列叠加(见 §4);
  • 决策板 + 告警:把"性能×转化"放同一块大盘;告警规则要设计成**"性能和业务同降才告警"**——单性能降(可能只是某个低价值页面)和单业务降(可能只是投放波动)都分开看,避免狼来了。

6. 防止甩锅的机制,本质是"责任矩阵 + 数据裁决"

这套系统防甩锅靠的不是态度,而是三条机制:

  1. 口径统一:双方都看同一张表、同一个 session_id 体系——"你说的转化率和我说的性能,来自同一次用户访问";
  2. 归因到环节:性能退化自动拆到"网络 / 首屏 / 交互 / 稳定性",每类都有对应的验证手段(§4.3 表格);
  3. 双向保护:运营侧变量(投放、活动、竞品)也纳入大盘——当性能没问题时,系统同样会还开发一个清白

于是周会上的对话变成:

运营:"转化率跌了。" 开发:"哦,看决策板——LCP 和转化率同向跌,且 LCP 归因到首屏资源,是上周图片改造的问题,我们今天回滚。" 运营:"行,回滚后明天看这行数据有没有回来。"

没有争吵,只有一行 SQL 能查出来的事实。


7. 坑与诚实提醒:别把"相关性"当成"因果"

这个系统最大的风险不是技术,是误读数据

  1. 相关性 ≠ 因果。最经典的混淆变量:弱网用户(地铁、偏远地区)往往 LCP 又慢、购买意愿又低——你观察到的"慢桶转化率低"可能一半是"用户本身就不太想买",而不是"慢导致不买"。所以分桶对比只能作为提示,要下因果结论需谨慎;
  2. 时间对齐要小心。页面性能和"转化"可能隔了好几分钟(用户看了 3 分钟才下单),别拿"首屏 LCP"直接硬套"本 session 转化"当因果;可以分"首屏完成前/后"的转化窗口;
  3. 别拿公司级大盘甩人。粒度越粗越容易误伤——归因尽量到"页面 × 渠道 × 设备"这个层级,再往上聚合;
  4. 告警别做成"性能差就喊"。否则运营会免疫。规则一定是**"同降才喊 + 给可执行归因"**,让每一次告警都自带"该谁看、看哪里"。

真正的做法是:分桶对比给假设,时间序列给趋势,对照组(A/B 或自然对照)给因果——三者一起用,这套系统才从"甩锅工具"变成"决策工具"。


8. 一句话总结

那个实习生做的东西,本质不是"监控系统",而是一套把性能指标和业务指标放进同一个会话、同一块大盘、同一条归因链路的"裁判系统":RUM 和业务埋点按 session_id 打通 → 分桶看"快/中/慢各桶转化率差多少" → 时间序列看"性能和转化是否同向走" → 按环节归因(网络归基建、首屏归前端、策略归运营)→ 性能和业务"同降"才告警。它防甩锅靠的不是态度,而是三件事:口径统一、归因到环节、双向保护(性能没问题时也还开发清白)。但要诚实——相关性不是因果,弱网用户这类混淆变量永远存在,分桶给假设、趋势给信号、对照组才给结论。数据不会吵架,但前提是你真的把两边放到了同一张桌上


参考

TanStack Query 实战:一个「收藏星标」的乐观更新

· 阅读需 13 分钟

收藏功能几乎是每个内容产品的标配,但它恰恰是「手写 fetch 最容易写脏」的一类交互:点一下,UI 要立刻翻转,网络请求在后台跑,失败还得翻回去。这背后其实藏着一串问题——loading 怎么禁按钮?等网络还是先反馈?失败怎么恢复?详情页、列表页、收藏页三处的数据怎么同步?

这篇文章用项目里一个真实的 FavoriteStar 组件,讲清楚 TanStack Query 是怎么把这四件事全部接管的。你会发现:这些逻辑一行都没手写,全靠 useMutation 的生命周期钩子。