diff --git a/public/banners/two-ways-a-javascript-file-stops-working-and-looks-fine.png b/public/banners/two-ways-a-javascript-file-stops-working-and-looks-fine.png new file mode 100644 index 0000000..86d18f4 Binary files /dev/null and b/public/banners/two-ways-a-javascript-file-stops-working-and-looks-fine.png differ diff --git a/public/og/two-ways-a-javascript-file-stops-working-and-looks-fine.png b/public/og/two-ways-a-javascript-file-stops-working-and-looks-fine.png new file mode 100644 index 0000000..e62b8e5 Binary files /dev/null and b/public/og/two-ways-a-javascript-file-stops-working-and-looks-fine.png differ diff --git a/scripts/banner-gen/generate.mjs b/scripts/banner-gen/generate.mjs index c69efe0..0b453fd 100644 --- a/scripts/banner-gen/generate.mjs +++ b/scripts/banner-gen/generate.mjs @@ -1271,6 +1271,23 @@ BANNERS['authentik-css-greater-than-bug'] = { ], }; +BANNERS['two-ways-a-javascript-file-stops-working-and-looks-fine'] = { + titlebar: "rt - app.js, silently dead", + lines: [ + { t: 'prompt', text: "$" }, { t: 'cmd', text: "rtCheckHandlers() -> []" }, + { t: 'dim', text: "page renders zh-CN exactly as before - that is the problem" }, { t: 'err', text: "TypeError: t is not a function (applyTheme(t) shadowed t())" }, + { t: 'prompt', text: "$" }, { t: 'cmd', text: "?lang=pseudo strings stretched 1.6x" }, + { t: 'ok', text: "no horizontal overflow at 1440px" }, { t: 'hl', text: "assert effects, not intent: every hook really swaps" }, + { t: 'dim', text: "second cause: let LANG declared below its first call (TDZ)" }, { t: 'dim', text: "3 packs from one table - key drift cannot merge" }, + ], + flow: [ + { n: '1', label: "load the page" }, + { n: '2', label: "assert typeof t" }, + { n: '3', label: "switch to pseudo" }, + { n: '4', label: "assert no overflow" }, + ], +}; + // ---------- read frontmatter ---------- const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`); let category = 'devops'; diff --git a/scripts/og-gen/generate.mjs b/scripts/og-gen/generate.mjs index f26e3da..00a4c22 100644 --- a/scripts/og-gen/generate.mjs +++ b/scripts/og-gen/generate.mjs @@ -387,6 +387,11 @@ TERMINALS['authentik-css-greater-than-bug'] = `
 selector matches, computed display: list-item
$read the sheet back out of the CSS parser-> u003e, not >
`; +TERMINALS['two-ways-a-javascript-file-stops-working-and-looks-fine'] = ` +
$node --check app.js -> valid, no output
+
 TypeError: t is not a function (a parameter named t)
+
$typeof t === 'function' ?lang=pseudo clean-> script reaches the end
`; + // ---------- read frontmatter ---------- const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`); if (!existsSync(postPath)) { diff --git a/src/content/posts/two-ways-a-javascript-file-stops-working-and-looks-fine.md b/src/content/posts/two-ways-a-javascript-file-stops-working-and-looks-fine.md new file mode 100644 index 0000000..ca89c72 --- /dev/null +++ b/src/content/posts/two-ways-a-javascript-file-stops-working-and-looks-fine.md @@ -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 ``。 + +部署上去之后,页面和之前一模一样。不是坏了——是**毫无变化**。同样的版式、同样的中文标签,没有缺失的面板、没有错误卡片、控制台也没有告警。语言选择器就是不在那里,而新代码本该做的其它事也一件都没发生。 + +这正是我想写下来的那种 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. **当一次改动「什么都没做」时,先怀疑脚本,再怀疑改动。** 「什么都没发生」是渲染型页面的默认失败形态,不是你代码没有生效的证据。 + +## 结果 + +两个一行原因,在同一个文件里,都是靠浏览器侧的断言找出来的,而不是靠把代码再读一遍。看板现在能在运行时切换语言,默认渲染与改动前逐字节一致,三份语言包出自同一张生成的表——于是少一个键会表现为一条红色的测试,而不是一个安静地保持中文的词。 + +可以推广的那句话是我一直记着的:**崩溃会告诉你该往哪里看,而静默停摆什么都不告诉你,还长得像成功。** 如果一个功能可以在一个像素都不动的情况下失效,那么「页面看起来还是对的」就不算一条检查。