浏览器原理

浏览器进程与线程

一些基本概念:

  • 进程是系统资源分配的最小单位

  • 进程之间相互独立

  • 线程是系统资源调度的最小单位

  • 一个进程由一个或多个线程组成

  • 多个线程在进程中协作完成任务

  • 同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆栈等)

浏览器是多进程的,例如,每打开一个 Tab 页,就相当于创建了一个独立的浏览器进程

浏览器主要进程

  • Browser 进程:浏览器主进程,只有一个,作用如下:

    • 负责浏览器界面显示,与用户交互。如前进,后退等

    • 负责各个页面管理,创建销毁其他进程

    • 将 Renderer 进程返回的内存中的 Bitmap 绘制到用户界面上

    • 网络资源的管理,下载等

  • GPU 进程:最多一个,用于 3D 绘制等

  • 第三方插件进程:每个插件对应一个进程,仅当使用该插件时才创建

  • 浏览器渲染进程:即 Renderer 进程,内部是多线程的,默认每个 Tab 页面都会创建一个渲染进程,互不影响。主要作用是页面渲染、脚本执行、事件触发等

浏览器多进程的优劣

优点:

  1. 避免单个 Tab 页面崩溃影响整个浏览器

  2. 避免第三方插件崩溃影响整个浏览器

  3. 多进程充分利用多核优势

缺点:

内存资源消耗大

渲染进程

前端开发主要需要关注的就是渲染进程,每个 Tab 都是一个独立的渲染进程,而每个渲染进程又是多线程的

GUI 渲染线程

负责渲染浏览器界面,解析 HTML、CSS,构建 DOM Tree、CSS Rules Tree、Rendering Tree,回流与重绘等。

GUI 渲染线程与 JavaScript 引擎线程是互斥的,当 JavaScript 引擎执行时 GUI 线程会被挂起,GUI 更新会被保存在一个队列中等待 JavaScript 引擎空闲时再执行。

JavaScript 引擎线程

负责处理 JavaScript 脚本程序。在一个 Tab 页的渲染进程中,永远都只有一个 JavaScript 线程,同时 GUI 渲染线程与 JavaScript 引擎线程是互斥的,所以如果 JavaScript 引擎线程执行时间过长,就会导致页面渲染卡顿,或页面初始加载时阻塞。

JavaScript 为什么是单线程

JavaScript 是单线程的原因在于 JavaScript 的用途,它是一个脚本语言,主要用于处理 DOM 的操作和一些用户交互,这种不复杂的需求使用单线程的设计更简单,占用资源少,执行效率也更高,省去了多线程同时操作 DOM 时需要死锁等手段处理操作矛盾的麻烦。

当然,现今前端职能越来越多,页面也越来越复杂,JavaScript 有时候也需要承担一些计算密集型的需求,这就催生出了 Web Worker,它会创建一个额外的线程来执行耗时的 JavaScript,但它并未改变 JavaScript 引擎线程是单线程这一设定,因为 Web Worker 创建的线程完全受 JavaScript 引擎线程控制,且 Web Worker 创建的线程无法操作 DOM,只能通过 postMessage API 与 JavaScript 引擎线程通信。

JavaScript 引擎线程和渲染线程的互斥性

JavaScript 引擎线程与渲染线程互斥保证了渲染线程渲染页面时 DOM 是被 JavaScript 引擎修改后最新的,否则渲染线程使用的 DOM 和 JavaScript 引擎修改后的 DOM 不一致,可能造成更大的渲染开销,多次不必要的回流、重绘甚至是渲染错误。

事件触发线程

事件触发看似归属于 JavaScript 引擎,实际是一个独立的线程。当 JavaScript 引擎执行各类事件代码时,会将对应的任务(通常是回调函数)添加到事件线程中,当对应的事件符合触发条件被触发时,该线程会把事件回调函数添加到待处理队列的队尾,等待 JavaScript 引擎的处理

定时触发线程

即 setInterval 与 setTimeout 所在线程,与事件触发线程类似,独立于 JavaScript 引擎线程,因为 JavaScript 引擎线程阻塞时会影响计时准确,故独立出来。当计时完成触发时,事件(通常是回调函数)会被添加到消息队列,等待 JavaScript 引擎处理。

值得注意的是,尽管为了保证定时精确,已经将定时触发线程独立出来,但我们发现有时候定时任务依然延迟了。是计时出问题了吗?答案是否定的,定时触发线程已经保证了计时的精确,那么问题其实是出在消息队列了。定时触发线程只保证了事件被添加到消息队列的时间是精确的,但若是消息队列中有其他耗时任务,就无法保证定时任务精确执行了。

异步 HTTP 请求线程

在 XMLHttpRequest 连接后启动一个线程,若线程检测到请求的状态变更,且有回调函数的情况下,该线程会把回调函数添加到消息队列,等待执行。

Web Workers 线程

由于 JavaScript 线程的唯一性,当有密集型计算时 JavaScript 执行时间太长会导致页面阻塞,所以有了 Web Workers。Web Workers 使得一个 Web 应用程序可以在一个单独的线程中执行费时的处理任务,从而保证主线程(通常指 GUI 渲染线程)运行不被 JavaScript 引擎线程阻塞。

注意:

  • Web Worker 线程服务于 JavaScript 引擎线程,完全受 JavaScript 引擎线程控制,且该线程无法操作 DOM

  • JavaScript 引擎线程与 Web Worker 线程间通过 postMessage API 通信

  • Web Worker 线程只属于一个浏览器渲染进程(即一个 Tab 页面)

  • Shared Worker 则是浏览器单独创建的用来运行 JavaScript 的进程,它可以被所有浏览器渲染进程共享,且一个浏览器中最多只能存在一个 Shared Worker 进程。

GUI 渲染线程

浏览器内核

浏览器内核指渲染引擎和 JavaScript 引擎,随着 JavaScript 引擎越来越独立,现在浏览器内核更倾向指代渲染引擎。

页面加载过程

  • DNS 解析服务器 IP 地址

  • 建立 TCP 连接

  • 发送 HTTP 请求并接收服务器响应

  • 客户端渲染页面

前三步都是网络相关,最后一步是浏览器渲染部分,接下来着重讲客户端渲染。

浏览器渲染过程

  • 根据 HTML 文件建立文档对象模型树(DOM Tree)

  • 根据 HTML 中 link 的 CSS 文件建立样式规则树(CSS Rule Tree)

  • 根据文档对象模型树与样式规则树建立渲染树(Rendering Tree)

  • 根据渲染树计算所有节点的几何尺寸,进行布局(layout),或者叫回流(reflow)

  • 最后调用操作系统 Native GUI 的 API 进行绘制

  • 当用户操作改变了样式时,则会触发重绘(repaint)或回流(reflow)

注意事项:

  1. 生成 CSS 规则树时,CSS 匹配 HTML 元素是需要递归遍历的,这既复杂又耗性能,若 CSS 规则嵌套过深将严重影响性能。

  2. 渲染树中不包含不可见元素,如 head 元素及 display 为 none 的元素

浏览器渲染的阻塞

  1. 现代浏览器是并行加载资源的,阻塞只是针对主线程构建 DOM 树,并不影响预解析后续的资源进行请求

  2. CSS 不会阻塞 DOM 构建,但现在几乎所有的前端应用都有 JavaScript,因为 JavaScript 阻塞了 DOM,而 JavaScript 也可能操作 CSS,这就需要 CSS 在 JavaScript 执行前构建完成,故 CSS 通过阻塞 JavaScript,表现出了阻塞 DOM 的现象。这也是为什么 CSS 的引入 link 标签通常在 JavaScript 的 script 标签前

  3. 无 defer 与 async 属性的 script 标签会触发浏览器的渲染并阻塞 DOM 树的构建,在加载与执行完 JavaScript 后再继续 DOM 树的构建,这也是 script 标签通常放在 body 结束标签前的原因

  4. 有 defer 属性的 script 标签不会阻塞 DOM 树的构建,异步加载后一直等待到 DOMContentLoaded 事件前,按 script 的顺序执行 JavaScript,执行完成后触发 DOMContentLoaded 事件(DOMContentLoaded 事件仅指 DOM 加载完成,load 事件则表示整个页面都完全渲染加载)

  5. 有 async 属性的 script 标签可能会阻塞 DOM 树的构建,因为其异步加载完成后就会直接执行,可能在 DOMContentLoaded 前也可能在 DOMContentLoaded 后,但一定在 load 事件前。若在 DOMContentLoaded 前加载完成执行,此时 DOM 还未完成构建,将阻塞 DOM 树构建。

  6. async 与 defer 属性都只在有 src 即外部引入的 script 标签生效

重绘与回流

  • 重绘(repaint):我们对 DOM 的样式进行了修改,但不涉及元素的几何属性时,浏览器无需重新计算元素的几何属性,直接为该元素绘制新样式

  • 回流/重排(reflow/relayout):我们对 DOM 的样式进行了修改引发了元素几何属性的变化(如元素宽高或显隐),浏览器需要重新计算元素几何属性后,再进行绘制

特点:

  1. 回流必定重绘,但重绘不一定回流

  2. 回流的性能消耗大于重绘

引起回流的情形:

  • 添加、删除可见 DOM 元素

  • 元素尺寸改变——margin、padding、border、width、height

  • 元素内容改变——input 中输入字

  • 元素位置改变

  • 浏览器窗口尺寸改变——resize 事件

  • 计算 offsetWidth 和 offsetHeight

  • 设置 style 即行内属性值时

  • 激活 css 伪类,如:hover

  • 改变浏览器默认字体大小

减少回流的方式:

  • 不使用 table 布局

  • 尽量使用 class 控制样式而不是 JavaScript

  • 使用 visibility:hidden 替代 display:none

  • 减少 css 的嵌套层级

  • 避免使用 css 表达式,如 calc()

  • 将动画元素脱离文档流,使用绝对定位,减少对其他元素的影响

  • 尽量减少 DOM 操作并将必要的 DOM 操作一起完成

  • 尽量不要使用会引起浏览器清空回流队列的属性,如

    • clientWidth、clientHeight、clientLeft、clientTop
    • offsetWidth、offsetHeight、offsetLeft、offsetTop
    • scrollWidth、scrollHeight、scrollLeft、scrollTop
    • width、height
    • getComputedStyle()
    • getBoundingClientRect()
    • scrollIntoView()、scrollIntoViewIfNeeded()、scrollTo

    浏览器对回流的优化是将一系列回流的操作放入一个队列中,当达到一定数量或时间达到一定间隔后,一起执行,这样可以把多次回流整合为一次,而上述属性与方法会导致清空队列,立即执行回流

JavaScript 引擎线程

JavaScript 引擎线程的核心工作机制是事件循环(event loop)

相关概念:

  • 堆(heap):一大块非结构化的内存区域,对象、数据被存放在堆中

  • 栈(stack):栈在 JavaScript 中又称为执行栈、调用栈(call stack),是一种后进先出的数据结构。Javascript 引擎线程会执行所有调用栈中的任务。例如,函数执行时会被放在调用栈的顶部,执行完成后又从栈顶移出,JavaScript 引擎线程会一直执行,直到调用栈被清空

  • 队列(queue):队列在 JavaScript 中又称为任务队列、消息队列(message queue),是一种先进先出的数据结构。ES6 后又针对微任务增加了工作队列(job queue)

ES6 以前的事件循环

同步任务与异步任务

由于 JavaScript 是单线程的,所以所有的任务都需要排队,前一个任务执行完成才能执行下一个。如果一个任务中有 IO 操作,如网络请求等,这些 IO 操作的耗时远远大于 CPU 执行代码时间,CPU 此时已经把 IO 操作前的任务代码执行完成,处于空闲等待 IO 操作的状态,虽然 CPU 空闲,但因为单线程任务排队的机制,下一个任务并无法执行。为了减少 CPU 空闲时间的浪费,JavaScript 将任务分为两种:同步任务与异步任务。

同步任务:调用立即会得到结果的任务,它们在主线程上排队,前一个任务执行完成才能执行后一个任务

异步任务:指无法立即得到结果,需要额外操作触发才能获得结果的任务。异步任务不进入主线程,而是触发后进入消息队列。

具体的过程为 JavaScript 引擎遇到异步任务,如事件监听、网络请求、计时器等,会交给相对应的线程去维护任务,等待得到结果,如用户点击、请求成功、计时结束等,对应线程会将异步的回调函数加入消息队列中,等待被主线程执行。

事件循环的过程

  1. 将所有同步任务交由 JavaScript 引擎线程执行,进入调用栈

  2. 遇到异步任务,则交由对应异步线程执行,待有结果后将带有结果的回调函数添加到消息队列中,表示异步任务已经可继续下一步执行

  3. 调用栈中的所有同步任务执行完成后,会检查消息队列,将消息队列中所有回调函数依次压入调用栈,开始执行

  4. 主线程不断重复上面三步就形成了事件循环

ES6 事件循环

ES6 后,由于增加了非常重要的 Promise,这也就使得事件循环也有了不小的改变。

基本概念:

  • 宏任务(macro task):script 标签中的所有代码、setTimeout、setInterval、I/O(如网络请求)、UI 交互事件、MessageChannel、setImmediate(Node.js 环境)

  • 微任务(micro task):Promise.then、MutationObserver、Process.nextTick(Node.js 环境),微任务通常都会对应当前调用栈中的宏任务,例如 Promise 通常在异步网络请求成功后 resolve,那么 Promise.then 的微任务就对应网络请求的宏任务;一个极端情况就是直接 resolve,那么 Promise.then 的微任务就对应当前调用栈中正在执行的宏任务

  • 消息队列(message queue):包含了异步线程添加的已得到结果的宏任务回调函数

  • 工作队列(job queue):包含了微任务回调函数

事件循环过程

  1. script 标签中的所有代码是一个特殊的宏任务,页面载入时,它是消息队列中唯一的任务,此时的工作队列是空的。我们可以将 script 标签中的所有代码理解成一个特殊的函数,这时,这个特殊的函数从消息队列移入调用栈,消息队列与工作队列都为空,代码开始执行

  2. 代码执行过程中,遇到宏任务就将它交给相对应的异步线程去处理,等得到结果后,由引对应的异步线程将带有结果的回调函数添加到消息队列中,等待执行;同样的,代码执行过程中,遇到微任务,则将微任务的回调函数添加到工作队列中

  3. script 标签中所有代码执行完成,调用栈为空,此时检查工作队列中是否有未执行的回调函数,将工作队列中的所有回调函数依次压入调用栈执行,直到工作队列为空

  4. 工作队列中的所有回调函数执行完成,此时调用栈为空,工作队列为空,渲染线程根据最新的 DOM 开始回流或重绘

  5. 渲染线程完成工作后挂起,JavaScript 引擎线程从消息队列中取出下一个宏任务,压入调用栈开始执行,之后重复 2、3、4、5 步

现在我们将 script 标签中所有代码不再当作特殊宏任务,就总结出了事件循环的过程

  • 从消息队列中取出一个宏任务压入调用栈中执行

  • 代码在调用栈中执行时,依据宏任务与微任务不同,将得到结果的回调函数添加至消息队列或工作队列

  • 宏任务执行完成后检查工作队列,将其中回调函数依次压入调用栈执行,直至工作队列为空

  • 渲染线程工作

  • 渲染线程执行完成后重复上述步骤

以上就是 ES6 的事件循环机制。

ES6 与 ES5 事件循环的对比

在 ES5 中,事件循环是以消息队列所有任务清空一次为一个周期的;但 ES6 中,事件循环则是以消息队列中的一个宏任务执行完成并将宏任务执行周期内产生的所有微任务执行完成作为一个周期的

Promise 保证了什么

相信明白了 ES6 的事件循环机制,你也就明白了 Promise 为什么叫 Promise,它到底保证了什么。

试想一下,如果 Promise 依然按照原来事件循环机制,网络请求成功后,将得到结果的回调函数添加在消息队列的末尾,倘若消息队列中有一个耗时操作,就可能导致网络请求的回调函数被阻塞,虽然请求成功,但迟迟不响应。而增加了工作队列,Promise.then 的回调函数被添加到网络请求宏任务执行后的工作队列中,网络请求的宏任务执行完成后,立即执行工作队列中的回调函数,这样就不会被阻塞或延迟,Promise 所“保证”的正是这种执行的及时性。