《浏览器工作原理与实践》系列笔记 - 浏览器中的页面

Posted by cody1991 on March 23, 2021

页面性能分析:利用 chrome 做 web 性能分析

Chrome 开发者工具

包含了 10 个功能面板,包括了 Elements、Console、Sources、NetWork、Performance、Memory、Application、Security、Audits 和 Layers

提供了访问编辑 DOM 和 CSSOM 的能力,也提供了强大的调试和性能指标工具

网络面板

由控制器、过滤器、抓图信息、时间线、详细列表和下载信息概要这 6 个区域构成

控制器

过滤器

选择自己想看的文件类型等

抓图信息

可以看到用户等待加载时看到的画面,分析用户的体验

时间线

HTTP HTTPS WebSocket 加载的状态和时间的关系,直观感受页面加载过程。如果多个堆叠在一起代表同时加载,具体需要到下面的文件列表查看

详细列表

每个资源发起到完成请求的所有过程状态信息,最终完成的数据信息

下载信息摘要

关注 DOMContentLoaded 和 Loaded 两个事件的时间

  • DOMContentLoaded 代表 DOM 构建好了,所需要的 HTML CSS JavaScript 都下载完成了
  • Loaded 代表加载完所有的资源了

网络面板中的详细列表

每个文件的信息都是非常详细的:

我们看下单个资源的时间线,这里涉及到了 HTTP 请求流程

  • 查找是否有缓存
  • DNS 获取 IP 地址
  • 建立 TCP 连接
  • 发送 HTTP 请求
  • 响应头如果包含重定向,直接走回开始的步骤
  • 否则接收返回数据

这里面每一项是什么意思呢?

首先看看 Queuing 是排队的时间,发起一个请求有时候并不能立刻发送,需要等待,有很多原因导致的

  • 页面资源有优先级, CSS、HTML、JavaScript 是核心数据,优先级最高,图片、视频、音频这类资源就不是核心资源,优先级比较低,所以后者一般需要让路,进入待排队状态
  • 浏览器维护了 6 个 TCP 连接,如果发起这个请求的时候都处于忙绿状态,则进入待排队状态
  • 为数据分配磁盘空间也需要等待磁盘空间分配完成

等待排队完以后,进行发起连接状态,这个时候也有可能会推迟,叫做 Stalled,停滞的意思

然后是 Initial connection/SSL ,服务器建立连接的时间,包括 TCP 连接的时间,如果是 HTTPS 还有 SSL 握手的时间,协商一些加密的信息

建立好以后准备请求数据,发送给网络,就是 Request sent 阶段了,这个阶段很快,只要把浏览器缓冲区的数据发送出去就好了,一般不到 1ms

接下来就是等待服务器第一个字节的数据,称之为 TTFB,第一字节时间,是反应服务器响应速度的指标,TTFB 越短响应越快

后面进入完整数据接收状态 Content Download ,这是第一个字节到全部接收完成的时间

优化时间线上耗时项

排队时间过长 Queuing

大概是每个域名只能有 6 个 TCP 连接导致的,我们可以在一个站点下放多个资源域名,这叫做分片技术。但是更加建议升级到 HTTP/2,通过多路复用没必要最多维护 6 个 TCP 连接的限制了

TTFB 过久

  • 可能是服务器生成的数据时间过长
  • 网络问题
  • 发送请求头有多余的用户信息,一些不必要的 cookie 等

解决方案有下面一些

  • 可以提高服务器的处理性能,也可以缓存服务器处理的结果
  • 第二个问题,可以通过 CDN 来缓存静态文件
  • 减去不必要的头信息,压缩头部信息

Content Download 过久

字节数太多了,减少文件大小,比如压缩,去掉源码中不必要的地方

总结

  • 介绍了 Chrome 开发者工具 10 个基础的面板信息
  • 剖析了网络面板,再结合之前介绍的网络请求流程来重点分析了网络面板中时间线的各个指标的含义
  • 简要分析了时间线中各项指标出现异常的可能原因,并给出了一些优化方案

如果你要去做一些实践性的项目优化,理解其背后的理论至关重要。因为理论就是一条“线”,它会把各种实践的内容“串”在一起,然后你可以围绕着这条“线”来排查问题

DOM 树:JavaScript 是如何影响 DOM 树构建的

DOM 树是怎么生成的,说下 DOM 树的解析流程

然后说下遇到 JS 脚本,DOM 解析器会怎么处理,另外还有 DOM 解析器是怎么处理跨站资源的

DOM

网络传给渲染引擎的 HTML 无法被引擎理解,要转成能理解的内部结构,就是 DOM 了

DOM 提供了 HTML 文档结构化的表述,它有三个作用

  • DOM 是生成页面的基础结构
  • DOM 提供了 JS 脚本操作的接口,JS 可以对 DOM 结构进行访问,改变文档的结构样式内容
  • DOM 是安全防护线,不安全的内容在 DOM 解析的时候会去掉

DOM 树如何生成

渲染引擎内部有个 HTML 解析器,负责把 HTML 字节流转成 DOM 结构

首先 HTML 解析器不会等等这个文档加载完毕在解析,加载了多少,HTML 就解析多少

网络接收到响应头判断 content-type 为一个 HTML 文件的时候,为该请求分配一个渲染进程,网络进程和渲染进程会有一个共享数据的通道,网络进程接收数据就往这个通道写数据,渲染进程源源不断的读数据,然后塞给 HTML 解析器

字节流是怎么转成 HTML 的呢?

一共有三个阶段

分词器把字节流转成 Token,分为 Tag Token(startTag, endTag) 和文本 Token

上面的代码会变成下图这样:

同步进行二三步骤,Token 解析为 DOM 节点,DOM 节点加到 DOM 树

HTML 解析器维护了一个 Token 栈结构,该 Token 栈主要用来计算节点之间的父子关系,在第一个阶段中生成的 Token 会被按照顺序压到这个栈

  • 如果押入栈的是 start token,HTML 解析器会为该 Token 创建一个 DOM 节点,加入到 DOM 树,父节点就是当前栈顶的 DOM 节点
  • 如果是文本 token,生成文本节点,加入到 DOM 树,文本节点不需要押入栈
  • 如果是 end token,检查栈顶元素是否是 start tag,如果是,star tag 弹出,标识解析完了

新 Token 就这样不停地压栈和出栈,整个解析过程就这样一直持续下去,直到分词器将所有字节流分词完成

我们看看下面这段代码

1
2
3
4
5
6
<html>
  <body>
    <div>1</div>
    <div>test</div>
  </body>
</html>

第一个是 start tag html,押入栈中,创建一个 html dom 节点,加入 dom 树

另外默认创建一个根为 document 的空 DOM,同时吧 start tag document 的 token 压入到栈,创建一个 html dom 节点,添加到 document 上

比如下图

然后按照相同的流程,解析 start tag body 和 start tag div

然后创建 第一个 div 的文本 token,添加到 dom,父元素就是栈顶的元节点

解析出第一个 end tag div,判断栈顶是不是 start tag div,是的话弹出

最终我们得到了下图

现实中包含了很多其他的元素,我们继续往下看

JavaScript 如何影响 DOM 生成

1
2
3
4
5
6
7
8
9
10
<html>
  <body>
    <div>1</div>
    <script>
      let div1 = document.getElementsByTagName('div')[0];
      div1.innerText = 'time.geekbang';
    </script>
    <div>test</div>
  </body>
</html>

script 标签之前,所有的解析流程还是和之前介绍的一样,但是解析到 script 标签时,渲染引擎判断这是一段脚本,此时 HTML 解析器就会暂停 DOM 的解析,因为接下来的 JavaScript 可能要修改当前已经生成的 DOM 结构

解析到 JS 的时候,如上图

这里 JS 接入,执行脚本,修改了 DOM 第一个 div 的内容

执行完以后,HTML 解析器恢复解析

如果加入的是 外部的 JS 文件

1
2
3
4
5
6
7
<html>
  <body>
    <div>1</div>
    <script type="text/javascript" src="foo.js"></script>
    <div>test</div>
  </body>
</html>

和上面的流程基本一样,但是需要先下载 JS。

需要注意下载环境,文件下载会阻塞 dom 解析,因为下载过程会阻塞 dom 解析,通常也是耗时的

不过 Chrome 做了一些优化,主要的优化手段是预解析的操作

渲染引擎收到字节码后,开启一个预解析线程,分析 HTML 文件是否有 JS CSS 等文件,解析道的话预解析线程会提前下载

我们可以用一些策略来规避这些问题,比如 cdn 加速 js 文件加载,压缩 js 体积,另外如果没有操作 dom 结构的代码可以设置为 异步加载,通过 async 或者 defer 标记

1
2
<script async type="text/javascript" src="foo.js"></script>
<script defer type="text/javascript" src="foo.js"></script>

它们有一点区别, async 的脚本一旦加载完成,就会立即执行

defer 的脚本需要在 DomContentLoaded 事件结束前执行

另外在执行 js 前,也需要先解析 js 语句之上所有的 css 样式,如果引用了外部的 css 文件,还要等待 css 文件下载完成,解析成 cssom 才可以执行

js 引擎在解析 js 之前不知道是否操作了 cssom,所以不管是否执行了 cssom,都会执行 css 文件下载,解析操作,再执行 js 脚本

所以 js 脚本依赖样式表,这又多了一个阻塞的过程

于是我们知道了 js 会阻塞 dom 生成,样式文件又会阻塞 js 执行,所以实际工程中要关注 js 文件和样式表文件,使用不当会影响页面性能

总结

额外说明,渲染引擎还有一个安全检查模块叫做 xssAuditor 用来检测词法安全,分词器解析出来 token 以后,检测这些模块是否安全,比如引用外部的脚本是否符合 csp 规范,是否存在跨站点请求

如果出现不规范的,xssAuditor 会对脚本或者下载任务进行拦截

渲染流水线:CSS 如何影响首次加载时的白屏时间?

本文站在渲染流水线的视角介绍 css 如何工作,css 的工作流程来分析性能瓶颈,最后再讨论如何减少首次加载白屏

看一段简单的代码

1
2
3
4
5
/* theme.css */
div {
  color: coral;
  background-color: black;
}
1
2
3
4
5
6
7
8
<html>
  <head>
    <link href="theme.css" rel="stylesheet" />
  </head>
  <body>
    <div>🐶 14 🐱</div>
  </body>
</html>

  • 主页面发起请求
  • 网络进程执行。请求到 html 数据后,发给渲染进程
  • 渲染进程解析 HTML 数据并构建 DOM(看到存在一定的空闲时间,可能成为渲染瓶颈)

渲染进程接收到 HTML 会先开启预解析线程,如果遇到 js 或者 css 会提前下载这些数据

  • 所以预解析线程解析出一个外部的 theme.css,发起请求
  • dom 构建结束,但是 theme.css 还没下载好,这里也有一段空闲的时间,可能成为瓶颈
  • 合成布局树,需要 cssom 和 dom,这里要等 css 加载结束解析成 cssom

渲染流水线为什么需要 cssom

浏览器也无法解决 css 文件内容,要把它解析成渲染引擎可以理解的,就是 cssom 结构

它第一个功能室给 js 操作样式表的能力,另外一个是布局树合成提供的基础信息样式

cssom 体现在 dom 中就是 document.styleSheets

有了 dom 和 cssom ,可以合成布局树。布局树基本复制 dom 树,但是不显示的元素会过滤掉,比如 display:none,head 标签,script 标签

复制好基本的布局树结构,会为 dom 元素选择对应的样式信息,这个过程就是样式计算

样式计算玩一会,开始计算布局中每个元素对应的几何位置,这个过程叫做计算布局

最终完成布局树的构建,之后就是绘制工作了

1
2
3
4
5
//theme.css
div {
  color: coral;
  background-color: black;
}
1
2
3
4
5
6
7
8
9
10
11
12
<html>
  <head>
    <link href="theme.css" rel="stylesheet" />
  </head>
  <body>
    <div>geekbang com</div>
    <script>
      console.log('time.geekbang.org');
    </script>
    <div>geekbang com</div>
  </body>
</html>

我们在中间加入了一个 js 的代码,渲染流水线就变了

如果解析 DOM 过程遇到了 js ,暂停 dom 去执行 js

如果前面又遇到了 css,先转成 cssom ,阻塞 js 的运行

上面的例子如果把 js 代码改成一个外链,整个流水线又变了

接收到 html 数据后进行预解析的过程,HTML 预解析器识别有 css 和 Js 文件需要下载,同时发起请求。后面的流水线就一样了。这里不管 css 还是 js 先到达,都要等 css 文件下载解析成 cssom 后才能执行 js 脚本

影响页面展示的因素以及优化策略

渲染流水线影响首次页面的速度,首次页面的速度影响用户体验,所以分析渲染流水线的目的就是找出一些影响首屏展示的方法,然后做出针对性的调整

从发起 url 到显示页面内容在视觉上经历了三个阶段

  • 请求发出去后,到提交数据阶段,展示的还是之前页面的内容
  • 提交数据后渲染进程创建空白页面,这段时间为解析白屏,并等待 css 文件和 js 文件加载完成,生成 cssom 和 dom,然后合成布局树,最后经过一系列的步骤准备首次渲染
  • 等首次渲染出来以后,进入完整页面的生成阶段,一点点被绘制出来

影响第一个阶段主要是服务器处理 第三阶段后面会讲述

第二阶段主要问题是白屏时间,如果太久影响用户体验

为了缩短这个时间,分析这个阶段主要的任务是解析 HTML 下载 CSS 下载 JS 生成 cssom,执行 js,绘制画面

通常瓶颈就在下载 css 和下载 js 文件和执行 js 文件。可以采取下面的策略缩短白屏时间

  • 内联 js,内联 css 移除两种类型的下载,获得 html 结构以后直接渲染
  • 不是都适合内联的,那么还可以尽量减少文件体积,比如 webpack 去掉无用的注释,压缩 js 文件
  • 不需要解析 HTMl 阶段使用的 js 标记上 sync 或者 defer
  • 大的 css 文件,通过媒体查询,拆分为多个用途的 css 文件,只会在特定场景下加载特定的 css 文件

总结

  • 介绍 CSS 在渲染流水中的位置,CSS 是如何影响渲染流程的
  • 分析发起页面后的三个阶段
  • 介绍白屏阶段和优化该阶段的策略

分层和合成机制:为什么 css 动画比 JavaScript 高效

介绍渲染引擎的分层和合成机制,它们代表着浏览器最先进的合成技术

理解他们有利于我们深刻理解 css 动画 和 js 底层机制

显示器是如何显示图片的

每个显示器有自己的刷新频率,通常是 60HZ,即每秒更新 60 张图像,更新的图像来自于显卡一个叫做前缓冲区的地方。显示器的功能很简单:每秒固定读取前缓冲区 60 张图片,并显示在屏幕上

显卡做什么的呢,它的职责是合成新的图像,并把图放在后缓冲区。一旦显卡把图像放在了后缓冲区,系统就让前缓冲区和后缓冲区互换,保证显示器能读到最新合成的图像

一般显卡更新频率和显示器一样,但是一些复杂场景显卡处理一张图片的速度变化,就会造成视觉上的卡顿

帧 vs 帧率

当滚动页面或者缩放页面的时候,屏幕上就会产生动画。之所以能感觉到动了,是因为操作的时候渲染引擎会通过渲染流水线生成新的图像,发送到显卡的后缓冲区

大多数屏幕的更新频率是 60 次/秒,为了保证流畅的动画,渲染引擎需要每秒生成 60 张图片到后缓冲区

我们把渲染流水线的每一张图片叫做一帧,每秒更新了多少帧叫做帧率。比如滚动的时候 1 秒更新了 60 张图片,叫做 60HZ(或者 60 fps)

用户很容易观察到那些丢失的帧,如果一次动画中渲染引擎生成的帧太久,用户就能感受到卡顿

那我们需要解决每帧生成太慢的问题,Chrome 为浏览器渲染做了很多的工作,其中最卓越的就是引入了分层和合成机制,我们接下来看看这两项技术

如何生成一副图像

关于任意一帧生成的方法,有重排,重绘和合成三种方式

渲染的路径越长,生成一帧的时间就越长

比如重排需要重新根据 dom 和 cssom 计算布局树,这样生成一张图像的时候,让渲染流水线的每个流程都跑一遍。布局复杂的话效率就很难保证了。

重绘没有重排的重新布局阶段,操作效率稍微高一些,但是还是需要计算绘制信息,触发绘制操作后的一系列操作

合成的路径就短了很多,如果还采用了 GPU,合成效率非常高

分层和合成

通常页面很复杂,一些细微的操作,如果没有分层的话,都要触发重排或者重绘,严重影响了渲染的效率。于是 Chrome 引入了分层合成的策略

我们可以把页面看成是很多图片叠加在一起,每个图片对应一个图层,Chrome 合成器最终把它们合成一行图片显示

把素材分为多个层的技术叫做分层,把图片合并在一起的技术叫做合成

考虑一下划分为两层,当渲染下一帧的时候,上面的一帧进行了某些变化,比如旋转,缩放,阴影和 alpha 渐变,合成器只要将两个层进行相应的变化操作就好了

Chrome 的流水线中分层表现在生成布局树之后,根据布局树的特点转为分层树,分层树是后续流水线的基础

分层树中每个节点代表一图层,下一步的绘制阶段依赖于它。但是绘制不是真的绘制出图片,它是一系列的绘制指令列表

有了绘制指令列表以后,进入光栅化阶段。光栅化指的是按照绘制指令生成图片。每个图层对应一个图片。

合成线程有了这些图片以后,把它们合成一张图片,并把它发送到后缓冲区。

合成操作是在合成线程上执行的,不会影响主线程,这也是为什么经常主线程卡住的时候 css 还是能正常执行动画

分块

如果分层从宏观上提升了渲染的效率,那么分块从微观上提升了渲染的效率

一般页面内容会比屏幕大,显示一个页面如果等所有的图层都生成完毕,在进行合成的话,会产生不必要的开销,以及生成时间很长

合成图层会把每个图层分割为固定大小的块,优先绘制靠近视窗的图块,大大加快了页面显示出来的效率

不过有时候绘制优先级最高的图块也耗费不少时间,因为涉及到一个关键的因素 - 纹理上传,从计算机内存上传给 GPU 的内存操作比较慢

于是 Chrome 又采取了一种策略:合成图块的时候先使用低分辨率的图片,比如正常分辨率的一半,分辨率减少了一半,纹理也减少了 3/4。

在首次显示图片的时候先把这些低分辨率的图片显示出来,合成器会继续绘制正常分辨率的图像,正常分辨率的图片绘制完成以后,替换掉低分辨率的图片。这样比一开始什么都看不到好很多

如何利用分层技术优化代码

有时候需要对页面的某些元素进行几何变换,透明度变化,缩放操作。如果用 js 来写的话会牵扯到整个流水线的变化,绘制效率非常低

我们可以给它加上下面的属性:

1
2
3
.box {
  will-change: transform, opacity;
}

这是告诉浏览器 box 将要做几何变化和透明度变化,这个时候浏览器会为这个元素单独实现一帧,等发生变化的时候,渲染引擎会告诉合成器直接去处理变化,这些变化没有涉及到主线程,大大提高了渲染效率。这也是 css 比 js 动画高效的原因

所以我们可以多使用 will-change 告诉渲染引擎为它准备独立的图层。不过这样也会增大内存的开销,所以需要适度的使用

总结

  • 介绍了图像的原理,以及帧和帧率的概念
  • 基于帧和帧率介绍了渲染引擎如何实现一帧图像
  • 渲染生成一帧图像有三种方式:重排,重绘,合成
  • 重排,重绘都在主线程上进行,比较耗时,影响主线程效率;合成操作在合成线程上进行,执行快,不占用主线程
  • 浏览器合成的技术细节:分层,分块,合成
  • css 比 js 高效的原因,以及使用 will-change 来优化动画效果

页面性能:如何系统优化页面

分析页面生命周期的不同阶段来进行优化

  • 加载阶段:发出请求到渲染出完整页面的过程,影响这里主要是网络和 js 脚本
  • 交互阶段:页面加载完成到用户交互的过长,主要影响的是 js 脚本
  • 关闭阶段:用户发起关闭页面后的清理工作

接下来主要关注加载阶段和交互阶段

加载阶段

并非所有的资源都会阻塞页面首次绘制,比如图片,视频,音频

而 js,请求首页的 html 和 css 是会阻塞首次绘制的,因为构建 dom 的时候需要 js 和 html,构造渲染树的时候需要 css

我们把能阻塞首页绘制的资源叫做关键资源,我们可以细化出影响首屏的核心因素

  • 关键资源的个数,关键资源个数越多,首次加载的时间就会越长
  • 关键资源的大小,关键资源越小,下载越快阻塞渲染的时间也越短了
  • 关注请求资源需要多少 rtt(round trip time),TCP 传输一个文件的时候,比如文件大小是 0.1M,由于 TCP 的特性,这个属性不是一次性传输到服务器的,而是拆分成数据包来回多次传输的。RTT 就是这里的往返时延,它也是网络的一个重要性能指标,指从发送方发送数据开始,到发送端接收到服务端的确认消息结束,经历的时间。通常一个 http 包在 14kb 左右,所以 1 个 0.1M 的文件需要拆分 8 个数据包传输,就是需要 8 个 RTT

接下来我们要系统考虑优化的方案了

  • 减少资源的个数,一般把 css 和 js 改成内联的方式,另外一种是如果 js 没有操作 dom 或者 cssom,把它们改成 defer 或者 async;同样对于 css,可以使用媒体查询避免不必要的样式预先加载,这样它们就不是关键资源了
  • 减少关键资源的大小,压缩 css js 资源,移除它们里面的注释
  • 降低关键资源的 RTT:减小体积和数量相结合,也可以配合 CDN

我们可以画出关键资源图表,按照上面的方案去优化,然后画出优化后的关键资源图案

交互阶段

这里主要是渲染进程渲染帧的速度,帧的速度决定了画面的流畅度

我们看看渲染流水线

通常是 js 引起的动画变化

回顾交互阶段是怎么生成一个帧的,一般是 js 修改 dom 或者 cssom 触发,还有一部分是 css 触发的

如果计算样式阶段发现布局变化了,触发了重排,然后触发后续一系列的渲染操作,代价非常高

计算样式阶段发现只有颜色一类的改变,那么跳过布局阶段,进入绘制阶段,这个过程叫做重绘,重绘代价也是不小的

css 实现的变形,渐变,动画特效,是 css 触发的,在合成线程上进行,不触发重绘重排,而且合成的速度非常快,效率最高

那我们的优化方案就是快速生成帧

减少 js 的执行时间

如果一个 js 脚本执行时间太长,霸占主线程执行其他渲染工作,我们可以有以下的优化方案

  • 把一个函数的执行分为多个任务,每次执行的时间不要太长
  • 采用 web worker,它是主线程外的一个线程,可以在里面执行 js 脚本

避免强制同步

通常添加元素和删除元素是需要重新计算样式布局的,不过正常情况下是异步执行的,避免占用主线程太长的时间。

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<html>
  <body>
    <div id="mian_div">
      <li id="time_li">time</li>
      <li>geekbang</li>
    </div>

    <p id="demo">强制布局 demo</p>
    <button onclick="foo()">添加新元素</button>

    <script>
      function foo() {
        let main_div = document.getElementById('mian_div');
        let new_node = document.createElement('li');
        let textnode = document.createTextNode('time.geekbang');
        new_node.appendChild(textnode);
        document.getElementById('mian_div').appendChild(new_node);
      }
    </script>
  </body>
</html>

我们通过工具查看:

执行添加元素是在新的任务中执行的,而重新计算布局和样式是在另外一个任务中执行的

然后我们看看怎么是强制同步布局:它是强制把执行计算布局和样式的任务提前到当前的任务了

我们改成这样:

1
2
3
4
5
6
7
8
9
10
11
function foo() {
  let main_div = document.getElementById('mian_div');
  let new_node = document.createElement('li');
  let textnode = document.createTextNode('time.geekbang');
  new_node.appendChild(textnode);
  document.getElementById('mian_div').appendChild(new_node);
  // 由于要获取到 offsetHeight,
  // 但是此时的 offsetHeight 还是老的数据,
  // 所以需要立即执行布局操作
  console.log(main_div.offsetHeight);
}

要获得 offsetHeight 需要重新布局,强制渲染引擎进行一次布局的操作,如下图

计算样式和布局在当前的任务中执行

为了避免这种情况,我们可以先计算查询相关的值

1
2
3
4
5
6
7
8
9
function foo() {
  let main_div = document.getElementById('mian_div');
  // 为了避免强制同步布局,在修改 DOM 之前查询相关值
  console.log(main_div.offsetHeight);
  let new_node = document.createElement('li');
  let textnode = document.createTextNode('time.geekbang');
  new_node.appendChild(textnode);
  document.getElementById('mian_div').appendChild(new_node);
}

避免抖动

布局抖动指的是多次布局和抖动的操作,比如:

1
2
3
4
5
6
7
8
9
10
11
function foo() {
  let time_li = document.getElementById('time_li');
  for (let i = 0; i < 100; i++) {
    let main_div = document.getElementById('mian_div');
    let new_node = document.createElement('li');
    let textnode = document.createTextNode('time.geekbang');
    new_node.appendChild(textnode);
    new_node.offsetHeight = time_li.offsetHeight;
    document.getElementById('mian_div').appendChild(new_node);
  }
}

每次读取属性值前进行计算样式和布局

在 foo 内部重复计算样式和布局,大大影响了执行的效率。优化的方案就是修改 dom 的时候不要去查询一些样式值

合理利用 css 动画

尽量使用 css 动画,借助合成线程的高效,并且通过属性 will-change 告诉浏览器

避免频繁的垃圾回收

如果函数频繁创建临时对象,垃圾回收就要频繁执行,它会占用主线程,影响其他任务的执行,造成卡顿

如果优化?调整数据结构,尽量少使用小颗粒度的数据

总结

如何优化加载阶段和交互阶段

  • 加载阶段:优化关键资源的加载速度,减少关键资源的数量,降低关键资源的 rtt 次数
  • 交互阶段:减少一帧的生成时间,比如减少单次 js 执行的时间避免强制布局同步,避免布局抖动,尽量使用 css 合成动画,避免频繁的垃圾回收

虚拟 DOM:虚拟 DOM 和实际 DOM 有何不同

聊聊 DOM 的一些缺陷,然后虚拟 DOM 是如何解决的,然后站在 双缓存和 MVC 框架的视角上聊聊 虚拟 DOM,更好理解这些框架

DOM 的缺陷

调用 document.body.appendChild(node) 往 body 节点上添加一个元素,会引起一系列的连锁反应,触发了样式计算,布局,绘制,光栅化,合成等任务,这一系列叫做重排,牵一发而动全身。

DOM 的不当操作也可能引起强制同步布局和布局抖动,降低渲染效率

对于复杂的单页面,一般 DOM 结构很复杂,重排重绘的代价非常高,带来性能问题

所以我们需要减少 DOM 的操作,虚拟 DOM 上场了

什么是虚拟 DOM

我们看下它主要解决的问题

  • 把 DOM 的变化应用到虚拟 DOM 上,不直接作用到 DOM 上
  • 虚拟 DOM 收到这些变化并不急着马上渲染页面,仅仅调整了内部的状态,这样操作虚拟 DOM 的代价很低
  • 虚拟 DOM 收集到足够的变化,一次性应用到真实的 DOM 上

上图是虚拟 DOM 的执行流程

  • 创建阶段: 根据 jsx 和基础数据创建出虚拟 DOM,反映了真实 DOM 的结构,然后渲染出真实的 DOM 结构,触发渲染进程渲染页面
  • 更新阶段:如果数据变化了,根据新的数据创建一个新的虚拟 DOM,然后比较两个树,找出变化的地方,把变化的地方一次性更新到真实的 DOM 上,渲染引擎更新渲染流水线,生成新页面

我们关注下比较过程,一开始比较两个虚拟 DOM 是在一个递归函数里面执行的,核心算法是 reconciliation,通常这个算法执行很快,但是 DOM 比较复杂的时候,可能占用主线程的时间很长,导致卡顿,于是 React 改写了这个算法,新的叫做 Fiber reconciler

协程在其他章节介绍了,另外一个称呼就是 Fiber,所谓 Fiber reconciler 就是执行算法的时候让出主线程,解决了原来占用主线程过长的问题

接下来我们从双缓存和 MVC 框架看看虚拟 DOM

双缓存

开发游戏或者处理其他图像的时候,屏幕从前缓冲区读取数据然后展示。但是很多图像的计算都非常复杂,需要大量的计算,比如一个完整的图像需要多次的计算才能出结果,如果完成一部分的画面就写入到前缓冲区,那么稍微复杂的页面就会看到是一部分一部分展示出来的,让用户感觉整个页面在闪烁

使用双缓存,先把中间计算的结果放到另外一个缓冲区,等计算全部完成了以后,这个缓冲区已经有了完整的图像数据,一次性复制到前缓冲区,整个页面的输出效果就很稳定

我们可以把虚拟 DOM 看成是一个 buffer,和图像显示一样,在完成了一次完整的操作以后才统一应用到 DOM 上,避免不必要的更新,保证 DOM 的稳定输出

MVC 模式

看看虚拟 DOM 在 MVC 模式里面扮演的角色

MVC 把数据和图像分离开,涉及到复杂的项目的时候,大大减少了项目的耦合度,使程序易于维护

它有控制器,模型和视图组成,核心想法就是数据和视图的分离,它们之间不能直接通信,需要控制器来完成

一般视图发生了变化,通知控制器,控制器根据实际情况决定是否更新模型数据

根据不同的通信路径和控制器又分化出很多其他的框架,比如 MVVM,MVP,但是它们都是 MVC 而来的

我们把 React 看成是 MVC 中的视图,结合 Redux 就构建了一个 MVC 的框架类型

在上图我们把 虚拟 DOM 看成是 MVC 的视图,控制器和数据都是 Redux 实现的

  • 控制器监控 DOM 的变化,一旦 DOM 变化了,通知模型更新数据
  • 数据更新完以后,控制器通知视图模型发生变化了
  • 视图接收到变化消息,根据新的模型生成新的虚拟 DOM
  • 新的虚拟 DOM 和原来的虚拟 DOM 进行比较,找出变化的节点
  • React 把变化的虚拟节点应用到 DOM 上,触发了 DOM 节点的变化
  • DOM 的变化引起了后续一系列的渲染流水线的发生,最终页面得到了更新

总结

  • 分析直接操作 DOM 会引起一系列的操作,操作不当比如强制布局同步和布局抖动问题
  • 介绍虚拟 DOM 是怎么解决这些问题的的,以及 React Fiber 的更新机制
  • 从双缓存和 MVC 角度分析了虚拟 DOM。双缓存是经典的思路,应用在很多的场景,减少了页面的无效刷新和闪烁问题,虚拟 DOM 就是双缓存的一种体现
  • MVC 框架也广泛应用到很多场景,衍生了很多其他的 MVP,MVVM 框架,不过都是基于 MVC 的。基于 MVC 框架分析虚拟 DOM,能更好地理解

PWA:解决了 web 应用哪些问题

PWA,渐进式网页应用,progressive web app,渐进式可以从两个方面去理解

  • 对于 web 开发者来说,PWA 提供了渐进式的过渡方案,让普通的 web 网页逐步过渡到 web 应用。采用渐进式可以降低改造网站的成本,逐步应用新技术,而不是一步到位
  • PWA 也是一种渐进式的演化过程,在技术层面一点点演进,比如提供更好的设备特性支持,不断优化更加流畅的动画效果,不断让页面加载速度越来越快,不断实现本地应用的特性

采取一个缓和的策略,充分发挥 web 的优点,慢慢缩短和应用,小程序的差距

web 最大的优势是什么?自由开放,一套代码可以运行在多个设备上,这就是跨平台,也恰好是本地应用不具备的

PWA 是一套理念,渐进增强 web 的优势,通过各种手短缩短和应用,小程序的差距,基于这套理念的都可以叫做 PWA 技术

Web 应用 vs 本地应用

  • web 应用缺少离线使用的能力,离线和弱网下基本无法使用,用户需要的是沉浸式体验,离线或者弱网下的流畅使用是用户对应用的基本需求
  • web 应用缺少消息推送,作为 App 厂商,需要有能力推送消息到应用上
  • 缺少一级入口,也就是把 web 安装到桌面的能力,我们需要的是能在桌面上直接打开 web 应用,而不是每次通过浏览器打开使用

PWA 提出了两项解决上面问题的技术: service worker 试着解决离线缓存和消息推送的能力,引入 manifest.json 解决一级入口的问题

什么是 service worker

service worker 的主要理念是在页面和网络之间增加一个拦截器,用来缓存和拦截请求

没有 service worker 之前,webapp 直接通过网络请求来获取资源,现在会先通过 sw 判断是返回 sw 的缓存资源还是去进行网络请求,一切控制权交给 sw 去做

sw 的设计思路

sw 主要功能就是拦截请求和缓存资源

架构

通过页面循环系统的分析我们已经知道了渲染流水线是在主线程上进行的,如果一段 js 执行时间过长,阻塞主线程,使得一帧生成时间变长,让用户产生卡顿的感觉,用户体验是非常不好的

为了避免 js 占据主线程时间过长,浏览器实现了 web worker 的功能,目的是让 js 能够运行在页面的主线程之外,不过 worker 是无法访问 DOM 的,只能执行与 DOM 无关的脚本,通过 postMessage 把执行的结果返回给主线程。所以 web worker 是在主线程之外开了一个线程,生命周期与页面相关

让其运行在主线程之外,是 sw 来自 web worker 的主要核心思想,不过 web worker 是临时的,执行完脚本就会退出,执行结果也不能保存下来,如果下次还要操作,又要重新执行一遍。所以 sw 是在 web worker 上面加了存储功能

sw 也需要为多个页面服务,不能和单个页面绑定。目前的架构 sw 是运行在浏览器进程上的,因为浏览器进程的生命周期最长,所以在浏览器生命周期内,sw 都可以提供服务

消息推送

也是基于 sw 实现的,消息推送的时候页面没有启动,需要 sw 来接受服务器的推送消息,并把消息通过一定的方式通知用户。

安全

HTTP 是明文传输的,存在被窃听,篡改和劫持的风险,所以一开始设计 sw 就要求在 https 上运行,https 上的通信消息都需要经过加密,即使被获取了也无法解开内容,而且也有验证机制,被篡改了通信双方也很容易知道。

总结

  • PWA 是很多技术组成的理念,核心思想是渐进式的,提供温和的方法,让开发者逐步把站点过渡到 web 应用
  • 技术本身它也是渐进式的,逐步把 web 技术发挥到极至的同时,缩小和本地应用的差距。
  • manifest.json 配置文件,可以让开发者自定义图标,显示的名称,启动信息,启动画面,页面主题等
  • 添加桌面图标,增加离线缓存,增加消息推送等是 PWA 走向设备的必备功能,但是决定 PWA 是否崛起的还是底层的技术,比如渲染的效率,对设备的支持程度,WebAssembly 等,而这些技术也是在渐进式发展中

webComponent:像搭积木一样构建 web 应用

什么是组件化?

对内高内聚,对外低耦合。对内各个元素彼此紧密结合,互相依赖,对外和其他组件联系最少且最简单

稍微复杂的项目就涉及到很多组件,多人协同合作需要每个组件的负责人尽可能单独完成一项功能,内部的状态不能影响到别人,需要交互的地方与其他人的组件预先定义好接口。降低整个系统的耦合度,降低程序员之间的沟通成本,让系统易于维护

使用组件化带来很多优势,很多语言也天生支持,比如 c/c++ 很好的把功能封装成模块,无论是业务逻辑还是基础功能,或者 UI,都能很好地结合在一起,实现高内聚和低耦合

大部分语言都可以实现组件化,归根到底是利用语言的特性,大多数语言都有自己的块级作用域,函数作用域,可以把内部状态应隐藏在作用域或者对象之内,外部就无法访问了,然后约定好接口进行通信

js 虽然有不少缺点,不过作为编程语言也可以很好的支持组件化,因为有自己的函数作用域和块级作用域,封装内部状态和提供外部接口是没有问题的

阻碍前端组件化的因素

HTML、CSS 和 JavaScript 是强大的开发语言,但是在大型系统维护起来比较困难,因为如果嵌入了第三方内容,还要保证第三方内容不会影响当前的内容,也要确保当前的 DOM 不会影响第三方内容

所以要说 web component 先看看 html css 是如何阻碍前端组件化的。我们看看下面的例子:

1
2
3
4
5
6
7
<style>
  p {
    background-color: brown;
    color: cornsilk;
  }
</style>
<p>🐱</p>
1
2
3
4
5
6
<style>
 p {
  background-color: red;
  color: blue
}
<p>🐶</p>

上面都实现了 p 标签的样式,单独开发没问题,但是整合在一起的时候,css 属性会影响到外部其他的 p 标签,因为 css 是全局的

渲染引擎会把所有的 css 解析成 cssom,布局树的时候会把所有的 cssom 作用在 dom 上,所以两个相同标签最终渲染出来的样式是一样的

阻碍组件化的还有 dom,任何地方都可以直接修改和读取 DOM。所以 js 组件化是没问题的,但是 js 一旦遇上了 css html 那就不好办了

WebComponent 组件化开发

WebComponent 给出了解决的思路,提供了局部的视图封装能力,让 dom css js 运行在局部环境中,那么这里的 dom css 也不会影响全局了

WebComponent 是一套技术的组合,涉及到了 custom element(自定义元素),shadow DOM(影子 DOM) 和 HTML templates(HTML 模板)

我们通过下面的例子看看是怎么封装的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
<!DOCTYPE html>
<html>
  <body>
    <!--
            一:定义模板
            二:定义内部 CSS 样式
            三:定义 JavaScript 行为
    -->
    <template id="geekbang-t">
      <style>
        p {
          background-color: brown;
          color: cornsilk;
        }

        div {
          width: 200px;
          background-color: bisque;
          border: 3px solid chocolate;
          border-radius: 10px;
        }
      </style>
      <div>
        <p>time.geekbang.org</p>
        <p>time1.geekbang.org</p>
      </div>
      <script>
        function foo() {
          console.log('inner log');
        }
      </script>
    </template>
    <script>
      class GeekBang extends HTMLElement {
        constructor() {
          super();
          // 获取组件模板
          const content = document.querySelector('#geekbang-t').content;
          // 创建影子 DOM 节点
          const shadowDOM = this.attachShadow({ mode: 'open' });
          // 将模板添加到影子 DOM 上
          shadowDOM.appendChild(content.cloneNode(true));
        }
      }
      customElements.define('geek-bang', GeekBang);
    </script>

    <geek-bang></geek-bang>
    <div>
      <p>time.geekbang.org</p>
      <p>time1.geekbang.org</p>
    </div>
    <geek-bang></geek-bang>
  </body>
</html>

它的三个主要步骤是:

  • 使用 template 来创建模板,利用 DOM 可以查找到模板的内容,但是模板内容不会渲染到页面上,也就是说他不会出现在布局树上,所以我们可以用来自定义一些基础的元素结构,而且这些元素结构是可以重复利用的,模板定义好了以后我们也会加上样式信息
  • 我们需要创建一个 GeekBang 类,完成三件事情

    • 查找模板内容
    • 创建影子 DOM
    • 把模板添加到影子 DOM 上

    影子 DOM 其实作用就是把模板中的内容和全局的 DOM 和 CSS 隔离,我们就可以实现元素和样式的私有化了,可以把影子 DOM 看成是一个作用域,内部的样式和元素不会影响全局的样式和元素。全局环境如果想要访问影子 DOM 内部的样式和元素需要约定好接口

    所以通过影子 DOM,实现了样式和元素的封装,创建好封装影子 DOM 的类之后,可以通过 customElements.define 自定义元素了

最终上面创建出来的如下图:

我们也可以发现影子 DOM 里面的样式不会影响全局的 cssom,使用 dom api 也无法访问到内部的元素

想要访问影子 dom 里面的元素需要提供专门的接口

不过影子 dom 里面的 js 没有隔离,比如影子 dom 里面的 js 函数还是可以被外部调用,因为 js 本身就很好地完成了组件化了

浏览器如何实现影子 DOM

影子 DOM 主要的两个特点:

  • 里面的元素对于全局网页是不可见的
  • CSS 不会影响全局的 CSSOM,只对内部生效

浏览器是如何实现影子 DOM 的呢?

上图我们使用了两次 geek-bang,就生成了两个影子 DOM,每个影子 DOM 都有一个 shadow root 的根节点,我们把要展示的元素和样式添加到了影子 DOM 的根节点上

每个影子 DOM 可以看成独立的 DOM,有自己的样式和属性,内部样式不会影响外部,外部样式也不会影响内部

浏览器为了实现影子 DOM,内部做了很多判断,比如 DOM 查询的时候会去判断是否为影子 DOM,是的话直接跳过,也会判断是否是影子 DOM,是的话直接使用内部的样式

总结

  • 组件化是程序员的刚需,所谓组件化就是功能模块实现高内聚,低耦合的特性
  • 由于 HTML CSS 是全局的,影响前端组件化的主要原因
  • webComponent 包含了自定义元素,影子 DOM 和 HTML 模块,使得开发者可以隔离 CSS HTML
  • 还介绍了影子 DOM 的实现