页面的渲染流程、渲染阻塞以及JS Event loop

从输入URL到页面渲染过程

首先简单了解一下浏览器请求、加载、渲染一个页面的大致过程:

  • 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渲染线程

    1. 解析HTML,CSS,构建DOM树和Render树,布局和绘制等
    2. repaint/reflow
    3. GUI渲染线程与JS引擎线程是互斥的,当JS引擎执行时GUI线程会被挂起,GUI的更新会被保存在一个队列中等到JS引擎空闲时立即被执行。
  • JS引擎线程

    1. 解析Javascript脚本,单线程执行
    2. 与GUI互斥,GUI ON then JS Suspend.等待着任务队列中任务的到来,然后加以处理,JS执行的时间过长会导致页面渲染加载阻塞
  • 事件触发线程

    1. 归属于浏览器而不是JS引擎,用来控制事件循环。(这货是与浏览器进程交互的而非JS引擎)
    2. 当JS引擎执行代码块如click事件时(也可来自浏览器内核的其他线程,如鼠标点击、AJAX异步请求等),会将对应任务添加到事件待处理队列的队尾,等待JS引擎的处理
  • 定时触发器线程

    1. setInterval与setTimeout所在线程
    2. 浏览器定时计数器并不是由JS引擎计数的,因为JavaScript引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确
    3. 计时完毕后,将事件添加到事件队列中,等待JS引擎空闲后执行
  • 异步http请求线程

    1. 在XMLHttpRequest在连接后是通过浏览器新开一个线程请求
    2. 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中。再由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 标签该放在哪

实际使用时,可以遵循下面两个原则:

  1. CSS 优先:引入顺序上,CSS 资源先于 JavaScript 资源。(通常会把css放在头部,js放在body尾,不然js先操作修改DOM节点样式,之后css重新渲染该节点样式,导致渲染效果与预期不一致)

  2. 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。

在实际工程中,我们常常将脚本资源放到文档底部。

deferasync这两个属性会使 <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。

📌了解更多关于阻塞资源的信息:优化关键渲染路径

从Event Loop谈JS的运行机制

注意,这里不谈可执行上下文,VO,scop chain等概念,这里主要是结合Event Loop来谈JS代码是如何执行的。

事件循环机制

  • JS分为同步任务和异步任务
  • 同步任务都在主线程上执行,形成一个执行栈
  • 主线程之外,事件触发线程管理着一个任务队列,只要异步任务有了运行结果,就在任务队列之中放置一个事件。
  • 一旦执行栈中的所有同步任务执行完毕(此时JS引擎空闲),系统就会读取任务队列,将可运行的异步任务添加到可执行栈中,开始执行。
    在这里插入图片描述

看到这里,应该就可以理解了:为什么有时候setTimeout推入的事件不能准时执行?因为可能在它推入到事件列表时,主线程还不空闲,正在执行其它代码,所以自然有误差。

拓展:聊一聊定时器

什么时候会用到定时器线程?当使用setTimeoutsetInterval时,它需要定时器线程计时,计时完成后就会将特定的事件推入事件队列中。

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引擎线程维护

总结下运行机制:

  1. 执行一个宏任务(栈中没有就从事件队列中获取)
  2. 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
  3. 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
  4. 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
  5. 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)
    在这里插入图片描述

另外,请注意下Promise的polyfill与官方版本的区别:

  • 官方版本中,是标准的microtask形式
  • polyfill,一般都是通过setTimeout模拟的,所以是macrotask形式

请特别注意这两点区别:

有一些浏览器执行结果不一样(因为它们可能把microtask当成macrotask来执行了),但是为了简单,这里不描述一些不标准的浏览器下的场景(但记住,有些浏览器可能并不标准)

========================================================================================================

参看文档:
浏览器渲染原理及流程
从浏览器多进程到JS单线程,JS运行机制最全面的一次梳理
四步带你吃透浏览器渲染基本原理
一文看懂Chrome浏览器运行机制

  • 3
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值