同样的功能,为什么别人的网页纵享丝滑,你的却像在嚼炫迈?
今天聊聊,怎么让网页从"卡"变成"顺"。
原文地址
网页为什么会卡?
一条工厂流水线
浏览器渲染网页,就像一条工厂流水线:
yaml
原材料(HTML/CSS)
↓
加工(DOM + CSSOM = 渲染树)
↓
组装(布局/排版)
↓
上色(绘制)
↓
打包出厂(合成)
任何一个环节变慢,最终产品出来就慢。
四个拖慢流水线效率的环节
| 问题 | 原因 | 结果 |
|---|---|---|
| 重排(Reflow) | 改了个宽高 | 整个流水线重新跑 |
| 重绘(Repaint) | 改了个颜色 | 只需要重新上色 |
| 长任务(Long Task) | JS 执行太久 | 主线程被卡住 |
| 内存泄漏 | 废料没清理 | 车间越来越挤 |
主线程是什么?
流水线再快,也要工人来操作。主线程就是这个工人。
JS 代码、DOM 操作、样式计算、布局绘制------都要这个工人来干。
如果工人在干一件大事(长 JS 任务),其他人就只能排队等着。
重排重绘:流水线上的返工
什么是重排和重绘?
流水线上的产品,刚上完色又要改尺寸,刚改完尺寸又要换颜色------这就是返工。
重排(Reflow) = 刚组装完又要拆开重装,整个车间重新排
重绘(Repaint) = 只换了个颜色喷漆,不用动组装线
yaml
# 重排:刚组装完,发现尺寸错了,整个车间要重来
div.style.width = '200px'
# 重绘:只是换个颜色,喷漆工换个颜料就行
div.style.color = 'red'
哪些操作会触发重排?
不是所有操作都会触发重排。下面这些会:
yaml
# 元素尺寸相关
offsetWidth、offsetHeight、offsetTop、getComputedStyle()
→ 读取布局信息,浏览器需要返回准确值,强制触发重排
# 增删改元素
appendChild、removeChild、display: none
→ 改变 DOM 结构
# 改尺寸位置
width、height、margin、padding、left、top
→ 直接影响布局
# 浏览器内部优化:批量处理
div.style.width = '100px'
div.style.height = '200px'
→ 连续改两次尺寸,浏览器会合并成一次重排
强制同步布局:流水线的"加急单"
正常情况下,工人按顺序干活。但如果你突然插队问"现在产品到哪了",工人就得停下手里的活,去查状态。
这就是强制同步布局(Layout Thrashing):
js
// ❌ 先写后读,触发强制同步布局
div.style.width = '100px' // 工人干活中
console.log(div.offsetHeight) // 突然问尺寸,工人停下手去量
div.style.height = '200px' // 又写,工人又要重排
读 → 写 → 读 = 两次重排,工人都没法好好干活。
js
// ✅ 先读后写,读完再批量写
const height = div.offsetHeight // 先一口气读完所有尺寸
div.style.width = '100px' // 再一口气写完
div.style.height = height + 'px'
怎么减少返工?
用 transform 代替位置变化
yaml
# ❌ 改 left/top = 刚摆好位置又搬走,整个车间重排
element.style.left = '100px'
element.style.top = '100px'
# ✅ 改 transform = 用传送带调位置,GPU直接处理,不触发重排
element.style.transform = 'translate(100px, 100px)'
transform 和 opacity 为什么不触发重排?因为它们只需要 GPU 修改,不需要 CPU 重新算布局。
批量操作 DOM
yaml
# ❌ 每次 append = 传送带停一次,车间重新算一次
div.appendChild(p1)
div.appendChild(p2)
div.appendChild(p3)
# ✅ 一次操作完再显示 = 先停传送带,统一放上去,再开
container.style.display = 'none'
container.appendChild(p1)
container.appendChild(p2)
container.appendChild(p3)
container.style.display = 'block'
用 DocumentFragment
js
// 虚拟操作,最后一次性插入,触发一次重排
const fragment = document.createDocumentFragment()
fragment.appendChild(p1)
fragment.appendChild(p2)
fragment.appendChild(p3)
div.appendChild(fragment)
合成层:重要产品的专用通道
什么是合成层?
有些重要产品,整条流水线走下来太慢了。工厂会开辟一条专用通道,跳过前面几步,直接到最后的打包工序。
浏览器也是这样。合成层就是某些元素的专用通道,跳过布局计算,直接合成到屏幕。
yaml
普通元素:DOM 修改 → 样式计算 → 布局 → 绘制 → 合成(走完整条流水线)
合成层元素:样式计算 → 绘制 → 合成(跳过布局,GPU 直接处理)
合成层为什么快?
- 不占用主线程:Layout 和 Paint 在主线程,Composite 在 GPU
- 不影响其他元素:普通元素重排可能影响整个车间,合成层互相独立
- GPU 加速:transform 和 opacity 直接由 GPU 处理,CPU 解放出来
哪些情况会创建合成层?
yaml
1. transform: translate/rotate/scale(移动、旋转、缩放)
2. opacity 有变化(透明度调整)
3. will-change 提前声明了
4. position: fixed(视口固定元素)
5. video、canvas、iframe 等元素
用 will-change 申请专用通道
css
/* 提前跟工厂说:这个产品要走专用通道,提前准备 */
.box {
will-change: transform;
}
合成层的副作用
专用通道虽好,但也有代价:
yaml
内存占用:每个合成层都占用显存,100 个动画元素 = 100 个合成层 = 显存爆炸
层数过多:Chrome 需要管理所有图层,过多会影响性能
所以:
- 不要给所有元素都加 will-change
- 动画结束了要把 will-change 移除
- 尽量让元素"在一起"的动画共享一个合成层
css
/* ✅ 共享合成层:子元素和父元素一起动 */
.parent {
will-change: transform;
}
.parent:hover .child {
transform: scale(1.1);
}
长任务:别让工人一直干重活
什么是长任务?
流水线上的某个工人,一直干重活不休息,其他工人都得等他手里的活干完才能继续。
JS 执行一次超过 50ms,主线程就被占着,其他任务都要排队等着。
50ms 怎么来的?Google 认为 100ms 内响应用户操作,人感觉是"即时"的。所以任务如果超过 100ms 的一半(50ms),就会被人感知到卡顿。
长任务从哪来?
js
// 1. 大量数据计算
const result = heavyCalculate(data) // 数据处理
// 2. 复杂 DOM 操作
document.body.innerHTML = '' // 清空页面
for (let i = 0; i < 10000; i++) {
document.body.appendChild(createDiv())
}
// 3. 递归调用过深
function deepClone(obj) {
return JSON.parse(JSON.stringify(obj)) // 深拷贝大对象
}
怎么拆?
1. requestIdleCallback:让工人轮着休息
js
// 把大任务拆成小块,工人有空就干一点
function processItems(items, callback) {
let index = 0 // 当前处理到第几个
function work(deadline) {
// deadline.timeRemaining() 表示工人还有多少空闲时间
// 循环处理,每次只干一件,直到空闲时间用完或全部处理完
while (index < items.length && deadline.timeRemaining() > 0) {
process(items[index])
index++
}
// 还有剩下的活没干完?排到下次空闲再继续
if (index < items.length) {
requestIdleCallback(work) // 还有活,队列里排着
} else {
callback() // 全部干完了,通知调用方
}
}
// 第一次执行,排队等待工人空闲
requestIdleCallback(work)
}
2. Web Worker:另开一条生产线
yaml
主线程生产线:只管接待客人(UI 交互)
↓
Worker 生产线:干重活(计算、数据处理)
↓
主线程:接收结果,继续组装
js
// main.js
const worker = new Worker('processor.js')
worker.postMessage({ type: 'process', data: bigData })
worker.onmessage = (e) => {
const result = e.data
updateUI(result)
}
// processor.js
self.onmessage = (e) => {
const result = heavyCalculation(e.data.data)
self.postMessage(result)
}
3. Scheduler API:更精细的任务调度
js
// 把任务分解成更小的 chunk,yield 让出主线程
async function processData() {
for (const chunk of chunks) {
process(chunk)
await scheduler.yield() // 让出主线程,下一帧继续
}
}
4. 配合动画帧:requestAnimationFrame
js
// 配合流水线节奏,每帧干一点,不要一直占着
function animate() {
updateSomeUI()
requestAnimationFrame(animate)
}
内存泄漏:废料没及时清理
什么是内存泄漏?
工厂每天产生废料。该清理的没清理,越积越多,最后车间都堆满了。
网页也是这样。该回收的变量没回收,越积越多,网页越来越慢。
内存泄漏的原理
JavaScript 使用**垃圾回收器(GC)**自动清理内存。GC 通过判断对象"是否还被引用"来决定要不要回收。
yaml
可达对象:还有变量引用着 → 不回收
不可达对象:没有任何引用 → 下次 GC 回收
常见的废料堆
1. 闭包引用
js
function createClosure() {
let bigData = new Array(1000000) // 大数组
return function() {
// bigData 被闭包引用,废料清理不掉
console.log(bigData.length)
}
}
const fn = createClosure()
fn() // bigData 永远不会被回收
2. 全局变量
js
// 不用 let/const,变量会挂在 window 上,废料堆在工厂门口
function() {
cache = new Array(1000000) // 挂在 window 上
window.bigData = new Array(1000000) // 同上
}
3. 事件监听没移除
js
window.addEventListener('resize', handler)
// 组件销毁时没 removeEventListener → 废料一直堆着
4. 定时器没清除
js
setInterval(() => {
// 定时器里的变量,永远不会被回收
process(data)
}, 1000)
// 不用了要 clearInterval
5. 闭包里的定时器
js
function init() {
const largeData = new Array(1000000)
setTimeout(() => {
// 这个回调引用了 largeData,即使 init 执行完了
// setTimeout 没清除,largeData 就不会被回收
console.log(largeData.length)
}, 10000)
}
6. console.log 的"陷阱"
js
// 生产环境中,console.log 可能导致内存泄漏
// 浏览器需要保留 console 引用的对象,直到控制台被清空
const bigData = new Array(1000000)
console.log(bigData) // 如果控制台一直开着,bigData 不会被回收
怎么找废料在哪?
Chrome DevTools → Memory 面板:
yaml
堆快照:拍一张某个时刻的内存照片,对比两个时刻的差异
记录时间线:看内存是怎么涨的
分配堆栈:看是谁在分配内存没回收
查找内存泄漏的步骤:
yaml
1. 打开 Memory 面板
2. 选"堆快照"
3. 操作页面(比如打开关闭弹窗)
4. 再拍一张快照
5. 对比两次快照,找内存增长点
Performance API:给车间装计数器
是什么?
每个车间门口都可以装个计数器,统计每个环节花了多少时间。
浏览器有内置的性能监控工具:
js
performance.now() // 精确到微秒,比 Date.now() 更准
performance.mark() // 记录某个时间点
performance.measure() // 测量两个时间点之间花了多久
怎么用?
js
// 标记开始时间
performance.mark('start')
// 你的代码
doSomething()
// 标记结束时间
performance.mark('end')
// 测量耗时
performance.measure('doSomething耗时', 'start', 'end')
// 获取测量结果
const measures = performance.getEntriesByType('measure')
console.log(measures[0].duration)
PerformanceObserver:自动观察性能
js
// 自动观察长任务
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
console.log('发现长任务:', entry.name, entry.duration, 'ms')
}
}
})
observer.observe({ entryTypes: ['longtask'] })
火焰图:一眼看出哪慢
Chrome DevTools → Performance 面板 → 录制操作 → 生成火焰图:
yaml
火焰图怎么看:
- 横轴是时间,越宽说明越慢
- 纵轴是调用栈,越高说明调用层次越深
- 顶部是耗时最多的函数,要重点优化
Core Web Vitals
客户验收产品要看三个指标:
| 指标 | 含义 | 及格线 | 优化方式 |
|---|---|---|---|
| LCP | 最大那块料多久出来 | < 2.5s | 优化服务器、CDN、缓存 |
| FID | 第一次有人来响应 | < 100ms | 减少长任务、分解大 JS |
| CLS | 产品摆放位置晃不晃 | < 0.1 | 预留图片尺寸、固定动态元素 |
实战:列表页滚动优化
场景
列表页加载 1000 条数据,滚动特别卡。
分析
yaml
问题:1000 个产品各装了一个报警器,响了没人来处理
诊断:Performance 面板看到工人一直在响应报警,没空干活
第一步:事件委托
不用每个产品都装报警器,在车间门口装一个就够了。
js
// ❌ 1000 个产品各装一个监听
items.forEach(item => {
item.addEventListener('click', handleClick)
})
// ✅ 事件委托,只装一个
list.addEventListener('click', (e) => {
const item = e.target.closest('.item')
if (item) {
handleClick(item)
}
})
第二步:虚拟滚动
1000 个产品其实只有 20 个在展示台上。滚动时动态换展示台上的产品。
js
class VirtualList {
constructor(container, items) {
this.container = container
this.items = items
this.itemHeight = 50
this.visibleCount = Math.ceil(container.clientHeight / this.itemHeight)
container.addEventListener('scroll', () => this.onScroll())
this.render()
}
onScroll() {
const scrollTop = this.container.scrollTop
const startIndex = Math.floor(scrollTop / this.itemHeight)
this.render(startIndex)
}
render(startIndex = 0) {
const visibleItems = this.items.slice(
startIndex,
startIndex + this.visibleCount
)
// 只渲染可见的 20 个,而不是 1000 个
this.container.innerHTML = visibleItems.map(item => `
<div class="item" style="height: ${this.itemHeight}px">
${item.name}
</div>
`).join('')
this.container.scrollTop = this.container.scrollTop
}
}
第三步:懒加载图片
用 IntersectionObserver 检测,快到展示台了再搬过来。
js
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target
img.src = img.dataset.src // 开始加载真实图片
observer.unobserve(img) // 加载完就取消观察
}
})
}, {
rootMargin: '100px' // 提前 100px 开始加载
})
document.querySelectorAll('img[data-src]').forEach(img => {
observer.observe(img)
})
效果
滚动帧率从 15fps 提到 60fps。
写在最后
网页卡顿不是玄学,是有具体原因的。
记住这几点:
yaml
少返工 → 用 transform 代替位置,读写分离
走专用通道 → will-change 声明,及时移除
别让工人一直干重活 → 分解长任务,Worker 离线计算
废料及时清理 → 移除无用监听,定时器,闭包
用计数器找问题 → Performance API,火焰图
下次网页卡了,别急着怪电脑配置,先看看代码。