“JavaScript 很烂,因为
'0' == 0!”—— 几乎所有人都会这么说
确实,这一点很烂。但如今任何一个 JS 项目里,linter 都会对你写出这种代码大喊大叫。
所以我想聊的是 JavaScript 中一些更隐蔽的怪癖——它们比 '0' == 0 危险得多,而且你在 r/ProgrammerHumor 或 JS 教程里找不到。
以下所有问题都可能出现在任何 JavaScript/ECMAScript 环境(浏览器、Node.js 等)中,无论是否启用 'use strict'。(如果你还在维护没有启用严格模式的老项目,建议尽快跑路;如果不知道往哪跑——Hexclave 正在招人。)
1. eval 比你想象的更糟糕
如果以为下面两个函数行为相同,那就太天真了:
<span>function</span> <span>a</span>(<span>s</span>) {
<span>eval</span>(<span>"console.log(s)"</span>);
}
<span>a</span>(<span>"hello"</span>); <span>// 输出 "hello"</span>
<span>function</span> <span>b</span>(<span>s</span>) {
<span>const</span> evalButRenamed = <span>eval</span>;
evalButRenamed(<span>"console.log(s)"</span>);
}
<span>b</span>(<span>"hello"</span>); <span>// Uncaught ReferenceError: s is not defined</span>
区别在于:前者能访问当前作用域中的变量,而改名后的版本只能访问全局作用域。
为什么?因为 ECMAScript 规范中对函数调用的定义里硬编码了一个特例:当被调用的函数恰好名为 eval 时,会执行一个稍微不同的算法。
怎么强调都不过分:在规范里为每一次函数调用都埋这样一个 hack,实在太疯狂了!当然,任何还算靠谱的 JS 引擎都会对它做优化,所以它不会带来直接的性能损失,但确实让构建工具和引擎的实现复杂了很多。(举个例子:(0, eval)(...) 和 eval(...) 的语义并不相同,因此压缩器在删除“看似无用”的代码时必须考虑这一点。可怕!)
2. JS 循环假装变量是按值捕获的
标题听起来完全不通,但你马上就会明白。先看例子:
for (let <span>i</span> = <span>0</span><span>; i < 3; i++) {</span>
setTimeout(() => console.log(i))<span>;</span>
}
// 输出 "0 1 2" —— 符合预期
let <span>i</span> = <span>0</span><span>;</span>
for (<span>i</span> = <span>0</span><span>; i < 3; i++) {</span>
setTimeout(() => console.log(i))<span>;</span>
}
// 输出 "3 3 3" —— 什么鬼?
为什么变量定义的位置会影响行为?明明都是同一个变量啊?
在任何语言中,当 lambda/箭头函数捕获变量时,都有两种传递方式:按值(拷贝)或按引用(传递指针)。有些语言(如 C++)允许你选择:
// C++ 代码
// 按值捕获
int <span>byValue</span> = <span>0</span><span>;</span>
auto <span>func1</span> = [byValue] { std::cout << byValue << std::endl<span>; };</span>
<span>byValue</span> = <span>1</span><span>;</span>
func1()<span>;</span>
// 输出 0,因为拷贝了变量的值
// 按引用捕获
int <span>byReference</span> = <span>0</span><span>;</span>
auto <span>func2</span> = [&byReference] { std::cout << byReference << std::endl<span>; };</span>
<span>byReference</span> = <span>1</span><span>;</span>
func2()<span>;</span>
// 输出 1,因为按引用捕获
不过大多数高级语言(JS、Java、C# 等)都是按引用捕获:
let <span>byReference</span> = <span>0</span><span>;</span>
const <span>func</span> = () => console.log(byReference)<span>;</span>
<span>byReference</span> = <span>1</span><span>;</span>
func()<span>;</span>
// 输出 1
大多数情况下这正是你想要的,但在循环里就特别讨厌。循环中经常需要在回调函数里使用迭代变量:
// C<span># 代码</span>
for (int <span>i</span> = <span>0</span><span>; i < 3; i++) {</span>
setTimeout(() => {
Console.WriteLine(i)<span>;</span>
}, 1000 * i)<span>;</span>
}
// 输出 "3 3 3" —— 多半不是你想要的
作为一种“修复”,ECMAScript 标准给 for 循环的迭代变量打了补丁,赋予它不同行为——但仅当变量在循环头部定义时才生效:
for (let <span>i</span> = <span>0</span><span>; i < 3; i++) {</span>
setTimeout(() => {
console.log(i)<span>;</span>
}, 1000 * i)<span>;</span>
}
// 输出 "0 1 2"
// 但如果把循环变量提取出来就不行了:
let <span>i</span> = <span>0</span><span>;</span>
for (<span>i</span> = <span>0</span><span>; i < 3; i++) {</span>
setTimeout(() => {
console.log(i)<span>;</span>
}, 1000 * i)<span>;</span>
}
// 输出 "3 3 3"
我把这件事发到 Twitter 后,不少人告诉我:只要理解 ECMAScript 标准中 for 循环和闭包在作用域上的定义方式,这就“说得通”。没错,但从直觉上讲它确实非常怪异。更准确地说:如果你想在 JavaScript 中手动展开一个 for 循环,下面才是符合规范的写法:
// 直觉上的展开方式(在 JS 中是错的)
let <span>i</span> = <span>0</span><span>;</span>
while (i < 3) {
// ... for 循环体 ...
i++<span>;</span>
}
// 符合规范的展开方式
let <span>_iteratorVariable</span> = <span>0</span><span>;</span>
while (_iteratorVariable < 3) {
let <span>i</span> = _iteratorVariable<span>;</span>
// ... for 循环体 ...
i++<span>;</span>
<span>_iteratorVariable</span> = i<span>;</span>
}
话虽如此,几乎没人谈论这件事,恰恰说明这种 “hack” 有时非常有用。(TypeScript 的类型系统里就有大量这类“有用”的 hack,我想这也是它虽然复杂却依然流行的原因之一——改天我应该专门写篇文章聊聊这个。)
3. 那个 falsy 的对象
常识是:JavaScript 中有 8 个 falsy 值:false、+0、-0、NaN、""、null、undefined 和 0n。
抱歉,我撒谎了。其实有第 9 个,而且它是一个对象:
<span>console</span>.<span>log</span>(<span>document</span>.<span>all</span>); <span>// 输出 HTMLAllCollection [<html>, <head>, ...]</span>
<span>console</span>.<span>log</span>(<span>Boolean</span>(<span>document</span>.<span>all</span>)); <span>// 输出 false</span>
我本来差点没把这条写进文章,因为它只影响浏览器。但事实上它居然是写在 ECMAScript 标准里的,而不是你通常看到浏览器专属行为所在的 DOM 标准,所以还是留下了:
为什么?因为在老版本的 Internet Explorer 中没有 document.getElementById,取而代之的是一个叫 document.all 的属性,于是很多代码都写成这样:
<span>if</span> (<span>document</span>.<span>all</span>) { <span>// IE 专属</span>
<span>// 对 document.all 做点什么</span>
} <span>else</span> { <span>// 其他所有浏览器</span>
<span>// 对 document.getElementById 做点什么</span>
}
为了兼容 IE,其他浏览器也跟着实现了 document.all。但它比 document.getElementById 慢得多,于是这些浏览器决定让 document.all 成为 falsy,让上面这类代码走快速路径。我们真该谢谢 IE?
4. 字形与字符串迭代
相对广为人知的是:JavaScript 字符串采用 UTF-16 编码,存在高低代理对,这意味着某些字符会占用两个 UTF-16 码元:
<span>const</span> japanese = <span>"𠮷"</span>;
console.<span>log</span>(japanese.length); <span>// 输出 2</span>
console.<span>log</span>(japanese.<span>charCodeAt</span>(<span>0</span>)); <span>// 输出 55362</span>
console.<span>log</span>(japanese.<span>charCodeAt</span>(<span>1</span>)); <span>// 输出 57271</span>
代理对总是成对出现,绝无例外。因此合理推测:如果有 n 个字符,那么 String.prototype.length 应该介于 n 和 2n 之间,取决于代理对的数量。
那下面这段代码会输出什么?
<span>const</span> family = <span>"👨👩👧👦👨👩👧👦"</span>; <span>// 两个家庭 emoji</span>
console.<span>log</span>(family.length); <span>// 输出 23</span>
如果你非常熟悉 Unicode,就会知道代理对还不是全部——有些字符(尤其是 emoji)由多个 Unicode 码位组成(每个码位又可能是一个 UTF-16 码元或一对代理对)。
那么想迭代它们时会发生什么?
const <span>family</span> = <span>"👨👩👧👦👨👩👧👦"</span><span>;</span>
let <span>count</span> = <span>0</span><span>;</span>
for (const char of family) {
count++<span>;</span>
}
console.log(count)<span>; // 输出 15</span>
又一个不同的数字?显然哪里不对劲。
好吧,新出现的 Intl API 不就是为此而生、负责收拾这个烂摊子的吗?
const <span>family</span> = <span>"👨👩👧👦👨👩👧👦"</span><span>;</span>
const <span>chars</span> = new Intl.Segmenter().segment(family)<span>;</span>
console.log(<span>[...chars]</span>.length)<span>; // 输出 1</span>
还是不是 2!
本质上,“字符串长度”有四种合理定义,而 JavaScript 把它们混在一起用了:
- 23:UTF-16 码元数量(大多数字符串 API,如
.length、.split等) - 15:Unicode 码位数量(用
for迭代字符串时) - 2:显示字符数量(可能因浏览器的 emoji 支持而异)
- 1:扩展字形簇数量(
Intl.Segmenter)
如果把上面的字符串放进 Unicode 分析器,会更直观:
<span>UTF-16: 0x55357 0x56424 0x08205 ... 0x55357 0x56422</span>
└────────┘ │ └────────┘
<span>Unicode: Man ZWJ Woman ZWJ Girl ZWJ Boy │ Man ZWJ Woman ZWJ Girl ZWJ Boy</span>
└──────────────────────────────────┘ └──────────────────────────────────┘
<span>Display: Family ZWJ Family</span>
└─────────────────────────────────────────────┘
<span>Intl: Extended grapheme cluster</span>
简单来说:每个 Unicode 码位恰好是一个或两个 UTF-16 码元;每个浏览器/字体都有自己的规则决定如何把它们合并为显示字符;扩展字形簇算法试图逼近这个结果,但并不完美。
如果你感兴趣,Henri Sivonen 写过一篇非常棒的文章,介绍其他语言的处理方式。遗憾的是没有任何方案是完美的,因为国际化本质上就是个极难的问题。当然,你也可以选择彻底抛弃 Unicode。
5. 稀疏数组
在数组中连续使用逗号,就可以让某些元素变成 undefined:
const <span>sparse</span> = [<span>1</span>, , , <span>4</span>]<span>;</span>
console.log(sparse<span>[0]</span>, sparse<span>[1]</span>, sparse<span>[2]</span>, sparse<span>[3]</span>)<span>; // 输出 1 undefined undefined 4</span>
真的吗?
const <span>sparse</span> = [<span>1</span>, , , <span>4</span>]<span>;</span>
sparse.forEach(<span>e</span> => console.log(e))<span>; // 输出 1 4 —— 并没有输出 undefined</span>
和一个普通数组对比一下:
<span>const</span> dense = [<span>undefined</span>, <span>undefined</span>];
<span>const</span> sparse = [,,];
<span>console</span>.<span>log</span>(dense.<span>length</span>); <span>// 输出 2</span>
<span>console</span>.<span>log</span>(sparse.<span>length</span>); <span>// 输出 2</span>
<span>console</span>.<span>log</span>(dense); <span>// 输出 [undefined, undefined]</span>
<span>console</span>.<span>log</span>(sparse); <span>// 输出 [empty × 2]</span>
<span>console</span>.<span>log</span>(dense.<span>map</span>(<span><span>x</span> =></span> <span>123</span>)); <span>// 输出 [123, 123]</span>
<span>console</span>.<span>log</span>(sparse.<span>map</span>(<span><span>x</span> =></span> <span>123</span>)); <span>// 输出 [empty × 2]</span>
这就是所谓的“稀疏数组”。理解它最简单的方式是用 Object.entries:
console<span>.log</span>(<span>Object</span><span>.entries</span>(<span>[1, undefined, undefined, 4]</span>));
// 输出 <span>[// [<span>'0'</span>, 1]</span>,
// <span>[<span>'1'</span>, undefined]</span>,
// <span>[<span>'2'</span>, undefined]</span>,
// <span>[<span>'3'</span>, 4]</span>
// ]
console<span>.log</span>(<span>Object</span><span>.entries</span>(<span>[1, , , 4]</span>));
// 输出 <span>[// [<span>'0'</span>, 1]</span>,
// <span>[<span>'3'</span>, 4]</span>
// ]
JavaScript 的数组本质上只是对象,数组元素只是它身上的属性。如果某些属性缺失,大量内置数组方法的行为就会彻底混乱。这就是稀疏数组。
所以,你根本就不应该使用稀疏数组。不幸的是,Array 构造函数默认创建的就是稀疏数组,导致代码写起来非常别扭:
<span>const</span> sparse = <span>new</span> <span>Array</span>(<span>4</span>);
<span>console</span>.<span>log</span>(sparse); <span>// 输出 [empty × 4]</span>
<span>// 这样也不行:</span>
<span>const</span> stillNotDense = <span>new</span> <span>Array</span>(<span>4</span>).<span>map</span>(<span><span>x</span> =></span> <span>123</span>);
<span>console</span>.<span>log</span>(stillNotDense); <span>// 输出 [empty × 4]</span>
<span>// 你必须这样写:</span>
<span>const</span> dense = <span>new</span> <span>Array</span>(<span>4</span>).<span>fill</span>(<span>undefined</span>).<span>map</span>(<span><span>x</span> =></span> <span>123</span>);
<span>console</span>.<span>log</span>(dense); <span>// 输出 [123, 123, 123, 123]</span>
<span>// 或者:</span>
<span>const</span> alsoDense = <span>Array</span>.<span>from</span>({ <span>length</span>: <span>4</span> }, <span>() =></span> <span>123</span>);
<span>console</span>.<span>log</span>(alsoDense); <span>// 输出 [123, 123, 123, 123]</span>
如果这还不足以说服你:稀疏数组的性能也差得离谱。别在代码里用它们,任何情况下都不要,那你就没事了。
6. 奇怪的 ASI(自动分号插入)行为
下面这段代码会输出什么?(提示:不是 2 1 4 3。)
function <span>f1</span>(a, b, c, d) {
<span>[a, b]</span> = <span>[b, a]</span>
<span>[c, d]</span> = <span>[d, c]</span>
console<span>.log</span>(a, b, c, d)
}
<span>f1</span>(<span>1</span>, <span>2</span>, <span>3</span>, <span>4</span>)
结果是 4 3 3 4。
我故意省略分号,其实就是很好的提示。JavaScript 里有一个相当复杂的算法叫“自动分号插入”(Automatic Semicolon Insertion,ASI),它用一堆启发式规则来猜测分号应该插入在哪里。
<span>[a, b]</span> = <span>[b, a]</span>
<span>[c, d]</span> = <span>[d, c]</span>
<span>// ASI 实际把它解析为:</span>
<span>[a, b]</span> = <span>[b, a]</span><span>[c, d]</span> = <span>[d, c]</span>
^ ^
| |
| 逗号运算符
|
数组取值
<span>// 等价于:</span>
<span>[a, b]</span> = <span>[4, 3]</span>
<span>[b, a]</span><span>[4]</span> = <span>[4, 3]</span>
ASI 的具体机制不在本文范围内。简单来说:它先检查是否存在语法错误;如果有,并且错误位置前刚好是换行,就插入分号。因此,只要代码没有语法错误,它通常就不会插入分号。
从 ECMAScript 标准化委员会的视角看,这条规则相当苛刻:给语言添加新语法,可能让旧的语法错误不再是语法错误;但 ASI 又依赖语法错误出现在特定位置,因此每种新语法都可能破坏旧代码。为此,语言中存在一些特殊的“受限产生式”(restricted productions):只要出现换行,就一定插入分号,哪怕代码不加分号在语法上也是合法的。
等等,还有这些
下面是一些没来得及展开的怪异行为:
- 一切与
==和!=有关的 - 一切与类型转换有关的
- 一切与
this有关的 NaN不等于任何值+0与-0- 一切与浮点精度有关的内容,或者其他被 IEEE 754 覆盖的怪癖
typeof null的结果是"object"- 一切使用非严格模式或
var的场景 - 在构造函数中返回原始值
- 原型污染
Array.sort会把数字转换为字符串再排序- ……
适合有一定经验的 JS 开发者查漏补缺,帮助理解语言设计的历史包袱,在代码审查与面试中避开这些隐蔽陷阱。