代码执行原理
js 是解释型语言
js 引擎执行流程
分为两个阶段:
- 语法分析
- 执行阶段
执行阶段涉及的数据结构:
- 调用栈。处理执行上下文和执行代码
- 内存堆。给对象分配内存
- 任务队列。暂存待执行的任务,分为宏任务队列和微任务队列
V8 引擎编译与执行流程 (JIT)
“现代 V8 引擎采用了 JIT (即时编译) 技术。它结合了解释器启动快和编译器执行快的优点:先用解释器快速跑字节码,如果发现某段代码被频繁调用,就用编译器把它升级成机器码,让速度起飞。”
主要是以下四个步骤(一树两码两过程):
- 生成 AST(抽象语法树): 解析器(Parser)把我们写的 JS 源码进行词法和语法分析,转换成机器能理解的树状结构(AST)。
- 解释器(Ignition)生成 字节码(Bytecode): 为了最快地让代码跑起来,V8 会先将 AST 转为轻量级的字节码,并直接解释执行。
- 编译器(TurboFan)优化为 机器码(Machine Code): 这是 JIT 的核心!V8 会在运行时监控代码。如果某个函数被疯狂调用(称为热点代码 Hot Code),编译器就会把它直接编译成极快的机器码。下次再调这个函数,直接跑机器码,速度极快。
- 去优化(Deoptimization): JS 是动态语言。如果在后续执行中,V8 发现之前假设的数据类型突然变了(比如一直传数字的函数突然传了字符串),原来优化的机器码就作废了。此时 V8 会“去优化”,退回到第 2 步,老老实实去执行字节码。
🎯 如何编写有利于 V8 优化的代码?
既然 V8 喜欢“类型稳定”的热点代码,日常开发中迎合 V8 优化的核心原则就是“保持对象和参数的结构/类型稳定”:
- 保持对象属性顺序一致:V8 底层通过隐藏类(Hidden Class)优化属性访问。
{a:1, b:2}和{b:2, a:1}结构不同,无法复用隐藏类。- 避免动态增删属性:尽量在构造函数中一次性初始化好所有属性,不要随便用
delete。- 保持函数参数类型稳定:如果函数参数一会传
Number,一会传String,会触发 V8 的去优化,导致机器码降级回字节码,性能大损。
执行阶段
代码生成后 js 引擎会先创建执行上下文(也叫预编译),再逐块(执行上下文)逐行执行代码
执行上下文
分类:
- 全局执行上下文
- 函数执行上下文
- eval 函数执行上下文(下文暂不提及)
第一次读取 js 脚本时会生成全局执行上下文,有且只有一个,始终位于调用栈底部。当函数被调用时,会创建一个函数执行上下文并推入当前栈顶,执行完函数会出栈。栈顶是当前活动的执行上下文

注意(ES3 vs ES6 规范变更): 早期 ES3 规范使用变量对象(VO)和活动对象(AO)来描述变量存储。 现代 ES6+ 规范改用了词法环境(Lexical Environment)和变量环境(Variable Environment)。
每次创建执行上下文主要包含以下三个核心组件(以 ES6 为准):
- 变量环境(Variable Environment)
- 存储
var变量绑定和function声明。 - 这里的声明会在编译阶段被提升(Hoisting),并初始化为
undefined(对于var)或直接指向堆内存的函数对象(对于function)。
- 存储
- 词法环境(Lexical Environment)
- 存储
let、const、class等块级作用域变量绑定。 - 虽然也会提升,但处于暂时性死区(TDZ, Temporal Dead Zone),在代码实际执行到声明处之前访问会抛出
ReferenceError。 - 维护一个指向外部环境的引用(Outer Env Reference),这就是作用域链的底层实现原理。
- 存储
- This 绑定(This Binding)
- 确定当前执行上下文的
this指向。
- 确定当前执行上下文的
变量、函数提升
函数和变量声明提升是在创建变量对象(VO/AO)阶段进行的,举个例子:
function foo(a) {
console.log(b);
console.log(foo2);
console.log(c);
var b = 2;
function foo2() {}
var c = function () {};
b = 3;
}
foo(1);
创建执行上下文时,此时的 AO 是
AO = {
arguments: {
0: 1,
length: 1
},
a: 1,
b: undefined,
foo2: reference to function foo2(){},
c: undefined
}
最终的代码执行顺序其实就类似下面的代码:
function foo(a) {
var b;
function foo2() {}
var c;
console.log(b);
console.log(foo2);
console.log(c);
b = 2;
function foo2() {}
c = function () {};
b = 3;
}
为什么 let/const 不存在变量提升?
js 变量的生命周期如下:
- 声明阶段(Declaration phase)。在作用域中注册一个变量
- 初始化阶段(Initialization phase)。分配内存并为作用域中的变量创建绑定
- 赋值阶段(Assignment phase)。为初始化的变量赋值
let/const 在进入块级作用域后,会因为提升的原因先注册到作用域中,但不会被初始化,直到声明语句执行的时候才被初始化,初始化的时候如果使用 let 声明的变量没有赋值,则会默认赋值为 undefined,而 const 必须在初始化的时候赋值。而声明阶段到初始化之间的代码片段就形成了暂时性死区。更详细的内容可以参考 https://blog.csdn.net/weixin_39902608/article/details/112721083
函数声明和变量声明哪个优先?
从上面执行上下文的过程中可以看到,函数声明优先级更高,可以看下面这个例子
function func() {
foo();
function foo() {
console.log("foo1");
}
var foo = function () {
console.log("foo2");
};
}
func();
作用域和作用域链
参考 通过动图了解 JS 中的 ECStack、EC、VO 和 AO
作用域指的是变量的可访问性,是在 js 编译阶段就已经确定的。作用域可分为以下几种:
- 全局作用域
- 函数作用域
- 块级作用域
通常在函数中,查找变量会先从当前执行上下文的 VO 中查找,如果没有就到其父级执行上下文的 VO 查找,直至全局执行上下文的 VO。这流程所形成的链表就是作用域链(Scope Chain)
[[scope]]:
- 函数创建时,会生成的一个 JS 内部的隐式属性
- 函数存储作用域链的容器
- 作用域链:
- AO - 函数的执行期上下文
- GO - 全局的执行期上下文
- 作用域链:
- 函数执行完成以后,AO 会被销毁,再执行会重新创建一个新的 AO
- 全局执行的前一刻 GO -> 函数声明已经定义
- 存储闭包变量 Closure
作用域和执行上下文的区别是什么?
执行上下文是在函数调用的时候才会产生,并且会确定 this 的指向
this 指向
- 默认绑定全局对象
- 隐式绑定:this 指向调用它的对象
- 显式绑定:
apply/call/bind - new 绑定:指向新创建的对象
- 箭头函数:指向定义该函数时所在的作用域指向的对象,而不是使用时所在的作用域指向的对象
闭包
闭包 = 函数 + 访问了其他函数内部的变量,即使创建变量的上下文已经销毁,它仍然存在,可以理解为作用域链的延伸。对于基本类型变量如果存在于闭包当中,那么将存储在堆中
滥用闭包可能会造成内存泄漏
执行代码

执行代码的过程其实就是把调用栈里面的调用帧(一个执行上下文)依次执行,同时在内存堆中给新生成的变量分配空间。结合事件循环(Event loop),执行流程如下:
- 执行同步代码(可以看做第一个宏任务)
- 同步代码执行完毕后,检查并清空微任务队列(Microtask Queue)。在执行微任务的过程中,如果又产生了新的微任务,会继续被加入到当前微任务队列并在这一轮事件循环中被全部执行完毕(这也是如果微任务无限循环会导致页面卡死的原因)。
- 微任务队列清空后,浏览器会判断是否需要进行 UI 渲染(Render)。如果有需要,则执行重绘或重排。
- 渲染完毕后,Event Loop 就会从宏任务队列(Macrotask Queue)中取出一个队首的任务执行,开始下一轮循环。
微任务(Microtasks):
Promise.then/catch/finally、MutationObserver、process.nextTick(Node.js 独有,优先级高于 Promise)。 宏任务(Macrotasks):setTimeout、setInterval、setImmediate(Node.js/IE 独有)、I/O、UI 交互事件、requestAnimationFrame(通常在渲染前执行)。