Vue 列表输入卡 300ms:六处改动与各自的代价

格 格达软件 2026-10-30 0 次阅读

一个 5000 行的订单列表,顶部一个搜索框。用户敲一个字符,光标要等两百多毫秒才往前走一格。

这个页面不是 Vue 慢,是每次按键都在 patch 四万个 DOM 节点。下面是从复现到压下去的完整过程,每一处改动我都标了代价。

先把它测出来,别靠感觉改

Vue 3 提供了一个开关,打开之后 Chrome Performance 面板里才会出现组件级别的耗时标记:

// main.js
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)
// 必须在 mount 之前设置;只在开发构建生效,生产构建里这个开关是空操作
app.config.performance = true
app.mount('#app')

打开 DevTools → Performance → 点录制 → 在输入框里敲几个字 → 停止。火焰图里找 component render 这一层,你能直接看到一次按键触发了多少次渲染、每次多久。

坑在这里:用生产构建测,火焰图里组件名全被压成 anonymous,你根本分不清是哪个组件。要么用 dev 构建测(绝对值偏大但比例可靠),要么在 vite 里给生产构建保留组件名。

另一个更直接的工具是 onRenderTriggered,它会告诉你「是哪个响应式数据的变化触发了这次重渲染」:

<script setup>
import { onRenderTriggered } from 'vue'

// 只在开发构建可用
onRenderTriggered((e) => {
  // e.key 是触发的属性名,e.type 是 'set' / 'add' / 'delete'
  console.log('[trigger]', e.type, e.key)
})
</script>

别在里面写 debugger,断点会打断 Performance 的录制,你测出来的数字全是假的。

复现:一段能跑出卡顿的代码

<!-- OrderListSlow.vue -->
<script setup>
import { ref } from 'vue'

const keyword = ref('')
const rows = ref(
  Array.from({ length: 5000 }, (_, i) => ({
    id: i,
    orderNo: 'SO' + String(i).padStart(6, '0'),
    customer: '客户' + (i % 200),
    amount: ((i * 37) % 100000) / 100,
    status: ['待付款', '已发货', '已完成'][i % 3],
    createdAt: '2024-01-' + String((i % 28) + 1).padStart(2, '0'),
  }))
)

// 模板里直接调用,每次重渲染都会重跑一遍
function filterRows() {
  const k = keyword.value.trim()
  if (!k) return rows.value
  return rows.value.filter(
    (r) => r.orderNo.includes(k) || r.customer.includes(k)
  )
}
</script>

<template>
  <input v-model="keyword" placeholder="搜索订单号 / 客户" />
  <table>
    <thead>
      <tr><th>订单号</th><th>客户</th><th>金额</th><th>状态</th><th>日期</th></tr>
    </thead>
    <tbody>
      <!-- 三个问题叠在一起:index 做 key、方法调用、5000 行全渲染 -->
      <tr v-for="(row, i) in filterRows()" :key="i">
        <td>{{ row.orderNo }}</td>
        <td>{{ row.customer }}</td>
        <td>{{ row.amount.toFixed(2) }}</td>
        <td>{{ row.status }}</td>
        <td>{{ row.createdAt }}</td>
      </tr>
    </tbody>
  </table>
</template>

v-model 默认监听 input 事件,也就是每敲一个字符就同步更新一次 keyword,然后整个组件重渲染,然后 5000 行 × 5 列 = 25000 个单元格 diff 一遍。

我在这台 M1 / Chrome 120 / dev 构建上量的数字:

版本 首屏渲染 敲一个字符到画面更新
上面这段基线代码 ~820ms ~260ms
只把 filterRows() 换成 computed ~820ms ~245ms
再把 :key="i" 换成 :key="row.id" ~810ms ~232ms
加上虚拟滚动,只渲染视口内约 25 行 ~55ms ~9ms
再加 shallowRef + 120ms 输入防抖 ~55ms ~5ms

dev 构建比生产构建慢 2~3 倍,绝对值你不用对,看相对比例就行。

第一个反直觉的点:computed 救不了你

很多人第一反应是「模板里调方法没有缓存,改成 computed 就好了」。改完发现还是卡。

原因很简单:filterRows() 遍历 5000 个对象,大概 1~3ms。而 patch 25000 个 DOM 节点,200ms 起步。你把 3ms 优化成 0,剩下的 200ms 一点没动。

computed 的价值在别处:当组件因为其他原因重渲染时(比如某个无关的 ref 变了),有缓存就不会重新过滤。但如果重渲染的触发源就是 keyword 本身,缓存必然失效,该算还是得算。

// 这个改动值得做,但它解决的不是你现在的瓶颈
const filteredRows = computed(() => {
  const k = keyword.value.trim()
  if (!k) return rows.value
  return rows.value.filter(
    (r) => r.orderNo.includes(k) || r.customer.includes(k)
  )
})

顺手把 key 改对

:key="i" 在「列表内容稳定、只做尾部追加」的场景没问题。但你这个列表会被筛选、排序、删除,index 做 key 会让 Vue 按位置复用 DOM 节点:

<!-- 从 5000 行筛到 3 行时,Vue 复用了前 3 个 <tr>,只是把内容改掉 -->
<tr v-for="(row, i) in filteredRows" :key="i">

纯文本单元格看不出问题。但只要有行内 <input>、展开/收起状态、或者 <video>,用户输入的内容会残留在错误的那一行上。这是我在真实项目里踩过的 bug,排查了两个小时。

改成 :key="row.id",并且保证 id 在数据生命周期里唯一且稳定。用随机数或者 Math.random() 生成 key 更糟,每渲染一次全部节点重建。

真正的解法:不渲染你看不见的行

虚拟滚动的思路就一句话:容器高度按总行数算(撑出滚动条),真正渲染的只有滚动位置附近那几十行。

<!-- VirtualList.vue -->
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'

const props = defineProps({
  rows: { type: Array, required: true },
  rowHeight: { type: Number, default: 36 },
  buffer: { type: Number, default: 6 }, // 上下多渲染几行,快速滚动时不露白
})

const el = ref(null)
const scrollTop = ref(0)
const viewH = ref(0)

const startIndex = computed(() =>
  Math.max(0, Math.floor(scrollTop.value / props.rowHeight) - props.buffer)
)
const endIndex = computed(() =>
  Math.min(
    props.rows.length,
    Math.ceil((scrollTop.value + viewH.value) / props.rowHeight) + props.buffer
  )
)
const visibleRows = computed(() => props.rows.slice(startIndex.value, endIndex.value))
const padTop = computed(() => startIndex.value * props.rowHeight)
const totalHeight = computed(() => props.rows.length * props.rowHeight)

// 滚动事件一秒能触发上百次,用 rAF 合并到每帧一次
let ticking = false
function handleScroll(e) {
  if (ticking) return
  ticking = true
  const top = e.currentTarget.scrollTop
  requestAnimationFrame(() => {
    scrollTop.value = top
    ticking = false
  })
}

// 容器高度变化(窗口 resize、侧边栏收起)要重新算可见行数
let ro
onMounted(() => {
  viewH.value = el.value.clientHeight
  ro = new ResizeObserver(() => {
    viewH.value = el.value.clientHeight
  })
  ro.observe(el.value)
})
onBeforeUnmount(() => ro && ro.disconnect())
</script>

<template>
  <div ref="el" class="vs" @scroll.passive="handleScroll">
    <!-- 这一层只负责撑出滚动条的高度,不渲染内容 -->
    <div class="vs-ph" :style="{ height: totalHeight + 'px' }">
      <!-- 用 transform 平移,避免改 top 触发重排 -->
      <div class="vs-body" :style="{ transform: `translateY(${padTop}px)` }">
        <div
          v-for="row in visibleRows"
          :key="row.id"
          class="vs-row"
          :style="{ height: rowHeight + 'px' }"
        >
          <slot :row="row" />
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.vs { height: 100%; overflow-y: auto; position: relative; }
.vs-ph { position: relative; }
.vs-body { position: absolute; top: 0; left: 0; right: 0; will-change: transform; }
.vs-row { box-sizing: border-box; }
</style>

用法:

<VirtualList :rows="filteredRows" :row-height="36">
  <template #default="{ row }">
    <div class="grid grid-cols-5">
      <span>{{ row.orderNo }}</span>
      <span>{{ row.customer }}</span>
      <span>{{ row.amount.toFixed(2) }}</span>
      <span>{{ row.status }}</span>
      <span>{{ row.createdAt }}</span>
    </div>
  </template>
</VirtualList>

这几个坑必须提前知道

1. 固定行高是前提。 上面代码假设每行 36px。只要有一行内容换行变成 54px,后面所有行的位置全错。行高不定的场景要么用 ResizeObserver 逐行测量 + 二分查找定位(实现复杂度翻三倍),要么直接用 vue-virtual-scroller 的 VariableSizeList,别自己造。

2. 丢掉 <table> 语义。 上面用 div 模拟行,<thead> 和列宽对齐都没了。如果一定要用 <table>,得把 <tbody> 设为 display: block; overflow-y: auto,再把 <tr> 设为 display: table; table-layout: fixed; width: 100% 来对齐列宽,调起来很烦。做后台系统,直接上 div + CSS Grid,别跟原生表格较劲。

3. 浏览器 Ctrl+F 废了。 虚拟滚动下 DOM 里只有二三十行,用户按 Ctrl+F 搜订单号搜不到。这是硬代价,不是 bug。如果你们的用户有这个习惯,要么放弃虚拟滚动,要么给一个显式的搜索框并在 UI 上引导。

4. SSR 下会炸。 ResizeObserver 在服务端不存在,必须放在 onMounted 里。document 同理。

剩下那 9ms:从响应式和 props 上抠

虚拟滚动之后已经不怎么卡了,但还有两处白给的开销。

大数组别用深度响应式

ref([...5000 条]) 会创建深度响应式代理,每条记录的每个字段在首次访问时都会生成 Proxy。列表页通常不需要「改一个字段自动更新视图」这种能力,用 shallowRef 就够了:

import { shallowRef, triggerRef } from 'vue'

const rows = shallowRef([])

// 整体替换 → 触发更新
rows.value = await fetchOrders()

// 原地改 → 不触发,视图不会变
rows.value[0].status = '已完成'   // ❌ 没反应

这个坑很隐蔽:同事写了个「标记完成」的功能,数据改了、接口调了、刷新页面确实变了,但当前页面就是不更新。补救有两种:

// 方式一:不可变更新,适合批量改
rows.value = rows.value.map((r) =>
  r.id === targetId ? { ...r, status: '已完成' } : r
)

// 方式二:手动触发,适合改单条
rows.value[0].status = '已完成'
triggerRef(rows) // 强制通知依赖重新求值

5000 条浅拷贝一次大概 0.5ms,可以接受。但如果要在一个循环里改 100 条,用 triggerRef 只触发一次,比 map 100 次便宜得多。

另外两类对象千万别塞进响应式系统:ECharts 实例、编辑器实例、大段原始 JSON。用 markRaw 跳过代理:

import { markRaw } from 'vue'

// 不加 markRaw,Vue 会给整个 echarts 实例建 Proxy,后续每次内部属性访问都多一层
const chart = markRaw(echarts.init(el.value))

内联对象 props 会让子组件白渲染一次

这一点容易被从 React 过来的人搞反。先说要澄清的:Vue 里不需要 useCallback。下面这种写法是安全的,Vue 内部的 props 比对会跳过 emit 监听器:

<!-- 没问题,不会因为这个箭头函数导致子组件重渲染 -->
<RowItem v-for="row in rows" :key="row.id" :row="row" @select="(id) => select(id)" />

但有影响的是内联对象:

<!-- 每次父组件重渲染,都会生成一个新的 config 对象 → 引用不同 → 子组件重渲染 -->
<RowItem :row="row" :config="{ highlight: true }" />

改法是把常量提到模块作用域:

// 模块级常量,引用永远不变
const ROW_CONFIG = Object.freeze({ highlight: true })
<RowItem :row="row" :config="ROW_CONFIG" />

同样的道理,computed 里返回新对象、watch 里返回新数组,只要作为 props 传给子组件,都会破坏引用稳定性。

v-memo 能用,但很容易写出 bug

v-memo 会在依赖数组没变时跳过整个子树的 diff。对「行内容几乎不变」的表格有效:

<div
  v-for="row in visibleRows"
  :key="row.id"
  v-memo="[row.id, row.status, row.amount]"
>
  ...
</div>

坑在于依赖数组必须手写全。漏一个字段,那个字段就再也不更新:

<!-- 漏了 row.amount,用户改了金额,视图纹丝不动 -->
<div v-for="row in rows" :key="row.id" v-memo="[row.id]">
  {{ row.amount }}
</div>

这种 bug 不报错、不警告,只能靠人肉发现。所以只有在行数据确实不可变(比如日志流、只读报表)时才用 v-memo,可编辑表格别碰。

输入那一侧:别让每次按键都触发筛选

Vue 生态里最省事的是 @vueuse/core 的 refDebounced:

import { ref } from 'vue'
import { refDebounced } from '@vueuse/core'

const keyword = ref('')
const debouncedKeyword = refDebounced(keyword, 120) // 停顿 120ms 后才更新

// 筛选只依赖 debouncedKeyword
const filteredRows = computed(() => {
  const k = debouncedKeyword.value.trim()
  if (!k) return rows.value
  return rows.value.filter((r) => r.orderNo.includes(k))
})

或者用 v-model.lazy,它只在 change 事件(失焦 / 回车)时同步值:

<!-- 适合「输入完按回车搜」的场景,不适合实时搜索 -->
<input v-model.lazy="keyword" />

防抖时长的边界:120ms 以内用户基本无感;超过 300ms 就会觉得「输入框有延迟」,反而更差。如果你的筛选逻辑超过 50ms,说明该上虚拟滚动了,不是靠加防抖能盖住的。

那些看着有用、实际没用的操作

用 v-show 隐藏大列表。 切 Tab 时把列表藏起来,DOM 还在,样式计算和内存占用一样在。要隐藏就用 v-if,让它真正销毁。

watch(rows, fn, { deep: true }) 监听大数组。 每次任何字段变化都要深度遍历 5000 条。改成监听具体字段,或者配合 shallowRef 只在整体替换时触发。

用 Object.freeze 当优化手段。 Vue 3 不保证跳过冻结对象的代理,而且冻结之后你连改都改不了。想减少代理开销,用 shallowRef,别绕这个弯。

给所有组件套 v-once。 只要有一个字段需要更新,你就得把它拆出来,拆到最后发现整棵树都不能 once,白折腾。

一张取舍表

手段 改造成本 收益 什么时候别用
虚拟滚动 中(要处理行高、语义、Ctrl+F) 极大,O(n) → O(视口) 列表 < 200 行;行高不定且不想引入库;用户依赖 Ctrl+F
shallowRef 低 中,省掉深层代理 需要频繁原地改单个字段并自动更新
v-memo 低 中 行数据可变;依赖数组维护不了
computed 替代模板方法 极低 小(省的是计算,不是 patch) 计算本身有副作用时(那本来就不该用 computed)
输入防抖 极低 中 搜索需要即时反馈;用户明确要求实时
key 用稳定 id 极低 中(避免状态错位 bug) 列表只追加、从不重排
拆分组件 中 小到中 拆完 props 还是整个大数组,等于没拆

最后一行值得多说一句:拆分组件之所以有用,是因为父组件重渲染时,props 没变的子组件会被跳过。但如果你把 5000 条数据整个传下去,props 引用一直没变,子组件确实不重渲染,可它内部还是要渲染 5000 行。拆组件解决的是「不必要的重渲染」,不解决「一次重渲染太贵」,后者只有虚拟滚动能治。

什么时候这套东西全是负收益

列表只有 100 行,上面所有优化加起来换不来一帧的提升,反而多了一堆 ResizeObserver、shallowRef、v-memo 的维护负担。判断标准很简单:用 Performance 面板量一次按键的耗时,低于 16ms 就别动它。

还有一个容易被忽略的边界:如果你的页面在低端安卓机上跑,上面所有数字要乘以 3 到 5。开发机上 9ms 的操作,在千元机上就是 40ms,已经掉帧了。测试的时候拿真机,别只看自己的 MacBook。

先量,再改。别猜。

格
格达软件

专注软件定制开发,分享技术实践与行业观察。有开发需求?欢迎随时咨询。

咨询我们

有软件开发需求?我们 24 小时内响应

提交您的需求,格达软件将为您提供免费的需求评估与技术方案建议,咨询全程不收取任何费用。