手写vue响应式之Object类型的处理

大家好,我是爱水文的苏先生,一名从业5年+的前端爱好者,致力于用最通俗的文字分享前端知识的酸菜鱼

github与好文

前言

这么卷的2023,你还搞不懂vue响应式?一文中,我们通过手写的形式实现了响应式的核心内容,了解了响应式的核心实现思路。但文章只进行了横向实现,对其实现细节并未提及。

本篇我们纵向深入,将关注点放到数据本身,来进一步探讨对Object类型的处理细节,并补充上一节中对属性删除和编辑逻辑的处理缺失

拦截器的选择

前文中我们直接使用Proxy来进行实现,这也是vue3中的方案,但在vue2中使用的其实是Object.defineProperty。至于原因嘛,网上已经被人说烂了,不过为了文章的完整性,我也简单总结下:

  • 对数据类型的非原生支持,需要提供补救api,比如this.$set
  • 需要在初始化阶段执行全量递归,影响性能

Proxy与Reflect

在分析数据类型的处理之前,我们还需要搞懂Proxy和Reflect这两个的一些关键点问题,不过我不打算去长篇大论它们,我只会对与本文相关的特性或概念进行阐述说明,因为这对于后文的理解很重要

Proxy

Proxy可以创建一个代理对象,它允许我们拦截并重新定义对一个对象的基本操作,而所谓基本操作即一个动作,反过来说,如果一个操作由两个动作完成,那就不是基本操作,而叫复合操作了

以如下代码来说明,我们定义了对象obj,它包含一个名称为say的属性,并且其值为一个函数。当我们执行p.say时是一个基本操作,因为它只包含了获取这一个动作,如果我们执行的是p.say(),那它就是一个复合操作了,因为这包含了两个动作:1-获取p.say;2-对p.say的结果进行调用

const obj = {
    say:function(){}
}
const p = new Proxy(obj,{
    get(){
        ...
    }
})

Reflect

如果你阅读过它的相关文档,你会发现任何能够在Proxy中找到的方法,都能够在Reflect中找到同名的函数。对于本文来说,我们只关注它的第三个参数:receiver

我们先回顾下这么卷的2023,你还搞不懂vue响应式?一文中我们实现的代码

const obj = {
    name:'spp'
}
const p = new Proxy(obj,{
    get(target,key){
        ...
        return target[key]
    },
    set(target,key,newValue){
        target[key] = newValue
        ...
    }
})

现在我把obj对象进行下改造,为其增加get访问器,并在内部打印this是否就是代理对象p

const obj = {
    get getName(){
        console.log(this === p)
    }
}
const p = new Proxy(obj,{...})

如果你运行该示例,你会发现其结果为fasle,这意味着,我们如果在get访问器中通过this访问对象上的name属性时,是无法正确触发依赖收集的

那么是什么原因导致的呢?我们来分析一下,在Proxy内我们是通过target[key]获取返回值的,我们知道在JavaScript中,谁调用this就会指向谁,所以this指向的原始对象,而原始对象我们是不进行依赖追踪的

因此,我们要利用第三个参数修正下this指向,就像call、apply、bind所做的事情一样

const obj = {
    get getName(){
        console.log(this === p)
    }
}
const p = new Proxy(obj,{
    get(target,key,receiver){
        ...
        return Reflect.get(target,key,receiver)
    }
})

可以看到,我们使用Reflect进行映射而不再直接返回target,此时再次打印你会发现结果就为true了

image.png

抽离依赖追踪与更新派发

先不要着急嘛,小伙子!在真正开始之前,我们还需要填个坑

image.png

这么卷的2023,你还搞不懂vue响应式?一文中我们将依赖追踪和派发更新的代码内置到了get和set内,为了代码的可复用与可维护性,我们需要先将其进行下抽离(见demo\vue\响应式设计与实现\07.js)

trace

function trace(target,key){
    if (!actEffect) return target[key];
    let reactiveObj = bucket.get(target);
    if (!reactiveObj) bucket.set(target, (reactiveObj = new Map()));
    let effects = reactiveObj.get(key);
    if (!effects) reactiveObj.set(key, (effects = new Set()));
    effects.add(actEffect);
    actEffect.deps.push(effects);
}

trigger

function trigger(target, key, value){
    target[key] = value;
    const reactiveObj = bucket.get(target);
    if (reactiveObj) {
      const effects = reactiveObj.get(key) || [];
      const t = new Set(effects); 
      t.forEach((v) => {
        if(actEffect !== v){
            taskQueue.add(v)
            flushTask()
        }
      });
    }
}

代理Object类型(见demo\vue\响应式设计与实现\08.js)

这么卷的2023,你还搞不懂vue响应式?一文中我们假设对象读取操作只有一种,即obj.keyName,但实际上in操作符和for...in循环都是对象访问的形式

处理in操作符

由于Proxy上并没有一眼就能看出来是哪个拦截函数与之相对应,所以理论上来说我们需要去查阅相关规范才行。不过我比较懒,我选择先去看下阮一峰的es6教程,事实上,还真被我找到了

image.png

因此,对于in操作符,我们使用has拦截器来实现依赖追踪,并通过Reflect来判定是否存在

const obj = {
    name:'spp'
}
const p = new Proxy(obj,{
    has(target,key){
        trace(target,key)
        return Reflect.has(target,key)
    }
})

处理for...in循环

同理,我们找到关于for...in的拦截器

image.png

模拟key

仔细观察我们发现,ownKeys拦截器只提供了target而缺失了key属性,而key恰恰是我们构造bucket数据结构中最最重要的一环,它与具体的effect进行关联

因此,我们需要自己去构造一个唯一的值并当作key值使用,显然Symbol很适合

const UNI_KEY_FOR_IN = Symbol()

为此,我们需要在依赖追踪时向trace函数传入该UNI_KEY_FOR_IN

const proxyObj = new Proxy(obj, {
  ...
  ownKeys(target){
    trace(target,UNI_KEY_FOR_IN)
    return Reflect.ownKeys(target)
  }
});

打call时间:

学了那么久,一定累了吧?那我们先来看一波推广吧

image.png

我目前正在开发一个名为unplugin-router的项目,它是一个约定式路由生成的库,目前已支持在webpack和vite中使用,也已完成对vue-router3.x和vue-router4.x的支持,且已经接入到公司的一个vite3+vue3的项目中

不过受限于工作时间进度比较慢,在此寻找志同道合的朋友一起来完成这件事,后续计划对功能做进一步的完善,比如支持@hmr注解、支持权限路由等,也有对react路由和svelte路由的支持计划,以及除了webpack和vite这两个之外的构建工具的支持,还有单元测试的编写.....


确认关联关系

上一小节,我们使用一个Symbol值解决了ownKeys缺失key属性的问题,但是这又引出了一个新的问题:什么时候应该触发Symbol值对应的副作用函数重新执行?

image.png

这个问题其实等价于,哪些情况是需要进行依赖追踪的?现在我们分情况来进行下讨论:

  • 新增

当新增属性时,我们希望能追踪到依赖,为此我们需要在trigger中将与Symbol值关联的effect取出执行一遍

function trigger(target, key, value){
    ...
    // 取出UNI_KEY_FOR_IN,兼容for...in
    const forInEffects = reactiveObj.get(UNI_KEY_FOR_IN) || new Set()
    forInEffects.forEach(v=>t.add(v))
    ...
}
  • 修改

当修改时,由于属性已经被依赖收集过,所以我们不需要再次进行收集。不过对于Proxy而言,对象属性的新增和删除统称为对象的设置,因此我们需要能够区分出当前是在进行哪种操作,这一点,我们只需要通过判断对象上是否已经存在即可做出区分,并且将其作为trigger的第三个参数传入

...
const p = new Proxy(obj,{
    set(target,key,value){
        const type = target[key] ? 'edit' : 'add'
        trigger(target, key, type)
        ...
    }
})

然后在trigger中,我们根据type的类型为for...in的追踪逻辑添加守卫

function trigger(target, key, value){
    ...
    // 当为新增时,取出UNI_KEY_FOR_IN,兼容for...in
    if(type === 'add'){
      const forInEffects = reactiveObj.get(UNI_KEY_FOR_IN) || new Set()
      forInEffects.forEach(v=>t.add(v))
    }
    ...
}
  • 删除

这么卷的2023,你还搞不懂vue响应式?一文中我们当时为了解决dead code问题实现了reset用于重新进行依赖收集,这刚好也可以用于属性删除上

鉴于目前我们还没有处理过属性值的删除,因此老规矩,我们先查阅下阮的文档并找到deleteProperty拦截器

image.png

这里我们使用Object.property.hasOwnProperty来过滤原型上的属性,当删除成功后重新收集依赖,这样在reset中就会切断删除的那个key所对应的effect了

...
const p = new Proxy(obj,{
  deleteProperty(target,key){
    const exist = Object.prototype.hasOwnProperty(target,key)
    if(exist){
        const isDel = Reflect.deleteProperty(target,key)
        if(isDel){
            trigger(target,key,'delete')
            return true
        }
    }
    return false
  }
})

另外,你可能也注意到了,trigger函数的第三个参数类型我们新增了delete类型,这主要对应for...in循环的兼容处理

function trigger(target, key, value){
    ...
    // 当为新增或删除时,取出UNI_KEY_FOR_IN,兼容for...in
    if(type === 'add' || type === 'delete'){
      const forInEffects = reactiveObj.get(UNI_KEY_FOR_IN) || new Set()
      forInEffects.forEach(v=>t.add(v))
    }
    ...
}
  • 代码实现

代码比较多,感兴趣的可以到根据前文提示到对应的文件下查看完整的实现哈,我这里就不再贴了

总结

本文,我们通过引出前文对in和for...in处理的缺失,从而在对应的解决过程中顺道实现了一个对象除了新增之外,对删除、编辑的处理。至此,关于Object类型的处理就基本完成了。下一节,我们将继续探究关于Array类型的处理

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容