🎬 面试开场怎么引出这个话题
「我在比亚迪做了两年的业务数据可视化大屏,覆盖销售概览、区域分布、实时监控这些模块。印象最深的一个坑是 — 我们大屏上同时挂了 14 个 ECharts 实例,一开始我用 ref 把图表实例存起来,结果切换大屏 Tab 的时候页面直接卡死了 3 秒。后来定位到是 Vue 的响应式系统把整个 ECharts 实例(里面有几千个属性、Canvas、ZRender 对象)全劫持了一遍,改用 shallowRef 后秒切。这个坑我觉得挺值得聊聊的。」
✅ 这段话同时抛出了 3 个可深挖的点:① 14 个实例的规模 ② 响应式劫持原理 ③ shallowRef 解决方案。
面试官通常会顺着「为什么劫持会卡」往下问,你就顺势展开。
1

核心坑:Vue 响应式劫持了 ECharts 实例

⚠️ 踩坑现场

现象: 14 个图表的大屏,切换 Tab(隐藏/显示部分图表)时整个页面卡顿 2-3 秒,控制台疯狂报性能警告。切回来又卡一次。

第一反应: 以为是图表太多、重绘太慢。用了 dispose() + 重新 init(),反而更慢。

真实根因:const chart = ref(echarts.init(dom)) 存实例,Vue3 的 ref 会对传入对象做 深度响应式递归遍历(reactive)。而 ECharts 实例内部是一个巨型对象树 —— ZRender、Canvas、series、data、每个数据点都是响应式对象。Vue 给每个属性都装上 Proxy,初始化时卡,后续每次 setOption 触发更新,响应式系统还要 diff 这个巨大的对象树,直接卡死。
🧠 类比理解
把 ECharts 实例想象成一个 装满几万本书的图书馆
ref 包它 = 给图书馆里 每一本书都装一个防盗感应器,有任何一本书被移动,系统都要全馆扫描一遍。
shallowRef 包它 = 只在图书馆大门装一个感应器,只关心"换了一家图书馆",不管里面的书。
ECharts 实例本来就是你一个人管的(通过 setOption),根本不需要 Vue 帮你监听它内部的每个属性。
❌ 错误写法(会卡死)
// ❌ ref 会深度劫持整个 ECharts 实例
import { ref, onMounted } from 'vue'

const chart = ref(null)  // 这里埋雷了

onMounted(() => {
  chart.value = echarts.init(dom)  // init 返回的实例被深度 reactive 化
  chart.value.setOption({...})
})

// 后续每次 chart.value.setOption(),Vue 都会去 diff 整个实例树
✅ 正确写法(shallowRef)
// ✅ shallowRef 只对 .value 本身响应式,不递归内部
import { shallowRef, onMounted, onBeforeUnmount } from 'vue'

const chart = shallowRef(null)  // 关键改动

onMounted(() => {
  chart.value = echarts.init(dom)
  chart.value.setOption({...})
})

onBeforeUnmount(() => {
  chart.value?.dispose()  // 别忘了销毁,防内存泄漏
  chart.value = null
})
💡 同类的"必须 shallowRef / markRaw"的对象: ① ECharts 实例 ② Map / GL 地图实例(高德/百度/Leaflet) ③ 编辑器实例(TipTap/WangEditor/Monaco) ④ WebSocket 连接对象 ⑤ 第三方 class 实例(各种 SDK) ⑥ 大型不可变数据(Immutable.js)。
一句话规律: 凡是「你自己通过 new / init 创建、内部有自己的更新机制、不需要 Vue 监听它内部变化」的对象,都用 shallowRef。
2

原理深挖:ref vs shallowRef vs markRaw

面试官如果问「那 shallowRef 到底和 ref 有什么本质区别?」要能答出来:

ref(对象)

  • .value 做响应式
  • 如果 value 是对象,会调 reactive() 深度递归
  • 每一层属性都变成 Proxy
  • 修改内部任意属性都能触发更新
  • 代价: 大对象初始化和更新都慢

shallowRef(对象)

  • 只对 .value 整体替换做响应式
  • 不递归,内部属性是原始对象
  • 不装 Proxy,性能好
  • 改内部属性 不触发 更新
  • 必须整体 chart.value = xxx 才触发
🔑 补充:markRaw 的作用

如果某些场景你已经用 ref 存了一个列表,但列表里又混着不该响应式的对象(比如表格行数据里嵌了 ECharts 实例),可以用 markRaw 永久标记"这个对象别响应式"。

import { markRaw } from 'vue'

// 在数据进响应式之前,先标记
const instance = markRaw(echarts.init(dom))

// 区别:
// shallowRef = 容器是浅响应(治标,改容器层)
// markRaw    = 把对象本身标记为"永不响应式"(治本,标记对象层)
// 大屏场景两者都行,shallowRef 更常用因为更直观
⚠️ 易翻车点: 用了 shallowRef 后,如果你写了 chart.value.setOption(...) 期望视图更新某个 依赖 chart 的计算属性,它是不会触发的!因为 shallowRef 只监听 .value 的整体替换。需要的话手动 triggerRef(chart) 或直接重新赋值。
3

4K 大屏适配:scale 方案的"黑边"问题

📐 我们用的 scale 等比缩放方案
// 设计稿固定 1920×1080,运行时按屏幕等比缩放
const designWidth = 1920
const designHeight = 1080

function scaleScreen() {
  const scaleX = window.innerWidth / designWidth
  const scaleY = window.innerHeight / designHeight
  const scale = Math.min(scaleX, scaleY)  // 取小值,保证不变形

  const screen = document.getElementById('screen')
  screen.style.transform = `scale(${scale})`
  screen.style.transformOrigin = 'left top'

  // 居中,消除黑边
  const left = (window.innerWidth - designWidth * scale) / 2
  const top = (window.innerHeight - designHeight * scale) / 2
  screen.style.position = 'absolute'
  screen.style.left = left + 'px'
  screen.style.top = top + 'px'
}

window.addEventListener('resize', scaleScreen)
⚠️ 4K(3840×2160)的特殊坑

现象: 同样代码在 1080P 正常,接到 4K 电视上,虽然 scale 算对了,但出现了几个问题:

  • 字体模糊: scale 把 1080P 的渲染结果放大 2 倍,文字变虚。解决: 用 rem 方案配合(devicePixelRatio)或在 4K 下切换设计稿基准。
  • 鼠标事件偏移: scale 后,DOM 的点击坐标和实际像素坐标不一致,如果大屏里有交互(比如地图点击),e.clientX 要除以 scale。解决: 统一封装 getRealPos(e)
  • ECharts 内部交互错位: 鼠标移到 A 点,tooltip 跑到 B 点。解决: ECharts 实例所在的 DOM 不要单独再 scale,跟随父容器走;或 init 时传 { devicePixelRatio: window.devicePixelRatio }
  • 性能下降: 4K 下 Canvas 像素是 1080P 的 4 倍,14 个图表同时重绘明显掉帧。解决: 见下一节。

scale 方案(我们选的)

  • ✅ 像素级还原设计稿
  • ✅ 改动最小,一个 transform 搞定
  • ✅ 16:9 标准大屏稳定
  • ❌ 4K 放大有模糊(可缓解)
  • ❌ 非标准比例有黑边

rem / vw 方案(备选)

  • ✅ 4K 下字体清晰(原生渲染)
  • ✅ 无黑边
  • ❌ 所有尺寸要换算 rem
  • ❌ ECharts 字号要单独动态算
  • ❌ 工作量大,设计稿要重做
回答话术:「我们最终选 scale,因为比亚迪的大屏都是标准 4K/1080P 电视,16:9 等比缩放最稳定。4K 字体模糊的问题,我们通过设置 devicePixelRatio 和把 ECharts 的 fontSize 在 4K 下乘以 2 来缓解。如果是异形屏或多分辨率混合,会考虑 rem 方案。」
4

14 个图表的性能:不要全量 setOption

🎯 大屏性能优化 5 招(都实战用过)
优化点做法收益
1. 实例管理用 Map 统一存 14 个实例,避免重复 init,切 Tab 时只 dispose 不显示的切 Tab 卡顿 3s → 200ms
2. 增量更新setOption(opt, { notMerge: false }) 只更新变化数据,不重绘整个图更新耗时降 60%
3. 懒初始化IntersectionObserver,图表进可视区才 init(大屏滚动时)首屏快 40%
4. large 模式折线/散点开 large: true,Canvas 批量绘制万级数据点不卡
5. 降采样折线超 500 点前端降采样(取峰谷),不渲染原始秒级数据渲染点数减 90%
📋 实例统一管理器(核心代码)
// ChartManager:统一管理大屏所有图表实例
class ChartManager {
  constructor() {
    this.instances = new Map()  // 用 Map,不用响应式
  }

  // 注册:dom + option,返回实例
  register(key, dom, option) {
    if (this.instances.has(key)) return this.instances.get(key)
    const chart = echarts.init(dom, 'dark', {
      devicePixelRatio: window.devicePixelRatio,
      renderer: 'canvas'
    })
    chart.setOption(option)
    this.instances.set(key, chart)
    return chart
  }

  // 增量更新:只改数据,不重绘整个图
  update(key, option) {
    const chart = this.instances.get(key)
    chart?.setOption(option, { notMerge: false })  // 增量
  }

  // 销毁单个(切 Tab 时)
  dispose(key) {
    this.instances.get(key)?.dispose()
    this.instances.delete(key)
  }

  // 销毁全部(页面卸载时)
  disposeAll() {
    this.instances.forEach(c => c?.dispose())
    this.instances.clear()
  }
}

// 关键:这个 manager 本身用普通变量,不进响应式
// 组件里用 shallowRef 持有它即可
加分回答:「我们大屏是 7×24 不间断运行的,长时间运行内存会涨。除了 dispose,我还做了 每 24 小时凌晨自动 location.reload(),以及 ECharts 的 clear() 定期清理缓存。监控用 Performance Observer 观察长任务。」
5

WebSocket 实时推送:断线重连 + 心跳

⚠️ 真实事故:大屏半夜"死"了

现象: 大屏白天正常,第二天早上来发现数据停在昨晚某个时间点,但页面没报错,WebSocket 连接也没断开提示。

根因: 网络中间有 NAT/防火墙,空闲连接超过一定时间会被静默断开,但前端 onclose 不一定触发(半开连接)。前端以为还连着,实际服务端早断了。

解决方案: 心跳机制 — 前端每 30 秒发一个 ping,如果 10 秒内没收到 pong,主动 close 并重连。配合指数退避重连。
💓 带心跳 + 重连的 WebSocket 封装
class WSocket {
  constructor(url) {
    this.url = url
    this.ws = null
    this.heartbeatTimer = null
    this.pongTimer = null
    this.reconnectCount = 0
    this.forceClose = false  // 区分主动关闭还是异常关闭
  }

  connect() {
    this.ws = new WebSocket(this.url)

    this.ws.onopen = () => {
      this.reconnectCount = 0  // 重置重连次数
      this.startHeartbeat()
    }

    this.ws.onmessage = (e) => {
      const data = JSON.parse(e.data)
      if (data.type === 'pong') {
        clearTimeout(this.pongTimer)
        return
      }
      // 业务数据处理...
      this.onData(data)
    }

    this.ws.onclose = () => {
      this.stopHeartbeat()
      if (!this.forceClose) this.reconnect()  // 非主动关闭才重连
    }
  }

  startHeartbeat() {
    this.heartbeatTimer = setInterval(() => {
      if (this.ws?.readyState === 1) {
        this.ws.send(JSON.stringify({ type: 'ping' }))

        // 10 秒没收到 pong,判定连接死了
        this.pongTimer = setTimeout(() => {
          this.ws.close()  // 主动 close 触发 onclose → reconnect
        }, 10000)
      }
    }, 30000)
  }

  reconnect() {
    if (this.reconnectCount > 5) return  // 超过 5 次放弃,提示"连接断开"
    // 指数退避:1s, 2s, 4s, 8s, 16s
    const delay = Math.min(1000 * 2 ** this.reconnectCount, 16000)
    setTimeout(() => {
      this.reconnectCount++
      this.connect()
    }, delay)
  }

  close() {
    this.forceClose = true  // 标记主动关闭,不重连
    this.stopHeartbeat()
    this.ws?.close()
  }
}
注意: 组件卸载时一定要 ws.close(),不然路由切换后旧的 WebSocket 还在后台跑,数据还在 push 到已经销毁的图表上,直接报错 + 内存泄漏。
6

7×24 长跑的内存泄漏

⚠️ 长跑内存泄漏的几个源头

大屏 7×24 不间断运行,平时小项目根本暴露不了的问题全出来了:

  • ECharts 实例未 dispose: 切 Tab 频繁 init 不销毁,Canvas 上下文堆积。→ 统一 manager + dispose
  • setInterval / setTimeout 未 clear: 数据轮询定时器,组件销毁没清。→ onBeforeUnmount 全部 clear
  • WebSocket 未 close: 见上一节。
  • 事件监听未移除: window.resize、visibilitychange 监听器堆积。→ 用 Vue 的 onMounted/onBeforeUnmount 配对
  • 闭包引用大对象: 回调里引用了整个 option 对象,GC 回收不掉。
兜底方案: 每天凌晨 3 点(业务低峰)location.reload() 一次,强行重置。这不是偷懒 — 是大屏场景的行业标准做法,支付宝、美团的大屏都这么做。
排查工具: Chrome DevTools → Memory → 录制 Heap Snapshot,对比运行 1 小时和 6 小时的快照,看 Detached DOM Nodes 和 ECharts 相关对象是否持续增长。Performance Monitor 看 JS Heap Size 曲线是不是阶梯式上涨。
7

高频追问 & 容易翻车点

1为什么不能用 ref 存 ECharts 实例?shallowRef 和 ref 的区别? 高频
核心: ref 对对象类型会调 reactive 深度递归,给每个属性装 Proxy。ECharts 实例是巨型对象树(ZRender + Canvas + series + 每个数据点),深度劫持后初始化慢、每次 setOption 都要 diff 整棵树。
shallowRef 只对 .value 整体替换响应式,不递归内部。ECharts 实例有自己的更新机制(setOption),不需要 Vue 监听它内部。
💬 追问「那 markRaw 呢?」→ markRaw 是给对象打"永不响应式"标记,从对象层治本;shallowRef 是从容器层治标。大屏场景 shallowRef 更直观。
210+ 个图表同时渲染,性能怎么优化? 基础
① Map 统一管理实例,避免重复 init;② setOption 用 notMerge: false 增量更新;③ IntersectionObserver 懒初始化;④ large 模式 + Canvas 批量绘制;⑤ 数据超 500 点前端降采样;⑥ 切 Tab 时 dispose 隐藏的图表。
💬 追问「为什么不直接全量 setOption?」→ 全量会重新构建整个 option 树并 diff,14 个图同时全量更新会掉帧。增量只改变化的 series.data,快得多。
3大屏适配 4K 怎么做?字体模糊怎么办? 中等
方案: scale 等比缩放(1920×1080 设计稿,transform: scale)。
4K 字体模糊: scale 把 1080P 渲染结果放大 2 倍导致。缓解:① ECharts init 传 devicePixelRatio;② 4K 下 fontSize ×2;③ 实在不行切 rem 方案。
鼠标错位: scale 后 e.clientX 要除以 scale 才是设计稿坐标;ECharts 不要单独再 scale。
4WebSocket 为什么需要心跳?重连怎么做? 中等
为什么要心跳: NAT/防火墙会静默断开空闲连接,前端 onclose 不一定触发(半开连接),导致"伪连接"。
心跳: 每 30s 发 ping,10s 没收到 pong 就判定死了,主动 close 触发重连。
重连: 指数退避(1s/2s/4s/8s/16s),最多 5 次,超过提示"连接断开"。必须区分主动关闭和异常关闭,主动关闭不重连。
5大屏 7×24 运行内存泄漏怎么解决? 高频
源头: ECharts 未 dispose、定时器未 clear、WebSocket 未 close、事件监听未 removeEventListener、闭包引用大对象。
代码层: onBeforeUnmount 配对清理;统一 ChartManager.disposeAll()。
兜底: 每天凌晨 location.reload() 重置(行业标准)。监控用 Performance Observer 看长任务,Memory 录快照对比。
💬 追问「reload 不影响业务吗?」→ 选业务低峰(凌晨 3 点),reload 前先发个"系统维护中"提示,1-2 秒恢复,大屏场景可接受。
6实时数据推送,WebSocket vs 轮询 vs SSE 怎么选? 中等
轮询: 简单,KPI 类数据(10-30s 更新一次)够用。但要加请求锁防重入。
WebSocket: 服务端主动推,适合告警(秒级)、双向交互。复杂,要处理重连/心跳。
SSE: 单向推送,基于 HTTP,简单。但只能服务端→客户端。
我们项目: 轮询更新 KPI(30s)+ WebSocket 推告警。两者结合。
💬 追问「为什么不全用 WebSocket?」→ KPI 数据更新不频繁,轮询更省服务器连接数;WebSocket 留给真正实时的告警。
7大屏上同时有地图、图表、表格,数据怎么组织? 基础
后端聚合,不要前端拉原始数据。 一个大屏接口返回所有模块需要的聚合数据(KPI + 各图表 series + 告警列表),前端按模块分发。轮询时整体刷新,而不是每个模块各拉各的(减少请求数)。地图数据(省份销量)单独一个接口,因为数据量大且更新慢。
8Vue2 项目里也有这个问题吗?(简历写的 Vue2) 陷阱题
有,但机制不同。 Vue2 是 Object.defineProperty,只在初始化时遍历一层(不像 Vue3 Proxy 递归),所以 Vue2 里 ECharts 实例被劫持的程度比 Vue3 轻,但仍然会对实例的第一层属性做 getter/setter,有一定开销。
Vue2 的做法: 把实例挂在 this.$options 或用 Object.freeze() 冻结,或者干脆不放在 data 里(放在 created 的 this 上,Vue2 不会代理非 data 字段)。我们项目当时就是把 chart 实例挂在 this.chart(不是 data 里),避开了响应式。
💬 这是简历写 Vue2 时的关键细节!如果被问到"你 Vue2 怎么处理的",答 this.chart 不放 data + Object.freeze,体现你对两个版本响应式差异的理解。
8

30 秒电梯陈述(背熟)

🎤 30 秒版
「我在比亚迪做了两年的业务数据可视化大屏,用 Vue + ECharts。大屏上挂了 14 个图表,4K 分辨率,WebSocket 实时推送销售和监控数据。印象最深的坑是 ECharts 实例的响应式劫持问题 —— 一开始切 Tab 卡 3 秒,定位到是 Vue 把整个实例深度 reactive 化了,改 shallowRef 后秒切。另外大屏 7×24 运行,内存泄漏、WebSocket 半开连接这些平时遇不到的问题都得专门处理。这套经验我觉得挺扎实的。」
这段话的优势:
① 有规模数据(14 个图表、4K、2 年)→ 真实
② 有具体坑(响应式劫持)→ 有深度
③ 有解决方案(shallowRef)→ 有结论
④ 有延伸点(7×24、内存泄漏)→ 可深聊
面试官听完,大概率会问"展开讲讲响应式那个坑"——你就进入主场了。

面试亮点专题 01 — 4K 大屏 + ECharts 深度话题

shallowRef / markRaw · 4K scale 适配 · 14 图表性能 · WebSocket 心跳重连 · 7×24 内存泄漏

← 返回专题首页