Skip to main content

代码执行原理

js 在 V8 等现代引擎中采用 JIT(即时编译) 模式:先用解释器跑字节码实现"快速启动",再把频繁执行的热点代码编译成机器码实现"速度起飞",必要时还会去优化回退到字节码。所以严格说 js 早已不是纯解释型语言,而是解释 + JIT 编译的混合体。

js 引擎执行流程

总体分为两大阶段:

  1. 编译阶段(Parse + Compile):源码 → 词法/语法分析 → AST → 字节码 / 机器码
  2. 执行阶段(Execute):引擎创建执行上下文、推入调用栈、逐行执行;遇到异步任务时调度到任务队列

执行阶段涉及的数据结构:

  • 调用栈(Call Stack):LIFO 结构,存放执行上下文。栈顶是当前正在执行的上下文
  • 内存堆(Heap):非结构化内存区,给对象、闭包变量等分配空间
  • 任务队列(Task Queue):存放待执行的异步任务,又分宏任务队列和微任务队列

V8 引擎编译与执行流程 (JIT)

“现代 V8 引擎采用了 JIT (即时编译) 技术。它结合了解释器启动快编译器执行快的优点:先用解释器快速跑字节码,如果发现某段代码被频繁调用,就用编译器把它升级成机器码,让速度起飞。”

主要是以下四个步骤(一树两码两过程):

  1. 生成 AST(抽象语法树): 解析器(Parser)把我们写的 JS 源码进行词法和语法分析,转换成机器能理解的树状结构(AST)。
  2. 解释器(Ignition)生成 字节码(Bytecode): 为了最快地让代码跑起来,V8 会先将 AST 转为轻量级的字节码,并直接解释执行。
  3. 编译器(TurboFan)优化为 机器码(Machine Code): 这是 JIT 的核心!V8 会在运行时监控代码。如果某个函数被疯狂调用(称为热点代码 Hot Code),编译器就会把它直接编译成极快的机器码。下次再调这个函数,直接跑机器码,速度极快。
  4. 去优化(Deoptimization): JS 是动态语言。如果在后续执行中,V8 发现之前假设的数据类型突然变了(比如一直传数字的函数突然传了字符串),原来优化的机器码就作废了。此时 V8 会“去优化”,退回到第 2 步,老老实实去执行字节码。

🎯 如何编写有利于 V8 优化的代码?

既然 V8 喜欢“类型稳定”的热点代码,日常开发中迎合 V8 优化的核心原则就是“保持对象和参数的结构/类型稳定”

  1. 保持对象属性顺序一致:V8 底层通过隐藏类(Hidden Class)优化属性访问。{a:1, b:2}{b:2, a:1} 结构不同,无法复用隐藏类。
  2. 避免动态增删属性:尽量在构造函数中一次性初始化好所有属性,不要随便用 delete
  3. 保持函数参数类型稳定:如果函数参数一会传 Number,一会传 String,会触发 V8 的去优化,导致机器码降级回字节码,性能大损。

执行阶段

代码生成后 js 引擎会先创建执行上下文(也叫预编译),再逐块(执行上下文)逐行执行代码

执行上下文

分类:

  • 全局执行上下文
  • 函数执行上下文
  • eval 函数执行上下文(下文暂不提及)

第一次读取 js 脚本时会生成全局执行上下文,有且只有一个,始终位于调用栈底部。当函数被调用时,会创建一个函数执行上下文并推入当前栈顶,执行完函数会出栈。栈顶是当前活动的执行上下文

image.png

注意(ES3 vs ES6 规范变更): 早期 ES3 规范使用变量对象(VO)活动对象(AO)来描述变量存储。 现代 ES6+ 规范改用了词法环境(Lexical Environment)变量环境(Variable Environment)

每次创建执行上下文主要包含以下三个核心组件(以 ES6 为准):

  1. 变量环境(Variable Environment)
    • 存储 var 变量绑定和 function 声明。
    • 这里的声明会在编译阶段被提升(Hoisting),并初始化为 undefined(对于 var)或直接指向堆内存的函数对象(对于 function)。
  2. 词法环境(Lexical Environment)
    • 存储 letconstclass 等块级作用域变量绑定。
    • 虽然也会提升,但处于暂时性死区(TDZ, Temporal Dead Zone),在代码实际执行到声明处之前访问会抛出 ReferenceError
    • 维护一个指向外部环境的引用(Outer Env Reference),这就是作用域链的底层实现原理。
  3. This 绑定(This Binding)
    • 确定当前执行上下文的 this 指向。

JavaScript 执行上下文——JS 的幕后工作原理

变量、函数提升

函数和变量声明的提升是在创建词法环境 / 变量环境阶段进行的(ES3 时代用 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 编译阶段就已经确定的。作用域可分为以下几种:

  • 全局作用域
  • 函数作用域
  • 块级作用域

通常在函数中,查找变量会先从当前执行上下文的词法环境(ES6+,或 ES3 的 AO/VO)查找,没找到就沿作用域链往父级查找,直至全局。这条链就是作用域链(Scope Chain)

[[scope]](V8 内部表示):

  • 函数创建时生成的内部属性,是作用域链的容器
  • 链结构(由内向外):
    • 当前函数的执行期上下文(AO / Lexical Environment)
    • 外层函数 / 全局(GO / Global Environment)
  • 函数执行完毕后,AO 会被销毁;但如果被闭包引用,对应变量会留在堆上
  • 闭包变量 Closure 就存储在 [[scope]] 中

作用域和执行上下文的区别是什么?

作用域是静态的,编译阶段就决定了变量的可访问范围;执行上下文是动态的,函数调用时才创建,并确定 this 指向。一个作用域可以对应多个执行上下文(函数被多次调用)。

this 指向

绑定规则优先级this 指向典型写法
new 绑定1(最高)新创建的实例对象new Foo()
显式绑定2call/apply/bind 传入的对象foo.call(obj)
隐式绑定3调用方法的对象obj.foo()
默认绑定4(最低)全局对象(严格模式下为 undefinedfoo()
箭头函数继承外层词法作用域的 this,自身无法覆盖() => this.x

箭头函数没有自己的 this,也无法用 call/apply/bind 改变——这是上面那张表唯一的例外。详见语法-箭头函数对比

闭包

闭包 = 函数 + 它能"看到"的外部变量。即使创建这些变量的执行上下文已经销毁,闭包依然能访问它们(变量会留在堆上,不会被 GC)。

function makeCounter() {
let count = 0; // 局部变量
return function () {
return ++count; // 闭包:捕获了 count
};
}

const counter = makeCounter();
counter(); // 1
counter(); // 2
// makeCounter 早已执行完毕,但 count 仍存活于堆中

闭包 ≠ 内存泄漏。闭包本身是合理特性,只有当本应释放的引用被意外持有时才会泄漏(比如在长生命周期对象上挂了一个不再需要的大闭包)。

执行代码

image.png

执行代码的过程其实就是把调用栈里的调用帧(一个执行上下文)依次执行,同时在内存堆中给新生成的变量分配空间。结合事件循环(Event loop),执行流程如下:

  • 执行同步代码(可看做第一个宏任务)
  • 同步代码执行完毕后,清空当前微任务队列。在执行微任务的过程中如果又产生了新的微任务,会继续加入并在这一轮循环中全部执行完——这正是微任务无限递归会卡死页面的原因
  • 微任务队列清空后,浏览器判断是否需要 UI 渲染(Render),有需要则执行重绘/重排
  • 渲染完毕后,Event Loop 从宏任务队列中取出一个队首任务执行,开始下一轮循环

微任务(Microtasks)Promise.then/catch/finallyMutationObserverqueueMicrotask()process.nextTick(Node.js 独有,优先级高于 Promise) 宏任务(Macrotasks)setTimeoutsetIntervalsetImmediate(Node.js/IE 独有)、I/O、UI 交互事件、requestAnimationFrame(浏览器渲染前)

浏览器 vs Node.js 事件循环差异

维度浏览器Node.js
调度方浏览器渲染主线程libuv 线程池
微任务插入点每个宏任务之后每个阶段(timers / pending / idle / poll / check / close)切换时
特有任务requestAnimationFrameprocess.nextTick优先于其他微任务)、setImmediate(check 阶段)
setTimeout(fn, 0)实际最小 4ms(嵌套 5 次后)1ms 起步

经典坑:在浏览器里,await 后续代码是微任务;但在 Node.js 里,process.nextTickawait 后面的微任务更优先。同一段代码在两端可能输出顺序不同。

// 浏览器 vs Node.js 输出顺序差异
Promise.resolve().then(() => console.log("promise"));
process.nextTick?.(() => console.log("nextTick"));
// 浏览器:promise
// Node.js:nextTick → promise