cloudjfed
首发于cloudjfed

chrome浏览器页面渲染工作原理浅析

1. 简介

本篇文章基于自己对chrome浏览器工作原理的一些理解,重点在于页面渲染的分析。此文会随着我理解的深入持续更新,若有错误,欢迎随时指正!比心 (。♥‿♥。)

参考资料重点来源于:

  1. 《WebKit技术内幕》
    作者是朱永盛,Chromium项目的committer。作者的个人博客:blog.csdn.net/milado_nj
  2. HTML5规范
    该规范来自whatwg。和w3c制定的html规范不同的是,前者来自Mozilla、Chrome、Safari等,而后者背后是微软公司。因为本文主要探究的是chrome浏览器的行为,所以主要参考的是whatwg的规范。

文章目录:

  1. 简介
  2. 当我们谈论Webkit的时候,我们在谈论什么?
    1. 浏览器内核
    2. Webkit
    3. Chromium
  3. 渲染引擎做了啥
  4. JavaScript引擎做了啥
    1. js是单线程的
    2. JavaScript引擎怎么做
      1. 解释过程
      2. v8的解释过程
        1. v8之前的做法——机器码
        2. v8新版本——字节码
  5. 浏览器的多线程
    1. 多线程有哪些
    2. 线程之间如何配合
      1. 我是一段野生代码
      2. 事件循环机制
        1. 事件机制的概念
        2. 事件机制的原理
      3. 代码执行过程分析
        1. 分析
        2. 验证
  6. 基于浏览器(chrome内核)工作原理的代码优化
  7. 参考资料

2. 当我们谈论Webkit的时候,我们在谈论什么?

对于前端同学来说,webkit这个名词非常熟悉了,那么当我们在说chrome是Webkit内核的时候,我们到底在说什么?


2.1 浏览器内核

浏览器有一个重要的模块,它主要的作用是将页面变成可视(听)化的图形、音频结果,这就是浏览器内核。不同浏览器有不同内核,常用的有Trident(IE)、Gecko(Firefox)、Blink(Chrome)、Webkit(Safari)

浏览器内核又可以分成两部分:渲染引擎和 JS 引擎。

最开始渲染引擎和 JS 引擎并没有区分的很明确,后来 JS 引擎越来越独立,内核就倾向于只指渲染引擎。


2.2 Webkit

一说到Webkit,最先想起来的可能就是chrome。但其实Webkit最早是苹果公司的一个开源项目。

苹果同学哭瞎了眼。

Webkit项目的结构如下

(图片来自《WebKit技术内幕》第三章)


从图中可以看到,Webkit主要由WebCore(渲染引擎)、JavaScriptCore(JavaScript引擎)和Webkit Port(不同浏览器自己实现的移植部分)构成。

整个项目称为Webkit,而我们前端开发者在谈到Webkit的时候,往往指的是WebCore,即渲染引擎部分。

当我们打开一个页面的时候,网页的渲染流程如下:

(图片来自《WebKit技术内幕》第一章)


图中DOM和js引擎的双向箭头,指的是dom和js引擎的桥接接口,用于调用对方的一些方法。


2.3 chromium

那为什么我们提到Webkit的时候,往往会和chrome联系在一起呢?

2008 年,谷歌公司发布了 chrome 浏览器,浏览器使用的内核被命名为 chromium。

谷歌公司研发了自己的JavaScript引擎,v8, 但fork 了开源引擎 Webkit。后来谷歌公司在 Chromium 项目中研发 Blink 渲染引擎,也就是说,最初Blink 是从Webkit复制过来,没有太大区别,但谷歌逐渐进行了优化,并慢慢将其中于chromium 不相关的代码进行移除。所以可以预见的是,以后两者差距会愈来愈大。

对此,我采访了一下苹果公司,他们表示:

可能需要给他们寄一箱原谅套餐== 当然是假的!毕竟我又不是隔壁老王!


3. 渲染引擎做了啥!

3.1 Webkit 渲染过程

渲染引擎顾名思义,负责渲染,它将网络或者本地获取的网页和资源从字节流进行解释,呈现出来,流程如下图:



从图中可以看到,渲染引擎具体做了:

1. 用HTML 解释器 将字节流解释成DOM树

HTML解释器的工作如下:

(图片来自《Webkit技术内幕》第五章)

解释器进行分词后,生成节点,并从节点生成DOM树。
那如何从“并列”的节点,生成具有层次结构的树呢?
解释器在构建节点属性的时候,使用了栈结构,即这样一个代码片段<div><p><span></span></p></div>,当解释到span时,此时栈中元素就是 div、p、span,当解释到</span>时,span出栈,遇到</p> p 出栈,以此类推。

当然,HTML解释器在工作时很有可能遇到全局的js代码!我知道此刻你要说,那就停下来执行js代码啊!
事实上,解释器确实是停下来了,但并不会马上执行js代码,浏览器的预扫描和预加载机制会先扫描后面的词语,如果发现有资源,那就会请求并发下载资源,然后,再执行js代码。
详细可参考:HTML5解析算法


2. CSS解释器:把css字符串解释后生成style rules

3. RenderObject 树
Webkit检查DOM树中每个DOM节点,判断是否生成RenderObject对象,因为有些节点是不可见的,比如 style head 或者 display为none的节点(现在你知道为啥display:none和visibility:hidden为什么表现不一样了吧)。RenderObject对象叠加了2中相应的css属性。

4. 布局(Layout)
此时的RenderObject 树,并不包含位置和大小信息。Webkit根据模型来进行递归的布局计算。所以当样式发生变化时,就需要重新计算布局,这很耗费性能,更糟糕的是,一旦重排就要重绘了!

5. 绘制(Paint)
布局完,终于可以调用方法进行绘制了!
而我们常说的重绘(repaint),就是当这些元素的颜色、背景等发生变化时,需要进行的。

6. 复合图层化(Composite)
事实上,网页是有层次结构的,基于RenderObject树,建立了 RenderLayer树,每个节点都是RenderLayer节点,一个RenderLayer节点上有n个RenderObject。
什么是RenderLayer呢? 举个栗子:比如有透明效果的RenderObject节点和使用Canvas(或WebGL技术)的RenderObject节点都需要新建一个RenderLayer。

最后,浏览器使用GPU对这些层合成!

3.2 Blink 渲染流程

待补充

4.JavaScript引擎做了啥!

4.1 js是单线程的

我们都知道js是单线程的。为什么呢?js设计之初是为了进行简单的表单验证,操作DOM,与用户进行互动。若是多线程操作,则极有可能出现冲突,比如同时操作同一个DOM元素,那到底听谁的?我们当然可以使用 “锁”机制来解决这些冲突,但这提高了复杂度。毕竟,js是由js之父 Brendan Eich 花了10天开发出来的。

妈妈问我为什么跪着打下了这些字=。=


4.2 JavaScript引擎怎么做

JavaScript引擎的主要作用,就是读取文件中的JavaScript,处理它并执行。

js是一门解释型语言。解释型语言和编译型语言分别由解释器和编译器处理,下面是两者的处理过程:


解释型语言和编译型语言的区别在于,它不提前编译,或者说,你能不能拿到中间代码。


4.2.1 解释过程

一般的JavaScript引擎(比如JavaScriptCore)的执行过程是这样的:

源代码→抽象语法树(AST)→字节码 → JIT →本地代码

解释执行效率很低,因为相同的语句被反复解释。因此优化的思路是动态观察哪些代码经常被调用,对于那些被高频率调用的代码,就用编译器把它编译并且缓存下来,下次执行的时候就不用重新解释,从而提升速度。这就是 JIT(Just-In-Time)。


4.2.2 v8 的解释过程

4.2.2.1 v8之前的做法----机器码

基于字节码的实现是主流,然而v8独辟蹊径,它的解释过程是这样的

源代码→抽象语法树(AST)→JIT→本地代码

v8放弃了编译成字节码的过程,少了AST转化成字节码转化,节约了转化时间,而且原生机器码执行更快。在V8生成本地代码后,也会通过Profiler采集一些信息,来优化本地代码。换句话说,v8的做法,是牺牲空间换时间。


4.2.2.3 v8 新版本—字节码

然而,今年4月末,v8推出了新版本,他们启动了 Ignition 字节码解释器。v8又回归了字节码。

讲道理,机器码既然执行快,为什么又要“回退”到字节码呢?不能因为我超可爱,你就欺负我啊!

详细可以看《V8 Ignition:JS 引擎与字节码的不解之缘》

文章作者认为原因如下:

1. 减轻机器码占用的内存空间,即牺牲时间换空间(主要动机)
字节码是机器码的抽象,同一段代码,在字节码和机器码中的存储如下:

(图片来自Understanding V8’s Bytecode
显然,机器码占用内存过大

2. 提高代码的启动速度;
3. 对 v8 的代码进行重构,降低 v8 的代码复杂度

我的补充解释如下:

JIT优化过程中,safari的JSC的做法如下图:

(图片来自:[WebKit] JavaScriptCore解析

然而,js是无类型语言,也就是变量的类型有可能会改变。举一个典型的栗子:

function add(a, b) {
  return a + b;
}

如果这里的 a 和 b 都是整数,可见最终的代码是汇编中的 add 命令。如果类似的加法运算调用了很多次,解释器可能会认为它值得被优化,于是编译了这段代码。但如果下一次调用的是 add("你好哇", "云霁!"),之前的优化就无效了,因为字符串加法的实现和整数加法的实现完全不同。

而v8之前并没有字节码这个中间表示,所以优化后的代码(二进制格式)还得被还原成原先的形式(字符串格式),这样的过程被称为优化回滚。反复的优化 -> 优化回滚 -> 优化 …… 非常耗时,大大降低了引入 JIT 带来的性能提升。

于是JIT 就很难过

而现在的v8 使用 Ignition(字节码解释器) 加 TurboFan(JIT 编译器)的组合,缓解了这个问题

前后性能对比如下图:

(图片来自:emm...找不到出处了,有好心人知道望告知)


5. 浏览器的多线程

js是单线程的,但为什么能执行ajax和setTimeout等异步操作呢? 很简单,因为浏览器是多线程的呀!

5.1 多线程有哪些

一个浏览器通常由以下线程组成:

  • GUI 渲染线程
  • JavaScript引擎线程
  • 定时触发器线程
  • 事件触发线程(如鼠标点击、AJAX异步请求等)
  • 异步http请求线程


5.2 线程之间如何配合

5.2.1 我是一段野生代码

我们先来看一段代码

var init = new Date().getTime()
function a1(){
  console.log('1')
}
function a2(){
  console.log('2')
}
function a3(){
  console.log('3')
}
function a4(){
  console.log('4')
}
function a5(){
  console.log('5') 
}
function a6(){
  console.log('6')
}
function a7(){
  console.log('7')
}
function a8(){
  console.log('8')
}
function a9(){
  console.log('9')
}
function a10(){
  for(let i = 1;i<10000;i++){}
  console.log('10')
}

a1()
setTimeout(() => {
 a2()
 console.log(new Date().getTime()-init)
 Promise.resolve().then(() => {
a3()
}).then(() => {
 a4()
})
 a5()
}, 1000)
setTimeout(()=>{
a6()
console.log(new Date().getTime()-init)
}, 0)

Promise.resolve().then(() => {
 a7()
}).then(() => {
 a8()
})

a9()
a10()

之所以有n个a*函数,是为了后续方便调试,核心代码从a1()开始

执行结果:你猜?

代码里用到了定时器和异步请求,那么他们到底是怎么配合执行的呢?

这里需要引入一个概念,event loop。


5.2.2 事件循环机制

5.2.2.1 事件机制的概念

浏览器的主线程是event loop即事件循环,什么是eventloop呢?

HTML5规范是这么说的

To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. There are two kinds of event loops: those for browsing contexts, and those for workers.

为了协调事件、用户交互、脚本、UI 渲染、网络请求,用户代理必须使用 eventloop。


5.2.2.2 事件机制的原理

理解事件循环机制的工作原理是这样的:

我们基于规范学习一下这几个名词:


task queue(任务队列)

An event loop has one or more task queues. A task queue is an ordered list of tasks, which are algorithms that are responsible for work as...

一个事件循环会有一个或者多个任务队列,每个任务队列都是一系列任务按照顺序组成的列表。

而多个任务列表源于:每个任务都有指定的任务源,相同的任务源的任务按顺序放在同一个任务列表里。不同的任务列表按照优先级执行任务。


哪些是task任务源呢?

规范在Generic task sources中有提及(原文可看链接,为节省篇幅,此处直接给出翻译):

DOM操作任务源
此任务源用于对DOM操作作出反应,例如一个元素以非阻塞的方式插入文档。
用户交互任务源
此任务源用于对用户交互作出反应,例如键盘或鼠标输入
响应用户操作的事件(例如click)必须使用task队列。
网络任务源
此任务源用于响应网络活动。
历史遍历任务源
此任务源用于将对history.back()和类似API的调用排队
此外 setTimeout、setInterval、IndexDB 数据库操作等也是任务源。


Microtask

Each event loop has a microtask queue. A microtask is a task that is originally to be queued on the microtask queue rather than a task queue. There are two kinds of microtasks: solitary callback microtasks, and compound microtasks.

一个事件循环会有一个microtask列表,microtask中的任务通常指:

  • Promise.then
  • MutationObserver
  • Object.observe

简单来说,事件循环机制是这样运行的(此处规范原文):

  1. 从任务队列中取出最早的一个任务执行
    执行时产生堆栈
  2. 执行 microtask 检查点
    如果microtask checkpoint的flag(标识)为false,则设为true。执行 队列中的所有 microtask,直到队列为空,然后将microtask checkpoint的flag设为flase
  3. 执行 UI render 操作(可选)
    非每次循环必须,只要满足浏览器60HZ的频率即可
  4. 重复1


5.2.3 代码执行的过程分析

5.2.3.1 分析

根据以上理论,我们很容易分析到上述代码执行的事件循环,如下:

执行栈读到script,开始执行任务

第一次循环:

  1. a1()
  2. setTimeout1丢到定时线程中去计时
  3. setTimeout2丢到定时线程中去计时
  4. Promise.then() 的cb a7()放入microtask队列
  5. a9()
  6. a10()
  7. 检查执行microtask
  8. a7() ,将cb a8放入microtask
  9. a8()

(计时线程到时间后,将计时器的回调函数按顺序放入任务队列中)


第二次循环:

从任务队列中读到setTimeout2 cb

  1. a6()
  2. 输出时间console.log(new Date().getTime()-init)

因为setTimeout总是计时结束之后,在任务队列中排队等待执行,所以它执行的时间,总是大于等于开发者设置的时间

但是,即便设置为0,且当前没有正在执行的任务的情况下,时间也不可能为0,因为规范规定,最小时间为4ms!


第三次循环:

从任务队列中读到setTimeout1 cb

  1. a2()
  2. 输出时间console.log(new Date().getTime()-init)
  3. 将 Promise.then() 的cb a3放入microtasks
  4. a5()
  5. 检查执行microtask
  6. a3() 将cb a4放入microtasks
  7. a4()

5.2.3.2 验证

好了,我说了不算,我们用chrome developer tools的Perfomance面板来验证是否正确

步骤是酱的:

1. 打开隐身模式,或者去掉chrome启动的插件,因为这些插件会干扰我们分析

2. 打开控制台

3. 打开面板:新版chrome是Perfomance面板,老版是Timeline面板

4. 看见左上角那个实心圈圈没有?

趁他不注意,赶紧怼他一下!

5. 现在已经开始录制了,迅速刷新一下页面,等个3,4s就停止录制

6. 仔细看下面那个 Main那条来一起分析。

第一次循环:

看到一个很醒目的a1(紫条)了!

a1 后面是 黄色的setTimeout(黄条),再后面是a9 a10(紫条) run microtasks(黄条),下面一次是a7 a8(紫条)

(这就是为什么要写函数名,不然全世界都是匿名函数,乍一看还分不清谁是谁)

来镜头拉近看一下setTimeout那里的两个小黄条在做什么

红色框里的文字,是鼠标移上去看到的字,橙色框是详细信息,点击最后一行 index.html 可以看到具体代码,这里忘了截图。戳进去会跳转到第一个setTimeout那一行(也就是89行)。

这个是第二个setTimeout,定位是在第二个setTimeout那里。

可验证第一次循环判断正确!First Blood!


第二次循环:

Double Kill!


第三次循环:

可能有人会疑惑这里为什么没有a4,那是因为代码执行太快,而控制面板显示时间是精确到0.0m的,所以会有些误差,事实上,我们在a3中多执行一些耗时代码就能看到了。或者也可以多录制几次,每次结果都会有些出入,但是函数执行顺序是不会不一致滴!

Aced!一百昏!一百昏!老铁们双击666!


6. 基于浏览器引擎工作原理(chrome内核)的代码优化

说了那么多,此时难道我们不应该做点什么?

  1. 编写正确的HTML 代码,浏览器在html解释的时候,遇到错误标签,会启动容错机制,开发者应当规避这些错误。
  2. css优先,css优先于js引入,因为渲染树需要拿到DOM树和CSS规则,而js会停止DOM树的构建。
  3. 可以用媒体查询(media query)加载css,来解除对渲染的阻塞,这样,只有当出现符合media的情况时,才会加载该资源。
  4. 尽量不要使用css import 来加载css,@import无论写在哪一行,都会在页面加载完再加载css
  5. 优化css选择器。浏览器在处理选择器时依照从右到左的原则,因此最右端的选择器应该是优先级最高的,比如 div > span.test 优于 div span。 两个原因,一是 .test 比 span更准确,二是,浏览器看到 > span.test 会去找 div 的子元素,而不加大于号,则会寻找全局的span标签。
  6. 减少重绘重排
    1. 当你需要修改DOM节点样式时,不要一条一条改n次,直接定义好样式,修改css类即可,尽管chrome做了优化,并不会真的重绘/重排n次,但是不不能保证你没有强制重绘的代码破坏这个机制,更何况,作为开发者,应当有意识编写高质量代码
    2. 将多次对DOM的修改合并。或者,你先把它从渲染树移除(display:none),这会重排一次,然后你想做什么做什么
    3. 当需要频繁获取元素位置等信息时,可先缓存
    4. 不要使用table布局
    5. transform和opacity属性只会引起合成,所以写css动画的时候,注意两个属性的使用,尽量只开启GPU合成,而不重绘重排。
    6. 必要时使用函数防抖
  7. 防止js阻塞页面,将script标签放在</body>前面,或者使用defer async 属性加载
  8. 文件大小和文件数量做好平衡,不要因为数量太多,大大超过了浏览器可并行下载的资源数量,要不要因为文件太大,提高了单一资源加载的时间
  9. 优化回滚。不要书写触发优化会滚动的代码。

7. 参考资料

《WebKit技术内幕》

How browsers work

大前端开发者需要了解的基础编译原理和语言知识

《V8 Ignition:JS 引擎与字节码的不解之缘》

浏览器进程?线程?傻傻分不清楚!

event-loop-processing-model

JavaScript 运行机制详解:再谈Event Loop

编辑于 2017-10-16

文章被以下专栏收录