给 Vue 的 reactive 套 Proxy,为什么响应式会失灵?
先看最小 demo。
html
<template>
<button @click="add">添加</button>
<div>len: {{ proxyC.length }}</div>
<div v-for="item in proxyC" :key="item">{{ item }}</div>
</template>
<script setup>
import { reactive } from 'vue'
const raw = []
const reactiveArr = reactive(raw)
const cache = new WeakMap()
function wrap(value) {
if (value === null || typeof value !== 'object') return value
if (cache.has(value)) return cache.get(value)
const p = new Proxy(value, {
get(t, key) {
const val = t[key]
if (val !== null && typeof val === 'object') return wrap(val)
return val
},
})
cache.set(value, p)
return p
}
const proxyC = wrap(reactiveArr)
function add() {
proxyC.push('item-' + (proxyC.length + 1))
}
</script>
命名统一:
raw:最底层的原始数组。reactiveArr:reactive(raw)返回的响应式数组。它本质也是一个 Proxy,包着raw。proxyC:wrap(reactiveArr)返回的外层 Proxy,包着reactiveArr。
现象:点添加,raw.length 变成 1,数据进去了;但页面 len 不变,v-for 也不新增。
数据写进去了,视图不更新。
下面只跟一次 proxyC.push('item-1')。
一、proxyC.push 读到的是 reactiveArr 上的 push
proxyC.push('item-1') 第一步是读 proxyC.push。
proxyC 的目标是 reactiveArr,所以读 push 落到 reactiveArr 上。reactiveArr 是 Vue 的响应式数组,读到数组方法 push 时,返回 Vue 重写过的 push。它内部大致做两件事:
js
toRaw(this) // 先剥回原始对象
this.push(...) // 写操作仍然发生在 this 上
这一步只读,没有写。
二、reactiveArr 的 set 函数,先只看签名
调用这个 push 时,this 是 proxyC。它最终还是在 proxyC 上写索引和 length:
js
proxyC[0] = 'item-1'
proxyC.length = 1
proxyC 只是包在 reactiveArr 外面的一层,写属性最终落到里面的 reactiveArr 上。reactiveArr 本身也是 Proxy,所以写它会进入 reactiveArr 的 set 函数。它的签名是:
js
set(target, key, value, receiver) {
...
}
target、key、value 都好理解。第四个参数 receiver 是这次写操作的发起者。
单层代理时:
js
const p = new Proxy(rawObj, {
set(target, key, value, receiver) {
console.log(receiver === p) // true
return Reflect.set(target, key, value, receiver)
},
})
p.a = 2
多层代理时,发起者会作为 receiver 一路传下去:
js
const inner = new Proxy(rawObj, {
set(target, key, value, receiver) {
console.log(receiver === outer) // true
return Reflect.set(target, key, value, receiver)
},
})
const outer = new Proxy(inner, {})
outer.a = 2
如果中间某层写成 Reflect.set(target, key, value) 不传 receiver,receiver 就断在这一层,变成 target。
三、reactiveArr 的 set 函数简化代码
js
set(target, key, value, receiver) {
// target = raw, receiver = proxyC
target[key] = value
if (target === toRaw(receiver)) {
trigger(target, 'set', key, value, oldValue)
}
}
第一行就是把值写到 target 上 ,在这个 demo 里 target 就是 raw。
然后判断要不要通知视图。先看 toRaw 的实现:
js
function toRaw(observed) {
const next = observed && observed['__v_raw']
return next ? toRaw(next) : observed
}
它靠 __v_raw 一层层往下剥。Vue 的 reactive 代理上,读 __v_raw 返回它代理的原始对象:
js
const rawObj = { a: 1 }
const reactiveObj = reactive(rawObj)
reactiveObj.__v_raw === rawObj // true
trigger(target) 通知的是订阅了 target 的地方。只有这次写是 target 自己发起的,才通知:
js
// 直接写 reactiveArr:发起者是 reactiveArr 自己
reactiveArr[0] = 'item-1'
// target = raw, receiver = reactiveArr
// toRaw(receiver) = raw
// raw === raw,成立,trigger(raw)
// demo 里写的是 proxyC:发起者是外层代理
proxyC[0] = 'item-1'
// target = raw, receiver = proxyC
// 要判断 toRaw(proxyC) 是不是 raw
现在要知道 toRaw(proxyC) 到底是什么。接下来分析它。
四、toRaw(proxyC) 为什么剥不到 raw
toRaw(proxyC) 读 proxyC.__v_raw,走 proxyC 的 get 函数:
js
get(t, key) {
// t = reactiveArr, key = '__v_raw'
const val = t[key]
if (val !== null && typeof val === 'object') return wrap(val)
return val
}
proxyC 的 get 函数去 reactiveArr 上读 __v_raw,按上面的契约拿到 raw。但它拿到 raw 后,发现是对象,又 wrap(raw) 了一层。所以:
js
toRaw(proxyC) === wrap(raw) // 不是 raw
回到 proxyC[0] = 'item-1' 这个例子:
js
proxyC[0] = 'item-1'
// target = raw, receiver = proxyC
// 要判断 toRaw(proxyC) 是不是 raw
target === toRaw(receiver)
// raw === wrap(raw)
// false
判断失败,trigger 被跳过。数据写进了 raw,但没人通知视图,页面不动。
五、修复:让 proxyC 的 get 函数对 __v_raw 直通
问题出在 proxyC 的 get 函数:它把 __v_raw 也当普通对象属性处理了,reactiveArr 返回 raw,它又 wrap(raw) 了一层。
Vue 对 __v_raw 的契约很明确:访问这个特殊属性,返回这一层代理的目标对象。 照着语义做就行:
js
get(t, key) {
if (key === '__v_raw') return t
const val = t[key]
if (val !== null && typeof val === 'object') return wrap(val)
return val
}
这里的 t 就是 reactiveArr。所以 proxyC.__v_raw 返回 reactiveArr,toRaw 继续往下,reactiveArr.__v_raw 返回 raw。于是:
js
toRaw(proxyC) === raw
回到 proxyC[0] = 'item-1' 这个例子,判断恢复:
js
target === toRaw(receiver)
// raw === raw,成立
trigger(raw, ...) 正常执行,视图更新。