凡是写过 JavaScript 的人,多半都遇到过这种“薛定谔的变量”:代码里明明是先使用、后声明,结果它不报错,反而返回一个 undefined;函数表达式赋值给变量之后想提前调用,却被抛出一个 TypeError。这些乍看很“迷”的行为,底层其实都指向同一个机制——作用域提升(Hoisting)。而真正把这个机制落地的,是 JavaScript 引擎。日常开发里我们打交道最多的,就是 V8。
这篇文章不打算拽太深的内核源码,而是把 V8 从拿到一段源码、到真正把代码跑起来这条流水线上,跟“作用域提升”有关的几个关键节点讲清楚:解析器究竟在什么时候干完了哪些事、变量和函数是怎么被提前登记的、作用域链在运行时如何被检索,以及我们如何通过字节码和调试工具,亲眼看到提升留下的痕迹。适合刚准备碰 V8 源码的同学,也适合写了好几年 JavaScript 却没认真捋过“编译时”与“运行时”边界的工程师。
1. 从源码到可执行结构:V8 是怎么“读”代码的
1.1 第一步不是“执行”,而是把字符串变成数据结构
任何一段 JavaScript 代码,对 V8 来说最初都只是一串字符。它首先要经过 Scanner(分词器),把这串字符切成一个一个的 Token。Token 就是带类型的词法单元,比如关键字function、标识符foo、运算符=、数字、字符串等等。这个过程叫 Tokenize,它做得非常机械:碰到字母就尽量拼成标识符,碰到数字就尽量读成数值,碰到引号就解析字符串。
Token 流接下来会交给 Parser(解析器)。Parser 按照 JavaScript 的语法规则,把 Token 组合成一棵抽象语法树(AST)。这棵树才是 V8 真正“理解”的代码结构:每个节点代表一个语法成分,比如VariableDeclaration、FunctionDeclaration、BinaryExpression、IfStatement。很多人以为代码执行从“逐行读”开始,其实 V8 从来不逐行读,它先把整段代码变成一棵结构化的树,再去处理这棵树。
这一步非常关键,因为作用域提升并不是“运行时突然想到把变量挪个位置”,而是在构建 AST 的过程中就已经定好了:哪些名字属于哪个作用域、这个变量是 var 还是 let、那个函数声明挂在哪个层次。可以说,提升的本质是编译期的名字登记,不是运行时的魔法搬家。
1.2 预解析与懒解析:V8 故意“偷懒”的地方
V8 解析器有一个很反直觉的设计:它不会把脚本里的每一个函数都立刻完整解析。对于暂时没有被调用的嵌套函数,它只会做一次“预解析”(PreParse),只检查语法错误、收集必要的作用域信息,但不生成完整的 AST,也不展开函数内部细节。等到函数真正被调用前,再去完成完整解析。
为什么要这样?因为实际项目里大量函数是定义了但很少被调用的,甚至有些是库代码里永远跑不到的路径。如果一上来就把全部函数解析成完整 AST,内存和启动时间都会爆炸。V8 选择把成本推迟到“可能需要”的那一刻,这个机制叫惰性解析(Lazy Parsing)。
作用域提升和懒解析之间的关系很有意思:当一个函数被整体提升时,V8 可以提前知道这个函数的存在,但不会急着把它内部也解析完。也就是说,外层作用域在编译期就能看到“这里有函数 foo”,但 foo 内部有哪些变量、有哪些提升,是等 foo 真正执行前才去“补课”的。
1.3 提正在“哪个工序”里发生
再往下去,V8 拿到 AST 之后,会交给 Ignition 解释器生成字节码,然后字节码再由解释器逐条执行;当某段代码被多次执行、成为热点后,TurboFan 优化编译器会把字节码编译成更高效的机器码。这也是 V8 著名的“解释执行 + JIT 编译”双轨制。
但作用域提升并不发生在“执行字节码”这一步,发生在更早的解析和 AST 处理阶段。更准确地说,是在作用域分析(Scope Analysis)时,V8 会把当前函数内部声明的变量、函数、参数整理成一张“作用域成员表”。这张表会记录每个名字的存储位置:有些变量放在栈上,有些变量放在上下文对象里,有些只能通过全局对象访问。接下来生成字节码的时候,就直接依据这张表去给变量分配槽位、生成初始化指令。
所以如果你问我“提升到底是怎么实现的?”我的回答是八个字:编译期登记,运行时初始化。变量和函数并不是真的被“移动”到了文件开头,而是当作用域被创建时,V8 就在内存里把该出现的名字先占好了位置,然后再逐条执行代码、逐个把值填进去。
2. 三种提升形态:V8 在编译期记的“账本”
2.1 var 声明:先到账一个 undefined
在作用域入口处,V8 会给 var 声明的变量分配一个存储位置,并且立刻放进一个默认值undefined。这就是“var 提升”的全部真相。比如这段代码:
function demo() { console.log(a); // undefined var a = 42; }按直觉,console.log(a)应该报ReferenceError,因为a还没定义。但 V8 在解析demo函数时,已经知道了var a的存在,并在函数执行上下文创建时,把a这个槽位初始化成了undefined。等到执行console.log(a)时,顺着作用域链找到的a就是一个已经存在但值为undefined的变量。
值得强调的是,V8 会把var a = 42这条语句拆成两件事:声明a这件事,发生在进入作用域时;而赋值a = 42这件事,仍然留在原来的代码位置。你可以把 var 声明理解成“提前签收了一个空包裹”,包裹里的东西要等到快递单上写的那个时间点才真正放进去。
2.2 函数声明:连人带能力一起提前报到
函数声明的提升比 var 更彻底。它在作用域创建时,不光分配名字槽位,还会直接创建出完整的函数对象,并把名字绑定到这个函数对象上。所以在声明之前调用一个函数声明形式的函数,不仅不会报错,还能正常工作:
console.log(add(2, 3)); // 5 function add(a, b) { return a + b; }从 V8 的视角看,它在解析阶段发现了function add这个函数声明,于是分配了一个函数字面量,并且在生成字节码时,在函数入口处安排了一条CreateClosure指令。这条指令会基于当前上下文和内部函数信息创建出可调用的闭包对象,把它存到add这个槽位中。整个过程先于任何业务代码执行完毕,因此后面的add(2, 3)自然拿得到函数。
这里面还有个细节:函数声明在提升时已经捕获了当时所在的作用域链,所以闭包也一并形成了。即便函数内部没用到外部变量,V8 也会把上下文关系记录下来,因为引擎无法静态预知函数内部将来会不会访问外部变量——毕竟惰性解析还没展开内部代码。
2.3 let 和 const:登记了,但没有值
let 和 const 其实也会提升。很多人误以为“let 不提升”,是因为它在声明之前访问时,会抛出ReferenceError,看起来像是“没有这个变量”。但这恰恰说明它是提升了的:如果一个变量完全没有被登记,访问时应该报“变量未定义”;而现在报的是“初始化之前不能访问”,这正说明 V8 知道这个变量存在,只是不允许你现在碰它。
V8 内部用一个特殊标记来表示“这个变量已经登记但尚未初始化”。这个标记在 V8 的术语里被称为“空洞”(The Hole)。当代码访问一个 let/const 变量时,V8 会检查它对应的槽位里是不是空洞,如果是,就抛出我们熟悉的ReferenceError: Cannot access 'x' before initialization。
所以 let/const 提升的状态可以理解成“签收了空包裹,但包裹上贴着‘未初始化’的封条”。封条要到声明语句真正执行时才会被撕掉,在声明语句之前,任何读取、赋值、作为参数传递等行为都会被引擎拒之门外。这个时间段就是常说的“暂时性死区”(Temporal Dead Zone,TDZ)。
3. 提升之后怎么被找到:执行上下文与作用域链
3.1 VariableEnvironment 与 LexicalEnvironment
ECMAScript 规范里,每个执行上下文都包含两个环境:变量环境(VariableEnvironment)和词法环境(LexicalEnvironment)。最初这俩指向同一个环境对象,但随着 ES6 块级作用域的出现,它们开始分道扬镳。
简单说,变量环境主要管 var 声明的变量和函数声明;词法环境则叠加管 let、const、class 这类块级绑定。每当 V8 进入一个块(比如if块、for循环体、try块),它会基于当前词法环境创建一个新的词法环境作为外层环境的“子层”。块里的 let/const 声明就登记在这个新环境里,等代码离开这个块,这个环境也随之销毁。
这也是为什么var能“穿透” if 块、for 循环,而let不能。var 的登记目标始终是当前函数或全局的变量环境,与块级词法环境无关。eslint 为什么老劝你别用 var,除了风格原因,背后其实也有引擎作用域模型的因素:var 的归属范围太大,容易造成变量污染。
3.2 名字查找:作用域链不“查”还好,一查就是一条链
当代码里出现一个变量名x,V8 在生成字节码时通常不是“运行时从最外层一层层找”,而是尽可能在编译期就把x解析成确定的槽位索引。这个静态解析过程,基于解析阶段建立起来的作用域层级。
举个例子:
const globalMsg = 'hello'; function outer() { const outerMsg = 'world'; function inner() { console.log(globalMsg + outerMsg); } return inner; }解析inner函数时,V8 会建立一张“作用域信息表”,记录inner当前看到的作用域链有:函数自身作用域 → outer 函数作用域 → 全局作用域。等访问outerMsg时,解析器发现它不在 inner 自身作用域里,而是在上一层 outer 作用域里,于是会把它标记为一个“上下文槽位”的访问。运行时,字节码直接通过当前上下文的父级指针,逐级找到outerMsg所在的上下文,命中的是具体的槽位索引,并不需要真的遍历整个作用域链做字符串比较。
这正是 V8 执行 JavaScript 比很多解释型语言快的原因之一:作用域提升已经把名字的归属问题提前定死了,运行时只剩下“按索引取数”这个极快的操作。我们把“作用域链查找”理解成一条链是没错的,但实际在 V8 中,链上的每一级都会在编译期被尽可能固化成索引偏移。
3.3 块级作用域让“提升”变得更立体
ES6 之前理解提升,基本是“函数级”的:一个 var 声明在最内层 for 循环里,也会被提升到整个函数顶部。ES6 之后加入 let/const,作用域单位细化到了块,导致“提升”这件事出现了一个全新的维度。
比如 for 循环头部的let i,它既不提升到函数作用域,也不是简单地提升到循环体外层。更有意思的是,每次迭代都会生成一个独立的词法环境,里面保存着当前迭代的i值。否则下面的经典代码就不会输出 0、1、2,而会输出三次 3:
for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i)); }V8 是如何做到“每轮都有一个新 i”的?答案是在循环体的执行过程中,作用域不是创建一次,而是每迭代一次就复制一份词法环境,把i的值拷进去,再让闭包捕获这份新环境。这个机制极大依赖解析阶段对块级作用域的精细划分,也可以说是“提升 + 块作用域 + 闭包”三者交织出的脑洞设计。
4. 用字节码亲眼看看提升:Ignition 的底层小动作
4.1 构造一个能“暴露”提升的实验场景
空谈概念容易飘,我们直接请出 V8 的字节码输出。如果你有一个基于 V8 的运行时环境(比如浏览器内核或 Node),可以加上打印字节码的参数去运行一小段代码。我用下面这个例子来演示:
function testHoisting() { console.log(value); console.log(add(1, 2)); console.log(count); var value = 10; function add(a, b) { return a + b; } let count = 99; } testHoisting();预期表现是:console.log(value)输出 undefined,add(1, 2)正常输出 3,console.log(count)抛出 TDZ 错误。这三分支分别对应 var、函数声明、let 三种提升形态。把这段代码交给带字节码打印参数的运行时,会看到与本文描述高度一致的指令序列。
4.2 CreateClosure 与上下文槽的初始化指令
在打印出的字节码中,函数入口附近最先出现的通常包括CreateClosure指令。它接收一个内部函数索引和一个反馈向量槽位,作用就是把add这个函数对象创建出来并绑定到对应槽位。紧接着,会看到给value赋初始值的操作:加载一个小整数 undefined,然后写入某个栈槽或上下文槽。这对应的正是 var 提升时代的“先到账一个 undefined”。
字节码里还会出现类似StaCurrentContextSlot或StaContextSlot这样的指令,表示把值写入当前上下文的指定槽位。当你看到这些指令出现在函数体内、但源代码里明明还没写赋值语句时,就知道 V8 正在执行“提升后的初始化动作”。反过来,你在源代码里写的value = 10,对应的是字节码中更靠后的另一条存储指令。声明和赋值被拆成了两条指令,分布在不同的执行时点——这就是提升在字节码层次留下的最直观证据。
顺带说一句,如果你尝试把var value去掉,编译器就不会生成那一条 undefined 预初始化指令。这也是为什么我们用“声明提前”而不是“赋值提前”来解释提升:真正被提前的是绑定行为,赋值永远待在原处。
4.3 TDZ 是怎么被“拦”下来的
let 变量声明之前的访问,在字节码里能看到一个专门指令:ThrowReferenceErrorIfHole。它的语义是:检查当前变量槽位的值是不是“空洞”标记,如果是,立刻抛出 ReferenceError。
在let count = 99这条声明执行之前,count的槽位里存放的是空洞值。任何对它的读取指令前,解析器都会插入一条ThrowReferenceErrorIfHole来把关。这条指令存在,等于把 TDZ 变成了一道物理闸门:你进不了门,不是因为门不存在,而是门锁着,钥匙是那条声明语句。等到let count = 99执行,V8 把 99 写入槽位、清掉空洞标记之后,后续访问才畅通无阻。
这也能解释一个很多人踩过的坑:typeof 一个未声明的变量不会报错,返回 “undefined”,但 typeof 一个处于 TDZ 的 let 变量会直接抛错。因为 typeof 对未登记名字走的是一套宽松检查路径,而 TDZ 变量已经被登记了,它走的是“先检查空洞、再读值”的严格路径,所以直接撞上了ThrowReferenceErrorIfHole。
5. 提升的常见误判与实战排查技巧
5.1 “提升到函数顶部”不等于“定义了”
我面试过不少候选人,能熟练背诵“var 提升、函数提升、let 不提升”,可一到实际排查就翻车。最典型的误解是把“提升”等同于“声明已定义”。实际上提升只完成了“绑定名字”这一步,值的初始化是另一回事。var 绑定后的初始值是 undefined,let/const 绑定后的初始值是“禁止访问”的空洞状态,只有函数声明会立刻绑定为可调用的函数对象。
另一个相关误解是认为提升会把变量“搬到文件顶部”。如果真按字面搬家,那文件最顶部引用一个变量,应该等于引用“搬家后的变量”。但对 var 来说,文件最顶部其实仍然在函数作用域内,所以结果依然是 undefined;而对 let 来说,文件最顶部同样处于 TDZ。所谓“搬到顶部”只是为了方便初学者理解“先声明后使用”的假象,真正重要的是“绑定发生在作用域创建时”。
5.2 函数声明与函数表达式的分岔路
这段代码非常经典:
console.log(foo); // undefined console.log(bar); // ReferenceError: bar is not defined var foo = function bar() {};foo是 var 声明的变量,提升后初始值是 undefined;function bar() {}作为表达式的一部分,并不会触发函数声明的提升。函数表达式必须等执行到赋值语句时,才会把函数对象赋给foo。如果写var foo = function bar() {}再提前调用foo(),会得到TypeError: foo is not a function。
理解这两条路径,对排查“为什么这里调用报错、那里调用正常”非常有用。遇到类似问题,先判断目标名字的绑定形式:是函数声明、var 声明,还是 let/const 声明。然后再看访问点在声明语句之前还是之后。一个简单口诀:var 提前给 undefined,函数声明提前给函数,let/const 提前给异常。
5.3 提升会影响性能吗?
经常有同学担心:变量提升是不是会让作用域变得很复杂,从而拖慢性能?答案在 V8 的实际实现中,是否定的。提升反而帮助了性能优化。
因为提升意味着变量绑定信息在编译期就已经确定,V8 不需要在运行时动态扫描作用域里有哪些变量。TurboFan 做优化编译时,可以基于这套静态信息把变量直接映射到寄存器或栈固定偏移上,免去每次访问都做哈希查找。甚至对于未被闭包捕获的变量,V8 可以直接分配在栈上,作用域一结束就释放,成本极低。
真正需要留神的不是提升本身,而是闭包对变量生命周期的影响。一个变量如果被内部函数引用,就无法放在栈上简单释放,必须进入上下文堆对象。过度使用闭包,或者在大循环里反复创建闭包,才会给引擎带来真实的分配压力。这个锅不应该甩给提升。
5.4 在调试器里直接查看 Scope
实战排查提升问题时,最好用的工具是调试器的 Scope 面板。在浏览器开发者工具里,任何断点处都能看到当前作用域链上每一层的变量列表,通常分为 Local、Block、Closure、Global 这几类。其中 Block 就是块级词法环境的体现,Closure 则是闭包捕获的上下文。
我曾经排查过一个很隐蔽的问题:一个 for 循环里声明的 let 变量,循环结束后在外面还能访问到。后来用调试器一看,发现那根本不在循环的块作用域里,而是我误把 let 写在了函数体顶层。Scope 面板把每一层环境里的绑定一览无余地列了出来,结合断点往前推,很快定位到问题。
一个建议:多利用“Scope”面板去读作用域层级。比如在 var 声明语句之前打断点,看该变量出现在哪个 Scope 层级、值是不是 undefined;在 let 声明之前打断点,看对应变量是否显示uninitialized或<unavailable>。亲眼见过这些状态之后,你对提升的理解会从“背概念”变成“有手感”。
5.5 从一个生产环境的经典报错说起
最后分享一个很常见的线上场景。某段代码在条件分支里声明了一个 var 变量,并尝试在分支外读取。开发者觉得“分支没进就不应该存在这个变量”,结果读出来是 undefined,而不是报错。这不是引擎出 bug,而是 var 被提升到了整个函数顶部,与是否进入分支毫无关系。
更隐蔽的是另一个场景:代码在 try 块里const data = parse(x),catch 块里想访问data。结果编辑器直接标红,运行时也报Cannot access 'data' before initialization。这是因为 const 绑定到了 try 块的词法环境,try 块之后根本不处于这个环境的作用域内。想要跨块共享,就必须把声明放到外层作用域。这个例子跟提升无关,但它提醒我们:块级作用域和提升机制混在一起时,出问题的概率远高于单独理解一个概念。
我的实际体会是:遇到作用域相关的诡异行为,先别急着怀疑引擎,先在纸面上画出“声明语句在哪一层、访问语句在哪一层、绑定类型是什么”三条信息。90% 的问题都能在这一张图上现出原形。提升这件事,说到底就是引擎在告诉你一句话:名字可以先占位,但占位不代表有值;有空位、有 undefined、有禁入区,三种状态对应三种不同的“先使用后声明”。把这三种状态的边界吃透,JavaScript 作用域这块基本就稳了。