前言
本文将总结浏览器常见两大性能分析工具对于项目开发过程中问题定位的步骤、思路分析,并以一个React项目为例进行实操
LightHouse——线上跑分
此处为何强调是线上,因为开发环境下的
LightHouse受很多开发环境下特有的东西影响,比如脚本文件未压缩、存在未使用的模块等等;而这些东西,通常在我们打包之后可以避免。如tree-shakingJS压缩等
一图胜千言
serviceWork缓存接口数据:
此处其实过度设计了,使用HTTP缓存即可解决问题,但以下内容仍然保留,可做参考
场景:在简历制作网站中,整体分为简历内容 & 简历模版;简历模版是相对不会发生变化的部分,所以可以考虑将这部分数据进行预取 + 缓存;到了需要消费简历模版数据的页面就可以直接走缓存,减少接口等待时间
适用环境:
localhost/https
sw的操作整体分为两步:
- 缓存更新:非目标页面触发
- 此处个人理解有两层含义:
- 将值写入
caches缓存中 —— 预取 caches内已经有值,但需要更新它的值 —— 更新
- 将值写入
- 读取缓存:目标页面消费
实现思路:
- 非简历编辑页面调用模版接口 —— 页面a
sw拦截到对应的事件,缓存模版数据- 为避免非目标页面无限发起请求,通过通信机制,携带当前已请求次数与最大请求次数进行比较;合法,发起请求
- 判断请求前后间隔时间是否超过最大时间限制;合法,发起请求
- 简历编辑页面从缓存读取模版数据 —— 页面b
- 若缓存查询不到数据,再发起请求
sw注册:
// App.tsx
useEffect(() => {
if ('serviceWorker' in navigator) {
const registerSW = async () => {
try {
const registration = await navigator.serviceWorker.register(
'/sw.js', // 文件名称
{
scope: '/', // 文件位置
}
)
console.log('ServiceWorker 注册成功:', registration)
} catch (error) {
console.error('ServiceWorker 注册失败:', error)
}
}
registerSW()
}
}, [])
非关键页面预取数据:
useEffect(() => {
fetch(`${BASE_URL}/resume/templates/getAll/${userId}`, {
headers: {
Authorization: token,
'X-Pre-Load': 'true',
'X-Max-Count': String(sessionStorage.getItem('preload_count') || 0),
},
})
.then((res) => {
if (res.status === 204) {
console.log('预加载成功,无需解析响应')
sessionStorage.setItem(
'preload_count',
String((Number(sessionStorage.getItem('preload_count')) || 0) + 1)
)
return // 直接结束,不进入下一个 .then()
}
return res.json()
})
.then((_) => {}) // 当前页面只是作为预请求数据而存在,并不需要消费该数据
.catch((_) => {
console.log('fetch err')
})
}, [])
- 想象一种极端的场景:用户在这个非关键页面不断刷新,就会导致该数据接口不断调用,浪费网络资源,所以在此处可以限制一下最大请求次数
- 除了最大请求次数,也可以设置请求间隔时间
关键页面正常请求,被sw拦截:
useEffect(() => {
const getDetail = async () => {
if (params.randomId) {
const {
data
} = await getTemplatesAPI(userId)
setTemList(...)
}
}
setResumeId(params.randomId || undefined)
getDetail()
}, [])
sw.js
const TEMPLATE_CACHE_NAME = 'template-cache'
const INVALIDATE_TEMPLATE_CACHE = 'INVALIDATE_TEMPLATE_CACHE'
const MAX_COUNT = 3 // 允许的最大请求次数
// 使用应用的整个生命周期中,第一次命中的是注册事件,此后才是命中fetch事件
self.addEventListener('fetch', (event) => {
const req = event.request
const url = new URL(req.url)
if (url.pathname.startsWith('/resume/templates/getAll/')) {
// const userId = url.pathname.split('/').pop() || 'default'
const preLoad = req.headers.get('X-Pre-Load') === 'true'
const count = req.headers.get('X-Max-Count')
event.respondWith(
caches.open(TEMPLATE_CACHE_NAME).then(async (cache) => {
const cacheKey = req
// 非目标页面进行预加载
if (preLoad) {
// 小于最大请求次数,允许调用接口预请求数据
if (count < MAX_COUNT) {
try {
const resp = await fetch(req)
// 缓存数据
cache.put(cacheKey, resp.clone())
// 预请求数据的页面并不真正消费该数据,没必要重复发起请求
return new Response(null, { status: 204 })
} catch (error) {
// 预加载失败,返回null,不影响页面b
console.log('sw error')
return new Response(null, { status: 200 })
}
} else {
console.log('已达最大请求次数')
const cachedResp = await cache.match(cacheKey)
if (cachedResp) {
console.log('缓存命中')
return cachedResp
}
console.log('缓存不命中,发起网络请求,并将结果缓存')
// 缓存读取不到,则发起请求
const netResp = await fetch(req)
cache.put(cacheKey, netResp.clone())
return netResp
}
} else {
// 目标页面被worker拦截,优先从缓存读取
const cachedResp = await cache.match(cacheKey)
if (cachedResp) {
console.log('缓存命中')
return cachedResp
}
console.log('缓存不命中,发起网络请求,并将结果缓存')
// 缓存读取不到,则发起请求
const netResp = await fetch(req)
cache.put(cacheKey, netResp.clone())
return netResp
}
})
)
}
})
self.addEventListener('message', (event) => {
console.log(event)
const {
data: { type, userId },
} = event
// 更新caches缓存
if (type === INVALIDATE_TEMPLATE_CACHE) {
caches.open(TEMPLATE_CACHE_NAME).then((cache) => {
cache.keys().then((requests) => {
requests.forEach((request) => {
const url = new URL(request.url)
if (url.pathname.includes(`/resume/templates/getAll/${userId}`)) {
cache.delete(request)
}
})
})
})
}
})
更新缓存数据:
在用户创建/更新模版之后,需要手动更新一下
caches缓存的模版数据;这里通过sw的通信机制实现
// 更新sw
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({
type: 'INVALIDATE_TEMPLATE_CACHE',
userId: userId, // 具体传递的参数看实际项目需求
})
}
实现前后效果对比:
关于此处的跑分测试:
- 每次跑分的时候,使用的是全新的匿名浏览会话(类似
Chrome的无痕模式),不共享缓存、CookieLocalStorage等持久化存储,sw会被重新注册,之前的缓存不会保留;所以此处很难使用LightHouse进行测试- 另外,项目上线后并没有进行域名备案,无法使用
https协议,sw并不能生效,故只能在开发/预览环境下查看netWork面板情况
关于sw:
Service Worker是独立于网页的,即使移除相关代码不启用worker,只要原先开启过,之前注册的Service Worker仍然可能存在于浏览器中,并继续控制缓存,需要手动清除
处理图片资源优化LCP:
项目中,通过跑分发现
LCP的节点为图片资源,恰好可以与图片资源常见处理手段结合起来
- 压缩并选择合适的图片格式,此处选用的是
avif - 图片压缩网站
前后效果对比:
处理之前:
处理之后:
- 对应的图片资源约为
3.4kb,由于小于阈值,所以被自动处理为base64格式,减少图片资源的请求 - 当然,在该项目中,对于
LCP的优化效果前后其实并不是特别显著,也没必要为了0.1s的LCP提升而做这些动作,反倒增加了操作成本;只是想说明对于图片资源可以进行这种处理
动态导入、懒加载:
执行pnpm preview预览生产环境的构建产物,对比懒加载前后首页资源加载所需的大小和时间
此后的跑分、测试均是在预览环境下进行,忽略真实网络环境中的延迟等问题
懒加载之后👇
此处仅仅是在预览环境下做实验,真实的线上环境往往还会受真实网络波动情况的影响;但在保证
其他条件一致的前提下,懒加载后的资源大小会减少
白嫖免费服务器上线项目之后,查看netWork面板,查看相关资源耗时:
- 左一:未做懒加载前,访问页面会加载所有
js资源,耗时接近4s,有明显的白屏时间 - 右图:做懒加载之后,相同页面仅加载当前页面所需资源,耗时缩至
1s左右
骨架屏优化CLS:
场景:获取到模版数据之后,遍历渲染,由于原先模版列表是空的,有值之后再进行循环渲染会导致累计布局偏移增大,即CLS耗时长
解决方案: 使用骨架屏占位
CLS跟数据从哪读取/读取快慢没啥关系,累计的布局偏移依然存在;从缓存读取最多只是获取数据/渲染数据相较于从接口获取快一点;真正要做出改变的是不让它的布局猛的一下做出偏移
前后效果对比:
Profiler——组件分析利器
同样是以
简历项目为例,以实际例子,一步步讲解如何定位、分析、解决的问题
1. 定位非必要渲染——快速定位无效更新
项目中,更新某一个字段后,获取对应的快照:
其中可以看到有一个
LeftMenu组件的重新渲染也是由于该字段的更新引起的,但是,这个组件并不涉及具体的数据逻辑,功能仅仅是点击 icon,滚动到具体位置;但火焰图显示具体是iconClick这个prop发生变化引起的,看到具体代码:
此时就可以考虑使用 useCallback对该回调进行包裹;并将LeftMenu组件使用memo包裹,再次执行该字段的更新,并进行火焰图分析:
可以看到此时与此次状态更新无关的组件就不会重新触发渲染了;对于右侧菜单<RightMenu />的处理也是同理
useCallback保证的是与当前组件相关的props不要触发非必要更新;memo是避免与当前组件不相关的状态导致的更新- 这里可以再提一嘴,此前讲到“非必要不使用useCallback”;但是对于这里的场景:
useCallback传入的依赖项为[],只会在初始化时创建一次,引用稳定,就很适合处理这种静态回调(如DOM操作等)- 但为什么依赖项不需要传入
ref也可以正常工作?因为在React的整个生命周期中,ref的引用都是保持不变的,就算依赖项传入了ref,其实也是不生效的;所以useCallback对于这种场景就很适用
2. 何时该用memo/useCallback?——权衡优化成本和收益
并不是任何父子通信都得进行优化处理,还要综合考虑传递给组件的属性是否复杂可控、非必要的组件重新渲染耗时的长短,否则会用力过猛
比如在修改“技能特长”时监视组件的渲染情况,发现
<Skill>内的<Header />组件会做非必要的渲染,但查看传递给<Header />组件的属性:
此时你有两个选择:
<Header />组件使用memo包裹,对应的,传递的回调也要使用useCallback包裹,否则,状态每次更新都会令对应的回调函数重新声明,那么memo就不会起到对应的作用- 但是,对于
handleChangehandleBlur这种简单的回调,仅仅是做状态更新处理,使用useCallback的收益并不大,还会增加代码的理解成本以及缓存的开销成本
- 但是,对于
- 查看非必要组件的重新渲染耗时,其实还是在可接受范围内,那么就没必要做不必要的“反向优化”
- 在这个例子中,移除掉
Tooltip组件都要比所谓的“避免不必要的组件更新”收益来得大,毕竟一个的耗时就占据了此处的63%
- 在这个例子中,移除掉
不过倒还是有一个优化点:就是svg属性,即传递的icon;在整个应用生命周期中,icon是不会发生变化的,所以状态更新的时候,不管是必要的还是非必要的组件重新渲染,icon都没必要重新渲染,可以将其提取为外部静态变量
| 处理前的火焰图 | 处理后的火焰图 |
|---|
修改一次同样的state,查看火焰图,此时的icon图标就不再随状态的更新而重新渲染
3. 检测组件循环渲染——识别因副作用引发的无限更新
项目中的简历预览用的是递归实现内容渲染,边界条件处理不当就容易出现无限循环,所以可以使用
Profiler定位是否存在此类问题但暂未遇到,故无法展示
4. 多组件渲染未必是问题——必要渲染 VS 过度渲染
- 做优化并不意味着死钻牛角尖、处处得优化,往往是出现问题才会考虑到这一层面,毕竟研发无法永远站在上帝视角看问题,但可以站在当前时间点回望过去遗留的技术债
- 比如在当前项目场景中,
<Render />是一个递归组件,用户对一个字段的更新都会导致预览区域重新执行递归,导致一个字段的更新触发多个<Render />的更新,但他们渲染耗时都在理想范围内,此时便认为,这种优化是高投入低回报的,不必过早优化
终极手段
两个字:氪金。强大的硬件条件支持可以解决99.99%的问题,穷逼才需要吃力不讨好
后记:
- 本文主要结合项目,针对
LightHouseProfiler进行实操演练;所涉及的也仅仅是众多优化手段的一小部分,而更多的是对代码合理性层面的分析和处理 - 性能优化,前端能做的不多 参考双越老师的文章,前面耗尽手段所争取到的、看似优化了很多的结果,在现代网络环境下其实几乎感知不出来的;除非你也是白嫖了低配的服务器
比如懒加载减少初次js资源体积约
400kb、图片资源约150kb、模版接口数据几十kb(后面可能随着模版的增多而增大)
- 本文更多是为了演练,其实很多所谓的优化手段在未执行之前,结果都是在可接受的范围内的,优化前后的收益也不是特别大。正如前面所说,演练为主