JS Runtime
什么是 runtime ,说白了就是 JS 执行的环境。再具体一点,就是给 JS 引擎提供基础设施的环境。
JS 引擎和 JS Runtime 不是同一个东西
常见的 JS 引擎有 V8, JSC, quickJS ,他们提供了将 JS 代码转换为宿主可执行的机器指令的能力
-
JS Runtime 是 JS 引擎所在的环境,比如浏览器和 Nodejs,他们为JS 引擎提供了标准库和内置模块
- http / file / dom (浏览器才有)
-
虽然 JS 引擎并不一定是单线程模型( 变量可达性分析 / 双生代复制过程可能有多线程辅助)JS 的执行是单线程的,为了解决这个问题。NodeJS / 浏览器引入事件循环机制实现异步功能。
浏览器事件循环
requestIdleCallback 核心逻辑是基于事件循环和通信机制 (MessageChannel)实现的
我们前端常说的JS事件循环其实也是依赖浏览器事件循环的,JS事件循环是浏览器事件循环的子集
- 宏任务队列 ,同步任务其实就是当前已经在栈中的宏任务 (MessageChannel,I/O)
- 微任务队列 (promise,MutationObserver)
- 渲染队列( requestAnimationFrame ) 浏览器是否需要渲染画面取决于帧率(16.6ms/帧,一帧只需要渲染一次)
- 空闲队列 requestIdelCallback 被执行
每一个循环中, 浏览器会执行一个宏任务,以及当前宏任务所属的所有微任务。根据浏览器帧率看是否需要渲染,在渲染前执行rAF,然后执行空闲队列。如果有用户交互任务出现,会被插入到队列的头部。
function handleClick() {
// 微任务 (当前微任务队列在宏任务执行后执行)
Promise.resolve().then(() => {
blockingTask();
// 2 第一帧
logTask("Micro task executed (Promise)");
});
// 宏任务 (当前宏任务在下一个周期执行,也就是渲染以及rAF之后)
setTimeout(() => {
blockingTask();
// 4 第二帧
logTask("Macro task executed (setTimeout)");
}, 0);
// 用户交互事件 IO事件队列最先执行(其实是一个宏任务)
// 1 第一帧
window.addEventListener("click", () => {
blockingTask();
logTask("User interaction event executed (click)");
});
// requestAnimationFrame 回调在渲染之前执行(才能获取动画效果)
requestAnimationFrame((timestamp) => {
// 3 第一帧
blockingTask();
logTask("requestAnimationFrame callback executed");
});
// requestIdleCallback (RAF后且空闲时执行)
requestIdleCallback((timestamp) => {
// 5 第二帧
blockingTask();
logTask("requestIdleCallback callback executed");
console.log("requestIdleCallback callback executed at", timestamp);
});
// 触发交互
// document.body.click();
logTask("Script end");
}
NodeJS 事件循环
宏任务
-
timers 定时器
-
pending Callback ( 系统级别 I/O 回调 )
-
idle / prepared Libuv 内部使用,无需关注
-
polling ( data , connect )
- 有 timer 则跳转 timer
-
check ( setImmediate )
-
close ( 处理 onclose 事件 )
微任务
- nextTick(单独的队列)
- promise | async await
- queueMicrotask
setTimeout(() => console.log('Timeout'), 0);
setImmediate(() => console.log('Immediate'));
process.nextTick(() => console.log('NextTick'));
Promise.resolve().then(() => console.log('Promise'));
// NextTick → Promise → Timeout 或 Immediate(顺序可能互换)
timeout 的最小延迟是 1ms,在这个例子中,timeout 和 immediate 谁先执行取决于 timeout 到时的时机是否在进入 check 阶段之前
JS 引擎执行流程
编译阶段
编译阶段所做的事情主要有以下几点:
-
词法分析 Lexical Analysis
讲源代码拆解为 token,比如变量名, 运算符等
let x = 10; → [ {type: 'keyword', value: 'let'}, {type: 'identifier', value: 'x'}, ... ]
-
语法分析 Syntax Parsing
将 Token 转换为 AST 抽象语法树,表达出代码的层次结构
-
预编译和作用域分析
扫描当前函数的函数和变量声明,注册到词法环境
通过 outer 指向上一层作用域
讲当前的词法作用域保存到 [[scope]]
如果你学过编译原理, 你可以把这个阶段类比于静态链的构建, 他保存的是函数被声明的位置的环境。
-
字节码生成
V8 的 Igniition 解释器将 AST 转换为字节码
TurboFan 对热点代码转换为机器码(详见V8 JIT编译优化)
执行阶段
执行阶段指JS代码已经JS引擎翻译为机器能够阅读的字节码/机器码后的执行过程
这个过程也分为两步
-
捕获当前上下文
-
绑定当前的this值
-
创建当前的词法环境
环境记录 : 通过词法环境保存当前作用域的 let / const 变量和函数
outer 指针: 基于 [[scope]]通过 outer 指针指向上一层的作用域的词法环境 这个 outer 指针是在预编译和作用域分析中保存的,指向函数声明处
- 创建当前变量环境 (AO的具体实现 / 词法环境的一个特殊子集)
变量环境是专门用于存储 var 和函数声明的(默认与词法环境指向同一对象)
你可以把捕获上下文的内容类比编译原理的动态链生成,(但他们不完全一样)。词法环境和变量环境的捕获都需要上下文环境信息的参与, 也就是在预编译和上下文分析中得到的那部分信息(静态链信息)
-
LIFO 运行执行栈
静态和动态上下文
细心的读者可能注意到了,在JS的运行过程中,在”预编译和作用域分析“的时候和执行阶段“捕获上下文”的时候都最作用域和上下文执行了操作。那他们做的事情有什么区别?
对于编译阶段
静态构建代码层级关系
在函数声明时被触发,记录到父作用域链到 [[scope]]
只是引用父级变量,未做初始化
判断闭包依赖,减小运行时开销
对于执行阶段
动态赋值变量,创建完整的 AO,组装完整的作用域链
当前上下文将会包含实时参数和局部变量
支持动态的作用域拓展 (eval with)
var & let 视角
从性质看原理,由表及里
有了上面的基础,我们就可更加底层的理解 var 和 let 之间的区别了
函数 & 块级作用域
- var 具有函数作用域
function test() {
if (true) {
var x = 10; // 函数作用域
}
console.log(x); // 10(可访问)
}
test();
究其根本,是因为 var 保存在变量环境,变量环境具有函数作用域
- let 具有块级作用域
if (true) {
let y = 20;
}
console.log(y); // ReferenceError(不可访问)
let / const 保存在词法环境,具有块级作用域
变量提升
- var 有变量提升 ,let 没有
console.log(a); // undefined(变量提升)
var a = 10;
在编译阶段,var 和 let 都会被声明,但是不被赋值
如果此时访问 var 声明的变量,就是 undfined(缺省值)。
从编译阶段看:var变量在预编译时直接存入变量环境(对象式环境记录),初始化为 undefined。
如果访问 let / const 声明的变量,会报错 Reference Error (执行阶段报错) 。因为let const 在执行阶段赋值前处在 TDZ(暂时性死区)
从编译阶段看,let/const 声明的变量记录在词法环境中(声明式环境变量)
闭包共享
- var 在闭包中共享内容
function example() {
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log("var:", i)); // 333
}
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log("let:", j)); // 123
}
}
example();
由于 var 是函数作用域,所以 for 循环中的三次 i ,都保存在同一个变量环境中。
闭包内,v8将闭包变量提升到堆空间,通过作用域链访问
在 let 情况下,for 会生成块级作用域,每一次循环都是一个独立的作用域,所以 j 之间是独立的。
闭包内,v8通过 [[scope]] 引用打包外部的词法环境
小弟才疏学浅,恐难错漏,请各位读者多多指教。