阅读地图:本文用一个真实翻车案例,带你从"数据 → 渲染 → 模块 → 框架 → 兜底"逐层拆解大屏性能优化。全程 Vue3 + Vite,代码可直接抄。
先说个真实的翻车现场
上线前夜,我信心满满打开大屏:白屏 8 秒,风扇狂转,鼠标都拖不动。 产品站我背后,脸比屏幕还黑。
原因很蠢——我把后端返回的 2 万条数据直接 reactive 一把梭,十几个模块同一帧里同时 mount,主线程直接被干爆。
那一刻我才明白:大屏不卡,从来不是一个"用虚拟列表"能解决的事。它是一道分层工程题。
下面这套方案,就是那晚之后我沉淀下来的。现在大屏首屏稳定在 1 秒内。
核心心法:卡顿只有三类,解法只有一句话
让主线程每时每刻都有空。
具体卡在哪?翻来覆去就三个"反派 Boss":
| 反派 | 真身 | 表现 |
|---|---|---|
| Boss 1 长任务怪 | JS 单次执行 > 50ms | 掉帧、交互无响应 |
| Boss 2 DOM 潮 | 节点数过万、频繁重排 | 布局计算爆炸 |
| Boss 3 响应式幽灵 | 海量数据被做成深层响应式 | 依赖收集拖垮更新 |
没度量就别优化。 第一步永远是 DevTools → Performance 录制,看 Long Task 在哪、Nodes 多少、JS Heap 涨不涨。
下面逐层开打。
第一层:数据层 —— 从源头少拿、慢拿、按需拿
终极真相:最好的渲染,是不用渲染。 数据没来,CPU 就闲着。
1. 分页 / 分片,首屏只拿"看得见的"
大屏很多列表其实只展示 Top N(排行榜前 50、告警前 20)。让后端分页,首屏只拉第一页,剩下的滚动到可视区再加载。别一上来把整张表搬回家。
2. 增量更新,拒绝全量轮询
大屏要实时,但错误做法是每 3 秒把整个模块拉回来重渲染。正确姿势:
- WebSocket / SSE 推增量 diff,前端按
id去重合并; - 数据层做 throttle,高频推送合并成 1 秒一次渲染,别一秒重渲染十几次。
// 增量合并:按 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 直接喂图表;传输层开启 brotli,差距是数量级的。
第二层:渲染层 —— 少画 DOM,只画该画的
记住:屏幕只有这么大,你却渲染了一万行,纯属浪费。
1. 虚拟列表(最常用武器)
可视区外的一律不渲染。手写一个超简版,原理一眼懂:
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、vxe-table。
2. 时间切片:把一次渲染拆成多帧
数据必须一次性渲染时,用 requestAnimationFrame 拆成小批,每帧交还主线程控制权:
async function renderInChunks(data: Item[], chunkSize = 200) {
for (let i = 0; i < data.length; i += chunkSize) {
renderChunk(data.slice(i, i + chunkSize)) // 每帧只画 200 条
await nextFrame() // 交还主线程,页面不卡
}
}
const nextFrame = () => new Promise<void>(r => requestAnimationFrame(() => r()))
就算 1 万条,每帧 200 条,主线程始终能喘气。
3. 重武器:Canvas / WebGL
散点图几万个点、关系图上千节点,DOM 必死。上 Canvas(vxe-table canvas 模式、canvas-datagrid)或 WebGL(ECharts-GL、deck.gl)。图表开 large + progressive:
series: [{
type: 'scatter',
large: true,
largeThreshold: 2000,
progressive: 2000, // 每帧渐进渲染点数
data,
}]
第三层:模块层 —— 分而治之,错峰执行
大屏卡死的头号根因:十几个模块在同一帧里同时 mount + 渲染。 像早高峰地铁,全挤一列车。
1. 懒加载非关键模块
底部次要榜单用 defineAsyncComponent 懒加载,配合 IntersectionObserver 进视口再渲染:
import { defineAsyncComponent } from 'vue'
const BottomRank = defineAsyncComponent(() => import('./BottomRank.vue'))
2. 首屏优先 + 空闲调度
用 requestIdleCallback 在浏览器空闲时再渲染非关键模块,首屏第一时间先出来:
const whenIdle = (cb: () => void) =>
'requestIdleCallback' in window ? requestIdleCallback(cb) : setTimeout(cb, 1)
3. 把重活丢给 Web Worker
排序、聚合、格式化、地图坐标转换……纯计算全进 Web 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 原生支持这种写法,零配置。
4. 缓存已渲染模块
只是切换显隐的模块,用 <keep-alive> 包起来,避免反复 mount/unmount 的重复开销。
第四层:Vue3 专项 —— 最容易被忽略、性价比最高
那晚 8 秒白屏,一半就是死在这一层。
1. 海量数据用 shallowRef / shallowReactive
Vue 默认把 ref/reactive 做成深层响应式,2 万条 = 2 万个 Proxy + 2 万条依赖追踪,纯浪费。只关心"整体替换"时用 shallowRef:
import { shallowRef } from 'vue'
const tableData = shallowRef<Item[]>([]) // 不再逐条代理
tableData.value = await fetchList() // 整体替换即可
2. 不变的数据:Object.freeze / markRaw
静态配置、字典表用 Object.freeze;ECharts / 地图实例用 markRaw,避免被 Vue 代理拖慢:
import { markRaw } from 'vue'
const chart = markRaw(echarts.init(dom)) // 跳过响应式
3. v-memo 跳过静态行
列表项只依赖固定字段时,v-memo 告诉 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,渲染一次后不再追踪; - 大对象拆成多个
ref,别整坨丢进reactive; - 派生数据走
computed(自带缓存),别用methods; key用稳定唯一值,别用 index(index 会让整列错位重渲染)。
第五层:兜底与监控 —— 优化也要有 Plan B
再好的方案也要防极端:
- 降级:数据 > 5000 行自动切虚拟列表/Canvas;点数超阈值自动降采样;
- 骨架屏 + 分块动画:让用户感觉"在加载",而不是"卡死了";
- 性能预算:
PerformanceObserver监控长任务,超 50ms 上报,定位回归:
new PerformanceObserver(list => {
for (const e of list.getEntries())
if (e.duration > 50) console.warn('长任务:', e.duration, e.name)
}).observe({ entryTypes: ['longtask'] })
一张图记住全部(面试/复盘直接用)
| 层级 | 核心手段 |
|---|---|
| 数据层 | 分页分片、增量更新、节流去重、二进制压缩 |
| 渲染层 | 虚拟列表、时间切片、Canvas/WebGL、图表 large |
| 模块层 | 懒加载、idle 调度、Web Worker、keep-alive |
| 框架层(Vue) | shallowRef、markRaw、v-memo、v-once、冻结数据 |
| 兜底层 | 降级、骨架屏、性能监控 |
一句话收尾:少拿数据、少画 DOM、把重活挪走、错峰执行——让主线程永远有空,大屏就永远不会卡。
写在最后
这道题表面问"不卡顿",实际考的是你对浏览器渲染机制、JS 单线程、响应式原理、性能工具的综合理解。背八股没用,把你踩过的坑讲出来,面试官才会眼前一亮。
如果你也在做大屏(尤其 Vue3 + Vite),上面的方案基本都能直接落地。
如果这篇帮你避了一个坑,点个赞 / 收藏,评论区告诉我你大屏卡在哪个 Boss? 我挑典型的在评论区拆解。
本文配套 demo 来自
vue-screen大屏项目,完整优化清单见仓库 gitee.com/xcxsj/vue-s…