面试官:大屏项目里十几个模块同时渲染上万条数据,怎么做到不卡?

前阵子面试被问到一道题,感觉很有代表性,然后写了一个 vue-screen 大屏demo。题目大意是:

一个大屏页面里有好几个模块(图表、表格、排行榜、地图、滚动列表......),每个模块都要渲染很多数据。一进来如果所有模块同时渲染,页面直接卡死、白屏好几秒。这种情况下,怎么保证不卡顿?

很多人一上来就背"用虚拟列表"、"webwork"等。但是面试官想听的是我的一套分层的优化思路:从数据来源、到渲染策略、到模块调度、再到框架特性,层层拆解。下面把我的回答整理成一篇能直接用的方案。


一、先别急着优化,搞清楚"卡"在哪

卡顿的本质几乎只有三类:

  1. 主线程被长任务占满:一次性计算/序列化几万条数据,JS 执行超过 50ms,浏览器来不及渲染,掉帧。
  2. DOM 过多 / 频繁重排(reflow):每个模块几百行 DOM,十几个模块一万个节点,布局计算爆炸。
  3. 响应式开销过大: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...

相关推荐
前端市界1 小时前
Pro React Admin: 统一工程底座,承载多种应用...
前端·github
葡萄城技术团队1 小时前
dsh-spreadjs-editor 当前支持什么:安装前需要了解的几件事
前端
三*一2 小时前
优化修整算法
前端·javascript·算法
weifont3 小时前
自动化抓取和上架商品:能不动手,就少动手
运维·前端·自动化
米柆3 小时前
HTML5 audio /video 完整事件监听模板
前端·html·html5
IT_陈寒3 小时前
Vite热更新失效?你可能也踩了这个坑
前端·人工智能·后端
程序员大阳4 小时前
副队长CSS教程(30)–CSS Grid 实战:实现复杂自适应表单布局
前端·css·自适应·grid·表单
Csvn4 小时前
微前端实现
前端
Chrischaan5 小时前
做 AI 塔罗小程序时,我为什么不让大模型自己抽牌:一次上线复盘
前端