Appearance
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 区,然后互换角色
- 晋升老生代的条件:
- 经历一次 Scavenge 后仍存活
- TO 区内存超过 25%
- 25% 阈值原因:为了不影响后续 FROM 空间的分配
- 老生代:
- 标记清除:从根节点访问变量,可达为活动,不可达为垃圾;会造成内存碎片(不连续)
- 标记整理:解决内存碎片,清理边界碎片
3. V8 执行 JS 代码的整个过程
JS 属于解释型语言,但 V8 的执行过程并非纯粹的解释型,而是结合了解释器和编译器(JIT 即时编译)。整个过程如下:
第一步:生成 AST(抽象语法树)
- 词法分析(分词):将代码分解为 token,如
let name = 'sanyuan'分解为let、name、=、'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 代码在运行,它都在执行上下文中运行。
三种类型
- 全局执行上下文:默认/基础上下文,不在函数内部的代码都在全局上下文中。创建全局 window 对象,设置 this = 全局对象。程序中只有一个。
- 函数执行上下文:每当函数被调用时创建一个新的上下文,可以有任意多个。
- Eval 函数执行上下文:执行在 eval 内部的代码(不常用)。
执行栈(调用栈)
- 数据结构:LIFO(后进先出),存储代码运行时创建的所有执行上下文
- 流程:
- JS 引擎创建全局执行上下文压入栈底
- 遇到函数调用 → 创建新上下文压入栈顶
- 执行栈顶函数,执行完毕 → 上下文弹出
- 控制流程到达下一个上下文
- 所有代码执行完 → 全局上下文出栈
创建执行上下文的两个阶段
一、创建阶段(代码执行前,发生三件事):
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' }; → 返回 undefinedES3 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 采用块级作用域 + 词法作用域
- 词法作用域:描述的是"变量的查找规则"(静态作用域,写代码时就确定了查找路径)
延长作用域链
以下两种语句可以在作用域链前端临时增加一个变量对象(代码执行后移除):
- try-catch 的 catch 块:创建一个新的变量对象,包含被抛出的错误对象的声明
- 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为 falseundefined不是保留字,可被覆盖(危险),安全获取用void 0
9. 类型判断方法
typeof:只能判断基本类型,引用类型都返回 "object"(null 也返回 "object")Object.prototype.toString.call():最准确,返回[object Type]instanceof:判断原型链,适合判断自定义类和内置对象,不能判断基本类型Array.isArray():专门判断数组
补充知识点
基本包装类型
'1'.toString()为什么能调用?过程:创建new Object('1')临时包装对象 → 调用方法 → 立即销毁临时对象- 基本包装类型:
Boolean、Number、String(不建议用 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) // true10. 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":+obj、Number(obj)、数学运算(-/*//)、比较运算"default":二元加法+(可拼接也可相加)、==比较
转换优先级规则
- 若定义了
Symbol.toPrimitive,优先调用 - 若 hint 为
"string":先调toString(),若不存在或返回非原始值再调valueOf() - 若 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) // 3611. JS 隐式转换规则(ToString / ToNumber / ToBoolean)
ToString(其他类型 → 字符串)
null→"null",undefined→"undefined"- 布尔:
true→"true",false→"false" - 数字:转为字符串形式,如
10→"10",1e21→"1e+21" - 数组:所有元素按
,连接(相当于join()),空数组[]→"",数组中的 null/undefined 当空字符串处理 - 普通对象:
"[object Object]"
ToNumber(其他类型 → 数字)
null→0,undefined→NaN- 字符串:纯数字形式转对应数字,空字符串 →
0,否则 →NaN - 布尔:
true→1,false→0 - 数组/对象:先 ToPrimitive 转原始值,再按上述规则转数字
js
Number(null) // 0
Number(undefined) // NaN
Number('') // 0
Number([]) // 0([] → toString → "" → 0)
Number(['1']) // 1
Number({}) // NaNToBoolean(其他类型 → 布尔)
- 假值(falsy)只有 6 个:
false、null、undefined、""(空字符串)、0、NaN - 其他所有值都为 true,包括
[]、{}、" "(空格字符串)、Infinity
== 宽松相等比较的隐式转换规则
- 布尔 vs 其他:布尔先转数字(
true→ 1,false→ 0),再比较 - 数字 vs 字符串:字符串转数字,再比较
- 对象 vs 原始类型:对象先 ToPrimitive 转原始值,再比较
- null / undefined vs 其他:
null == undefined→true;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 风险,用户输入场景需谨慎使用
六、内存泄漏常见原因
- 意外的全局变量
- 被遗忘的定时器/回调函数
- 脱离 DOM 的引用
- 闭包
性能优化详见 性能优化专题