论 JavaScript 的怪异之处

论 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-0NaN""nullundefined0n

抱歉,我撒谎了。其实有第 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 应该介于 n2n 之间,取决于代理对的数量。

那下面这段代码会输出什么?

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 会把数字转换为字符串再排序
  • ......
相关推荐
gnip2 小时前
uts 插件示例:获取设备电量信息
前端·javascript
gnip3 小时前
uni-app 原生插件
前端·javascript·uni-app
gnip3 小时前
跨域常用解决方案
前端·javascript
用户298698530143 小时前
前端如何把 TXT 文本文件转成 Word 文档:React 实践
前端·javascript·react.js
OpsEye3 小时前
从代理转发到效能分析,企业 AI 网关正在发生什么样的变化
javascript·ai编程
2603_965896623 小时前
DOM文档对象模型|JS操作页面交互核心API
前端·javascript
志尊宝4 小时前
Vue3 零基础每日笔记(043):defineModel 与 defineExpose 的 TS 用法——3.4+ 新 API 进阶
前端·javascript·vue·html·vue3
YHHLAI4 小时前
从 SPA 到 Next.js 全栈:一个大前端的 SEO 突围笔记
javascript