最近看了浏览器原理,这里记录一下细节。浏览器原理可以把一系列知识串联起来。
目录
宏观视角下的浏览器
网页渲染流程
js 执行机制
chrome 垃圾回收
页面循环系统
页面优化
宏观视角下的浏览器
进程和线程
1. 线程 VS 进程
一个进程就是一个程序的运行实例。详细解释就是,启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,我们把这样的一个运行环境叫进程。多线程可以并行处理任务,但是线程是不能单独存在的,它是由进程来启动和管理的。
总结来说,进程和线程之间的关系有以下 4 个特点。
- 进程中的任意一线程执行出错,都会导致整个进程的崩溃。
- 线程之间共享进程中的数据。
- 当一个进程关闭之后,操作系统会回收进程所占用的内存。
- 进程之间的内容相互隔离。
Chrome 进程架构
最新的 Chrome 浏览器包括:1 个浏览器(Browser)主进程、1 个 GPU 进程、1 个网络(NetWork)进程、多个渲染进程和多个插件进程。
- 浏览器进程。主要负责浏览器界面显示、用户交互、子进程管理,同时提供存储等功能。
- 渲染进程。核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。
- GPU 进程。其实,Chrome 刚开始发布的时候是没有 GPU 进程的。而 GPU 的使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。最后,Chrome 在其多进程架构上也引入了 GPU 进程。
- 网络进程。主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。
- 插件进程。主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。
仅仅打开了 1 个页面,为什么有 4 个进程?因为打开 1 个页面至少需要 1 个网络进程、1 个浏览器进程、1 个 GPU 进程以及 1 个渲染进程,共 4 个;如果打开的页面有运行插件的话,还需要再加上 1 个插件进程。主进程和网络进程是共享的。
网页渲染流程
导航流程
- 用户输入 URL,浏览器会根据用户输入的信息判断是搜索还是网址,如果是搜索内容,就将搜索内容+默认搜索引擎合成新的 URL;如果用户输入的内容符合 URL 规则,浏览器就会根据 URL 协议,在这段内容上加上协议合成合法的 URL
- 用户输入完内容,按下回车键,浏览器导航栏显示 loading 状态,但是页面还是呈现前一个页面,这是因为新页面的响应数据还没有获得
- 浏览器进程构建请求行信息,会通过进程间通信(IPC)将 URL 请求发送给网络进程
GET /index.html HTTP1.1
- 网络进程获取到 URL,先去本地缓存中查找是否有缓存文件,如果有,拦截请求,直接 200 返回;否则,进入网络请求过程
- 网络进程请求 DNS 返回域名对应的 IP 和端口号,如果之前 DNS 数据缓存服务缓存过当前域名信息,就会直接返回缓存信息;否则,发起请求获取根据域名解析出来的 IP 和端口号,如果没有端口号,http 默认 80,https 默认 443。如果是 https 请求,还需要建立 TLS 连接。
- Chrome 有个机制,同一个域名同时最多只能建立 6 个 TCP 连接,如果在同一个域名下同时有 10 个请求发生,那么其中 4 个请求会进入排队等待状态,直至进行中的请求完成。如果当前请求数量少于 6 个,会直接建立 TCP 连接。
- TCP 三次握手建立连接,http 请求加上 TCP 头部——包括源端口号、目的程序端口号和用于校验数据完整性的序号,向下传输
- 网络层在数据包上加上 IP 头部——包括源 IP 地址和目的 IP 地址,继续向下传输到底层
- 底层通过物理网络传输给目的服务器主机
- 目的服务器主机网络层接收到数据包,解析出 IP 头部,识别出数据部分,将解开的数据包向上传输到传输层
- 目的服务器主机传输层获取到数据包,解析出 TCP 头部,识别端口,将解开的数据包向上传输到应用层
- 应用层 HTTP 解析请求头和请求体,如果需要重定向,HTTP 直接返回 HTTP 响应数据的状态 code301 或者 302,同时在请求头的 Location 字段中附上重定向地址,浏览器会根据 code 和 Location 进行重定向操作;如果不是重定向,首先服务器会根据 请求头中的 If-None-Match 的值来判断请求的资源是否被更新,如果没有更新,就返回 304 状态码,相当于告诉浏览器之前的缓存还可以使用,就不返回新数据了;否则,返回新数据,200 的状态码,并且如果想要浏览器缓存数据的话,就在相应头中加入字段:
``Cache-Control:Max-age=2000`
响应数据又顺着应用层——传输层——网络层——网络层——传输层——应用层的顺序返回到网络进程 - 数据传输完成,TCP 四次挥手断开连接。如果,浏览器或者服务器在 HTTP 头部加上如下信息,TCP 就一直保持连接。保持 TCP 连接可以省下下次需要建立连接的时间,提示资源加载速度
Connection:Keep-Alive
- 网络进程将获取到的数据包进行解析,根据响应头中的 Content-type 来判断响应数据的类型,如果是字节流类型,就将该请求交给下载管理器,该导航流程结束,不再进行;如果是 text/html 类型,就通知浏览器进程获取到文档准备渲染
- 浏览器进程获取到通知,根据当前页面 B 是否是从页面 A 打开的并且和页面 A 是否是同一个站点(根域名和协议一样就被认为是同一个站点),如果满足上述条件,就复用之前网页的进程,否则,新创建一个单独的渲染进程
- 浏览器会发出“提交文档”的消息给渲染进程,渲染进程收到消息后,会和网络进程建立传输数据的“管道”,文档数据传输完成后,渲染进程会返回“确认提交”的消息给浏览器进程
- 浏览器收到“确认提交”的消息后,会更新浏览器的页面状态,包括了安全状态、地址栏的 URL、前进后退的历史状态,并更新 web 页面,此时的 web 页面是空白页
渲染流程
导航流程结束后,现在是渲染进程的主场
构建 DOM 树。
- 构建 DOM 树的输入内容是 HTML 文件,然后经由 HTML 解析器解析,最终输出树状结构的 DOM。DOM 是保存在内存中树状结构,可以通过 JavaScript 来查询或修改其内容。
样式计算(Recalculate Style)。
- 当渲染引擎接收到 CSS 文本时,会执行一个转换操作,将 CSS 文本转换为浏览器可以理解的结构——styleSheets。document.styleSheets 可以查看。
- 转换样式表中的属性值,使其标准化。如 font-size:bold => font-size:700。
- 计算出 DOM 树中每个节点的具体样式。(层叠是 CSS 的一个基本特征,它是一个定义了如何合并来自多个源的属性值的算法。)
布局阶段。(计算出 DOM 树中可见元素的几何位置)
- 创建布局树。只包含可见元素布局树。
- 布局计算。计算布局树节点的坐标位置。
分层。渲染引擎需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree)。devtool Layers 可以看到。
- 拥有层叠上下文属性的元素会被提升为单独的一层。
- 需要剪裁(clip)的地方也会被创建为图层。(overflow)
- 图层绘制。
- 为每个图层生成绘制列表,并将其提交到合成线程。
- 绘制阶段其实并不是真正地绘出图片,而是将绘制指令组合成一个列表。
栅格化(raster)操作。
- 合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。所谓栅格化,是指将图块转换为位图。栅格化过程都会使用 GPU 来加速生成,使用 GPU 生成位图的过程叫快速栅格化,或者 GPU 栅格化,生成的位图被保存在 GPU 内存中。
- 合成和显示。一旦所有图块都被光栅化,合成线程就会生成一个绘制图块的命令——“DrawQuad”,然后将该命令提交给浏览器进程。
全流程
注意:
- css 与 js 的加载会阻塞布局树的生成,因为布局树需要 css 配合计算完成样式。
- 重排: 触发重新布局,解析之后的一系列子阶段,这个过程就叫重排
- 重绘: 并没有引起几何位置的变换,所以就直接进入了绘制阶段,然后执行之后的一系列子阶段,这个过程就叫重绘。
js 执行机制
JavaScript 的执行机制:先编译,再执行。变量和函数声明在代码里的位置是不会改变的,而且是在编译阶段被 JavaScript 引擎放入内存中。经过编译后,会生成两部分内容:执行上下文(Execution context)和可执行代码。
变量提升
变量提升,是指在 JavaScript 代码编译时,JavaScript 引擎把变量的声明部分和函数的声明部分提升到代码开头的“行为”。默认值 undefined。
执行上下文
执行上下文是 JavaScript 执行一段代码时的运行环境。在执行上下文中存在一个变量环境的对象(Viriable Environment)。
- 当 JavaScript 执行全局代码的时候,会编译全局代码并创建全局执行上下文,而且在整个页面的生存周期内,全局执行上下文只有一份。
- 当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。
- 当使用 eval 函数的时候,eval 的代码也会被编译,并创建执行上下文。
调用栈
又称执行上下文栈。调用栈是有大小的,当入栈的执行上下文超过一定数目,JavaScript 引擎就会报错,我们把这种错误叫做栈溢出。例如没有出口的递归。
作用域
作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。
es6 之前:
- 全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
- 函数作用域就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。
es6 新增:
- 块级作用域
块级作用域的实现 => 词法环境
函数内部通过 var 声明的变量,在编译阶段全都被存放到变量环境里面了。通过 let 声明的变量,在编译阶段会被存放到词法环境(Lexical Environment)中。块级作用域编译的时候不会把内部的 let 放到词法环境,而是在执行阶段。
当进入函数的作用域块时,作用域块中通过 let 声明的变量,会被存放在词法环境的一个单独的区域中,这个区域中的变量并不影响作用域块外面的变量。
当作用域块执行结束之后,其内部定义的变量就会从词法环境的栈顶弹出。
1 | function foo() { |
作用域链
在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为 outer。
词法作用域
词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。
闭包
在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。比如外部函数是 foo,那么这些变量的集合就称为 foo 函数的闭包。产生闭包的核心有两步:第一步是需要预扫描内部函数;第二步是把内部函数引用的外部变量保存到堆中。
闭包的作用:
- 私有化变量
1 | function Foo(name) { |
- 柯里化,即简化函数的参数。例如判断模式是 jj 的字符串
1 | function check(reg, text) { |
this
chrome 垃圾回收
在 JavaScript 的执行过程中, 主要有三种类型内存空间,分别是代码空间、栈空间和堆空间。
原始类型的数据值都是直接保存在“栈”中的,引用类型的值是存放在“堆”中的
因为 JavaScript 引擎需要用栈来维护程序执行期间上下文的状态,如果栈空间大了话,所有的数据都存放在栈空间里面,那么会影响到上下文切换的效率,进而又影响到整个程序的执行效率。
栈内存回收:
- 当一个函数执行结束之后,JavaScript 引擎会通过向下移动 ESP 来销毁该函数保存在栈中的执行上下文。下移后,原执行上下文便会变成无效内存,下一次有执行上下文进栈时便会直接覆盖。
堆内存回收:
代际假说。有以下两个特点:
- 第一个是大部分对象在内存中存在的时间很短,简单来说,就是很多对象一经分配内存,很快就变得不可访问;
- 第二个是不死的对象,会活得更久。
在 V8 中会把堆分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放的生存时间久的对象。
- 副垃圾回收器,主要负责新生代的垃圾回收。
- 主垃圾回收器,主要负责老生代的垃圾回收。
不论什么类型的垃圾回收器,它们都有一套共同的执行流程。
- 第一步是标记空间中活动对象和非活动对象。所谓活动对象就是还在使用的对象,非活动对象就是可以进行垃圾回收的对象。
- 第二步是回收非活动对象所占据的内存。其实就是在所有的标记完成之后,统一清理内存中所有被标记为可回收的对象。
副垃圾回收器
Scavenge 算法,是把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域。新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作。在垃圾回收过程中,首先要对对象区域中的垃圾做标记;标记完成之后,就进入垃圾清理阶段,副垃圾回收器会把这些存活的对象复制到空闲区域中,同时它还会把这些对象有序地排列起来,所以这个复制过程,也就相当于完成了内存整理操作,复制后空闲区域就没有内存碎片了。完成复制后,对象区域与空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。这样就完成了垃圾对象的回收操作,同时这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去。对象晋升策略,也就是经过两次垃圾回收依然还存活的对象,会被移动到老生区中。
主垃圾回收器
标记 - 清除(Mark-Sweep)的算法。遍历调用栈中不可触达的对象,然后清除。
全停顿
一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿(Stop-The-World)。
页面循环系统
每个渲染进程都有一个主线程,渲染引擎和 JS 引擎都是在该线程执行的。每个渲染进程都有一个主线程,既要处理 DOM,又要计算样式,还要处理布局,同时还需要处理 JavaScript 任务以及各种输入事件。这就需要一个系统来统筹调度这些任务,消息队列和事件循环系统。
消息队列:可以存放要执行的任务。也就是说要添加任务的话,添加到队列的尾部;要取出任务的话,从队列头部去取。
渲染进程专门有一个 IO 线程用来接收其他进程传进来的消息。
消息队列的两个问题:
如何处理高优先级的任务。
为了适应效率和实时性,引入了微任务。通常把消息队列中的任务称为宏任务,每个宏任务中都包含了一个微任务队列,在执行宏任务的过程中,如果 DOM 有变化,那么就会将该变化添加到微任务列表中,这样就不会影响到宏任务的继续执行,因此也就解决了执行效率的问题。如何解决单个任务执行时长过久的问题
可以通过回调功能来规避这种问题,也就是让要执行的 JavaScript 任务滞后执行。
setTimeout
除了正常使用的消息队列之外,还有另外一个消息队列,这个队列中维护了需要延迟执行的任务列表(延迟队列)。处理完主消息队列中的一个任务之后,会根据发起时间和延迟时间计算出到期延迟队列的任务,然后依次执行这些到期的任务。等到期的任务执行完成之后,会回到主消息队列。
如果当前任务执行时间过久,会影响定时器任务的执行。例如有个 for 循环任务阻塞住了主消息队列的执行。
未激活的页面,setTimeout 执行最小间隔是 1000 毫秒
延时执行时间有最大值。如果 setTimeout 设置的延迟值大于 2147483647 毫秒(大约 24.8 天)时就会溢出,那么相当于延时值被设置为 0 了,这导致定时器会被立即执行。
使用 setTimeout 设置的回调函数中的 this 不符合直觉,如果被 setTimeout 推迟执行的回调函数是某个对象的方法,那么该方法中的 this 关键字将指向全局环境,而不是定义时所在的那个对象。
requestAnimationFrame 实现的动画效果比 setTimeout 好的原因:
使用 requestAnimationFrame 不需要设置具体的时间,它会在一帧(一般是 16ms)间隔内根据选择浏览器情况去执行相关动作。requestAnimationFrame 里面的回调函数是在页面刷新之前执行,它跟着屏幕的刷新频率走,保证每个刷新间隔只执行一次,内如果页面未激活的话,requestAnimationFrame 也会停止渲染,这样既可以保证页面的流畅性,又能节省主线程执行函数的开销
同步回调和异步回调
回调函数:将一个函数作为参数传递给另外一个函数,那作为参数的这个函数就是回调函数。
同步回调: 在主函数返回之前执行,即为同步回调。同步回调就是在当前主函数的上下文中执行回调函数。
1 | let callback = function () { |
异步回调:
1 | let callback = function () { |
这次 callback 并没有在主函数 doWork 内部被调用,我们把这种回调函数在主函数外部执行的过程称为异步回调。
XMLHttpRequest 异步回调运作机制
网络进程接收到数据之后,就会利用 IPC 来通知渲染进程;渲染进程接收到消息之后,会将 xhr 的回调函数封装成任务并添加到消息队列中,等主线程循环系统执行到该任务的时候,就会根据相关的状态来调用对应的回调函数。
宏任务 和 微任务
普通消息队列和延迟执行队列中的任务都统称为“宏任务”。
宏任务的时间粒度比较大,执行的时间间隔是不能精确控制的,对一些高实时性的需求就不太符合了,比如后面要介绍的监听 DOM 变化的需求。
产生微任务有两种方式:
使用 MutationObserver 监控某个 DOM 节点,然后再通过 JavaScript 来操作节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务
promise
在当前宏任务快执行完成时,也就在 JavaScript 引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。
如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。也就是说在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行,而是在当前的宏任务中继续执行。
Event Loop 中,每一次循环称为 tick,每一次 tick 的任务如下:
执行栈选择最先进入队列的宏任务(一般都是 script),执行其同步代码直至结束;
检查是否存在微任务,有则会执行至微任务队列为空
宏任务 | 微任务 | |
---|---|---|
具体事件 | 1. script 2. setTimeout/setInterval 3. UI rendering/UI 事件 4. postMessage,MessageChannel 5. setImmediate,I/O(Node.js) | 1. Promise 2. MutaionObserver 3. Object.observe(已废弃;Proxy 对象替代)4. process.nextTick(Node.js) |
页面优化
白屏和优化
浏览器渲染页面包括构建 DOM,构建 CSSOM, 构建布局树,渲染等。白屏的瓶颈主要体现在:
- 下载 html 文件(阻塞 DOM 的合成)
- 下载 CSS 文件(阻塞 CSSOM 的合成)
- 下载 JavaScript 文件
- 执行 JavaScript
3、4 => 在解析 DOM 的过程中,如果遇到了 JavaScript 脚本,那么需要先暂停 DOM 解析去执行 JavaScript,因为 JavaScript 有可能会修改当前状态下的 DOM。)
策略
- 去掉、减少多余网络请求
- 通过内联 JavaScript、内联 CSS 来移除这两种类型的文件下载,这样获取到 HTML 文件之后就可以直接开始渲染流程了。
- 对于依赖数据的 html,采用预渲染和服务端渲染,减少在前端再次请求。
- 减少请求的文件大小
- 对于大的 CSS 文件,可以通过媒体查询属性,将其拆分为多个不同用途的 CSS 文件,这样只有在特定的场景下才会加载特定的 CSS 文件。
- 尽量减少文件大小,比如通过 webpack 等工具移除一些不必要的注释,并压缩 JavaScript 文件。
- Webpack 的代码切割
- 异步请求
- 将一些不需要在解析 HTML 阶段使用的 JavaScript 标记上 async 或者 defer。
- 优化网络请求
- CDN
- 障眼法
- 对于依赖数据的 html,采用骨架屏和 loading
为什么 CSS 动画比 JavaScript 高效
显示器是怎么显示图像的: 每个显示器都有固定的刷新频率,通常是 60HZ,也就是每秒更新 60 张图片,更新的图片都缓冲区,显示器每秒固定读取 60 次缓冲区中的图像,并将读取的图像显示到显示器上。
动画的效果:在滚动或者缩放操作等动画操作时,渲染引擎会通过渲染流水线生成新的图片,并发送到显卡的缓冲区。
正常情况下要实现流畅的动画效果,渲染引擎需要每秒更新 [刷新率] 张图片到显卡的后缓冲区
- 把渲染流水线生成的每一副图片称为一帧。
- 把渲染流水线每秒更新了多少帧称为帧率,比如滚动过程中 1 秒更新了 60 帧,那么帧率就是 60Hz(或者 60FPS)。
要解决卡顿问题,就要解决每帧生成时间过久的问题。
通常渲染路径越长,生成图像花费的时间就越多。比如重排,它需要重新根据 CSSOM 和 DOM 来计算布局树,这样生成一幅图片时,会让整个渲染流水线的每个阶段都执行一遍,如果布局复杂的话,就很难保证渲染的效率了。而重绘因为没有了重新布局的阶段,操作效率稍微高点,但是依然需要重新计算绘制信息,并触发绘制操作之后的一系列操作。
将素材分解为多个图层的操作就称为分层,最后将这些图层合并到一起的操作就称为合成。
相较于重排和重绘,合成操作的路径就显得非常短了,并不需要触发布局和绘制两个阶段,如果采用了 GPU,那么合成的效率会非常高。
能直接在合成线程中完成的任务都不会改变图层的内容,如文字信息的改变,布局的改变,颜色的改变,统统不会涉及,涉及到这些内容的变化就要牵涉到重排或者重绘了。
能直接在合成线程中实现的是整个图层的几何变换,透明度变换,阴影等,这些变换都不会影响到图层的内容。
比如滚动页面的时候,整个页面内容没有变化,这时候做的其实是对图层做上下移动,这种操作直接在合成线程里面就可以完成了。
1 | .box { |
这段代码就是提前告诉渲染引擎 box 元素将要做几何变换和透明度变换操作,这时候渲染引擎会将该元素单独实现一层,等这些变换发生时,渲染引擎会通过合成线程直接去处理变换,这些变换并没有涉及到主线程,这样就大大提升了渲染的效率。这也是 CSS 动画比 JavaScript 动画高效的原因。