HTML页面生命周期一次说清楚

1,768 阅读7分钟

前言

看似基础的问题,深究起来其实包含许多常被忽视的细节点。今天从浏览器架构出发,一次性说清楚这件事情

浏览器架构

进程和线程

  • 进程是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后浏览器的执行流程: 其中渲染流程如下

用文字梳理一下整个过程

  1. 浏览器进程将html资源请求回来并通信交给渲染器进程
  2. GUI线程解析HTML生成DOM树,解析CSS生成CSS规则树
  3. 上述两者合并为渲染树
  4. 根据渲染树计算布局(各元素尺寸、位置)
  5. 绘制图层
  6. 显示/光栅化:GPU将各图层合成(composite),然后将像素显示在屏幕上
  7. 若之后渲染树再次发生变化引起重绘(不改变布局)或重排/回流(改变了布局),则重新触发布局计算&绘制&显示

并不是非要等到HTML解析完才会触发绘制&显示,只要有完整CSS规则树+部分DOM树即可触发页面内容显示(尽管内容不全)

图层

  • 普通图层:正常文档流、absolute/fix布局的元素都在这一图层
  • 复合图层:开启了硬件加速的元素,会位于新的图层,其重绘/重排(回流)不会影响普通图层。开启硬件加速的方法包括:
    • 最常用的方式:translate3dtranslateZ
    • opacity属性/过渡动画(需要动画执行的过程中才会创建合成层,动画没有开始或结束后元素还会回到之前的状态)
    • <video><iframe><canvas><webgl>等元素
    • will-chang属性

宏观下的细节:各种资源对页面的阻塞效应

1. script脚本

script标签引入脚本的方式有2种:

  1. 引用(包括动态插入DOM的情况)
  2. 内联(不包括动态插入DOM的情况,因为有个冷知识:动态插入的内联script不会执行)

二者的区别是:

  • 前者在执行脚本前需要先加载,而后者不需要。
  • 后者的defer&async属性不生效

GUI线程解析HTML过程遇到script时(以引用型script为例):

  1. <script />:GUI线程暂停等待(解析过程被阻塞),浏览器进程加载脚本,接着JS线程执行脚本,然后GUI线程恢复并继续解析(正因此,才建议把普通script标签放在body标签最末位置以避免阻塞)
  2. <script defer />:即刻并行加载(不阻塞GUI线程解析),待HTML解析完成后再执行
  3. <script async />:即刻并行加载(不阻塞GUI线程解析),一旦加载完立即执行(阻塞解析)
  4. <script type="module" />:默认行为与defer一致,唯一区别是会将脚本中import的其它脚本也一并加载完
  5. <script type="module" async/>:在4的基础上,行为模式改为async

2. css样式

前文的渲染流程图已经指出,css的解析与html的解析是并行发生的,另外css的文件加载也不影响GUI线程。所以关于css的结论是:其加载&解析并不直接阻塞HTML的解析,但是

  1. 渲染树的生成依赖它,因此会阻塞页面的绘制和显示
  2. 其后的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:当页面所有资源(包括 CSSJS、图片、字体、视频等)都加载完成才触发,而且它是绑定到 window 对象上;
  • readystatechange:触发时查看document.readyState可以获知文档当前的状态
    • loading —— 文档正在被加载。
    • interactive —— 文档被全部读取。(DOMContentLoaded紧随其后)
    • complete —— 文档被全部读取,并且所有资源(例如图片等)都已加载完成。(onload紧随其后)

后续:浏览器事件循环

上述介绍完了页面初始化时从加载到渲染的宏观过程&细节,在这之后页面的变化主要来自事件循环:

  1. 事件线程、定时器线程、http线程等会将相应触发的回调送入任务队列
  2. JS执行线程负责将任务队列中的任务取出并放入执行栈中执行
  3. 执行完后再去检查任务队列并取出新的任务,依此循环

任务队列有两个:

  1. 宏任务队列:事件、请求、定时器等回调
  2. 微任务队列:Promise、MutationObserver等回调 image.png

image.png

补充说明:为什么网页会掉帧

我们所看到的网页,都是浏览器一帧一帧绘制出来的,每一帧表示浏览器执行一次光栅化显示的时间,这个时间理想情况是16ms以内(即满足每秒至少60次刷新),但实际每帧的时间并不固定,取决于一帧中各种事项的实际耗时

通常每帧做的事情按顺序为:

  1. rAF(requestAnimationFrame)回调(每帧必定执行)
  2. 重新计算布局&绘制
  3. 执行JS(单个宏任务及所有微任务)
  4. rIC(requestIdleCallback)回调(前面事情做完仍有空闲才会执行)
  5. GUI线程渲染(光栅化)

如果某帧耗时过长(比较常见的原因是JS任务执行时间过长)则会导致下一帧比较晚才显示,从而发生掉帧的现象

最后:离开页面

  • 当用户想要离开页面时,window 上的 beforeunload 事件就会被触发。如果我们取消这个事件,浏览器就会询问我们是否真的要离开(例如,我们有未保存的更改)。
  • 当用户最终离开时,window 上的 unload 事件就会被触发。在处理程序中,我们只能执行不涉及延迟或询问用户的简单操作。正是由于这个限制,它很少被使用。不过我们可以在unload回调中使用 navigator.sendBeacon 来发送网络请求,它在后台发送数据,浏览器离开页面,但仍然在执行 sendBeacon