前端性能问题排查的一些经验
目前参与的前端项目中,存在一些有性能瓶颈的前端核心业务页面,在数据量10W+的情况下页面卡顿,体验不好。最近着手进行了一波性能排查和优化,记录一下途中的一些心得。
前端一般借助chrome devtools来排查内存泄露和性能问题,对应memory面板和performance面板。
这两个工具非常强大,官方有对应的文档、社区也有很多类似的文章来介绍他们的使用,可以参考
- chromedevtool pracitce demo,可以在这个网站打开控制台练习性能调试
- Chrome 开发者工具 —— Performance 快速入门,掘金上的一篇Performance配套教程,可以结合上面这个网站使用
本文不会过多介绍具体的工具使用,主要着重于调试过程中的一些经验和心得体会。
性能排查目标
本轮前端页面性能问题排查,主要集中在下面内存泄露和长任务两个方面。
首先是内存泄露,由于内存无法被及时释放,导致标签页占用的内存越来越多,由于Chrome限制了单个标签页的最大内存(大概是5个G左右),最终导致页面崩溃。
由于我们这个页面需要承担大数据量的渲染,还涉及到socket消息推送、音视频和地图的交互业务,占据内存比较大,一旦出现内存泄露,页面在一段时间后就会崩溃掉,会产生较严重的线上事故,需要重点排查和修复内存泄露问题
然后是JS长任务,由于JS是单线程执行的,单次运行时间超过50ms的任务就被称作长任务,长任务会导致页面交互延迟,界面、动画卡顿,比较影响用户体验,比如在处理推送过来的消息时,可能导致地图无法拖拽、缩放等。
其余的如FCP、LCP等通用前端性能问题,本文就不过多介绍了。
了解内存占用成分
Chrome是多进程架构,每个标签页可能对应一个渲染进程,而渲染进程里又有多个线程。
打开Chrome任务管理器(右上角菜单 → 更多工具 → 任务管理器),查看标签页进程的所有内存占用
JavaScript堆内存(V8引擎),即在dev tools 的memory面板中看到的主要内存部分,也是前端性能问题排查的终点区域
- JS对象:由V8管理的JavaScript对象、闭包、事件监听器等。
- 优化/编译代码:JIT编译后的机器码和字节码。
- 内存泄漏:未被垃圾回收的分离DOM节点或未释放的引用。
渲染引擎内存(Blink/WebCore)
- DOM/CSSOM:解析后的DOM树、CSS规则和样式计算数据。
- 渲染树与布局:页面布局计算和合成层信息。
- Canvas/WebGL:位图、纹理和GPU缓冲区(可能部分计入显存)。
媒体资源
- 解码后的图像/视频:如JPEG、PNG、MP4等资源的解码缓存。
- 音频处理:Web Audio API的音频数据和处理缓冲区。
网络与缓存
- 资源缓存:HTML、CSS、JS文件的磁盘/内存缓存(若进程私有)。
- Service Worker缓存:通过Cache API存储的离线资源。
- 网络缓冲区:未完成的请求和响应数据。
扩展与插件
- 扩展脚本:注入页面的扩展程序代码和数据。
- 插件内容:如PDF查看器、广告拦截器等第三方组件。
字体与排版
- 字体数据:加载的Web字体文件(如TTF、WOFF)的解析结果。
系统与进程开销
- 进程基础内存:Chrome自身进程管理的开销(如IPC通信)。
- 内存碎片:操作系统级的内存分配碎片化损失。
其他技术相关
- WebAssembly内存:独立于JS堆的线性内存空间。
- GPU内存:合成层、CSS动画等GPU加速操作占用的显存(部分系统可能统计到进程内存中)。
排除干扰
我们本身是借助dev tools来调试问题的,但是需要注意一些devtools和chrome浏览器自身带来的性能问题,这些问题可能会误导我们的排查方向
忽略控制台的输出
有时候为了快速调试,会通过console.log
在chrome打印一堆数据
const arr = new Array(1000000).fill("123")
console.log(arr)
在控制台可以看到具体的输出
console控制台提供了预览这个对象的数据,这也会导致这个arr的数据占据的内存无法被回收
如果打印的是DOM节点
console.log(document.createElement("div"))
也会出现detached DOM node
但是console.log代码本身在没有打开控制台时应该是不会生效的,也不会有对应的性能问题。(当然出于编码规范也不建议在生产环境使用console)
因此,为了避免在调试阶段由于console
导致的内存占用,建议清除掉代码中的console代码再进行调试。
在隐私模式下进行性能调试
Chrome提供了扩展程序机制,丰富浏览器的功能。扩展程序借助Chrome extentions API和content scripts,可以操作页面上的代码。
前端主流框架都提供了相应的扩展程序,如React的React dev tools
、Vue的 Vue dev tools
等。
这些扩展程序方便了开发者调试前端应用,但需要注意的是,这些插件的实现可能会造成某些节点无法被释放(比如上述dev tools实现了一个Element Tree类似的Component Tree,依赖了页面的全局变量或者DOM节点),导致调试的时候出现了假的”内存泄露“现状:即发现内存在逐渐增长,实际上是这些devtools 导致的。
除了这些主流框架的dev tools,还有一些扩展程序,也有可能操作页面内容,造成内存没有释放的问题。
因此,建议在浏览器隐私模式下进行性能问题排查,隐私模式会禁用所有的浏览器插件,屏蔽外部原因带来的干扰。
CSS导致节点无法被回收(已修复)
参考这个issue,大概就是CSS Animation和Transition,导致dev tools的节点没有被释放。
这是Chrome 13x等版本的bug,恰好这段时间在调试性能问题,就踩到坑里面了,当时比较怀疑人生,不过目前看起来已经修复了。
如果在内存分析的时候还是看到了有由于CSSAnimation
、CSSTransition
依赖导致无法被释放的节点,可以参考这个下面这个方案解决。
解决方案,在记录memory快照前,在dev tools的animations面板清空对应的animation,避免影响判断
这个问题表明,dev tools并不是万能的,作为开发工具,它本身也可能会存在bug,在调试问题的时候,需要保持怀疑的态度。
下面还是记录一下当时的复现步骤。
css animaiton
只要节点有css animation,不论移除时动画是否结束,都会导致该节点无法被释放,对应关联的父节点等也无法被释放
最小复现demo:点击按钮将容器移除,该容器内部包含了一个loading动画节点的dom,会导致这几个节点都无法被回收
不论动画是否开始执行,正在执行,还是执行结束,都无法被释放,在memory面板可以看到CSSAnimation引用了对应节点
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.loading {
width: 100px;
height: 100px;
background-color: red;
animation: loadingCircle 1s linear ;
}
@keyframes loadingCircle {
0% {
transform: rotate(0);
}
100% {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div id="container">
<div class="loading"></div>
</div>
<button id="myBtn">click me</button>
<script>
myBtn.onclick =function(){
const container= document.querySelector("#container")
container.__bigArr = new Array(1000000).fill("123")
container.parentNode.removeChild(container)
container = null
}
</script>
</body>
</html>
transition导致节点无法被回收
transition如果是已经触发了变换,也会导致节点变成detached的
最小复现demo,在memory面板可以看到CSSTransition引用了对应节点
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.loading {
width: 100px;
height: 100px;
background-color: red;
transition: all linear .5s;
transition-delay: 3s;
}
@keyframes loadingCircle {
0% {
transform: rotate(0);
}
100% {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div id="container">
<div class="loading"></div>
</div>
<button id="myBtn">click me</button>
<script>
myBtn.onclick =function(){
const container= document.querySelector("#container")
const loading= document.querySelector(".loading")
loading.style.backgroundColor ="blue" // 如果这里注释点,不触发变化,则这个节点也可以被回收
container.__bigArr = new Array(1000000).fill("123")
container.parentNode.removeChild(container) // 如果前面触发了变换,这里同步执行,变化还没有应用,可以被回收
setTimeout(()=>{
// 如果前面触发了变换,这里异步执行,变化已经在运行或者运行接收,也无法被回收
},200)
}
</script>
</body>
</html>
快速定位内存泄露
当测试或用户反馈页面操作一段时间后卡段或者崩溃时,大概率就是页面存在内存泄露,需要快速定位内存泄露的原因。
快速快照
Memory面板可以通过内存快照,分析页面上当前有哪些占内存的代码。
最常用的就是这个Heap snapshot 堆快照
页面比较复杂,数据量较多的时候,进行一次全量的Heap snapshot是比较消耗时间的,甚至可能会造成页面卡死或者崩溃。
建议在进行性能分析的时候,缩小数据量和代码量,保证能复现问题即可。
前端内存泄露最常见的原因就是一些内有释放的DOM 节点、或者EventListener,
<div id="container">
<div class="loading"></div>
</div>
const container= document.querySelector("#container")
container.parentNode.removeChild(container)
window.__container = container
container = null
上面这个代码,在heap snapshot中展示的结果如下
搜索detached(也可以直接搜索deta),筛选出这种游离的DOM节点,可以发现虽然只在全局属性上保存了container这个DOM节点,但由于DOM API本身的chileNode等属性,导致container下面的loading节点和text等都没有被有被释放,因此出现了多个detached节点。
除了heap snapshot之外,也可以选择这个Detached elements
,这个操作可以快速打印出当前没有插入页面上、但也没有被回收的游离DOM节点,找到这些节点后,再去看对应地方的代码排查问题,会更容易发现问题
但是通过Detached elements,相关联的游离节点会被折叠起来,这样更方便排查问题
需要注意的是,detached nodes并不代表一定是内存泄露,像前端业务里面可能会用到的节点预加载、DOM缓存等,也会使用js变量来保存那些暂时不插入到页面上的DOM节点。需要根据具体业务具体分析,只有那种不符合预期的detached node,才有可能是内存泄露。
performance monitor
除了常驻devtools标签页的Memory 和Performance面板之外,还可以在more tools打开Performace monitor这个工具
Performance Monitor可以查看网页随着时间增长各个重要的性能指标
- Documents:文档数量异常可能指向iframe或未卸载的页面。
- DOM Nodes:大量DOM节点可能占用内存。
- JS Heap Size:确认堆内存稳定。
- Event Listeners:过多未移除的监听器可能导致内存泄漏。
打开这个面板,然后在网页操作一段时间,如果发现某些数据存在持续增加,大概率就是存在内存泄露了。
需要注意的是,在操作阶段需要一直打开 Performance Monitor,在切换了 Performance Monitor面板之后,里面的时间轴数据会被重置。
二分注释缩小代码量
在某些场景下,通过memory快照并没法很准确的定位到具体出现问题的代码,比如我们的React项目,某个detached dom节点查看引用,是React的fiber节点,再分析依赖树,却是浏览器的InnerNode,无法定位到具体的代码了。
在这种场景下,可以按照下面这种缩小代码量的方法来进行定位。
首先,不需要具体的范围,只需要一个粗略的范围,比如最简单的是某个页面发现了内存泄露,这个页面组件和其依赖的文件就是待排查的范围
然后,就是通过二分法,注释一半代码,操作复现步骤,如果发现内存泄露仍然存在,就说明目前这一部分代码包含内存泄露的代码,以此往复,找到那块注释了就没有内存泄露的代码,大概率就是引起内存泄露的代码,最终精确的找到那些可能出问题的地方。
当然这个方式也存在缺点,有可能引起页面内存泄露的地方不只一处,通过上面这种方式能找到内存泄露的代码,但不一定能找全,需要逐个问题修复之后,重复排查,直至最后所有问题都被解决。
小结
性能排查和优化不是一个一蹴而就的事情,需要持续关注、持续优化。
本文后续会应该会持续更新。
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。
