我以前也把 let a = 1 想成过 new Number(1) 的简写。这个模型能解释一个现象:为什么 a.toString() 可以跑。
但它在关键地方是错的。let a = 1 绑定的是 primitive number,不是 Number 对象。对象只会在访问属性或方法时,以临时 wrapper 的语义出现一下。
这里说“解释器”,更准确一点是 JS 引擎的执行语义。现代引擎会有 bytecode 和 JIT 优化,但这不改变 ECMAScript 规定的那套行为。

图:let a = 1; 的生命周期。先创建词法绑定,绑定在初始化前处于 TDZ;执行到声明语句时才把 primitive number 1 写入绑定。generated by gpt-image-2.
let a = 1 不是 new Number(1)
先看最容易误会的地方:
1 | let a = 1; |
如果它等价于 new Number(1),结果就完全不同:
1 | const b = new Number(1); |
更麻烦的是:
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 | { |
这和 var 不一样:
1 | { |
let 的设计不是为了让“提升”消失,而是把“声明已被登记”和“值已经可用”分开。这样读一个还没初始化的变量会直接报错,而不是给你一个看起来能用、其实很容易埋 bug 的 undefined。
执行到这一行时,才把 1 写进绑定
真正执行到:
1 | let a = 1; |
右边的数字字面量 1 会被求值成一个 primitive number。然后引擎把这个值写进 a 这个词法绑定里。
可以把它想成:
1 | Lexical Environment |
但不要想成:
1 | a -> Number object { [[NumberData]]: 1 } |
前者是 let a = 1。后者更接近 new Number(1)。
这里还有一个小细节:1 本身是 immutable,但 a 这个绑定如果用的是 let,可以被重新赋值。
1 | let a = 1; |
这不是把数字 1 改成 2。这是让绑定 a 从指向 primitive value 1,变成指向另一个 primitive value 2。
那为什么 a.toString() 能跑?
问题就在这里:
1 | let a = 1; |
如果 a 不是对象,它为什么有方法?
答案是:访问属性或方法时,JS 会对 primitive 走一次临时 wrapper 语义。规范里更准确的说法是把 primitive 做 ToObject,让这次属性查找可以落到 Number.prototype 上。
你可以先用这个模型理解:
1 | Number(a).toString(); |
但要马上补一句:这只是教学模型,不是 let a = new Number(1),也不代表引擎一定真的在堆上分配了一个对象。现代引擎经常会把这种临时对象优化掉。
更接近语义的过程是:
- 读到 primitive number
1 - 访问
.toString - 临时按
Numberwrapper 的方式查找属性 - 找到
Number.prototype.toString - 调用完以后,临时 wrapper 消失
a仍然是 primitive number

图:let a = 1 绑定的是 primitive。a.toString() 只是在方法访问时走临时 wrapper 语义;new Number(1) 才会创建持久对象。generated by gpt-image-2.
同样的逻辑也适用于字符串:
1 | const s = "abc"; |
"abc" 不是 String 对象。但访问方法时,JS 可以临时用 wrapper 语义去找 String.prototype.toUpperCase。
这也解释了为什么 primitive 不能挂持久属性:
1 | const s = "abc"; |
给临时 wrapper 塞进去的东西,下一行已经没了。
为什么要这样设计?
我觉得这里的设计其实很务实。
primitive 要便宜。数字、字符串、布尔值太常用了,如果每一个都变成对象,内存、GC 和比较语义都会变复杂。1 === 1 应该就是值比较,不应该变成两个对象引用之间的比较。
但开发者又需要统一的调用体验。你当然希望能写:
1 | "abc".toUpperCase(); |
而不是每次都显式创建对象。临时 wrapper 正好把这两件事接起来:值本身保持轻量和不可变,方法则挂在共享的 prototype 上。
TDZ 解决的是另一类问题。let 和 const 是块级作用域,代码读起来应该接近“声明之后才能用”。如果像 var 一样提前给个 undefined,很多错误会变成运行到更后面才爆。TDZ 选择早点报错。
所以这套机制背后不是“JS 偷偷把所有东西都变成对象”。更准确的理解是:
let a = 1 建立一个词法绑定,把 primitive number 放进去。primitive 保持 primitive;只有访问属性或方法时,才临时借用对应 wrapper 和 prototype 的能力。
这个模型记住以后,很多看似奇怪的 JS 行为就顺了:
1 | typeof 1; // "number" |
最后那行尤其适合提醒自己:wrapper object 是对象,不是值。