侧边栏

前端性能问题排查的一些经验

发布于 | 分类于 前端/前端业务

目前参与的前端项目中,存在一些有性能瓶颈的前端核心业务页面,在数据量10W+的情况下页面卡顿,体验不好。最近着手进行了一波性能排查和优化,记录一下途中的一些心得。

前端一般借助chrome devtools来排查内存泄露和性能问题,对应memory面板和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打印一堆数据

js
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,恰好这段时间在调试性能问题,就踩到坑里面了,当时比较怀疑人生,不过目前看起来已经修复了。

如果在内存分析的时候还是看到了有由于CSSAnimationCSSTransition依赖导致无法被释放的节点,可以参考这个下面这个方案解决。

解决方案,在记录memory快照前,在dev tools的animations面板清空对应的animation,避免影响判断

这个问题表明,dev tools并不是万能的,作为开发工具,它本身也可能会存在bug,在调试问题的时候,需要保持怀疑的态度。

下面还是记录一下当时的复现步骤。

css animaiton

只要节点有css animation,不论移除时动画是否结束,都会导致该节点无法被释放,对应关联的父节点等也无法被释放

最小复现demo:点击按钮将容器移除,该容器内部包含了一个loading动画节点的dom,会导致这几个节点都无法被回收

不论动画是否开始执行,正在执行,还是执行结束,都无法被释放,在memory面板可以看到CSSAnimation引用了对应节点

html
<!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引用了对应节点

html
<!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,

html
<div id="container">
    <div class="loading"></div>
</div>
js

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,无法定位到具体的代码了。

在这种场景下,可以按照下面这种缩小代码量的方法来进行定位。

首先,不需要具体的范围,只需要一个粗略的范围,比如最简单的是某个页面发现了内存泄露,这个页面组件和其依赖的文件就是待排查的范围

然后,就是通过二分法,注释一半代码,操作复现步骤,如果发现内存泄露仍然存在,就说明目前这一部分代码包含内存泄露的代码,以此往复,找到那块注释了就没有内存泄露的代码,大概率就是引起内存泄露的代码,最终精确的找到那些可能出问题的地方。

当然这个方式也存在缺点,有可能引起页面内存泄露的地方不只一处,通过上面这种方式能找到内存泄露的代码,但不一定能找全,需要逐个问题修复之后,重复排查,直至最后所有问题都被解决。

小结

性能排查和优化不是一个一蹴而就的事情,需要持续关注、持续优化。

本文后续会应该会持续更新。

你要请我喝一杯奶茶?

版权声明:自由转载-非商用-保持署名和原文链接。

本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。