通天塔搭建页项目是用来搭建各类活动页面,比较老且业务复杂的项目,可优化点还是非常多的。今年侧重对运营页首屏加载的性能优化,在保证系统稳定可控、需求持续迭代前提下,最终提升了 58.8% 速度。在此非常感谢通天塔产品组、后端组、前端组同学,对项目性能优化大力支持。
回顾一年的不断探(cai)索(keng),得出的感受的是:
选择大于努力了,努力的方向不对,想取得成果就会越来越费劲,事倍功半;方向选对了,事半功倍。
性能优化是长期的工程,需要优先确立正确的分析方法,真正且更早地找出系统的症结所在,而不是想当然或者仅停留于表面现象来下判断。
市面上有很多性能优化方案,数不胜数,但如果开始就只是模仿一些边边角的优化,虽然也会略有效果,但不一定能给系统解决核心卡顿问题,不能给系统带来质的提升,甚至到后面优化效果越来越差,这些优化可能会冲突、可能变成负优化。而且如果不去优先分析核心的问题,可能就会一直忽视核心问题,导致系统长期处于低效低体验的状态,随着业务复杂变得越来越卡。
分析不同类型资源大小、优先级、时间细分数据、请求和响应详情、缓存利用情况。还可以模拟不同网络环境。
https://developer.chrome.com/docs/devtools/network?hl=zh-cn
从瀑布流 Waterfall比较直观看出,资源等待时间、加载时间、加载时机,对页面整体加载有一个总览。
分析网页在运行时(而不是加载)时的性能。具体分析页面每个阶段哪些方法在执行,哪些资源在加载,很容易看出哪里在阻塞。
也可以用来设置不同的 cpu 和网络环境。由于开发者电脑一般性能比较好,模拟较差性能和网络也是有必要的。
https://developer.chrome.com/docs/devtools/performance?hl=zh-cn
官网给出分析顺序是,
1)先看 摘要 Summary 标签页,分析到底是加载、执行脚本、渲染、绘制哪一个最占时间,再去决定主要优化方向。
如果是加载速度慢,再去分析网络、资源大小、加载优先级、缓存等。
如果是执行脚本慢,再去分析代码执行顺序,看哪些任务执行时间长或者执行次数过多。
如果是渲染速度慢,还要具体分析,
(a)渲染 dom 太多则可以考虑虚拟滚动 ;
(b)图片的原因则考虑优化图片格式、压缩图片大小、图片懒加载、低质量图片过渡、图片异步解码;
(注:优化图片格式是指选择压缩率高、解码速度快、同时画质良好的图片格式)
(c)重新计算样式导致强制自动重排,需要尽量避免大型、复杂的布局和布局抖动;
还有其他方式可专注优化可视区域的渲染。
2)再展开主要 Main部分。开发者工具会向您显示主线程上的活动随时间变化的火焰图。x 轴表示一段时间内的记录。每个条形都代表一个事件。横条越宽,表示事件用时越长。y 轴表示调用堆栈。如果您看到事件相互堆叠,则表示上层事件导致了下层事件。
3)放大上方Overview,找到任务右上角的红色三角形,定位到耗时较长的任务,再定位到具体的方法中。
对于长任务,官网还有进一步优化建议:
https://web.dev/articles/optimize-long-tasks?utm_source=devtools&hl=zh-cn
懒加载相关建议:
https://developer.mozilla.org/zh-CN/docs/Web/Performance/Lazy_loading
主要分析 js 打包,查看具体打包情况,考虑如何对体积过大的包 “瘦身”。
常用手段有懒加载、全局引入第三方插件、删除冗余包、删除重复打包。
可以分析不同资源使用率情况。对于低使用率且较大体积的文件,考虑懒加载、移除无效代码。
Lighthouse 是 Google 开发的一款自动化的开源工具,用于提升网页质量。您可以针对任何公开网页或需要身份验证的网页运行它。它包含对性能、无障碍功能、渐进式 Web 应用、搜索引擎优化 (SEO) 等方面的审核。
https://developer.chrome.com/docs/lighthouse/overview?hl=zh-cn
主要是给页面综合评分,还有 FCP、LCP、TBT、CLS 分析。如果评分还有不足的地方,那就还有较大优化的空间。
也可以像 webpack-bundle-analyzer 装在本地运行。
1,加快资源请求速度
2,减少请求资源大小
3,减少请求资源数量
4,优化代码逻辑
5,优化业务逻辑
1)充分利用缓存
个人体验感觉,缓存带给页面性能提升是最明显的。
资源类:http 缓存、cdn 缓存、service worker;
数据类:cookie、localStorage、sessionStorage、indexDB;
像 base64 的图片可以当数据类存储。
还有函数内部的缓存;
2)http 相关优化
http/https1.0、1.1 版本下需要减少同域名下的请求
不同浏览器支持并发的情况不一样,比如 chrome 浏览器 http/https1.0、1.1 版本下有并发请求限制,在同一域名情况下:
- 同一 get 请求的并发数是 1,即只有上一个请求结束,才会执行下一次请求,否则即在队列中等待请求;
- 不同的 get/post 的请求的并发数量是 6 个,当达到 6 个时,其余的在队里中等待请求;
所以需要把资源分布放在多个域名下,类似 cdn1.jd.com,cdn2.jd.com,cdn3.jd.com。
如果有条件的尽量升级 http2 或者 http3,可实现多路复用:通过该功能,在一条连接上,您的浏览器可以同时发起无数个请求,并且响应可以同时响应。另外,多路复用中支持了流的优先级(Stream dependencies)设置,允许客户端告知服务器最优资源,可以优先传输。
升级后还有其他优点,比如也会对请求头压缩,进一步提升请求速度。
根据 Cloudflare 截至今年 4 月的统计,目前最普及的协定仍是 HTTP/2,市占率超过 60%。
3)预解析资源 Prefetch
html 代码里加入这样的 link 标签实现预解析,类似以下:
<link rel="dns-prefetch" href="//cdn1.jd.com">
<link rel="dns-prefetch" href="//cdn2.jd.com">
<link rel="dns-prefetch" href="//cdn3.jd.com">
普遍来说合理的 dns prefetching 能对页面性能带来50ms ~ 300ms的提升。
4)预加载资源 Preload
比如字体文件
5)所有资源走 CDN
不止是静态资源,有条件的话,尽量让所有资源都走 CDN,有效提升加载速度,减少白屏时间。
首先,我们先用分析下哪些模块代码比较大,需要优化。
1)尽量按需加载模块,尽量避免整个包都引入
比如:
(a)一些比较明显的全局引入,代码里存在的 * 引入。
import * as xxxx-ui from 'xxxx-ui'
const Button = xxxx-ui['Button']
最好都改成按需加载,确保打包时候不会把整个库都打进去。
import { Button } from 'xxxx-ui'
只要支持基于 ES modules 的 tree shaking,就会有按需加载的效果。
不过有的引入比较隐蔽,不容易直接发现,只有进行打包分析后才能实际发现多种情况打包了多余的包。
(b)引入子包写法不正确
比如引入加密包不正确,引入导致打包体积很大。
import crypto from 'crypto'
const str = 'xxxxxxxxx'
const sign = crypto.createHash('md5').update(str).digest('hex')
实际只用了 crypto 的 md5 方法,但却把整个 crypto 包和其依赖的包都打包进代码里。
后续改成只引入 crypto 的 md5 方法就能起到一样的效果。
import md5 from 'crypto-js/md5'
const str = 'xxxxxxxxx'
const sign = md5(str).toString()
就可以把上面这个 js 里绝大部分的代码都省去了,最后把这个 js 在 Gzipped 下从 190KB 减少到 3KB。
类似的 lodash 也有这样的情况,通过改变写法,也可以改善打包大小,在 Gzipped 下从几十 KB 减少到几 KB。
只需改写下,单个引入 lodash 里的方法
比如,antd3.0 的组件引用也有类似问题,需要改写引用代码按需加载组件;
比如,仅修改通天塔列表 cms 项目里所有的 data-kit 引入方法,就让整体打包 js 体积缩小了 50%;
(c)引入某个包的样式文件也有可能导致整个包都打包进来
比如 打包分析和排查代码时,发现 braft-editor 这个编辑器实际没有使用了,但引入样式的代码没注释掉,导致整个 braft-editor 包都被打包进来。后续注释掉,直接节省 Gzipped 下 114KB 体积。
// 引入编辑器样式
import 'braft-editor/dist/index.css'
⚠️注意:如果发现按照上述几种方法,修改了引入方法,但打包分析里依然还是完整引入,那么这里情况就会比较复杂,只能上网找类似问题了。有可能是想不到的其他地方隐晦全量引入,也有可能是写法还是不对,也有可能是打包配置不对,甚至有可能就是那个包的版本实际不支持按需引入。
2)不重要的代码模块懒加载(dynamic import)
比如 React.lazy,
常规引入:
import Header from './Component/Header'
<Header />
懒加载处理后:
const Header = React.lazy(() => import(/* webpackChunkName:"Header" */ './Component/Header'))
<React.Suspense fallback="">
<Header />
</React.Suspense>
fallback 可以为空,也可以用一些 Spin(加载中)、Skeleton(骨架)或者简化业务组件来占位、过渡。
⚠️注意 1:还是不能滥用,主要针对低优先级或者体积过大的模块、组件。
⚠️注意 2:实际发现 setTimeout 也可以懒加载一些文件,不过不太推荐使用。
setTimeout(() => import(/* webpackChunkName: 'xxx'*/ './xxx.less' ), 500)
3)使用更精简的代码或者包替代原先的包
(a)原生方法或者手写方法(保证性能的前提下)
比如,在安全性不重要的场景下,可以只用原生的编码方法 atob、btoa 或者再封装的方法来替代加密包的方法;
(b)更小的包
比如,使用 Day.js 替换 momentjs 优化打包大小,但是打包多一个配置,这是 antd 里介绍的配置;
4)不重要的插件改成全局引入第三方插件
根据需要适时请求,插件方法挂在 window 全局下,避免产生直接依赖。
同时给 script 标签加上 async 或者 defer 属性,避免脚本阻塞页面。
(从表现形式上来说,async 的优先级比 defer 高,也就是如果同时存在这 2 个属性,那么浏览器将会以 async 的特性去加载此脚本。)
<script src="https://cdn1.jd.com/xxx.js" async></script>
比如这个生成 excel 文件的插件,打包在项目里就会很重。放在 cdn 上,不随项目打包版本更新,每次加载后还能有较长缓存时间。
⚠️注意 1:有的时候,确实把第三方包抽出来了,但是调用方法时,还是使用 import 引用了,这时可能存在第三方包没加载导致的 import 报错,此时需要严格控制时机。个人建议非关键的包不轻易使用 webpack 的 externals,一般就挂在 window 下,不产生直接依赖,可以等较晚时候直接调用(建议调用时,trycatch 包一下,调用出错就看下面兜底方案);
⚠️注意 2:第三方的 cdn,可能会在有的区域、有的网络加载不出来资源。这时可能要考虑加载失败时,重新加载或者切换备用的下载链接。包括上面的懒加载,都有资源加载失败的情况。对于慢网和 cdn 故障等情况,要尽可能做多重兜底;
推荐一个兜底方案的链接: https://zhuanlan.zhihu.com/p/459698050
5)压缩资源大小
(a)压缩图片大小;
- 直接压缩工具压缩;
- 对象存储(OSS)压缩或者裁剪图片;
- 版本较高浏览器使用webp格式图片;
(b)Gzip压缩(Gzip 对一般纯文本内容可压缩到原大小的 40%);
- nginx 服务器中配置;
- http2 头部压缩;
(c)Brotli压缩(Brotli 的性能相比 Gzip 提高了 17-25%);
⚠️注意:使用 Brotli 压缩所有资源非常耗费计算资源和时间,在最高压缩级别下,会让服务器等待动态资源。服务器开始发送响应所花费的时间会抵消文件大小减少带来的任何潜在收益,也就是说会延长 TTFB 的时间。
(d)第三方插件都使用压缩版的;
(e)压缩 html 文件(包含其中内联的 script、style 代码);
比如,配置 HtmlWebpackPlugin 的 minify。
6)放弃对老浏览器支持
@babel/polyfill为了弥补低版本浏览器中缺失的特性,会导致打包体积变大。就算配置 按需注⼊(useBuiltIns: "usage"),还是会比较大。有条件还是早点放弃低版本浏览器。
7)删除多余的语言包
多数语言包是不需要被打包进来的,可以打包分析检查一遍。
8)字体文件压缩
一个完整字体文件都有几 MB,而一般项目里只有少数文本需要用到特殊字体,可以利用类似Fontmin把需要的文字单独拎出来。
如果字数较少也考虑图片替代,和其他图片合并。
9)打包移除多余代码
(a)Tree Shaking 删除多余代码
webpack3 可以配置,webpack4+ 的 mode: procution 下自带。其他打包工具也有支持。
⚠️注意 1:使用 ES2015 模块语法(即 import 和 export)。
⚠️注意 2:确保没有编译器将 ES2015 模块语法转换为 CommonJS。比如 lodash 就是基于 commonjs,所以才有上文《减少请求资源大小》中把整个 lodash 包都打进去的情况。
⚠️注意 3:在项目的 package.json 文件中添加 "sideEffects" 属性。
https://webpack.docschina.org/guides/tree-shaking/
(b)移除生产环境的 console.log、debugger、注释
new UglifyJsPlugin({
uglifyOptions: {
compress: {
drop_console: true,
drop_debugger: true
}
}
})
10)生产环境选择适当 source-map 方案,控制打包体积
根据实际情况,需要考虑源代码要不要隐藏,调试要不要更友好。
1)js 类
合并 js 请求:配置 webpack 里 splitChunks 的 cacheGroups,把必用的公共依赖打包到一起,类似:
2)图片类
(a)多个图片合并成雪碧图;
(b)图标类图片尽量使用矢量图标库 iconfont(尽量按需配置、按需打包);
(c)可 css、svg 绘制的,尽量用 css、svg 实现;
(d)非可视区域的图片懒加载;
比如利用 loading 属性
3)埋点或者不紧急不重要的上报延迟发送
埋点的信息,可以先全局存起来,等页面基本加载完成后,再加载埋点插件,上报埋点信息。减少首屏情况下,发送大量埋点上报。
1)业务代码逻辑
(a)有些数据会非常大,尽量分页优先请求或者加载渲染涵盖可视区域范围前几个、前十几个,让首屏的展示速度更快一点,后续再用完整数据覆盖或者增量渲染;
(b)非权限类的请求可以放在更早的位置发出,非阻塞性的请求可以先请求再等较晚时候处理逻辑,业务优先级低的请求可以放在页面渲染完成以后发出;
(c)不同优先级的组件,可以分不同阶段加载、处理逻辑,让关键模块优先渲染、页面整体过渡效果更好;
(d)如果存在前置的页面,可以在前置的页面空闲时间提前加载后续页面的数据,甚至是资源;
(e)复杂的渲染和数据处理,也可以考虑迁移到服务端来做;
(f)全局引入的第三方插件如果有业务场景限制,可以按需动态加载,而不是每次加载所有第三方插件。理论上,其他代码也可以这样处理,只是同一路由下,有一定难度,比如服务端按需渲染。
2)通用代码逻辑
(a)循环、递归嵌套层级太深太多的话很容易造成卡顿;
(b)循环使用时,确认是否可以提前中断循环,而不是把每个循环都走完;
(b)有的方法比较耗费性能,类似深拷贝、字符串拼接,注意使用次数;
(c)检视下手写的方法是否可以用 lodash 等成熟库的方法替代,可能性能更好;
(d)不同模块里相同或者相似的代码,提取成公共方法或者组件;
(e)监听器、计时器最好控制数量,配套退出机制,及时清除;
(f)高频触发事件,最好用防抖和节流;
以上都可以用性能分析(Performmance)、打印埋点、大量数据验证优化效果,可以适当评估实际性能差异,再做取舍。
------------------------------------------------------分割线----------------------------------------------------------
(以上都是正常的性能优化,下面试图换个角度)
一个成熟项目应该是,由合理产品规划、稳定技术架构、统一设计交互组成的。
这三个方面,都应该是优化项目的方向,而不是单纯技术思路。
对于大部分项目,其实已经做过或多或少的优化,但可能依然有卡顿。
对于大部分文章,其实主要集中于纯技术,也都很少去思考产品规划是否合理、业务逻辑是否合理。
对于大部分开发,其实也是更关心如何更好实现技术,很少参与产品规划和设计交互。
技术可以解决问题,但是不是唯一途径。
单个页面承载功能是有限的,首屏加载的资源和数据也是有限的,普通用户的网络和设备性能也是有限的。
可以考虑以下点:
(a)针对大多数用户使用习惯,优先提供简化易懂易用的常用交互,同时把专业垂直复杂的冷门交互单独拎出来;
(b)有的业务模块逻辑非常重的,可以独立出来,或者拆分成多级、多个模块;
(c)对于功能繁杂的页面,应该把不重要的业务模块迁出、收起、延后展示;
(d)通过埋点确认没人使用的业务模块,应该考虑下线;
(e)低使用频率的功能控制加载次数,类似版本更新功能,每次打开页面都会请求最新配置接口数据,可以设置间隔一段时间才能重新请求,或者跟随版本号更新后才能重新请求一次;
(f)分析用户群体绝大部分的常用设备、常用浏览器,有意引导用户使用现代浏览器,逐步放弃对低版本设备、低版本浏览器支持;
总有一些问题,需要开发来推动业务改动吧。
感谢诸位阅读至此!文章仓促,有不足之处,还望多多指点。
性能优化之路漫漫且学无止尽,本文只能提供一些初步想法,具体落地还要看实际场景。
Chrome 文档:
网络分析功能 https://developer.chrome.com/docs/devtools/network?hl=zh-cn
性能面板 https://developer.chrome.com/docs/devtools/performance?hl=zh-cn
灯塔面板 https://developer.chrome.com/docs/lighthouse/overview?hl=zh-cn
Chrome DevRel:
优化耗时较长的任务 https://web.dev/articles/optimize-long-tasks?utm_source=devtools&hl=zh-cn
避免大型、复杂的布局和布局抖动 https://web.dev/articles/avoid-large-complex-layouts-and-layout-thrashing?utm_source=devtools&hl=zh-cn#avoid-forced-synchronous-layouts
Webpack:
打包分析 https://github.com/webpack-contrib/webpack-bundle-analyzer
Tree Shaking https://webpack.docschina.org/guides/tree-shaking/
知乎:
Chrome DevTools 代码覆盖率功能详解 https://zhuanlan.zhihu.com/p/26281581
最完备的懒加载错误兜底方案 https://zhuanlan.zhihu.com/p/459698050
Antd:
按需加载 https://3x.ant.design/docs/react/getting-started-cn#%E6%8C%89%E9%9C%80%E5%8A%A0%E8%BD%BD
使用 Day.js 替换 momentjs 优化打包大小 https://3x.ant.design/docs/react/getting-started-cn#%E4%BD%BF%E7%94%A8-Day.js-%E6%9B%BF%E6%8D%A2-momentjs-%E4%BC%98%E5%8C%96%E6%89%93%E5%8C%85%E5%A4%A7%E5%B0%8F
作者:京东零售 李振纲
来源:京东云开发者社区 自猿其说 Tech 转载请注明来源