|
首先简单了解一下浏览器请求、加载、渲染一个页面的大致过程:
- DNS 查询
- TCP 连接
- HTTP 请求
- 服务器响应
- 客户端渲染
至于客户端渲染之前更具体的说明,👉浏览器导航过程发生了什么?(Chrome)
页面渲染流程 |
旧版webkit内核浏览器渲染流程图(其他的也大同小异):
渲染大概可以划分成以下几个步骤:
- 解析html建立DOM树(深度遍历)
- 将CSS解析成 CSSOM树
- 根据DOM树和CSSOM树来构造 Render树
- 布局render树,负责各元素尺寸、位置的计算
- 绘制render树,绘制页面像素信息
- 浏览器会将各层的信息发送给GPU,GPU会将各层合成,显示在屏幕上。
📌上述这个过程是逐步完成的,为了更好的用户体验,渲染引擎将会尽可能早的将内容呈现到屏幕上,并不会等到所有的html都解析完成之后再去构建和布局render树。它是解析完某一部分(整体大块的)内容就显示一部分内容,同时,可能还在通过网络下载其余内容。
更清晰了解构建渲染树:
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>Demos</title>
<style>
body {font-size: 16px;}
p {color: red;}
p span {display:none;}
span {font-size: 14px;}
img {float: right;}
</style>
</head>
<body>
<p>Hello <span>berwin</span></p>
<span>Berwin</span>
<img src="https://p1.ssl.qhimg.com/t0195d63bab739ec084.png" />
</body>
</html>
这段代码构建渲染树图:
渲染进程里面的线程(浏览器内核) |
-
GUI渲染线程
- 解析HTML,CSS,构建DOM树和Render树,布局和绘制等
- repaint/reflow
- GUI渲染线程与JS引擎线程是互斥的,当JS引擎执行时GUI线程会被挂起,GUI的更新会被保存在一个队列中等到JS引擎空闲时立即被执行。
-
JS引擎线程
- 解析Javascript脚本,单线程执行
- 与GUI互斥,GUI ON then JS Suspend.等待着任务队列中任务的到来,然后加以处理,JS执行的时间过长会导致页面渲染加载阻塞
-
事件触发线程
- 归属于浏览器而不是JS引擎,用来控制事件循环。(这货是与浏览器进程交互的而非JS引擎)
- 当JS引擎执行代码块如click事件时(也可来自浏览器内核的其他线程,如鼠标点击、AJAX异步请求等),会将对应任务添加到事件待处理队列的队尾,等待JS引擎的处理
-
定时触发器线程
- setInterval与setTimeout所在线程
- 浏览器定时计数器并不是由JS引擎计数的,因为JavaScript引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确
- 计时完毕后,将事件添加到事件队列中,等待JS引擎空闲后执行
-
异步http请求线程
- 在XMLHttpRequest在连接后是通过浏览器新开一个线程请求
- 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中。再由JavaScript引擎执行。
浏览器内核中线程之间的关系 |
-
GUI渲染线程与JS引擎线程互斥
由于JavaScript是可操纵DOM的,如果在修改这些元素属性同时渲染界面(即JS线程和UI线程同时运行),那么渲染线程前后获得的元素数据就可能不一致了。当JS引擎执行时GUI线程会被挂起,GUI更新则会被保存在一个队列中等到JS引擎线程空闲时GUI线程就执行队列中的UI更新。 -
JS阻塞页面加载
从上述的互斥关系,可以推导出,JS如果执行时间过长就会阻塞页面,自然会感觉到页面卡顿。
如何解决JS执行时间过长带来的问题? |
Web Worker为Web内容在后台线程中运行脚本提供了一种简单的方法。线程可以执行任务而不干扰用户界面一个worker是使用一个构造函数创建的一个对象(e.g. Worker()) 运行一个命名的JavaScript文件 这个文件包含将在工作线程中运行的代码; workers 运行在另一个全局上下文中,不同于当前的window因此,使用 window快捷方式获取当前全局的范围 (而不是self) 在一个 Worker 内将返回错误。主要用来处理大运算量问题。
- 创建Worker时,JS引擎向浏览器申请开一个子线程,子线程是浏览器开的,完全受JS主线程控制,而且不能操作DOM,该线程拥有JS执行环境
- JS引擎线程与worker线程间通过特定的方式通信(postMessage API,需要通过序列化对象来与线程交互特定的数据)
📌拓展:WebWorker与SharedWorker
- WebWorker只属于某个页面,不会和其他页面的Render进程(浏览器内核进程)共享。
Chrome在Render进程中创建一个新的线程来运行Worker中的JavaScript程序。 - SharedWorker是浏览器所有页面共享的,不能采用与Worker同样的方式实现,因为它不隶属于某个Render进程,可以为多个Render进程共享使用。
Chrome浏览器为SharedWorker单独创建一个进程来运行JavaScript程序,在浏览器中每个相同的JavaScript只存在一个SharedWorker进程,不管它被创建多少次。
本质上就是进程和线程的区别。SharedWorker由独立的进程管理,WebWorker只是属于render进程下的一个线程。
|
现代浏览器总是并行加载资源,例如,当 HTML 解析器(HTML Parser)被脚本阻塞时,解析器虽然会停止构建 DOM,但仍会识别该脚本后面的资源,并进行预加载。
阻塞资源分类 |
-
CSS与JS都被视为渲染阻塞资源 ,浏览器会等到CSSOM 构建完毕,才会进入下一阶段(合成渲染树)。
-
JavaScript 被认为是解析器阻塞资源,JS会阻塞HTML解析。
阻塞资源之间的影响 |
-
存在阻塞的 CSS 资源时,浏览器会延迟 JavaScript 的执行和 DOM 构建。
-
当浏览器遇到一个
<script>
标记时,DOM 构建将暂停,直至脚本完成执行。 -
JavaScript 可以查询和修改 DOM 与 CSSOM。
-
CSSOM 构建时,JavaScript 执行将暂停,直至 CSSOM 就绪。
所以,script 标签的位置很重要。
script 标签该放在哪 |
实际使用时,可以遵循下面两个原则:
-
CSS 优先:引入顺序上,CSS 资源先于 JavaScript 资源。(通常会把css放在头部,js放在body尾,不然js先操作修改DOM节点样式,之后css重新渲染该节点样式,导致渲染效果与预期不一致)
-
JavaScript 应尽量少影响 DOM 的构建。
css加载是否会阻塞dom树加载? |
<link>
标签(或是内联样式或行内样式)会被视为阻塞渲染的资源,浏览器会优先处理这些 CSS 资源,直至 CSSOM 构建完毕。
<link>
引入的css文件是由单独的下载线程异步下载的。
css加载不会阻塞DOM树的解析 |
CSS 的加载和解析并不会阻塞 DOM Tree 的构建,因为 DOM Tree 和 CSSOM Tree 是两棵相互独立的树结构。
css加载会阻塞DOM树的渲染 |
构建渲染树同时依赖CSSOM树和DOM树。在没有处理完 CSS 之前,文档是不会在页面上显示出来的,这个策略的好处在于页面不会重复渲染;如果 DOM Tree 构建完毕直接渲染,这时显示的是一个原始的样式,等待 CSSOM Tree 构建完毕,再重新渲染又会突然变成另外一个模样,除了开销变大之外,用户体验也是相当差劲的。
css加载会阻塞后面js语句的执行 |
如果js先操作修改DOM节点样式,之后css重新渲染该节点样式,导致渲染效果与预期不一致
内联样式和嵌入样式可以减少页面请求,为什么还要使用link外联样式呢? |
在构建 DOM Tree 的过程中,如果遇到 link
标记,浏览器就会立即发送请求获取样式文件。
当然我们也可以直接使用内联样式或嵌入样式,来减少请求;但是会失去模块化和可维护性,并且像缓存和其他一些优化措施也无效了,利大于弊,性价比实在太低了;除非是为了极致优化首页加载等操作,否则不推荐这样做。
异步加载脚本 defer与asysn |
解析过程中无论遇到的JavaScript是内联还是外联,只要浏览器遇到 <script>
标记,就会唤醒 JavaScript解析器,并暂停解析HTML,并等待 CSSOM 构建完毕,才去执行js脚本。
因为脚本中可能会操作DOM元素,而如果在加载执行脚本的时候DOM元素并没有被解析,脚本就会因为DOM元素没有生成取不到响应元素,但由于JavaScript可以修改CSSOM,所以需要等CSSOM构建完毕后再执行JS。
在实际工程中,我们常常将脚本资源放到文档底部。
defer
与async
这两个属性会使 <script>
异步加载,只针对外联脚本,对于inline-script是无效的。
defer |
defer
属性会开启新的线程下载脚本文件并延迟执行引入 的JavaScript脚本,即 JavaScript 脚本加载时 HTML 并未停止解析,这两个过程是并行的。
如果声明了多个defer
的脚本,则会按顺序下载并在整个 document 解析完毕且 defer-script
也加载完成之后按顺序执行,defer脚本会在DOMContentLoaded
之后和load
事件之前执行。
async |
async
属性表示异步执行引入的 JavaScript,与 defer
的区别在于,如果已经加载好,就会开始执行,无论此刻是 HTML 解析阶段还是 DOMContentLoaded
触发(HTML解析完成事件)之后,但一定在 load
触发之前执行。
声明了多个async
的脚本,其下载和执行都是异步的,不能确保彼此的先后顺序。
值得注意的是,通过js向 document 动态添加 script 标签时,async 属性默认是 true。
📌了解更多关于阻塞资源的信息:优化关键渲染路径
|
注意,这里不谈可执行上下文,VO,scop chain等概念,这里主要是结合Event Loop来谈JS代码是如何执行的。
事件循环机制 |
- JS分为同步任务和异步任务
- 同步任务都在主线程上执行,形成一个执行栈
- 主线程之外,事件触发线程管理着一个任务队列,只要异步任务有了运行结果,就在任务队列之中放置一个事件。
- 一旦执行栈中的所有同步任务执行完毕(此时JS引擎空闲),系统就会读取任务队列,将可运行的异步任务添加到可执行栈中,开始执行。
看到这里,应该就可以理解了:为什么有时候setTimeout
推入的事件不能准时执行?因为可能在它推入到事件列表时,主线程还不空闲,正在执行其它代码,所以自然有误差。
拓展:聊一聊定时器 |
什么时候会用到定时器线程?当使用setTimeout
或setInterval
时,它需要定时器线程计时,计时完成后就会将特定的事件推入事件队列中。
setTimeout(function(){
console.log('hello!');
}, 1000);
这段代码的作用是当1000毫秒计时完毕后(由定时器线程计时),将回调函数推入事件队列中,等待主线程执行。
setTimeout(function(){
console.log('hello!');
}, 0);
console.log('begin');
这段代码的效果是最快的时间内将回调函数推入事件队列中,等待主线程执行。
执行结果是:先begin后hello!
虽然代码的本意是0毫秒后就推入事件队列,但是W3C在HTML标准中规定,规定要求setTimeout
中低于4ms的时间间隔算为4ms。
(不过也有一说是不同浏览器有不同的最小时间设定)
就算不等待4ms,就算假设0毫秒就推入事件队列,也会先执行begin(因为只有可执行栈内空了后才会主动读取事件队列)
事件循环进阶:宏任务(macrotask)与微任务(microtask) |
上述JS事件循环机制梳理了一遍,在ES5的情况是够用了,但是在ES6盛行的现在,仍然会遇到一些问题,譬如下面这题:
let p =new Promise((resolve,reject)=>{
console.log('before resolve');
resolve('resolve')
console.log('after resolve');
}).then(r=>{
console.log(r);
})
setTimeout(()=>{
console.log('setTimeout');
},0)
console.log("全局代码块");
Promise实例创建就会执行。
resolved 的 Promise 是在本轮事件循环的末尾执行,总是晚于本轮循环的同步任务。
setTimeout是宏任务,会再下一轮循环执行。
JS中分为两种任务类型:macrotask(宏任务)和microtask(微任务)。在ECMAScript中,macrotask称为task,microtask称为jobs。
macrotask |
macrotask 可以理解是每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)
- 每一个task会从头到尾将这个任务执行完毕,不会执行其它。
- 浏览器为了能够使得JS内部task与DOM任务能够有序的执行,会在一个task执行结束后,在下一个 task 执行开始前,对页面进行重新渲染
microtask |
microtask 可以理解是在当前 task 执行结束后立即执行的任务
- 在当前task任务后,下一个task之前,在渲染之前
- 它的响应速度相比setTimeout(setTimeout是task)会更快,因为无需等渲染
也就是说,在某一个macrotask执行完后,就会将在它执行期间产生的所有microtask都执行完毕(在渲染前)
📌哪些任务属于macrotask或microtask呢?
- macrotask:主代码块,setTimeout,setInterval等(可以看到,事件队列中的每一个事件都是一个macrotask)
- microtask:Promise(不是新建实例的情况下),process.nextTick等
补充:在node环境下,process.nextTick的优先级高于Promise,也就是可以简单理解为:在宏任务结束后会先执行微任务队列中的nextTickQueue部分,然后才会执行微任务中的Promise部分。
再根据线程来理解下:
- macrotask中的事件都是放在一个事件队列中的,而这个队列由事件触发线程维护
- microtask中的所有微任务都是添加到微任务队列(Job Queues)中,等待当前macrotask执行完毕后执行,而这个队列由JS引擎线程维护
总结下运行机制:
- 执行一个宏任务(栈中没有就从事件队列中获取)
- 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
- 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
- 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
- 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)
另外,请注意下Promise的polyfill与官方版本的区别:
- 官方版本中,是标准的microtask形式
- polyfill,一般都是通过setTimeout模拟的,所以是macrotask形式
请特别注意这两点区别:
有一些浏览器执行结果不一样(因为它们可能把microtask当成macrotask来执行了),但是为了简单,这里不描述一些不标准的浏览器下的场景(但记住,有些浏览器可能并不标准)
========================================================================================================
参看文档:
浏览器渲染原理及流程
从浏览器多进程到JS单线程,JS运行机制最全面的一次梳理
四步带你吃透浏览器渲染基本原理
一文看懂Chrome浏览器运行机制