给 Vue 的 reactive 套 Proxy,为什么响应式会失灵?

给 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, ...) 正常执行,视图更新。

相关推荐
deli0076 分钟前
红绿灯配时实测:固定 60 秒 vs 自适应,2400 辆车差多少
前端
去伪存真6 分钟前
井道无信号、满手黑机油,我们如何用 DeepSeek V4 打造不“犯浑”的电梯维保 Agent?
前端·agent
deli0079 分钟前
60fps 为什么还是一顿一顿?帧时间曲线实测给你看
前端
时间的情敌37 分钟前
Cesium 流动管线、管道动画、管线流向仿真,管网、油气、水利孪生高频需求
前端·交互·cesium
yuyuxun138 分钟前
协同过滤的校园二手物品智能推荐系统的设计与实现 毕业设计-附源码40082
javascript·vue.js·spring boot·python·数据分析·django·课程设计
wangxiaoxuan2172 小时前
SLAM 的基本结构:前端、后端、回环与地图优化
前端·机器人·科沃斯八界
总要学点什么4 小时前
第105篇:三维特效-流动图片墙体效果——让箭头沿墙长滚,它俩竟是同一份代码
开发语言·javascript·ecmascript·gis·webgl·材质·cesium
Agent世界5 小时前
用 CDP 做发布自动化:四道机器闸门与产物验收
前端·javascript
FungLeo5 小时前
成为全栈·Vue 管理后台篇·编辑器图片上传:从文件选择到文章附件关联
前端·vue.js·编辑器·markdown·文件上传·formdata·附件管理
总要学点什么5 小时前
第104篇:三维特效-流动墙体效果——让贴图沿墙流动,它却只肯走一条对角线
开发语言·javascript·ecmascript·gis·webgl·材质·cesium