前端难点:Vue3 响应式遇上 Three.js / ECharts ------ 为什么要用 shallowRef?
摘要 :Three.js / ECharts 实例用 ref 包一层就卡顿、tooltip 异常?该用 shallowRef 和 markRaw。 专栏 :前端难点实战 · 阅读约 7 分钟 · 难度 :⭐⭐⭐⭐ 实战场景:Vue3 中大型 B 端后台
一、现象:图表 tooltip 异常、3D 场景卡顿、内存暴涨
在实际项目的 3D 模型查看器 和 Echart 组件里,如果这样写:
typescript
// ❌ 危险写法
const scene = ref(new THREE.Scene())
const myChart = ref(echarts.init(dom))
可能出现:
- ECharts tooltip 位置错乱、事件不触发
- Three.js 渲染变慢(Vue 递归代理整个 scene graph)
- 控制台警告
Reactive object detected in readonly operation
二、根因:ref() 会深度 reactive
Vue 3 的 ref() 对对象类型会调用 reactive(),递归把对象所有属性变成 Proxy:
typescript
const obj = ref({ a: { b: { c: 1 } } })
// obj.value.a.b.c 也被代理
Three.js 的 Scene 下有成千上万个 Mesh、Material、Geometry 节点;ECharts 实例内部同样有复杂对象树。被 Vue 代理后:
- 性能:每次 render 触发大量 Proxy 读写
- 兼容性 :库内部可能用
===、私有字段、非 configurable 属性,与 Proxy 冲突 - 内存:响应式依赖追踪额外开销
三、解法:shallowRef + markRaw
3.1 shallowRef:只代理 .value 引用
typescript
const scene = shallowRef<THREE.Scene | null>(null)
const myChart = shallowRef<echarts.ECharts | null>(null)
scene.value = new THREE.Scene() // Scene 内部不会被代理
替换 .value 整对象时仍会触发更新;修改 scene.value.children 不会触发 Vue 重渲染------而这正是我们想要的(渲染由 Three 的 rAF 循环驱动)。
3.2 markRaw:永久标记为非响应式
typescript
myChart.value = markRaw(echarts.init(chartDom.value, 'normal'))
即使不小心放进 reactive 对象,也不会被代理。
四、实际项目中的分层实践
| 数据类型 | 推荐 API | 示例 |
|---|---|---|
| Three Scene/Camera/Renderer | shallowRef |
3D 模型查看器 |
| ECharts 实例 | shallowRef + markRaw |
Echart 组件 |
| UI 状态 loading/tooltip | ref |
loading.value = true |
| 模型 mesh 列表(只读展示) | shallowRef |
modelData |
| MQTT 消息 payload | ref / 普通对象 |
业务数据 |
typescript
// 3D 查看器组件节选
const scene = shallowRef<THREE.Scene | null>(null)
const hoverData = shallowRef<HoverEventData | null>(null) // 含 Mesh 引用
const loading = ref(true) // 纯 UI 布尔值用 ref
五、什么时候必须用 ref?
- 模板里要绑定的简单 UI 状态
- 需要 deep watch 的表单 model (或用
reactive) - 数组/map 增删要驱动列表重渲染
不要把整个第三方实例塞进 reactive 表单对象。
六、组件卸载时的清理(另一个难点)
响应式解绑 ≠ GPU/内存释放。Three.js / ECharts 必须手动 dispose:
typescript
onUnmounted(() => {
myChart.value?.dispose()
renderer.value?.dispose()
renderer.value?.forceContextLoss()
controls.value?.dispose()
})
Vue 组件销毁不会帮你清理 WebGL context。
七、小结
| 问题 | 方案 |
|---|---|
| 重型对象被深度代理 | shallowRef |
| 实例需永久免代理 | markRaw |
| 渲染循环 | 交给 Three/ECharts,不依赖 Vue 重渲染 |
| 内存泄漏 | onUnmounted 里 dispose |
写在最后
以上难点来自真实 B 端项目工程实践。若对你有帮助,欢迎 点赞、收藏,有问题评论区交流。
发布到掘金时建议:
- 分类:前端
- 标签:前端、Vue.js、Three.js、ECharts、JavaScript
- 封面:DevTools 布局截图或代码片段图
- 摘要:复制文首「摘要」段落到编辑器摘要栏
专栏「前端难点实战」:
- 上一篇:《前端难点:Flex 布局里「表格/页面撑不满」------ min-height: 0 到底解决什么?》
- 下一篇:《前端难点:Element Plus 样式覆盖 ------ :deep()、CSS 变量与滚动状态类名》