ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

前端性能优化30招:从加载、渲染到内存的全面提速指南 1. 先给性能优化立个规矩不量化就是瞎忙先说句大实话标题里的“提升10倍速度”确实能让人兴奋但如果你不知道页面现在慢在哪个环节后面二十九招全都是盲人摸象。我接手过一个老后台项目首屏白屏接近 5 秒所有人都在骂接口慢结果我用 Performance 面板一看光是全量引入的 moment 和 lodash 就占了 800 多 KB 的解析执行时间接口才花了 200ms。那一刻我才明白性能优化首先要做的不是“改代码”而是“量体温”。1.1 为什么“10倍”必须有基线没有基线的优化全是玄学。同一个页面在 M1 笔记本上跑和在公司老旧 Windows 台式机上跑体感完全不一样开了一堆浏览器扩展的 Chrome 和全新无痕窗口数据也完全不同。所以我接手任何项目的第一件事都是先定基线用无痕模式打开页面关掉所有扩展保证环境干净。用 Performance 面板录制一次完整的页面加载过程记录 FCP、LCP、TTI 以及长任务数量。把不少于 10 次的采样数据合并去掉最高和最低取中位数。这里的关键不是追求“和线上完全一致”而是给自己一个可复用的对照标准。没有这个对照你改完一个代码块根本说不清到底是优化起了作用还是今天网络心情好。1.2 三个工具组合比单个工具更有用我常用的组合是三件套Performance 面板、Lighthouse、以及代码里的 performance.mark / performance.measure。Performance 面板适合看主线程火焰图能直观看到哪个函数占用时间长哪些长任务阻塞了用户交互。Lighthouse 适合出报告它能给出 FCP、LCP、TBT 等指标还能列出“消除阻塞渲染的资源”这类具体建议。但 Lighthouse 有个问题它模拟的是中低端设备有时候得分太苛刻你只需要把它当参考不用追求满分。真正定位到业务瓶颈的还得靠自己在代码里埋点。比如某个接口返回数据后要做一层很重的字段映射你可以这样测performance.mark(transform-start); const list rawData.map(normalizeItem); performance.mark(transform-end); performance.measure(data-transform, transform-start, transform-end);然后在 Performance 面板的“Measured”里看具体耗时。这个方式能把“业务代码慢”和“框架渲染慢”区分开很多优化动作就是靠这种埋点才找到真正目标的。1.3 从性能面板找到真正瓶颈的顺序性能面板打开后先别急着看 JS 函数我习惯按这个顺序排查一是看网络请求瀑布图找最大的 JS/CSS 文件以及阻塞的请求。二是切到“主线程”视图看有没有超过 50ms 的长任务。三是看“渲染”相关的指标确认是不是频繁发生布局抖动。整体来说加载阶段的优化优先级是网络传输 解析执行 渲染 内存。因为一个文件少 100KB可能换来 100ms 的下载时间一个死循环少跑 1 次可能换来 1s 的交互恢复。后面的 30 招我也会按这个顺序展开。2. 加载阶段 10 招把首屏时间打骨折加载阶段是最容易出效果的地方因为很多成本发生在浏览器还没开始运行业务代码之前。这个阶段的目标很明确让浏览器更快拿到关键代码并且更少地阻塞解析。2.1 第1招gzip 或 Brotli 压缩必须开这一招属于“零代码改动”的优化但很多项目居然没做。服务器上给静态资源开启 gzip很多时候能把 JavaScript 文件压缩到原来的三分之一左右条件允许就上 Brotli压缩率比 gzip 还要再高 5% 到 15%。我试过把同一个 350KB 的 bundle 分别用 gzip 和 Brotli 压缩结果 gzip 后约 90KBBrotli 后约 70KB。这还只是单文件如果一个项目里有几十个 chunk累计传输体积差异非常明显。有一点很多人容易忽略API 接口的 JSON 也应该开压缩。如果接口响应体特别大开启压缩后移动端网络体验能提升一大截。别只盯着静态资源。2.2 第2招代码分割别一个 bundle 走天下老项目最典型的问题就是“一个 app.js 装下全世界”。路由级别的懒加载是最容易落地的分割方式const routes [ { path: /dashboard, component: () import(./views/Dashboard.vue) }, { path: /reports, component: () import(./views/Reports.vue) } ];这样首屏只加载当前路由的代码其他路由等进入时再拉。实测一个系统从单 bundle 切到路由懒加载后首屏 JS 体积从 1.2MB 降到了 300KB 左右加载时间立竿见影。但注意分割后要合理配置公共模块的抽取逻辑否则会出现多个路由 chunk 反复加载同一份公共代码的情况。webpack 的 splitChunks 会把 node_modules 里的核心依赖抽到一个 commons chunk 里这个配置值得花时间研究。2.3 第3招tree shaking 要确认真的生效很多项目都以为自己开启了 tree shaking实际上并没有。关键是你的源码必须用 ES Module 的静态 import/export 语法不能使用 CommonJS 的 require否则大部分打包工具没法静态分析出哪些代码没被用到。比如从 lodash 里引入函数// 这样整库可能被打包进去 import _ from lodash; _.chunk(list, 2); // 这样只打包 chunk 相关代码 import chunk from lodash/chunk; chunk(list, 2);检查方法很简单把打包后的产物丢到编辑器里搜一下某个你没用过的函数名是否出现在产物中。或者直接用 webpack-bundle-analyzer 看依赖占比。很多“莫名的大体积”其实就是 tree shaking 没生效。2.4 第4招大依赖换轻量替代品这是“降本增效”效果最猛的一招。我曾经把一个管理后台里的 moment.js 换成 day.js包的体积直接少了接近 300KB。为什么moment.js 为了兼容性内置了大量规则而大多数业务只用到了日期格式化、加减天数这几个功能day.js 几乎零成本替代。类似的还有直接用原生 Array.prototype.flat 代替 lodash flatten用 URLSearchParams 代替 qs用 Intl.DateTimeFormat 代替复杂的日期格式化库。每换掉一个都记得看打包体积和实际功能测试别让隐蔽的边界问题影响业务。2.5 第5招缓存策略别让浏览器每次都重新下载静态资源改完后你的服务器如果还在给同一个文件名返回新内容那前面所有体积优化都白费。正确做法是带指纹的文件名例如app.8f3k2.js设置Cache-Control: public, max-age31536000, immutable。不带指纹的index.html设置no-cache保证发布后能拿到新的资源列表。另外注意浏览器在“弱网”或“过期”情况下会发起协商缓存。你需要在服务端配置好 ETag 和 Last-Modified 的返回策略避免 200 响应变成常态。缓存不是越多越好而是“该持久就持久该失效就失效”。2.6 第6招defer 和 async 别用错script标签默认会阻塞 HTML 解析脚本下载和执行期间页面剩余部分得等着。如果你把非关键脚本直接放在head里首屏就会被白白拖延。普通脚本且需要等待 DOM 解析完成用defer。独立、不依赖其他脚本的统计类代码可以用async。其实在现代浏览器里script typemodule默认就有 defer 的行为。所以写原生 HTML 嵌入脚本时别偷懒把一堆script src...裸放进去。这个改动虽然小但在低端机上对 FCP 的影响很明显。2.7 第7招preload / preconnect 关键资源preload 可以提前加载首屏必需的字体、图片或脚本preconnect 可以提前建立第三方域的连接。我最常做的是link relpreload href/font/main.woff2 asfont typefont/woff2 crossorigin link relpreconnect hrefhttps://api.example.com这里要克制别把十几张首屏图片全部 preload否则会把网络带宽耗尽反而拖慢关键请求。preconnect 主要用在你确定会立即请求的跨域接口或 CDN 上比如日志上报域名。DNS 解析、TCP 连接、TLS 握手的时间叠加起来在移动端常有几百毫秒的损耗preconnect 能把这部分提前到后台完成。2.8 第8招图片压缩和现代格式切换很多页面的性能瓶颈不是 JavaScript而是巨大的 PNG/JPG。我见过一个列表页光图片总大小 8MB接口数据才 200KB。这种情况把图片换成 WebP/AVIF体积直接减少 60% 以上。浏览器兼容性用picture标签处理picture source srcsetbanner.avif typeimage/avif source srcsetbanner.webp typeimage/webp img srcbanner.jpg altbanner /picture另外纯色背景和简单图案优先用 CSS 渐变或者 SVG而不是图片。UI 素材里的装饰性图标能合成就合成雪碧图或者直接用字体图标。虽然现代 HTTP/2 对多个请求的容忍度高了但图片解码依然是新能成本能少则少。2.9 第9招消除阻塞渲染的 CSS 和 JSCSS 媒体查询不对也会阻塞渲染。比如一个只有打印时才需要的样式表如果不加mediaprint浏览器也会认为它需要加载完才能渲染。对于首屏把关键样式内联到 HTML 里非关键的 CSS 用异步方式加载。例如link relstylesheet hrefnon-critical.css mediaprint onloadthis.mediaall这个技巧能让首屏更快出内容但要注意别把超大段 CSS 全部内联否则 HTML 本身变得很臃肿。内联只针对首屏骨架样式其他仍然走外链缓存。2.10 第10招配合 IntersectionObserver 做图片和组件懒加载懒加载不仅可以用在图片上也可以用在整个组件上。当一个组件出现在视口附近时才去加载它的 JS 和渲染 DOM这个模式对长页面非常有效。const observer new IntersectionObserver((entries) { for (const entry of entries) { if (entry.isIntersecting) { const el entry.target; el.src el.dataset.src; observer.unobserve(el); } } }); observer.observe(imgElement);注意不要对首屏关键内容做懒加载那样会适得其反。判断标准是这个内容如果不出现在首屏用户会感知到吗不会的话才适合懒加载。3. 运行时 10 招让代码跑得更快首屏加载优化完了接下来要解决的是用户操作时的卡顿。运行时性能优化的核心不是“少写代码”而是“减少无效计算”。3.1 第11招循环里避免重复读取全局变量和重复计算很多新手写循环是这么写的const list getList(); for (let i 0; i list.length; i) { doSomething(list[i], window.config.xxx); }这个写法虽然没问题但每次循环都要走一遍list.length和window.config.xxx的属性查找。尽管 V8 引擎已经很聪明能把这些优化掉一部分但遇到复杂情况仍然会有作用域链查找的成本。我更建议这样const len list.length; const cfg window.config.xxx; for (let i 0; i len; i) { doSomething(list[i], cfg); }把长度和配置项缓存到局部变量里既符合直觉也能避免高维作用域查找。这个改动在性能敏感的大数组遍历里能直观观察到一丁点提升积累起来就是流畅度。3.2 第12招用 Set/Map 代替数组 includes/find 做高频查找这是非常经典的一个性能坑。数组中includes和find的时间复杂度是 O(n)如果你在一个循环里面对每个元素都执行一次includes查找整体就成了 O(n²)。数据量小的时候没感觉数据量到 10 万以上页面基本就卡死了。看这个例子const blacklist [bad, worse, terrible]; const result list.filter(item !blacklist.includes(item.category));如果list有 10 万条blacklist有 1 万条最坏要做 10 亿次比较。换成Setconst blacklistSet new Set([bad, worse, terrible]); const result list.filter(item !blacklistSet.has(item.category));Set.has是 O(1) 级别的操作整个流程瞬间降为 O(n)。我在一个关键词过滤功能里试过优化前耗时 2.3 秒优化后不到 30ms这就是“10倍”的典型来源之一。3.3 第13招避免嵌套循环用 Map 做一次遍历两个数组互相找出交集或差异的场景特别容易出现嵌套循环。正确的套路是先遍历其中一个数组建立一个 Map 或 Set再去遍历另一个数组做查询。const a getLargeList(); const b getAnotherList(); const bMap new Map(b.map(item [item.id, item])); const common a.filter(item bMap.has(item.id));这样最多只需要遍历两遍而不是两两比较。我第一次把这个模式用在订单列表与退款列表匹配上耗时从 8 秒降到 0.1 秒当时同事还以为是数据量变小了。3.4 第14招缓存重复计算结果memoization只要函数是纯函数同样的输入永远得到同样的输出就可以缓存结果。最经典的场景是复杂数据转换const 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; }; }; const expensiveTransform memoize((raw) { // 这里有一大段很重的字段处理逻辑 return raw.map(transformItem); });注意缓存的 key 如果包含对象需要谨慎使用 JSON.stringify因为对象序列化可能不稳定。另一个常见位置是 React 里的 useMemo 和 useCallback它们本质也是缓存机制但要记住缓存本身有开销只有计算成本明显高于缓存成本时才值得用。3.5 第15招选对遍历方式不同遍历方式虽然逻辑等价但引擎优化级别不同。性能敏感的热点代码建议用最基础的 for 循环配合索引。for...of在没有迭代器开销的数组上也很快而forEach每次循环都会回调函数会额外产生调用帧。比如// 性能最好 for (let i 0; i len; i) { process(arr[i]); } // 也可接受 for (const item of arr) { process(item); }但千万别用for...in遍历数组它会遍历所有可枚举属性包括原型链上的属性速度慢且行为容易出问题。这段经验适合那些数据量极大的算法场景普通几十个元素的列表你完全不用在意这种差距。3.6 第16招防抖和节流要分场景别混用防抖和节流是处理高频事件最常用的招但很多人用错。防抖一定时间内没有再触发事件才执行一次。适合输入框搜索、textarea 校验。节流一定时间内最多执行一次。适合滚动事件、窗口 resize、鼠标移动。简单实现节流function throttle(fn, delay 100) { let last 0; return (...args) { const now Date.now(); if (now - last delay) { last now; fn(...args); } }; } window.addEventListener(scroll, throttle(handleScroll, 100));如果你做的事情是触发布局或样式更新那么推荐用requestAnimationFrame节流这样能和浏览器渲染帧对齐避免不必要的中间态绘制。3.7 第17招长任务交给 Web Worker主线程一旦被一个大循环或复杂计算占用用户就无法点击按钮、滚动页面体验直接“假死”。遇到纯计算密集型任务比如大数组排序、数据加密、JSON 序列化/反序列化、数据清洗都应该考虑丢给 Web Worker。// worker.js self.onmessage (e) { const result e.data.map(heavyProcess); self.postMessage(result); }; // 主线程 const worker new Worker(./worker.js); worker.postMessage(rawData);这里有个性能细节postMessage传对象默认是结构化克隆对大对象拷贝成本很高。如果数据是 ArrayBuffer可以用 transferable 对象把所有权转移给 Worker这样没有拷贝开销。实际项目中我曾经把一个日志清洗场景从主线程阻塞 1.8 秒降到几乎无感。3.8 第18招大数据处理用时间切片分批执行有些计算不方便放到 Worker 里而是必须依赖主线程上的 DOM 或框架状态。这时就用“时间切片”思路把一个大任务切成多个小段每段执行完让出主线程。const chunkSize 1000; let index 0; function processChunk() { const end Math.min(index chunkSize, list.length); for (; index end; index) { process(list[index]); } if (index list.length) { requestIdleCallback(processChunk); } }通过requestIdleCallback把剩余任务分配到浏览器空闲时执行用户在看页面时不会感到卡顿。当然兼容性上有一定问题可以回退到setTimeout(fn, 0)。3.9 第19招延迟初始化非关键对象如果一个类实例很重但用户可能一直不会用到就别在页面初始化时 new 出来。比如图表组件、代码编辑器、权限配置解析器都应该做懒加载。let editorInstance null; function getEditor() { if (!editorInstance) { editorInstance new HeavyEditor(element); } return editorInstance; }这样做的好处是减少首屏的主线程计算和内存占用。代价是首次调用会有一点延迟但通常用户第一次点开编辑器时能接受加载。配合 loading 状态体验会比页面一进来就卡顿好得多。3.10 第20招避免高频循环里创建函数和闭包闭包虽然好用但它在每次创建时都会捕获当前作用域。如果在循环里创建函数GC 会面临大量的垃圾回收压力。// 不好每次循环都生成一个箭头函数 for (const item of items) { btn.addEventListener(click, () handle(item)); } // 相对好定义在循环外通过 data 属性传参 function handle(e) { const item e.target.dataset.item; process(item); }对于动画循环、拖拽、实时计算这类高频场景函数复用尤其重要。这个优化很难在指标上直观看到但长期运行时内存波动会明显变缓GC 停顿变少页面就更跟手。4. DOM 与渲染 5 招别让浏览器做无用功JavaScript 性能再好如果不停地操作界面浏览器照样会卡。因为 DOM 操作背后的 layout、paint、composite 都是成本。4.1 第21招批量插入节点用 DocumentFragment 或字符串拼接很多人写动态列表list.forEach(item { const li document.createElement(li); li.textContent item.name; ul.appendChild(li); });这样每插入一个节点都会触发一次布局更新。优化后const fragment document.createDocumentFragment(); list.forEach(item { const li document.createElement(li); li.textContent item.name; fragment.appendChild(li); }); ul.appendChild(fragment);DocumentFragment 不会占用真实 DOM 树的位置把所有节点先挂到它里面最后一次性插入父节点这样只触发一次重排。我试过一次插入 3000 个节点前者耗时 80ms后者只有 15ms 左右。4.2 第22招动画用 requestAnimationFrame 驱动setTimeout到 16ms 不代表会在那一帧渲染真正绘制要看浏览器的刷新率。用requestAnimationFrame可以保证动画逻辑在浏览器下一次重绘之前执行避免出现掉帧和中间状态的闪烁。let top 0; function step() { top 2; el.style.transform translateY(${top}px); requestAnimationFrame(step); } requestAnimationFrame(step);同时把动画属性改成transform而不是top这背后涉及浏览器渲染管线的知识。改top会触发 layout而transform只触发合成合成通常在 GPU 上进行代价低很多。这是页面动画流畅的最关键一招。4.3 第23招一次性渲染上千条列表用虚拟列表不可避免地要渲染大量数据时千万不要把所有节点都挂到 DOM。虚拟列表的思路是只渲染视口内看得见的那些节点。实现上就是固定一个外层容器高度监听滚动位置计算应该渲染的起始索引和结束索引只给这几十个节点创建真实 DOM。其余数据通过 padding 的方式模拟高度撑起滚动条。自己实现并不复杂但要处理滚动偏移、行高变化、异步数据等边界情况。生产项目里我更推荐直接沿用成熟库方案比如基于tanstack/react-virtual或vue-virtual-scroller这样的库能省大量时间。4.4 第24招使用合成属性避免频繁触发布局除了动画之外高频事件处理器里也要特别注意。比如拖拽时实时修改元素left和width可能会导致每一帧都整页重排。改用transform: translate3d(...)和scale()之后元素进入合成层布局不会受影响。但别把所有元素都塞进合成层因为一个页面的合成层数量太多会占用 GPU 内存反而变慢。这个度要自己观察 Performance 面板看是 layout 时间高还是 paint 时间高再决定应该优化哪个方向。4.5 第25招缓存 DOM 查询结果别反复 querySelector在事件监听器里频繁document.querySelector(.item)看起来没什么但要是个复杂选择器每次都会进行递归查找。把常用节点缓存到变量里const navLists document.querySelectorAll(.nav-item); function updateNav() { // 直接使用缓存的 navLists }尤其是那些在每次 resize、scroll 里都会执行的逻辑查询结果缓存后能明显减少调用成本。注意如果 DOM 结构发生变化缓存可能失效所以可以在插入节点后手动重新查询一次。5. 内存与长期运行 5 招不泄漏才能持久快前面解决的是“跑得快”内存这块解决的是“长期跑不慢”。很多 SPA 应用刚打开挺流畅用过十分钟后越来越卡就是因为内存泄漏堆积。5.1 第26招事件监听器用完要解绑这是最常见的泄漏来源。你给一个 DOM 元素绑定了事件随后把元素移除但绑定并没有解除于是这个元素连同事件闭包里的变量都被保留在内存里无法被 GC 回收。React 和 Vue 在组件卸载时通常会帮你清理但如果你手动 addEventListener 到 window 或 document框架是不管的。function handleResize() { ... } window.addEventListener(resize, handleResize); // 组件销毁时必须有 window.removeEventListener(resize, handleResize);我在一个旧项目里排查卡顿发现就是每个页面都往 window 上绑了 resize 事件切路由后旧监听依然存在十个页面就产生了上百个无效监听。解绑之后内存稳定下降。5.2 第27招定时器和观察器及时清理setInterval 如果没被 clear会一直执行回调即使页面已经销毁。同样IntersectionObserver、MutationObserver 这些观察器如果没 disconnect也会持有目标元素的引用。一个稳妥的习惯是在 Vue 的 beforeUnmount 或 React useEffect 的 cleanup 函数里统一清理所有“异步资源”。useEffect(() { const timer setInterval(tick, 1000); const observer new IntersectionObserver(callback); observer.observe(domRef.current); return () { clearInterval(timer); observer.disconnect(); }; }, []);只要你维护一份“需要清理的资源清单”并在销毁时逐个关闭内存泄漏的问题能规避掉 80%。5.3 第28招用对象池避免反复创建销毁大对象游戏引擎里有一个老概念叫对象池你不直接丢弃对象而是把用过的对象放回池子里下次需要时再取出来。JavaScript 里的垃圾回收机制会自动处理但如果创建和销毁特别频繁GC 的 STW 停顿就会影响到渲染。前端业务里比较典型的是 Canvas 粒子动画、拖拽辅助层、复杂数据结构包装器。比如const pool []; function getParticle() { return pool.pop() || createNewParticle(); } function recycleParticle(p) { pool.push(p); }这种模式会让内存占用稳定在一个水位线附近而不是忽高忽低。优化监控面板里GC 次数和耗时会明显下降。5.4 第29招防止全局变量和缓存容器无限增长很多人喜欢把数据丢到全局变量里比如window.cachedList [];这个数组如果只增不减内存就会持续上涨。即使不是一个全局变量在组件里声明的“缓存 Map”也可能因为 key 不清理而无限变大。建议给任何缓存容器设定上限或者定期清理。比如if (cache.size 10000) { cache.clear(); }排查内存泄漏时用 Chrome 的 Memory 面板做两次 heap snapshot分别在页面稳定后和交互若干次后比较 Retained Size 变化。如果某个自定义对象数量持续上涨基本就找到了泄漏点。5.5 第30招建立性能监控防止优化效果回退优化不是一次性工作。今天把包体积从 1MB 降到 300KB下个月某位同学加了一个重型依赖马上又回到 800KB。所以一定要把关键指标监控起来。简单方式是使用 PerformanceObserver 收集运行时指标const observer new PerformanceObserver((list) { for (const entry of list.getEntries()) { reportMetric(entry.name, entry.value); } }); observer.observe({ entryTypes: [largest-contentful-paint, first-input] });构建层面也可以加入 CI 检查如果某次构建的产物体积超过阈值就提醒团队确认原因。我见过最惨的情况是优化完半个月后线上性能跌回原点而没人发现直到用户投诉。监控的意义就是给性能优化一个长期生命线。6. 最后说点实战结果和我的体会回到开头那个后台系统。我把整个优化过程走下来实际用到的招数没有 30 个那么多真正起决定作用的只有 5 个上 Brotli 压缩、路由懒加载、换掉 moment.js、用 Set 替代数组查找、批量插入 DOM。首屏从 5 秒降到 0.5 秒交互卡顿几乎消失。这种结果说明一个道理“提升10倍”不是平均用力而是找到最大的那个瓶颈用最合适的一招把它击穿。多数项目的性能问题都集中在少数几个点上你花 80% 精力去优化那 20% 的热点效果远比均匀修炼好。我个人还有一个小习惯每次优化完都会把优化前后的基线数据截图存下来。不是为了写报告而是为了下一次有人质疑“你改这段代码有什么用”的时候直接甩出对比数据。这种实打实的依据比任何说辞都有说服力。最后再分享一个建议别把 30 招背下来就结束而是在日常开发里按“加载 运行 渲染 内存”的顺序去形成检查清单。遇到一个新页面卡顿先用 Performance 面板定位再对照清单挑几招去实测。时间久了这些招数就会变成肌肉记忆。到那时候你看一个页面的加载瀑布图基本就能猜出它的问题在哪个环节了。
返回列表