前阵子面试被问到一道题,感觉很有代表性,然后写了一个 vue-screen 大屏demo。题目大意是:
一个大屏页面里有好几个模块(图表、表格、排行榜、地图、滚动列表......),每个模块都要渲染很多数据。一进来如果所有模块同时渲染,页面直接卡死、白屏好几秒。这种情况下,怎么保证不卡顿?
很多人一上来就背"用虚拟列表"、"webwork"等。但是面试官想听的是我的一套分层的优化思路:从数据来源、到渲染策略、到模块调度、再到框架特性,层层拆解。下面把我的回答整理成一篇能直接用的方案。
一、先别急着优化,搞清楚"卡"在哪
卡顿的本质几乎只有三类:
- 主线程被长任务占满:一次性计算/序列化几万条数据,JS 执行超过 50ms,浏览器来不及渲染,掉帧。
- DOM 过多 / 频繁重排(reflow):每个模块几百行 DOM,十几个模块一万个节点,布局计算爆炸。
- 响应式开销过大:Vue 把上万条数据全部做成深层响应式,每次数据变动触发大量依赖收集与更新。
所以第一步一定是定位 :打开 DevTools → Performance 录制 → 看 Long Task 在哪、看 Nodes 数量、看 JS Heap。没有度量,就不要优化。
二、数据层:从源头减负
不要等数据到了才开始优化,要在拿到数据之前就少拿、慢拿、按需拿。
1. 分页 / 分片拉取,首屏只拿"看得见的"
大屏很多列表其实只展示 Top N(比如排行榜前 50、告警前 20)。让后端分页,首屏只拉第一页,剩下的滚动到可视区再加载。
2. 增量更新,别全量轮询
大屏普遍要实时。错误做法:每 3 秒把整个模块的数据全量拉回来重新渲染。正确做法:
- 用 WebSocket / SSE 推送增量 diff (新增/更新/删除的条目),前端做
id去重合并。 - 数据层做 throttle:高频推送合并成 1 秒一次渲染,避免一秒重渲染十几次。
ts
// 简单的增量合并:按 id 去重更新
function mergeById(prev: Item[], incoming: Item[]): Item[] {
const map = new Map(prev.map(i => [i.id, i]))
for (const item of incoming) map.set(item.id, { ...map.get(item.id), ...item })
return [...map.values()]
}
3. 大数据走二进制 / 压缩
上万条数据用 JSON 体积大、解析慢。可以考虑:
- 后端返回 类型化数组(TypedArray)/ 二进制 ,前端用
ArrayBuffer直接喂给图表。 - 开启
gzip/brotli,传输快很多。
三、渲染层:少画 DOM,只画该画的
1. 虚拟列表 / 虚拟滚动(最常用武器)
长列表、滚动榜单,永远只渲染可视区的那几十行。手写一个超简版理解原理:
ts
const itemHeight = 40
const containerHeight = 600
const visibleCount = Math.ceil(containerHeight / itemHeight) + 2 // 多缓冲两行
function getVisibleList(scrollTop: number, data: Item[]) {
const start = Math.floor(scrollTop / itemHeight)
const end = start + visibleCount
return {
offsetY: start * itemHeight,
list: data.slice(start, end),
}
}
实际项目直接用成熟库:vue-virtual-scroller、el-table-v2(Element Plus 虚拟表格)、或 vxe-table。
2. 时间切片:把一次渲染拆成多帧
如果数据必须一次性渲染(比如一个超长表格没法分页),用 requestAnimationFrame 把渲染拆成小批:
ts
async function renderInChunks(data: Item[], chunkSize = 200) {
for (let i = 0; i < data.length; i += chunkSize) {
const chunk = data.slice(i, i + chunkSize)
renderChunk(chunk) // 渲染这一批 DOM
await nextFrame() // 把控制权交还主线程,保证页面不卡
}
}
function nextFrame(): Promise<void> {
return new Promise(res => requestAnimationFrame(() => res()))
}
这样即使渲染 1 万条,每帧只处理 200 条,主线程始终有空响应交互。
3. 重武器:Canvas / WebGL / 图表增量渲染
当 DOM 真的扛不住时(如散点图几万个点、关系图上千节点):
- 表格考虑 Canvas 渲染 (
vxe-table的 canvas 模式、canvas-datagrid)。 - 图表用 ECharts 的
large: true/progressive渐进渲染 模式,或 WebGL 渲染(如 ECharts-GL、deck.gl)。 - 静态部分(标题、边框、装饰)提前用
Object.freeze或预渲染成图片,避免重复计算。
ts
// ECharts 大数据散点:开启 large + 渐进渲染
series: [{
type: 'scatter',
large: true,
largeThreshold: 2000,
progressive: 2000, // 每帧渐进渲染的点数
data,
}]
四、模块层:分而治之,错峰执行
大屏"卡死"最常见的根因是:十几个模块在同一帧里同时 mount + 渲染。解决思路是调度。
1. 组件懒加载,按需挂载
非首屏关键模块(比如底部次要榜单)用 defineAsyncComponent 懒加载,配合 IntersectionObserver 进入视口再渲染。
ts
import { defineAsyncComponent } from 'vue'
const BottomRank = defineAsyncComponent(() => import('./BottomRank.vue'))
2. 首屏优先 + 空闲调度
用 requestIdleCallback(兼容降级用 setTimeout)在浏览器空闲时才渲染非关键模块,保证首屏第一时间出来:
ts
const whenIdle = (cb: () => void) =>
'requestIdleCallback' in window
? requestIdleCallback(cb)
: setTimeout(cb, 1)
3. 数据计算丢给 Web Worker
列表排序、聚合、格式化、地图坐标转换......这些纯计算完全可以放进 Web Worker,主线程只负责渲染,彻底不阻塞。
ts
// worker 计算聚合,主线程只拿结果渲染
const worker = new Worker(new URL('./aggregate.worker.ts', import.meta.url), { type: 'module' })
worker.postMessage(rawData)
worker.onmessage = e => render(e.data)
Vite 原生支持 new Worker(new URL(...), { type: 'module' }),无需额外配置。
4. 缓存已渲染模块
已经渲染过、只是切换显隐的模块,用 <keep-alive> 包起来,避免反复 mount/unmount 造成的重复开销。
五、Vue3 专项优化(结合 vue-screen 项目)
这是最容易被忽略、性价比最高的一层。
1. 海量数据用 shallowRef / shallowReactive
Vue 默认把 ref/reactive 做成深层响应式 ,上万条数据 = 上万个 Proxy + 上万个依赖追踪,纯属浪费。只关心"整体替换"时用 shallowRef:
ts
import { shallowRef } from 'vue'
// 一万条数据整体替换,不需要深层响应
const tableData = shallowRef<Item[]>([])
tableData.value = await fetchList() // 直接替换,不会逐条做响应式
2. 永不变化的数据用 Object.freeze / markRaw
静态配置、字典、常量表,Object.freeze 后 Vue 会跳过响应式处理;第三方实例(ECharts 实例、地图实例)用 markRaw 标记,避免被代理拖慢:
ts
import { markRaw } from 'vue'
const chart = markRaw(echarts.init(dom)) // 不再被 Vue 代理
3. v-memo 跳过静态列表重渲染
列表项如果依赖少量固定字段,用 v-memo 告诉 Vue"这些没变就别重渲染这行":
vue
<div v-for="item in list" :key="item.id" v-memo="[item.id, item.value]">
{{ item.name }} - {{ item.value }}
</div>
4. v-once 一次性渲染
纯静态、永不更新的内容(标题、图例说明)用 v-once,渲染一次后 Vue 不再追踪。
5. 控制响应式粒度
- 大对象不要整个丢进
reactive,拆成多个ref。 - 计算属性
computed天然有缓存,复杂派生数据走computed而非methods。 key用稳定唯一值,别用 index(index 会导致整列错位重渲染)。
六、兜底与监控:优化也要有 Plan B
再好的方案也要防极端情况:
- 降级策略:数据量超过阈值(如 > 5000 行)自动切换为虚拟列表/Canvas;图表点数超阈值自动降采样(抽稀)。
- 骨架屏 + 分块加载动画:让用户感觉"在加载"而不是"卡死"。
- 性能预算 :用
PerformanceObserver监控长任务,超 50ms 上报,方便定位回归。
ts
new PerformanceObserver(list => {
for (const entry of list.getEntries())
if (entry.duration > 50) console.warn('长任务:', entry.duration, entry.name)
}).observe({ entryTypes: ['longtask'] })
七、总结:一张"优化地图"
把上面串起来,面试时就能给出一套完整回答:
| 层级 | 核心手段 |
|---|---|
| 数据层 | 分页/分片、增量更新、节流去重、二进制压缩 |
| 渲染层 | 虚拟列表、时间切片、Canvas/WebGL、图表 large 模式 |
| 模块层 | 懒加载、idle 调度、Web Worker、keep-alive |
| 框架层(Vue) | shallowRef、markRaw、v-memo、v-once、冻结数据 |
| 兜底层 | 降级、骨架屏、性能监控 |
记住一句话:卡顿不是靠一个"虚拟列表"解决的,而是靠"让主线程每时每刻都有空"------少拿数据、少画 DOM、把重活挪走、错峰执行。
写在最后
这道题表面问"不卡顿",实际考察的是你对浏览器渲染机制、JS 单线程、响应式原理、性能排查工具的综合理解。背八股没用,结合自己项目踩过的坑讲出来,面试官才会眼前一亮。
如果你们的大屏也用 Vue3 + Vite,上面这些方案基本都能直接落地。有问题欢迎评论区交流~
本文配套 demo 来自
vue-screen大屏项目,完整优化清单见仓库 gitee.com/xcxsj/vue-s...