从卡顿到顺滑,只差这几个优化

同样的功能,为什么别人的网页纵享丝滑,你的却像在嚼炫迈?

今天聊聊,怎么让网页从"卡"变成"顺"。


原文地址

墨渊书肆/从卡顿到顺滑,只差这几个优化


网页为什么会卡?

一条工厂流水线

浏览器渲染网页,就像一条工厂流水线:

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 直接处理)

合成层为什么快?

  1. 不占用主线程:Layout 和 Paint 在主线程,Composite 在 GPU
  2. 不影响其他元素:普通元素重排可能影响整个车间,合成层互相独立
  3. 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,火焰图

下次网页卡了,别急着怪电脑配置,先看看代码。

相关推荐
卸任17 小时前
打造基于 Milkdown 的所见即所得 Markdown 编辑器
前端·react.js·markdown
蓝莓味的口香糖17 小时前
带图标的Loading组件封装
开发语言·前端·javascript
夜雪闻竹17 小时前
Electron 入门:Web 应用打包成桌面软件
前端·javascript·electron
勇哥的编程江湖17 小时前
25 Elasticsearch Terms Aggregation 实战
java·服务器·前端
张小凡vip17 小时前
python的__init__.py说明
开发语言·前端·python
GIS66880017 小时前
零基础webgis开发入门:HTML/CSS/JavaScript前端核心基础①
前端·css·html
JiaWen技术圈17 小时前
React 19 Fiber 架构 深度解析
前端·react.js·架构
大阳光男孩17 小时前
【UniApp小程序开发】解决无法使用Vue自定义指令的完美替代方案:权限组件封装
前端·vue.js·uni-app
武当王丶也17 小时前
React Native Turbo Module 实战:从 0 封装一个 PDA 扫码模块
android·前端·react native