首页 / 资讯中心 / 文章详情

JavaScript性能优化实战:从卡顿定位到渲染提速

JavaScript性能优化实战:从卡顿定位到渲染提速 ★ FEATURED ARTICLE
我前阵子被同事拉着看了一个页面数据量不算大一千多行列表但滚动起来像PPT一样一卡一顿。他最初怀疑是框架太重已经在调研要不要换技术栈了。我劝他先别急着重写把浏览器自带的性能录制工具打开录了十来秒操作结果发现最掉帧的那几段全是同一个过滤函数在列表滚动时反复执行跟渲染管线基本没关系。最后改掉这十几行代码滚动立刻丝滑页面体积一点没动。这个故事我记了很久。它说明一个很朴素的道理JavaScript性能优化实战里最难的部分往往不是“怎么优化”而是“先搞清楚到底慢在哪”。这篇文章我打算把它拆开讲——从定位方法、渲染链路、代码执行再到首屏加载和防劣化基建把一套能直接复用的思路整理出来。1. 性能问题从哪来先定位再动手别凭感觉优化1.1 一次差点被“重写”的卡顿页面先复盘刚才那个场景。同事的第一反应是“数据列表量大DOM节点太多所以卡”。这个推断听起来非常合理一千多行每行还有好几个嵌套节点换谁都会先怀疑DOM。可真正的问题是滚动事件里绑了一个过滤方法每次滚动都会把已经过滤好的数据再整一遍还顺手创建了一堆中间对象。这事CPU密集主线程被占满之后浏览器的滚动、绘帧全被堵在后面排队。如果当时真按“换框架”的路子走成本少说一两个月代码迁移风险还高。但结果只花了一小时定位加修复都完成了。这让我意识到经验能帮你列出“嫌疑方向”但无法替你确定“真实根因”。性能问题有太多长得像的地方了一个卡顿可能是布局抖动可能是JS长任务可能是资源加载太快阻塞解析也可能是内存持续增长导致的GC抖动。不去测量光靠肉眼和直觉很容易走错方向。1.2 用性能面板把嫌疑犯锁定到函数浏览器开发者工具里的Performance面板现在也有的叫性能记录器是我做优化的第一站。操作路径很直接我给的建议是养成肌肉记忆打开开发者工具切到性能录制面板开始录制。正常复现真实的用户操作滚动几屏、点击按钮、打开弹窗别只录“静止页面”。三四秒后停止录制。时间轴会出现主线程活动区域里面能看到一段段的“长任务”。展开长任务块鼠标悬停可以看到调用栈。我会切到“Call Tree”或“Bottom-Up”视图按“Self Time”排序看自己代码里消耗最大的函数。双击跳转到源码位置对照函数名和调用上下文判断是否合理。这一步有个细节容易被忽略一定要操作页面让问题“当场发生”。页面静止时CPU几乎不干活你只能看到空白时间轴。另外录制时间不要拖太长录十几秒会产生大量数据反而不容易分析我一般录三到五秒就够。拿到Call Tree之后先看Top几个函数的“Self Time”也就是函数自身消耗的时间不包含子调用。这个数值最能说明热点在哪。如果你自己的业务函数排在前几位问题大概率出在数据处理或渲染逻辑上如果排在前面的是某第三方库内部的函数那才是换库、换方案的合理时机。1.3 先把优化目标写成数字定位到问题之后下一步是把“卡顿”说清楚。“页面有点卡”这种描述没法验收。我习惯先定义一个最小指标集合再对照着看效果。目前比较通用的几个指标是指标含义参考目标FCP首屏内容显示时间1.8秒以内LCP最大内容绘制时间2.5秒以内INP整体交互响应延迟200毫秒以内CLS页面布局偏移0.1以内除了这些体验指标我还会给业务定义一两个自己的量化目标比如“搜索框从输入到结果渲染完不超过300毫秒”“列表滚动帧间耗时低于12毫秒”。定义得越具体后面每一步优化都能对照着看是变好还是变坏而不是最后凭感觉说“好像快了一点”。2. 渲染链路里的实战被反复忽略的布局抖动与长任务2.1 先理解浏览器从JS到像素的路径笨人总结一下浏览器拿到JS执行结果之后要走一条很长的流水线先做样式计算再算布局然后绘制最后合成。其中样式计算、布局、绘制都在主线程上排队JS也在主线程上跑。这意味着JS执行时间一旦过长后面所有阶段都被堵住页面看起来就是卡。所以当你说“这段JS有点慢”时其实有两种完全不同的慢一种是纯计算慢比如一个循环跑了一百毫秒另一种是JS本身没多少计算但因为读了某些DOM属性逼着浏览器立刻做布局反复几次就成了几百毫秒。后者就是我们常说的强制同步布局也叫布局抖动。它隐蔽性很强代码看起来只是“读了几个属性”实际代价大得很。2.2 读写交替导致的布局抖动看一个典型的反面例子const list document.getElementById(list); list.style.width i px; // 修改样式 const height list.offsetHeight; // 立即读取布局信息这里先改了宽度又立刻读offsetHeight。问题是浏览器把JS执行和布局计算之间做了异步调度正常情况下会攒一批修改等JS执行完再统一算布局。但你一旦在修改后立刻读取“当前布局结果”浏览器就没办法等到最后了只能在那一刻同步跑一次布局把数值能算出来给你。如果这发生在高频率的循环里比如每次滚动都要读一次offsetTop每帧可能触发好几次同步布局帧率自然就崩了。我之前排查过一个案例滚动列表时总有一帧卡到三十多毫秒。代码点开一看滚动回调里有一个逻辑根据当前滚动位置动态改一个节点的top值然后立刻去读这个节点的getBoundingClientRect()做位置判断。整个循环三百多个节点每滚一次就强制同步布局三百多次性能想好都难。处理方式也不复杂要么把“读”和“写”分开先把所有涉及位置的数值一次性读出来存成变量再统一去改样式要么把写的操作丢进requestAnimationFrame让它跟渲染帧节奏对齐。这里的关键是——读和写不要在同一轮循环里交替。有个很有意思的补充很多人一谈DOM性能就想到DocumentFragment说批量插入节点要用文档片段。这个说法有道理但经常被夸大。DocumentFragment的价值是减少中间态的布局和绘制机会但它不是解决“卡顿”的主武器。真正导致卡顿的往往不是插入节点本身而是插入前后有人读DOM布局、或者节点结构复杂到单次布局都要算很久。所以别迷信某一招警惕“一招鲜”的思维我吃过这种亏。2.3 把长任务拆开让主线程喘口气即使你的JS没有触发强制同步布局只要一个函数连续执行超过50毫秒浏览器就无法及时响应用户的点击和滚动这也是一种卡。尤其是那种“一次性处理全量数据”的同步循环。比如有这样一个场景把十万条原始数据做解析、字段映射、排序再塞给列表组件。直接在事件回调里同步干完浏览器会冻结一小段时间用户的感受就是“点了按钮没反应”。较早的优化思路是分片处理把大循环拆成小批量每批之间让出主线程async function processInChunks(data, chunkSize 200) { let index 0; while (index data.length) { const chunk data.slice(index, index chunkSize); handleChunk(chunk); index chunkSize; await new Promise((resolve) setTimeout(resolve, 0)); } }每次处理一小块然后用宏任务让出主线程让浏览器有机会处理点击、滚动、绘制。如果环境允许也可以考虑配合requestIdleCallback来做优先级更低的后台处理。这个方法不改变总体计算量但体感上会让页面保持可控的响应能力是从“整块堵死”变成“平滑分批”。有个度要掌握分片太小会导致大量任务切换反而浪费性能分片太大又会让单帧卡顿明显。我通常是按数据规模和实际帧耗时来调一般200到500条一批比较稳具体要以自己项目实测为准。3. 代码执行层面的优化热点函数、重复计算与大循环3.1 先找出真正的高频开销函数渲染问题说完回到纯JS执行层面的优化。这个领域最常出现的“隐形杀手”不是算法复杂度差而是函数被调用次数太多。比如某个工具函数本身只需要一毫秒但你在每次输入、每次滚动、每个列表项渲染里都调用它一秒钟调用几千次积少成多就成了性能问题。我优化过一个数据表格用户在筛选框里输入一个字符前端就会对几千条记录做一次过滤加统计。写的时候挺顺手用filter、find这些高阶函数组合起来逻辑清晰又好读。但实测每次输入击键主线程要忙上两三百毫秒整页都跟着“顿一下”。排查下来发现输入事件没有做任何节流而且用的是数组的filter去遍历全量数据。改法非常直白先给输入事件做节流至少要等用户停止输入再计算再把高频用到的字段提取成一个Map索引查询时靠键取值替代全数组扫描。这两步一上击键到结果渲染的时间直接降到几十毫秒以内。这类优化有个共性不要一上来就盯着算法复杂度先确认调用频率和调用链。一个函数就算复杂度再低被调用一万次也架不住一个看似复杂的过程如果只在页面初始化时跑一次那再慢也大概率不值得做激进优化。3.2 记忆化用缓存换时间的关键边界重复计算的核心解法是缓存结果。前端里最常见的就是记忆化纯函数——参数相同结果也相同那就没必要算第二遍。我自己常用一个很轻的记忆化封装function memoize(fn) { const cache new Map(); return (...args) { const key JSON.stringify(args); if (cache.has(key)) { return cache.get(key); } const result fn(...args); cache.set(key, result); return result; }; }用起来很简单把一个纯函数交给memoize之后重复传相同参数就直接走缓存。但这里必须泼点冷水。第一JSON.stringify这个动作本身也有成本参数特别复杂或者含大对象时生成key可能比重新计算还慢。第二缓存一把梭会导致内存增长尤其是数据不断变化、key无限膨胀的场景反而引发GC压力。第三如果函数依赖了外部闭包状态那就不是纯函数缓存会得到错误结果。所以记忆化的适用范围是“参数少、可序列化、函数纯粹、调用频繁且成本高”的函数。别把它当兜底方案更别在主流程里无脑包一层。顺带说一句在很多组件框架里useMemo这类工具也是同一个逻辑它帮你在依赖不变时跳过重计算但依赖判断本身要做对比如果依赖对象每帧都重建useMemo基本等于白写还额外多了对比开销。写之前想清楚“这笔缓存买卖划不划算”。对照框架选型也是一样不要为了“按需计算”的思维而付出不必要的心智和运行时成本。3.3 大循环里的隐形开销对象分配和GC另一个经常被忽略的性能税是大循环里不停创建临时对象。每次循环产生几个对象表面看都是小东西但总量上去之后会显著拉高内存分配频率触发垃圾回收。而垃圾回收一旦在渲染帧中间启动就会表现为间歇性的卡顿用户说不清为什么就感觉“过一阵子卡一下”。一个典型模式是在循环里拼接状态rows.forEach((item) { const temp { id: item.id, label: item.label, ...item.meta }; result.push(temp); });如果这个循环在滚动或高频事件里反复执行temp对象的创建量会非常可观。优化的方向不是彻底不用对象而是让数据结构扁平化、减少临时对象生命周期或者在确实需要时考虑复用对象池。需要说明的是对象池本身也有复杂度对新手来说维护成本不低。如果是低频操作完全没必要引入。只有当你确认GC是卡顿元凶、且数据规模足够大时才值得往这个方向投入。3.4 大循环的双写作因为末尾偷懒导致超时……继续补充吧。还有一个更直白的例子给一个长列表做排序、去重、统计一次汇总。如果这个操作每次进入页面都要做且数据量在十万级以上可以考虑把“完整计算”变成“懒计算”——先用最小代价渲染首屏剩下的大计算放到空闲时间或用户真正需要时再触发。我在很多项目里见过“启动时把一切算好”的模式其实首屏并不需要这么全量的数据完全可以让用户先看到内容再继续算下一步。这个思路属于“延迟加载思维”在性能优化里特别好用。说到底代码执行层面的优化无非是三件事减少调用次数、减少重复计算、减少垃圾产生。每一条都能落地且不会动架构级的东西。4. 首屏加载提速拆包、动态导入与资源调度4.1 把产物体积拆开看谁占了主要位置很多时候我们觉得首屏慢一追踪发现业务代码只占一小部分大头全在主工程入口里塞了一堆第三方依赖。比如入口文件里全部import了某个图表库、某个日期库、某个工具函数集合哪怕页面根本没用上打包器也会把它们一起打进首屏资源里。我习惯先用打包分析工具看产物体积分布列出各模块大小排序。有一次分析一个项目总包体1MB出头其中某个图表库就占了400KB而整个页面当时还没用到图表。这个例子里把图表情节改成动态加载首屏直接少了三分之一体积。当然动态加载不是银弹。如果某个库几乎每个页面都在用拆出去之后每个页面都要加载一份那反而不如打进公共包用缓存。决策依据应该是“这个模块的复用率和加载时机”。这个“拆”和“合”的平衡比单纯追求体积小重要得多。4.2 动态导入让代码走真正需要的路径动态导入的语法很普及了我在项目里常见的写法是const openEditor async () { const { default: Editor } await import(./editor.js); editor new Editor(); };按钮点击了才加载编辑器模块首屏完全不占用。对普通里那些“低频但体积大”的功能模块来说这种写法几乎是零成本收益。要注意的是动态导入之后模块加载会有毫秒级的网络往返所以交互上要加一个轻量的loading状态别让用户点了没反应。在构建配置里我还会手动分一下公共依赖把几个大体积的库拆成独立Chunk。比如用Vite时可以这样简单配置build: { rollupOptions: { output: { manualChunks: { ui: [some-ui-library], utils: [some-toolchain] } } } }这么做的目的不只是减少单包体量更重要的是让长期不变的第三方库可以走独立缓存。业务代码频繁更新不值得每次都让用户重新下载包含所有第三方库的大文件。4.3 资源加载顺序用对preload、prefetch与preconnect拆包之后资源之间的加载顺序和优先级也值得管一管。浏览器对资源有一套自己的加载策略但我们可以通过几个标签提示它link relpreload告诉浏览器这个资源当前页面马上会用到请优先加载。适合首屏的关键CSS、核心JS、关键字体。link relprefetch告诉浏览器这个资源后续可能用到等网络空闲再去拉。适合下一步路由即将用到的懒加载模块。link relpreconnect提前和目标静态资源的域名建立连接。适合跟CDN跨域请求的场景比如图片域名、接口域名。link reldns-prefetch预先解析域名效果比preconnect弱但成本更低。比如页面上要请求一个跨域图片服务可以在head里加link relpreconnect hrefhttps://img.example.com crossorigin这样图片请求发出时连接已经建好省下一轮握手时间。不过我提醒一句preload用太多反而会挤占带宽把真正重要的资源往后挤。我的习惯是每个页面最多preload两到三个最关键的资源其余交给浏览器默认调度。4.4 不要为了“优化”把代码写得面目全非拆包、动态加载、资源提示这些都很有效但它们都有一个共同的隐性成本代码路径变复杂了。如果一个相对简单的页面被动态导入拆出五六个chunk模块之间还要互相等加载那这优化很可能是在增加延迟而不是减少延迟。我的判断标准很简单先看数据。首屏白屏超过2秒、核心资源体积超过我的性能预算说明需要加动态导入如果首屏本身已经够快只是个别大体积模块很重那就只对那几个模块做懒加载。优化是一项投资别在影响很小的地方过度投入反而把代码可维护性搞没了。5. 防劣化基建性能预算、监控与常见误区5.1 把性能预算固定下来防止回归性能优化最怕的不是一开始慢而是优化完又慢慢退化。团队协作里一个页面今天400KB明天合了个需求变500KB过两周变成700KB没人会觉得是“事故”——但用户会明显感知到变慢。要防止这种温水煮青蛙最直接的方式是定义性能预算让构建过程成为一道闸门。我在项目里会设定两条硬性预算产物体积首屏入口chunk大小不能超过约定阈值比如235KB gzip超了就触发告警。代码质量重活不进主线程比如禁止在模块顶层直接跑大数据循环或同步解析大JSON。体积预算可以简单做成一个脚本在构建完成后读取产物大小超过阈值就让流水线失败if [[ $BUNDLE_SIZE -gt 235000 ]]; then echo 首屏包体积超出预算 exit 1 fi这种机制比“大家自觉”可靠得多。有了预算之后每次代码评审、每次合并请求体积的变化都有迹可循。我发现一个很有意思的现象只要预算写进流水线大家写代码时会自动掂量“这个依赖真的需要进首屏吗”比开会强调一百遍都管用。5.2 真机监控才是最终评审本地调试和构建预算都还是“按我们的机器和网络”来评估但性能的最终评审永远是线上真实用户的感受。所以上线后我习惯做一层真实用户性能采集用浏览器提供的性能观察接口把核心指标上报到监控平台。比如收集最大内容绘制时间const observer new PerformanceObserver((list) { for (const entry of list.getEntries()) { reportMetric(lcp, entry.startTime); } }); observer.observe({ type: largest-contentful-paint, buffered: true });同样可以收集FCP、CLS、交互延迟等指标。有了真实数据就既能知道优化方案在用户端是否真的生效也能及时发现某次版本发布引入的性能回退。真机数据有一个非常大的好处它会覆盖用户的真实网络、真实设备性能差异这是本地开发环境永远模拟不出来的。我自己的经验是监控指标不要贪多先定三个核心指标监控就够了。指标太多团队会麻木反而没人看。等核心指标稳定了再逐步增加一二线长尾指标。优先稳住用户感知最强的部分比构建一个完美的评分面板更实际。5.3 常见误区复盘我从这些坑里爬起来过第一只优化首个页面忽略了后续页面的交互响应。有的页面LCP漂亮得很但用户点按钮时卡成狗优化等于没做。所以前面那个INP指标一定要盯它管的是整体交互而不是开场表演。第二把“缓存”当成万能药。记忆化也好、接口缓存也好都有失效策略和内存成本。我见过一个组件把用户输入当场key把整堆结果塞进内存Map结果数据一变老缓存全成了垃圾性能反而更差。第三过度优化低优先级路径。曾经有大把时间花在给一个只运行一次的函数做极致的性能调优做完发现总耗时才少了几毫秒用户完全无感知。后来我给自己立个规矩先把耗时的分布图做出来只动头部占了80%时间的那些函数。第四根本没有预案就开始动手。开头那个想换框架的同事后来我们只改了十几行把滚动里的重复计算干掉帧时间从三十多毫秒降到个位数页面原封不动。这件事非常直观定位准确优化成本可能低到超出你想象定位不准做得越多偏得越远。说回流程本身。现在再有人报“页面卡”我的默认反应不是问“你觉得是哪的问题”而是先复现、录制、看数据再列假设、尝试、验证。我还喜欢顺手做一个小记录表把每次性能变更前后的指标值、改了哪些代码、谁负责的、哪个版本上线的都记下来。开始做这件事之前我也觉得挺麻烦真正做起来才发现性能优化最难的不是某一招而是让每一次优化都能闭环验证、能被团队看到收益。最后分享一个实操小窍门在项目里定期跑一次产物体积对比把历史版本和当前版本的关键指标贴在团队文档里让每次大版本迭代都把性能变化当成一个必看数据。匿名与否不重要重要的是它成了大家默认关心的东西。性能这件事说白了就是三个字——盯紧它。
阅读完成 · 觉得有帮助?
咨询建站