性能优化的目的

​ 希望页面展示更快交互响应快页面无卡顿情况。更详细的说,就是指,在用户输入url到站点完整把整个页面展示出来的过程中,通过各种优化策略和方法,让页面加载更快;在用户使用过程中,让用户的操作响应更及时,有更好的用户体验。

常规性能优化

1. JS优化

  • 防抖和节流
  • 使用事件委托
  • 复杂计算开启webworker进行计算
  • 计算结果缓存,减少运算次数
  • 不要覆盖原生方法:无论你的 JavaScript 代码如何优化,都比不上原生方法。因为原生方法是用低级语言写的(C/C++),并且被编译成机器码,成为浏览器的一部分。当原生方法可用时,尽量使用它们,特别是数学运算和 DOM 操作。

2. 图片优化

  • 雪碧图:减少http请求次数

  • 图片懒加载:当图片进入可视区的时候进行加载

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    // 方法一:intersectionObserver

    // html
    <img data-src="http://example.com" />;
    // js
    const lazyLoadIntersection = new IntersectionObserver((entries) => {
    entries.forEach((item, index) => {
    if (item.isIntersecting) {
    item.target.src = item.target.dataset.src;
    lazyLoadIntersection.unobserve(item.target);
    }
    });
    });
    const imgList = document.querySelectorAll(".img-list img");
    Array.from(imgList).forEach((item) => {
    lazyLoadIntersection.observe(item);
    });

    // 方法二:getBoundClientRect()
    const { top, left, height, width } = el.getBoundingClientRect();
    if (top < clientHeight && left < clientWidth) {
    return true;
    }
  • 小图片Base64编码

  • 使用webg格式的图片,体积比png和jpg小

3. CSS优化

  • CSS放头部:CSS影响渲染树的构建,会阻塞页面的渲染,因此应该尽早尽快将CSS资源加载

  • 降低CSS选择器的复杂度:减少嵌套次数。CSS选择器对性能的影响源于浏览器匹配选择器和文档元素时所消耗的时间。CSS选择器是从右往左进行规则匹配的。所以嵌套越多寻找越费劲。后代选择器开销也是高的,因为其需要遍历所有目标元素还需向上遍历直到根节点。

  • 使用外链式的JS和CSS:使用外部文件时, JavaScript 和 CSS 有机会被浏览器缓存起来。对于内联的情况,由于 HTML 文档通常不会被配置为可以进行缓存的,所以每次请求 HTML 文档都要下载 JavaScript 和 CSS。所以,如果 JavaScript 和 CSS 在外部文件中,浏览器可以缓存它们,HTML 文档的大小会被减少而不必增加 HTTP 请求数量。

  • 首屏加载优化:使用骨架屏或者动画优化用户体验;资源按需加载,首页不需要的资源延迟加载。

减少重绘重排

  • 增加多个节点使用documentFragment:不是真实dom的部分,不会引起重绘和回流
  • 用 translate 代替 top ,因为 top 会触发回流,但是translate不会。所以translate会比top节省了一个layout的时间
  • 使用 visibility 替换 display: none ,因为前者只会引起重绘,后者会引发回流(改变了布局);opacity 代替 visiability,visiability会触发重绘(paint),但opacity不会。
  • 把 DOM 离线后修改,比如:先把 DOM 给 display:none (有一次 Reflow),然后你修改 100 次,然后再把它显示出来
  • 尽量少用table布局,table布局的话,每次有单元格布局改变,都会进行整个tabel回流重绘;
  • 最好别频繁去操作DOM节点,最好把需要操作的样式,提前写成class,之后需要修改。只需要修改一次,需要修改的时候,直接修改className,做成一次性更新多条css DOM属性,一次回流重绘总比多次回流重绘要付出的成本低得多;
  • 动画实现的速度的选择,动画速度越快,回流次数越多,也可以选择使用 requestAnimationFrame
  • 每次访问DOM的偏移量属性的时候,例如获取一个元素的scrollTop、scrollLeft、scrollWidth、offsetTop、offsetLeft、offsetWidth、offsetHeight之类的属性,浏览器为了保证值的正确也会回流取得最新的值,所以如果你要多次操作,最好取完做个缓存。
  • 将频繁运行的动画变为图层,图层能够阻止该节点回流影响别的元素。比如对于 video 标签,浏览器会自动将该节点变为图层。

4. webpack打包优化

⭐ 缩小loader匹配范围

  • OneOf
  • include / exclude

⭐ 代码压缩

  • JS代码压缩:terser-webpack-plugin
  • css代码压缩:css-minimizer-webpack-plugin
  • html压缩:html-minifier-terser
  • 文件压缩
  • 图片压缩

⭐ Tree Shaking(去除JS中没有使用上的代码,默认开启)

​ 依赖于ES Module。因为ES模块中引入了静态分析,在编译的时候可以判断加载了哪些模块。

​ webpack中tree shaking有两种方案:

  • usedExports:通过标记某些函数是否被使用,之后通过Terser来进行优化的
  • sideEffects:跳过整个模块/文件,直接查看该文件是否有副作用

⭐babel-plugin-transform-runtime

⭐code split(代码分割,按需加载)

​ 所有的JavaScript代码(业务代码、第三方依赖、暂时没有用到的模块)在首页全部都加载,就会影响首页的加载速度。

​ 通过splitChunksPlugin来实现,该插件webpack已经默认安装和集成,只需要配置即可。

⭐多线程打包提升打包速度

5. 网络优化

  • 使用缓存
  • 使用CDN
  • 使用多个域名:现代化浏览器,都会有同域名限制并发下载数的情况,不同的浏览器及版本都不一样,使用不同的域名可以最大化下载线程,但注意保持在 2~4 个域名内,以避免 DNS 查询损耗。

6. react优化

  • map循环展示添加key
  • 路由懒加载
  • 第三方插件按需引入
  • 使用memo或者pureComponent避免不必要的渲染
  • 合理使用useMemo、memo、useCallback他们三个的应用场景都是缓存结果,当依赖值没有改变时避免不必要的计算或者渲染。
  1. useCallback 是针对函数进行“记忆”的,当它依赖项没有发生改变时,那么该函数的引用并不会随着组件的刷新而被重新赋值。当我们觉得一个函数不需要随着组件的更新而更新引用地址的时候,我们就可以使用 useCallback 去修饰它。
  2. React.memo 是对组件进行 “记忆”,当它接收的 props 没有发生改变的时候,那么它将返回上次渲染的结果,不会重新执行函数返回新的渲染结果。
  3. React.useMemo是针对 值计算 的一种“记忆“,当依赖项没有发生改变时,那么无需再去计算,直接使用之前的值,对于组件而言,这带来的一个好处就是,可以减少一些计算,避免一些多余的渲染。当我们遇到一些数据需要在组件内部进行计算的时候,可以考虑一下 React.useMemo

7. angular优化

angular事件响应过程

​ 如果考虑优化页面响应的速度,可以从各个阶段入手:

(1)对于触发事件阶段,可以减少事件的触发,来减少整体的变更检测次数和重新渲染

(2)对于 Event Handler 执行逻辑阶段,可以通过优化复杂代码逻辑来减少执行时间

(3)对于 Change Detection 检测数据绑定并更新 DOM 阶段,可以减少变更检测和模板数据的计算次数来减少渲染时间

(4)对于浏览器渲染阶段,则可能需要考虑使用不同浏览器或从硬件配置上进行提升

优化方案

(1)针对异步任务 ——减少变更检测的次数
  • 使用 NgZone 的 runOutsideAngular 方法执行异步接口
  • 手动触发 Angular 的变更检测
(2)针对 Event Task —— 减少变更检测的次数
  • 将 input 之类的事件换成触发频率更低的事件
  • 对 input valueChanges 事件做的防抖动处理,并不能减少变更检测的次数
(3)针对组件 ——减少不必要的变更检测
  • 组件使用 onPush 模式
    • 只有输入属性改变或组件或者其子组件中的 DOM 事件触发时,该组件才会检测
    • 非 DOM 事件的其他异步事件,只能手动触发检测
    • 声明了 onPush 的子组件,如果输入属性未变化,就不会去做计算和更新
(4)针对模板 ——减少不必要的计算和渲染
  • 列表的循环渲染使用 trackBy
(5)unsubscribe

性能指标(FCP LCP TTI CLS)

Largest Contentful Paint最大内容绘制(LCP)

是什么?

​ 用于记录视窗内最大的元素绘制的时间,该时间会随着页面渲染变化而变化,因为页面中的最大元素在渲染过程中可能会发生改变,另外该指标会在用户第一次交互后停止记录。官方推荐的时间区间,在 2.5 秒内表示体验优秀.

优点

​ 一是指标实时更新,数据更精确;二是代表着页面最大元素的渲染时间,通常来说页面中最大元素的快速载入能让用户感觉性能还挺好。

哪些元素可以被定义为最大元素呢?

  • img标签
  • 在svg中的image标签
  • video标签
  • css background url()加载的图片
  • 包含内联或文本的块级元素

使用原生js测量

1
2
3
4
5
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
console.log('LCP candidate:', entry.startTime, entry);
}
}).observe({type: 'largest-contentful-paint', buffered: true});

可能影响LCP的因素

  • 服务端响应时间
  • Javascript和CSS引起的渲染卡顿
  • 资源加载时间
  • 客户端渲染

First Input Delay (FID)

是什么?

​ 用户交互事件触发到页面响应中间耗时多少。推荐响应用户交互在 100ms 以内.

使用原生js测量

1
2
3
4
5
6
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
const delay = entry.processingStart - entry.startTime;
console.log('FID candidate:', delay, entry);
}
}).observe({type: 'first-input', buffered: true});

优化FID

  • 减少第三方代码的影响
  • 减少Javascript的执行时间
  • 最小化主线程工作
  • 减少请求数量和请求文件大小

Cumulative Layout Shift(CLS)

CLS 代表了页面的稳定指标,它能衡量页面是否排版稳定。尤其在手机上这个指标更为重要,因为手机屏幕挺小,CLS值一大的话会让用户觉得页面体验做的很差。CLS的分数在0.1或以下,则为Good。浏览器会监控两桢之间发生移动的不稳定元素。布局移动分数由2个元素决定:impact fractiondistance fraction

但是要注意的是,并不是所有的布局移动都是不好的,很多web网站都会改变元素的开始位置。只有当布局移动是非用户预期的,才是不好的

​ 换句话说,当用户点击了按钮,布局进行了改动,这是ok的,CLS的JS API中有一个字段hadRecentInput,用来标识500ms内是否有用户数据,视情况而定,可以忽略这个计算。

使用原生js测量

1
2
3
4
5
6
7
8
9
let cls = 0;
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (!entry.hadRecentInput) {
cls += entry.value;
console.log(‘Current CLS value:’, cls, entry);
}
}
}).observe({type: ‘layout-shift’, buffered: true});

优化CLS

  • 图片或视频元素有大小属性,或者给他们保留一个空间大小,设置width、height,或者使用 unsized-media feature policy 。布局不稳定是加剧 Web 体验的现有问题之一。例如,当未指定元素的大小时,将导致元素周围的内容跳来跳去。这是因为渲染器不知道在加载图像之前为图像保留多少空间,并且一旦知道图像大小,渲染器将不得不重新布局所有内容,从而导致内容在网页上移动。unsized-media feature policy旨在通过要求所有媒体元素提供大小来解决此问题;如果没有,将选择默认值,以便图像在加载后不会更改大小。
  • 不要在一个已存在的元素上面插入内容,除了相应用户输入。
  • 使用animation或transition而不是直接触发布局改变。

性能检测工具

  • Lighthouse(关注了上述几个指标,并且给出了优化建议)
  • Chrome DevTools

性能优化具体案例

1. 长列表性能优化

  • 分页
  • 懒加载:一次只渲染一部分数据,等渲染数据即将滚动完成再去渲染下面的数据
  • 虚拟列表:可视区域渲染,只渲染页面可视区域的列表项,在滚动列表时动态更新可视的列表项。当然,为了防止快速滚动导致页面来不及渲染新列表而产生白屏,虚拟列表也会对非可视区域的数据进行不完全渲染,就是先预加载可视区域上下一定数量的列表。(参见:虚拟列表实现原理

不使用虚拟列表的情况下,渲染10w个节点:

不使用虚拟列表

使用虚拟列表的情况下,渲染10w个节点:

使用虚拟列表

2. CDN

⭐用户访问CDN节点的过程

  • 用户在浏览器地址栏键入网站IP/域名

  • 浏览器查DNS缓存看能否获取目标网站对应的IP地址

  • 如果键入的是域名,浏览器先查询浏览器本地DNS缓存获取目标网站对应的IP地址(chrome可访问chrome://net-internals/#dns查看),没有对该域名缓存,则进行下 一步

  • 查询本地操作系统的DNS缓存(host文件),没有对该域名缓存,则进行下一步

  • 查询与本地直接相连的路由器中是否有对应的DNS缓存

  • 当没能从DNS缓存中获取到目录网站IP,浏览器调用域名解析程序以递归查询的方式,向本地DNS服务器查询。本地DNS服务器查询该域名是否包含在本地配置区域资源中,有则返回对应IP,无则进行下一步

  • 本地DNS服务器如果有设置转发器,则向上一级发起查询,直到查询到目标网站IP或返回查询失败结果

  • 本地DNS服务器如果没有设置转发器,本地DNS服务器以迭代查询的方式依次向根域名服务器、 顶级域名服务器、权威DNS服务器发起查询请求:

  • 根域名服务器(.)返回一条NS记录,让本地DNS服务器转到对应的顶级域名(.com)服务器查询

  • 顶级域名服务器接收到本地DNS服务器请求,返回一条NS记录,让本地DNS服务器转到xisenbao.com对应的权威域名服务器进行查询

  • 权威域名服务器查询到www.xisenbao.com对应的CNAME并返回给本地域名服务器(注意如果链路较长的时候这一步可能重复多次,依次查询到多个CNAME)

  • 本地域名服务器向CDN专用的域名解析服务器发起域名解析,CDN专用的域名解析服务器返回CDN全局负载均衡系统的IP地址给本地域名服务器

  • 本地域名服务器将全局负载均衡系统的IP地址返回给浏览器

  • 浏览器访问全局负载均衡系统的IP地址

  • 全局负载均衡系统的IP地址根据浏览器所在机器的IP地址判断请求来源于哪个区域,返回对应区域的本地负载均衡服务器的IP地址给浏览器

  • 本地负载均衡服务器根据

1
2
3
a. 用户IP地址,判断哪一台服务器距用户最近;
b. 用户所请求的URL中携带的内容,判断哪一台CDN节点上有用户所需内容;
c. 查询各个节点当前的负载情况,判断哪一台服务器尚有服务能力来选择最合适的CDN 服务器返回对应的IP地址给浏览器
  • 浏览器拿到IP地址向CDN节点获取数据(如果CDN节点有命中缓存直接返回用户所需数据,如果对应节点中没有缓存用户所需要数据,该CDN节点则需要去找到源站获取数据并缓存下来,下一次有用户访问同样的数据直接返回)。

  • 三次握手建立TCP连接

  • 浏览器发送HTTP请求,等待服务器应答

  • 服务器响应请求返回数据

  • 浏览器得到数据开始进行页面渲染

  • 四次挥手断开TCP连接等

⭐CDN接入前端的方案

​ 假设这里的 CDN 域名是 http://cdn.example.com,源站(主站)域名是 http://www.[example](https://www.zhihu.com/search?q=example&search_source=Entity&hybrid_search_source=Entity&hybrid_search_extra={"sourceType"%3A"answer"%2C"sourceId"%3A683562496}).com。

  1. 单独搭建一个静态资源服务作为 CDN 源站,在源站下划分一个路由例如 /static,LB 层将主站域名 /static 路由解析到静态资源服务即 CDN 源站上,CDN 配置成只有 /static 路由下的资源指向源站回源。
  2. 在 Webpack 生产构建配置中为所有资源文件中加上 hash,并设置 publicPath//cdn.example.com/static/path/to/dist
  3. 在构建流水线中于 Webpack 构建完成后加一步增量上传产物文件夹下的内容到静态资源服务源站上的步骤,并将 html 文件存起来记上版本号。
  4. 主站用 Nginx / Apache 等静态资源服务器或者用 Node.js 写个简单的服务在对应的访问路由返回步骤 3 中构建产物的 html 文件。
  5. 主站对 html 配置无缓存(某些情况下也可以加上协商缓存),CDN 则可以把缓存时间设置的尽可能的长。

3. Web Worker优化长任务

​ 由于浏览器 GUI 渲染线程与 JS 引擎线程是互斥的关系,当页面中有很多长任务(执行时间超过50ms的任务)时,会造成页面 UI 阻塞,出现界面卡顿、掉帧等情况。

​ 如果直接把下面这段代码直接丢到主线程中,计算过程中页面一直处于卡死状态,无法操作。

1
2
3
4
5
6
js复制代码let sum = 0;
for (let i = 0; i < 200000; i++) {
for (let i = 0; i < 10000; i++) {
sum += Math.random()
}
}

​ 使用 Web Worker 执行上述代码时,计算过程中页面正常可操作、无卡顿。

1
2
3
4
5
6
7
8
9
10
11
12
js复制代码// worker.js
onmessage = function (e) {
// onmessage获取传入的初始值
let sum = e.data;
for (let i = 0; i < 200000; i++) {
for (let i = 0; i < 10000; i++) {
sum += Math.random()
}
}
// 将计算的结果传递出去
postMessage(sum);
}

web worker通信时长

​ 并不是执行时间超过 50ms 的任务,就可以使用 Web Worker,还要先考虑通信时长问题。

​ 假如一个运算执行时长为 100ms,但是通信时长为 300ms, 用了 Web Worker可能会更慢。比如新建一个 web worker, 浏览器会加载对应的 worker.js 资源,下图中的 Time 是这个资源的通信时长(也叫加载时长)。

load.png

当任务的运算时长 - 通信时长 > 50ms,推荐使用Web Worker

web worker概述

​ JavaScript 语言采用的是单线程模型,也就是说,所有任务只能在一个线程上完成,一次只能做一件事。前面的任务没做完,后面的任务只能等着。随着电脑计算能力的增强,尤其是多核 CPU 的出现,单线程带来很大的不便,无法充分发挥计算机的计算能力。

​ Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。等到 Worker 线程完成计算任务,再把结果返回给主线程。这样的好处是,一些计算密集型或高延迟的任务,被 Worker 线程负担了,主线程(通常负责 UI 交互)就会很流畅,不会被阻塞或拖慢。

webworker使用注意点

(1)同源限制

​ 分配给 Worker 线程运行的脚本文件,必须与主线程的脚本文件同源。

(2)DOM 限制

​ Worker 线程所在的全局对象,与主线程不一样,无法读取主线程所在网页的 DOM 对象,也无法使用documentwindowparent这些对象。但是,Worker 线程可以navigator对象和location对象。

(3)通信联系

​ Worker 线程和主线程不在同一个上下文环境,它们不能直接通信,必须通过消息完成。

(4)脚本限制

​ Worker 线程不能执行alert()方法和confirm()方法,但可以使用 XMLHttpRequest 对象发出 AJAX 请求。

(5)文件限制

​ Worker 线程无法读取本地文件,即不能打开本机的文件系统(file://),它所加载的脚本,必须来自网络。

基本用法

主线程用法(worker线程和主线程的数据不是被共享的,而是被复制的)

​ 主线程采用new命令,调用Worker()构造函数,新建一个 Worker 线程。

1
var worker = new Worker('work.js');

Worker()构造函数的参数是一个脚本文件,该文件就是 Worker 线程所要执行的任务。由于 Worker 不能读取本地文件,所以这个脚本必须来自网络。如果下载没有成功(比如404错误),Worker 就会默默地失败。

​ 然后,主线程调用worker.postMessage()方法,向 Worker 发消息。

1
2
worker.postMessage('Hello World');
worker.postMessage({method: 'echo', args: ['Work']});

worker.postMessage()方法的参数,就是主线程传给 Worker 的数据。它可以是各种数据类型,包括二进制数据。

​ 接着,主线程通过worker.onmessage指定监听函数,接收子线程发回来的消息。

1
2
3
4
5
6
7
8
9
worker.onmessage = function (event) {
console.log('Received message ' + event.data);
doSomething();
}

function doSomething() {
// 执行任务
worker.postMessage('Work done!');
}

​ 上面代码中,事件对象的data属性可以获取 Worker 发来的数据。

​ Worker 完成任务以后,主线程就可以把它关掉。

1
worker.terminate();
worker线程用法

​ 监听主线程传来的消息

1
2
3
4
this.addEventListener('message', function (e) {
// data属性包含主线程发来的数据
this.postMessage('You said: ' + e.data);
}, false);

​ 根据主线程发来的数据,Worker 线程可以调用不同的方法,下面是一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
self.addEventListener('message', function (e) {
var data = e.data;
switch (data.cmd) {
case 'start':
self.postMessage('WORKER STARTED: ' + data.msg);
break;
case 'stop':
self.postMessage('WORKER STOPPED: ' + data.msg);
self.close(); // Terminates the worker.
break;
default:
self.postMessage('Unknown command: ' + data.msg);
};
}, false);
错误处理

​ 主线程可以监听 Worker 是否发生错误。如果发生错误,Worker 会触发主线程的error事件。

1
2
3
4
5
6
7
8
9
10
worker.onerror(function (event) {
console.log([
'ERROR: Line ', e.lineno, ' in ', e.filename, ': ', e.message
].join(''));
});

// 或者
worker.addEventListener('error', function (event) {
// ...
});

​ Worker 内部也可以监听error事件。

webworker实例:完成轮询
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
function createWorker(f) {
var blob = new Blob(['(' + f.toString() +')()']);
var url = window.URL.createObjectURL(blob);
var worker = new Worker(url);
return worker;
}

var pollingWorker = createWorker(function (e) {
var cache;

function compare(new, old) { ... };

setInterval(function () {
fetch('/my-api-endpoint').then(function (res) {
var data = res.json();

if (!compare(data, cache)) {
cache = data;
self.postMessage(data);
}
})
}, 1000)
});

pollingWorker.onmessage = function () {
// render data
}

pollingWorker.postMessage('init');

​ 上面代码中,Worker 每秒钟轮询一次数据,然后跟缓存做比较。如果不一致,就说明服务端有了新的变化,因此就要通知主线程。