关于 JavaScript 的那些怪异之处

Konstantin Wohlwend 2025年4月1日

“JavaScript 真烂,因为 '0' == 0!”

  • 几乎所有人

当然,JavaScript 的这部分确实很糟糕,但现在的每个 JS 设置都包含一个 linter,会对你编写的类似代码发出警告。

相反,我想谈谈 JavaScript 的一些更奇怪的特性——那些比这更阴险的特性——你在 r/ProgrammerHumor 或 JS 教程中找不到的那种东西。

所有这些都可能发生在任何 JavaScript/ECMAScript 环境(因此是浏览器、Node.js 等)中,无论是否启用 use strict。(如果你正在处理没有严格模式的遗留项目,你应该赶紧跑路。如果你不知道去哪里:[Stack Auth 正在招聘](https://stack-auth.com/blog/https:/news.ycombinator.com/item?id=42308388)。)

#1. eval 比你想象的更糟糕

认为这两者是相同的,这将是多么愚蠢:

function a(s) {
 eval("console.log(s)");
}
a("hello"); // 打印 "hello"

function b(s) {
 const evalButRenamed = eval;
 evalButRenamed("console.log(s)");
}
b("hello"); // Uncaught ReferenceError: s is not defined

区别在于前者可以访问当前作用域中的变量,而重命名版本只能访问全局作用域。

为什么?事实证明,ECMAScript 函数调用的定义有一个硬编码的特殊情况,当调用的函数被称为 eval 时,它会运行一个稍微不同的算法:

eval spec

我再怎么强调,在 每个函数调用 的规范中都有这个 hack 是多么疯狂!虽然不用说,任何像样的 JS 引擎都会对其进行优化,因此虽然没有直接的性能损失,但它肯定会使构建工具和引擎更加复杂。(例如,这意味着 (0, eval)(...)eval(...) 不同,因此 minifier 在删除看似无用的代码时必须考虑到这一点。可怕!)

#2. JS 循环假装它们的变量是通过值捕获的

是的,标题毫无意义,但你很快就会明白我的意思。让我们从一个例子开始:

for (let i = 0; i < 3; i++) {
 setTimeout(() => console.log(i));
}
// 打印 "0 1 2" — 正如预期的那样
let i = 0;
for (i = 0; i < 3; i++) {
 setTimeout(() => console.log(i));
}
// 打印 "3 3 3" — 什么?

为什么变量的定义位置很重要?无论如何,它都是同一个变量,对吧?

在任何编程语言中,当你使用 lambda/箭头函数捕获值时,有两种传递变量的方式:按值(复制)或按引用(传递指针)。有些语言(如 C++)允许你选择:

// C++ 代码如下:
// 按值捕获
int byValue = 0;
auto func1 = [byValue] { std::cout << byValue << std::endl; };
byValue = 1;
func1();
// 打印 0,因为变量的值被复制

// 按引用捕获
int byReference = 0;
auto func2 = [&byReference] { std::cout << byReference << std::endl; };
byReference = 1;
func2();
// 打印 1,因为变量是通过引用捕获的

也就是说,大多数高级语言(JS、Java、C#,…)通过引用捕获变量:

let byReference = 0;
const func = () => console.log(byReference);
byReference = 1;
func();
// 打印 1

通常,这是你想要的,但在循环中尤其不受欢迎。在那里,你通常需要在回调函数中使用迭代器变量做一些事情:

// C# 代码如下:
for (int i = 0; i < 3; i++) {
 setTimeout(() => {
  Console.WriteLine(i);
 }, 1000 * i);
}
// 打印 "3 3 3" — 可能不是你想要的

作为一种“修复”,ECMAScript 标准对 for 循环变量进行 hack 以使其具有不同的行为,但前提是它们是在循环标头中定义的:

for (let i = 0; i < 3; i++) {
 setTimeout(() => {
  console.log(i);
 }, 1000 * i);
}
// 打印 "0 1 2"
// 但如果我们分解出循环变量,它就不起作用:
let i = 0;
for (i = 0; i < 3; i++) {
 setTimeout(() => {
  console.log(i);
 }, 1000 * i);
}
// 打印 "3 3 3"

我把这个发布在 Twitter 上,你们中的很多人告诉我,如果你了解 for 循环和闭包如何在 ECMAScript 标准中根据作用域定义,那么这“有道理”。这是真的,尽管从某种意义上说,它非常奇怪,因为它真的不符合大多数人的直觉。更准确地说,如果你想在 JavaScript 中展开 for 循环,这将是符合规范的方法:

// 展开 for 循环的直观方式(在 JS 中是错误的)
let i = 0;
while (i < 3) {
 // ... for-loop body ...
 i++;
}
// 展开 for 循环的符合规范的方式
let _iteratorVariable = 0;
while (_iteratorVariable < 3) {
 let i = _iteratorVariable;
 // ... for-loop body ...
 i++;
 _iteratorVariable = i;
}

也就是说,几乎没有人谈论它的事实证明了这些“hack”有时非常有用。(TypeScript 的类型系统有很多像这样的“有用”的 hack,我认为这就是为什么它尽管很复杂但仍然如此受欢迎的部分原因——总有一天我应该写一篇关于它的文章。)

#3. 那个 falsy 对象

常识是 JavaScript 中有 8 个 falsy 值:false+0-0NaN""nullundefined0n[](https://stack-auth.com/blog/<https:/developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt>)

糟糕,我说谎了。实际上还有第九个,它是一个对象:

console.log(document.all); // 打印 HTMLAllCollection [<html>, <head>, ...]
console.log(Boolean(document.all)); // 打印 false

我几乎没有将此内容包含在此文章中,因为它仅影响浏览器。但事实证明,它实际上是在 ECMAScript 标准中指定的,而不是在 DOM 标准中(你通常会在其中看到特定于浏览器的内容),所以我将其保留:

document.all spec

为什么?因为在旧版本的 Internet Explorer 上,document.getElementById 不可用,而是有一个名为 document.all 的属性,因此编写了很多类似这样的代码:

if (document.all) { // IE-specific
 // 使用 document.all 做一些事情
} else { // every other browser
 // 使用 document.getElementById 做一些事情
}

为了与 IE 兼容,其他浏览器随后也实现了 document.all。但是,它比 document.getElementById 慢得多,因此这些浏览器决定 document.all 应该为 falsy,以便使上面的代码采用快速路径。我们不喜欢 IE 吗?

#4. 字素 & 字符串迭代

众所周知,JavaScript 中的字符串是 UTF-16 编码的,这意味着存在低代理项和高代理项。本质上,这意味着某些字符占用两个 UTF-16 代码单元:

const japanese = "𠮷";
console.log(japanese.length); // 打印 2
console.log(japanese.charCodeAt(0)); // 打印 55362
console.log(japanese.charCodeAt(1)); // 打印 57271

代理项总是成对出现,从不更多。因此,合理地,如果你有 n 个字符,那么 String.prototype.length 将始终在 n2n 之间,具体取决于有多少个代理项。

但是,这有什么输出?

const family = "👨‍👩‍👧‍👦‍👨‍👩‍👧‍👦"; // 两个家庭表情符号
console.log(family.length); // 打印 23

如果你精通 Unicode,你就会知道代理项并没有说明全部情况——某些字符(尤其是表情符号)由多个 Unicode 代码点组成(每个代码点可能是一个 UTF-16 代码单元,或一个代理对)。

现在,如果我们想遍历它们怎么办?

const family = "👨‍👩‍👧‍👦‍👨‍👩‍👧‍👦";
let count = 0;
for (const char of family) {
 count++;
}
console.log(count); // 打印 15

不同的数字?显然有些不对劲。

好吧,无论如何,新的 Intl API 存在于此目的,它们修复了这个混乱。对吗?

const family = "👨‍👩‍👧‍👦‍👨‍👩‍👧‍👦";
const chars = new Intl.Segmenter().segment(family);
console.log([...chars].length); // 打印 1

仍然不是 2!

本质上,有四种“字符串长度”的合理概念,而 JavaScript 将它们全部混合在一起:

  1. 23,UTF-16 代码单元的数量(大多数字符串函数,例如 .length.split 等)
  2. 15,Unicode 代码点的数量(使用 for 迭代字符串时)
  3. 2,显示字符 的数量(可能因你的浏览器的表情符号支持而异)
  4. 1,扩展字素簇 的数量 (Intl.Segmenter)

如果我们将上面的字符串粘贴到 Unicode 分析器中,它会更有意义:

UTF-16: 0x55357 0x56424 0x08205 0x55357 0x56425 0x08205 0x55357 0x56423 0x08205 0x55357 0x56422 0x08205 0x55357 0x56424 0x08205 0x55357 0x56425 0x08205 0x55357 0x56423 0x08205 0x55357 0x56422
      └────────┘    │    └────────┘    │    └────────┘    │    └────────┘    │    └────────┘    │    └────────┘    │    └────────┘    │    └────────┘  
Unicode:    Man  zero-width-joiner Woman  zero-width-joiner  Girl  zero-width-joiner  Boy      │      Man  zero-width-joiner Woman  zero-width-joiner  Girl  zero-width-joiner  Boy   
        └─────────────────────────────────────────────────────────────────────────────────┘      │      └─────────────────────────────────────────────────────────────────────────────────┘    
Display:                        Family                      zero-width-joiner                     Family                       
                             └───────────────────────────────────────────────────────────────────────────────────────────────────────────┘                        
Intl:                                               Extended grapheme cluster                                             

本质上,每个 Unicode 代码点正好是一个或两个 UTF-16 代码单元。每个浏览器/字体都有自己的规则来将它们合并到显示字符中,扩展字素簇算法试图近似它,但并不完美。

如果你好奇,Henri Sivonen 写了这篇出色的博客文章 关于其他语言做什么,但可悲的是,没有解决方案是完美的,因为国际化从根本上来说是一个难题。虽然,我想你总是可以 完全摆脱 Unicode

#5. 稀疏数组

你可以只在数组中重复逗号,使某些元素为 undefined

const sparse = [1, , , 4];
console.log(sparse[0], sparse[1], sparse[2], sparse[3]); // 打印 1 undefined undefined 4

或者不是?

const sparse = [1, , , 4];
sparse.forEach(e => console.log(e)); // 打印 1 4 — 不打印 undefined

让我们将其与普通数组进行比较:

const dense = [undefined, undefined];
const sparse = [,,];
console.log(dense.length); // 打印 2
console.log(sparse.length); // 打印 2
console.log(dense); // 打印 [undefined, undefined]
console.log(sparse); // 打印 [empty × 2]
console.log(dense.map(x => 123)); // 打印 [123, 123]
console.log(sparse.map(x => 123)); // 打印 [empty × 2]

这被称为“稀疏数组”。理解正在发生的事情的最简单方法是使用 Object.entries

console.log(Object.entries([1, undefined, undefined, 4]));
// 打印 [
//  ['0', 1],
//  ['1', undefined],
//  ['2', undefined],
//  ['3', 4]
// ]
console.log(Object.entries([1, , , 4]));
// 打印 [
//  ['0', 1],
//  ['3', 4]
// ]

JavaScript 数组实际上只是对象,而数组元素只是它的属性。如果某些属性丢失,这将完全搞乱许多内置数组方法。我们称之为稀疏数组。

也就是说,你可能根本不应该使用稀疏数组。不幸的是,Array 构造函数默认创建稀疏数组,从而导致非常不自然的代码:

const sparse = new Array(4);
console.log(sparse); // 打印 [empty × 4]
// 这一个也不起作用:
const stillNotDense = new Array(4).map(x => 123);
console.log(stillNotDense); // 打印 [empty × 4]
// 但你需要这样做:
const dense = new Array(4).fill(undefined).map(x => 123);
console.log(dense); // 打印 [123, 123, 123, 123]
// 或者你可以写这个:
const alsoDense = Array.from({ length: 4 }, () => 123);
console.log(alsoDense); // 打印 [123, 123, 123, 123]

如果这不能说服你,稀疏数组的性能也绝对糟糕。永远不要在你的代码中使用它们,你就会没事。

#6. 奇怪的 ASI 怪癖

这段代码会打印什么?(提示:它不是 2 1 4 3。)

function f1(a, b, c, d) {
 [a, b] = [b, a]
 [c, d] = [d, c]
 console.log(a, b, c, d)
}
f1(1, 2, 3, 4)

剧透 结果是 4 3 3 4

我缺少分号这一事实很好地暗示了正在发生的事情。有一个相当复杂的算法,称为 自动分号插入 (ASI),它试图通过一堆启发式方法来猜测它们应该去哪里。

[a, b] = [b, a]
[c, d] = [d, c]
// ASI 将其解释为:
[a, b] = [b, a][c, d] = [d, c]
       ^ ^
       | |
       | 逗号运算符
       |
       数组访问
// 这与以下内容相同:
[a, b] = [4, 3]
[b, a][4] = [4, 3]

ASI 的确切机制超出了本文的范围,但本质上,它会检查是否存在语法错误,如果存在,并且之前有换行符,它会插入一个分号。因此,如果没有语法错误,它通常不会插入分号。

从 ECMAScript 标准化委员会的角度来看,此规则非常严格。向该语言添加新语法意味着旧的语法错误可能不再是语法错误,但由于 ASI 依赖于在特定位置发生的语法错误,因此每个新语法都可能破坏旧代码。为此,该语言中存在特殊的所谓的 受限产生式,如果存在换行符,它们总是会插入分号,即使代码在语法上是正确的。

Et cetera

以下是我没有足够的空间来撰写有关的奇怪行为的列表:

如果你知道我未在此处列出的任何怪癖,我希望你可以在 TwitterBluesky 上告诉我。如果你还没有,请查看 我们关于 OAuth 的博客文章