当 JS 变量遭遇“智子干扰”?

在刘慈欣的科幻小说《三体》中,人类科学家遭遇了一个前所未有的困境:来自三体世界的智子施加干扰,使得高能物理实验的结果变得混乱不堪,仿佛整个物理法则被悄然篡改,让原本铁律般的科学定律变得不再可信。这种状况引发了科学界的巨大恐慌,甚至有人宣称“物理学不存在了”。—— 那假如一段简单如 Hello world 的代码也出现了意料之外的运行结果,你是否还会相信代码世界的秩序和逻辑?

当然,至少到目前为止,小说中的情节并没有变为现实。所以让我们睁大眼睛,看以下 JavaScript 代码及其运行结果中,是哪里出现了问题。

一段“不可思议”的代码

let а = 1;
(() => {
  let a = 1;
  а -= 10;
  console.log('local a =', a);
})();
console.log('global a =', а);

乍一看,这段代码可能被用来演示 JavaScript 的变量作用域:变量 a 被声明了两次,一次在全局作用域中,另一次在匿名函数的局部作用域中。这两个变量虽然名字相同,但是它们存在于不同的作用域中,是相互独立的。在函数作用域内部对变量 a 进行的修改不会影响全局作用域中的变量 a。有编程基础的朋友应该可以指出这段代码的输出应是:

local a = -9
global a = 1

但假如你现在得知,这段代码的执行结果实际上是:

local a = 1
global a = -9

这是否出乎你的意料?不过幸运的是,这是一个可以复现的问题,你可以尝试执行这段代码,验证我所言非虚。


假如眼睛欺骗了你

如果你没有注意到全局作用域的 а 实际上和函数作用域的 a 是两个不同的字符,那么你可能已经落入了这个陷阱:代码中混入了 西里尔小写字母 а(U+0430)。它看起来与我们常用的 拉丁文小写字母 a(U+0061)极为相似,但在计算机的眼中,它们是两个不同的字符。

名称 字符 Unicode 码点
Cyrillic Small Letter A а U+0430
Latin Small Letter A a U+0061

在允许使用非 ASCII 字符作为变量名的 JavaScript 中,也对应着两个不同的变量名。因此,在匿名函数中,实际上既可以访问到全局变量 а,也可以访问到局部变量 a

const a = 1;
const а = 2;
console.log(a); // 1
console.log(а); // 2

所以谜底是?

为了便于阅读,以下内容会对字母 a 进行染色。

  • 西里尔小写字母 а 会用 红色 表示
  • 拉丁文小写字母 a 会用 蓝色 表示

如果你还没能搞清楚状况,我们可以一起睁大眼睛,逐行阅读这段代码:

  • 第 1 行 let а = 1;:声明了一个名为 а 的全局变量,并对其赋初始值 1
  • 第 2~6 行是一个立即执行函数表达式(IIFE),函数内声明的变量属于函数内的局部作用域

    • 第 3 行 let a = 1;:声明了一个名为 a 的局部变量,并对其赋初始值 1
    • 第 4 行 а -= 10;:将全局变量 а 的值减少 10(注意,由于变量名不同,所以实际上修改的不是局部变量 a
    • 第 5 行 console.log('local a =', a);:将局部变量 a 的值打印出来
  • 第 7 行 console.log('global a =', а);:将全局变量 а 的值打印出来

我们可以进行一下字符替换,将不常用的西里尔小写字母 а 替换为 alpha 来提高这段代码的可读性。替换后的代码如下,它的流程和逻辑与原始代码一致。相信此时大家都能正确地指出它的输出了。

let a = 1;
(() => {
  let alpha = 1;
  a -= 10;
  console.log('local a =', alpha);
})();
console.log('global a =', a);

有请 "Sherlock VSCode"

如果我们将这段“不可思议”的代码用 VSCode 打开,VSCode 会对其中易混淆的非 ASCII 字符进行标注和提示。

字符 U+0430 "а" 可能会与 ASCII 字符 U+0061 "a" 混淆,后者在源代码中更为常见。

让一切回归既有秩序

将西里尔小写字母 а 替换为拉丁文小写字母 a 后,我们可以得到最初所预期的执行结果。

let a = 1;
(() => {
  let a = 1;
  a -= 10;
  console.log('local a =', a);
})();
console.log('global a =', a);

它会输出:

local a = -9
global a = 1

谢天谢地,又是无 bug 的一天😆。