Skip to content

JS 引擎与执行上下文

V8 垃圾回收 · V8 执行过程 · 执行上下文 · 词法环境 · 作用域链 · 闭包 · 内存机制 · 类型系统


一、V8 引擎

1. DOM 树是怎么生成的

  • 流程:浏览器是多进程架构,网络进程根据 Content-Type 创建渲染进程 → 渲染进程中 HTML 解析器逐字节解析字节流
  • 解析四阶段:词法分析 → 语法分析 → 转换 → 生成(对标 babel 等编译工具)
    • 词法分析:对声明、命令、赋值、注释等分词
    • 对 HTML 而言,分词分为标签和文本内容,再通过算法转换成 DOM
  • script 标签的影响:遇到 <script> 会暂停 DOM 转换,去解析 JS(阻塞);async/defer 可异步加载不阻塞 HTML 转换

渲染进程完整架构详见 浏览器 · 进程与渲染

2. V8 垃圾回收(分代式)

  • 背景:JS 单线程,GC 会占用主线程导致卡顿,所以需要策略
  • 新生代(Scavenge 算法)
    • 分为 FROM 和 TO 两个区
    • 每次 Scavenge 将 FROM 区中未被引用的销毁
    • 活着的对象复制到 TO 区,然后互换角色
    • 晋升老生代的条件
      1. 经历一次 Scavenge 后仍存活
      2. TO 区内存超过 25%
    • 25% 阈值原因:为了不影响后续 FROM 空间的分配
  • 老生代
    • 标记清除:从根节点访问变量,可达为活动,不可达为垃圾;会造成内存碎片(不连续)
    • 标记整理:解决内存碎片,清理边界碎片

3. V8 执行 JS 代码的整个过程

JS 属于解释型语言,但 V8 的执行过程并非纯粹的解释型,而是结合了解释器和编译器(JIT 即时编译)。整个过程如下:

第一步:生成 AST(抽象语法树)

  • 词法分析(分词):将代码分解为 token,如 let name = 'sanyuan' 分解为 letname='sanyuan' 四个 token
  • 语法分析:将 token 按语法规则转化为 AST 树形结构
  • 生成 AST 后,后续工作都基于 AST 而非源代码(Babel 原理同理:ES6 AST → ES5 AST → ES5 代码)

第二步:生成字节码

  • AST 通过 V8 解释器 Ignition 生成字节码
  • 字节码是介于 AST 和机器码之间的中间代码,体积远小于机器码
  • 早期 V8 直接将 AST 转为机器码,但机器码体积太大导致严重内存占用问题

第三步:执行字节码(JIT 即时编译)

  • 解释器逐行执行字节码
  • 执行过程中,如果某部分代码重复出现,V8 将其标记为热点代码(HotSpot)
  • 编译器 TurboFan 将热点代码编译为机器码缓存起来,下次直接执行
  • 代码执行越久,执行效率越高(越来越多热点代码被优化)

注意:JS 并非完全的解释型语言,因为字节码同时与解释器(Ignition)和编译器(TurboFan)打交道,这种技术称为 JIT(Just-In-Time)即时编译


二、执行上下文

4. 执行上下文(Execution Context)

概念

执行上下文是计算和执行 JS 代码的环境的抽象概念。每当 JS 代码在运行,它都在执行上下文中运行。

三种类型

  1. 全局执行上下文:默认/基础上下文,不在函数内部的代码都在全局上下文中。创建全局 window 对象,设置 this = 全局对象。程序中只有一个。
  2. 函数执行上下文:每当函数被调用时创建一个新的上下文,可以有任意多个。
  3. Eval 函数执行上下文:执行在 eval 内部的代码(不常用)。

执行栈(调用栈)

  • 数据结构:LIFO(后进先出),存储代码运行时创建的所有执行上下文
  • 流程
    1. JS 引擎创建全局执行上下文压入栈底
    2. 遇到函数调用 → 创建新上下文压入栈顶
    3. 执行栈顶函数,执行完毕 → 上下文弹出
    4. 控制流程到达下一个上下文
    5. 所有代码执行完 → 全局上下文出栈

创建执行上下文的两个阶段

一、创建阶段(代码执行前,发生三件事):

ExecutionContext = {
  ThisBinding: <this value>,
  LexicalEnvironment: { ... },   // 词法环境
  VariableEnvironment: { ... },  // 变量环境
}

1) this 绑定

  • 全局上下文:this 指向全局对象(浏览器中为 window)
  • 函数上下文:取决于调用方式
    • 被对象引用调用 → this = 该对象
    • 无引用对象调用 → this = 全局对象(严格模式下为 undefined)

2) 词法环境(Lexical Environment)

  • 定义:持有标识符—变量映射的结构(标识符是名字,变量是对实际数据的引用)
  • 组成
    • 环境记录器(Environment Record):存储变量和函数声明的实际位置
    • 外部环境引用(outer):指向父级词法环境(即作用域)
  • 两种类型
    • 全局环境:outer 为 null,拥有内建对象和用户定义的全局变量
    • 函数环境:存储函数内部用户定义的变量,outer 指向全局或外部函数环境
  • 环境记录器两种类型
    • 声明式环境记录器:存储变量、函数、参数(用于函数环境)
    • 对象环境记录器:定义全局上下文中变量和函数的关系(用于全局环境)

3) 变量环境(Variable Environment)

  • 也是一个词法环境,拥有词法环境的所有属性
  • 与词法环境的区别(ES6)
    • 词法环境:存储函数声明let/const 变量绑定
    • 变量环境:只存储 var 变量绑定
js
// 创建阶段的上下文示例:
GlobalExecutionContext = {
  ThisBinding: <Global Object>,
  LexicalEnvironment: {
    EnvironmentRecord: { a: <uninitialized>, b: <uninitialized>, multiply: <func> },
    outer: null
  },
  VariableEnvironment: {
    EnvironmentRecord: { c: undefined },
    outer: null
  }
}

变量提升的本质

  • var 在创建阶段被设为 undefined → 可在声明前访问(值为 undefined)
  • let/const 在创建阶段为 uninitialized(未初始化)→ 声明前访问会报 ReferenceError(暂时性死区 TDZ)

JS 自动分号补全(ASI)

JS 解释器有 Semicolon Insertion 规则,会在适当位置自动补充分号。

经典陷阱

js
function b() {
  return
  { a: 'a' }
}
// 实际等价于:return; { a: 'a' }; → 返回 undefined

ES3 vs ES5 规范对比

概念ES3 规范ES5 规范
变量存储Variable Object(VO)/ Activation Object(AO)Lexical Environment + Variable Environment
作用域作用域链(scope chain)词法环境的外部引用(outer)
代表书籍《JavaScript 高级程序设计》第三版ES5+ 规范文档

看到"变量对象/活动对象"→ ES3;看到"词法环境/变量环境"→ ES5+


三、作用域与闭包

5. 作用域与作用域链

  • 作用域:定义变量的区域,有一套访问规则,管理引擎如何在当前及嵌套作用域中查找变量
  • 作用域链:保证执行环境有权访问的所有变量和函数的有序访问,本质是指向变量对象的指针列表
  • 查找变量时沿作用域链从内向外查找(就近原则),前端为当前执行上下文,末端为全局对象
  • 函数的形参相当于在函数内部声明变量,属于局部变量

作用域的分类

类型说明关键字
全局作用域函数外部声明,页面任何地方可访问var(全局)/ 不声明直接赋值
函数作用域(局部)函数内部声明,只能在函数内使用var / let / const
块级作用域{} 内部(if/for/while),只能在块内使用let / const(ES6)

ES6 之前采用函数作用域 + 词法作用域;ES6 采用块级作用域 + 词法作用域

  • 词法作用域:描述的是"变量的查找规则"(静态作用域,写代码时就确定了查找路径)

延长作用域链

以下两种语句可以在作用域链前端临时增加一个变量对象(代码执行后移除):

  1. try-catch 的 catch 块:创建一个新的变量对象,包含被抛出的错误对象的声明
  2. with 语句:将指定对象添加到作用域链中(不推荐使用,影响性能且难以调试)

6. 闭包

  • 定义:能访问其他函数内部变量的函数(能访问自由变量的函数)
  • 概念补充:上下文中的 scope 被销毁,但因保持对 scope 中某个变量的引用(GC 根节点可达性),导致未销毁干净,留存在内存中形成闭包
  • 使用场景:防抖节流、定时器回调、模块封装
  • 优点:内部变量私有,隔离作用域,数据不被污染
  • 缺点:垃圾回收无法清理内部变量,可能导致内存泄漏

闭包实战示例(闭包 vs 非闭包、引用类型、变量提升、块级作用域)详见 作用域与闭包


四、类型系统与内存

7. JS 内存机制与数据类型存储

  • 基本类型(7种):Undefined、Null、Boolean、Number、String、Symbol(ES6)、BigInt(ES10)
  • 引用类型:Object(含 Function、Array、Date 等)
  • 存储方式
    • 基本类型:直接存储在中,空间小、大小固定
    • 引用类型:栈中存指针(指向堆中实体起始地址),堆中存实际数据,空间大、大小不固定
    • 闭包变量存在堆内存中(不在栈中,否则函数调用完栈销毁后闭包变量就丢失了)
  • 赋值操作的区别
    • 原始类型:直接完整复制变量值(互不影响)
    • 对象类型:复制引用地址(两个变量指向同一堆空间,修改互相影响)
  • 为什么不全部用栈存储?:如果把复杂对象也存栈中,切换上下文的开销将变得巨大
    • 堆内存虽然空间大,但垃圾回收开销更大(V8 64位系统最多分配 1.4GB,32位最多 0.7GB)

8. null 与 undefined 区别

  • undefined:变量已声明但未赋值;null:表示空对象(有意为空)
  • typeof null 返回 "object"(历史 Bug:最初 32 位系统中 null 低位全为 0,被误判为 object)
  • null == undefined 为 true,null === undefined 为 false
  • undefined 不是保留字,可被覆盖(危险),安全获取用 void 0

9. 类型判断方法

  • typeof:只能判断基本类型,引用类型都返回 "object"(null 也返回 "object")
  • Object.prototype.toString.call():最准确,返回 [object Type]
  • instanceof:判断原型链,适合判断自定义类和内置对象,不能判断基本类型
  • Array.isArray():专门判断数组

补充知识点

基本包装类型

  • '1'.toString() 为什么能调用?过程:创建 new Object('1') 临时包装对象 → 调用方法 → 立即销毁临时对象
  • 基本包装类型:BooleanNumberString(不建议用 new 创建)

0.1 + 0.2 !== 0.3

  • 原因:0.1 和 0.2 转二进制后无限循环,精度截断导致相加结果为 0.30000000000000004
  • JS 中 Number 采用 IEEE 754 双精度浮点,安全整数范围:-(2^53-1) ~ 2^53-1

Object.is vs ===

  • ===(严格相等):值和类型都需相同
  • Object.is:在 === 基础上修复两个特殊情况:
    • +0 !== -0=== 认为相等,Object.is 区分)
    • NaN === NaN=== 为 false,Object.is(NaN, NaN) 为 true)

函数传参机制(按共享传递)

  • 函数传参传递的是对象在堆中的内存地址值(按共享传递)
  • 通过参数可修改原对象属性,但重新赋值参数不会影响原变量
js
function test(person) {
  person.age = 26     // 修改了原对象
  person = { name: 'new' }  // 重新指向新地址,不影响原变量
  return person
}
const p1 = { name: 'fyq', age: 19 }
const p2 = test(p1)
// p1: { name: 'fyq', age: 26 }
// p2: { name: 'new' }

Symbol.hasInstance 自定义 instanceof

js
class PrimitiveNumber {
  static [Symbol.hasInstance](x) {
    return typeof x === 'number'
  }
}
console.log(111 instanceof PrimitiveNumber) // true

10. valueOf / toString / toPrimitive(隐式转换)

对象参与运算时需先转换为原始值,转换规则如下:

valueOf —— 返回对象的原始值

对象返回值
Array数组本身
Date毫秒时间戳
Function函数本身
Number数字值
Object对象本身
String字符串值

toString —— 返回对象的字符串表示

对象返回值
Array逗号分割字符串,如 [1,2].toString()"1,2"
Date可读时间字符串
Function函数源代码字符串
Object"[object Object]"
  • toString() 可传参指定进制输出(如 (255).toString(16)"ff"
  • null/undefined 不能调用 toString,但 String(null) 可以

Symbol.toPrimitive —— 优先调用的转换方法

js
obj[Symbol.toPrimitive](hint) // hint: "string" | "number" | "default"
  • hint 由 JS 引擎根据上下文自动判断:
    • "string":模板字符串 ${obj}alert(obj)String(obj)
    • "number"+objNumber(obj)、数学运算(-/*//)、比较运算
    • "default":二元加法 +(可拼接也可相加)、== 比较

转换优先级规则

  1. 若定义了 Symbol.toPrimitive优先调用
  2. 若 hint 为 "string":先调 toString(),若不存在或返回非原始值再调 valueOf()
  3. 若 hint 为 "number""default":先调 valueOf(),若不存在或返回非原始值再调 toString()

经典面试题

实现 a == 1 && a == 2 && a == 3== 触发隐式转换,hint 为 default)

js
class A {
  constructor(v) { this.a = v }
  valueOf() { return ++this.a }  // hint=default 时优先调 valueOf
}
var a = new A(0)
a == 1 && a == 2 && a == 3  // true

实现 a === 1 && a === 2 && a === 3=== 不隐式转换,用 getter)

js
var value = 1
Object.defineProperty(window, 'a', {
  get() { return value++ }
})
a === 1 && a === 2 && a === 3  // true

参数累加(利用 toPrimitive 实现 curry(1,2)(3,4,5)(6)(7,8) 求和)

js
function curry(...args1) {
  let arr = [...args1]
  const sum = function(...args2) {
    arr = [...arr, ...args2]
    return sum
  }
  sum[Symbol.toPrimitive] = function() {
    return arr.reduce((t, c) => t + c, 0)
  }
  return sum
}
const res = curry(1, 2)(3, 4, 5)(6)(7, 8)
console.log(+res)  // 36

11. JS 隐式转换规则(ToString / ToNumber / ToBoolean)

ToString(其他类型 → 字符串)

  • null"null"undefined"undefined"
  • 布尔:true"true"false"false"
  • 数字:转为字符串形式,如 10"10"1e21"1e+21"
  • 数组:所有元素按 , 连接(相当于 join()),空数组 []"",数组中的 null/undefined 当空字符串处理
  • 普通对象:"[object Object]"

ToNumber(其他类型 → 数字)

  • null0undefinedNaN
  • 字符串:纯数字形式转对应数字,空字符串 → 0,否则 → NaN
  • 布尔:true1false0
  • 数组/对象:先 ToPrimitive 转原始值,再按上述规则转数字
js
Number(null)       // 0
Number(undefined)  // NaN
Number('')         // 0
Number([])         // 0([] → toString → "" → 0)
Number(['1'])      // 1
Number({})         // NaN

ToBoolean(其他类型 → 布尔)

  • 假值(falsy)只有 6 个falsenullundefined""(空字符串)、0NaN
  • 其他所有值都为 true,包括 []{}" "(空格字符串)、Infinity

== 宽松相等比较的隐式转换规则

  1. 布尔 vs 其他:布尔先转数字(true → 1,false → 0),再比较
  2. 数字 vs 字符串:字符串转数字,再比较
  3. 对象 vs 原始类型:对象先 ToPrimitive 转原始值,再比较
  4. null / undefined vs 其他null == undefinedtrue;null/undefined 只与彼此宽松相等,与其他所有值都不等

经典踩坑示例

js
[] == ![]       // true(![] → false,[] → "" → 0,false → 0)
['0'] == false  // true(['0'] → "0" → 0,false → 0)
[null] == false // true([null] → "" → 0,false → 0)

注意if (x) 中 x 做 ToBoolean 转换,而 if (x == true) 中 true 做 ToNumber 转 1,两者行为不同!


五、new Function()

js
let func = new Function(arg1, arg2, ..., functionBody)
  • 最后一个参数是函数体(字符串),其余参数是形参
  • 函数体数据格式是字符串,这是其无可替代的核心特性

应用场景

js
// 1. 非合法 JSON 字符串合法化
let str = `{ "id": 10393, name: 'zhangxinxu', 'date': '2022-04-30' }`;
JSON.stringify(new Function('return ' + str)())

// 2. 模板字符串作为模板引擎
String.prototype.interpolate = function (params) {
  const names = Object.keys(params)
  const vals = Object.values(params)
  return new Function(...names, `return \`${this}\``)(...vals)
}
  • new Function 没有闭包,函数体中的变量指向全局作用域而非局部作用域
  • 类似 eval,存在 XSS 风险,用户输入场景需谨慎使用

六、内存泄漏常见原因

  1. 意外的全局变量
  2. 被遗忘的定时器/回调函数
  3. 脱离 DOM 的引用
  4. 闭包

性能优化详见 性能优化专题


相关