js在浏览器中的运行原理


前端开发中 js 是最重要的一门开发语言。js 可以运行在浏览器中,也可以运行在服务器端,甚至在智能设备上都有实践(智能家居、机器人等)。当然对于 Web 前端开发来讲,弄清 js 在浏览器中的运行原理和表现是非常有必要的,甚至是必须的。

浏览器是多进程的

进程和线程的区别可以参考阮一峰老师的文章 进程与线程的一个简单解释

浏览器是多进程的。 主要有以下几种进程:

  • Browser 进程:浏览器的主进程(负责协调、主控),只有一个。作用有:负责浏览器界面显示,与用户交互,如前进,后退等;负责各个页面的管理,创建和销毁其他进程;将 Renderer 进程得到的内存中的 Bitmap,绘制到用户界面上;网络资源的管理,下载等。
  • 第三方插件进程:每种类型的插件对应一个进程,仅当使用该插件时才创建
  • GPU 进程:最多一个,用于 3D 绘制等
  • 浏览器渲染进程(浏览器内核)。在浏览器中默认每个 Tab 页面新起一个进程(进程内有自己的多线程),互不影响。主要作用有页面渲染,脚本执行,事件处理等。

相比于单进程,多进程有如下优点:

  • 避免单个 page crash 影响整个浏览器
  • 避免第三方插件 crash 影响整个浏览器
  • 多进程充分利用多核优势
  • 方便使用沙盒模型隔离插件等进程,提高浏览器稳定性

浏览器有时也会将多个进程合并,比如打开多个空白标签页后,会发现多个空白标签页被合并成了一个进程。

页面的渲染进程

浏览器的渲染进程是多线程的。当浏览器打开一个页面后,就会开启一个进程。这个进程中需要多个线程互相配合才能完成工作。其中常驻的线程有:

  • 渲染引擎线程(GUI),负责页面的渲染,解析 HTML,CSS,构建 DOM 树和 RenderObject 树,布局和绘制等。当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行。
  • js 引擎线程,负责 js 的解析和执行。js 引擎一直等待着任务队列中任务的到来,然后加以处理,一个 Tab 页(renderer 进程)中无论什么时候都只有一个 JS 线程在运行 JS 程序。GUI 渲染线程与 JS 引擎线程是互斥的
  • 定时触发器线程,处理 setInterval 和 setTimeout。浏览器定时计数器并不是由 JavaScript 引擎计数的,因为 JavaScript 引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确。因此通过单独线程来计时并触发定时,计时完毕后,添加到事件队列中,等待 JS 引擎空闲后执行。注意,W3C 在 HTML 标准中规定,规定要求 setTimeout 中低于 4ms 的时间间隔算为 4ms。
  • 事件触发线程, 归属于浏览器而不是 JS 引擎,用来控制事件循环,当 JS 引擎执行代码块如 setTimeOut 时(也可来自浏览器内核的其他线程,如鼠标点击、AJAX 异步请求等),会将对应任务添加到事件线程中。当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待 JS 引擎的处理。
  • 异步 http 请求线程,处理 http 请求。在 XMLHttpRequest 在连接后是通过浏览器新开一个线程请求。将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中。再由 JavaScript 引擎执行。

从上可知 js 是单线程的。之所以这样设计,是为了避免复杂性。假如同时有两个线程都在运行 js 代码,一个给页面上添加内容,另一个线程却在删除内容。这样就会遇到不可预知的结果。所以 js 一开始设计就是单线程。但是这里的单线程指的是 js 的主流程是单线程。并不是浏览器只会给 js 分配了一个线程。

消息队列与事件循环

上图左边的栈(stack)存储的同步任务,一般是能立即执行,耗时很短的任务,如变量和函数的初始化、事件的绑定、数据的逻辑处理等。右边的堆(heap)是用来存储函数体、对象等。底部是异步消息队列(queue),一旦某个异步任务有了响应就会被推入队列中。JS 引擎线程用来执行栈中的同步任务,当所有同步任务执行完毕后,栈被清空,然后读取消息队列中的一个待处理任务,并把相关回调函数压入栈中,单线程开始执行新的同步任务。JS 引擎线程从消息队列中读取任务是不断循环的,每次栈被清空后,都会在消息队列中读取新的任务,如果没有新的任务,就会等待,直到有新的任务,这就叫事件循环(Event Loop)。

下图是 AJAX 异步请求示意图。

AJAX异步请求示例


js 引擎的解析和执行

接下来再说说 js 引擎是怎么解析和执行代码的。

全局预处理阶段

在 js 代码真正执行之前, 编译器会先对代码执行预处理, 预处理的过程主要为:
1、创建词法作用域(可以理解为一个 js 代码执行前就存在的全局变量)
2、扫描全局作用域下所有使用 var 关键字声明的变量, 将其赋值为 undefined,遇到声明冲突时跳过;
3、扫描全局作用域下所有使用 函数声明 方式声明的函数, 并将其赋值为对该函数的引用,遇到声明冲突时则覆盖。

这里要注意,ES6 里引入的 let 和 const 声明的变量不会被提前,这个特性会引起暂时性死区。

函数预处理阶段

函数在真正执行之前也会经历一个预处理阶段:

1、创建一个 AO 对象
2、找出函数形参以及函数作用域内的变量声明, 并赋值为 undefined
3、将形参与实参的值相统一
4、找出作用域内所有的函数声明, 复制为其函数的引用

与全局预处理不同, 函数的预处理阶段多了对函数参数的处理。

macroTasks 和 microTasks

event loop 里面有维护了两个不同的异步任务队列 macroTasks(Tasks) 的队列 microTasks 的队列

macro-task: 一个 event loop 有一个或者多个 task 队列。task 任务源非常宽泛,比如 ajax 的 onload,click 事件,基本上我们经常绑定的各种事件都是 task 任务源,还有数据库操作(IndexedDB ),需要注意的是 setTimeout、setInterval、setImmediate 也是 task 任务源。总结来说 task 任务源:

  • setTimeout
  • setInterval
  • setImmediate
  • I/O
  • UI rendering

micro-task(Job):microtask 队列和 task 队列有些相似,都是先进先出的队列,由指定的任务源去提供任务,不同的是一个 event loop 里只有一个 microtask 队列。另外 microtask 执行时机和 Macrotasks 也有所差异。
微任务包括:

  • process.nextTick
  • promises
  • Object.observe
  • MutationObserver

每次开始执行一段代码(一个 script 标签)都是一个 macroTask

1、event-loop start
2、从 macroTasks 队列抽取一个任务,执行
3、microTasks 清空队列执行,若有任务不可执行,推入下一轮 microTasks
4、更新界面渲染
5、结束 event-loop,返回第一步

这里有个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
Promise.resolve().then(function promise1() {
console.log("promise1");
});
setTimeout(function setTimeout1() {
console.log("setTimeout1");
Promise.resolve().then(function promise2() {
console.log("promise2");
});
}, 0);

setTimeout(function setTimeout2() {
console.log("setTimeout2");
}, 0);

运行过程:
script 里的代码被列为一个 task,放入 task 队列。

  • 循环 1:
    • 【task 队列:script ;microtask 队列:】
      从 task 队列中取出 script 任务,推入栈中执行。
      promise1 列为 microtask,setTimeout1 列为 task,setTimeout2 列为 task。
    • 【task 队列:setTimeout1 setTimeout2;microtask 队列:promise1】
      script 任务执行完毕,执行 microtask checkpoint,取出 microtask 队列的 promise1 执行。
  • 循环 2:
    • 【task 队列:setTimeout1 setTimeout2;microtask 队列:】
      从 task 队列中取出 setTimeout1,推入栈中执行,将 promise2 列为 microtask。
    • 【task 队列:setTimeout2;microtask 队列:promise2】
      执行 microtask checkpoint,取出 microtask 队列的 promise2 执行。
  • 循环 3:
    • 【task 队列:setTimeout2;microtask 队列:】
      从 task 队列中取出 setTimeout2,推入栈中执行。 7.setTimeout2 任务执行完毕,执行 microtask checkpoint。
    • 【task 队列:;microtask 队列:】

eventloop