0. 写在前面的废话
性能优化是软件研发领域经久不衰的话题,而前端作为用户与软件打交道的一扇窗,其性能优劣决定了用户是否愿意在你的软件系统中多愁两眼。而 Web 作为最主流的前端载体,Web 性能优化则无论是日常谈资亦或前端面试中都是津津乐道的话题。我想身为前端工程师的你,脱口而出两个性能优化建议并非难事,但如果真的致力于将 Web 系统性能优化到极致,则需要有完备的知识体系和孜孜不倦的探索精神。我的微信公众号:谈天说地博古论今
1. 性能优化前的准备
性能优化不是一声令下就立马开干的,需先高屋建瓴的思考应该从哪些方面优化,如何衡量性能优化的效果。通常我们从4个角度来进行性能优化,分别是:
- 加载链路优化
- 加载体积优化
- 感官体验优化
- 运行时性能优化
衡量的维度就是速度和体积。网页的加载速度,通常采用 FPT 或 FMP 等指标来衡量。我们需要借助一些工具来采集相关数据,进而计算出相关指标参数,业界知名的指标有:Apdex 指数、RUM 指数、TTFB 指数、TTI 指数等。Apdex 的公式如下:
Apdex = (满意数量 + 一般数量 / 2) / 总请求数量
对于网站的满意度与网站自身的性质有关,如可认为 FMP 为1秒则非常满意,3秒内则一般满意,而超过3秒则体验就非常差了。基于此可计算出网站的 Apdex 指标,通常 Apdex 指数在 0.85 以上可认为网站的用户体验是非常好的。
关于体积优化,通常需收集统计包总体积大小、首屏大小、最大延迟组件、资源压缩率等。需关注分包策略是怎样的,图片、CSS是抽离的还是内联的等基本信息。
性能优化是一项长期工程,需首先建立指标体系,然后不断优化,提高指标数据。
2. 加载链路优化
HTTP 请求,始于客户端,终于客户端,这一去一回就是链路,优化这条链路是性能优化中最重要的方向。链路优化可从如下角度着手:
2.1 尽量避免发送 HTTP 请求
这里聊的是缓存的话题,所以也是指避免再次发同样的请求,当然第一次发是不可避免的。
-
合理利用浏览器的缓存策略。浏览器缓存策略包括强缓存和协商缓存。强缓存浏览器将资源缓存在本地磁盘,资源不过期则不会发起 HTTP 请求。协商缓存,请服务器来判断资源是否更新,如无更新可继续使用本地缓存。通常的最佳实践是,对于有 hash 的资源可采用强缓存,对于 HTML 和无 hash 的资源应采用协商缓存。强缓存有关的 header 有:Cache-Control(max-age http1.1)、Expires(过期时间 http1.0),与协商缓存有关的 header 有:Etag / If-None-Match(修改版本号相关)、Last-Modified / If-Modified-Since(修改时间相关)。
-
可以使用 PWA 中的 Service Worker 的 Cache API 来缓存资源,以实现更灵活更细粒度的缓存方案,当然它的设计初衷是应用的离线访问。
-
移动端离线包缓存技术,可将整个资源包内置在移动端,这样访问移动端页面时都是本地请求,对访问速度提升极大。不过离线包方案需考虑版本更新机制,当有更新的线上版本时,需能自动拉取到最新的上线版本并缓存到本地。
-
应用层对于多个模块共享的数据,也可集中维护本地缓存,无需重复发请求。
2.2 尽量少发 HTTP 请求
HTTP 是基于 TCP 的协议。HTTP 1.0,每个 HTTP 请求都需要进行 TCP 3次握手,请求完成后再进行4次挥手,效率极低。HTTP 1.1,引入了持久连接(Connection),即 TCP 建立连接后,可传输多个 HTTP 请求,而连接一直保持。HTTP 1.1 虽然解决了 TCP 连接的问题,但是通常浏览器会限制单域名下 TCP 链接的个数。这样如果1个或多个 TCP 连接很慢阻塞了,依然影响加载速度,因此应尽量减少 HTTP 请求的数量,常见的方法有:
-
小图片合并为雪碧图。
-
小图片内联到 CSS 中。
-
CSS 内联到 JavaScript 中。
-
JavaScript 文件通过打包合并在一起。
-
API 接口请求尽量合并。
-
减少301重定向。
以上是减少 HTTP 请求数量的手段,但不代表推荐你用,需自行测试。如小图片内联,多小算小呢?内联到 JavaScript 中,是不是 JavaScript 体积增大很多下载变慢。JavaScript 都合并在一个文件中显然也不现实。
另外,HTTP2 中,通过1个 HTTP 长连接实现多路复用技术,可并行发起 HTTP 请求,各请求可设置优先级,服务器可随时响应对应的请求,还可按优先级响应。因此使用 HTTP2 协议的网站,并不需要刻意减少 HTTP 请求,相反甚至可能会将 JavaScript 拆分得很小,这样会出现大量的 HTTP 请求。
HTTP2 是对 HTTP1.1 的重大改进,升级到 HTTP2 是重要的优化手段,但升级依赖于 Web 服务器及所有中间设备都支持 HTTP2,好在 HTTP2 已发布多年,HTTP2 的使用已较为普及。
2.3 尽量减少 HTTP 请求与响应的报文大小
-
header 压缩。HTTP1.1 中是不支持对 HTTP header 进行压缩的。HTTP2 的其中一个特性就是通过 HPACK 算法进行请求头压缩。
-
响应体压缩。通常通过 Accept-Encoding 请求头和 Content-Encoding 响应头,来进行内容压缩协商,在服务器支持压缩算法的情况下即可运行时自动压缩请求。
-
对请求进行预压缩,也是一个常见的方案。资源预压缩后,服务器则不会再次压缩,可缓解服务器执行压缩时的性能损耗。
-
业务层合理的数据结构,减少冗余数据的发送。
2.4 资源的预加载和延迟加载
- 对于当前页面中使用的关键资源,应使用 preload 提前加载资源。preload用于加载通常较晚发现的资源,是否有必要使用 preload 可以通过 Lighthouse 工具观察并评估。
<link rel="preload" href="main.js" as="script">
- 对于未来可能用到的资源,使用 prefetch 异步加载,用于优化跳转体验。浏览器会在空闲时间预加载声明了 prefetch 的资源,浏览器并不会立即解析资源即并不会阻塞渲染。
<link rel="prefetch" href="news.js" as="script">
-
非关键 JavaScript 设置 async。默认情况下,JavaScript 的下载是会阻塞渲染的,因为下载的 JavaScript 可能会影响 HTML 的渲染,但如果我们明确知道这个 JavaScript 并不影响渲染,则可以显式设置 async。这样脚本下载完成后,异步执行,不阻塞渲染。适用async的场景:依赖的 DOM 已经渲染或不依赖 DOM,此脚本不依赖其他脚本(与顺序无关)。
-
对于要修改 DOM 或样式的 JavaScript 应设置 defer,这样不阻塞页面加载和渲染。适用 defer 的场景:所有 DOM 加载完才执行,各 defer 脚本有先后顺序保证。defer 和 async 都是将资源声明为非关键资源,这样就不会阻塞渲染,它们的区别并不大,如果脚本可独立执行可设置 async,否则用 defer 即可。
-
对于非首屏的资源(不可见区域的 DOM)和数据可延迟加载或懒加载。
2.5 域名解析加速
网页请求的第一步就是域名解析,页面中依赖资源的域名都需进行域名解析。更快的域名解析意味着更快的网页加载。域名解析加速的策略包括:
-
选择优质的域名服务注册商。
-
对页面中使用的所有域名地址,如CDN,配置 dns-prefetch。
<link rel="dns-prefetch" href="https://www.mycdn.com/" />
- 拥抱 HTTPDNS,HTTPDNS 是基于 HTTP 协议的 DNS,域名解析请求绕过运行商的 Local DNS,而直接发送到 HTTPDNS服务器,调度精准,延迟低,客户端对接下 HTTPDNS 服务商的 SDK 即可。
2.6 链路加速
-
高带宽、强CPU的服务器、优质的云服务意味着更快的响应。
-
静态资源上CDN,利用CDN加速,优化加载距离。同时通过域名分片解决了 HTTP1.1 同域域名个数请求限制。
-
使用7层负载均衡的特性:TCP 链接复用、HTTP 压缩、SSL 加速等。
2.7 页面渲染机制优化
-
服务端渲染。如使用 Nextjs,页面组件和数据在服务端完成渲染,客户端只需渲染静态页面,可有效提升页面加载速度。
-
客户端预渲染。对于静态页面,可通过打包时的客户端预渲染。
3. 打包体积优化
无论是 HTML、CSS、Javascript,本质上都是文件。体积越小意味着加载速度越快。这里体积包括整体的体积和单个文件的问题。打包体积优化可从如下角度着手:
3.1 合理使用打包工具
-
代码压缩。所有资源,HTML、CSS、JavaScript 都应该是经过压缩的。Webpack 中有现成插件可以很容易做到:用 html-webpack-plugin 插件配置 minify 来压缩 HTML,用 optimize-css-assets-webpack-plugin 插件压缩 CSS,用 terser-webpack-plugin 插件来优化压缩 JavaScript 代码。
-
使用 Tree-shaking 摇树优化。Tree-shaking是一种打包时静态分析代码来删除没有使用的代码的技术,从 Webpack2 开始支持,当我们设置mode=production时,会自动启用 Tree-shaking。需注意,Tree-shaking 只能针对 ES6 编写的模块进行优化,而不能是 CommonJS。
-
优化polyfill。polyfill俗称垫片,是为了解决低版本浏览器不支持ES6+特性而引入的库。早期的方案是引入 babel-polyfill,但其不支持按需加载,会全部引入到代码中。目前推荐使用 core-js + @babel/preset-env + useBuildIns。
-
合理的 .browserslistrc。根据用户不同版本浏览器占比设置合适的browserslist。
-
选用更小的 lib 库,如可以使用 day.js 替代 moment.js
-
合理的分包策略。常见的分包策略有2种:通过 CommonsChunkPlugin 插件提取公共代码,不过从 Webpack4 开始不再推荐使用,已经被更优秀的 SplitChunksPlugin 替代,通过设置 optimization.runtimeChunk 来分包;还有一种方式,通过 DllPlugin 和 DLLReferrencePlugin 将公共的库单独打包,这种方式可使得多个应用之间共享不频繁修改的代码,加快构建速度。
3.2 按需加载、懒加载
复杂的应用,通过按需加载、懒加载对应的路由、组件等。按需加载和懒加载,英文单词是不一样的,On-demand loading 和 Lazy loading,不过在实际使用中有混用的嫌疑。
懒加载,根据加载的场景可以分为:路由懒加载、模块懒加载、非首屏懒加载。不过他们的原理都是类似的,都是通过 import 函数动态加载一个组件,这时 Webpack 会将这些组件单独打包,生成一个独立的代码块,和主代码块分离,只在需要使用时才加载。
React 中,可以通过 React.lazy(() => import('path/to/component'e)) 函数动态引入 React 组件,对于动态引入的组件,需使用 Suspense 组件包裹。Suspense 可以设置 fallback,当懒加载的组件未完成渲染时,显示 fallback 组件。
const Home = lazy(() => import('./Home'));
const About = lazy(() => import('./About'));
<Suspense fallback={<div>Loading...</div>}>
<Switch>
<Route exact path="/" component={Home} />
<Route path="/about" component={About} />
</Switch>
</Suspense>
通常我们做到路由层面的懒加载就可以了,至于是否需要针对组件懒加载,需自行定夺。
3.3 预压缩
上文介绍减少 HTTP 请求和响应报文时提到可以利用 Web 服务器的特性,自动压缩 HTTP 响应头。也提到可以自行对数据进行压缩,以减少对服务器的压力。称为预压缩。
compression-webpack-plugin 插件就是用来做资源预压缩的,它可以对 JavaScript、CSS、HTML 等文件进行压缩。
常见的 Web 资源压缩算法有:gzip、brotli,其中 brotli 更先进、压缩率更高,不过对于服务器 CPU 资源和浏览器兼容性要求较高。条件允许的前提下,可以优先使用 brotli 算法。
3.4 图片及字体优化
- 合适的文件格式
常见的图片格式有:gif、png8、png24、jpg、svg、webp、apng
gif:支持动画,压缩率高,但色彩空间小,不适合大图。适合简单动画。
png8:色彩精度高,透明度表现好,适合需要透明度的简单图片。
png24:色彩精度高,色彩复杂,支持透明,无损压缩,适合大图。
jpg:压缩比高,有损压缩,
svg:简单矢量图。
apng:支持透明和动画,文件小,低版本浏览器不支持。
webp:压缩率高,支持透明和动画,低版本浏览器不支持。不考虑 ie、低版本safari、低版本Android的情况下是不错的选择。
评估图片格式的选择,主要就是是否支持动画、是否支持透明、图片色彩精度、压缩质量、浏览器兼容性。
- 为不同分辨率提供不同的图片尺寸
对于2倍屏、3倍屏使用2倍图、3倍图。
- 压缩图片
可以使用在线压缩网站对图片进行压缩,如 tinypng.com
而事实上 image-webpack-loader 就可以打包时对图片进行压缩,还支持将图片转化为 webp 格式,以及其他压缩算法。
怎么说呢,讲了一大堆,可能忧伤的是,图片大小是影响包体积的主要因素。找出项目中所有的大图片来压缩下,一定可以有效降低包体积。
- 图片懒加载
对于非首屏显示的图片可以采用图片懒加载。
传统的图片懒加载做法是,可通过不设置 src 而改为使用别的属性如 data-src 来存储图片的真实路径。监听滚动,图片到达可视区域而现实图片。
不过通过修改 src 来实现懒加载是一个过时的方案,目前浏览器的 img 标签都支持设置 loading=lazy 来实现懒加载。
- 小图优化
小图片太多,HTTP1.1 加载是有性能瓶颈的。可通过制作为雪碧图、iconfont 来管理。
4. 感官体验优化
上文从 HTTP 的加载链路,以及资源体积的角度分析了性能优化的技术。其实优化感官体验也是重要的方面。优化感官体验通常包括:
- 骨架屏
在真实数据还未返回的情况下,先显示骨架屏,让用户感知到有内容正在加载。如何开发一个和真实显示效果类似的骨架屏,用什么样的通用方案是一个挑战。否则这骨架屏也没啥意义。
- loading 动画
事实上大部分情况下,使用 loading 就可以优化感官体验。对于白屏时或路由跳转时一定要有 loading。loading的一个挑战时,去除 loading 的时机,不良的代码可能导致在异常情况下不能正确去除 loading,导致整个网站不能响应,则得不偿失。
- 加载占位图
在网站真实图片未能及时加载的情况下,可考虑使用占位图。占位图的尺寸需与真实图片相同,图片应尽量小,真实图片完成加载后切换流畅。
- 渐进式加载图片
渐进式加载图片是一种优化用户体验和提高图片加载速度的技术,它可以在图片加载过程中,让用户看到图片的模糊轮廓和一些基础结构,以使用户感到页面在加载,从而提高用户对页面的加载感知速度。
首先需将图片转化为渐进式 JPGE 格式,此时图片的数据就是逐步加载的,图片的清晰度随着下载的进度而逐步清晰。
- 响应顺序优化
HTTP2 中可以设置请求的优先级。不过即使在业务中,我们也应通过编码实现重要业务的优先展示。
- 首屏优化
感官优化最重要的是优化首屏,首屏的速度决定了用户的留存率和转化率。
5. 运行时优化
以上介绍的都是最基本(最重要)的优化,接下来才是针对代码的考验硬实力(效果最不明显)的优化。
- HTML优化
减少冗余标签嵌套,避免 HTML 标签中内联样式,能用样式就不要用标签,CSS 尽可能放到 head 中等
- CSS 优化
抛弃传统的布局模型,尽量使用 Flexbox,Css Grid,谨慎使用 !important,内联首屏关键CSS,优化CSS去除无用CSS,选择器不要太长,减少昂贵的CSS属性如 border-radius 等。
- JavaScript优化
减少同步加载、避免阻塞页面
避免频繁操作 DOM,如采用虚拟 DOM 技术
防抖与节流,最小化限制代码的执行次数
避免使用闭包造成内存泄露
及时移除事件监听避免内存泄露
虚拟列表优化,而不是把所有数据都渲染到列表中
能用CSS实现的,就不要用JS,减少重排和重绘
用正则还是字符串查找
双层 for 循环的优化,switch 和 if 的选择
数组值单独保存为变量,避免频繁访问索引
6. 结束语
我想写了这么多,其实还是理论派,也许应对面试是可以的,但如果要针对企业的业务环境进行优化,是一件非常复杂的事情,每一项技术的落地需要雄厚的技术实力和丰富的实践经验,才能综合评估一个更合理的方案。
但有时性能优化又是一门玄学,不知道你信不信,性能又不是越快越好,其中韵味留于君自行揣摩。
本人从事前端多年,也喜欢写作,但都是写些乱七八糟的东西,前端的技术文章好没好好写过几篇。以后得加油输出了,在掘金多于大家交流学习。也欢迎大家关注我的微信公众号:谈天说地博古论今