零路由补路由 · 普查要能推翻已写的代码
入档:2026-08-20 来源:PB Arena(pb.tiaozhuxiansheng.com)自检收口与全站接入 hash 路由的整轮会话,两批共 9 文件 +476 −40,后端用例 28 → 29,两次部署上线 状态:已上线并在生产站逐条实测;insight 1/2/3/4 为本次首次发现 ⚠️;insight 5 是 成稿链接存进库没人读_写入方不等于展示方_v1 家族的第四形态;insight 6 是 全站文字截断体检_检测工具本身要先被证伪_v1 的人肉版第三次现形 前情:自检46条零驳回_同类中的异类才是缺陷藏身处_v1(同项目上一轮,2026-08-19)——本篇是那一轮清单里最后一项、也是唯一的架构级缺口的落地
事实记录(不可修改区)
- 起点:上一轮自检清单清空到只剩一项——全站零路由(对
pushState / popstate / hashchange / location.hash全文零命中)。后果:手机返回键直接弹出整个应用、链接不可分享、刷新永远回到大厅 - 前端是一个约 5300 行的
app.js(vanilla JS,无框架无构建),视图切换只有switchView(viewName)一个函数,10 个弹窗靠hidden属性开关 - 做法:动手前先跑了一轮集成点普查(四个方向并行摸底:视图切换入口 / 弹窗开关点 / 值得进 URL 的状态 / 接路由会打架的地方)
客观查证(不加解释)
-
选型实测:线上
curl深层路径下的相对资源——GET /events/abc/app.js → 200 Content-Type: text/html <!doctype html><html lang="zh-CN">…index.html引资源用的是相对路径(./app.js),走 History 路由时浏览器会去要这个地址,而服务端对任何未知路径都兜底回index.html -
普查推翻了已写代码:动手时我已经写好一版让
switchView自己写 hash 的实现(语法通过)。普查指出它会和函数内部的state.currentView赋值互相触发形成回环,正确做法是单向流(navigate()是全文唯一写 hash 的地方)。已写的那版被整段替换 -
否掉的建议:普查给的弹窗进历史方案是「收编三十来处裸
hidden赋值、每个弹窗各占一格历史」。实际采用popstate拦截(有弹窗开着时返回键先关弹窗、不换视图,然后把路由推回去),一个函数 -
时钟那条第一次修错:系统时钟跳变导致一个 tick 内递归穿完三阶段。第一版修法是加防重入守卫,自测仍然走到了结算——重新对时是异步的,在途期间 250ms 的 tick 照样拿旧偏移各推进一格。加了在途标记才稳住(实测:时钟前跳 20 分钟、连跑 8 拍,阶段不动)
-
普查顺带挖出五个存量 bug(均不在原自检清单里):首屏
state.currentView初值写死"lobby"而页面 active 的是首页且init全程不调switchView;switchView取视图元素那行无可选链(下一行取导航按钮的反而有);清会话完全不管弹窗(登出后弹窗浮着 +body仍带滚动锁的position:fixed,整页滚不动);六段重复的遮罩监听绕过统一出口;静态兜底对.js也回index.html -
生产实测:落地
#/home、七视图往返、前进后退、刷新停在当前页、返回键只关弹窗不换视图、全新标签打开分享链接直达赛事详情且返回落到列表仍在站内、坏 ID / 非法视图名 / 未登录进后台三种降级,控制台报错 0 -
自己的失误:写开赛时间的回归测试时把赛事列表接口写成
/api/admin/official-events(实际在/api/official-events)。这是同一种错的第三次——前两次是 action 名finished写成finish、好友接受漏了/respond
一句话总结
改动面大的功能,普查不只是用来「开工前列清单」的——它得能推翻你已经写出来的代码;判断该不该被它推翻,看对方给的是「哪一行会和哪一行互相触发」这种机制,还是「建议重构一下」这种口号。
六条可复用 insight
1 · 普查要能推翻已写的代码 ⚠️首次
动手前摸集成点,这件事本身不新鲜。这次真正值钱的是:普查推翻了一版已经写好、语法也过了的实现。
推翻一个想法很容易,推翻已经写出来的东西难得多——沉没成本是真实的,而且「它看起来能跑」会持续为自己辩护。这次做到了,唯一的原因是普查给的是具体机制(switchView 里的 state.currentView 赋值会和 hash 互相触发形成回环),不是一句「建议改成单向数据流」。
判据:收到与自己已有实现冲突的建议时,先问「它给的是机制还是口号」。给得出「哪一行和哪一行打架」的,认真对待;给不出的,可以先放着。
2 · 技术选型的探路实验是免费的 bug 探针 ⚠️首次
为了判断 hash 还是 History,我 curl 了一下线上的深层路径资源。这个实验的目的是回答「能不能做」,副产品却是「现在的行为对不对」——那条兜底规则对 .js 也生效,意味着任何资源路径写错都不是 404,而是 200 + 一坨 HTML 当脚本,浏览器只会抱怨 MIME 不符,报错位置和真正的原因完全对不上。
这个 bug 存在很久、不在任何清单上、正常使用也不会暴露(只有写错路径时才现形,而那时人会去查别的地方)。
规律:选型探路和行为体检共用同一批观察。多看一眼观察结果本身,别只从里面提取「能/不能」这一个比特。
3 · 完备方案 ≠ 该做的方案 ⚠️首次
普查建议的「三十处收编、每个弹窗各占一格历史」是完备的做法。实际采用的「一处 popstate 拦截」不完备——按返回只能关最上面那个弹窗,关不掉叠起来的一摞。
但本站几乎不叠弹窗,这个差别在实际使用里遇不到;而代价差了一个量级:三十个改动点意味着三十次「漏一处就出现历史与显隐脱节」的机会。
规律:来自普查 / 审计 / AI 的建议默认朝完备去,因为提建议的一方不承担回归成本。落地方必须自己做这一步权衡。
配套动作:把取舍写下来(这次写进了更新报告单列一节)。不写的话,半年后回看只会看到一个「没做完」的实现,不知道当时权衡过什么。
4 · 异步修复要问「在途期间会发生什么」 ⚠️首次
时钟跳变那条,第一版修法是加防重入守卫——它挡住了「一个 tick 内递归穿三阶段」,但挡不住「连续几个 tick 各推进一格」,因为重新对时是异步的,在途期间旧偏移仍然有效。自测时才发现还是走到了结算。
规律:给异步流程加同步守卫是不够的。守卫覆盖的是「同一次调用栈」,而异步意味着在结果回来之前还会有别的调用栈进来。修完问一句:这个操作在途的那几百毫秒里,谁还会跑?
这条差点就当成修好发出去——写自测的那几分钟是唯一拦住它的东西。
5 · 初值与初始渲染分处维护,中间没有同步动作,两边必漂 ⭐ 同族第四形态
state.currentView 初值写死 "lobby",而 HTML 里 active 的是首页,init 全程一次 switchView 都不调。于是首屏所有读这个状态的分支判的都是错的——对战悬浮条、房间聊天、赛事轮播、反馈的来源页字段。
为什么一直没暴露:用户几乎总会先点一下导航,一点就对了。只有「打开就直接用首屏功能」这条路径会撞上,而这条路径少见到没人报过。
这是 成稿链接存进库没人读_写入方不等于展示方_v1 家族的第四形态:前三种是「写入方 ≠ 推进方 / 展示方」「界面演完 ≠ 链路存在」,这一种是**「初值方 ≠ 渲染方」**。
判据:看到一个初始状态时问三句——这个初值是谁写的?谁在渲染时用它?它俩碰过面吗? 中间没有一次同步动作的,一定已经漂了。
6 · 「觉得自己知道于是没查」要变成动作,不是提醒 ⭐ 第三次现形
三次同一种错,全在写回归测试时:action 名 finished 写成 finish、好友接受漏了 /respond、赛事列表接口误加 admin 前缀。每次都是凭印象手打字符串。
这是 全站文字截断体检_检测工具本身要先被证伪_v1 那条律的人肉版本——那条讲「工具的输出是待验证的假设」,这条讲「我的记忆同样是待验证的假设」。
前两次我在复盘里写了「要先查」。没用,第三次照犯。 光记住不管用,得改成动作:
写任何调用接口的测试前,先 grep 出路由定义和 action 白名单,把实际字符串贴过来,不手打。
关联文档
- 同项目前情:自检46条零驳回_同类中的异类才是缺陷藏身处_v1(上一轮自检,本篇是清单最后一项的落地)· 公屏下线只删了界面_删界面不等于关链路_v1 · 零作品的比赛_界面演完不等于链路存在_v1 · 赛事状态机到点不切换_写入方不等于推进方_v1
- 核心律同族:成稿链接存进库没人读_写入方不等于展示方_v1(insight 5 是它的第四形态)· 全站文字截断体检_检测工具本身要先被证伪_v1(insight 6 是它的人肉版)
- 区索引:09_平台工程索引
- 对外版(无双链,可直接转发):
08_对外分发/给老项目补路由_四条踩过的坑_公开版.md