JavaScript 文件静默失效的两种方式:而页面看上去一切正常
一次「什么都没发生」的改动
我给一个自己用的媒体转码队列看板加了语言切换:三份语言包、一个 t(key, vars) 函数、界面 chrome 上的 data-i18n、顶栏一个 <select>。
部署上去之后,页面和之前一模一样。不是坏了——是毫无变化。同样的版式、同样的中文标签,没有缺失的面板、没有错误卡片、控制台也没有告警。语言选择器就是不在那里,而新代码本该做的其它事也一件都没发生。
这正是我想写下来的那种 bug:因为页面并不是「大体能用」。脚本在中途停止执行了——两次,两个互不相关的一行原因——而页面照样渲染得完美好看。
为什么什么都看不出来
有两个设计决定我还会再做一遍,而它们各自都替我把故障藏了起来。
第一个是默认语言写在标记里。中文原文就写在元素内部,data-i18n 只是一个「切换语言时覆盖它」的钩子。这是刻意的——页面不依赖任何 fetch 就能正确渲染,语言包丢了你会看到中文,而不是一排 top.theme_to_dark 这样的键名。代价是:一层彻底死掉的 i18n,产出的仍然是视觉上完全正确的页面。
第二个是按钮用内联处理器接线(onclick="rtFoo()"),脚本中断之后它们指向从未被定义的函数。没有任何提示。页面只在有人真的点了某个东西时才吐露真相。
所以症状是「我的改动没有效果」——这是一个网页能给出的信息量最低的症状。
故障一:一个参数吞掉了一个函数
这个文件里有一个主题块和一个 i18n 块。主题块原本长这样:
function applyTheme(t) { // t = 主题(theme)
label.textContent = t('top.theme_to_dark'); // TypeError: t is not a function
}
applyTheme 写在 i18n 层存在之前,那时 t 作为「theme」的缩写完全合理。等 i18n 来了,那个作用域里的 t 就不再是主题,而变成了我想调用的那个函数——但参数把它遮住了,于是这次调用成了「把一个字符串当函数调」。
经典脚本顶层抛出的 TypeError 不只让一行失败。它后面的一切都不再运行:语言包加载、data-i18n 扫描、切换器渲染。一个错字母,静默地删掉了一整块功能。
修法是一个字符——把参数改名成 th——外加一行留在原地、给下一个人的注释:
function applyTheme(th) { // 不要叫 t:这个文件里 t() 是 i18n 函数
故障二:一个来晚了的声明
第二个原因在同一个文件里,而且是个人人都知道、却照样会踩的 JavaScript 规则。主题块在加载时运行并调用 t('…');而 i18n 块在文件更靠下的位置,以这一行开始:
let LANG = localStorage.getItem('rt_lang') || 'zh-CN';
let 和 const 会被提升,但不会初始化:从作用域开始到那一行之间,它们待在暂时性死区(TDZ)里,任何访问都会抛 Cannot access 'LANG' before initialization。因为主题块就在 i18n 块上面,这不是竞态——是每次加载都必然发生。
注意它与 var 的区别:var 会安静地给我一个 undefined,然后页面继续工作。TDZ 是这门语言在阻止我写出即将写出的那个 bug;而它之所以看起来像「什么都没发生」,还是因为标记里那份回退文案。
修法是排序,而这里的排序是依赖,不是风格偏好:如果一个块调用了 t(),i18n 的声明就必须在它上面。这个文件按主题排布(「先是主题,后是 i18n」),因为它就是这么长出来的。
为什么我的检查一个都没抓到
我有测试。它们检查的是意图,不是效果:
- 三份语言包存在且能解析;
- 标记里用到的每个键,在每份包里都有;
- 每份包里没有死键(拼写错误探测器);
- 三份包的
{var}占位符集合一致。
全部为真,全部为绿。而它们没有一条会执行页面,所以没有一条能看见一个在 900 行的第 12 行停摆的脚本。静态检查在结构上就无法捕捉「文件合法、程序已死」这类故障。
在浏览器里,断言效果
真正找出这两个 bug 的,是向线上页面问一些只有运行中的程序才能回答的问题:
typeof t === 'function' // 入口点还调得动吗?
document.querySelectorAll('[data-i18n]').length // 钩子还在吗?
rtCheckHandlers() // 内联处理器还解析得到吗?
最后那条比看起来更值钱。这个页面带着 219 个内联 onclick 属性,指向 217 个不同的函数;这条检查把它们逐个枚举,并断言每个名字都在 window 上有定义。它把「手点一遍的冒烟测试」变成一个两秒的自动检查,也正是它把「页面看起来是对的」变成了一条可测量的性质。
第二个工具更便宜、也更刻薄:伪语言。?lang=pseudo 把语言包里每一条文案加上 [~~ 前缀并拉长 1.6 倍。然后断言两件事——在 1440px 宽下没有横向溢出,以及导航里没有任何元素缺少 [~~ 前缀(也就是没有任何一处漏翻)。前者抓住只在更长语言里才破的版式;后者抓住根本没接上翻译钩子的节点。它在同一轮里就抓到了一个真的。
诚实的范围说明:伪语言不能证明你的马来文翻译好不好。它证明的是管线完整——每条文案都流过了机制,版式也扛得住长度。对文字的判断仍然留给人。
如果重来一次
- 在真实浏览器里加载页面,并在相信一个客户端重构成功之前,先对入口点断言
typeof。 一个死掉的脚本,你手上任何静态检查都看不见。 - 永远不要让参数名遮住同一个文件里会调用的函数。 一个字母换掉了我整个下午;而那行注释才是防止它重演的部分。
- 把声明顺序当成依赖,而不是审美。 如果一个块要调用某个函数或读取某个常量,那个声明就放到它上面——在一段长脚本里尤其如此。
- 用一张表生成三份语言包。 这样「键集合一致」是测试而不是希望,漂移根本合不进来。
- 当一次改动「什么都没做」时,先怀疑脚本,再怀疑改动。 「什么都没发生」是渲染型页面的默认失败形态,不是你代码没有生效的证据。
结果
两个一行原因,在同一个文件里,都是靠浏览器侧的断言找出来的,而不是靠把代码再读一遍。看板现在能在运行时切换语言,默认渲染与改动前逐字节一致,三份语言包出自同一张生成的表——于是少一个键会表现为一条红色的测试,而不是一个安静地保持中文的词。
可以推广的那句话是我一直记着的:崩溃会告诉你该往哪里看,而静默停摆什么都不告诉你,还长得像成功。 如果一个功能可以在一个像素都不动的情况下失效,那么「页面看起来还是对的」就不算一条检查。