尧图精选

前端性能优化30招:从加载、执行到渲染的全面提速指南

🕒 发布时间:2026/11/5 8:16:35 📁 来源:尧图网络
1. 优化前先做诊断5分钟定位JS性能瓶颈你在地址栏敲下回车之后从HTML解析到第一个可点击的按钮之间藏着成千上万次渲染、布局、脚本执行。哪个环节慢了用户不会告诉我具体原因只会留下一个印象“这网站卡死了”。JavaScript性能优化就是在这些最不起眼又最能决定体验的细节里做减法。这篇文章把常见的优化手段整理成30招不追求让你理解V8内部每个字节码的含义而是解决一个实际问题页面为什么慢怎么让它快起来以及优化之后如何验证“真的快了”。适合刚接触性能优化、被长列表和首屏白屏折磨过的前端同学也适合准备面试时被问“你会从哪些角度做性能优化”的人。1.1 先分清场景白屏慢还是操作卡我见过最多的性能翻车现场不是某个算法写得烂而是压根不知道瓶颈在哪。所以动手优化前第一件事不是写代码而是判断你的页面属于哪种“病人”。一类是白屏型打开页面转圈转两三秒内容迟迟不出现。这种问题多半出在网络加载阶段脚本体积太大、请求太多、关键JS被阻塞。另一类是交互型页面加载完了但滚动掉帧、点击反应慢、按钮按下去像隔了一层棉花。这种问题多半出在主线程被长任务占用、DOM频繁重排重绘、或者事件处理器太多。两种场景的优化策略完全不一样。就好比一个人是吃得太撑导致走不动另一个是路面上全是坑导致摔跤你不能用同一种药去治。1.2 Performance面板找长任务三步操作确认了大致类型之后用Chrome DevTools的Performance面板做一次真实体检比凭感觉猜靠谱得多。第一步打开DevTools切到Performance面板点击左上角录制按钮。第二步正常操作你的页面比如等待首屏、滚动列表、点击按钮持续10秒左右。第三步停止录制看主线程的火焰图。如果出现红色或者标记为“Long Task”的色块说明主线程在那里被阻塞了超过50ms这就是卡顿的直接证据。我有一个习惯录制的时候会把CPU降频调成4x或6x模拟低端机语言里叫“CPU Throttling”。在真机高配iPhone上顺滑的页面降频后很可能惨不忍睹。用这种方式测出来的长任务才更容易暴露真实项目里的小隐患。Performance面板里那个自顶向下的调用树也很关键点击耗时的函数能看到具体是哪个JS文件里的哪一行在作妖。1.3 目标量化LCP、INP、CLS先定基线没有量化的优化本质上都是自我感动。所以体检完你得给页面定几个数字指标。现在Web性能领域谈得最多的是Core Web Vitals三个核心指标基本覆盖了用户最直观的感受。LCPLargest Contentful Paint代表最大内容绘制时间翻译成人话就是“用户看到核心内容花了多久”INPInteraction to Next Paint代表交互到下一次绘制的延迟就是“用户点了按钮之后页面响应有多快”CLSCumulative Layout Shift代表累计布局偏移就是“页面内容有没有突然跳走”。还有FCP和TTFB作为辅助参考。我给自己项目定的基线通常是LCP在2.5秒以内INP在200ms以下CLS小于0.1。当然移动端低端机可以适当放宽但目标一定要可测量。后续每做一次优化都跑一遍同样的场景拿数据说话这样才知道是白忙一场还是真见效。2. 加载期优化让脚本更小、更早、更聪明用户的第一道门槛就是白屏等待所以加载期的优先级最高。这一阶段的核心思路只有一句话让用户需要的那部分脚本更快到达浏览器同时让不需要的代码别来凑热闹。2.1 前三招Tree Shaking、路由拆包、动态导入第一招Tree Shaking。简单说就是在打包阶段把模块里没被用到的导出删掉。前提是你用的是ES Module因为它的依赖关系是静态的打包器才能安全地做“摇树”。CommonJS那种动态require的写法基本没法可靠摇树。实际项目里经常出现“工具函数文件导出几十个函数实际只用了一个打包出来全带上了”的情况。用Webpack或Vite的产物分析工具看一眼你会吓一跳。配置package.json的sideEffects字段也很关键它可以告诉打包器“这个文件的副作用可以忽略大胆删”。第二招路由级代码分割。比如React项目里用React.lazy和Suspense把非首屏路由拆成独立chunkVite天然支持Webpack需要配splitChunks。这么做的收益是首屏只加载当前页面的代码其他路由的JS等用户真正跳转时才下。我见过一个后台管理系统把所有路由组件打成一个5MB的bundle改成路由级拆包之后首屏直接快了一倍多。第三招按需动态导入。这招可以看作代码分割的一种具体应用。比如一个图表库只在弹窗打开时才需要那就别在入口文件里import改写成点击弹窗时再dynamic import。这样首屏不加载图表库用户也不会感知到多等因为弹窗打开前有一小段时间可以用来预加载。2.2 第四招关键脚本内联与preload/prefetch搭配很多人把优化理解为“减少请求数”所以见到一切外链都想内联。其实内联适合的是那些极小且关键的逻辑比如首屏接口请求的初始化参数、移动端浏览器特性探测、或者一小段样式渲染逻辑。把这三五行代码直接写在HTML里可以避免一次额外的网络往返。但千万别把React、Vue这种大库也内联那会把HTML撑得很臃肿反而拖慢解析。更常用的手段是preload和prefetch。preload告诉你“当前页面马上要用这个资源提前下载”prefetch告诉你“将来某段时间可能用趁空闲下载”。link relpreload asscript href/js/chunk-main.a1b2c3.js link relprefetch asscript href/js/detail-page.js注意别滥用preload。移动端带宽有限一次preload三五个大文件首屏带宽会被抢光得不偿失。通常我只对首屏真正依赖的1-2个关键资源做preloadprefetch则放在网络空闲时做。2.3 第五招Brotli压缩与不可变缓存传输层压缩是性价比最高的一项投入甚至比改代码还快见效。现在主流做法是用Brotli压缩率普遍比Gzip再低15%到20%。Nginx里启用Brotli很简单brotli on; brotli_comp_level 6; brotli_types text/javascript application/javascript application/json;如果服务器还没装Brotli模块先装一下长期收益非常大。除了压缩静态资源的缓存策略也必须配上。文件名带hash的JS/CSS可以放心用长缓存因为内容变了文件名就变了不会出现缓存不失效的问题location /static/ { add_header Cache-Control public, max-age31536000, immutable; }immutable这个指令是告诉浏览器这个文件可以放心缓存一年连重新验证都不用。很多团队优化半天代码体积结果每次回访用户都在重新下载同样的文件等于前面全白干。2.4 第六招defer和async的选择时机原生script标签的加载方式很多人用了好几年都没搞清楚。它们的区别直接影响页面解析流程。加载方式DOM解析阻塞执行时机执行顺序普通script阻塞下载完立即执行按文档顺序async不阻塞解析下载完立即执行不保证顺序defer不阻塞解析DOM解析完成后执行按文档顺序我的建议很简单现代项目里几乎不要用普通同步script放在头部阻塞解析多个相互独立的第三方脚本可以用async依赖顺序的脚本、以及业务代码用defer。如果是ES Module默认就是defer行为这正好符合大多数场景。还有一个隐藏细节动态创建的script标签默认async如果你动态插入脚本又想保持顺序得把async显式设为false。3. 执行期优化把CPU花在真正必要的事情上加载期优化解决的是“代码什么时候到”执行期优化解决的是“代码到了之后怎么跑得更快”。这一章讲运行时也就是浏览器主线程上的那些活儿。3.1 三招一路Map/Set、memoization、缓存作用域第七招高频查找用Map/Set而不是朴素对象。这是个很实用但很容易被忽略的点。对象的原型链上挂着Object.prototype访问属性时会有额外查找和类型转换。当你维护一个一万条的映射表每秒做几千次查找时Map的优势会被明显放大。Set则特别适合“判断某个值是否存在”的场景比如去重、白名单校验。我早年写过一个通讯录匹配逻辑用对象存联系人索引后来改成Map在三千个联系人里做搜索耗时几乎减半。第八招memoization记住纯函数的计算结果。同一个入参永远返回同一个结果、没有副作用的函数才值得缓存。下面这个简单的备忘函数可以放在工具库里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; }; }注意两点JSON.stringify本身也有成本简单场景可以自己拼key缓存必须考虑上限否则缓存本身就成了内存泄漏。第九招和第十招其实是一对难兄难弟循环里不建闭包、局部变量缓存全局引用。循环里定义一个函数每次迭代都会创建新的函数对象带来无谓的垃圾回收压力同理每次循环都写document.querySelector(...)引擎要从作用域链一路找到全局对象还要解析字符串选择器。正确做法是把需要的全局对象和DOM节点先存到局部变量里让作用域链查找变短路const doc document; const list doc.querySelectorAll(.item); for (let i 0; i list.length; i) { // 使用doc和list }3.2 字符串拼接与运算细节容易被忽视的隐形开销第十一招是字符串拼接。很多人觉得模板字符串万能但在一个成千上万次的循环里生成大段HTML反复会导致字符串对象反复分配、复制。我在一个表格导出功能里试过生成五千行的HTML用拼接需要120ms改成数组push再join直接降到30ms左右。现在V8引擎对字符串拼接优化了不少但大量循环场景里这种差距依然存在。第十二招位运算。位运算确实快但前提是别牺牲可读性。整数判断、标志位组合、颜色值处理这些场景用位运算是合理的比如判断一个数是奇数还是偶数、组合权限位。但像~indexOf这种写法性能提升微乎其微代码却变得难以阅读。优化不是为了炫技值不值得要算总账。第十三招警惕隐式类型转换。这个坑比想象中常见。数组求和时万一某个元素是字符串整个加法链瞬间变成字符串拼接用比较会触发两边类型的隐式转换热路径里频繁发生这些转换性能损耗会被放大。我建议业务代码里一律用数据进函数前先做一次类型校验别让脏数据流到热循环里。3.3 第十四招Web Worker搬走CPU密集任务主线程是单线程一个耗时的同步任务会把所有交互堵死。你压图片、处理大量数据、加密、解析大型JSON时页面就会看起来“死了一会儿”。Web Worker就是浏览器开给我们的后门它在一个独立线程里运行不阻塞主线程。const worker new Worker(/worker.js); worker.postMessage({ data: hugeArray, chunkSize: 1000 }); worker.onmessage (e) { renderTable(e.data.result); };Worker里不能访问DOM所以适合把数据处理好再传回主线程。这里有个细节普通postMessage传ArrayBuffer会拷贝一份大数据量时开销很大用transferable对象列表传引用可以避免拷贝比如worker.postMessage(buffer, [buffer])。不过注意转移之后主线程里的原buffer就不能再访问了。3.4 第十五招长任务切片与微任务节制有些任务没法完全塞进Worker比如要操作DOM的批量渲染。这时候需要把任务切成小块避免任何一个宏任务占用主线程超过50ms。比如有几万条数据要逐条处理可以分批执行function processInChunks(items, chunkSize, fn) { let index 0; function chunk() { const end Math.min(index chunkSize, items.length); for (; index end; index) { fn(items[index]); } if (index items.length) { setTimeout(chunk, 0); } } chunk(); }这里用setTimeout把下一批任务排到宏任务队列让浏览器有机会插入渲染帧。另一个容易踩的坑是微任务滥用Promise.then会在当前宏任务结束后立刻全部执行如果你在一个循环里连续创建成千上万个Promise渲染一样被堵死。所以微任务要节制需要“让一让”渲染的话用宏任务或者MessageChannel作为切片的载体。4. 渲染期优化从DOM到像素减少无效动作如果说加载期是“用户等代码”执行期是“引擎算代码”渲染期就是“浏览器画页面”。这个阶段用户感知最强优化余地也最大。4.1 第十六十八招批量DOM、合并样式、读写分离第十六招是批量插入DOM。连续往页面里appendChild十次浏览器可能要布局十次。正确的姿势是把节点先塞进DocumentFragment最后一次性插进去const frag document.createDocumentFragment(); for (const data of list) { const div document.createElement(div); div.textContent data.name; frag.appendChild(div); } container.appendChild(frag);第十七招是合并样式操作。逐个修改style.left、style.color会触发重复的样式计算把多个样式变化合并成一个class切换是最稳的做法。classList.add看起平平无奇但它走的是选择器匹配浏览器对这类变更做了大量优化。第十八招是杜绝强制同步布局这是渲染优化里最容易违背直觉的一条。先写后读会造成“布局抖动”比如循环里先设置style.height再读offsetHeight浏览器为了给你一个准确的高度只能被迫立即重新布局。正确顺序是“先批量读、再批量写”或者把写操作放进requestAnimationFrame统一执行。这个坑常见于滚动加载、展开收起动画、拖拽排序等交互里排查方法是在Performance面板里找紫色高亮的“Forced Reflow”标记。4.2 第十九招事件委托监听器越少越好给列表里的每个item分别绑事件和给父容器绑一个事件性能差异在实际项目中非常明显。一个1000行的动态列表每行绑三四个监听器光绑定和销毁就是几千次操作内存里保留的引用也会消耗空间。事件委托的原理就是利用事件冒泡listContainer.addEventListener(click, (e) { const item e.target.closest(.list-item); if (!item) return; handleItemClick(item.dataset.id); });额外的好处是新增的行不用重新绑定事件动态列表场景尤其省心。注意别在委托处理器里做太重的事因为它服务的是整个列表的点击。4.3 防抖节流与rAF交互优化的三种正常姿势第二十招和第二十一招经常一起出现。防抖debounce适合“连续操作后在结束时执行”的场景典型例子是搜索框输入停止输入300ms后才发起请求。节流throttle适合“固定间隔最多执行一次”的场景典型例子是滚动事件里判断吸顶逻辑。二者实现方式有很多但核心共识是高频触发的事件处理器绝不能裸着跑。动画场景则更适合requestAnimationFrame。setTimeout靠的是“等多少毫秒后执行”而rAF跟着浏览器的刷新频率走通常60Hz或120Hz保证每次绘制前都有一次机会更新状态。用setTimeout驱动动画切到慢速设备或者后台标签页时很容易出现掉帧或延迟。我自己做拖拽、进度条动画一律用rAF。4.4 动画优化transform与will-change的取舍第二十二招能用transform和opacity实现的动画就别用left、top、width。前者走的是合成器浏览器可以在独立的合成层上完成动画不触发layout也不触发paint后者每帧都要重新布局和绘制性能差了一个量级。比如一个元素从左侧飞入移动left每帧都触发布局改成transform: translateX()之后动画线程直接接管主线程几乎无感。第二十三招是关于will-change的。这个属性确实可以提前告诉浏览器“这个元素要变化先建好合成层”但滥用反而有害。合成层是要占GPU内存的几十个元素都加will-change低端手机上内存直接告急。我的习惯是只给正在动画的元素加动画结束时立刻移除。与其到处贴will-change不如优先保证动画属性本身用的是transform和opacity。4.5 懒加载、虚拟列表与监听器清理长列表三件套第二十四招IntersectionObserver做懒加载。监听一个元素是否进入视口浏览器原生帮你判断比scroll事件里手动getBoundingClientRect再比较高效得多因为它是异步观察、浏览器层面优化过的。图片懒加载最轻量的替代方案是loadinglazy属性但复杂场景还是IntersectionObserver可控。第二十五招虚拟列表。当长列表动辄几百上千条每条里面还有图片、按钮、文本节点时就算所有其他优化都做了一次性渲染也会拖垮渲染线程。虚拟列表的思路是只渲染可视区内的节点配合一个隐形撑高的容器来维持滚动条长度滚动时动态计算起始索引和结束索引。固定行高的实现比较简单不固定行高就得预先测量或者用内容自适应复杂度会高不少。第二十六招监听器要复用、要及时清理。SPA里组件卸载不清理定时器和监听器是内存泄漏和性能退化的隐形根源。现代浏览器支持AbortControllerconst controller new AbortController(); window.addEventListener(resize, handler, { signal: controller.signal }); // 组件卸载时 controller.abort();这一招配合事件委托基本可以让事件相关内存做到可控。5. 移动端专项与工具链升级不止是缩小代码PC上运行流畅的页面放到中低端安卓机上可能一秒掉十帧。移动端的硬件瓶颈、网络带宽、GPU内存都和桌面环境差很远所以必须有专项策略。5.1 第二十七招首屏预算与Coverage分析移动端首屏JS预算业内常提的经验值是gzip压缩后170KB以内。这个数字不是拍脑袋它考虑了低端机的解析执行时间以及3G网络的下载耗时。超过这个值不代表一定慢但意味着你需要警惕。配合DevTools的Coverage面板能直观看到代码里哪些行被标注成红色——红色就是从未执行过的死代码。把没执行的部分剔除或拆成按需加载首屏体积通常能砍掉一大截。注意预算要按路由设定管理后台每个页面对应一个预算文件低于预算的chunk放行超出的在CI里报警。有了预算才会倒逼团队每加一个依赖时问一句这值不值得。5.2 第二十八招图片与合成层的移动端取舍移动端屏幕小但DPR高一张1920宽的大图在普通手机上会先下载完整大图再做缩放浪费带宽又增加解码耗时。正确做法是响应式图片img srcsetimg-480w.jpg 480w, img-960w.jpg 960w sizes(max-width: 600px) 480px, 960px srcimg-960w.jpg alt示例 格式上优先AVIF或WebP兼容性处理靠picture标签加fallback。另外所有有尺寸图片尽量写width/height避免图片加载完成后把页面撑一下这一下就是CLS的来源。合成层在移动端更敏感。transform动画、will-change、video、canvas都会产生合成层而移动端GPU内存有限合成层太多会触发额外的纹理上传和内存占用。我的建议是动画结束后及时清理合成层状态页面上同时存在的合成层控制在几十个以内。5.3 第二十九招现代工具链让构建产物更干净工具链对性能的影响经常被忽视。同样是打包一个大型项目Webpack做一次全量构建要几分钟而基于Rust的工具链比如SWC、oxc可以把编译速度提升数倍复杂表达式、类型擦除、压缩都交给原生代码处理。Vite在开发阶段基于ESBuild生产构建也能接入oxc产物体积的优化颗粒度更细。另一个容易被忽略的点是TypeScript的编译目标。很多项目为了兼容旧浏览器把target设成ES5大量新语法被转译成笨重的ES5代码还额外引入polyfill。如果你的用户群体都用现代浏览器把target提升到ES2017或更高产物能瘦不少。polyfill也尽量按需引入切不要整个core-js全量塞进去。5.4 第三十招性能监控与回归防线性能优化最怕的是“优化完就结束了”过几周又慢回去。所以最后一招是把关键性能指标收进监控体系用数据拦截回归。PerformanceObserver可以在线收集真实用户指标const observer new PerformanceObserver((list) { for (const entry of list.getEntries()) { reportMetric(entry.name, entry.value); } }); observer.observe({ type: largest-contentful-paint, buffered: true });配合Lighthouse CI每个合并请求跑一次性能基准分数或关键指标不达标就阻塞合并。这样性能优化才不是一次性活动而是持续改进的流程。别在生产环境全量开启所有调试仪表只采集核心几条就够。6. 实战记录一次滚动卡顿的完整排查与修复前面三十招铺开讲可能有点零散。最后一个部分我用一个真实的排查过程把它们串起来。6.1 现场现象与最初判断一个信息流页面里面是大量带图卡片往下滚动时明显掉帧Chrome DevTools右上角FPS显示在40左右徘徊。最初大家的直觉是“图片太多解码太慢”。我做了个简单验证把页面里的图片全部屏蔽滚动还是卡说明图片不是唯一元凶。6.2 工具定位长任务、强制布局与冗余监听器用Performance录制10秒滚动发现主线程隔几百毫秒就出现一个长任务点进去看调用栈里面多次出现scrollHandler和getBoundingClientRect。再打开Rendering面板里的Paint flashing滚动时大片绿色区域不断闪烁说明频繁重绘。代码检查后发现三个问题第一每个卡片都绑了独立的click监听器和touch监听器总共几千个第二scroll事件里每次都对所有卡片做getBoundingClientRect判断是否进入视口第三图片全部用原图地址动不动一两MB解码和缩放都压在主线程。定位到这里其实优化方案已经很清楚。6.3 修复动作与优化后的真实数据改动分四步scroll处理器改成防抖加读写分离先批量读取视口信息再统一做判断图片改成懒加载加srcset同时给img固定宽高所有卡片事件改成父容器事件委托外层容器增加虚拟列表只渲染可视区域上下各一屏。改完之后再录制FPS基本稳定在60长任务从10秒内出现七八次变成零次INP从280ms降到120ms左右。这个结果不算玄学每一处改动都能在Performance面板里找到对应依据。6.4 30招速查实际项目从哪切入分类招数适用场景加载期第1招 TreeShaking、第2招 路由拆包、第3招 动态导入首屏JS体积过大加载期第4招 内联preload/prefetch关键资源加载慢加载期第5招 Brotli不可变缓存重复下载静态资源加载期第6招 defer与async脚本阻塞DOM解析执行期第7招 Map/Set、第8招 memoization、第9招 循环内不建闭包、第10招 缓存全局引用频繁计算与查找执行期第11招 字符串拼接、第12招 位运算适度、第13招 隐式转换热路径代码执行期第14招 Web WorkerCPU密集任务执行期第15招 长任务切片大量DOM数据需分批处理渲染期第16招 DocumentFragment、第17招 合并样式、第18招 读写分离DOM操作频繁渲染期第19招 事件委托大量列表交互渲染期第20招 防抖节流、第21招 rAF高频事件、动画渲染期第22招 transform/opacity、第23招 will-change动画掉帧渲染期第24招 IntersectionObserver、第25招 虚拟列表、第26招 监听器清理长列表与资源懒加载移动端与工具第27招 首屏预算、第28招 图片与合成层移动端卡顿移动端与工具第29招 现代工具链构建产物体积、构建速度移动端与工具第30招 性能监控与回归防线长期维护优化成果我自己这几年的体会是性能优化最值钱的部分不是背下某一条优化规则而是养成“先诊断再动手、再量化验证”的习惯。每个项目开始之前先跑一遍面板、定两个指标、列一张长任务清单然后再从加载期、执行期、渲染期逐项排查。这样优化出来的效果稳定、可解释、不容易退回去。三十招像一包药方不用一次全开按症状选着用就好但一定要知道每一招在解决什么问题。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →