# 性能优化总结

# 什么是 Web 性能优化

前端性能优化的目的: 让用户访问网站开始到页面完整展示出来的过程中, 通过各种优化策略和优化方法, 让页面加载的更快, 让用户操作响应更加及时, 给用户带来更好的用户体验

# 为什么需要性能优化

研究表明, 网页性能差直接加速产品的衰败, 也影响网站收入(广告), 因此我们需要提升 Web 性能从而提升用户体验, 公司营收等

# 优化步骤

以下笔者来详述所有常见的优化手段

# 一、图片优化

# 图片分类

图片格式众多, 附上图片分类脑图 (opens new window)

图片总结

# 图片格式选取

JPEG(JPG/JPE): 有损压缩格式, 不支持透明度, 体积占用不大, 颜色细节质量不高, 颜色丰富, 通常网页大图(bannber / 轮播)等需要使用

PNG(PNG-8/PNG24): 无损压缩格式, 体积占用大, 细节表现好, 通常用于图标 / LOGO 等

GIF: 不支持半透明, 支持全透明, 通常用于动画图标

WEBP: Google 开源的图像格式, 无损的 WEBP 比 PNG 小 26%, 有损 WEBP 比 JPEG 小 25-34%, 比 GIF 有更好的动画, 但是兼容性不好, 需要做 Hack 处理

# 图片压缩

在实际的使用中, 必须对图片进行压缩, 常用以下工具进行压缩, 可以在本地压缩后上传至 CDN, 也可以在Node服务端使用在线处理:

  • 在线压缩 TinyPng(TinyJpg)
  • JPG 压缩工具: Jpegtran
  • PNG 压缩工具: node-pngquant-native
  • GIF 压缩工具: Gifsicle

# 响应式图片

不同的网络环境, 应该加载不同尺寸和像素的图片, 通过请求不同的 URL 参数

httP://img.xxx.com/images/q100x100/c2exas.... 对应的图片是 100x100

实现方式:

  1. 通过 JS 读取窗口大小, 选择合适的图片

  2. 通过媒体查询

@media screen and (max-width: 640px) {
  .img_640 {
    width: 640px;
  }
}
  1. 通过 H5 的新属性srcset
<img srcset="img-320w" />

# 逐步加载图片

  • 使用统一占位符

  • 使用LQIP(低质量图像占位符)

    安装: npm install lqip, 使用lqip-loader来引入

  • 使用SQIP(基于 SVG 的图像占位符)

    安装: npm install sqip

# 图片降级方案

  • Web Font 代替图片
  • 使用 Data URI 代替图片
  • 采用雪碧图(image spriting)

# 二、HTML 优化

  • 减少 HTML 的嵌套, 减少 DOM 的节点数

  • 压缩 HTML,删除不必要的字符

可以使用构建工具的插件html-webpack-plugin

  • HTM 的结构优化

CSS样式尽量放页面的头部, JS引用放在HTML底部

CSS 加载不会阻塞 DOM Tree 的解析, 但是会阻塞 DOM Tree 的渲染, 也会阻塞后面 JS 的执行。因此 body 元素之前, 可以确保在文档中解析了所有 CSS 样式, 从而减少了浏览器必须重排文档的次数。如果放在底部, 就需要等待最后一个 css 文件下载完成, 出现白屏,影响用户体验

JS 放在底部是防止加载、解析、执行对阻塞页面后续元素的正常渲染

  • 设置 favicon.ico

网站不设置 favicon.ico,控制台会报错,设置的优点是更便于用户对品牌的记忆

  • 增加网页的骨架屏

# 三、CSS 优化

  • 避免使用通配符和类正则属性选择器
  • 避免使用类的多层级和装饰写法: div#elem.view ul li span{}
  • 避免使用占用过多 CPU 和内存的属性: text-indent: -9999px
  • 关注可继承的 CSS 属性, 避免重复定义相同的属性
  • 避免使用 table 布局/float 布局, 一个 td 会导致整个回流
  • 使用外链 css(CDN 部署),避免使用@import(阻塞 css 文件加载)
  • CSS 文件压缩
  • 字体部署在 CDN, 或者将字体以 base64 保存在 css 中并通过 localstorage 缓存
  • Google 使用国内托管
  • CSS 复杂动画应该尽量将该元素脱离文档流, 否则会引起元素以后的所有元素频繁的回流
  • 合理开启 GPU 加速(opacity/will-change/transform/filters), 过多的使用会导致内存占用大, 抗锯齿无效

# 四、JS 优化

# JS 代码优化

  • JS 文件放在<body>底部
  • 使用节流和防抖
  • 使用事件委托
  • 避免使用 eval, 太耗性能
  • 避免函数嵌套定义, 会导致多次预编译
  • JS 函数的参数类型尽量一致,V8 会调用 turboFan 进行机器码编译优化

# JS 的动画优化

  • 避免添加大量 JS 动画
  • 使用requestAnimationFrame代替setTimeoutsetInterval

requestAnimationFrame告诉浏览器在下次重绘前执行, 而setTimeoutsetInterval无法保证回调的执行时机

  • 动画最好使用 canvas
  • 尽量使用 CSS3 动画方案

# JS 对 DOM 的操作优化

  • 防止频繁的操作 DOM, 尽量批量化操作
  • 将 DOM 离线再进行大量操作
  • 避免触发同步布局事件

(offset|client|scroll)(Top|Left|Width|Height 的获取都应该缓存起来

# 五、Webpack 优化

  • 依赖包优化(选用相同功能的小库)
  • 缩小文件查询时间: (resolve.extension / resolve.mainFields / resolve.modules / resolve.alias)
  • Loader 优化(babel 的 cacheDirectory:true / include / exclude / module.noParse)
  • HappyPack 多进程打包 / hardSourceWebpackPlugin 设置中间模块缓存 / TerserWebpackPlugin / ingorePlugin / Dll 动态链接库 & DllReference
  • TreeShaking / Scope hosting
  • 压缩 CSS(optimize-css-assets-webpack-plugin / mini-css-extract-plugin)
  • 分包按需加载(splitChunks.cacheGroup)
  • long term cache (固化 js 的 chunkhash / 固化 css 的 contenthash / 固化 chunkId(optimize.chunkIds: 'hashd') / 固化 moduleIds(optimize.moduleIds:'name') / 按需加载模块使用魔法字符串'webpackChunkName' / 提取 webpack 的 runtime 代码)

# 六、使用缓存

  • memory cache
  • service worker
  • disk cache
  • push cache(一种存在于会话阶段的缓存,当 session 终止时,缓存也随之释放, 同一个 h2 连接可以共享)

# 七、浏览器的渲染过程

  1. 浏览器解析 HTML,生成 DOM Tree
  2. 浏览器解析 CSS, 生成 CSSOM Tree
  3. 浏览器将 DOM Tree 和 CSSOM Tree 合成渲染树
  4. 布局: 根据生成的 Render Tree, 进行回流, 计算出每个节点的几何位置
  5. 绘制: 根据渲染树和回流得到几何信息,得到每个节点的绝对像素,并生成图层
  6. CPU 将默认图层和复合图层输入到 GPU 进行合成, 最终的到了页面并展示

# 八、渲染优化

  • 服务端渲染

    包括后端同步渲染、同构直出、BigPipe

  • 客户端渲染

    JS 渲染:静态化、前后端分离、单页面应用 WebApp: React、 Vue、Angular、PWA 原生 APP: IOS、Android HybridApp: PhoneGap、Appcan 跨平台开发: RN、Flutter、 小程序

# 预渲染

同构方案集合 CSR 与 SSR 的优点,可以适用于大部分业务场景。但由于在同构的系统架构中,连接前后端的 Node 中间层处于核心链路,系统可用性的瓶颈就依赖于 Node ,一旦作为短板的 Node 挂了,整个服务都不可用

一般的场景,使用预渲染即可, 使用 webpack 插件prerender-spa-plugin

缺点:

  1. 预渲染只是快照页面, 不适合频繁变动页面
  2. 设置路由多, 构建时间增长

# 同构直出

降低首屏渲染时间, 利于 SEO, 直接上线 2 个版本, 利于灾备

  • next.js 服务端渲染 React 组件框架
  • gatsbyjs: 服务端 React 渲染框架
  • nuxt.js 服务端渲染 Vue 框架

# 关于渲染的技术选型

  • 依赖业务形式: 根据业务情况, 选择最佳的业务方案

  • 依赖团队规模: 创业初期选择同步直出 JSP, 后面团队变大可以使用同构直出Node server, 富余人力用 PWA 等等

  • 依赖技术水平: 适合公司的技术水平, 选择合适的技术方案

# 九、加载优化

  1. 懒加载

对长网页延迟加载特定元素(图片、JS/CSS),也可以是 JS 的特定函数和方法,优点是减少当前屏无效资源的加载

  1. 预加载

让浏览器预加载某些资源(图片、js、css、模板),提前加载到本地,后面使用直接从缓存中获取,优点是减少用户后续加载资源的等待时间

方式一:

<img src="https://xxxx" style="display:none" />

方式二:

const img = new Image()
img.src = 'https://xxxx'

方式三:

<!-- 当前页需要的资源 as最高优先级,没有as被看做异步请求 -->
<link rel="preload" href="style.css" as="style" />
<!-- 其他页需要的资源 -->
<link rel="prefetch" href="image.png" />
<!-- 预解析跨域的DNS,避免用来解析当前站 -->
<link rel="dns-prefetch" href="https://xxx.com" />
<!-- 预先建立与服务器的连接 -->
<link rel="preconnect" href="https://xxx.com" crossorigin />
  1. 预渲染

优点:

懒加载组件出来之前, 用户需要时间等待完成; 还有一种预加载的方式是提前渲染, 渲染好后隐藏起来, 用的时候直接展示

实现方式:

<link rel="prerender" href="https://xxx.com" />
  1. 按需加载

可以分为常规按需加载(js 或者其他脚本)、不同 App 按需加载(js-sdk 脚本)、 不同设备按需加载(pc 和 h5 样式)、不同分辨率按需加载(css 媒体查询)

# 十、接口优化

  1. 接口合并

一个页面很多业务接口和依赖的第三方接口统一起来,在部署在集群的接口上统一调用,减少页面请求

  1. 接口上 CDN

这是基于接口的性能考虑,把不需要实时更新的接口同步到 CDN,等接口内容变更之后自动同步到 CDN 集群上。如果一定时间内未请求到数据,回源站接口请求

  1. 接口域名上 CDN

增强可用性,稳定性

  1. 接口降级

电商大促中,核心接口进行降级备用基础接口进行业务实现。例如推荐接口,大促可以直接用运营的编辑数据。防止接口无法使用时,备用垫底备份数据

  1. 接口监控

不是指服务端的TP99,是指用户实际情况成功和失败的情况, 包括弱网、超时、网络异常、网络切换等

  1. 接口缓存

包括 ajax 缓存、本地缓存(localstorage)、重发请求(网络切换)

# 十一、WebView 优化

IOS 的 webveiw 分为UIWebviewWKWebview, 后者性能更优,内存占用较前者低,加载速度快,可以直接与 JS 互调函数, 而UIWebview需要第三方库来完善; WKWebview的缺点是不支持自动注入cookie,不支持 POST 参数

Android 的系统 webview 分为Webkit Webviewchromium Webview(更优秀), 第三方的 webview 主要有X5内核,速度更快, 兼容性更好, 国内各种手机厂商的碎片化支持更好, 视频播放更加强大

因此 IOS 选用WKWebview和 Android 使用X5内核

  1. 使用全局 Webview 优化

APP 启动, 默认不初始化浏览器内核, 当创建实例时才启动内核, 大概有 70-700ms 延迟。客户端刚启动就初始化全局 webview, 需要使用时,直接加载内容;但是额外会消耗一些内存

  1. URL 预加载

准备和请求页面同步进行,URL load 和动画并行加载

  1. 滚动条使用体验

模拟 WIFI 下页面加载过程, 让用户感觉变快

  1. JS-SDK 的优化

一般来说, 常用有三种方式可以调用 nativeApi, 包括上下文注入弹窗拦截URL Scheme

现在可直接使用webkit直接调用

  1. H5 离线包方案

离线包

首先加载全局包->判断本地是否安装->如果安装了直接解包到内存->如果未安装去线上比对后下载再解包到内存->webview 加载资源时候直接读取内存中的数据->内存中存在直接返回->否则去线上地址取

# 十一、混合式 APP(RN/Weex)

  1. RN 的实现方式

混合式App

底层架构

技术选型: React 技术全家桶可以选用 RN

  1. Flutter 的实现方式

底层架构

底层比较)

学习曲线: 相对比 RN 高,重新学习 Dart 语言

性能: Native 性能最好,直接和 Skia(C/C++) 引擎通信,没有 JS Bridge

选型建议: 考虑性能, 业务面向多终端, APP 团队人员多

# 十二、CDN 优化

# 优点

避免 Ddos 攻击、高可用性处理高流量和负载、节省流量

# HTTP 请求流程说明

1、用户在浏览器输入要访问的网站域名,向本地 DNS 发起域名解析请求。

2、域名解析的请求被发往网站授权 DNS 服务器。

3、网站 DNS 服务器解析发现域名已经 CNAME 到了 <www.example.com.c.cdnhwc1.com。>

4、请求被指向 CDN 服务。

5、CDN 对域名进行智能解析,将响应速度最快的 CDN 节点 IP 地址返回给本地 DNS。

6、用户获取响应速度最快的 CDN 节点 IP 地址。

7、浏览器在得到速度最快节点的 IP 地址以后,向 CDN 节点发出访问请求。

8、CDN 节点将用户所需资源返回给用户。

# CDN 缓存

常用建议:

  • HTML:3 分钟
  • JS/CSS: 10 分钟、1 天、30 天

CDN 的Nginx中通过设置 expires 字段的时长

# CDN 灰度发布

不会区域/地区部分运营商优先发布静态资源, 验证通过后, 再进行全量发布

通过设置特殊 VIP 解析至要灰度的城市/运营商

# CDN 备战

如果是大促, 增加机房带宽 / 增加运营商流量 / CDN 应用缓存时间由 10 分钟设置成 1 小时, 大促后恢复

# 十三、DNS 优化

# DNS 查询

访问远程服务的时候,不会直接使用服务的出口 IP,而是使用域名。DNS 是应用层协议,事实上他是为其他应用层协议工作的,包括不限于 HTTP 和 SMTP 以及 FTP,用于将用户提供的主机名解析为 ip 地址

DNS 获取的流程主要分为以下几个步骤:

  1. 浏览器缓存:当用户通过浏览器访问某域名时,浏览器首先会在自己的缓存中查找是否有该域名对应的 IP 地址(若曾经访问过该域名且没有清空缓存便存在)

  2. 系统缓存:当浏览器缓存中无域名对应 IP 则会自动检查用户计算机系统 Hosts 文件 DNS 缓存是否有该域名对应 IP;

  3. 路由器缓存:当浏览器及系统缓存中均无域名对应 IP 则进入路由器缓存中检查,以上三步均为客服端的 DNS 缓存;

  4. ISP(互联网服务提供商)DNS 缓存:当在用户客服端查找不到域名对应 IP 地址,则将进入 ISP DNS 缓存中进行查询。比如你用的是电信的网络,则会进入电信的 DNS 缓存服务器中进行查找;

  5. 根域名服务器:当以上均未完成,则进入根服务器进行查询。全球仅有 13 台根域名服务器,1 个主根域名服务器,其余 12 为辅根域名服务器。根域名收到请求后会查看区域文件记录,若无则将其管辖范围内顶级域名(如.com)服务器 IP 告诉本地 DNS 服务器;

  6. 顶级域名服务器:顶级域名服务器收到请求后查看区域文件记录,若无则将其管辖范围内主域名服务器的 IP 地址告诉本地 DNS 服务器;

  7. 主域名服务器:主域名服务器接受到请求后查询自己的缓存,如果没有则进入下一级域名服务器进行查找,并重复该步骤直至找到正确纪录;

  8. 保存结果至缓存:本地域名服务器把返回的结果保存到缓存,以备下一次使用,同时将该结果反馈给客户端,客户端通过这个 IP 地址与 web 服务器建立链接。

这里我们需要了解的是

  • 首先,DNS 解析流程可能会很长,耗时很高,所以整个 DNS 服务,包括客户端都会有缓存机制,这个作为前端不好涉入;
  • 其次,在 DNS 解析上,前端还是可以通过浏览器提供的其他手段来“加速”的。

DNS Prefetch 就是浏览器提供给我们的一个 API。它是 Resource Hint 的一部分。它可以告诉浏览器:过会我就可能要去 yourwebsite.com 上下载一个资源啦,帮我先解析一下域名吧。这样之后用户点击某个按钮,触发了 yourwebsite.com 域名下的远程请求时,就略去了 DNS 解析的步骤。使用方式很简单

<link rel="dns-prefetch" href="//yourwebsite.com" />

# DNS 查询优化-预先建立连接

我们知道,建立连接不仅需要 DNS 查询,还需要进行 TCP 协议握手,有些还会有 TLS/SSL 协议,这些都会导致连接的耗时。使用 Preconnect[3] 可以帮助你告诉浏览器:“我有一些资源会用到某个源(origin),你可以帮我预先建立连接”

根据规范,当你使用 Preconnect (opens new window) 时,浏览器大致做了如下处理:

  • 首先,解析 Preconnect 的 url;
  • 其次,根据当前 link 元素中的属性进行 cors 的设置;
  • 然后,默认先将 credential 设为 true,如果 cors 为 Anonymous 并且存在跨域,则将 credential 置为 false;
  • 最后,进行连接。

使用 Preconnect 只需要将 rel 属性设为 preconnect 即可:

<link rel="preconnect" href="//sample.com" />

当然,你也可以设置 CORS:

<link rel="preconnect" href="//sample.com" crossorigin />

需要注意的是,标准并没有硬性规定浏览器一定要(而是 SHOULD)完成整个连接过程,与 DNS Prefetch 类似,浏览器可以视情况完成部分工作。

# 客户端处理

# Android 中采用 一些 DNS 模块(okhttp)

  • 支持 H2
  • 连接池复用减少延迟
  • 支持 GZIP,压缩体积
  • 响应缓存可以避免网络重复请求
  • 配置了多个 IP 地址, 一个 IP 失败,OKhttp 自动尝试下一个

# IOS 中可以采用自研 DNS 模块

  • APP 启动时,缓存所有可能要用到的域名 IP,同时异步处理,客户端无需缓存
  • Cache 中有域名缓存, 直接使用缓存
  • 没有缓存则重新向 Http server 申请

# Web 前端中的处理, 浏览器有并发数限制,做域名分散,资源分布在多个域名

  • Java、php 等 API 接口放在一个域名
  • 页面和样式(HTML/JS/CSS)放在一个域名
  • 图片(jpg、png、gif)放在一个域名

# 十四、HTTP 优化

下图是请求声明周期中各个阶段的示意图,可以帮助我们理解发送请求(以及接收响应)的流程。

images

# 减少 HTTP 请求数

  • css sprites
  • 图片使用 DataURI、Web Font
  • JS/CSS 文件合并
  • JS/CSS 请求 combo
  • 接口合并
  • 接口存储 localstorage
  • 静态资源存储 localstorage
  • 主站首页设置白名单
  • 定期删除非白名单 Cookie
  • cookie 设置子域名,防止静态资源挟带 cookie
  • 设置合理的过期时长

Cookie 什么时候才会自动携带呢?

NAME=VALUE 赋予Cookie的名称和其值(必须项)
expires=DATE Cooke的有效期(若不明确指定则默认为浏览器关闭前为止)
path=PATH 将服务器上的文件目录作为Cookie的适用对象(若不指定则默认为文档所在的文件目录)
domain=域名 作为Cookie适用对象的域名(若不指定则默认为创建Cookie的服务器的域名)
Secure 仅在HTTPS安全通信时才会发送Cookie
HttpOnly 加以限制,使Cookie不能被JavaScript脚本访问

如果满足下面几个条件:

1、浏览器端某个 Cookie 的 domain 字段等于 aaa.www.com 或者 <www.com>

2、都是 http 或者 https,或者不同的情况下 Secure 属性为 false

3、要发送请求的路径,即上面的 xxxxx 跟浏览器端 Cookie 的 path 属性必须一致,或者是浏览器端 Cookie 的 path 的子目录,比如浏览器端 Cookie 的 path 为/test,那么 xxxxxxx 必须为/test 或者/test/xxxx 等子目录才可以

# Ngix 开启 Gzip 压缩

HTTP 请求头 Accept-Encoding 会将客户端能够理解的内容编码方式——通常是某种压缩算法——进行通知(给服务端)。通过内容协商的方式,服务端会选择一个客户端提议的方式,使用并在响应头 Content-Encoding 中通知客户端该选择。

Nginx 配置: nginx.conf 文件增加 gzip on

Apache 配置: AddOutputFilterByTypeAddOutputFilter

# 开启 HTTPS

优点: 利于SEO更加安全

实施步骤:

  1. 购买证书(GoGetSSL / SSLs.com / SSLmate.com)

  2. 本地安装测试证书

// 通过HomeBrew安装
brew install mkcert
// 本地安装根证书
mkcert --insatll
// 本地生成签名
mkcert xxx.com

  1. 本地 nginx 配置
server{
  listen 443 ssl; #启用HTTPS
  server_name xxx.com #刚才的签名

  ssl_certificate xxx+y.pem;
  ssl_certificate_key xxx+y-key.pem;
}

# 使用 HTTP2

  • 二进制传输
  • 多路复用 TCP 连接
  • header 头部压缩
  • 服务端推送

缺点是如果一个TCP包丢失,会导致整个TCP的数据重传

  1. 升级 OpenSSL
openssl version
  1. 重新编译
cd nginx-xxx
./configure --with-http_ssl_module --with-http_v2_module
make && make insatll
  1. 配置 Nginx 的字段
server{
  listen 443 ssl http2; #启用http2
  server_name xxx.com #刚才的签名

  ssl_certificate xxx+y.pem;
  ssl_certificate_key xxx+y-key.pem;
}

  1. 验证 HTTP2

浏览器下查看有没有小绿锁

  1. 查看浏览器请求的快照 protocol 字段 是不是 h2

# 十五、性能优化指标

每个字段的具体含义这里不展开介绍,具体可以看 W3C 对应文档。

# 性能指标

  1. DNS 解析耗时: domainLookupEnd - domainLookupStart
  2. TCP 连接耗时: connectEnd - connectStart
  3. SSL 安全连接耗时: connectEnd - secureConnectionStart
  4. 网络请求耗时(TTFB): responseStart - requestStart
  5. 数据传输耗时: responseEnd - responseStart
  6. DOM 解析耗时: domInteractive - responseEnd
  7. 资源加载耗时: loadEventStart - domContentLoadedEventEnd
  8. 首包时间: responseStart - domainLookupStart
  9. 首次渲染时间 / 白屏时间: responseEnd - fetchStart
  10. 首次可交互时间: domInteractive - fetchStart
  11. DOM Ready 时间: domContentLoadEventEnd - fetchStart
  12. 页面完全加载时间: loadEventStart - fetchStart

# 白屏 & 首屏

# 白屏时间(FP)

白屏时间(First Paint):是指浏览器从响应用户输入网址地址,到浏览器开始显示内容的时间。

白屏时间 = 页面开始展示的时间点 - 开始请求的时间点

白屏时间是从用户开始请求页面时开始计算到开始显示内容结束,中间过程包括 DNS 查询、建立 TCP 链接、发送首个 HTTP 请求、返回 HTML 文档、HTML 文档 head 解析完毕。

因此影响白屏时间的因素:网络、服务端性能、前端页面结构设计。

通常认为浏览器开始渲染<body>或者解析完<head>的时间是白屏结束的时间点。所以我们可以在 html 文档的 head 中所有的静态资源以及内嵌脚本/样式之前记录一个时间点,在 head 最底部记录另一个时间点,两者的差值作为白屏时间

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>白屏时间计算-常规方法</title>
    <script>
      window.pageStartTime = Date.now()
    </script>
    <link
      rel="stylesheet"
      href="https://b-gold-cdn.xitu.io/ionicons/2.0.1/css/ionicons.min.css"
    />
    <link
      rel="stylesheet"
      href="https://b-gold-cdn.xitu.io/asset/fw-icon/1.0.9/iconfont.css"
    />
    <script>
      window.firstPaint = Date.now()
      console.log(`白屏时间:${window.firstPaint - window.pageStartTime}`)
    </script>
  </head>
  <body>
    <div>这是常规计算白屏时间的示例页面</div>
  </body>
</html>

白屏时间 = window.firstPaint - window.pageStartTime

这个方法有个缺点:无法获取解析 HTML 文档之前的时间信息。

# 首屏时间(FCP)

首屏时间(First Contentful Paint):是指浏览器从响应用户输入网络地址,到首屏内容渲染完成的时间。

首屏时间 = 首屏内容渲染结束时间点 - 开始请求的时间点

首屏时间要知道两个时间点:开始请求时间点和首屏内容渲染结束时间点。开始请求时间点和白屏时间一样,下面就是如何拿到首屏内容渲染结束时间点。

首屏结束时间应该是页面的第一屏绘制完,但是目前没有一个明确的 API 可以来直接得到这个时间点,所以我们只能智取,比如我们要知道第一屏内容底部在 HTML 文档的什么位置,那么这个第一屏内容底部,也称之为首屏线

现在的问题是:首屏线在哪里?

实际情况分很多种,不同的场景有不同的计算方式。

# 首屏计算-标记首屏标签模块

这种方式比较简单,通过在 HTML 文档中,在首屏线的位置添加脚本去获取这个位置的时间。

但是在哪里添加我们只能大约估摸一个位置,以手机屏幕为例,不同型号手机屏幕大小不一样,我们取的位置可能在首屏线上面,也可以在下面,只能得到一个大约的值。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>首屏时间计算-标记首屏标签模块</title>
    <script>
      window.pageStartTime = Date.now()
    </script>
    <link
      rel="stylesheet"
      href="https://b-gold-cdn.xitu.io/ionicons/2.0.1/css/ionicons.min.css"
    />
    <link
      rel="stylesheet"
      href="https://b-gold-cdn.xitu.io/asset/fw-icon/1.0.9/iconfont.css"
    />
  </head>

  <body>
    <div>首屏时间计算-标记首屏标签模块</div>
    <div class="module-1"></div>

    <div class="module-2"></div>

    <script type="text/javascript">
      window.firstScreen = Date.now()
      console.log(`首屏时间:${window.firstScreen - window.pageStartTime}`)
    </script>
    <div class="module-3"></div>

    <div class="module-4"></div>
  </body>
</html>

首屏时间 = window.firstScreen - window.pageStartTime

适用场景

  • 首屏内不需要拉取数据,否则可能拿到首屏线获取时间的时候,首屏还是空白
  • 不需要考虑图片加载,只考虑首屏主要模块

在业务中,较少使用这种算法,大多数页面需要使用接口,所以这种方法就太不常用

但是如果你的页面是静态页面,或者异步数据不影响整体的首屏体验,那么就可以使用这种办法

# 首屏计算-统计首屏最慢图片加载

我们知道在一个页面中,图片资源往往是比较后加载完的,因此可以统计首屏加载最慢的图片是否加载完成,加载完了,记录结束时间。

如何知道首屏内哪张图片加载最慢?

我们可以拿到首屏内所有的图片,遍历它们,逐个监听图片标签的 onload 事件,并收集到它们的加载时间,最后比较得到加载时间的最大值。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>首屏时间计算-统计首屏最慢图片加载时间</title>
    <script>
      window.pageStartTime = Date.now()
    </script>
  </head>

  <body>
    <img
      src="https://gitee.com/HanpengChen/blog-images/raw/master/blogImages/2021/spring/20210107155629.png"
      alt="img"
      onload="load()"
    />
    <img
      src="https://gitee.com/HanpengChen/blog-images/raw/master/blogImages/2020/autumn/article-gzh-qrcode.png"
      alt="img"
      onload="load()"
    />
    <script>
      function load() {
        window.firstScreen = Date.now()
      }
      window.onload = function () {
        // 首屏时间
        console.log(window.firstScreen - window.pageStartTime)
      }
    </script>
  </body>
</html>

首屏时间 = window.firstScreen - window.pageStartTime

适用场景

  • 首屏元素数量固定的页面,比如移动端首屏不论屏幕大小都展示相同数量的内容。

但是对于首屏元素不固定的页面,这种方案并不适用,最典型的就是 PC 端页面,不同屏幕尺寸下展示的首屏内容不同。上述方案便不适用于此场景。

# 首屏计算-自定义模块计算

这种算法和标记首屏的方法相似,同样忽略了首屏内图片加载的情况,这个方法主要考虑的是异步数据。

在首屏标签标记法中,是无法计算到异步数据带来的首屏空白的,所以它的适配场景十分有限

自定义模块,就是根据首屏内接口计算比较得出最迟的时间

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>首屏时间计算-自定义模块计算法</title>
    <script>
      window.pageStartTime = Date.now()
    </script>
  </head>

  <body>
    <div class="module-1"></div>

    <div class="module-2"></div>
    <script type="text/javascript">
      setTimeout(() => {
        // 假设这里异步加载首屏要显示的文章列表数据
        window.firstScreen = Date.now()
        console.log(window.firstScreen - window.pageStartTime)
      }, 500)
    </script>
    <div class="module-3"></div>
  </body>
</html>

# Performance

window.performance 是一个浏览器中用于记录页面加载和解析过程中关键时间点的对象,放置在 global 环境下,通过 JavaScript 可以访问到它。

通过以下代码可以探测和兼容 performance:

const performance =
  window.performance || window.msPerformance || window.webkitPerformance
if (performance) {
  // 你的代码
}

# Performance-总览

  • memory:显示此刻内存占用情况,是一个动态值
  • usedJSHeapSize:JS 对象占用的内存数
  • jsHeapSizeLimit:可使用的内存
  • totalJSHeapSize:内存大小限制

正常 usedJSHeapSize 不大于 totalJSHeapSize,如果大于,说明可能出现了内存泄漏。

  • navigation:显示页面的来源信息

  • redirectCount:表示如果有重定向的话,页面通过几次重定向跳转而来,默认为 0

  • type:表示页面打开的方式。0-正常进入;1-通过 window.reload()刷新的页面;2-通过浏览器的前进后退按钮进入的页面;255-非以上方式进入的页面。

  • onresourcetimingbufferfull:在 resourcetimingbufferfull 事件触发时会被调用的一个 event handler。它的值是一个手动设置的回调函数,这个回调函数会在浏览器的资源时间性能缓冲区满时执行。

  • timeOrigin:一系列时间点的基准点,精确到万分之一毫秒。

  • timing:一系列关键时间点,包含网络、解析等一系列的时间数据。

performance

# Performance-timing

下面我们来看下 timing 中的各个时间点:

  • navigationStart:同一个浏览器上一个页面卸载(unload)结束时的时间戳。如果没有上一个页面,这个值会和 fetchStart 相同

  • unloadEventStart: 上一个页面 unload 事件抛出时的时间戳。如果没有上一个页面,这个值会返回 0。

  • unloadEventEnd: 和 unloadEventStart 相对应,unload 事件处理完成时的时间戳。如果没有上一个页面,这个值会返回 0。

  • redirectStart: 第一个 HTTP 重定向开始时的时间戳。如果没有重定向,或者重定向中的一个不同源,这个值会返回 0

  • redirectEnd: 最后一个 HTTP 重定向完成时(也就是说是 HTTP 响应的最后一个比特直接被收到的时间)的时间戳。如果没有重定向,或者重定向中的一个不同源,这个值会返回 0

  • fetchStart: 浏览器准备好使用 HTTP 请求来获取(fetch)文档的时间戳。这个时间点会在检查任何应用缓存之前。

  • domainLookupStart: DNS 域名查询开始的 UNIX 时间戳。如果使用了持续连接(persistent connection),或者这个信息存储到了缓存或者本地资源上,这个值将和 fetchStart 一致。

  • domainLookupEnd: DNS 域名查询完成的时间。如果使用了本地缓存(即无 DNS 查询)或持久连接,则与 fetchStart 值相等

  • connectStart: HTTP(TCP) 域名查询结束的时间戳。如果使用了持续连接(persistent connection),或者这个信息存储到了缓存或者本地资源上,这个值将和 fetchStart 一致。

  • connectEnd: HTTP(TCP) 返回浏览器与服务器之间的连接建立时的时间戳。如果建立的是持久连接,则返回值等同于 fetchStart 属性的值。连接建立指的是所有握手和认证过程全部结束。

  • secureConnectionStart: HTTPS 返回浏览器与服务器开始安全链接的握手时的时间戳。如果当前网页不要求安全连接,则返回 0。

  • requestStart: 返回浏览器向服务器发出 HTTP 请求时(或开始读取本地缓存时)的时间戳。

  • responseStart: 返回浏览器从服务器收到(或从本地缓存读取)第一个字节时的时间戳。如果传输层在开始请求之后失败并且连接被重开,该属性将会被数制成新的请求的相对应的发起时间。

  • responseEnd: 返回浏览器从服务器收到(或从本地缓存读取,或从本地资源读取)最后一个字节时。(如果在此之前 HTTP 连接已经关闭,则返回关闭时)的时间戳。

  • domLoading: 当前网页 DOM 结构开始解析时(即 Document.readyState 属性变为“loading”、相应的 readystatechange 事件触发时)的时间戳。

  • domInteractive: 当前网页 DOM 结构结束解析、开始加载内嵌资源时(即 Document.readyState 属性变为“interactive”、相应的 readystatechange 事件触发时)的时间戳。

  • domContentLoadedEventStart: 当解析器发送 DOMContentLoaded 事件,即所有需要被执行的脚本已经被解析时的时间戳。

  • domContentLoadedEventEnd: 当所有需要立即执行的脚本已经被执行(不论执行顺序)时的时间戳。

  • domComplete: 当前文档解析完成,即 Document.readyState 变为 'complete'且相对应的 readystatechange 被触发时的时间戳

  • loadEventStart: load 事件被发送时的时间戳。如果这个事件还未被发送,它的值将会是 0。

  • loadEventEnd: 当 load 事件结束,即加载事件完成时的时间戳。如果这个事件还未被发送,或者尚未完成,它的值将会是 0

通过上面这些时间点,我们看能计算到哪些时间:

  • 重定向耗时:redirectEnd - redirectStart
  • DNS 查询耗时:domainLookupEnd - domainLookupStart
  • TCP 链接耗时:connectEnd - connectStart
  • HTTP 请求耗时:responseEnd - responseStart
  • 解析 dom 树耗时:domComplete - domInteractive
  • 白屏时间:responseStart - navigationStart
  • DOM ready 时间:domContentLoadedEventEnd - navigationStart
  • onload 时间:loadEventEnd - navigationStart

performance.timing 记录的是用于分析页面整体性能指标。如果要获取个别资源(例如 JS、图片)的性能指标,就需要使用 Resource Timing API。

performance.getEntries()方法,包含了所有静态资源的数组列表;每一项是一个请求的相关参数有 name,type,时间等等。

除了performance.getEntries之外,performance 还包含一系列有用的方法,比如:

  • performance.now()
  • Performance.getEntriesByName()
  • ....

上面这些方法我不具体介绍,大家可以自行查阅相关文档了解,这里我主要说一下我们可以利用 getEntriesByName()这个方法来计算首屏时间:

首屏时间:performance.getEntriesByName["first-contentful-paint"](0)\].startTime - navigationStart

在评估页面是否开始渲染方面,首屏时间会比白屏时间更精确,但是二者的结束时间往往很接近。所以要根据自己的业务场景去决定到底该用哪种计算方式。

对于交互性比较少的简单网页,由于加载比较快,所以二者区别不大,因此,可以根据喜好任选一种计算方式。

对于大型的复杂页面,你会发现由于需要处理更多复杂的元素,白屏时间和首屏时间相隔比较远,这时候,计算首屏时间会更有用。

目前白屏常见的优化方案有:

  • SSR
  • 预渲染
  • 骨架屏

优化首屏加载时间的方法:

  • CDN 分发(减少传输距离)
  • 后端在业务层的缓存
  • 静态文件缓存方案
  • 前端的资源动态加载
  • 减少请求的数量
  • 利用好 HTTP 压缩

参考文档: