渲染优化
🎯 引言
上一篇我们解决了「页面加载慢」的问题,但加载快不代表用起来流畅。你可能遇到过这些情况:搜索框每敲一个字就发一次请求、列表数据一多页面就卡、滚动时动画一卡一卡的。这些都属于渲染阶段的性能问题。
学完本篇,你将能够:
- 手写防抖和节流,说清楚两者的区别和各自的适用场景。
- 解释长列表卡顿的原因,讲清虚拟列表的核心思想。
- 说出重排和重绘是什么、谁代价更大,并用批量修改、读写分离等手段减少它们。
这三个技巧不依赖任何框架,是纯 JavaScript 和浏览器原理层面的优化,学会后在任何项目里都用得上。
🧱 防抖:等你操作完再执行
先看一个常见场景:搜索输入联想。用户在输入框里打字,我们希望根据输入内容去请求联想词。如果直接监听 input 事件,每敲一个字母就发一次请求,输入「vue3」四个字母就发了四次请求,前三次的结果用户根本不看,白白浪费请求。
解决思路是防抖(debounce):事件触发后不立刻执行,而是等一会儿,如果这段时间内又触发了,就重新计时,直到安静下来才真正执行。
生活化一点说,防抖就像电梯等人:电梯门打开后开始倒计时,倒计时期间有人进来,就重新倒计时,直到一段时间没人进来,电梯才关门上楼。电梯不会为每个人单独跑一趟。
用代码实现只需要一个定时器:
function debounce(fn, delay) {
let timer = null; // 用闭包记住上一次的定时器
return function (...args) {
// 每次触发都清掉旧定时器,相当于「重新倒计时」
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
用法很直接,把原来的处理函数包一层就行:
// 搜索联想:停止输入 300ms 后才发请求
const onInput = debounce((keyword) => {
console.log('发送搜索请求:', keyword);
}, 300);
这样用户连续敲键盘时请求一次都不会发,只有停下手来才真正请求,请求次数从「按字母数算」变成「按停顿次数算」。
🧱 节流:固定节奏执行
再看另一个场景:监听页面滚动,根据滚动位置决定是否显示「回到顶部」按钮。scroll 事件触发频率很高,拖一下滚动条可能触发几十次,每次都执行处理函数太浪费了。
这次防抖就不合适了:用户可能一直滚动不停,我们希望滚动过程中也能定期更新状态,而不是等停下来才更新。解决思路是节流(throttle):不管事件触发多频繁,都按固定的时间间隔执行,间隔内的触发直接忽略。
节流就像游戏里的技能冷却:技能放出去后进入冷却时间,冷却期间你怎么按技能键都没反应,冷却结束才能放下一次。按键频率再高,技能释放的节奏也是固定的。
function throttle(fn, interval) {
let lastTime = 0; // 上次执行的时间点
return function (...args) {
const now = Date.now();
// 距离上次执行还不够一个间隔,直接忽略
if (now - lastTime >= interval) {
lastTime = now;
fn.apply(this, args);
}
};
}
用法同样是包一层:
// 滚动监听:每 200ms 才执行一次
const onScroll = throttle(() => {
console.log('当前滚动位置:', window.scrollY);
}, 200);
window.addEventListener('scroll', onScroll);
事件照样高频触发,但处理函数每 200ms 才执行一次,开销立刻降下来了。
⚡ 防抖与节流的区别
这两个概念是初学者很容易混淆的一对,我们用一张表对比清楚:
| 对比项 | 防抖 debounce | 节流 throttle |
|---|---|---|
| 执行时机 | 事件「停下来」之后执行一次 | 按固定间隔执行,停不下来也会执行 |
| 生活比喻 | 电梯等人,人齐了才关门 | 技能冷却,到点才能放 |
| 触发 n 次事件 | 通常只执行 1 次(最后一次) | 大约执行 n × 间隔比例次 |
| 典型场景 | 搜索联想、resize、自动保存 | 滚动监听、鼠标移动、按钮防连点 |
一句话记忆:防抖是「等你做完」,节流是「按我的节奏来」。选型的关键问题是:你需要的是「最终那一次」的结果,还是「过程中持续的反馈」?前者用防抖,后者用节流。
removeEventListener,传入的必须是当初绑定的那个包装后的函数。🧱 虚拟列表:只渲染看得见的部分
再来看长列表的卡顿问题。假设一个页面要展示一万条数据,如果用 v-for 全部渲染,浏览器就要一口气创建一万个 DOM 节点。DOM 节点是很「重」的对象,创建、布局、绘制都要消耗资源,节点多了之后,首次渲染慢、滚动也卡。
关键在于:用户一次能看到的,只有屏幕里那十几条。看不到的那九千多条,渲染出来纯属浪费。虚拟列表(Virtual List)的核心思想就是:只渲染可视区域附近的条目,滚动时动态替换内容,让页面上的 DOM 节点始终维持在几十个。
生活化一点说,这就像机场的航班信息屏:机场一天有几百个航班,但屏幕一次只显示十几行,滚动查看时屏幕上的行数不变,只是每一行的内容被替换成了新的航班。虚拟列表里的 DOM 节点就是这些固定行,复用位置、替换内容。
原理可以拆成三步:
- 撑开总高度:用一个占位元素把高度设为「条目数 × 每条高度」,让滚动条表现得和真实一万条一样长。
- 算出该渲染哪些:监听滚动,用
scrollTop ÷ 每条高度算出可视区域起始下标,加上一屏能显示的条数,得到要渲染的一小段数据。 - 对齐位置:用一个内层容器做垂直偏移(
transform: translateY),让这一小段内容正好出现在可视区域里。
简化的示意代码如下:
const itemHeight = 50; // 每条高度固定 50px
const total = 10000; // 共一万条
const visibleCount = 10; // 一屏大约显示 10 条
function getVisibleItems(scrollTop) {
// 1. 算出可视区域从第几条开始
const start = Math.floor(scrollTop / itemHeight);
// 2. 截取这一小段数据(实际渲染的只有 10 条)
// listData 是完整的一万条数据
const items = listData.slice(start, start + visibleCount);
// 3. 内层容器的偏移量,让内容对齐到正确位置
const offsetY = start * itemHeight;
return { items, offsetY };
}
无论 total 是一万还是十万,真正渲染的 DOM 都只有 visibleCount 那么多,滚动时只需要更新这一小段数据和偏移量,这就是虚拟列表流畅的原因。
实战中不必自己造轮子,Vue3 项目可以直接用成熟库,比如 vue-virtual-scroller(本地验证环境:Node.js v22,包版本 vue-virtual-scroller@2.0.0-beta.8):
pnpm add vue-virtual-scroller@2.0.0-beta.8
<script setup>
import { RecycleScroller } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
// users 是一万条用户数据
defineProps(['users']);
</script>
<template>
<RecycleScroller :items="users" :item-size="50" style="height: 500px">
<template #default="{ item }">
<div class="row">{{ item.name }}</div>
</template>
</RecycleScroller>
</template>
指定每条高度和容器高度,库就帮你处理好了上面说的所有计算,你只需要关心每条数据怎么展示。
🧱 重排与重绘:浏览器是怎么画页面的
最后一个话题回到浏览器本身。要理解为什么有些操作特别卡,先要认识两个概念:重排(reflow)和重绘(repaint)。
浏览器渲染页面可以粗略理解为两步:先算好每个元素的几何信息(多宽多高、在什么位置),再把它们画到屏幕上(颜色、背景、文字)。这两个概念就对应这两步:
- 重排:元素的几何信息变了(宽高、位置、字体大小等),浏览器要重新计算布局,受影响的元素甚至整棵树的布局都可能要重算。就像停车场里把一个车位加宽,后面的车位线全都要跟着重新画。
- 重绘:元素的外观变了但几何信息没变(颜色、背景色等),浏览器只需要重新画一遍,不用重算布局。就像给车位里那辆车重新喷个漆,车还是那辆车,车位线一根都不用动。
很明显,重排的代价比重绘大得多,因为重排包含了布局计算,而且重排之后通常必然跟着重绘。常见的重排触发场景有:修改元素宽高位置、添加删除 DOM 节点、改变窗口大小,以及读取某些布局属性(这一点下面细讲)。
🛠 减少重排重绘的三个手段
知道了重排很贵,优化的方向就是让它少发生、小范围发生。下面三个手段日常开发中很实用。
手段一:批量修改样式,别一条一条改。
逐条修改样式,每次修改都可能触发一次重排;改用 class 一次性切换,浏览器只需要处理一次变化:
// 不好:三次修改可能触发三次重排
el.style.width = '100px';
el.style.height = '100px';
el.style.backgroundColor = 'skyblue';
// 好:预先写好一个 class,一次切换
el.classList.add('active');
手段二:动画用 transform 和 opacity,别用 top / left。
top / left 改变的是元素位置,每一帧都会触发重排。而 transform(位移、缩放、旋转)和 opacity(透明度)可以由浏览器的合成线程直接处理,跳过重排,动画会流畅很多:
/* 不好:每一帧都在改布局 */
.box {
transition: left 0.3s;
}
.box.moved {
left: 100px;
}
/* 好:位移交给 transform,不触发重排 */
.box {
transition: transform 0.3s;
}
.box.moved {
transform: translateX(100px);
}
手段三:读写分离,别边读边写。
这是容易踩的隐藏坑。浏览器不会每改一行样式就立刻重排一次,而是会把短时间内连续的样式修改先记下来,攒成一批后统一做一次重排,这样十次修改也只花一次重排的成本。但如果你在修改之间读取了布局信息(如 offsetTop、clientWidth、getBoundingClientRect()),浏览器为了给你返回准确的值,必须立刻把攒着的修改结算掉、执行一次重排,这个「攒一批再统一处理」的优化就被打断了:
// 不好:写一下、读一下,每次循环都强制重排
for (const el of items) {
el.style.width = el.offsetTop + 10 + 'px';
}
// 好:先集中读完,再集中写
const widths = items.map((el) => el.offsetTop + 10);
items.forEach((el, i) => {
el.style.width = widths[i] + 'px';
});
记住口诀:先读后写,读写分开。循环里夹杂读写是性能杀手,把读取集中在前、写入集中在后,重排就从「每次循环一次」变成「读一批、写一批」。
🧾 小节总结
- 防抖:事件停下后才执行一次,像电梯等人,适合搜索联想、resize、自动保存。
- 节流:按固定间隔执行,像技能冷却,适合滚动、鼠标移动等高频事件。
- 两者选型看需求:要「最终那一次」用防抖,要「过程中持续反馈」用节流。
- 虚拟列表只渲染可视区域的条目,让一万条数据的列表也只维持几十个 DOM 节点,实战可直接用 vue-virtual-scroller。
- 重排重算布局、代价大,重绘只重画外观、代价小,重排之后必然跟着重绘。
- 优化三板斧:批量修改样式、动画用
transform/opacity、读写分离(先集中读、再集中写)。
❓ 知识问答
Q1:防抖和节流可以自己写,项目里还要引入 lodash 吗?
A:本文的手写版本覆盖了常用场景,够用了。lodash 的 debounce / throttle 额外支持「立即执行」「最大等待时间」等选项,如果项目已经用了 lodash,直接用它的更省事。
Q2:按钮防重复点击,用防抖还是节流?
A:用节流(或直接把冷却时间设长一点)。防连点的目的是「一段时间内只响应一次点击」,这正是固定节奏执行,和节流一致。防抖要等用户停止点击才执行,反而不符合预期。
Q3:虚拟列表只能用在固定高度的列表吗?
A:固定高度实现起来简单,高度不固定也能做,但需要额外记录和估算每条的高度,实现复杂度明显上升。这种场景建议直接用 vue-virtual-scroller 这类库,它们已经处理好了动态高度的情况。
Q4:怎么确认页面有没有频繁重排?
A:可以用 Chrome DevTools 的 Performance 面板录制一段操作,观察其中紫色的 Layout(重排)和绿色的 Paint(重绘)耗时是否密集出现。
Q5:重排和重绘,是不是完全不触发更好?
A:不是,也做不到。页面内容变化本来就需要重新布局和绘制,这是正常开销。优化的目标是避免不必要的、重复的、大范围的重排,而不是追求一次都不发生。
🧪 小练习
练习一:给下面的搜索框加上防抖,要求停止输入 300ms 后才打印搜索词。把文中手写的 debounce 复制过来直接用即可。
<script setup>
// 请在这里粘贴 debounce 的实现
function search(keyword) {
console.log('搜索:', keyword);
}
// 请在这里编写代码:用 debounce 包装 search,延迟 300ms
</script>
<template>
<!-- 请在这里编写代码:绑定 input 事件,调用包装后的函数 -->
<input placeholder="输入关键词" />
</template>
练习二:下面这段代码在一个 500 项的循环里边读边写,请改成「读写分离」的写法。
const items = document.querySelectorAll('.item');
// 请在这里编写代码:先集中读取 offsetTop,再集中写入 height
for (const el of items) {
el.style.height = el.offsetTop + 20 + 'px';
}
🎉 恭喜你已经掌握渲染优化技能啦!现在你能处理高频事件、长列表和频繁重排这三类常见的页面卡顿问题了。下一篇我们进入缓存优化,看看如何利用 HTTP 缓存让资源加载事半功倍。
