当输入 Let a = 1; 的时候,解释器发生了什么?

Read in English

我以前也把 let a = 1 想成过 new Number(1) 的简写。这个模型能解释一个现象:为什么 a.toString() 可以跑。

但它在关键地方是错的。let a = 1 绑定的是 primitive number,不是 Number 对象。对象只会在访问属性或方法时,以临时 wrapper 的语义出现一下。

这里说“解释器”,更准确一点是 JS 引擎的执行语义。现代引擎会有 bytecode 和 JIT 优化,但这不改变 ECMAScript 规定的那套行为。

Lifecycle of let a equals 1

图:let a = 1; 的生命周期。先创建词法绑定,绑定在初始化前处于 TDZ;执行到声明语句时才把 primitive number 1 写入绑定。generated by gpt-image-2.

let a = 1 不是 new Number(1)

先看最容易误会的地方:

1
2
3
4
let a = 1;

typeof a; // "number"
a === 1; // true

如果它等价于 new Number(1),结果就完全不同:

1
2
3
4
const b = new Number(1);

typeof b; // "object"
b === 1; // false

更麻烦的是:

1
Boolean(new Number(0)); // true

对象永远是真值,哪怕它包装的是 0。所以实际业务代码里基本不要写 new Number(...)new String(...)new Boolean(...)。它们制造的是 wrapper object,不是普通 primitive。

进入作用域时,a 已经存在,但还不能读

当 JS 引擎处理到一个 block、script 或 module 时,会先建立当前作用域里的词法绑定。let a = 1 里的 a 在这个阶段已经被登记到 Lexical Environment 里。

但它不是 undefined

它是 uninitialized。

这就是 TDZ,Temporal Dead Zone:

1
2
3
4
{
console.log(a); // ReferenceError
let a = 1;
}

这和 var 不一样:

1
2
3
4
{
console.log(v); // undefined
var v = 1;
}

let 的设计不是为了让“提升”消失,而是把“声明已被登记”和“值已经可用”分开。这样读一个还没初始化的变量会直接报错,而不是给你一个看起来能用、其实很容易埋 bug 的 undefined

执行到这一行时,才把 1 写进绑定

真正执行到:

1
let a = 1;

右边的数字字面量 1 会被求值成一个 primitive number。然后引擎把这个值写进 a 这个词法绑定里。

可以把它想成:

1
2
3
Lexical Environment

a -> 1

但不要想成:

1
a -> Number object { [[NumberData]]: 1 }

前者是 let a = 1。后者更接近 new Number(1)

这里还有一个小细节:1 本身是 immutable,但 a 这个绑定如果用的是 let,可以被重新赋值。

1
2
let a = 1;
a = 2;

这不是把数字 1 改成 2。这是让绑定 a 从指向 primitive value 1,变成指向另一个 primitive value 2

那为什么 a.toString() 能跑?

问题就在这里:

1
2
let a = 1;
a.toString(); // "1"

如果 a 不是对象,它为什么有方法?

答案是:访问属性或方法时,JS 会对 primitive 走一次临时 wrapper 语义。规范里更准确的说法是把 primitive 做 ToObject,让这次属性查找可以落到 Number.prototype 上。

你可以先用这个模型理解:

1
Number(a).toString();

但要马上补一句:这只是教学模型,不是 let a = new Number(1),也不代表引擎一定真的在堆上分配了一个对象。现代引擎经常会把这种临时对象优化掉。

更接近语义的过程是:

  1. 读到 primitive number 1
  2. 访问 .toString
  3. 临时按 Number wrapper 的方式查找属性
  4. 找到 Number.prototype.toString
  5. 调用完以后,临时 wrapper 消失
  6. a 仍然是 primitive number

Primitive auto-boxing versus new Number

图:let a = 1 绑定的是 primitive。a.toString() 只是在方法访问时走临时 wrapper 语义;new Number(1) 才会创建持久对象。generated by gpt-image-2.

同样的逻辑也适用于字符串:

1
2
const s = "abc";
s.toUpperCase(); // "ABC"

"abc" 不是 String 对象。但访问方法时,JS 可以临时用 wrapper 语义去找 String.prototype.toUpperCase

这也解释了为什么 primitive 不能挂持久属性:

1
2
3
const s = "abc";
s.x = 1; // sloppy mode: ignored; strict mode: TypeError
console.log(s.x); // sloppy mode: undefined

给临时 wrapper 塞进去的东西,下一行已经没了。

为什么要这样设计?

我觉得这里的设计其实很务实。

primitive 要便宜。数字、字符串、布尔值太常用了,如果每一个都变成对象,内存、GC 和比较语义都会变复杂。1 === 1 应该就是值比较,不应该变成两个对象引用之间的比较。

但开发者又需要统一的调用体验。你当然希望能写:

1
2
"abc".toUpperCase();
(1).toString();

而不是每次都显式创建对象。临时 wrapper 正好把这两件事接起来:值本身保持轻量和不可变,方法则挂在共享的 prototype 上。

TDZ 解决的是另一类问题。letconst 是块级作用域,代码读起来应该接近“声明之后才能用”。如果像 var 一样提前给个 undefined,很多错误会变成运行到更后面才爆。TDZ 选择早点报错。

所以这套机制背后不是“JS 偷偷把所有东西都变成对象”。更准确的理解是:

let a = 1 建立一个词法绑定,把 primitive number 放进去。primitive 保持 primitive;只有访问属性或方法时,才临时借用对应 wrapper 和 prototype 的能力。

这个模型记住以后,很多看似奇怪的 JS 行为就顺了:

1
2
3
4
5
6
7
8
typeof 1; // "number"
typeof new Number(1); // "object"

1 === Number(1); // true
1 === new Number(1); // false

Boolean(0); // false
Boolean(new Number(0)); // true

最后那行尤其适合提醒自己:wrapper object 是对象,不是值。

参考