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