Vue 3000 行表格渲染从 1.8s 到 190ms

格 格达软件 2026-10-23 1 次阅读 AI 生成

一个后台的订单列表页,3000 条数据一次拉回来不分页,表格每行 8 列。切到这个路由白屏 1.8 秒,改一次筛选条件卡 400 毫秒,在输入框里敲字每个字符掉 8 帧。

这篇文章只拆这一个页面:怎么定位到具体是哪一步慢,每项改动改了什么、收益多少,以及哪些「优化」加了反而更糟。测量环境 Vue 3.4 + Vite 5、Chrome 124、M1 MacBook Pro。

先分层:慢在 render、patch 还是浏览器 layout

别上来就加 v-memo。Vue 一次更新要走三段:响应式触发 → 组件重新执行 render 生成新 vnode → patch(diff + 更新 DOM)→ 浏览器 layout/paint。三段慢掉的优化手段完全不同,先分清在哪一段。

第一件工具:Vue 自己的性能标记

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

const app = createApp(App)
// 仅开发环境生效,会在 Chrome Performance 面板的 User Timing 轨道
// 打出 componentRender / componentUpdate 的耗时标记
app.config.performance = true
app.mount('#app')

Chrome DevTools → Performance → 录制 → 切一次路由 → 停止。Timings 轨道里按耗时排序,元凶一眼能看到。

第二件工具:找出是谁触发了这次渲染

onRenderTriggered 是 dev-only 钩子,直接打印触发重渲染的依赖:

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

onRenderTriggered((e) => {
  // e.type: 'set' | 'add' | 'delete' | 'clear'
  // e.key: 变的是哪个属性;e.target: 哪个响应式对象
  console.log('[trigger]', e.type, e.key, e.target)
})
</script>

基线数据如下,后面每项优化都对着这张表看:

场景 改之前
首次进入页面 1860 ms
切换筛选条件 430 ms
输入框每次击键 110 ms
JS 堆内存 142 MB

内联箭头函数会把整张表变成全员重渲染

坏写法:

<template>
  <div class="table">
    <Row
      v-for="row in rows"
      :key="row.id"
      :row="row"
      @select="() => select(row)"
    />
  </div>
</template>

@select="() => select(row)" 每次父组件执行 render,都会新建一个箭头函数。它会作为 onSelect 塞进 Row 的 props。Vue 的 shouldUpdateComponent 逐项比较 props,函数引用 !==,于是判定 Row 需要更新——哪怕 row 一个字段都没变。

3000 行就是 3000 次无意义 patch。单独回滚这一项测过:切换筛选 430 ms → 150 ms。

改法是让子组件 emit 时把 row 带出来,父组件传稳定引用:

<!-- Row.vue -->
<script setup>
const props = defineProps({ row: Object })
const emit = defineEmits(['select'])
</script>

<template>
  <!-- 传的是 emit 和 row,没有在模板里再包一层函数 -->
  <div class="row" @click="emit('select', row)">
    {{ row.id }} / {{ row.status }}
  </div>
</template>
<!-- 父组件:select 是 setup 里的函数,引用永远不变 -->
<Row v-for="row in rows" :key="row.id" :row="row" @select="select" />
function select(row) {
  current.value = row
}

事件多、层级深时,可以直接上事件委托,把 N 个监听器压成 1 个:

<template>
  <div class="table" @click="onTableClick">
    <Row v-for="row in rows" :key="row.id" :row="row" />
  </div>
</template>

<script setup>
function onTableClick(e) {
  // closest 往上找带 data-row-id 的祖先,点到行内任意子元素都能命中
  const el = e.target.closest('[data-row-id]')
  if (!el) return
  select(Number(el.dataset.rowId))
}
</script>

3000 次 addEventListener 换成 1 次,首屏省了大约 30~40 ms,单项不如上一条明显,但叠加起来也不少。

模板里做计算,等于每次渲染都重算

坏写法:

<td>{{ new Date(row.createdAt).toLocaleString('zh-CN') }}</td>
<td>{{ row.items.filter(i => i.checked).length }}</td>

模板表达式没有任何缓存。父组件重渲染一次,3000 行就重新 new Date + toLocaleString、重新 filter 一遍。toLocaleString 单次 5~15 μs,3000 次就是 15~45 ms,再乘以重渲染次数。

分两层处理。

数据层:加载完预处理一次,别放在渲染路径上。

const raw = await fetchOrders()

// Intl 实例复用,不要每次 new;比 toLocaleString 快 3~5 倍
const dtf = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' })

const rows = shallowRef(raw.map(o => ({
  ...o,
  createdAtText: dtf.format(new Date(o.createdAt)),
  checkedCount: o.items.reduce((n, i) => n + (i.checked ? 1 : 0), 0),
})))

渲染层:真需要随响应式变化的,用 computed。

// 依赖没变就不重算,模板里直接引用这个 computed
const checkedTotal = computed(() =>
  rows.value.reduce((n, r) => n + r.checkedCount, 0)
)

reactive 大数组,是一次性付出的 24000 个 Proxy

// 坏:3000 个对象、每个对象 8 个字段,读取时全要过 get 拦截器
const rows = reactive(await fetchOrders())

Vue 3 的 reactive 是读一层代理一层,但每次读取都要经过拦截器,内存和 CPU 都是实打实的。在前面两项改完的基础上(首屏已经降到 700 ms 左右),把这一处换成 shallowRef:堆内存 142 MB → 108 MB,首屏 700 ms → 560 ms。

import { shallowRef, triggerRef } from 'vue'

const rows = shallowRef(await fetchOrders())

// 只有整体替换 .value 才触发更新
rows.value = newRows

// 改了内部字段又要让视图更新,得手动触发
rows.value[0].status = 'done'
triggerRef(rows)

边界条件要跟团队说清楚:shallowRef 的 .value 存对象时,改内部属性不触发更新,这是契约不是 bug。不然新人加一行 rows.value[0].x = 1 发现界面不动,能查一下午。

完全不需要响应式的字段(图表 option、地图实例、超大只读 JSON)用 markRaw 跳过代理:

const chartOption = markRaw(buildHugeOption()) // 永远不会变成 Proxy

顺带一个常见误解:Object.freeze 治不了这个。reactive(frozenObj) 发现对象不可扩展,会直接返回原对象而不是 Proxy,属性变化不触发更新——看着像优化,实际埋了个视图不更新的雷。

v-memo 好用,但漏一个依赖就是幽灵 bug

Vue 3.2 起有 v-memo,按依赖数组跳过子树更新:

<div v-for="row in rows" :key="row.id" v-memo="[row.id, row.status, row.amount]">
  <!-- 三个字段都没变,这一行不会进 diff/patch -->
</div>

三个必须知道的代价:

  1. 依赖数组必须列全。 写成 v-memo="[row.id]" 但模板里渲染了 row.status,状态变了视图不更新。这个 bug 往往只在「其他地方也触发了更新」时才显形,极难定位。
  2. 比较本身有成本。 数组越长,每次逐项 === 的开销越大,收益被吃回去。
  3. 它不阻止父组件执行 render。 跳过的是后面的 diff/patch,render 函数本身慢的,它救不了。

我的用法:只在「结构固定、更新频繁、字段明确」的地方用,比如实时行情、监控大盘。普通 CRUD 列表把内联函数和模板计算清掉,收益已经够了,不加也够。

DOM 数量才是硬上限

前面全部做完,切换筛选 150 ms、首屏 560 ms 左右。还能不能再压?能,但方向要换——之前优化的都是「每次更新多快」,现在的问题是「一次要更新多少个 DOM」。

3000 行 × 8 列 ≈ 24000 个 DOM 节点,每个节点 patch 就算只要 2 μs,也是 48 ms,再加上浏览器 layout 和 paint,怎么都下不去。

一屏只看得到 20 行,剩下 2980 行纯粹在给滚动条做贡献。上虚拟滚动:

普通渲染 虚拟滚动
DOM 节点数 ~24000 ~240(30 行 × 8 列)
首屏 560 ms 190 ms
切换筛选 150 ms 22 ms
快速滚动 卡顿 每帧一次 slice,稳定

固定行高的最小实现,不引库:

<!-- VirtualList.vue -->
<template>
  <div class="vlist" @scroll.passive="onScroll">
    <!-- 撑开滚动条,保证滚动条长度和滚动距离是对的 -->
    <div class="vlist__phantom" :style="{ height: totalHeight + 'px' }"></div>
    <!-- 只渲染可视区 + buffer,靠 translateY 挪到正确位置 -->
    <div class="vlist__body" :style="{ transform: `translateY(${offsetY}px)` }">
      <template v-for="item in visibleItems" :key="item.id">
        <slot :item="item" />
      </template>
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'

const props = defineProps({
  items: { type: Array, required: true },
  rowHeight: { type: Number, default: 44 },
  buffer: { type: Number, default: 6 }, // 上下各多渲染几行,减少快速滚动白屏
})

const scrollTop = ref(0)
const viewportHeight = ref(600) // 简化写死,实际项目用 ResizeObserver 量真实高度

function onScroll(e) {
  // 每帧最多一次,开销很小
  scrollTop.value = e.target.scrollTop
}

const startIndex = computed(() =>
  Math.max(0, Math.floor(scrollTop.value / props.rowHeight) - props.buffer)
)
const endIndex = computed(() =>
  Math.min(
    props.items.length,
    Math.ceil((scrollTop.value + viewportHeight.value) / props.rowHeight) + props.buffer
  )
)
const visibleItems = computed(() => props.items.slice(startIndex.value, endIndex.value))
const offsetY = computed(() => startIndex.value * props.rowHeight)
const totalHeight = computed(() => props.items.length * props.rowHeight)
</script>

<style scoped>
.vlist {
  position: relative;
  height: 100%;
  overflow-y: auto;
}
.vlist__phantom {
  width: 1px; /* 只用来占高度 */
}
.vlist__body {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
}
</style>

用法:

<VirtualList :items="rows" :row-height="44">
  <template #default="{ item }">
    <Row :row="item" @select="select" />
  </template>
</VirtualList>

几个绕不过去的边界:

  • 行高必须固定。 不定高要上 ResizeObserver 逐行量高 + 维护位置偏移表,代码量翻三倍,行高变化时还会抖。能固定就固定。
  • @scroll 加 .passive。 否则浏览器要等 JS 跑完才滚,滚动会明显发涩。
  • onScroll 里别干重活。 那里的代码每帧都会跑,在里面加 filter/map 等于把卡顿从渲染搬到了滚动。
  • 和 shallowRef 搭配时要注意。 items 传的是未被代理的原始数组,行内对象改字段不会触发更新,得整体替换数组或 triggerRef,否则虚拟列表里的行不刷新。
  • 别过早用。 经验阈值:超过 200 行、或超过 1500 个 DOM 节点再考虑。20 行的列表套虚拟滚动,维护成本远大于收益。

两个顺手要清的小坑

v-for 的 key 用 index:

<!-- 坏:删除中间一项后,后面所有行的 key 都变了,DOM 复用全部错位,
     输入框里的内容会留在原来那一行上 -->
<div v-for="(row, i) in rows" :key="i">

有稳定 id 就用 id。纯静态、永不重排的数组用 index 也行,但要知道自己在赌什么。

v-if 和 v-for 写在同一个元素上:

<!-- 坏:Vue 3 里 v-if 优先级高于 v-for,拿不到 row,直接报错 -->
<div v-for="row in rows" v-if="row.visible" :key="row.id">

要过滤就先 computed:

const visibleRows = computed(() => rows.value.filter(r => r.visible))

什么时候不该做这些

3000 行不分页本身就是产品决策问题。服务端分页 + 搜索,首屏直接回到 40 ms 量级,代码还更简单——很多时候这才是正解,前端渲染优化只是给「业务就是要一屏全展示」兜底。

如果确实得优化,顺序我建议是这样:

  1. 先量。 没有 app.config.performance 和 Performance 面板的数据,所有优化都是猜。
  2. 清结构性问题。 内联函数、模板内计算、index key,这三样改完,绝大多数列表页就够用了,改动小、风险低。
  3. 再看响应式粒度。 shallowRef / markRaw 收益中等,但改动面大,容易引入「视图不更新」的坑,要先跟团队对齐契约。
  4. 最后上虚拟滚动。 它最有效,引入的边界条件也最多:固定行高、快速滚动白屏、行内组件状态复用。

v-memo 我一般放最后甚至不加。它跳过的是 patch,而多数业务页面的瓶颈根本不在 patch——把内联函数清掉收益就比它大。

格
格达软件

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

咨询我们

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

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