---
title: "当输入 let a = 1; 的时候，解释器发生了什么？"
url: https://clean99.github.io/zh/2026/06/11/What-Happens-When-You-Type-Let-A-Equals-1/
date: 2026.06.11
lang: zh
---

我以前也把 `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](/img/js-let-primitive-lifecycle/let-a-lifecycle.png)

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

## `let a = 1` 不是 `new Number(1)`

先看最容易误会的地方：

```js
let a = 1;

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

如果它等价于 `new Number(1)`，结果就完全不同：

```js
const b = new Number(1);

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

更麻烦的是：

```js
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：

```js
{
  console.log(a); // ReferenceError
  let a = 1;
}
```

这和 `var` 不一样：

```js
{
  console.log(v); // undefined
  var v = 1;
}
```

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

## 执行到这一行时，才把 `1` 写进绑定

真正执行到：

```js
let a = 1;
```

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

可以把它想成：

```txt
Lexical Environment

a -> 1
```

但不要想成：

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

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

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

```js
let a = 1;
a = 2;
```

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

## 那为什么 `a.toString()` 能跑？

问题就在这里：

```js
let a = 1;
a.toString(); // "1"
```

如果 `a` 不是对象，它为什么有方法？

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

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

```js
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](/img/js-let-primitive-lifecycle/primitive-autoboxing-vs-number-object.png)

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

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

```js
const s = "abc";
s.toUpperCase(); // "ABC"
```

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

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

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

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

## 为什么要这样设计？

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

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

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

```js
"abc".toUpperCase();
(1).toString();
```

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

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

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

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

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

```js
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 是对象，不是值。

## 参考

- [ECMAScript: let and const declarations](https://tc39.es/ecma262/#sec-let-and-const-declarations)
- [ECMAScript: ToObject](https://tc39.es/ecma262/#sec-toobject)
- [MDN: Primitive](https://developer.mozilla.org/en-US/docs/Glossary/Primitive)
