2026 前端进阶面试题里,Vue 大数组优化几乎是绕不开的一道题。很多人第一反应是虚拟滚动,这没错,但面试官如果继续问:为什么 Vue 处理大数组会卡?调度器在干吗?响应式更新是怎么一层层传播的?不少同学会突然卡住。这个问题真正重要的不是背知识点,而是建立一条从性能问题定位到 Vue 更新机制的完整链路。
我会按实际调试大列表的顺序展开:先定位慢在哪一步,再拆响应式分形架构和调度器抢占,最后落到虚拟滚动、浅层响应式、不可变数据这些具体优化方案上。如果你正在准备前端面试,或者正在维护一个日志流、表格、时间线类型的中后台页面,这篇内容可以帮你把零散的优化经验串成能讲清楚的逻辑。
注意,下面所有内容默认基于 Vue 3 的 Composition API 和较新的稳定版本。如果项目还在用 Vue 2,响应式实现用的是另一套模型,部分 API 不能直接迁移,需要先理解差异再套用思路。
1. 大数组变慢到底慢在哪一步
很多人的第一反应是“Vue 渲染能力不够”。实际上,大数组卡顿很少是单一原因。它可能发生在数据层、响应式通知层、虚拟 DOM diff 层,也可能发生在浏览器最终的 layout 和 paint。不先分清楚瓶颈在哪一层,后面所有优化方案都是在猜。
1.1 先拆渲染链路:数据、更新、绘制不是同一件事
一条数据从到达页面到最终显示,大致要经过这些环节:
- 数据从接口或者业务逻辑进入响应式状态。
- Vue 的响应式系统通知依赖该状态的 effect 需要重新执行。
- 组件 render 函数重新执行,生成新的虚拟 DOM。
- 框架对新旧虚拟 DOM 做 diff,计算出需要修改的 DOM 操作。
- 浏览器更新真实 DOM,然后重新计算布局和绘制。
Vue 能深度优化的主要是第 2 到第 4 步。第 5 步的 layout 和 paint 属于浏览器行为,框架很难直接干预。虚拟滚动能解决大列表问题,本质上就是绕开第 5 步里的“一次性创建大量 DOM 节点”和“大范围布局计算”。
这里容易忽略的是:大数组里的对象如果包含复杂嵌套结构,进入reactive后还需要一层层做 Proxy 代理。数据越多、嵌套越深,初始代理和访问依赖收集的开销就越大。很多人发现数据赋值那一步就慢,不一定全是渲染问题。
1.2 看现象判断瓶颈:CPU、内存、滚动掉帧
我在实际调试中一般会先看任务管理器、Chrome Performance 面板,再返回代码分析。几个典型的判断方向:
- 如果 CPU 持续很高,并且卡顿集中在数据变化之后,重点查响应式更新和 diff 范围。
- 如果内存稳定上涨,要查是不是保留了完整大数组,同时 DOM 节点也长期占在页面上。
- 如果滚动掉帧,但点击按钮更新数据不卡,优先查真实 DOM 数量、布局抖动、滚动事件里是否触发了复杂计算。
- 如果首屏渲染就卡,先看接口返回数据量、数据拷贝方式、初始化时是否做了大量遍历。
这里不要急着看代码里的某个 API。先用 Performance 录一段操作,看 Long Task 出现在哪个时间段,再决定往哪一层排查,效率会高很多。
1.3 先动手测一轮:基准数据很重要
优化前一定要先量出基准数据。没有基线,后面改完你根本不知道优化有没有起作用。
我的测试方式比较朴素:造一个纯列表页面,分别渲染 1000、10000、50000 条数据,记录首次渲染耗时、点击某一行后的更新耗时、滚动帧率。测量时不要在内容中间穿插 console.log 同步打印大对象,这会影响结果。
import { ref, nextTick } from 'vue' const rows = ref([]) async function updateRows(newRows) { const start = performance.now() rows.value = newRows await nextTick() console.log('update flush cost(ms):', performance.now() - start) }用性能测试时有一个重要前提:必须用生产构建。开发模式下 Vue 本身有更多警告、检查逻辑,Proxy 数据也会增加额外开销,测试出来的数据不能代表最终线上表现。
如果一万条数据本身不卡,只是在业务项目中卡,那问题很可能是别的代码把单个任务的执行时间拖长了,而不是 Vue 列表 diff 的锅。
2. 响应式分形架构:为什么 Vue 3 的更新可以只动局部
我第一次听到“响应式分形架构”时也有点懵,因为这个词不像官方文档里的概念。但如果细看 Vue 3 的依赖收集机制,你会发现它天然会形成一种局部化、递归化的结构。理解这个结构,是理解大数组为什么要拆分列表项组件的关键。
2.1 组件依赖图是递归的,每一层都是同一套规则
Vue 的响应式系统不是“全局发一个事件,所有组件都去判断要不要更新”。它的工作方式更像是:
一个组件在 render 过程中读取了哪些响应式数据,它就会订阅哪些依赖。数据更新后,只有真正依赖它的 effect 才会被触发。
这个规则放在单个组件上成立,放在很多层组件嵌套的结构里也成立。无论组件树有多深,放大到任意一个节点看,它都是“局部读取、局部订阅、局部通知”。我把这个特点理解成分形架构:
- 整个组件树有一个大的依赖关系图。
- 单独看列表项组件,它又有自己的 item 级依赖。
- 继续放大到 item 里的某个字段,依赖规则不变。
这套结构让 Vue 有机会做到“只更新该更新的局部”,并不要求整棵树从头到尾重跑。
2.2 v-for 里的数组索引与 item 级依赖
大数组最容易出现的问题,是很多人把整段列表形态写得太粗。
看下面这个常见的低效写法:
<template> <div v-for="row in rows" :key="row.id"> {{ row.name }} {{ row.value }} </div> </template>如果rows是 2 万条,文件里又只有这一层组件,那么rows.value = newRows或任意一条 row 的字段变化,都会让这个组件重新执行整个 v-for diff。数据量大时,一次更新可能消耗几十毫秒甚至更多。
如果把每一行拆成独立的ListRow子组件,情况会不一样:
<template> <ListRow v-for="row in rows" :key="row.id" :row="row" /> </template>父组件只负责遍历,子组件内部才读取row.name和row.value。当某一行内部字段变化时,如果该行对应的 ListRow effect 订阅了这个字段,理论上只需要更新那一个子组件。这就是响应式局部更新的体现。
拆子组件并不是为了好看,而是为了让依赖收集范围从“整个列表”缩小到“单行”。
2.3 分形架构的真正价值:让可见范围决定刷新范围
实际开发中,大数据列表很难做到绝对精确的 item 级更新。原因是你往往无法控制每个列表项内部到底读取了什么,也不能保证数据更新方式足够规范。
但分形架构给了我们一个很好的优化方向:让组件的可见范围尽量和数据的变化范围一致。
如果一次数据变动只影响一行,那就希望只重渲染一行;如果数据变动是整体刷新,那无论怎么拆分,父组件这段遍历逻辑还是会重新执行。这也是为什么大数组不能只靠“拆组件”解决,还要配合虚拟滚动,让列表在视觉可见范围内永远只保留少量组件。当 DOM 子节点数量和响应式组件数量都降到几十个之后,diff 成本自然大幅下降。
其实在很多面试题里,“分形”并不是要求你讲数学概念,而是看你有没有理解 Vue 的局部订阅是怎么递归发生的。能解释清楚“每个组件维护自己的依赖集合,父组件和子组件的更新边界不同”,这道题就已经过了一半。
3. 调度器抢占与任务收敛:Vue 怎么处理高频连续更新
标题里提到“调度器抢占”,如果把它当成操作系统的抢占式调度,那理解会有偏差。Vue 没有时间片,也不会中断一个正在运行的 JS 函数。Vue 的调度器更像是一个“异步任务队列”,内部通过合并、去重和延后刷新,把连续多次数据修改收敛成最小次数的渲染。
3.1 修改数据后并不会立刻同步渲染
了解 Vue 的人都知道更新不是同步的。但你有没有想过,为什么 Vue 要把更新设计成异步?
假设一个组件里连续修改了三个响应式状态:
state.a = 1 state.b = 2 state.c = 3如果同步执行,每个状态赋值都触发一次渲染,那一个逻辑函数里改 10 个字段就会渲染 10 次。异步调度可以把这三次赋值收集到同一个任务队列,最后只执行一次组件 render。这是大数组性能表现的基础:如果没有调度器,哪怕数组只有几千条,频繁变更也会把页面拖垮。
3.2 队列去重批处理:不被夸大的“抢占”
如果追过 Vue 3 源码,会看到一个关键模块是 scheduler。核心函数包括queueJob、queuePostFlushCb、flushJobs。从函数名可以看到,它不是抢占式执行,而是队列式刷新。
整个模型大致是这样:
- 数据变化后,相关 effect 会被丢进一个队列。
- 同一个 job 如果已经存在,不会重复入队。
- Vue 会通过微任务触发一次统一的队列刷新。
- 刷新完成后,如果队列里又来了新任务,再继续处理。
所以“抢占”更准确的说法是“任务收敛”和“去重”。在一次 tick 内,后面数据变化产生的旧渲染任务,会被合并成同一个待执行任务。你没有必要为每个字段变化跑一次完整 diff。
这个机制对 watch 也很重要。如果你在 watch 里监听一个深层大数组,并且回调里又去更新别的数据,很可能因为循环触发造成无谓的队列抖动。使用时要考虑数据变化频率,必要时做防抖。
3.3 flush 时机和 nextTick 判断
Vue 3 中 effect 可以配置不同的 flush 时机,常见的是:
pre:组件更新前执行,watch 默认接近这个时机。- 默认:普通的 render effect 会跟着调度刷新。
post:组件更新后再执行。
实际业务里,当我们要在数据变化后读取最新 DOM,经常用nextTick。例如统计这次更新耗时:
async function onAppend() { rows.value = nextRows await nextTick() // 此时 DOM 和组件更新已经完成 }用nextTick做测量是一种比较直观的性能验证方法。但它只能告诉你 flush 总耗时,不能告诉你卡顿到底发生在 render、diff 还是浏览器绘制阶段。所以要配合 Performance 面板再往下看一层。
3.4 调度器不能替你解决的问题
调度器可以减少渲染次数,但没有办法把一次超大范围的 render 变成小范围。
假设你有一个 10 万条数据的数组,直接赋值为新数组,调度器合并后确实只跑了一次更新,但这一次更新里仍然要对 10 万个 vnode 做遍历和 diff。如果此时 DOM 节点也是 10 万个,浏览器还会在后面对 10 万节点做布局和绘制。这种量级的单次大任务,无论队列怎么合并,体验都不会好。
所以调度器优化的价值在产品里主要体现在:
- 高频数据回调下,避免重复渲染。
- 同一事件里修改多个字段,合并成一次更新。
- watch 和 computed 的执行顺序更可控。
- 配合分片任务,减少单次长任务阻塞。
只要 DOM 数量和渲染范围不下降,调度器只能延缓问题,不能消灭问题。要把大数组真正做流畅,下一层的虚拟滚动才是关键。
4. 第一层优化:把超长数组挡在 DOM 之外
如果页面只需要显示视口范围内的几十行,那就没必要让浏览器真正渲染 10 万个 DOM 节点。虚拟滚动的价值不是让 Vue diff 更快,而是从根上减少真实 DOM 数量,把上万条数据渲染降成几十条渲染。
4.1 目标不是让 Vue 更快,而是让浏览器不渲染那么多节点
许多前端性能优化的常规思路,是“让框架少算一点”。虚拟滚动则是“让页面少一点真实 DOM”。
例如一个 2 万行的日志列表,如果全量渲染,哪怕每行就是一个<div>,页面也可能会有 2 万个节点。这些节点的创建、更新、查找、布局,都会随着数据量增加线性上升。浏览器的 layout 阶段处理很复杂的大 DOM 树时,会出现明显掉帧。
虚拟列表的思路是:不渲染全部行,只渲染可视区域内的行。滚动时通过 scrollTop 计算当前可见的起始行和结束行,再动态更新渲染片段。这样无论总数据是 1 万还是 10 万,页面真实存在的节点数都只和一个视口高度相关。
4.2 一个最小虚拟列表的落地结构
下面是一个按固定行高实现的简单示例,足够说明结构,不涉及复杂的高度测量:
<script setup> import { ref, shallowRef, computed } from 'vue' const rowHeight = 40 const viewportHeight = 600 const overscan = 10 const rows = shallowRef([]) const range = shallowRef({ start: 0, end: 20 }) const visibleRows = computed(() => rows.value.slice(range.value.start, range.value.end) ) const paddingTop = computed(() => range.value.start * rowHeight) const paddingBottom = computed(() => Math.max(0, (rows.value.length - range.value.end) * rowHeight) ) function updateRange(scrollTop) { const total = rows.value.length const start = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan) const end = Math.min(total, Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscan) range.value = { start, end } } </script> <template> <div class="viewport" style="height: 600px; overflow-y: auto;" @scroll="updateRange($event.target.scrollTop)" > <div :style="{ paddingTop: paddingTop + 'px', paddingBottom: paddingBottom + 'px' }" > <ListRow v-for="row in visibleRows" :key="row.id" :row="row" /> </div> </div> </template>核心就是三个值:
start:当前可见区域从哪一行开始。end:当前可见区域在哪一行结束。overscan:多渲染一些额外行,让快速滚动时不会出现空白。
这份代码里我用shallowRef存整个数组和 range,因为虚拟列表滚动时只需要整体替换数组或 range,不需要对每个内部字段做深度响应式代理。数据量越大,这种浅层处理带来的收益越明显。
4.3 动态高度、滚动位置恢复与 overscan
固定行高是最容易实现的情况,但真实场景里列表项高度往往会变化。比如日志消息可以换行,评论内容长度不一致,表格行可能在展开后变高。
动态高度一般有三种处理思路:
- 统一设置一个预估高度,配合滚动位置估算,必要时用真实高度修正。
- 渲染可见项后,用 ResizeObserver 监听每项高度,缓存高度,再重算总高度。
- 如果高度差异巨大,也可以考虑把列表改成“分页加载 + 用户按需展开”的方案。
如果你要做到无限滚动下拉加载,还要考虑数据还没到齐时,总高度如何估算。如果用户在快速滚动过程中,后端接口还没返回下一批数据,通常需要显示 loading 占位行,避免滚动条跳动。
“滚动位置恢复”是另一个容易被忽略的问题。如果用户在详情页切走再切回来,之前滚动到第 5000 行的位置不能简单丢弃。这里要保存 scrollTop,或者保存顶部的 key,用scrollTo恢复。千万不要在数据刷新后自动把用户带回列表顶部。
5. 第二层优化:缩小响应式系统的感知范围
虚拟滚动解决的是 DOM 数量问题,响应式系统的感知范围则是另一个独立开销。哪怕你只渲染 50 行,如果数据源里有一个 5 万条的大数组被深度代理,某些操作仍然会很慢。所以需要让 Vue 尽可能少地对不必要的数据做深层响应式转化。
5.1 shallowRef 用整体替换换掉深度代理
普通ref在赋值一个对象或数组时,会把这个值继续转换成深层响应式。对大数组场景来说,深层代理本身有两部分成本:
- 初始化时需要一层层访问并代理内部对象。
- 每次对象属性被访问,都先经过 Proxy 的 get 拦截,尽管每次拦截时间很短,但数量大了以后总成本不可忽略。
shallowRef的效果是只追踪.value这一层的变化。你给ref.value赋一个新数组,它能够触发更新;但数组内部的对象属性变化,不会自动触发依赖该数组的 effect。
使用时要遵守一条原则:用数据替换代替就地修改。
import { shallowRef } from 'vue' const rows = shallowRef([]) function pushRows(nextRows) { rows.value = rows.value.concat(nextRows) }如果写rows.value.push(item),在shallowRef下不会触发依赖更新。这一点是最容易踩的坑。决定用浅层语义之前,先想清楚项目里的数据到底是通过“整体替换”更新,还是通过“修改某一行字段”更新。如果大量操作是就地修改某个 row 的属性,那直接上 shallowRef 反而会出现数据变了但视图不更新的问题。
5.2 markRaw 和固定静态数据
markRaw用来标记一个对象,让 Vue 永远不会对它做响应式代理。典型场景是:引入一个第三方地图实例、图表实例,或者一个体积很大但结构固定的配置对象。
用在大数组上,常见做法是:如果某条数据只用于展示一次,并且后续不会再发生变化,可以把它标记为 raw,减少依赖追踪开销。
import { markRaw } from 'vue' const staticRows = rows.map(row => markRaw(row)) rows.value = staticRows使用 markRaw 时要想清楚,这不是简单的性能开关。跳过响应式代理后,后续如果某个属性变化,组件不会接收到通知。它适合“写入后不再改”的数据,不适合频繁编辑的行数据。
5.3 模板内过滤、watch 深度遍历要注意
大数组场景里,模板内直接写复杂函数调用经常成为性能黑洞。
<!-- 不推荐 --> <div v-for="row in filterRows(rows, keyword)" :key="row.id"> {{ row.message }} </div>每次组件更新,filterRows都会执行。即使