代码漂流记:JS从编译到执行 (含浏览器原理)

142 阅读6分钟

JS Runtime

什么是 runtime ,说白了就是 JS 执行的环境。再具体一点,就是给 JS 引擎提供基础设施的环境。

JS 引擎和 JS Runtime 不是同一个东西

常见的 JS 引擎有 V8, JSC, quickJS ,他们提供了将 JS 代码转换为宿主可执行的机器指令的能力

  1. JS Runtime 是 JS 引擎所在的环境,比如浏览器和 Nodejs,他们为JS 引擎提供了标准库和内置模块

    1. http / file / dom (浏览器才有)
  2. 虽然 JS 引擎并不一定是单线程模型( 变量可达性分析 / 双生代复制过程可能有多线程辅助)JS 的执行是单线程的,为了解决这个问题。NodeJS / 浏览器引入事件循环机制实现异步功能。

浏览器事件循环

requestIdleCallback 核心逻辑是基于事件循环和通信机制 (MessageChannel)实现的

我们前端常说的JS事件循环其实也是依赖浏览器事件循环的,JS事件循环是浏览器事件循环的子集

  1. 宏任务队列 ,同步任务其实就是当前已经在栈中的宏任务 (MessageChannel,I/O)
  2. 微任务队列 (promise,MutationObserver)
  3. 渲染队列( requestAnimationFrame ) 浏览器是否需要渲染画面取决于帧率(16.6ms/帧,一帧只需要渲染一次)
  4. 空闲队列 requestIdelCallback 被执行

每一个循环中, 浏览器会执行一个宏任务,以及当前宏任务所属的所有微任务。根据浏览器帧率看是否需要渲染,在渲染前执行rAF,然后执行空闲队列。如果有用户交互任务出现,会被插入到队列的头部。

image.png

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");
}

image.png

whiteboard_exported_image.png

NodeJS 事件循环

宏任务

  1. timers 定时器

  2. pending Callback ( 系统级别 I/O 回调 )

  3. idle / prepared Libuv 内部使用,无需关注

  4. polling ( data , connect )

    1. 有 timer 则跳转 timer
  5. check ( setImmediate )

  6. close ( 处理 onclose 事件 )

微任务

  1. nextTick(单独的队列)
  2. promise | async await
  3. 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 引擎执行流程

image.png

编译阶段

编译阶段所做的事情主要有以下几点:

  1. 词法分析 Lexical Analysis

讲源代码拆解为 token,比如变量名, 运算符等

let x = 10; → [ {type: 'keyword', value: 'let'}, {type: 'identifier', value: 'x'}, ... ]

  1. 语法分析 Syntax Parsing

将 Token 转换为 AST 抽象语法树,表达出代码的层次结构

  1. 预编译和作用域分析

扫描当前函数的函数和变量声明,注册到词法环境

通过 outer 指向上一层作用域

讲当前的词法作用域保存到 [[scope]]

如果你学过编译原理, 你可以把这个阶段类比于静态链的构建, 他保存的是函数被声明的位置的环境。

  1. 字节码生成

V8 的 Igniition 解释器将 AST 转换为字节码

TurboFan 对热点代码转换为机器码(详见V8 JIT编译优化)

执行阶段

执行阶段指JS代码已经JS引擎翻译为机器能够阅读的字节码/机器码后的执行过程

这个过程也分为两步

  1. 捕获当前上下文

  2. 绑定当前的this值

  3. 创建当前的词法环境

环境记录 : 通过词法环境保存当前作用域的 let / const 变量和函数

outer 指针: 基于 [[scope]]通过 outer 指针指向上一层的作用域的词法环境 这个 outer 指针是在预编译和作用域分析中保存的,指向函数声明处

  1. 创建当前变量环境 (AO的具体实现 / 词法环境的一个特殊子集)

变量环境是专门用于存储 var 和函数声明的(默认与词法环境指向同一对象)

你可以把捕获上下文的内容类比编译原理的动态链生成,(但他们不完全一样)。词法环境和变量环境的捕获都需要上下文环境信息的参与, 也就是在预编译和上下文分析中得到的那部分信息(静态链信息)

  1. 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]] 引用打包外部的词法环境

小弟才疏学浅,恐难错漏,请各位读者多多指教。