关于 JavaScript 的那些怪异之处
关于 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
时,它会运行一个稍微不同的算法:
我再怎么强调,在 每个函数调用 的规范中都有这个 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
、-0
、NaN
、""
、null
、undefined
和 0n[](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 标准中(你通常会在其中看到特定于浏览器的内容),所以我将其保留:
为什么?因为在旧版本的 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
将始终在 n
和 2n
之间,具体取决于有多少个代理项。
但是,这有什么输出?
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 将它们全部混合在一起:
- 23,UTF-16 代码单元的数量(大多数字符串函数,例如
.length
、.split
等) - 15,Unicode 代码点的数量(使用
for
迭代字符串时) - 2,显示字符 的数量(可能因你的浏览器的表情符号支持而异)
- 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
以下是我没有足够的空间来撰写有关的奇怪行为的列表:
- 任何与
==
和!=
有关的事情 - 任何与类型强制转换有关的事情
- 任何与
this
有关的事情 - NaN 不等于任何东西
- +0 与 -0
- 任何与浮点精度有关的事情,或 IEEE 754 涵盖的其他内容
typeof null
是"object"
- 任何使用非严格模式或
var
的内容 - 从构造函数返回原始值
- 原型污染
Array.sort
将数字转换为字符串- ...
如果你知道我未在此处列出的任何怪癖,我希望你可以在 Twitter 或 Bluesky 上告诉我。如果你还没有,请查看 我们关于 OAuth 的博客文章!