Vue 3 响应式陷阱与状态单例:从 TDZ 白屏到跨进程 Proxy 剥离
Composition API 给了 Vue 3 用户极大的自由------
setup()里写几个ref、computed就能组装出复杂逻辑。但自由的另一面是陷阱的隐身 :你随手写的一个const提升错位、一个被多组件复用的 composable、一个跨 Electron IPC 传递的 reactive 对象,都可能让应用在某个看似无关的时刻白屏、状态分裂、或静默错乱。本文盘点这些"看起来没问题、跑起来才崩"的响应式陷阱,并给出工程化修复。
一、TDZ:const 提升的隐形杀手
TDZ(Temporal Dead Zone,暂时性死区)是 ES6 let/const 的语义------在声明语句执行之前 访问变量会抛 ReferenceError。在普通脚本里 TDZ 几乎不会咬人,但在 Vue 3 的 <script setup> 里,配合顶层立即执行函数(IIFE),它会变成白屏元凶。
典型翻车场景:
vue
<script setup lang="ts">
import { ref, computed } from 'vue'
// ① 这里 IIFE 立即执行,访问了尚未初始化的 docRef
const summary = (() => {
return computed(() => docRef.value.clips.length) // 💥 TDZ
})()
// ② docRef 在 IIFE 之后才声明
const docRef = ref({ clips: [] })
</script>
const 声明会提升 到作用域顶部,但绑定 留在原位。IIFE 执行时 docRef 已声明、未初始化------访问触发 ReferenceError。Vue 模板编译产物在 setup 抛错时会卸载整个组件,渲染出空白页面,控制台只剩一句 Uncaught ReferenceError: Cannot access 'docRef' before initialization。
这种 bug 极难复现------它在 dev / build 都不一定炸,但某个特定的 HMR 状态、某个特定的路由顺序下突然白屏。生产建议:
- 永远把 ref 声明放在使用之前 :哪怕代码组织上看起来不顺手,也把
const x = ref()顶到 setup 开头。 - 避免顶层 IIFE :要立即执行就写成普通函数后跟
(),IDE 会高亮未声明变量,IIFE 的括号会"骗"过部分 linter。 - 用 ESLint
no-use-before-define:开启variables: true,编译期就拦住。 <script setup>报错时检查控制台第一行:白屏 90% 是 setup 阶段抛错,错误信息虽然不直观但确实在。
二、computed 的参数陷阱
ts
// ❌ 第一参数传字符串
const totalDuration = computed('clips.duration.sum', () => {
return clips.value.reduce((acc, c) => acc + c.duration, 0)
})
Vue 2 时代 computed({ ... }) 接受对象,Vue 3 的 computed 第一参数必须是函数或 getter/setter 对象 。传字符串不会报错------它会被当成 getter 函数(字符串不是函数,调用时炸),运行时给 Uncaught TypeError: fn is not a function。
更隐蔽的是 SSR 场景下,字符串参数在某些 vue 版本里静默返回 undefined,渲染出"空数据",控制台没任何报错。
修复:永远用箭头函数形式,需要可写时才用 { get, set }:
ts
const totalDuration = computed(() => clips.value.reduce((acc, c) => acc + c.duration, 0))
三、Composable 单例陷阱:被低估的状态分裂
这是 Vue 3 中最常见、最隐蔽的架构级 bug。Composable 写法鼓励"复用":
ts
// useRenderExport.ts
import { ref } from 'vue'
const document = ref<ProjectDocument>(emptyDoc()) // ← 看起来很合理
export function useRenderExport() {
return { document, exportProject, ... }
}
问题:模块级 const document 在 ES Module 系统里是单例 ,但 Vue 的 HMR(热模块替换)和某些打包配置下,同一个模块可能被实例化多次------多个组件分别 import 时,理论上应该拿到同一份 document,但:
- HMR 重载时 :模块重新求值,新模块有新的
document,旧组件持有的还是旧引用。状态分裂。 - 多个 Vue 应用实例:测试或微前端场景下,每个 app 加载独立模块实例,状态彻底不通。
- SSR 隔离:服务器多请求并发,模块级状态会跨请求串数据。
但更常见的翻车不是 HMR,而是显式 new 出多个独立状态:
ts
// useRenderExport.ts ------ ❌ 错误写法
import { ref } from 'vue'
export function useRenderExport() {
// 每次调用 new 一份独立 state
const document = ref<ProjectDocument>(emptyDoc())
function exportProject() { /* ... */ }
return { document, exportProject }
}
vue
<!-- 组件 A -->
<script setup>
const { document: docA } = useRenderExport()
</script>
<!-- 组件 B -->
<script setup>
const { document: docB } = useRenderExport()
</script>
docA 和 docB 是两个完全独立的 ref,A 里改 document、B 完全感知不到。这是 Vue 3 Composition API 的"自由代价"------你以为是单例,其实是工厂。
修复:懒加载单例模式
ts
// useRenderExport.ts
import { ref, type Ref } from 'vue'
let _document: Ref<ProjectDocument> | null = null
let _api: ReturnType<typeof createRenderExportApi> | null = null
function getState() {
if (!_document) {
_document = ref<ProjectDocument>(emptyDoc())
_api = createRenderExportApi(_document)
}
return { document: _document, api: _api! }
}
export function useRenderExport() {
const { document, api } = getState()
return { document, ...api }
}
第一次调用 useRenderExport() 才真正创建 state,后续调用复用同一份。这种"懒加载单例"在 Vue 生态里很常见(Pinia 的 store 就是这个模式)。
进阶:用 provide/inject 显式声明作用域
单例的全局性有时是缺点------测试隔离、多窗口隔离都难做。更工程化的方案是用 provide/inject:
ts
// App.vue
import { provide, ref } from 'vue'
const document = ref<ProjectDocument>(emptyDoc())
provide('document', document)
provide('renderExportApi', createRenderExportApi(document))
// 子组件
import { inject } from 'vue'
const document = inject<Ref<ProjectDocument>>('document')!
provide 默认是单例 within 应用实例------同 app 下所有 inject 拿到同一份,不同 app 完全隔离。生产代码里这是更稳妥的选择,但需要在 App 根部 wire 一遍,对小型应用稍重。
四、跨 Electron IPC 的 Proxy 剥离
Vue 3 的响应式基于 Proxy。Proxy 对象通过 Electron IPC 传给主进程时,会触发结构化克隆失败 或克隆出纯数据但丢响应式。两种结果都不是你想要的:
- 失败:
Failed to serialize argument: <ref> could not be cloned,IPC 直接报错。 - 丢响应式:主进程拿到的对象改字段,渲染进程的 ref 不更新(不是同一个对象)。
根本原则:跨 IPC 边界传递前必须剥离 Proxy。三种方式:
1. JSON.parse(JSON.stringify(obj))
最简单粗暴。缺点:丢失 Date、Map、Set、undefined、函数。对纯 document 数据通常够用。
2. toRaw() from Vue
ts
import { toRaw } from 'vue'
const plain = JSON.parse(JSON.stringify(toRaw(document.value)))
toRaw 拿到底层原始对象,再 JSON 一遍。比纯 JSON 安全------避免 Proxy 的某些 getter 副作用(如 computed 触发)。
3. Structured Clone Algorithm
Node 17+ 有 structuredClone,保留 Date/Map/Set/TypedArray,但不保留函数(IPC 也不该传函数):
ts
const plain = structuredClone(toRaw(document.value))
这是 Electron 内部本身就在用的算法,性能最好。生产首选。
反向:主进程返回的数据
主进程返回的对象本身就是 plain object,但渲染进程要让它响应式:
ts
const result = await window.api.project.load(path)
document.value = reactive(result) // 或 ref(result)
注意 reactive vs ref:
reactive(obj)把 obj 本身变成响应式,保留引用 。reactive({}) === reactive({})为 false,但const a = {}; reactive(a) === reactive(a)为 true(同一对象多次 reactive 返回同一 proxy)。ref(obj).value = newObj会触发依赖更新,更适合"整体替换 document"的场景。
跨 IPC 边界时的一个微妙陷阱:主进程返回的 clips 数组用 reactive() 包后,对数组元素的 push/splice 仍保持响应式 (Vue 3 重写了数组方法)。但直接索引赋值 arr[3] = x 在 reactive 上不触发更新 ------必须用 arr.splice(3, 1, x) 或 Vue.set 等价物。Vue 3 文档里有写但容易忽略。
五、reactive 数组的引用保持
Vue 2 时代 Vue.set(arr, idx, val) 是必修课。Vue 3 用 Proxy 重写后,arr[idx] = val 会触发响应式更新 ------但前提是 arr 本身被 reactive 包过。如果是从 ref 解构出来的:
ts
const clips = ref<Clip[]>([])
const first = clips.value[0] // first 是 Clip 原始对象,未响应式
first.duration = 5 // 不触发更新
ref 的 .value 是浅响应 ------对数组元素的属性修改不触发。要么用 reactive 包整个对象图,要么用 clips.value = [...clips.value, newClip] 替换整个数组。
实战技巧:对顶层集合用 ref,对集合内元素用 reactive:
ts
const document = ref<ProjectDocument>(emptyDoc())
// 整体替换 document.value = newDoc 触发更新
// 修改 document.value.clips[0].duration 不触发更新 ❌
要让深层修改也触发,必须 reactive(document.value) 或显式 triggerRef(document)。或者更简单------整个 document 用 reactive,不要用 ref:
ts
const document = reactive<ProjectDocument>(emptyDoc())
document.clips[0].duration = 5 // ✅ 触发更新
但 reactive 不能整体替换(document = newDoc 会丢失响应式),需要 Object.assign(document, newDoc) 或显式字段拷贝。这就是为什么大型项目通常选 Pinia------它内部用 reactive 但暴露 store.$state = newState 接口,绕过这个矛盾。
六、watch 的 deep 与 flush
ts
watch(document, (newDoc) => {
persistToDisk(newDoc)
}, { deep: true })
deep: true:深监听,document 任意属性变更都触发。代价是每次都全树遍历比对,大 document 性能差。flush: 'post':在 DOM 更新后触发回调,避免回调里改状态又触发新一轮 watch 的循环。flush: 'sync':同步触发,调试方便但容易死循环。
生产建议:
- 持久化用
deep + flush:post + 防抖:用户连续改 100 次只触发一次写盘。 - 派生 UI 用
computed而非watch:computed 有缓存,watch 没有。 - 避免在 watch 回调里改被监听的 ref:除非明确知道终止条件,否则死循环。
七、单测里的响应式陷阱
ts
import { ref } from 'vue'
import { useRenderExport } from './useRenderExport'
test('export updates document', async () => {
const { document, exportProject } = useRenderExport()
await exportProject({ format: 'mp4' })
expect(document.value.exportSettings.format).toBe('mp4')
})
如果 useRenderExport 是懒加载单例,多个测试用例之间会共享状态 ------beforeEach 没重置,第二个测试拿到的 document 是第一个测试改过的。修复:
- 测试间显式 reset 单例:
ts
beforeEach(() => {
resetRenderExportSingleton()
})
-
改用
provide/inject模式,每个测试createApp()隔离。 -
用 Pinia 的
setActivePinia(createPinia())在 beforeEach 重建。
八、小结
Vue 3 的响应式系统比 Vue 2 强大得多,但"自由"换来的陷阱也不少:
| 陷阱 | 根因 | 修复 |
|---|---|---|
| TDZ 白屏 | let/const 提升但未绑定 |
声明放使用前 + ESLint |
| computed 参数错 | 字符串非函数 | 永远用箭头函数 |
| Composable 状态分裂 | 每次调用 new 独立 ref | 懒加载单例 / provide-inject |
| IPC Proxy 序列化失败 | Proxy 不能结构化克隆 | structuredClone(toRaw()) |
| 深层数组修改不响应 | ref.value 浅响应 | 用 reactive 或 triggerRef |
| watch 死循环 | 回调改被监听状态 | flush: post + 防抖 |
每个陷阱都不是"语法错误"------它们在编译期都合法,运行时才显形。生产代码里,建议用 ESLint 规则 + Pinia / 自定义单例工厂 + 一份"响应式边界"约定文档(哪些对象必须 plain、哪些必须 reactive),把这些陷阱挡在 code review 阶段。Vue 3 不是"自由越多越好",它是"约束越早越好"。