你是不是也有过这种经验页面逻辑写完了功能全通演示给同事看的时候却卡得人想摔键盘。滚动列表一顿一顿输入框按下键盘要等半天才出字打开DevTools Performance面板一看满屏的红色长条。JavaScript性能优化这件事说难不难说简单也绝对不简单——它不像修bug那样有明确的报错信息而是一堆“能跑但很慢”的问题堆在一起让你摸不着头脑。我在一线写了几年前端被线上性能问题教育过很多次这几年陆陆续续把踩过的坑和验证过的方法整理成了20个实战招式覆盖代码执行、渲染合成、内存回收、资源加载几个层面。今天这篇全部摊开讲适合正在做Web应用优化、被性能问题困扰、或者想系统提升前端基本功的开发者。这20招我都给过真实场景下的参数和取舍逻辑照着抄基本不会翻车。1. 性能优化先做测量不量化就动手等于盲人摸象1.1 用Performance面板建立基线数据我见过太多人一上来就改代码改了十几个文件最后发现瓶颈根本不是他们以为的那个。JavaScript性能优化有一个铁律先测量再动手。不测量就优化跟闭着眼睛修水管没区别。打开Chrome DevTools的Performance面板点录制然后复现你的慢场景滚动、输入、点击切换录5-10秒停止后你会得到一条完整的时间线。重点看两个东西Scripting时间JavaScript执行占了多长时间。如果Scripting区域有大块红色/黄色长条说明瓶颈在JS主线程运算。Rendering和Painting时间如果这两个区域突刺频繁说明浏览器在反复重排、重绘你的页面。第一次录数据的时候很多人会发现自己以为的“JS太慢”其实是“布局被反复触发”或者反过来。这就是基线的意义——它先告诉你敌人在哪你才知道往哪个方向开枪。我个人的习惯是存一份优化前的JSON报告优化后再录一份前后对比用数据说话才能说服自己改得值不值。1.2 用Lighthouse和PerformanceObserver量化目标光有Performance面板的直观时间线还不够建议再用Lighthouse跑一遍全页面的性能评分。Lighthouse给的是量化指标FCP首次内容绘制、LCP最大内容绘制、TBT总阻塞时间、CLS布局偏移。这些指标直接决定用户感知的“快”和“慢”。另外一个容易被忽略的工具是PerformanceObserver它可以精确监控长任务const observer new PerformanceObserver((list) { for (const entry of list.getEntries()) { if (entry.duration 50) { console.warn(长任务阻塞了 ${entry.duration.toFixed(0)}ms开始时间${entry.startTime}); } } }); observer.observe({ entryTypes: [longtask] });这里有个浏览器定义的门槛主线程单次任务超过50ms就会被标记为long task因为超过50ms用户就会觉得“卡了一下”。我把这个监听器放在公共代码里开发环境开着每日常规操作随手看控制台比等项目上线出问题再排查效率高得多。优化不是一次性的活儿建立一个可量化的监控闭环后面每改动一处代码都能立刻知道它是不是变慢了。2. 代码执行层把高开销的计算挪出热路径2.1 第3招 防抖与节流减少无效调用次数前端性能问题里比例最高的一类就是事件处理函数被高频触发而每个处理函数里都干了不该干的活。最典型的两个场景输入框实时搜索、滚动或resize事件里做DOM操作。这两种场景对应两个不同的解决方案。**防抖Debounce**的思路是有人连续触发我就等你消停一会儿再执行。**节流Throttle**的思路是不管你怎么疯狂触发我固定间隔执行一次。防抖的实现有很多现成库比如Lodash的debounce但我建议初级开发者自己写一遍核心逻辑这样你能真正理解它内部发生什么function debounce(fn, wait 300) { let timer null; return function (...args) { if (timer) clearTimeout(timer); timer setTimeout(() { fn.apply(this, args); timer null; }, wait); }; } function throttle(fn, interval 200) { let last 0; let timer null; return function (...args) { const now Date.now(); const remaining interval - (now - last); if (remaining 0) { if (timer) { clearTimeout(timer); timer null; } last now; fn.apply(this, args); } else if (!timer) { timer setTimeout(() { last Date.now(); timer null; fn.apply(this, args); }, remaining); } }; }防抖适合搜索请求、窗口resize后的重算节流适合滚动到底部加载更多、拖拽元素时的坐标更新。我在真实项目里的经验值是搜索防抖用250-400ms超过400ms用户会觉得“它是不是不让我搜了”滚动节流用100-200ms低于100ms在低端机上没意义高于200ms会明显丢帧。防抖和节流不是性能优化层面的银弹但它们能直接掐断事件风暴这个源头属于性价比很高的一招。2.2 第5招 虚拟滚动几千条DOM一次性渲染神仙也扛不住列表渲染是个老生常谈的问题。渲染500条数据可能还能凑合渲染5000条、50000条如果你直接把模块全塞进DOM浏览器会直接给脸色看。因为每个DOM节点都要占用内存、参与布局和绘制节点一多哪怕只是改一个class都会触发级联的布局计算。我处理过一个后台日志列表一次性要展示1万条记录早期代码用innerHTML全量拼接页面加载直接白屏6秒滚动时CPU占用率拉满。最直接的解法是虚拟滚动只渲染可视区域内的条目其他条目用空白撑起滚动条高度。核心思路拆解一下// container 是固定高度的滚动容器 const itemHeight 28; // 每条固定高度 const visibleCount Math.ceil(container.clientHeight / itemHeight); // 可视区能放几条 const startIndex Math.max(0, Math.floor(container.scrollTop / itemHeight) - overscan); const endIndex Math.min(data.length, startIndex visibleCount overscan * 2);关键就在于overscan参数我通常设为5-10它多渲染一段缓冲区防止快速滚动时出现白屏闪烁。有人说“我们的条目高度不固定”那更麻烦你需要先估算高度再在渲染后修正偏移量。虚拟滚动实现起来有一堆边界情况如果项目着急可以用成熟的库——react-window、vue-virtual-scroller都行——但是理解上面这个startIndex/endIndex原理很重要因为调参和排查问题的时候你得知道它底层在算什么。2.3 第6招 条件优先与提前返回写高判断效率的代码这一招看着基础但我在Code Review里几乎每次都提。JavaScript引擎做条件判断时虽然会做各种优化但一个函数里嵌套五六层if-else可读性差不说逻辑上也会有不少无效分支。举一个真实例子某个接口要处理多种消息类型最开始的代码长这样function handleMessage(msg) { if (msg) { if (msg.type sync) { if (msg.payload msg.payload.list) { doSync(msg.payload.list); } else { logError(payload缺失); } } else if (msg.type heartbeat) { // ... } } }这种写法的问题不在性能而在“校验逻辑散落嵌套”。更高效的写法是守卫子句Guard Clauses把异常和边界情况先挡在门外主逻辑平铺直叙function handleMessage(msg) { if (!msg) return; if (!msg.type) return; if (msg.type sync) { if (!msg.payload || !msg.payload.list) { logError(payload缺失); return; } doSync(msg.payload.list); return; } if (msg.type heartbeat) { // ... } }有人会说这跟性能有什么关系关系其实分两面。一方面提前返回确实能减少不必要的分支判断和函数调用但更重要的另一方面是当代码结构简单了那些藏在嵌套分支里的重复计算、多余的API调用你才有机会一眼看到并把它提出来。优化代码的第一步永远是先把代码写到人能轻松看懂的水平——机器看得懂不等于人能看懂而这个“人”就是你未来三个月之后的自己。2.4 第7招 用缓存计算结果Memoization的实用姿势如果同一个函数用相同参数被反复调用而计算本身开销又大缓存是最直接的提速手段。这里说的缓存不是浏览器HTTP缓存而是代码层面的Memoization记忆化。一个典型场景处理表格数据时某个纯函数要把原始记录map成展示结构而且列表一变化就全部重算。没缓存的时候每次setState都要重跑几千条数据的转换逻辑。简单的手写缓存可以这样function memoize(fn) { const cache new Map(); return function (...args) { const key JSON.stringify(args); if (cache.has(key)) return cache.get(key); const result fn.apply(this, args); cache.set(key, result); return result; }; }注意这里我用Map而不是普通对象因为Map的key可以是任意类型而且它遍历顺序和插入顺序一致。但这种简单版本有个局限如果参数是嵌套对象JSON.stringify本身也要开销。所以实用的原则是只对参数简单、计算贵、调用频繁的函数做memoize如果参数本身就有几千条数组JSON.stringify反而成了新的瓶颈。更精准的做法是泛化缓存策略——比如以第一条数据的id作为key或者用WeakMap关联对象本身。3. 渲染合成层别逼着浏览器反复重排重绘3.1 第8招 批量DOM操作一次写入替代多次零碎修改操作DOM是JavaScript性能杀手排行榜的常客。尤其当你用循环去改多个DOM属性或者频繁增删节点时浏览器每次都要重新计算样式这叫重排Reflow然后重新绘制页面这叫重绘Repaint。重排尤其贵因为它影响的不只是当前节点还可能波及子节点和后续兄弟节点。一个很常见的场景往表格里插入1000行数据。新手写法是循环里逐条appendChild效率极低。正确做法是先把节点拼接或创建到一个DocumentFragment里最后一次塞进DOMconst fragment document.createDocumentFragment(); for (let i 0; i 1000; i) { const tr document.createElement(tr); tr.innerHTML td${i}/tdtddata-${i}/td; fragment.appendChild(tr); } table.appendChild(fragment);DocumentFragment是一个轻量级的“虚拟容器”它不占DOM树位置所有节点先在内存里组装好最后一次性提交到真实DOM浏览器只需要做一次重排。这段代码我优化过最快的case原本耗时120ms改成fragment之后只花12ms——十倍差距且代码改动量极小。3.2 第9招 读写分离与强制同步布局别在读取时插入写入这一招非常隐蔽大部分人踩了坑都意识不到问题出在哪。浏览器为了性能会把你对样式的写入操作批量排队等到需要读取时再一次刷新。但如果你在代码里写了“写入-读取-写入-读取”的交叉模式就会强制浏览器提前做一次布局这叫强制同步布局Forced Synchronous Layout。最常见的例子是循环里同时改样式又读取offsetHeight// 性能杀手每轮循环都强制同步布局 for (let i 0; i items.length; i) { items[i].style.height ${items[i].offsetHeight 10}px; }每一次循环offsetHeight的读取都会逼着浏览器立刻执行一次布局计算来给你一个准确值。循环100次就是100次布局。修法很简单把读取和写入分成两批const heights items.map(item item.offsetHeight); // 先统一读取 items.forEach((item, i) { item.style.height ${heights[i] 10}px; // 再统一写入 });第一次读完后浏览器已经把布局算过一遍了后面对style.height的写入可以批量排队。这个“读写分离”的思路在处理列表滚动、游戏界面HUD更新等高频场景格外重要。我自己甚至会在代码里做注释标注// 此处是读取区不要插入任何style写入。3.3 第10-12招 动画性能三连transform、requestAnimationFrame与will-change动画卡顿是性能优化里最容易被感知的问题。用户可能说不清什么TPS、FPS但他能一眼看出“滚动不顺”“动画掉帧”。这里有三招组合拳。第一用transform和opacity实现动画别用top/left/width/height。因为transform和opacity只触发合成Composite不触发重排和重绘而top/left的每次变化都会触发布局计算。同样一个滑入动画用transform: translateX()做GPU能直接参与合成丝滑程度完全不一样。第二用requestAnimationFrame控制动画节奏。不要用setTimeout或setInterval控制动画帧因为setInterval不跟屏幕刷新率同步容易丢帧或造成抖动。requestAnimationFrame会让浏览器在每一次重绘之前调用你注册的回调它天然适配60Hz刷新率function animate() { // 更新动画状态 updatePosition(); // 触发下一帧 requestAnimationFrame(animate); } requestAnimationFrame(animate);第三will-change要谨慎使用。will-change: transform会告诉浏览器提前建立合成层听起来很美但滥用会让浏览器为大量节点准备图层内存暴涨反而拖垮整体性能。我的实践原则是只在动画即将开始的元素上临时加动画结束就移除或者只在真正需要平滑动画的关键元素上用不要大面积铺开。3.4 第13招 Canvas性能减少状态切换与离屏画布如果你在做图表、游戏或者复杂的动效Canvas是绕不开的。Canvas性能问题的根源往往不是绘制次数太多而是状态切换太频繁。每次strokeStyle、fillStyle、lineWidth切换Canvas内部都要做一次状态校验和恢复几百个形状切换几千次累计开销非常可观。我的经验是先按状态分组把同颜色的元素一起画完再切换下一次颜色能合到一个路径里的就合到一个path里减少beginPath到stroke的重复调用次数。另一个实用技巧是离屏Canvas把静态背景、网格线、复杂的装饰层预先画到一个内存中的canvas上每次渲染时直接drawImage把它贴上去而不是每帧重新计算绘制。这相当于把「每帧都要算的复杂背景」变成了「一次算好、每帧只拷贝」性能提升肉眼可见。4. 内存与生命周期泄漏和GC抖动是隐形杀手4.1 第15招 事件监听器与定时器的回收时机写页面的时候加了监听器、开了定时器页面切走或者组件销毁的时候你有没有清理如果没有这些回调会一直存活在内存里不仅造成泄漏还可能导致意外触发。尤其SPA单页应用里组件销毁但监听器还在是一类高频Bug。以Vue/React组件为例标准的清理模式一样// 挂载时 const handler () { /* ... */ }; window.addEventListener(scroll, handler); const timer setInterval(() { /* ... */ }, 1000); // 卸载时必须 window.removeEventListener(scroll, handler); clearInterval(timer);这里有个容易遗漏的细节addEventListener传了匿名函数就永远无法removeEventListener了必须先把函数引用存成变量。定时器同理。还有一类坑是第三方库内部注册的全局事件比如某个弹窗插件在全局监听了keydown你关闭弹窗时它没卸载结果用户在页面上打字莫名触发快捷键逻辑。排查这类问题可以在Chrome的Memory面板里录制heap snapshot搜索detached关键字能看到那些游离在外的DOM节点和挂在它们身上的事件。4.2 第16招 缓存必须设上限Memory Cache的无界增长Memoization虽好但如果你缓存了上万个结果且永远不清理它就变成了内存泄漏。这是一个非常典型的“优化引入新问题”的案例。给缓存加容量上限有几种策略。最简单的是LRULeast Recently Used最近最少使用的缓存条目优先淘汰。实现不用很复杂一个Map加一个数组记录使用顺序就够了。我在项目里写过一个小工具类只保留最近500条计算结果class LRUCache { constructor(limit 500) { this.limit limit; this.cache new Map(); } get(key) { if (!this.cache.has(key)) return undefined; const value this.cache.get(key); this.cache.delete(key); this.cache.set(key, value); // 刷新到最新 return value; } set(key, value) { if (this.cache.has(key)) this.cache.delete(key); else if (this.cache.size this.limit) { this.cache.delete(this.cache.keys().next().value); // 淘汰最老的 } this.cache.set(key, value); } }这块的细节是Map的迭代顺序等于插入顺序最老的key是cache.keys().next().value。有了上限缓存工具在高频调用下内存占用保持恒定不会越跑越肥。顺便说一句某些场景用WeakMap更合适——它的key是弱引用不会被垃圾回收机制视为“有用引用”意味着没有其他引用时对象可以被顺利回收适合存“跟某个DOM节点或对象关联的私有数据”。4.3 第17招 字符串与数组的高效处理方式JavaScript里字符串是不可变的每次拼接都会产生一个新字符串。这条规则在数据量小的时候无所谓但如果在一个循环里反复拼接几百次中间产生的临时字符串都跟着成为垃圾对象给GC增加负担。处理方式有两种。小规模拼接用模板字符串最清晰prefix-${value}-suffix。大规模拼接比如拼几千行HTML最稳妥是先推入数组再join()或者用Array.prototype.reduce组合字符串。现代JavaScript引擎对字符串拼接做了不少优化普通场景不用太焦虑但一旦发现Performance面板里GC垃圾回收频繁闪烁就要回头审视有没有在循环里做无谓的字符串或数组重建。数组处理的高效姿势同样重要频繁增删头部元素用shift/unshift是低效的改成尾部增删或改用双端队列查找元素用includes替代indexOf语义更清晰在大量数据里查找key用Map而不是数组遍历。这些不是玄学都是能让引擎原生优势发挥出来的写法。5. 网络与启动性能把等待时间压到最低5.1 第18招 代码拆分与按需加载包体积直接影响首屏加载时间。很多项目最后打出来的包动辄一两MB用户首屏就要下载整个应用所有页面的代码。代码拆分Code Splitting的思路是把每个路由对应的页面代码拆成独立chunk只有用户访问到该路由才加载。Webpack/Vite都天然支持这个能力。以Vite为例路由级别的懒加载巨简单const routes [ { path: /, component: () import(./pages/Home.vue) }, { path: /detail, component: () import(./pages/Detail.vue) } ];() import()这种动态导入语法构建时会被单独分割成一个chunk。但拆分的原则也要克制——拆得太碎每个chunk的请求开销反而拖慢加载。我一般掌握两个尺度首屏必需代码全部打进主包其他路由按页面拆只对体积超过30-50KB的第三方库单独分包并用preload提前加载关键资源。5.2 第19-20招 资源加载优先级与高效API浏览器加载资源是有优先级的但很多开发者没主动干预过。link relpreload能提前请求关键资源比如首屏字体、首屏图片link relprefetch能在浏览器空闲时提前缓存将来可能用到的资源。一个明显的场景是表格页依赖一个很大的图表库你可以在用户鼠标悬停在“查看图表”按钮上时就开始动态加载这个库等真点进去已经加载完了。最后补一招很多项目没做到的用原生高性能API替代手写逻辑。Array.prototype的map/filter/reduce、Object.keys/values/entries、URLSearchParams、IntersectionObserver……这些内置API都是引擎层深度优化的比你自己写for循环去解析、去遍历、去检测要快得多而且代码量更少、语义更清晰。比如图片懒加载手动监听scroll然后计算位置性能损耗大还容易算错换成IntersectionObserver浏览器底层帮你处理了位置检测和回调时机性能和稳定性都拉满const observer new IntersectionObserver((entries) { entries.forEach(entry { if (entry.isIntersecting) { entry.target.src entry.target.dataset.src; observer.unobserve(entry.target); } }); }); document.querySelectorAll(img[data-src]).forEach(img observer.observe(img));这不只是便捷IntersectionObserver的回调是异步且基于GPU合成信息的不会阻塞主线程。我经常跟组里的新人说与其造轮子不如先翻一遍MDN索引页看看有哪些原生API能帮你省事——很多性能问题是“用错了工具”导致的。6. 20招速查清单与自检顺序最后把20招完整列一个速查表方便你直接在项目里对照检查。优化的顺序建议先从第1、2招开始——先测出问题再动手改别跳步。类别招数核心动作测量1-2招Performance建立基线监控Lighthouse定指标预算代码执行3-4招防抖/节流控制事件高频触发代码执行5招大数据量列表用虚拟滚动代码执行6招守卫子句简化分支逻辑代码执行7招高频纯函数加Memoization缓存代码执行8招批量DOM操作用DocumentFragment合并写入渲染9招读写分离避免强制同步布局渲染10-12招transform/opacity做动画rAF控制节奏will-change慎用渲染13招Canvas按状态分组绘制离屏画布渲染/脚本14招事件委托减少监听器数量内存15招监听器与定时器成对清理内存16招缓存加LRU上限弱引用用WeakMap内存17招高频字符串/数组操作换引擎友好写法网络/启动18招路由代码拆分按需加载网络/启动19-20招preload/prefetch关键资源优先使用原生高性能API检查的时候建议按顺序过一遍先看Performance面板有没有long task长条再看Network面板有没有多余的重复请求然后打开Memory面板拍一次heap snapshot看有没有游离DOM最后用Lighthouse打一次分。这套组合拳下来不管是老项目抢救还是新项目防患都能找到明确的发力点。很多性能问题并不是某一个点特别严重而是十几个小毛病叠加。你在第1、2招里建了基线后面每一招改完都可以对比数据看提升多少——这种正向反馈才是做性能优化能坚持下来最重要的动力。
阅读完成 · 觉得有帮助?