原创 硅步 2024-09-25 08:30 浙江
作者最近在尝试对负责的平台进行性能优化,本文整理了些前端性能优化的一些常见策略。
阿里妹导读
作者最近在尝试对负责的平台进行性能优化,本文整理了些前端性能优化的一些常见策略。
踏上取经路,比抵达灵山更重要。
一、概览
最近在尝试对负责的平台进行性能优化,整理了一下前端性能优化的一些常见策略。一般性能优化的常见思路是:先进行性能现状分析定义出问题,然后针对问题结合性能优化策略给出技术方案并进行评估,最后再落地&验证技术方案。工欲善其事,必先利其器,让我们跟随本文了解一下常见的性能优化策略。话不多说,先上大纲。
清晰大图见链接或点击文末阅读原文查看:
https://developer.aliyun.com/article/1610662
二、常见性能优化策略
2.0 前置储备知识点
2.0.1 用户访问页面CRP(用户输入一个URL后会发生什么)
前端面试有一个很经典的问题:用户输入一个URL后会发生什么。这个问题可以很有效的考察候选人知识的广度,甚至于深度。一般来说,web端的性能优化,本质上就是尽可能缩短“用户输入一个URL”到“用户可以在网页上进行预期行为”这两个时间点的时间间隔。
因此,web性能优化一般会被纳入用户体验优化的一环。在一些C端场景,用户可以进行预期行为的耗时,和最终相关页面产生的收益有着很明显的正向关联,网络上可以找到一些相关案例。
所以,在进行性能优化前,我们需要了解我们要优化的东西是什么。也就引出了用户访问页面CRP(关键渲染路径)。简单点说,用户输入URL后一般经历如下环节。
浏览器对输入URL的域名进行DNS解析获得IP地址;
基于IP地址和目标服务器建连接(http1.1 / http2 / http3);
基于建立的连接,向服务器发送http请求;
目标服务器处理请求并返回http响应;
浏览器收到服务器的响应(我们只考虑先请求html的情况)返回html;
浏览器对html进行解析,基于html和js脚本构建dom树,基于css构建cssom树,合成渲染树;
结合渲染树和屏幕分辨率等相关信息计算节点布局信息;
浏览器基于渲染树和布局信息进行页面绘制;
最后将不同的图层合并为最终的图像,出现在页面上;
核心公式:“用户访问页面耗时” = “资源请求耗时” + “页面渲染耗时”。参考下方示意图:
2.0.2 浏览器的进程和线程机制
优化策略中有涉及web worker 等概念,web worker是基于后台线程去运行的,所以在下图简单介绍下浏览器的进程和线程机制。
2.0.3 页面重绘&重排(回流)
重绘是指当元素的外观(如颜色、背景、阴影等)发生变化时,浏览器需要重新绘制这些元素,但不需要重新计算它们的布局。重绘不会影响文档的结构,只是更新了元素的视觉表现。比如改变元素的背景颜色、修改文本的颜色或字体样式。
重排,又称回流,是指当元素的几何属性(如宽度、高度、位置等)发生变化时,浏览器需要重新计算元素的布局。这通常会导致整个文档的布局被重新计算,因此重排的开销比重绘要大得多。比如改变元素的大小(如宽度或高度)、添加或删除 DOM 元素、改变元素的边距、填充或边框。
重绘相对较轻量,通常不会显著影响性能。重排则可能导致性能问题,尤其是在频繁触发时,因为它会影响到整个文档的布局,可能导致多次重排和重绘。
2.1 资源请求环节
2.1.0 优化思路
首先我们定义出“资源请求耗时”的几个相关因素:请求并发数量、单次请求时间以及资源请求顺序。其中资源请求顺序并不能直接缩短“资源请求耗时”,但可以让“关键的资源”先完成请求,从而页面渲染先行启动。
2.1.1 增加请求并发数量
默认情况下,浏览器对同一域名下的并发请求数量有限制,通常为6-8 个。 这意味着浏览器在同一时间最多同时发送6-8 个请求给同一域名下的服务器。 超过这个数量的请求将会被排队等待。我们可以通过如下方案增加请求并发数量。
2.1.1.1 使用多个域名(又称域名分片、域名负载均衡)
域名分片(Domain Sharding)是一种优化技术,旨在提高网页加载速度和资源请求的并发性。其基本原理是将网站的静态资源(如图片、CSS、JavaScript文件等)分散到多个不同的域名或子域名上,以绕过浏览器对同一域名的并发请求限制。
使用多个子域名
例如,如果你的主域名是example.com,可以使用static1.example.com、static2.example.com等子域名来托管静态资源。
使用不同的顶级域名
可以将资源分散到不同的顶级域名上,例如example1.com、example2.com等。
CDN支持
许多内容分发网络(CDN)允许你使用多个域名来分发内容。
2.1.1.2 升级http2
HTTP/2 是 HTTP 协议的第二个主要版本,旨在提高网络性能和效率。在 HTTP/1.x 中,每个请求通常需要建立一个新的 TCP 连接(HTTP/1.0),或者在同一连接上按顺序处理请求(HTTP/1.1,Keep-Alive),这导致了连接建立和关闭的开销。HTTP/2 允许多个请求和响应在同一连接上并发进行(多路复用特性),减少了连接的数量和延迟。下方介绍下HTTP2的多路复用以及其他特性。
多路复用
HTTP/2 允许在单个连接上并发发送多个请求和响应,而不需要等待前一个请求完成。这减少了延迟并提高了资源利用率。
头部压缩
HTTP/2 使用 HPACK 算法对请求和响应头部进行压缩,减少了传输的数据量,特别是在请求头部较大的情况下。
服务端推送
服务器可以主动向客户端推送资源,而不需要客户端请求。这意味着服务器可以在客户端请求某个页面时,提前发送该页面所需的资源(如 CSS 和 JavaScript 文件)。
二进制分帧
HTTP/2 使用二进制格式而不是文本格式,这使得解析更高效,减少了错误的可能性。
优先级和流控制
HTTP/2允许客户端为请求设置优先级,服务器可以根据这些优先级来优化资源的发送顺序。此外,流控制机制可以防止某个流占用过多的带宽
2.1.1.3 升级http3
HTTP/3是超文本传输协议的第三个主要版本,它是基于QUIC(快速UDP互联网连接)协议构建的。QUIC最初是由Google设计的,旨在减少延迟并提高Web性能。HTTP/3解决了HTTP/2中存在的一些问题。
减少连接建立时间
HTTP/2 基于 TCP 和 TLS,需要多个往返时间(RTT)来完成握手。HTTP/3 使用 QUIC 协议,它将加密和传输合并为一个过程,允许在一个 RTT 完成连接建立,在最佳情况下甚至可以在零 RTT 中恢复会话。
多路复用无阻塞
HTTP/2 虽然支持多路复用,但 TCP 层的队头阻塞问题仍然存在。HTTP/3 通过 QUIC 改进的多路复用能力,在 QUIC 中由于是基于数据报的 UDP,独立的流可以在其他流发生丢包时继续传输,解决了 TCP 的队头阻塞问题。
快速丢包恢复和拥塞控制
QUIC 实现了更快速的丢包恢复机制。TCP 需要等待一段时间来确认丢包,而 QUIC 可以利用更精细的确认机制来迅速响应丢包情况,并相应调整拥塞控制策略。
连接迁移
QUIC 支持连接迁移,允许客户端在网络环境变化(如从 Wi-Fi 切换到移动网络)时,保持现有的连接状态。在 HTTP/2 这种情况通常会导致连接中断和需要重新建连。
2.1.2 缩短单次请求时间
我们比较粗暴的定义一下“单次请求时间”的计算公式,“单次请求时间” = “目标资源体积” / “用户网速”。其中我们作为平台方很难去提升“用户网速”,所以就尽可能降低“目标资源体积”。对于前端来说,“目标资源体积”约等于“前端构建产物体积”。这里常规思路有三个:一是从减少构建产物体积;二是网络传输过程中进行压缩;三是启用缓存,直接不请求。
2.1.2.1 减少构建产物体积
这一步我们一般结合具体前端工程去做。我所参与开发的前端工程项目使用的是koi4(基于umi4) + webpack,所以是基于koi4和umi体系去介绍的。和其他体系相比,原理都一样,具体工程实现的方案上可能会有差异。
常用公共依赖umd + externals化
我们常用的基础依赖比如 react/react-dom/antd/lodash/moment 等等,一般不会发生变化。我们就通过umd方式配合externals对其进行加载使用,让其长久的缓存在本地即可,每次应用发布也不会导致这些固定缓存失效。这种方案,对于富文本/图表类大依赖项目来说,还可以有效的提升开发体验。
这个改造第一个需要注意的是,在微前端架构下,不同子应用所使用的基础依赖版本可能不是那么统一,这个改造针对工程依赖体系统一的项目收益最大。有些依赖升不动的应用可以先放掉,提升roi。
第二个需要注意的是,三方依赖代码中有很多如 antd/es/Button 类的写法,如果此时只针对 antd 配置一个externals会发现antd还是会被打包到产物中。可以通过webpack插件等方式将所有组件的es引用方式也externals掉,确保产物中没有额外的antd。这个feature我们团队在koi4中进行了内置。lodash等同理。
代码共享还有module federation方案,不再赘述。
适当的代码分割策略(dynamicImport)
umi3有一个api是dynamicImport,在umi4中默认开启了这个feature,会按页拆包、按需加载。如果不开启这个feature,我们所有页面都会被打到同一个bundle(umi.js)中,导致首屏加载慢的可怕。我接手过的一个最夸张的项目umijs体积压缩后6MB+,经过简单改了一些配置:
(umd + externals + dynamicImport + runtimeImport + treeshaking),压缩后体积降低到了834KB。
微前端架构下,umi.js的体积不仅仅影响的是网络加载,还影响微前端架构下qiankun对umi.js的解析速度,从而影响各种性能指标。我们近期对umi.js入口文件体积进行了优化,用户访问链路上整体350KB的体积减少,让FCP从 1000ms -> 750ms,其他指标 LDP/LCP/MCP由于也要前置经历FCP,也有类似效果的提升。
开启Tree shaking
Tree shaking 是一种优化技术,主要用于 JavaScript 应用程序的构建过程中,尤其是在使用模块化系统(如 ES6 模块)时。它的主要作用是消除未使用的代码,从而减小最终生成的文件大小,提高加载速度和性能。
Tree shaking 通过package.json中增加 "sideEffects": false开启,也可以配置绕过特定文件。开启Tree shaking 主要关注的是项目代码中有无“副作用”存在,如果有的话“副作用”代码被误干掉后会造成相关问题。但是常规项目一般也不会有这么多副作用。
我之前遇到的一段副作用代码是接手的代码中通过 import 'xxx' 引用执行了一段js脚本,导致开启 Tree shaking 后相关代码被干掉,从而页面白屏。
// edges/index.tsx
import { Graph, Path } from '@antv/x6';
import { GraphEdge, GRID_SIZE } from '../../constants';
Graph.registerEdge(xxxx);
Graph.registerConnector(xxx);
// index.tsx
import './components/edges';
打包压缩
umi中
cssMinifier,可以选择使用 esbuild / cssnano / parcelCSS 中的一个对css进行压缩,默认esbuild。
jsMinifier,可以使用 esbuild / terser / swc / uglifyJs 中的一个对js进行压缩,默认esbuild。
html压缩,不同的打包体系也有不同的插件可以对html进行压缩,这个收益就比较低。
webpack
可以通过配置 TerserPlugin 来压缩JavaScript代码。
rollup
可以使用 rollup-plugin-terser 来压缩js代码。
其他工程体系
一般都会有对应的内置压缩插件。
umi4关闭不需要的插件
umi4会默认开启不少不需要的插件,可以按需关闭。
antd: false,如果将antd改造为umd+externals加载,一般我们就不需要这个,按需实现就好。
locale: false,按需配置就好。
helmet: false,如果你都没听过这个,那就直接关了吧。
2.1.2.2 网络传输中压缩
一般用的都是gzip压缩。gzip压缩是一种广泛使用的数据压缩技术,主要用于减少文件大小以提高传输效率。服务端或者nginx配置一下开启就好。我们如果使用DEF打包项目扔到CDN上的话,g.alicdn.com也默认支持gzip压缩。
工程中的压缩主要是通过删除无用代码等减小文件体积,gzip压缩是在文件在网络传输过程中进行压缩。
2.1.2.3 缓存静态资源和关键接口
首先我们明确一下缓存对象:前端静态资源(如js/css/image等)以及对实时性要求不高但是在CRP中block后续渲染的接口(比如用户固定信息/菜单数据)等等。我们目前常涉及的缓存方案包括:CDN边缘节点缓存、HTTP缓存、service-worker缓存以及web存储API(localstorage/sessionstorage/indexdb)。我们一般不用Web存储API缓存静态资源,因为其空间有限。
CDN边缘节点缓存
CDN(内容分发网络)边缘节点缓存是指在CDN网络中的各个边缘节点(也称为缓存服务器)上存储的内容,以提高用户访问速度和降低源服务器负担。简单点说,就是拉近和终端用户的物理位置,缓存在离用户最近的CDN节点。
http缓存
http缓存分为 强缓存 和 协商缓存,http缓存流程如下。
如何判断强缓存是否过期?
Expires
Expires 是HTTP/1.0引入的头部字段,用于指定一个特定的日期和时间,在这个时间点,响应被视为过期。它的值是一个HTTP日期格式的字符串(绝对时间)。
Cache-Control
Cache-Control 是HTTP/1.1引入的头部字段,可以更精确地控制缓存策略。它可以包含多个指令,每个指令之间用逗号分隔。
public : 表示响应可以被任何缓存所存储。
private : 表示响应只能被单个用户的缓存存储,比如上方的CDN就无法缓存了。
no-cache : 强制缓存必须先去协商后端(即,再请求一次)再使用缓存。
no-store : 不允许缓存存储任何关于客户端请求或服务器响应的信息。
max-age=<seconds> : 指定响应的最大缓存时间,单位为秒。超过这个时间,缓存会被视为过期。
must-revalidate : 表示缓存必须在使用过期数据之前进行重新验证。
service worker缓存
service worker 是 PWA 的核心技术,用作网络浏览器和网络服务器之间的代理。它们旨在通过提供离线访问功能来提高可靠性,同时提升网页性能。
service worker 主要是针对弱网或者离线场景,在我们的 web性能优化 场景中,特别是中后台场景,相对于 http缓存 貌似并没有太大优势。
我们经过调研,准备用 service worker 采用 Stale While Revalidate缓存策略 缓存首次访问的html以及对实时性要求不高但是在CRP中block后续流程的接口。原因如下。
我们一般会向首屏html注入很多额外接口查询的数据,比如用户信息,导致对首屏html的访问有 150-300ms。经历了这150-300ms,后续请求和渲染流程才会启动。这个缓存策略可以提升全站访问速度 150-300ms(取决于html请求耗时)。
对实时性要求不高但是在CRP中block后续流程的接口,同理。至于这些接口为什么不尝试用localstorage等缓存?举个例子,我们的导航和加载器是umd方案引入的,如果每个都改造成本还是挺高的。通过 service worker 可以实现全局拦截。类似 filter 和 interceptor。
为什么采用 Stale While Revalidate 缓存策略?如果硬控html等10分钟,万一我们的某次发版存在bug,那就抓瞎了。
web存储API
web存储API 提供了几种机制来在客户端存储数据,使得web应用能够像桌面应用一样持久化数据。我们一般用 web存储API 对一些对实时性要求较低的数据接口(比如字典)进行缓存,提升页面性能。
localStorage
使用 localStorage 存储的数据是持久性的,这意味着除非用户或脚本显式地删除它,否则数据会一直存在。存储空间通常是 5MB 或更多。
sessionStorage
与 localStorage 不同,sessionStorage 中的数据只存在于浏览器的一个会话期间。当浏览器窗口关闭时,存储的数据会被清除。sessionStorage 的存储容量往往与 localStorage 相同,但在某些实现中可能会更少。
indexedDB
indexedDB 是一种客户端侧的 NoSQL 数据库技术,允许 Web 应用程序在用户浏览器中存储大量的结构化数据。与 localStorage 和 sessionStorage 相比,IndexedDB 提供了更加强大的数据存储功能,支持事务处理、索引以及复杂的查询能力。
一般 indexedDB 的存储空间在 250MB 及以上,如果用户同意的话,还可以进一步拓展。
2.1.3 优化资源请求顺序
2.1.3.1 关键资源尽早加载
dns-prefetch,DNS预获取
DNS预获取允许浏览器在页面加载早期就开始进行DNS查询。这对于跨域资源尤其有用,因为它可以减少由于DNS解析带来的延迟。用法如下。
<link rel="dns-prefetch" href="https://example.com">
preconnect,域名预连接
预连接允许浏览器尽早建立与第三方资源服务器的连接。这包括DNS查询、TCP握手以及TLS协商。通过预连接,浏览器可以在实际请求资源之前就准备好网络连接,从而加速资源的下载。用法如下。
<link rel="preconnect" href="https://fonts.gstatic.com">
如果资源需要HTTPS连接,可以添加 crossorigin 属性。
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
preload,提升资源加载的优先级
预加载允许开发者告知浏览器立即加载某些资源,即使这些资源不是立即需要的。这通常用于那些对用户体验至关重要的资源,比如关键css或js文件。通过预加载,浏览器可以在用户实际需要这些资源之前就提前加载它们,从而减少延迟。其中,as 属性指定了要预加载的资源类型。用法如下。
<link rel="preload" href="/style.css" as="style">
<link rel="preload" href="/script.js" as="script">
prefetch,预加载其他页面用到的资源
预获取允许浏览器在空闲时间预先下载将来可能需要的资源。这通常用于那些用户可能会访问的下一个页面的资源,例如链接到的其他页面。预获取是在用户尚未请求资源之前就开始下载资源的技术,它有助于减少未来请求的延迟。用法如下。
<link rel="prefetch" href="/next-page.html">
<link rel="prefetch" href="/image.jpg" as="image">
需要注意的是,过度使用 preload 和 prefetch 可能会导致不必要的带宽消耗,因此应该谨慎选择哪些资源进行 preload 或 prefetch。preconnect 和 dns-prefetch 同理,按需合理使用最好。
2.1.3.2 非关键资源延迟加载&执行
async
对于普通脚本,如果存在 async 属性,那么普通脚本会被并行请求,并尽快解析和执行。
对于模块脚本,如果存在 async 属性,那么脚本及其所有依赖都会被并行请求,并尽快解析和执行。
defer
defer属性的作用是告诉浏览器在HTML文档解析完成后且页面加载之前执行脚本。这样可以确保脚本不会阻塞页面渲染,并且所有元素都在DOM中可用。defer 会阻塞DOMContentLoaded事件触发。
关于async 和 defer的几个注意事项
async 和 defer 这两个属性会让相应js执行不阻塞主线程,但是其加载还是按正常顺序加载的。
执行只是延后,但是还是会在主线程上执行。
async设置后,其执行顺序是不确定的;如果对执行顺序有要求,可以使用defer。
按需加载
对于公共依赖,我们团队同学开发了一个umi插件——umi-plugin-runtime-import[1](umi-plugin-runtime-import-v4[2],针对umi4)。比如我们进行上方 umd + externals 加载方案改造的话,大量的umd前置加载会让首屏加载不堪重负。如果使用async方案的话,可能会出现页面代码执行了,umd还没执行完,从而引发白屏。runtimeImport插件可以对这些umd实现按需引入,只有当页面中使用了这个umd对应的依赖,才会去加载。从而解决 umd + externals 改造带来的首屏加载问题。
针对页面级别代码,umi自带dynamicImport实现按页拆包,按需加载机制。
这两个方案虽然是umi的插件机制,但是底层都是使用了 webpack 相关能力,其他工程体系也可以参考实现自己的方案。
2.2 页面渲染环节
2.2.0 优化思路
资源请求环节的优化是为了让页面渲染的资源尽可能快的ready,然后进行页面的渲染。在页面渲染环节,我们回顾一下页面渲染CRP。
浏览器对html进行解析,基于html和js脚本构建dom树,基于css构建cssom树,合成渲染树;
结合渲染树和屏幕分辨率等相关信息计算节点布局信息;
浏览器基于渲染树和布局信息进行页面绘制;
最后将不同的图层合并为最终的图像,出现在页面上;
首先,肯定离不开SSR这个老生常谈的优化方案。其次,我们当前重点关注CRP中几个常规web场景下的性能影响因素:JS代码、CSS代码、HTML代码、图片加载以及动画。可能不同业务场景下关注的重点不一样,其他的比如 WebGL、视频编码解码以及文档编辑器等等不再展开。
2.2.1 服务端渲染SSR
SSR(Server-Side Rendering,服务器端渲染)是一种网页渲染技术,它在服务器上生成完整的 HTML 页面,然后将其发送到客户端(浏览器)。与客户端渲染(CSR)相比,SSR 在用户请求页面时,服务器会处理所有的渲染逻辑,并将生成的页面直接返回给用户。
优点:更快的首屏加载时间;SEO 友好;更好的性能,降低一部分客户端负担。
缺点:需要服务器资源,且流量高场景下服务器负担较重,可能非C端场景团队内推动SSR不是那么容易;开发较复杂,特别是处理数据获取和状态管理时;交互存在延迟,也就是SSR页面初次加载时需要额外的js才能实现动态交互。
总结:真正使用还是要考虑场景和roi,相对SSR带来的收益,启用SSR带来的成本是否可以接受。
2.2.2 JS代码性能优化
2.2.2.1 DOM优化
减少 DOM 操作
DOM 操作通常是性能瓶颈,因为每次对 DOM 的修改都会导致浏览器重新计算样式、布局和重绘。减少 DOM 操作可以显著提高性能。
批量进行 DOM 更改
批量更新 是指将多个 DOM 操作合并为一次操作,以减少重排和重绘的次数。通常可以用 DocumentFragment 和 innerHTML 方案实现。
简化 HTML 代码
简化 HTML 代码是指减少不必要的 DOM 节点和复杂的结构,以提高性能。包括减少嵌套、移除不必要的元素。
使用 虚拟DOM
现代前端框架中,其实我们已经很少直接操作 DOM,而是使用 React 和 Vue 这类采用了虚拟DOM技术的前端框架。虚拟DOM 通过在内存中维护一个虚拟的 DOM 树,减少直接对真实 DOM 的操作,从而提高了性能和响应速度。
由于 虚拟DOM 本身特性(新旧树Diff)原因,在如下场景会导致一些性能问题:频繁的状态更新、组件树过于庞大等。不过这些问题目前也有成熟的解决方案。
事件委托
事件委托是一种高效的事件处理技术,主要用于减少事件处理程序的数量,提高性能和简化代码。它的基本原理是将事件处理程序添加到父元素上,而不是每个子元素上。
比如一个卡片包含100个循环渲染出来的数字,我们要对数字点击进行监听。一种方式是给每个数字上加监听,这样就要加100个监听器。另外一种方式就是直接给卡片添加一个监听器,结合事件冒泡机制,通过event.target判断是对哪个数字的点击,从而执行相应的操作。
及时删除不再需要的事件监听器
不再赘述,不用了就及时清理,减少内存占用,避免意外行为。
2.2.2.2 函数任务优化
Web Worker
Web Worker 是一种在浏览器中运行 JavaScript 的机制,允许开发者在后台线程中执行脚本,从而实现多线程处理。它的主要目的是提高网页的性能,尤其是在处理大量计算或 I/O 操作时,避免阻塞主线程(UI 线程)。详情参考 MDN Web Workers API[3]。
WebGPU
WebGPU 是一种新的 Web API,旨在为网页提供高性能的图形和计算能力。它允许开发者利用 GPU(图形处理单元)进行并行计算,从而加速图形渲染和数据处理。详情参考 MDN WebGPU API[4]。
长任务处理
在 JavaScript 中,长任务指的是那些需要较长时间才能完成的操作,例如复杂的计算、大量的数据处理或网络请求等。这些长任务如果在主线程中执行,会导致用户界面冻结,影响用户体验。
针对长任务,常见处理策略如下
将长任务分解为多个小任务,并使用 setTimeout 或 setInterval 将它们分批执行。这样可以让浏览器有机会处理其他事件(如用户输入、渲染等)。
将长任务移到 Web Worker 中执行。Web Worker 在后台线程中运行,不会阻塞主线程,适合处理复杂计算或大量数据。
对于需要频繁更新 UI 的长任务,可以使用 requestAnimationFrame 来分解任务。这样可以在浏览器的重绘周期内执行任务,确保流畅的动画效果。
节流
节流是一种限制函数在一定时间内只能执行一次的技术。它通常用于处理高频率的事件,确保在指定的时间间隔内只执行一次目标函数。
常见使用场景:滚动事件处理、窗口调整大小事件(与防抖结合使用)、定时更新数据(如 API 请求)等。
防抖
防抖是一种确保某个函数在一定时间内只被调用一次的技术。它通常用于处理用户输入事件,确保在用户停止输入后再执行某个操作。
常见使用场景:输入框的实时搜索建议、窗口调整大小事件、表单提交按钮的防止重复点击等。
2.2.2.3 React专项
React18引入了useTransition和useDeferredValue两个钩子,用于管理渲染性能,特别是在并发模式下。
React19 计划引入“自动记忆化”,即:不用手动编写 useMemo useCallback memo 来进行性能优化,通过全新编译器,React 19 可以自动检测组件的状态变化,并智能地决定是否要重新渲染。
hooks
useCallback / useMemo
useCallback / useMemo 都是为了优化性能而设计的Hooks,它们可以帮助我们在组件重新渲染时避免不必要的计算或函数创建
一般useMemo用来缓存值,useCallback用来缓存函数,不再赘述。详情自行参考文档:useMemo[5]、useCallback[6]。
useTransition(React18)
允许使用者标记一些状态更新为次要更新(non-urgent updates),从而让这些更新在用户交互暂停期间执行。这样可以避免由于大量数据更新导致的UI卡顿,提高应用的响应性。
useDeferredValue(React18)
与 useTransition 类似,但是它专门用于延迟某些值的变化,直到当前工作完成。这通常用于列表滚动或者其他需要立即响应的情况,比如滚动一个长列表时,希望在滚动结束后再更新某些值。
memo
memo 是一个高阶组件(Higher-Order Component,HOC),它用于优化纯函数组件的性能。主要作用是在组件的props未发生改变时避免不必要的重新渲染,从而提高应用的性能。
Fragment
Fragment 允许使用者将子元素列表分组,而无需添加额外的DOM节点到最终渲染的HTML结构中。
可以用于简化 DOM结构,减少 DOM嵌套。
优化列表渲染(Key)
React 可以使用 key 对列表渲染进行优化,key 的主要作用是帮助 React 标识哪个元素已被添加、移除或改变,从而使得 React 能够更高效地更新 DOM。正确使用 key 属性可以在 React 渲染大量列表时提高性能并减少内存消耗。
另外 Key 也可以用在非列表场景,比如:动态组件、条件渲染、HOC等。
懒加载
React 懒加载(Lazy Loading)是一种优化技术,用于在需要时按需加载组件,而不是一开始就加载所有的组件。此法可以显著提高应用的启动速度和性能,特别是对于大型应用或者有很多组件的应用。
React 中一般使用 lazy + Suspense 组合使用实现懒加载。针对使用了 lazy 的组件文件,打包工具会自动将其分割成单独的代码块,并在需要时按需加载。
虚拟滚动
虚拟滚动(Virtual Scrolling)是一种优化技术,用于高效地渲染大量数据的列表。当列表中的元素非常多时,如果一次性渲染所有元素,会导致性能问题,特别是在移动设备上。虚拟滚动通过只渲染当前可视区域内的元素来解决这个问题,从而大大减少内存占用和提高渲染性能。
虚拟滚动是一种通用技术方案,不限于 React。一般相关的适用场景,通过三方包直接接入即可。我一般直接用 ahooks 的 useVirtual,antd 中的相关组件也大多支持 virtual 选项,直接用即可。
使用合适的状态管理
合适的状态管理方案可以帮助我们更好地控制状态更新的时机和范围,从而减少不必要的重新渲染。
例如,使用 Redux 或 MobX 等状态管理库,我们可以更精细地控制哪些组件需要重新渲染。特别是大型或复杂的 React 应用。
SWR
SWR 是一个用于优化 React 应用程序中数据获取的 JavaScript 库,它由 Vercel(前身为 Zeit)开发并维护。SWR 的名字来源于其主要功能:“Stale While Revalidate”,这是一种缓存策略,旨在提高用户体验,减少延迟。
ahooks 中的 useRequest[7] 也是支持 SWR 的,但是看其 代码[8] 是缓存在内存中的。这样刷新页面后缓存的数据就会丢失,没办法达到 我关闭浏览器后重新访问仍能最快速度拿到数据 的效果。为了解决这个问题,我们自己实现了一下,并将数据缓存在 indexedDB中。
qiankun相关
prefetch
比较核心的子应用可以 prefetch。我们有一个页面 PV 超级高,且不是站点首页,甚至代码都不和站点首页在一起。这个页面所在子应用是由于业务因素中途接入进来,并且在其他地方也有引用,且还在持续迭代,也没法直接把这个页面搬运到我们主应用中来减少中间加载环节。
这个场景下,我尝试对这个页面所在子应用进行了 prefetch,通过 AEM 性能数据对比观察,有一定收益。
优化entry js文件体积
优化 entry js 体积有两个考虑。第一个,参考上方,让资源请求更快。第二个,子应用加载时,qiankun 会对 entry js 进行解析,这个解析过程也是耗时的。一般entry js体积越大,耗时越多,并且每次重新生成子应用都要经历一次解析过程。资源请求可以通过缓存优化,但这个解析耗时我们目前很难优化(下方贴两个 qiankun 优化 entry js 解析耗时的链接,有兴趣可以看看),所以优化 entry js 体积也会有一些受益。
[RFC] qiankun的极致性能优化思路,与import-html-entry有关。[9]
改了 3 个字符,10倍的沙箱性能提升?!![10]
关于优化 entry js 体积,我最近有过两次实践。
第一个是接入一个临时接手的子应用,该子应用由于历史背景+年久失修,umi js 高达6MB+(gzip压缩后)。我是把这个子应用部分页面嵌入到抽屉里,每次用户首次打开抽屉都要经历 5-6S 的转圈时间(qiankun 解析该子应用 umi.js 耗时)。我做了两次优化,第一次,将该 umi.js 体积 从 6MB+ 降低到了 1.8MB,转圈时间缩短到了 2-3S;第二次,将该 umi.js 体积 从 1.8MB 降低到了 834KB,目前转圈时间缩短到了 1S 以内。
第二个是针对我所负责的站点进行了一系列的优化,所有子应用 umi.js 体积降低 120KB - 200KB 不等,站点FCP 降低 200ms+。详见 2.1.2.1 减少构建产物体积——FCP75分位图。
2.2.3 CSS代码性能优化
CSS 模块化
可以通过社区成熟的 CSS Modules 方案,模块化CSS,达到按需加载的效果,只加载需要内容。
CSS 精灵图
CSS 精灵图[11] 是一种面试常说,但是我还没实践过的方案。精灵图将我们希望在站点上使用的多个小图像(例如图标)放入单个图像文件中,然后使用不同的 background-position[12] 值在不同的位置显示图像的一部分。这可以大大减少获取图像所需的 HTTP 请求数量。
但是在 HTTP/2 和 HTTP/3 中,由于引入了多路复用等新特性,精灵图的优势已经不像在 HTTP/1.x 时代那样明显。
CSS Containment
用 CSS 实现按需渲染的一种方案。CSS Containment 可以指示浏览器将页面的不同部分隔离开来,并独立地优化它们的渲染。这可以在渲染各个部分时提高性能。例如,可以指定浏览器在特定容器于视口中可见之前不要渲染它们。具体使用包括:contain、content-visibility。
contain,允许开发者精确指定要应用于页面上各个容器的 Containment 类型。这使得浏览器可以针对 DOM 的一部分重新计算布局、样式、绘制、大小或它们的任意组合。
content-visibility,允许开发者在一组容器上应用一组强大的局限,并指定浏览器在需要之前不要布局和渲染这些容器。
优化 CSS 写法
删除不必要的样式。所有脚本都会被解析,无论它在布局与绘制时是否被使用,因此删除无用样式可以加速网页渲染。目前似乎没有什么低成本方案去除打包产物中无用的CSS,我们编码时要养成好习惯。
不要将样式应用于不需要的元素。比如将通用选择器将样式应用于所有元素,这种类型的样式会对性能产生负面影响,特别是在较大的站点上。
简化选择器。冗余的选择器不仅会增加文件大小,还会增加解析的时间。
2.2.4 HTML代码性能优化
响应式处理替代元素
借助 CSS 媒体查询 技术,实现根据设备尺寸,提供不同尺寸图像。以此来提高性能。比如移动设备只需下载适合其屏幕的图像,无需下载更大的桌面图像。
要根据设备的分辨率和视口大小提供相同图像的不同分辨率版本,我们可以使用 srcset 和 sizes 这两个属性。srcset 提供源图像的固有尺寸以及它们的文件名,而 sizes 在每种情况下提供媒体查询和需要填充的图像槽宽度。然后,浏览器根据每个槽位决定加载哪些图像。
<img
srcset="480w.jpg 480w, 800w.jpg 800w"
sizes="(max-width: 600px) 480px, 800px"
src="800w.jpg"
/>
为图像和视频提供不同来源
picture 基于 img,允许我们为不同的屏幕尺寸提供多个不同的源。例如,如果布局是宽的,我们可能希望有一个宽的图像;如果是窄的,我们会希望有一个在该上下文中仍然有效的较窄的图像。source 的 media 属性中包含媒体查询。如果媒体查询返回 true,则加载 source 的 srcset 属性引用的图像。
<picture>
<source media="(max-width: 799px)" srcset="narrow-banner-480w.jpg" />
<source media="(min-width: 800px)" srcset="wide-banner-800w.jpg" />
<img src="large-banner-800w.jpg" alt="Dense forest scene" />
</picture>
video 类似 picture,但在使用上存在一些差异。video中,源文件使用的是 src 而不是 srcset;另外多了一个 type 属性指定视频格式,浏览器将加载其支持(媒体查询测试返回 true)的首个格式。
<video controls>
<source src="video/smaller.mp4" type="video/mp4" />
<source src="video/smaller.webm" type="video/webm" />
<source src="video/larger.mp4" type="video/mp4" media="(min-width: 800px)" />
<source
src="video/larger.webm"
type="video/webm"
media="(min-width: 800px)" />
<!-- fallback for browsers that don't support video element -->
<a href="video/larger.mp4">download video</a>
</video>
iframe懒加载
通过给 iframe 设置 loading="lazy" 属性,我们可以指示浏览器对最初屏幕外的 iframe 内容进行懒加载。
2.2.5 图片加载性能优化
适当的缩放、裁剪、压缩
当我们收到设计同学给的图,如果展示要求不是那么高的话,我们可以相关平台或者工具对图片进行二次处理(比如缩放、裁剪、压缩等),降低图片体积。
合适的图片格式
webp 格式的图片比 png/jpg 有着更优秀的算法,在图片体积上会比 jpg/png 更小,从而加载的更快、耗费的带宽更少。下图是 webp 兼容性情况。
响应式加载
参考 2.2.4 HTML代码性能优化--为图像和视频提供不同来源。本质就是不同尺寸屏幕加载不同体积大小的图片,根据实际情况按需加载,不浪费带宽。
load="lazy"
loading="lazy" 属性值表示该图片应该使用懒加载(Lazy Loading)的方式加载,即只有当图片即将进入视口时才开始加载。类似 CSS Containment,给图片应用 content-visibility,也可以实现类似懒加载效果。
两者的区别是,设置 load="lazy" 后,图片资源的网络请求不会在解析 img 标签时直接发出,而是接近视口时才发出;而 content-visibility 控制的只是渲染,和图片资源的网络请求无关。
fetch-priority="high"
fetch-priority 属性允许开发者明确指定某些资源的加载优先级,当我们希望某些关键资源(如重要的图片或样式表)能够尽快加载时,可以使用 fetch-priority="high"。
decoding="async"
decoding="async" 可以指示浏览器异步解码图片,从而提高页面的渲染性能。这个属性特别适用于那些不需要立即显示的图片,例如位于页面底部的图片或懒加载的图片。
2.2.6 动画性能优化
动画使用原则
去除任何非必要的动画、尽可能简化动画 或者 懒加载动画。特别是中后台场景,避免滥用。
动画实现原则
尽量在 GPU 上进行动画处理
使用3D变换动画,例如 transform: translateZ() 和 rotate3d()。
具有某些其他属性动画的元素,例如 position: fixed。
应用了 will-change 的元素。
特定的在其自己层中渲染的元素,包括 <video>、<canvas> 和 <iframe>。
优先使用 CSS 实现
尽可能避免使用如下属性
修改元素的尺寸,例如 width、height、border 和 padding。
重新定位元素,例如 margin、top、bottom、left 和 right。
更改元素的布局,例如 align-content、align-items 和 flex。
添加改变元素几何形状的视觉效果,例如 box-shadow。
优先使用如下属性,减少重排(回流)
opacity
filter
变换
其次可使用 Web Animations API 实现
Web Animations API 提供了许多高级功能来帮助开发者创建更高效、更流畅的动画效果。比如细粒度的控制、关键帧管理、动画合成等等。
可以同时结合使用 requestAnimationFrame 实现
requestAnimationFrame 可以保证回调函数在浏览器的下一个重绘(repaint)之前调用,这确保了动画帧之间的同步性,意味着动画会尽可能地与浏览器的刷新频率同步,从而提供更流畅的视觉效果。同时浏览器会智能地调度 requestAnimationFrame 回调,当页面不可见(例如最小化窗口或切换到另一个标签页)时,动画会自动暂停,从而节省计算资源。
三、常见分析工具
可以通过网络自行研究用法。
3.1 chrome自带分析工具
3.1.1 performance面板
用于分析测试网页性能。
3.1.2 lighthouse面板
用于分析测试网页性能。
3.1.3 network面板
用于查看网页网络资源加载情况。
3.1.4 性能数据分析面板(即将被移除)
用于分析测试网页性能,但即将弃用。
3.2 三方测评站点
用于分析测试网页性能。
3.2.1 pagespeed
3.2.2 webpagetest
3.3 webpack分析插件
Webpack Bundle Analyzer
用于分析 Webpack 构建输出的工具。它能够帮助开发者更好地理解构建产物(bundle)的组成,包括各个模块、库和资源文件的大小分布情况。
Source Map Explore
用于分析 JavaScript 源代码大小及其在打包后(如使用 Webpack 打包后的 bundle 文件)所占比例的工具。
umi,analyze=1
umi内置分析产物构成体积分布方案。
3.4 VSCode 插件
Bundle Size
用于实时查看 Webpack 构建产物大小的插件。
四、总结
本文介绍了一些常见的性能优化策略以及用于调试优化效果的性能分析工具。我们在实际性能优化过程中,可以对自己要优化的目标项目进行分析,然后采用适合的策略去解决具体问题,最后用性能分析工具去验证优化效果。前端技术在持续发展,性能优化策略也会与时俱进。我们可以大胆尝试,小心求证。
路漫漫其修远兮,吾辈上下而求索.
参考链接:
[1]https://www.npmjs.com/package/umi-plugin-runtime-import
[2]https://www.npmjs.com/package/umi-plugin-runtime-import-v4
[3]https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers
[4]https://developer.mozilla.org/en-US/docs/Web/API/WebGPU_API
[5]https://zh-hans.react.dev/reference/react/useMemo
[6]https://zh-hans.react.dev/reference/react/useCallback
[7]https://ahooks.js.org/zh-CN/hooks/use-request/cache#swr
[8]https://github.com/alibaba/hooks/blob/master/packages/hooks/src/useRequest/src/utils/cache.ts
[9]https://github.com/umijs/qiankun/issues/1555
[10]https://www.yuque.com/kuitos/gky7yw/gs4okg
[11]https://css-tricks.com/css-sprites/
[12]https://developer.mozilla.org/zh-CN/docs/Web/CSS/background-position