1. 现象确认任务卡在 0/1但不是没跑先说明一下背景。我这边内部效率工具里有一个叫 WorkBuddy 的模块每天会推一批小任务其中有一个“探索优秀灵感”的任务规则是从推荐卡片里点开一篇内容停留并滚动浏览就算完成一次有效探索进度会从 0/1 变成 1/1。上线没几天就有人反馈自己明明认真读完了文章任务却一直停在 0/1重启客户端、重新登录都不管用。这个场景很容易让人怀疑是后端计数接口挂了或者事件上报丢了。但我去后端日志里查了一圈发现这个用户当天根本没有产生任何“探索优秀灵感”事件。也就是说问题大概率出在前端要么没触发上报要么触发了但被某个环节静默滤掉了。与其反复猜不如直接把线上发布的客户端资源包翻到底看任务完成判定到底依赖什么。1.1 复现路径什么样的操作会触发卡 0/1为了把问题锁定我先用测试账号按产品文档走了一遍进入 WorkBuddy 首页看到“探索优秀灵感”任务卡片点击进入灵感广场随机点开一篇推荐文章停留大概十秒然后返回任务页。结果任务依然显示 0/1。连续试了三次都一样。我又换了一个操作顺序先打开任务详情再通过任务里的按钮跳转灵感广场以及先清掉本地缓存再操作。结果有点意思——清缓存后第一次操作偶尔能变 1/1第二次又卡回 0/1。这个“偶尔能成”的特征很关键说明端到端链路本身是通的问题不是接口完全不可用而是某个条件在部分时刻不满足或者某个状态被后续操作覆盖了。1.2 为什么优先翻包而不是加日志重发版本按常规思路遇到这种案子应该先在前端代码里打点、发个新版本让用户升级。但我当时只有线上包而且用户环境无法轻易升级。更重要的一点是前端包已经发布生产环境的代码和我本机仓库里可能有差异——比如某个热修复分支合错、构建时被插件改写等。以线上包为准是排查线上问题的第一步。我选择直接用浏览器开发者工具和本地静态资源代理把线上 JS 文件拉下来格式化后逐段搜索。这个过程不涉及修改源码重新发布只是“读代码、找判定、验证假设”。翻包不是破解排查自己系统的线上行为和做安全测试是两回事如果连自己的包都读不明白排查这类进度问题时只能靠猜。2. 把包翻到底判定逻辑藏在哪2.1 定位关键 chunk从中文文案反查代码第一件事是打开页面的网络面板找到负责 WorkBuddy 任务的 JS chunk。Production 构建下文件名通常是index.xxxx.js或task.xxxx.js直接搜变量名很难搜因为代码被压缩过变量名都变成了a、b、e这些。我对付压缩代码的顺序是先在“Search”里搜中文文案比如“探索优秀灵感”或“0/1”的模板字符串因为中文在压缩后通常不会被改写成 Unicode也不会被混淆。很快就搜到了任务卡片渲染那一段顺着 render 函数找到了状态字段completedTimes和requiredTimes。界面上显示 0/1就是completedTimes0、requiredTimes1。到这里只能证明前端展示没有问题真正要追的是什么逻辑会把这个completedTimes从 0 改成 1。压缩代码还有个小技巧搜completedTimes搜不到完整字段名的情况很常见因为 minify 之后它可能变成e.completedTimes或t.completedTimes。这时候可以用更稳定的字符串比如后端接口返回的taskId、IMPROVE_INSPIRATION这种枚举值或者界面上的中文文案。从外到内一步步缩小范围比直接扎进压缩代码里效率高得多。2.2 判定逻辑全貌不是“浏览了就算”而是“上报了才算”继续往下游翻把completedTimes的所有赋值点都找出来。最后锁定了两个函数一个是handleTaskProgressUpdate它处理服务端下发的任务进度另一个是recordTaskEvent它把前端产生的行为事件发给服务端。正常情况下应该是前端记录事件 - 服务端累加 - 下发新进度 - 前端更新。实际代码里recordTaskEvent调用后如果sendTaskEvent失败会有 catch 分支把错误吞掉然后在回调里返回一个“假成功”。最阴险的是这个假成功也会触发completedTimes的本地预更新但因为后端没有真正落库下一轮服务端进度同步会把本地这个值覆盖回 0/1。用户看到的表象就是“明明操作成功进度却不动”。async function recordTaskEvent(taskId, payload) { try { const res await sendTaskEvent(taskId, payload); // 注意这里没判断业务码只看 HTTP 状态 if (res.status 200) { optimisticallyMarkTaskComplete(taskId); } return res; } catch (err) { // 增加缓存队列后没有错误上报 enqueueFailedEvent(taskId, payload); return { status: 200, fake: true }; } }这段代码就是判定逻辑的入口。它解释了一部分“假成功”但还没解释为什么后端日志里连事件都没有——如果链路只是后端拒收日志里应该有一条失败记录。于是我把重点转到了sendTaskEvent里的三个静默失败点。2.3 为什么是 0/1 而不是 0/0这个细节我多解释一句。任务卡片上的“0/1”是后端任务模板给的requiredTimes1表示这个任务只需要完成一次。很多同学看到 0/1 会误以为“还差 1 次”其实它只是“已完成 0 次共需 1 次”的展示格式。搞清楚这个格式很重要因为它直接决定了排查方向如果显示 0/2 或者 0/3可能是多次计数被拆分如果显示 0/1几乎可以确定任务是“单次完成型”影响它的往往是一个布尔条件而不是累加器。也就是说我们要找的是一个“是否满足完成条件”的判断而不是“次数累加逻辑”。这个细节让我后面的排查少走了弯路。3. 三个静默失败点逐一拆给你看3.1 静默失败点一元素选择器没选中Observer 根本没建立找到sendTaskEvent的调用链后看到事件是一个IntersectionObserver触发的当用户在灵感广场里滚动某个推荐卡片进入可视区域且可见比例超过 0.8前端就认为“用户开始探索”进而上报事件。问题出在初始化 Observer 之前的一行const target document.querySelector(.inspire-card[data-id currentCardId ]); if (!target) { // 静默返回没有日志没有错误上报 return; } observer.observe(target);看起来逻辑没毛病但currentCardId在页面加载后的 100 毫秒内是空的。接口请求灵感列表需要时间而初始化代码在路由挂载后立即执行于是querySelector找不到元素直接 return。等接口返回、卡片渲染出来时Observer 已经不会被重新创建了。用户看到页面有卡片但代码里根本没有绑定监听所以无论怎么滚动都不会触发上报。生产环境难就难在这里所有东西看起来都正常DOM 里有卡片滚动也有但 Observer 不存在。后来我在本地用代理脚本把线上代码替换成带日志版本才在控制台看到那一行静默 return 被执行。这也是为什么我一直强调生产环境排查不能只靠复现必须想办法“看代码实际走到哪一步”。3.2 静默失败点二延迟上报把关键字段等没了即使 Observer 正常还有第二个坑。卡片进入可视区域后代码并不是立刻上报而是放进一个setTimeout延迟 5 秒为的是确认用户停留。这个延迟本身合理但问题在于 payload 里的activeCardKey是从一个全局状态对象里取的setTimeout(() { sendTaskEvent(IMPROVE_INSPIRATION, { cardKey: taskState.activeCardKey, duration: 5000, source: inspire_square }); }, 5000);用户在 5 秒内如果切走了卡片同一路由的resetTaskState()会被调用activeCardKey被重置成空字符串。此时定时器回调执行cardKey就是空的。后端收到的载荷缺少关键标识校验不通过直接忽略。最坑的是这个忽略发生在接入层返回的 HTTP 状态是 204前端用sendBeacon发送数据时根本不处理响应体于是又静默了。这里值得记一条教训延迟上报本身没问题但上报数据必须在“事件发生的那一刻”就做快照而不是等到发送时才去读当前状态。全局可变状态是异步任务里最常见的地雷。3.3 静默失败点三后端幂等把“有效第二次”吞了第三个点不在前端包里但不翻包也发现不了。我在查后端调用链时看到任务事件接口做了幂等同一个用户同一天内对同一任务类型只处理一次重复上报直接返回DUPLICATE。设计初衷是防止前端多次点击刷任务。但幂等 key 只用了userId taskId 日期没带cardKey或事件唯一 ID。于是“第一次事件因 cardKey 为空被丢弃第二次事件因幂等被丢弃”这种组合就出现了。用户实际操作中第一次进入卡片时因为 Observer 问题没有产生有效事件第二次进入时产生了事件但幂等层以为之前已经处理过直接丢掉。前端照样收到 HTTP 200进度照样是 0/1。幂等 keyuserId taskId yyyy-MM-dd 实际情况第一次无效事件占用了 key第二次有效事件被错误去重这提醒我们幂等 key 的设计要跟着“业务有效性”走而不是笼统地按用户和任务类型来。如果用户一次无效尝试占用了当天的幂等名额后续所有有效尝试都会变成静默失败。4. 修复方案与回归验证4.1 修复一让 Observer 建立时机和卡片渲染对齐针对失败点一最直接的修复是把初始化逻辑从“路由挂载”延后到“DOM 里出现目标卡片”。我当时用的是三步保留原有的初始尝试但增加日志console.warn([task-event] observer target not found)。用MutationObserver监听列表容器直到.inspire-card出现后再建 Observer。加上超时兜底如果 3 秒后还没出现卡片把失败信息上报到监控。const container document.querySelector(#inspire-list); const waitCard new MutationObserver(() { const target document.querySelector(.inspire-card[data-id currentCardId ]); if (target) { waitCard.disconnect(); observer.observe(target); } }); waitCard.observe(container, { childList: true, subtree: true });这里要提醒一个细节MutationObserver本身也可能触发多次所以observe之前必须先disconnect否则同一个卡片会被观察两遍事件重复上报。虽然幂等挡得住但如果幂等 key 修好了重复上报反而会让任务进度变得不可控。4.2 修复二上报前快照所有关键字段失败点二的修复不是去掉延迟而是让延迟期间不能丢关键字段。具体做法是在用户真正点开卡片那一刻就把cardKey从全局状态里拷贝到事件对象中const eventPayload { cardKey: taskState.activeCardKey, eventId: generateUUID(), startedAt: Date.now() }; setTimeout(() { sendTaskEvent(IMPROVE_INSPIRATION, { ...eventPayload, duration: 5000 }); }, 5000);这样即使后面resetTaskState()把全局字段清空上报数据依旧是完整的。更稳妥的做法是把整个待发送队列做成持久化下次启动时重新尝试发送避免用户退回桌面导致定时器被系统回收。这一块我后来用localStorage做了一个简易队列实测下来效果不错至少没有再出现过“手机一锁屏就丢事件”的情况。4.3 修复三幂等 key 加入业务唯一 ID后端改起来相对简单把幂等 key 从userId taskId 日期升级为userId eventId。eventId由前端生成并随每次上报携带接口层只对相同eventId去重。这样即使同一天内第一次尝试无效第二次也会被当作新事件正常处理。同时我要求前端不再以 HTTP 状态码作为唯一成功标准而是解析响应体里的bizCode。如果bizCode是DUPLICATE或者INVALID_PARAM就触发一次告警。这样以后再出现类似静默失败至少能在监控里看到而不是等到用户反馈。4.4 回归验证按原路径跑三轮修复完成后我用测试账号做了回归结果如下表操作路径修复前结果修复后结果说明直接进入灵感广场点击文章停留 5 秒0/11/1Observer 正常建立进入后立即切换卡片再停留0/11/1快照机制生效清缓存后连续完成两次任务第一次 1/1第二次 0/1两次均 1/1幂等 key 生效停用网络后完成操作恢复网络0/1队列重试后 1/1待发送队列兜底这里要说明修复后不能只验证“能到 1/1”就收工还要验证“不会到 2/1”。因为任务模板requiredTimes1如果幂等和 Observer 双重失效可能导致事件重复上报后端可能把completedTimes累加成 2。虽然界面按模板显示 1/1但底层数据已经脏了。所以我在回归里专门加了一条快速重复点击路径确认重复事件确实被eventId去重。5. 翻包排查的通用方法 避免静默失败的工程习惯5.1 生产包排查三步走这次排查从头到尾其实可以归纳成三步以后遇到类似“任务不计数”“进度不对”的问题都能套用。第一步是“定位代码块”。不要凭感觉改仓库里的代码先下载线上包用中文文案或接口路径反查对应模块。压缩混淆后的文件不怕关键是找到入口字符串比如任务 ID、接口地址、报错文案。第二步是“还原判定函数”。把相关的赋值语句、条件分支摘出来搞清楚数据从哪里来、到哪里去、在哪个环节被丢弃。这一步要特别关注所有if (xxx) return;和catch分支因为它们是最可能的静默出口。建议把关键函数单独抽出来用注释逐行标注“这个分支为什么存在失败时会怎样”。第三步是“验证假设”。直接在浏览器 Sources 面板里用Overrides功能替换线上 JS加日志、加断点然后本地复现。也可以用一个代理工具把响应文件替换成修改版。验证通过后再回到源码修复并走发布流程。这套流程看起来慢实际上比反复猜原因再发版验证快得多。5.2 静默失败的四种典型模式这次遇到的三个失败点加上之前的“假成功”可以归纳成四类反复出现的模式条件不满足时直接 return且没有日志。这类问题最隐蔽因为代码“看起来正常”。异步回调引用了可变全局状态。写setTimeout、请求回调时一定要先快照所需字段。后端幂等 key 设计得比业务语义宽。去重本来是保护但设计太宽会变成误伤。只看 HTTP 状态码不校验业务码。HTTP 200 只代表请求到达后端不代表后端接受。我后来把这些模式直接写进了团队 Code Review 的检查清单。谁要是提交了“静默 return”或者“只判断res.status”的代码会被打回来补充日志和业务码校验。这不只是较真而是因为这类 bug 排查成本实在太高一次线上事故的时间成本远超写日志的几分钟。5.3 日常开发怎么让静默失败提前暴露如果你不想等线上用户反馈才知道出了问题建议做三件事。第一事件上报的关键路径至少要有一条debug日志默认关闭但可以远程开启。不要担心日志多现在的日志系统按关键字过滤成本很低。第二前端对任务类接口要统一封装一个handleBizResponse函数把 HTTP 状态、业务码、幂等标识、错误原因都打点上报。业务码为DUPLICATE或INVALID也要上报只是级别不同。第三为任务模块写一个“黄金路径”自动化用例每天定时模拟一次真实操作。比如用无头浏览器打开灵感广场、点击卡片、滚动、等待上报然后断言任务进度能变成 1/1。这样一来任何静默失败都会在每天早上的巡检里现形而不是等用户来骂。我个人在实际排查里最深的体会是所谓“静默失败”往往不是单点坏掉而是几个小问题叠加出来的。单独看每个点都能找到合理解释组合在一起就成了一个几乎无法定位的黑洞。所以现在的我只要看到进度类任务卡住第一反应不是质问业务逻辑而是先把所有“提前 return”和“吞异常”的地方列出来逐个问一句这里真的可以不做任何记录吗问完答案一般就出来了。