性能优化简介

渲染优化

掌握防抖节流、虚拟列表、长列表与高频事件优化技巧,减少页面卡顿。

🎯 引言

上一篇我们解决了「页面加载慢」的问题,但加载快不代表用起来流畅。你可能遇到过这些情况:搜索框每敲一个字就发一次请求、列表数据一多页面就卡、滚动时动画一卡一卡的。这些都属于渲染阶段的性能问题。

学完本篇,你将能够:

  • 手写防抖节流,说清楚两者的区别和各自的适用场景。
  • 解释长列表卡顿的原因,讲清虚拟列表的核心思想。
  • 说出重排重绘是什么、谁代价更大,并用批量修改、读写分离等手段减少它们。

这三个技巧不依赖任何框架,是纯 JavaScript 和浏览器原理层面的优化,学会后在任何项目里都用得上。


🧱 防抖:等你操作完再执行

先看一个常见场景:搜索输入联想。用户在输入框里打字,我们希望根据输入内容去请求联想词。如果直接监听 input 事件,每敲一个字母就发一次请求,输入「vue3」四个字母就发了四次请求,前三次的结果用户根本不看,白白浪费请求。

解决思路是防抖(debounce):事件触发后不立刻执行,而是等一会儿,如果这段时间内又触发了,就重新计时,直到安静下来才真正执行。

生活化一点说,防抖就像电梯等人:电梯门打开后开始倒计时,倒计时期间有人进来,就重新倒计时,直到一段时间没人进来,电梯才关门上楼。电梯不会为每个人单独跑一趟。

用代码实现只需要一个定时器:

debounce.js
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);

这样用户连续敲键盘时请求一次都不会发,只有停下手来才真正请求,请求次数从「按字母数算」变成「按停顿次数算」。

防抖适合「只关心最后一次结果」的场景:搜索联想、窗口 resize 后重新布局、表单自动保存草稿。

🧱 节流:固定节奏执行

再看另一个场景:监听页面滚动,根据滚动位置决定是否显示「回到顶部」按钮。scroll 事件触发频率很高,拖一下滚动条可能触发几十次,每次都执行处理函数太浪费了。

这次防抖就不合适了:用户可能一直滚动不停,我们希望滚动过程中也能定期更新状态,而不是等停下来才更新。解决思路是节流(throttle):不管事件触发多频繁,都按固定的时间间隔执行,间隔内的触发直接忽略。

节流就像游戏里的技能冷却:技能放出去后进入冷却时间,冷却期间你怎么按技能键都没反应,冷却结束才能放下一次。按键频率再高,技能释放的节奏也是固定的。

throttle.js
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、自动保存滚动监听、鼠标移动、按钮防连点

一句话记忆:防抖是「等你做完」,节流是「按我的节奏来」。选型的关键问题是:你需要的是「最终那一次」的结果,还是「过程中持续的反馈」?前者用防抖,后者用节流。

两个函数都返回一个新函数,事件绑定和解绑时要用同一个新函数。比如在 Vue 组件卸载时 removeEventListener,传入的必须是当初绑定的那个包装后的函数。

🧱 虚拟列表:只渲染看得见的部分

再来看长列表的卡顿问题。假设一个页面要展示一万条数据,如果用 v-for 全部渲染,浏览器就要一口气创建一万个 DOM 节点。DOM 节点是很「重」的对象,创建、布局、绘制都要消耗资源,节点多了之后,首次渲染慢、滚动也卡。

关键在于:用户一次能看到的,只有屏幕里那十几条。看不到的那九千多条,渲染出来纯属浪费。虚拟列表(Virtual List)的核心思想就是:只渲染可视区域附近的条目,滚动时动态替换内容,让页面上的 DOM 节点始终维持在几十个。

生活化一点说,这就像机场的航班信息屏:机场一天有几百个航班,但屏幕一次只显示十几行,滚动查看时屏幕上的行数不变,只是每一行的内容被替换成了新的航班。虚拟列表里的 DOM 节点就是这些固定行,复用位置、替换内容

原理可以拆成三步:

  1. 撑开总高度:用一个占位元素把高度设为「条目数 × 每条高度」,让滚动条表现得和真实一万条一样长。
  2. 算出该渲染哪些:监听滚动,用 scrollTop ÷ 每条高度 算出可视区域起始下标,加上一屏能显示的条数,得到要渲染的一小段数据。
  3. 对齐位置:用一个内层容器做垂直偏移(transform: translateY),让这一小段内容正好出现在可视区域里。

简化的示意代码如下:

virtual-list.js
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
UserList.vue
<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');

手段二:动画用 transformopacity,别用 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);
}

手段三:读写分离,别边读边写。

这是容易踩的隐藏坑。浏览器不会每改一行样式就立刻重排一次,而是会把短时间内连续的样式修改先记下来,攒成一批后统一做一次重排,这样十次修改也只花一次重排的成本。但如果你在修改之间读取了布局信息(如 offsetTopclientWidthgetBoundingClientRect()),浏览器为了给你返回准确的值,必须立刻把攒着的修改结算掉、执行一次重排,这个「攒一批再统一处理」的优化就被打断了:

// 不好:写一下、读一下,每次循环都强制重排
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(重绘)耗时是否密集出现。

Chrome DevTools 的界面会随版本更新,面板名称和入口位置可能与你看到的略有不同。以上流程仅供参考,核心思路不变,操作时以你本地的面板为准。

Q5:重排和重绘,是不是完全不触发更好?

A:不是,也做不到。页面内容变化本来就需要重新布局和绘制,这是正常开销。优化的目标是避免不必要的、重复的、大范围的重排,而不是追求一次都不发生。


🧪 小练习

练习一:给下面的搜索框加上防抖,要求停止输入 300ms 后才打印搜索词。把文中手写的 debounce 复制过来直接用即可。

SearchBox.vue
<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 缓存让资源加载事半功倍。