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 异步请求示意图。
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 | Promise.resolve().then(function promise1() { |
运行过程:
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 执行。
- 【task 队列:script ;microtask 队列:】
- 循环 2:
- 【task 队列:setTimeout1 setTimeout2;microtask 队列:】
从 task 队列中取出 setTimeout1,推入栈中执行,将 promise2 列为 microtask。 - 【task 队列:setTimeout2;microtask 队列:promise2】
执行 microtask checkpoint,取出 microtask 队列的 promise2 执行。
- 【task 队列:setTimeout1 setTimeout2;microtask 队列:】
- 循环 3:
- 【task 队列:setTimeout2;microtask 队列:】
从 task 队列中取出 setTimeout2,推入栈中执行。 7.setTimeout2 任务执行完毕,执行 microtask checkpoint。 - 【task 队列:;microtask 队列:】
- 【task 队列:setTimeout2;microtask 队列:】