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,175 @@
|
||||
---
|
||||
title: "Two Ways a JavaScript File Stops Working and Looks Fine"
|
||||
description: "A shadowed function name and a let declared below its first call both killed a whole script — and the page rendered perfectly, because nothing had changed on screen."
|
||||
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
|
||||
---
|
||||
|
||||
## The change that did nothing at all
|
||||
|
||||
I added a language switcher to a dashboard I run for a media-conversion
|
||||
queue. Three language packs, a `t(key, vars)` function, `data-i18n`
|
||||
attributes on the chrome, a `<select>` in the top bar.
|
||||
|
||||
After deploying it, the page looked exactly as it had before. Not broken —
|
||||
*unchanged*. Same layout, same Chinese labels, no missing panels, no error
|
||||
card, no console banner. The switcher was simply not there, and neither was
|
||||
anything else the new code was supposed to do.
|
||||
|
||||
That is the shape of bug I want to write down, because the page was not
|
||||
"mostly working". **The script had stopped executing partway through —
|
||||
twice, for two unrelated one-line reasons — and the page rendered perfectly
|
||||
anyway.**
|
||||
|
||||
## Why nothing looked broken
|
||||
|
||||
Two design decisions I would make again, and both of them hid the failure.
|
||||
|
||||
The first is that the default language lives in the markup. The Chinese text
|
||||
is written inside the elements; `data-i18n` is only a hook that *overrides*
|
||||
it when you switch. That is deliberate — the page needs no fetch to render,
|
||||
and if a language pack goes missing you get Chinese instead of a row of
|
||||
`top.theme_to_dark` keys. It also means a completely dead i18n layer
|
||||
produces a page that is, visually, correct.
|
||||
|
||||
The second is that the buttons are wired with inline handlers
|
||||
(`onclick="rtFoo()"`), which after a script abort point at functions that
|
||||
were never defined. Nothing warns you. The page only reveals the truth when
|
||||
a human clicks something.
|
||||
|
||||
So the symptom was "my change had no effect", which is the least informative
|
||||
symptom a web page can give you.
|
||||
|
||||
## Failure one: a parameter that swallowed a function
|
||||
|
||||
The file has a theme block and an i18n block. The theme block had this
|
||||
signature:
|
||||
|
||||
```js
|
||||
function applyTheme(t) { // t = the theme
|
||||
label.textContent = t('top.theme_to_dark'); // TypeError: t is not a function
|
||||
}
|
||||
```
|
||||
|
||||
`applyTheme` was written before the i18n layer existed, and `t` was a
|
||||
perfectly reasonable name for "theme". When the i18n layer arrived, `t` in
|
||||
that scope stopped meaning the theme and started meaning *the function I
|
||||
wanted to call* — except the parameter shadows it, so the call became
|
||||
"invoke a string".
|
||||
|
||||
A `TypeError` at the top level of a classic script does not just fail one
|
||||
line. Everything after it never runs: the language-pack loader, the
|
||||
`data-i18n` sweep, the switcher rendering. One wrong letter removed an
|
||||
entire feature, silently.
|
||||
|
||||
The fix is one character — rename the parameter to `th` — plus a comment
|
||||
where the next person will look:
|
||||
|
||||
```js
|
||||
function applyTheme(th) { // NOT `t`: t() is the i18n function in this file
|
||||
```
|
||||
|
||||
## Failure two: a declaration that arrived too late
|
||||
|
||||
The second cause was in the same file, and it is a JavaScript rule people
|
||||
know and still trip over. The theme block runs at load and calls `t('…')`.
|
||||
The i18n block, further down the file, began with:
|
||||
|
||||
```js
|
||||
let LANG = localStorage.getItem('rt_lang') || 'zh-CN';
|
||||
```
|
||||
|
||||
`let` and `const` are hoisted but **not initialised**: between the start of
|
||||
the scope and that line they live in the temporal dead zone, and any access
|
||||
throws `Cannot access 'LANG' before initialization`. Because the theme block
|
||||
sits *above* the i18n block, the failure was not a race — it was guaranteed
|
||||
on every single load.
|
||||
|
||||
Note the distinction from `var`, which would have quietly given me
|
||||
`undefined` and a working page. The TDZ is the language protecting me from
|
||||
exactly the bug I was about to write; the only reason it looked like
|
||||
"nothing happened" is the markup fallback again.
|
||||
|
||||
The fix is ordering, and ordering here is a *dependency*, not a style
|
||||
choice: if a block calls `t()`, the i18n declarations have to be above it.
|
||||
The file had been organised by topic ("theme, then i18n") because that is
|
||||
how it grew.
|
||||
|
||||
## Why my checks did not catch either one
|
||||
|
||||
I had tests. They checked intent, not effect:
|
||||
|
||||
- the three language packs exist and parse;
|
||||
- every key used in the markup exists in every pack;
|
||||
- no pack contains a dead key (a typo detector);
|
||||
- the `{var}` placeholder sets in the three packs match.
|
||||
|
||||
All true. All green. None of them executes the page, so none of them can see
|
||||
a script that aborts on line 12 of 900. Static checks are structurally
|
||||
unable to catch "the file is valid and the program is dead".
|
||||
|
||||
## Assert on effects, in a browser
|
||||
|
||||
What found both bugs was asking the live page questions that only a running
|
||||
program can answer:
|
||||
|
||||
```js
|
||||
typeof t === 'function' // is the entry point still callable?
|
||||
document.querySelectorAll('[data-i18n]').length // hooks present
|
||||
rtCheckHandlers() // do the inline handlers still resolve?
|
||||
```
|
||||
|
||||
That last one is worth more than it looks. The page carries **219 inline
|
||||
`onclick` attributes naming 217 distinct functions**; the check enumerates
|
||||
them and asserts each name is defined on `window`. It turns a hand-clicked
|
||||
smoke test into a two-second automated one, and it is the check that turns
|
||||
"the page looks right" into a measurable property.
|
||||
|
||||
The second tool is cheaper and nastier: a **pseudo-language**.
|
||||
`?lang=pseudo` takes every string in the pack, prefixes it with `[~~` and
|
||||
stretches it 1.6×. Then assert two things — no horizontal overflow at
|
||||
1440px, and no element in the nav still lacks the `[~~` prefix. The first
|
||||
catches layout that only breaks in a longer language; the second catches
|
||||
nodes that never got a translation hook at all. It found a real one in the
|
||||
same run.
|
||||
|
||||
Honest scope note: a pseudo-locale does not prove your Malay translation is
|
||||
*good*. It proves the *plumbing* is complete — every string flows through
|
||||
the mechanism, and the layout survives length. Judgement about the words
|
||||
stays with a human.
|
||||
|
||||
## What I would do differently
|
||||
|
||||
1. **Load the page in a real browser and assert `typeof` on the entry points
|
||||
before believing a client-side refactor worked.** A dead script is
|
||||
invisible to every static check you own.
|
||||
2. **Never let a parameter name shadow a function the same file calls.** One
|
||||
letter bought me an entire afternoon; the comment is the part that stops
|
||||
it recurring.
|
||||
3. **Treat declaration order as a dependency, not aesthetics.** If a block
|
||||
calls a function or reads a constant, that declaration goes above it —
|
||||
especially in one long script.
|
||||
4. **Generate the language packs from one table.** Then "the keys match" is
|
||||
a test rather than a hope, and drift cannot be merged.
|
||||
5. **When a change does nothing, suspect the script before the change.**
|
||||
"Nothing happened" is the default failure mode of a rendered page, not
|
||||
evidence that your code was inert.
|
||||
|
||||
## The result
|
||||
|
||||
Two one-line causes, both in the same file, both found by browser-side
|
||||
assertions rather than by reading code again. The dashboard switches
|
||||
language at runtime now, the default render is byte-for-byte what it was
|
||||
before the change, and the three packs come from a single generated table —
|
||||
so a missing key shows up as a red test instead of as a word that quietly
|
||||
stays Chinese.
|
||||
|
||||
The generalisation is the part I keep: **a crash tells you where to look. A
|
||||
silent stop tells you nothing, and looks like success.** If a feature can
|
||||
fail without moving a single pixel, then "the page still looks right" is not
|
||||
a check.
|
||||
@@ -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