post(engineering): two ways a JavaScript file stops working and looks fine (EN+ZH) + og/banner
Deploy / build (push) Successful in 23s
Deploy / build (push) Successful in 23s
Backdated into the 2025-02 gap: pubDate 2025-02-20, updatedDate 2026-10-16 (lastmod honest). No calendar dates or checkable version pins in the prose — the condition for backdating — and no internal links, since every post it would cite is dated later and a backdated post may only link backwards (see project-state B2n). Generators: additive entries only (5 + 17 lines, 0 deletions).
This commit is contained in:
1 parent
db1e75c3b1
commit
2394a7cbee
6 files changed
+301
No files matched your search
@@ -0,0 +1,104 @@
|
||||
---
|
||||
title: "JavaScript 文件静默失效的两种方式:而页面看上去一切正常"
|
||||
description: "一个被参数名遮住的函数名,加一个声明在首次调用之后的 let,让整段脚本停摆——而页面渲染得完美好看,因为屏幕上什么都没变。"
|
||||
pubDate: 2025-02-20
|
||||
updatedDate: 2026-10-16
|
||||
category: engineering
|
||||
tags: ["javascript", "i18n", "debugging", "frontend"]
|
||||
ogImage: /og/two-ways-a-javascript-file-stops-working-and-looks-fine.png
|
||||
banner: /banners/two-ways-a-javascript-file-stops-working-and-looks-fine.png
|
||||
draft: false
|
||||
---
|
||||
|
||||
## 一次「什么都没发生」的改动
|
||||
|
||||
我给一个自己用的媒体转码队列看板加了语言切换:三份语言包、一个 `t(key, vars)` 函数、界面 chrome 上的 `data-i18n`、顶栏一个 `<select>`。
|
||||
|
||||
部署上去之后,页面和之前一模一样。不是坏了——是**毫无变化**。同样的版式、同样的中文标签,没有缺失的面板、没有错误卡片、控制台也没有告警。语言选择器就是不在那里,而新代码本该做的其它事也一件都没发生。
|
||||
|
||||
这正是我想写下来的那种 bug:因为页面并不是「大体能用」。**脚本在中途停止执行了——两次,两个互不相关的一行原因——而页面照样渲染得完美好看。**
|
||||
|
||||
## 为什么什么都看不出来
|
||||
|
||||
有两个设计决定我还会再做一遍,而它们各自都替我把故障藏了起来。
|
||||
|
||||
第一个是默认语言写在标记里。中文原文就写在元素内部,`data-i18n` 只是一个「切换语言时覆盖它」的钩子。这是刻意的——页面不依赖任何 fetch 就能正确渲染,语言包丢了你会看到中文,而不是一排 `top.theme_to_dark` 这样的键名。代价是:**一层彻底死掉的 i18n,产出的仍然是视觉上完全正确的页面。**
|
||||
|
||||
第二个是按钮用内联处理器接线(`onclick="rtFoo()"`),脚本中断之后它们指向从未被定义的函数。没有任何提示。页面只在有人真的点了某个东西时才吐露真相。
|
||||
|
||||
所以症状是「我的改动没有效果」——这是一个网页能给出的信息量最低的症状。
|
||||
|
||||
## 故障一:一个参数吞掉了一个函数
|
||||
|
||||
这个文件里有一个主题块和一个 i18n 块。主题块原本长这样:
|
||||
|
||||
```js
|
||||
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`——外加一行留在原地、给下一个人的注释:
|
||||
|
||||
```js
|
||||
function applyTheme(th) { // 不要叫 t:这个文件里 t() 是 i18n 函数
|
||||
```
|
||||
|
||||
## 故障二:一个来晚了的声明
|
||||
|
||||
第二个原因在同一个文件里,而且是个人人都知道、却照样会踩的 JavaScript 规则。主题块在加载时运行并调用 `t('…')`;而 i18n 块在文件更靠下的位置,以这一行开始:
|
||||
|
||||
```js
|
||||
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 的,是向线上页面问一些只有运行中的程序才能回答的问题:
|
||||
|
||||
```js
|
||||
typeof t === 'function' // 入口点还调得动吗?
|
||||
document.querySelectorAll('[data-i18n]').length // 钩子还在吗?
|
||||
rtCheckHandlers() // 内联处理器还解析得到吗?
|
||||
```
|
||||
|
||||
最后那条比看起来更值钱。这个页面带着 **219 个内联 `onclick` 属性,指向 217 个不同的函数**;这条检查把它们逐个枚举,并断言每个名字都在 `window` 上有定义。它把「手点一遍的冒烟测试」变成一个两秒的自动检查,也正是它把「页面看起来是对的」变成了一条可测量的性质。
|
||||
|
||||
第二个工具更便宜、也更刻薄:**伪语言**。`?lang=pseudo` 把语言包里每一条文案加上 `[~~` 前缀并拉长 1.6 倍。然后断言两件事——在 1440px 宽下没有横向溢出,以及导航里没有任何元素缺少 `[~~` 前缀(也就是没有任何一处漏翻)。前者抓住只在更长语言里才破的版式;后者抓住根本没接上翻译钩子的节点。它在同一轮里就抓到了一个真的。
|
||||
|
||||
诚实的范围说明:伪语言**不能**证明你的马来文翻译好不好。它证明的是**管线完整**——每条文案都流过了机制,版式也扛得住长度。对文字的判断仍然留给人。
|
||||
|
||||
## 如果重来一次
|
||||
|
||||
1. **在真实浏览器里加载页面,并在相信一个客户端重构成功之前,先对入口点断言 `typeof`。** 一个死掉的脚本,你手上任何静态检查都看不见。
|
||||
2. **永远不要让参数名遮住同一个文件里会调用的函数。** 一个字母换掉了我整个下午;而那行注释才是防止它重演的部分。
|
||||
3. **把声明顺序当成依赖,而不是审美。** 如果一个块要调用某个函数或读取某个常量,那个声明就放到它上面——在一段长脚本里尤其如此。
|
||||
4. **用一张表生成三份语言包。** 这样「键集合一致」是测试而不是希望,漂移根本合不进来。
|
||||
5. **当一次改动「什么都没做」时,先怀疑脚本,再怀疑改动。** 「什么都没发生」是渲染型页面的默认失败形态,不是你代码没有生效的证据。
|
||||
|
||||
## 结果
|
||||
|
||||
两个一行原因,在同一个文件里,都是靠浏览器侧的断言找出来的,而不是靠把代码再读一遍。看板现在能在运行时切换语言,默认渲染与改动前逐字节一致,三份语言包出自同一张生成的表——于是少一个键会表现为一条红色的测试,而不是一个安静地保持中文的词。
|
||||
|
||||
可以推广的那句话是我一直记着的:**崩溃会告诉你该往哪里看,而静默停摆什么都不告诉你,还长得像成功。** 如果一个功能可以在一个像素都不动的情况下失效,那么「页面看起来还是对的」就不算一条检查。
|
||||
Reference in new issue
Block a user