HTML页面生命周期一次说清楚
前言
看似基础的问题,深究起来其实包含许多常被忽视的细节点。今天从浏览器架构出发,一次性说清楚这件事情
浏览器架构
进程和线程
- 进程是cpu资源分配的最小单位(CPU占用率、内存等),进程间彼此独立互不干扰,可以通信但代价较大
- 线程是cpu调度的最小单位(线程是建立在进程的基础上的一次程序运行单位,一个进程中可以有单或多个线程)
浏览器是多进程的,以Chrome浏览器为例,其为多进程多线程架构
- 浏览器进程:只有一个,浏览器主进程,负责处理选项卡页面之外的内容,用于控制用户可见的 UI 部分(比如地址栏,书签,后退、前进按钮)和用户不可见的隐藏部分(比如网格请求和文件访问),支持多线程
- UI 线程:绘制浏览器的按钮和输入字段
- 网络线程:发送请求,接收数据
- 存储线程:控制对文件的访问
- GPU 进程:只有一个,处理图像,3d 绘制,提高性能
- 插件进程:每种类型的插件对应一个进程,仅当使用该插件时才创建
- 渲染进程(浏览器内核):每个选项卡都有一个,负责渲染UI、JS执行、事件循环
- GUI 渲染线程:负责渲染浏览器界面,解析HTML&CSS、构建DOM树和渲染树、计算布局以及绘制等
- JS 执行线程:解析执行 JavaScript;与 GUI 渲染线程互斥,因此长时间的JS执行会导致阻塞UI渲染造成掉帧现象
- Worker线程:JS线程向浏览器申请获得的子线程,可独立运行JS(但不能访问DOM)
- 事件线程:监听浏览器事件,事件触发后将需执行的代码塞进JS任务队列,等待JS引擎线程执行
- 定时器线程:负责为setTimeout、setInterval进行计时和将回调推送进JS任务队列
- http请求线程:监听XMLHttpRequest,待响应后将回调推送进JS任务队列
宏观过程:页面从加载到渲染
用户输入URL后浏览器的执行流程:
其中渲染流程如下
用文字梳理一下整个过程
- 浏览器进程将html资源请求回来并通信交给渲染器进程
- GUI线程解析HTML生成DOM树,解析CSS生成CSS规则树
- 上述两者合并为渲染树
- 根据渲染树计算布局(各元素尺寸、位置)
- 绘制图层
- 显示/光栅化:GPU将各图层合成(composite),然后将像素显示在屏幕上
- 若之后渲染树再次发生变化引起重绘(不改变布局)或重排/回流(改变了布局),则重新触发布局计算&绘制&显示
并不是非要等到HTML解析完才会触发绘制&显示,只要有完整CSS规则树+部分DOM树即可触发页面内容显示(尽管内容不全)
图层
- 普通图层:正常文档流、absolute/fix布局的元素都在这一图层
- 复合图层:开启了硬件加速的元素,会位于新的图层,其重绘/重排(回流)不会影响普通图层。开启硬件加速的方法包括:
- 最常用的方式:
translate3d
、translateZ
opacity
属性/过渡动画(需要动画执行的过程中才会创建合成层,动画没有开始或结束后元素还会回到之前的状态)<video><iframe><canvas><webgl>
等元素will-chang
属性
宏观下的细节:各种资源对页面的阻塞效应
1. script脚本
script标签引入脚本的方式有2种:
- 引用(包括动态插入DOM的情况)
- 内联(不包括动态插入DOM的情况,因为有个冷知识:动态插入的内联script不会执行)
二者的区别是:
- 前者在执行脚本前需要先加载,而后者不需要。
- 后者的
defer
&async
属性不生效
GUI线程解析HTML过程遇到script时(以引用型script为例):
<script />
:GUI线程暂停等待(解析过程被阻塞),浏览器进程加载脚本,接着JS线程执行脚本,然后GUI线程恢复并继续解析(正因此,才建议把普通script标签放在body标签最末位置以避免阻塞)<script defer />
:即刻并行加载(不阻塞GUI线程解析),待HTML解析完成后再执行<script async />
:即刻并行加载(不阻塞GUI线程解析),一旦加载完立即执行(阻塞解析)<script type="module" />
:默认行为与defer
一致,唯一区别是会将脚本中import
的其它脚本也一并加载完<script type="module" async/>
:在4的基础上,行为模式改为async
2. css样式
前文的渲染流程图已经指出,css的解析与html的解析是并行发生的,另外css的文件加载也不影响GUI线程。所以关于css的结论是:其加载&解析并不直接阻塞HTML的解析,但是
- 渲染树的生成依赖它,因此会阻塞页面的绘制和显示
- 其后的script会等待它再执行(不论该script是在head还是body中),等待期间GUI线程停滞,因此会间接阻塞script之后的HTML解析
以下是佐证结论的若干示例,运行示例前记得先将浏览器网络节流模式调慢
示例1:运行后,浏览器控制台可以观察到「元素」中已经出现了h1
,但页面是空白的,等待若干秒后才在页面看到h1
内容。这个例子佐证了结论1,即HTML顺畅解析,但需要等待css文件加载&解析完毕、合成渲染树、绘制,然后才能在页面把内容真正显示出来
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<link href="https://cdn.bootcss.com/bootstrap/4.0.0-alpha.6/css/bootstrap.css" rel="stylesheet" />
</head>
<body>
<h1>我是 h1 标签</h1>
</body>
</html>
示例2:可以观察到一开始「元素」中已经出现了script
,但未出现body
,页面是空白的,"head script executed!"
这段话也没打印出来,直到等待若干秒后才打印成功,并且内容显示在页面上。这个例子佐证了结论2
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<link href="https://cdn.bootcss.com/bootstrap/4.0.0-alpha.6/css/bootstrap.css" rel="stylesheet" />
<script>
console.log("head script executed!")
</script>
</head>
<body>
<h1>我是 h1 标签</h1>
</body>
</html>
引申问题:为什么不把css样式放在body里?
运行下面的例子可以发现,网页马上显示黑色h1
文字,但等待一会儿(script加载&运行完)后文字变成红色。这说明将css放到body后,不再阻塞渲染,但是当css加载/解析完成后页面发生了重绘,带来的视觉效果就是样式闪动,这对用户来说是很不好的体验,正是为了避免这种情况我们才建议要把css放在head中
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
</head>
<body>
<h1>我是 h1 标签</h1>
<script src="https://cdn.bootcss.com/jquery/2.1.4/jquery.min.js"></script>
<style>
h1 {
color: red;
}
</style>
</body>
</html>
3. 图片/音频/视频/字体等媒体资源
不会阻塞HTML解析及渲染
宏观下的细节:事件触发
DOMContentLoaded
:当HTML已经完成解析且除async和动态插入之外的脚本均执行完成时触发(尽管此时外部资源比如样式和脚本可能还没加载完成),并且该事件需要绑定到document
对象上;onload
:当页面所有资源(包括CSS
、JS
、图片、字体、视频等)都加载完成才触发,而且它是绑定到window
对象上;readystatechange
:触发时查看document.readyState
可以获知文档当前的状态loading
—— 文档正在被加载。interactive
—— 文档被全部读取。(DOMContentLoaded
紧随其后)complete
—— 文档被全部读取,并且所有资源(例如图片等)都已加载完成。(onload
紧随其后)
后续:浏览器事件循环
上述介绍完了页面初始化时从加载到渲染的宏观过程&细节,在这之后页面的变化主要来自事件循环:
- 事件线程、定时器线程、http线程等会将相应触发的回调送入任务队列
- JS执行线程负责将任务队列中的任务取出并放入执行栈中执行
- 执行完后再去检查任务队列并取出新的任务,依此循环
任务队列有两个:
- 宏任务队列:事件、请求、定时器等回调
- 微任务队列:Promise、MutationObserver等回调
补充说明:为什么网页会掉帧
我们所看到的网页,都是浏览器一帧一帧绘制出来的,每一帧表示浏览器执行一次光栅化显示的时间,这个时间理想情况是16ms以内(即满足每秒至少60次刷新),但实际每帧的时间并不固定,取决于一帧中各种事项的实际耗时
通常每帧做的事情按顺序为:
- rAF(requestAnimationFrame)回调(每帧必定执行)
- 重新计算布局&绘制
- 执行JS(单个宏任务及所有微任务)
- rIC(requestIdleCallback)回调(前面事情做完仍有空闲才会执行)
- GUI线程渲染(光栅化)
如果某帧耗时过长(比较常见的原因是JS任务执行时间过长)则会导致下一帧比较晚才显示,从而发生掉帧的现象
最后:离开页面
- 当用户想要离开页面时,
window
上的beforeunload
事件就会被触发。如果我们取消这个事件,浏览器就会询问我们是否真的要离开(例如,我们有未保存的更改)。 - 当用户最终离开时,
window
上的unload
事件就会被触发。在处理程序中,我们只能执行不涉及延迟或询问用户的简单操作。正是由于这个限制,它很少被使用。不过我们可以在unload
回调中使用navigator.sendBeacon
来发送网络请求,它在后台发送数据,浏览器离开页面,但仍然在执行sendBeacon
转载自:https://juejin.cn/post/7154672141794246663