上周我负责的电商项目上线前,测试同学甩来一个录屏:商品列表滚动时,页面像打了马赛克一样卡顿,鼠标滚轮滚一下,列表能卡半秒 —— 我盯着录屏里跳动的帧率数字(最高 25fps,最低甚至跌到 10fps),瞬间想起三年前第一次做类似功能时,因为没做性能优化,上线后被用户吐槽 “用着像老年机” 的尴尬。
JavaScript 性能优化从来不是 “炫技”,而是 “救场” 的刚需 —— 用户不会等你解释 “我这代码逻辑很优雅”,他们只会在页面卡顿的 3 秒内关掉网页。这篇文章不聊虚的 “V8 引擎底层原理”,只讲我在 10 + 项目里验证过的实战优化技巧:从怎么精准定位性能问题,到具体场景的代码改造,再到上线后的性能监控,每一步都带真实案例和数据,保证你看完就能用到自己的项目里。
很多人优化性能的第一步就错了:凭感觉改代码 —— 觉得 “for 循环比 forEach 快” 就全换成 for,觉得 “DOM 操作多” 就随便加缓存,最后性能没提升多少,代码还变得难维护。
我现在的习惯是:先定位瓶颈,再动手优化。就像医生看病得先做检查,不能上来就开药。以下两个工具,是我每次排查性能问题的 “救命稻草”。
Chrome 的 Performance 面板,能完整记录页面运行时的 “每一秒”,我用它排查过 90% 的前端性能问题,比如开头说的商品列表卡顿。
renderGoodsList()函数,里面有 100 多次 DOM 操作。
第一次用 Performance 时,我以为 “只要有 Long Task 就该优化”,花了半天把一个持续 60ms 的函数改到 30ms,结果页面还是卡顿 —— 后来才发现,真正的问题是这个函数 “每 100ms 就执行一次”,而不是单次耗时太长。所以看报告时,频率比单次耗时更重要。
如果只是想排查某段代码的耗时(比如一个循环、一个函数),不用开复杂的 Performance 面板,用
console.time()就能快速定位,我写业务代码时经常用。
javascript
运行
// 要排查的函数:格式化1000个商品价格(加千分位、保留两位小数)
function formatGoodsPrice(prices) {
return prices.map(price => {
// 复杂的格式化逻辑,比如处理负数、特殊价格(如“99.9”)
return Number(price).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
})
}
// 用console.time()定位耗时
console.time('formatPrice')
// 模拟1000个商品价格
const testPrices = Array(1000).fill('12345.67')
formatGoodsPrice(testPrices)
console.timeEnd('formatPrice') // 输出:formatPrice: 8.2ms
如果输出结果超过 50ms,就说明这段函数有优化空间;如果只有几毫秒,就不用浪费时间改了 ——优化的投入要放在 “耗时超过阈值” 的代码上,不然就是捡了芝麻丢了西瓜。
定位到问题后,就该动手优化了。以下 5 个场景是我在项目里遇到最多的,每个场景都有 “问题代码→优化代码→优化原理”,改完性能提升都能肉眼看到。
DOM 操作是 JavaScript 性能的 “重灾区”—— 每一次增删改 DOM,浏览器都要做 “重排”(计算元素位置)和 “重绘”(渲染像素),这两步非常耗时。我之前做的商品列表,就是因为每次渲染都要
appendChild100 次,导致滚动卡顿。
javascript
运行
// 渲染100个商品项:每次循环都append,触发100次重排
function renderGoodsList(goods) {
const list = document.getElementById('goods-list')
goods.forEach(good => {
const item = document.createElement('div')
item.className = 'goods-item'
item.innerHTML = `
<img src="${good.img}" alt="${good.name}">
<p>${good.name}</p>
<span>${good.price}</span>
`
list.appendChild(item) // 每次循环都操作DOM,触发重排
})
}
javascript
运行
function renderGoodsList(goods) {
const list = document.getElementById('goods-list')
// 1. 创建文档片段(DocumentFragment),它是“虚拟DOM容器”,不会触发重排
const fragment = document.createDocumentFragment()
goods.forEach(good => {
const item = document.createElement('div')
item.className = 'goods-item'
item.innerHTML = `
<img src="${good.img}" alt="${good.name}">
<p>${good.name}</p>
<span>${good.price}</span>
`
fragment.appendChild(item) // 先加到片段里,不触发重排
})
// 2. 最后一次性把片段加到真实DOM,只触发1次重排
list.appendChild(fragment)
}
之前循环 100 个商品,重排次数从 100 次降到 1 次,滚动帧率从 25fps 提升到 52fps——DOM 操作的核心原则是 “批量处理”,能一次做完的别分多次。
如果要修改大量 DOM(比如给列表所有项加 “已售罄” 标签),可以先把列表设为
display: none(触发 1 次重排),改完后再恢复显示(再触发 1 次重排),比边显示边改少很多次重排:
javascript
运行
const list = document.getElementById('goods-list')
// 1. 隐藏DOM
list.style.display = 'none'
// 2. 批量修改DOM(比如加标签)
modifyAllGoodsItems(list)
// 3. 恢复显示
list.style.display = 'block'
循环本身很快,但循环里的 “重复计算” 会让耗时翻倍 —— 我之前做订单统计时,一个循环里重复获取数组长度,导致处理 10 万条数据时耗时从 80ms 涨到了 200ms。
javascript
运行
// 计算10万条订单的总金额
function calculateTotalAmount(orders) {
let total = 0
// 问题:每次循环都要获取orders.length(数组属性查找,比变量慢)
for (let i = 0; i < orders.length; i++) {
total += Number(orders[i].amount)
}
return total
}
javascript
运行
function calculateTotalAmount(orders) {
let total = 0
// 1. 缓存数组长度:只获取1次,避免重复查找
const len = orders.length
// 2. 用let代替var,减少作用域链查找(现代浏览器优化,但好习惯)
// 3. 用++i代替i++(理论上快一点,主要是规范)
for (let i = 0; i < len; ++i) {
// 4. 提前把amount转成数字(如果订单数据里已经是数字,可省略)
const amount = Number(orders[i].amount)
total += amount
}
return total
}
很多人说 “forEach 比 for 慢,map 比 forEach 慢”,但实际测试发现:处理 10 万条数据时,for 循环耗时 80ms,forEach 耗时 95ms,map 耗时 110ms—— 差距不大。但如果是处理 100 万条数据,for 循环的优势才会明显。
我的建议是:业务代码里,优先用可读性高的 forEach/map,只有数据量超过 10 万条时,再考虑换成 for 循环—— 代码可维护性比那几十毫秒的耗时更重要。
如果页面有很多相同的元素需要绑定事件(比如商品列表的 “加入购物车” 按钮),给每个按钮都绑一个点击事件,会导致内存占用增加,还会影响页面初始化速度。
javascript
运行
// 渲染100个商品,每个“加入购物车”按钮都绑事件
function renderGoodsList(goods) {
const list = document.getElementById('goods-list')
goods.forEach(good => {
const item = document.createElement('div')
item.className = 'goods-item'
item.innerHTML = `
<p>${good.name}</p>
<button class="add-cart" data-id="${good.id}">加入购物车</button>
`
list.appendChild(item)
// 问题:100个按钮绑100个事件处理函数
item.querySelector('.add-cart').addEventListener('click', () => {
addToCart(good.id)
})
})
}
javascript
运行
function renderGoodsList(goods) {
const list = document.getElementById('goods-list')
// 1. 只给父元素绑1个事件处理函数
list.addEventListener('click', (e) => {
// 2. 判断点击的是“加入购物车”按钮(通过class或data属性)
if (e.target.classList.contains('add-cart')) {
// 3. 从data-id获取商品ID
const goodId = e.target.dataset.id
addToCart(goodId)
}
})
// 3. 渲染商品列表(不用再绑事件)
const fragment = document.createDocumentFragment()
goods.forEach(good => {
const item = document.createElement('div')
item.className = 'goods-item'
item.innerHTML = `
<p>${good.name}</p>
<button class="add-cart" data-id="${good.id}">加入购物车</button>
`
fragment.appendChild(item)
})
list.appendChild(fragment)
}
事件处理函数从 100 个减少到 1 个,页面初始化时间从 300ms 降到 180ms——事件委托的核心是 “利用事件冒泡”,父元素代子元素处理事件,尤其适合动态生成的元素(比如分页加载的列表)。
内存泄漏是 “隐形杀手”—— 页面看着不卡,但随着时间推移,内存占用越来越高,最后浏览器卡顿甚至崩溃。我之前做的后台管理系统,就因为没清理定时器,用户用 2 小时后,内存从 200MB 涨到了 800MB。
| 泄漏场景 | 问题代码示例 | 优化方案 |
|---|---|---|
| 未清理的定时器 |
setInterval(() => { updateData() }, 1000) | 组件卸载 / 页面关闭时用
clearInterval清理 |
| 未解绑的事件监听 |
window.addEventListener('scroll', handleScroll) | 不用时用
removeEventListener解绑 |
| 未清理的 DOM 引用 |
const el = document.getElementById('box'); el.parentNode.removeChild(el);(el 还被引用) | 手动把 el 设为
null:
el = null |
| 闭包引用的无用数据 | 函数内引用了大数组,函数执行完后数组还被闭包持有 | 函数执行完后,手动把大数组设为
null |
javascript
运行
// 后台页面:每隔1秒请求数据更新表格
let timer = null
function initDataUpdate() {
// 启动定时器
timer = setInterval(() => {
fetch('/api/table-data').then(res => res.json()).then(data => {
updateTable(data)
})
}, 1000)
}
// 页面关闭/组件卸载时,清理定时器(关键!)
function destroyPage() {
if (timer) {
clearInterval(timer)
timer = null // 手动置空,帮助GC回收
}
}
// 监听页面关闭事件
window.addEventListener('beforeunload', destroyPage)
用 Chrome 的「Memory」面板:
打开页面,操作 10 分钟(比如滚动、切换 tab);点击「Take snapshot」获取内存快照;对比多次快照,如果某类对象(比如 DOM 节点、数组)数量一直在增加,说明有泄漏。如果一个函数需要频繁执行,且输入相同的时候输出也相同(比如价格格式化、列表筛选),可以把计算结果缓存起来,下次直接用 —— 我之前做的商品筛选功能,用了缓存后,筛选速度从 200ms 降到了 20ms。
javascript
运行
// 问题:每次筛选都要重新计算,即使筛选条件相同
function filterGoods(goods, keyword) {
return goods.filter(good => {
return good.name.includes(keyword) || good.category.includes(keyword)
})
}
// 优化:用Map缓存筛选结果
const filterCache = new Map()
function filterGoodsWithCache(goods, keyword) {
// 1. 生成缓存key(用筛选条件作为key)
const cacheKey = keyword
// 2. 如果缓存里有,直接返回
if (filterCache.has(cacheKey)) {
return filterCache.get(cacheKey)
}
// 3. 没有缓存,计算后存入缓存
const result = goods.filter(good => {
return good.name.includes(keyword) || good.category.includes(keyword)
})
filterCache.set(cacheKey, result)
return result
}
// 额外优化:缓存满了清理(避免内存占用过高)
if (filterCache.size > 50) {
// 删除最早的缓存(Map按插入顺序存储,用keys().next()获取第一个key)
const oldestKey = filterCache.keys().next().value
filterCache.delete(oldestKey)
}
如果数据经常变化(比如实时更新的商品库存),缓存会导致 “数据不一致”——只有 “输入输出稳定” 的计算,才适合用缓存,比如筛选、格式化,而不是实时数据请求。
优化完不能拍脑袋说 “快了”,得有数据支撑 —— 我每次优化后,都会用以下两个方法 “验收”,确保优化真的有效。
把优化前后的关键指标记下来,形成对比表格,比如:
| 指标 | 优化前 | 优化后 | 提升幅度 |
|---|---|---|---|
| 列表滚动帧率 | 25fps | 52fps | +108% |
| 100 条商品渲染时间 | 300ms | 180ms | -40% |
| 页面内存占用(2 小时后) | 800MB | 220MB | -72.5% |
技术指标再好,不如用户说 “不卡了”—— 我会找测试同学、产品同学一起体验,重点关注:
滚动列表时,有没有 “掉帧” 的感觉;点击按钮后,有没有 “延迟响应”;页面打开 30 分钟后,有没有越来越卡。如果有用户反馈 “还是卡”,就要重新用 Performance 定位问题 —— 可能是优化得不彻底,也可能是有新的性能瓶颈。
做了这么多优化,我总结出 3 个 “不踩坑” 的原则,比任何技巧都重要:
先定位,再优化:别凭感觉改代码,用 DevTools 找到真正的瓶颈,不然就是 “瞎忙活”;不要过早优化:如果代码跑起来很流畅,就算理论上能优化,也不用动 —— 过早优化会让代码变复杂,性价比太低;用户体验优先:性能优化的最终目标是 “用户觉得快”,而不是 “指标好看”—— 比如一个函数从 50ms 优化到 30ms,用户可能没感觉,但从 300ms 优化到 50ms,用户会明显觉得 “不卡了”。如果觉得这篇实战技巧有用,建议收藏一下,下次遇到性能问题时,直接对着步骤排查、优化,比到处搜教程更高效~