第 1 章

10天造出的语言:Brendan Eich 的设计取舍与历史债务

typeof null === 'object' 不是 bug,它是1995年5月一个工程师为了赶 deadline 写下的位标记方案,此后30年没有人敢修复它——因为修复会导致数百万个已运行网页崩溃。

🔹 Level 1 · 你需要知道的

你每天写的 JavaScript 里,有几处代码是1995年就存在的历史遗留物。认出它们,你才能判断什么时候应该用现代替代,什么时候遇到的是已知陷阱而非自己的 bug。

5个历史遗留物清单

遗留物 引入版本 现代替代 说明
typeof null === 'object' ES1 (1997) 无法修复 原始实现的类型标签 bug
== 隐式类型转换 ES1 (1997) === 转换规则12条,多数人记不全
var 提升 ES1 (1997) let / const 声明提升到函数顶部,不是块级作用域
arguments 对象 ES1 (1997) 剩余参数 ...args 类数组但不是数组,有隐藏性能代价
with 语句 ES1 (1997) 对象解构 严格模式已禁止,阻止编译器优化

5个新手常犯错误

错误 1:相信 typeof 能区分 null

// 错误:这个检查永远不会触发 null 的分支
function process(value) {
  if (typeof value === 'null') {  // 'null' 这个字符串永远不会出现
    return '空值';
  }
  return value;
}

// 正确:null 必须用严格相等单独检查
function process(value) {
  if (value === null) {           // 正确
    return '空值';
  }
  if (typeof value === 'object') { // 此时才是真正的对象
    return JSON.stringify(value);
  }
  return String(value);
}

错误 2:混用 == 导致意外真值

// 这些结果让大多数开发者感到意外
console.log(0 == false);      // true  — 都被转为数字 0
console.log('' == false);     // true  — 都被转为数字 0
console.log(null == undefined); // true — 规范特殊处理
console.log(null == 0);       // false — 规范特殊处理,不遵循一般规则
console.log([] == false);     // true  — [] → '' → 0,false → 0
console.log([] == ![]);       // true  — 两边都变成 0,这是真实面试题

// 养成习惯:除了检查 null/undefined 同时为假,其他一律用 ===
if (value == null) { /* value 是 null 或 undefined */ } // 唯一合理的 == 用法

错误 3:var 的块级作用域幻觉

// 循环中 var 的经典陷阱(Node.js / 浏览器控制台可直接运行)
var funcs = [];
for (var i = 0; i < 3; i++) {
  funcs.push(function() { return i; }); // 捕获的是变量 i,不是值
}
console.log(funcs[0]()); // 3,不是 0
console.log(funcs[1]()); // 3,不是 1
console.log(funcs[2]()); // 3,不是 2

// ES2015 修复:let 是块级作用域,每次迭代创建新绑定
var funcs2 = [];
for (let i = 0; i < 3; i++) {
  funcs2.push(function() { return i; });
}
console.log(funcs2[0]()); // 0
console.log(funcs2[1]()); // 1
console.log(funcs2[2]()); // 2

错误 4:把 arguments 当数组用

// arguments 是类数组对象,没有数组方法
function sum() {
  return arguments.reduce((a, b) => a + b); // TypeError: arguments.reduce is not a function
}

// 旧式写法(ES5时代)
function sum() {
  return Array.prototype.reduce.call(arguments, (a, b) => a + b, 0);
}

// ES2015 正确写法:剩余参数
function sum(...nums) {
  return nums.reduce((a, b) => a + b, 0); // nums 是真正的数组
}

错误 5:不知道函数声明和函数表达式的提升差异

// 函数声明:完整提升(声明 + 函数体)
console.log(add(1, 2)); // 3 — 可以在声明前调用
function add(a, b) { return a + b; }

// 函数表达式:只提升变量名,值不提升
console.log(multiply(2, 3)); // TypeError: multiply is not a function
var multiply = function(a, b) { return a * b; };
// var multiply 被提升为 undefined,调用 undefined() 报错

🔸 Level 2 · 它是怎么运行的

Brendan Eich 的10天,1995年5月

Brendan Eich 于1995年4月加入 Netscape,职位描述是"在浏览器里实现 Scheme"。但他到岗后发现情况完全变了:Netscape 与 Sun Microsystems 签了合作协议,要在浏览器里推广 Java,公司需要的是一个"看起来像 Java 的脚本语言",用于让非程序员和网页设计师能写一些简单逻辑。

1995年5月的10天,Eich 写出了 JavaScript 的第一版原型(当时叫 Mocha,后改名 LiveScript,最终在1995年12月改名 JavaScript)。这10天里的每个决策,都留下了30年无法消除的影响。

决策 1:原型继承,而非类继承

Eich 的选择树:
                    面向对象范式
                   /            \
           基于类(class)        基于原型(prototype)
          /                              \
    Java 风格                         Self 语言风格
    严格继承层次                      对象直接委托给其他对象
    Eich 不选这个 ←                  → Eich 选了这个
    (Java 正在流行,但 Eich 认为
     浏览器脚本语言不需要这么复杂)

为什么选原型?Eich 喜欢 Self 语言的简洁性:对象可以直接委托给另一个对象,不需要"类"这一层抽象。但他也受到压力——Netscape 市场部要求语法看起来像 Java,所以他加了 new 关键字和 this,但底层还是原型。这就造成了一个奇怪的混合体:

// 表面上像 Java 的语法(用 new 创建实例)
function Animal(name) {
  this.name = name;           // Java 风格的构造器语法
}
Animal.prototype.speak = function() {
  return this.name + ' makes a noise.'; // 但底层是原型链
};

var dog = new Animal('Rex');
// dog.__proto__ === Animal.prototype  (原型链查找)
// dog 本身没有 speak 方法,查找委托给 Animal.prototype

ES2015 的 class 关键字只是语法糖,底层机制从未改变:

// ES2015 class — 完全等价于上面的原型写法
class Animal {
  constructor(name) { this.name = name; }
  speak() { return this.name + ' makes a noise.'; }
}
// Animal.prototype.speak 依然存在
// new Animal('Rex') 依然通过 [[Prototype]] 查找

决策 2:类型标签方案导致 typeof null 的历史债务

Mocha 的原始实现用32位整数表示每个值。最低3位是类型标签(type tag)

原始 C 实现的类型标签(1995年):
┌─────────────────────────────────────────────────────┐
│  值的内存表示(32 位)                                │
│  ┌──────────────────────────────────┬──────────────┐ │
│  │  实际值或指针(高29位)            │  类型标签(低3位)│ │
│  └──────────────────────────────────┴──────────────┘ │
│                                                       │
│  类型标签:                                            │
│  000 = object(对象指针)                              │
│  001 = int(31位整数)                                 │
│  010 = double(指向 float 的指针)                     │
│  100 = string(指向字符串的指针)                       │
│  110 = boolean                                        │
│                                                       │
│  null 的表示:NULL 指针,全0位 → 0x00000000           │
│  最低3位 = 000 → 类型标签 = object                    │
└─────────────────────────────────────────────────────┘

null 的32位表示是全0(C语言的 NULL 指针惯例)。类型检查代码读取最低3位,发现是 000,于是返回 "object"。这是一个实现层面的疏漏,在代码写出后数周就被发现,但 Netscape 已经开始演示这个语言,没有机会修复。

决策 3:两套相等运算符

JavaScript 最初只有 ==,它执行隐式类型转换。问题在于1998年 ES1 发布后,大量网页代码已经依赖 == 的行为。ES3(1999年)加入 ===,作为"更严格的替代"——但为了不破坏现有代码,== 的行为无法修改。

== 的决策时间线:
1995年 → 只有 ==,执行类型转换
1999年 → 加入 ===,解决 == 的混乱
2009年 → 'use strict' 模式,但 == 仍然存在
2015年 → ESLint 的 eqeqeq 规则推广 === 的使用
现在   → 行业规范:几乎所有代码规范都要求用 ===

决策 4:Netscape 与 Sun 的命名协议

1995年12月4日,Netscape 和 Sun 联合宣布发布"JavaScript"。JavaScript 这个名字是市场决策,不是技术决策——目的是蹭 Java 的热度。Eich 在多次采访中表示他对这个名字不满意,因为它造成了持续至今的混淆。Java 和 JavaScript 的关系就像"Car"和"Carpet"的关系。

浏览器大战(1995—2001):实现分裂的根源

浏览器大战时间线:
1995 → Netscape Navigator 2.0,首次包含 JavaScript
1996 → Microsoft 发布 IE 3.0,包含 JScript(JavaScript 的逆向实现)
       ↓
       JScript 和 JavaScript 不完全兼容
       同一段代码在 IE 和 Netscape 行为不同
       ↓
1997 → ECMAScript 1 (ES1) 发布 —— 试图统一标准
1999 → ECMAScript 3 (ES3) —— 奠定现代 JS 基础
       ↓
       IE 占据 ~90% 市场份额,停止更新 5 年
       Netscape 被 AOL 收购,浏览器开发几乎停止
       ↓
2004 → Firefox 1.0 发布(Mozilla 基金会)
2008 → Chrome 发布,V8 引擎,JIT 编译带来 10-100x 性能提升
2009 → IE 的垄断时代结束,ES5 推出
2011 → IE9 支持 ES5 严格模式

分裂的实现带来了巨大的"浏览器兼容性"问题:开发者需要写 if (document.all) 来检测 IE,jQuery 的最初目标之一就是抹平这些差异。

ES4(2003-2008):失败的激进改革

ES4 的提案由 Adobe(Flash/ActionScript 团队)、Mozilla 和 Opera 主导,想要为 JavaScript 加入:

微软强烈反对——因为 ES4 会让 IE 的 JScript 实现代价极高。Google 当时还没有主流浏览器。2008年,经过5年拉锯,ES4 被正式放弃。这是 JavaScript 历史上最大的技术政治事件。

ES4 失败的原因链:
技术过于激进(静态类型等于重写引擎)
    + 阵营分裂(微软 vs Adobe/Mozilla)
    + "不破坏现有代码"原则无法满足
    = 2008年8月,TC39 正式宣布 ES4 作废
    ↓
ES3.1(后改名 ES5)以"修补而非革命"为原则推进

ES5(2009):在约束下修复

ES5 的设计原则:所有 ES3 代码在 ES5 引擎中必须能运行,且结果相同。在此约束下,ES5 做到了:

// 1. 严格模式:opt-in 的子集,修复了最严重的设计问题
'use strict';
// 在严格模式下:
// - with 语句报错
// - 意外全局变量报错
// - 删除不可删除属性报错
// - arguments 和 caller 访问受限

// 2. Object.defineProperty:元编程的基础
const obj = {};
Object.defineProperty(obj, 'readOnly', {
  value: 42,
  writable: false,   // 不可写
  enumerable: true,  // for...in 可见
  configurable: false // 不可重新定义
});
obj.readOnly = 100;  // 静默失败(非严格模式)/ 报错(严格模式)
console.log(obj.readOnly); // 42

// 3. JSON 内置(之前需要第三方库)
JSON.parse('{"key": "value"}');
JSON.stringify({ key: 'value' });

// 4. Array 新方法
[1, 2, 3].forEach(x => x);
[1, 2, 3].map(x => x * 2);
[1, 2, 3].filter(x => x > 1);
[1, 2, 3].reduce((acc, x) => acc + x, 0);

🔺 Level 3 · 规范怎么定义的

ES1(1997)规范的设计原则

ECMAScript 第1版(ECMA-262,1997年6月)的前言中明确写道:

"ECMAScript is an object-oriented programming language for performing computations and manipulating computational objects within a host environment. ECMAScript as defined here is not intended to be computationally self-sufficient; indeed, there are no provisions in this specification for input of external data or output of computed results. Instead, it is expected that the computational environment of an ECMAScript program will provide not only the objects and other facilities described in this specification but also certain environment-specific host objects..."

关键设计原则,体现在 ES1 第1节(Scope):

"This standard defines the ECMAScript scripting language. It does not define facilities for obtaining external data, specifying the format of files, providing graphical user interfaces, or interacting with the user."

这说明 ES1 从一开始就将自身定义为宿主环境中的计算层,而非完整的编程语言。documentwindow 这些 DOM API 不在规范内——它们属于浏览器宿主。

"Web Compatibility"原则的规范体现

ES5 规范(ECMA-262 第5版,2009年12月)第16节「Errors」中包含:

"An implementation may report syntax errors in the program text supplied to it using the script mechanism described in section 14.1. An implementation shall report all errors as specified, except for the following: A host may leave certain behaviors 'implementation-defined'..."

更重要的是附录 B(Annex B),它收录了所有因 Web 兼容性原因必须保留但不推荐使用的特性:

Annex B(规范性附录)收录的 Web 兼容特性(ES5):
- String.prototype.substr()
  (非标准,但广泛使用,不能删除)
- Date.parse() 的非标准字符串解析
  (各引擎实现不同,但都必须支持某些格式)
- HTML-style 注释 <!-- 和 -->
  (最初是为了让旧版不支持 JS 的浏览器
   把 JS 代码当成 HTML 注释,现代规范仍保留此语法)
- escape() / unescape()
  (废弃但不能删除)

Annex B 机制本质上是一个"历史债务收纳仓库"——规范承认这些特性设计不好,但因为已有大量代码依赖,必须继续保留。这是 JavaScript 的 Web Compatibility 约束在规范层面的直接体现。

ES1 的类型系统设计

ES1 第8节(Types)定义了6种类型:

ES1 的6种原始类型(1997年):
1. Undefined
2. Null
3. Boolean
4. Number(64位 IEEE 754)
5. String(UTF-16)
6. Object

typeof 运算符的返回值(ES1 第11.4.3节):
Type of val      Result
Undefined        "undefined"
Null             "object"     ← 规范就这样写的,承认了实现错误
Boolean          "boolean"
Number           "number"
String           "string"
Object           "object"

规范在 ES1 就已经将 typeof null === 'object' 成文,这意味着它从 bug 变成了 feature——哪怕是一个被承认设计不当的 feature。


💎 Level 4 · 边界与陷阱

陷阱 1:typeof null === 'object' 的完整推导

原始 C 实现(Netscape 内部代码,1995年)

根据 Eich 和其他 Netscape 工程师的描述,最初的 Mocha 引擎用以下结构表示 JS 值:

/* 伪代码,还原1995年的实现思路 */
typedef uint32_t JSValue;

/* 类型标签(最低3位)*/
#define JSTAG_OBJECT   0  /* 000 */
#define JSTAG_INT      1  /* 001 */
#define JSTAG_DOUBLE   2  /* 010 */
#define JSTAG_STRING   4  /* 100 */
#define JSTAG_BOOLEAN  6  /* 110 */

/* NULL 在 C 中是 ((void*)0),即全0 */
#define JS_NULL        0  /* 0x00000000 */

/* typeof 的实现 */
const char* js_typeof(JSValue val) {
    if (val == JS_NULL) {
        /* BUG: 这里应该特殊处理返回 "null"
           但代码没有这样做,直接落入下面的标签检查 */
    }
    switch (val & 0x7) {  /* 取最低3位 */
        case JSTAG_OBJECT:  return "object";  /* null 到这里,返回 "object" */
        case JSTAG_INT:     return "number";
        case JSTAG_DOUBLE:  return "number";
        case JSTAG_STRING:  return "string";
        case JSTAG_BOOLEAN: return "boolean";
        default:            return "undefined";
    }
}

为什么永远无法修复?

2011年,Brendan Eich 在 Twitter 上确认可以修复这个 bug,并给出了一个提案:让 typeof null 返回 "null"。结果TC39测试显示,修改会导致数量庞大的网站出现以下模式的代码崩溃:

// 全球数百万个网站有类似这样的代码
if (typeof someValue === 'object') {
  // 处理 null 和对象
  if (someValue !== null) {
    someValue.doSomething(); // 实际上依赖 typeof null === 'object'
  }
}
// 如果 typeof null 变成 'null',上面第一个 if 就不会包含 null 了
// 里面的 someValue !== null 判断变得多余,但不会导致 bug
// 真正有问题的是:
if (typeof someValue === 'object' || typeof someValue === 'null') {
  // 这样写的代码是0个,因为没人期待 typeof null === 'null'
  // 但是:
}

// 破坏性更大的模式:
function isObject(val) {
  return typeof val === 'object'; // 被用来检测"非基础类型",包括 null
}
// 如果修复,isObject(null) 从 true 变成 false
// 所有调用 isObject(null) 并依赖返回 true 的代码全部崩溃

结论typeof null === 'object' 是 JavaScript 中最著名的 wontfix(故意不修复)bug,被正式收入 ECMAScript 规范的 Annex B 精神范畴。


陷阱 2:NaN !== NaN 的底层原因

NaN !== NaN 不是 JavaScript 的设计,是 IEEE 754-1985 标准的规定,JavaScript 只是遵循了它。

IEEE 754 的设计理由

NaN(Not a Number)的产生:
- 0 / 0 = NaN
- Math.sqrt(-1) = NaN
- Infinity - Infinity = NaN
- parseInt('abc') = NaN

为什么 NaN !== NaN?
IEEE 754 委员会的论文(1985年)给出的理由:
"NaN 表示一个未知数。两个未知数是否相等?不知道。
 不知道的结果应该是 false(或更准确地说,是未定义的)。"

类比:
  如果你不知道房间里的人是谁(call them NaN1 和 NaN2)
  你无法说 NaN1 === NaN2
  所以 IEEE 754 规定:NaN 与任何值(包括自身)比较都返回 false

跨语言对比

# Python(同样遵循 IEEE 754)
import math
nan = float('nan')
print(nan != nan)    # True
print(nan == nan)    # False
print(math.isnan(nan))  # True — 正确检测方式
// Java(同样遵循 IEEE 754)
double nan = Double.NaN;
System.out.println(nan != nan);    // true
System.out.println(nan == nan);    // false
System.out.println(Double.isNaN(nan));  // true
// JavaScript — 和 Python/Java 行为完全一致
const nan = NaN;
console.log(nan !== nan);       // true
console.log(Number.isNaN(nan)); // true  — 正确检测方式
console.log(isNaN('abc'));      // true  — 危险!先转换再判断
console.log(Number.isNaN('abc')); // false — ES2015 加入,不做转换

陷阱 3:0.1 + 0.2 !== 0.3 ——这不是 JavaScript 的锅

console.log(0.1 + 0.2);            // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3);    // false

IEEE 754 双精度浮点数的原因

10进制  →  2进制
0.1     →  0.0001100110011... (无限循环!)
0.2     →  0.0011001100110... (无限循环!)

64位 double 只有 52 位尾数,必须截断:
0.1 的近似值:0.1000000000000000055511151231257827021181583404541015625
0.2 的近似值:0.200000000000000011102230246251565404236316680908203125
相加后的近似值≠0.3的近似值

跨语言验证(都有这个"问题"):

# Python
>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 == 0.3
False
// Java
System.out.println(0.1 + 0.2);          // 0.30000000000000004
System.out.println(0.1 + 0.2 == 0.3);  // false
// 正确处理方式(JavaScript)
// 方案1:使用 epsilon 比较
function nearlyEqual(a, b) {
  return Math.abs(a - b) < Number.EPSILON; // Number.EPSILON = 2.22e-16
}
console.log(nearlyEqual(0.1 + 0.2, 0.3)); // true

// 方案2:整数运算(金融计算推荐)
// 将 0.1 元表示为 10 分(整数)
const price = 10; // 10分
const tax = 20;   // 20分
const total = price + tax; // 30分 = 0.30元,无精度问题

// 方案3:ES2020 BigInt(只支持整数)
// 方案4:decimal.js 等专用库

陷阱 4:历史遗留代码模式及现代替代写法

以下5种模式在2010年前的生产代码中非常常见,现在仍然出现在老项目里:

// ═══════════════════════════════════════════════════════════
// 模式 1:用 arguments 实现可变参数(ES3 时代)
// ═══════════════════════════════════════════════════════════

// 旧式(ES3/ES5,2009年前)
function logAll() {
  var args = Array.prototype.slice.call(arguments); // 转换为数组
  args.forEach(function(arg) {
    console.log(arg);
  });
}

// 现代(ES2015+)
function logAll(...args) {
  args.forEach(arg => console.log(arg)); // args 就是真正的 Array
}

// ═══════════════════════════════════════════════════════════
// 模式 2:自执行函数(IIFE)实现模块化(ES6 Module 之前)
// ═══════════════════════════════════════════════════════════

// 旧式(2009-2015年广泛使用)
var MyModule = (function() {
  var privateVar = 'secret'; // 通过闭包实现私有
  return {
    getSecret: function() { return privateVar; }
  };
})();

// 现代(ES2015 Module)
// myModule.js
const privateVar = 'secret';
export function getSecret() { return privateVar; }

// ═══════════════════════════════════════════════════════════
// 模式 3:原型链实现继承(class 语法糖之前)
// ═══════════════════════════════════════════════════════════

// 旧式(ES5 时代,jQuery 源码中大量使用)
function Animal(name) { this.name = name; }
Animal.prototype.speak = function() { return this.name; };

function Dog(name) {
  Animal.call(this, name); // 借用构造器
}
Dog.prototype = Object.create(Animal.prototype); // 设置原型链
Dog.prototype.constructor = Dog; // 修复 constructor
Dog.prototype.bark = function() { return 'Woof!'; };

// 现代(ES2015+)
class Animal {
  constructor(name) { this.name = name; }
  speak() { return this.name; }
}
class Dog extends Animal {
  bark() { return 'Woof!'; }
}

// ═══════════════════════════════════════════════════════════
// 模式 4:回调地狱(Promise 之前的异步)
// ═══════════════════════════════════════════════════════════

// 旧式(Node.js 0.x 时代,2009-2014年)
fs.readFile('a.txt', function(err, dataA) {
  if (err) return handleError(err);
  fs.readFile('b.txt', function(err, dataB) {
    if (err) return handleError(err);
    fs.writeFile('c.txt', dataA + dataB, function(err) {
      if (err) return handleError(err);
      console.log('done');
    });
  });
});

// 现代(ES2017+)
async function mergeFiles() {
  const dataA = await fs.promises.readFile('a.txt');
  const dataB = await fs.promises.readFile('b.txt');
  await fs.promises.writeFile('c.txt', dataA + dataB);
  console.log('done');
}

// ═══════════════════════════════════════════════════════════
// 模式 5:手动 polyfill(现代语言特性在旧浏览器的垫片)
// ═══════════════════════════════════════════════════════════

// 旧式(ES5 时代为 IE8 写 polyfill)
if (!Array.prototype.forEach) {
  Array.prototype.forEach = function(callback) {
    for (var i = 0; i < this.length; i++) {
      callback(this[i], i, this);
    }
  };
}

// 现在有 core-js + Babel 处理,手写 polyfill 已不必要
// Babel 配置:
// { "targets": "> 0.5%, last 2 versions, not dead" }
// 自动引入所需 polyfill

本章小结

  1. JavaScript 的设计只用了10天(1995年5月),Brendan Eich 在 Netscape 的商业压力下做出了大量取舍。这些取舍中的绝大多数,因为 Web Compatibility 原则,在此后30年无法撤销。

  2. typeof null === 'object' 是32位类型标签的实现疏漏:NULL 指针(全零位)与 object 类型标签(低3位为000)重合,被发现时已无法修复,并被正式写入 ECMAScript 规范的 Annex B。

  3. NaN !== NaN 遵循 IEEE 754-1985 标准,Python、Java 等所有遵循 IEEE 754 的语言行为一致。正确检测方式是 Number.isNaN()(ES2015),而非全局 isNaN()

  4. 0.1 + 0.2 !== 0.3 是 IEEE 754 双精度浮点的必然结果,不是 JavaScript 的 bug。所有使用 64 位浮点的语言都有此问题。金融计算应使用整数运算或专用库。

  5. ES4(2003-2008)的失败是 JavaScript 的转折点:它迫使社区重新思考"渐进式改进"策略,最终催生了 ES5 的"修补而不革命"路线,以及后来 ES2015 的"大版本但向后兼容"原则。

本章评分
4.7  / 5  (115 评分)

💬 留言讨论