每次加载 PDF 都会触发相同的 ReferenceError。堆栈轨迹(stack trace)指向的地方毫无用处,而指导代码编写的规范在纸面上看起来也完全合理。它要求检查一个特性标志(feature flag),并将每个区域与 pageWidth 的一半进行比较。它描述了应该发生什么,但却没有描述 pageWidth 应该从哪里获取,而仅仅这一个疏忽就足以让整个流水线崩溃。

架构文档擅长解释行为,但往往不擅长解释边界。一句“函数将 x 与 pageWidth 进行比较”并不是一个技术契约;它是一个用平实的英语隐藏了依赖关系的叙述。当开发者读到这句话并编写一个通过名称引用 pageWidth 的模块级函数时,代码看起来是正确的,因为它符合描述。随后,运行时尝试解析该名称,在作用域内找不到任何内容,于是抛出了错误。

那个本该简单的重构

我在一次页面组装重构中看到了完全相同的模式。规范列出了两个看似清晰的要求:

  • 检查 FEATURE_LAYOUT
  • 将每个区域与 pageWidth / 2 进行比较。

开发者忠实地执行了指令。他们在模块作用域下提取了一个工具函数,并将 pageWidth 直接放入函数体中,而没有将其作为参数进行说明。规范并未说明 pageWidth 必须通过参数列表传入。它也没有说明该函数位于模块作用域,在那里 pageWidth 已不再可见。它只是理所当然地假设实现者隐含地理解了执行上下文。

结果是每次加载 PDF 时都会出现 ReferenceError。因为该变量在模块作用域中不存在,函数立即抛出了异常。如果规范明确指出了函数的边界及其输入,开发者就会将 pageWidth 传入,那么这个 bug 在结构上就不可能发生。相反,这条指令就像一个陷阱,诱导实现者去访问一个并不存在的父级作用域。

“使用”的四种含义

更深层的问题在于,散文式的描述缺乏类型系统。当规范说“函数使用 X”时,在现代 JavaScript 代码库中,这句话至少在四个具体的维度上具有歧义:

  • 函数将 X 作为形式参数接收。
  • 函数从同一文件中声明的模块级变量中读取 X。
  • 函数通过闭包捕获了嵌套父作用域中的 X。
  • 函数从传入它的一个更大的对象中提取 X。

这些选项中的每一个都符合规范的措辞。每一个都能通过静态分析。但对于给定的边界,只有其中一种是正确的,而错误的选择会以一种在编译时静默发生的方式,将假设泄露到边界之外。

开发者在编写代码时通常会选择阻力最小的路径。如果 pageWidth 恰好位于外部作用域,他们会直接从那里读取,而不是修改函数签名。闭包隐藏了依赖关系。代码在第一次运行时正常工作,通过了测试套件,然后发布。几周后,有人为了复用或提高可读性,将同一个函数移动到了另一个文件中。父级作用域消失了。代码崩溃了,而且这种崩溃看起来像是一个新的回归问题,尽管根本原因在于最初隐藏的依赖关系。

Web Workers 抹去了证据

一旦 Web Workers 进入架构,这个问题就会变得极其棘手。当 Worker 内部发生错误时,浏览器会剥离掉你最需要的信息。

实际发生的情况如下:在 Worker 内部,一个未捕获的异常会触发一个 ErrorEvent。如果 Worker 将该错误转发到主线程,典型的做法是获取 message 字符串并将其跨越边界发送。主线程接收到该字符串,并基于它构建一个新的 Error 对象,然后进行记录或重新抛出。在 DevTools 中显示的是主线程消息处理器中重建的错误。原始的文件名、行号和堆栈轨迹都被丢弃了。故障的真实位置变得不可见。

因此,当缺失的 pageWidth 在 Worker 内部触发 ReferenceError 时,主线程仅在处理消息的地方报告了“pageWidth is not defined”这段文本。实际的模块级函数位于