代码改变世界

全面了解Vue3的 reactive 和相关函数

2021-03-31 16:17  金色海洋(jyk)  阅读(3223)  评论(0编辑  收藏  举报

Vue3的 reactive 怎么用,原理是什么,官网上和reactive相关的那些函数又都是做什么用处的?这里会一一解答。

ES6的Proxy

Proxy 是 ES6 提供的一个可以拦截对象基础操作的代理。因为 reactive 采用 Proxy 代理的方式,实现引用类型的响应性,所以我们先看看 Proxy 的基础使用方法,以便于我理解 reactive 的结构。

我们先来定义一个函数,了解一下 Proxy 的基本使用方式:

// 定义一个函数,传入对象原型,然后创建一个Proxy的代理
const myProxy = (_target) => {
  // 定义一个 Proxy 的实例
  const proxy = new Proxy(_target, {
    // 拦截 get 操作
    get: function (target, key, receiver) {
      console.log(`getting ${key}!`, target[key])
      // 用 Reflect 调用原型方法
      return Reflect.get(target, key, receiver)
    },
    // 拦截 set 操作
    set: function (target, key, value, receiver) {
      console.log(`setting ${key}:${value}!`)
      // 用 Reflect 调用原型方法
      return Reflect.set(target, key, value, receiver)
    }
  })
  // 返回实例
  return proxy
}

// 使用方法,是不是和reactive有点像?
const testProxy = myProxy({
  name: 'jyk',
  age: 18,
  contacts: {
    QQ: 11111,
    phone: 123456789
  }
})
console.log('自己定义的Proxy实例:')
console.log(testProxy)
// 测试拦截情况
testProxy.name = '新的名字' // set操作 
console.log(testProxy.name) // get 操作

Proxy 有两个参数 target 和 handle。
* target:要代理的对象,也可以是数组,但是不能是基础类型。
* handler:设置要拦截的操作,这里拦截了 set 和 get 操作,当然还可以拦截其他操作。

我们先来看一下运行结果:
自己写的 Proxy 实例的运行结果

  • Handler 可以看到我们写的拦截函数 get 和 set;
  • Target 可以看到对象原型。

注意:这里只是实现了 get 和 set 的拦截,并没有实现数据的双向绑定,模板也不会自动更新内容,Vue内部做了很多操作才实现了模板的自动更新功能。

用 Proxy 给 reactive 套个娃,会怎么样?

有个奇怪的地方,既然 Proxy 可以实现对 set 等操作的拦截,那么 reactive 为啥不返回一个可以监听的钩子呢?为啥要用 watch 来实现监听的工作?

为啥会这么想?因为看到了 Vuex4.0 的设计,明明已经把 state 整体自动变成了 reactive 的形式,那么为啥还非得在 mutations 里写函数,实现 set 操作呢?好麻烦的样子。

外部直接对 reactive 进行操作,然后 Vuex 内部监听一下,这样大家不就都省事了吗?要实现插件功能,还是跟踪功能,不都是可以自动实现了嘛。

所以我觉得还是可以套个娃的。

实现模板的自动刷新

本来以为上面那个 myProxy 函数,传入一个 reactive 之后,就可以自动实现更新模板的功能了,结果模板没理我。

这不对呀,我只是监听了一下,不是又交给 reactive 了吗?为啥模板不理我?

经过各种折腾,终于找到了原因,于是函数改成了这样:

  /**
   * 用 Proxy定义一个 reactive 的套娃,实现可以监听任意属性变化的目的。(不包含嵌套对象的属性)
   * @param {*} _target  要拦截的目标
   * @param {*} callback 属性变化后的回调函数
   */
  const myReactive = (_target, callback) => {
    let _change = (key, value) => {console.log('内部函数')}
    const proxy = new Proxy(_target, {
      get: function (target, key, receiver) {
        if (typeof key !== 'symbol') {
          console.log(`getting ${key}!`, target[key])
        } else {
          console.log('getting symbol:', key, target[key])
        }
        // 调用原型方法
        return Reflect.get(target, key, receiver)
      },
      set: function (target, key, value, receiver) {
        console.log(`setting ${key}:${value}!`)
        // 源头监听
        if (typeof callback === 'function') {
          callback(key, value)
        }
        // 任意位置监听
        if (typeof _target.__watch === 'function') {
          _change(key, value)
        }
        // 调用原型方法
        return Reflect.set(target, key, value, target)  // 这里有变化,最后一个参数改成 target
      }
    })
    // 实现任意位置的监听,
    proxy.__watch = (callback) => {
      if (typeof callback === 'function') {
        _change = callback
      }
    }
    // 返回实例
    return proxy
  }

代码稍微多了一些,我们一块一块看。

  • get
    这里要做一下 symbol 的判断,否则会报错。好吧,其实我们似乎不需要 console.log。

  • set
    这里改了一下最后一个参数,这样模板就可以自己更新了。

  • 设置 callback 函数,实现源头监听
    设置一个回调函数,才能在拦截到set操作的时候,通知外部的调用者。只是这样只适合于定义实例的地方。那么接收参数的地方怎么办呢?

调用方法如下:

    // 定义一个拦截reactive的Proxy
    // 并且实现源头的监听
    const myProxyReactive = myReactive(retObject,
      ((key, value) =>{
        console.log(`ret外部获得通知:${key}:${value}`)
      })
    )

这样我们就可以在回调函数里面得到修改的属性名称,以及属性值。

这样我们做状态管理的时候,是不是就不用特意去写 mutations 里面的函数了呢?

  • 内部设置一个钩子函数
    设置一个 _change() 钩子函数,这样接收参数的地方,可以通过这个钩子来得到变化的通知。

调用方法如下:

   // 任意位置的监听
    myProxyReactive.__watch((key, value) => {
      console.log(`任意位置的监听:${key}:${value}`)
    })

只是好像哪里不对的样子。
首先这个钩子没找到合适的地方放,目前放在了原型对象上面,就是说破坏了原型对象的结构,这个似乎会有些影响。

然后,接收参数的地方,不是可以直接得到修改的情况吗?是否还需要做这样的监听?

最后,好像没有 watch 的 deep 监听来的方便,那么问题又来了,为啥 Vuex 不用 watch 呢?或者悄悄的用了?

深层响应式代理:reactive

说了半天,终于进入正题了。
reactive 会返回对象的响应式代理,这种响应式转换是深层的,可以影响所有的嵌套对象。

注意:返回的是 object 的代理,他们的地址是相同的,并没有对object进行clone(克隆),所以修改代理的属性值,也会影响原object的属性值;同时,修改原object的属性值,也会影响reactive返回的代理的属性值,只是代理无法拦截直接对原object的操作,所以模板不会有变化。

这个问题并不明显,因为我们一般不会先定义一个object,然后再套上reactive,而是直接定义一个 reactive,这样也就“不存在”原 object 了,但是我们要了解一下原理。

我们先定义一个 reactive 实例,然后运行看结果。

// js对象
const person = {
  name: 'jyk',
  age: 18,
  contacts: {
    QQ: 11111,
    phone: 123456789
  }
}
// person 的 reactive 代理 (验证地址是否相同)
const personReactive = reactive(person)
// js 对象 的 reactive 代理 (一般用法)
const objectReactive = reactive({
  name: 'jykReactive',
  age: 18,
  contacts: {
    QQ: 11111,
    phone: 123456789
  }
})

// 查看 reactive 实例结构
console.log('reactive', objectReactive )

// 获取嵌套对象属性
const contacts = objectReactive .contacts
// 因为深层响应,所以依然有响应性
console.log('contacts属性:', contacts)
 
// 获取简单类型的属性
let name = objectReactive.name 
// name属性是简单类型的,所以失去响应性
console.log('name属性:', name) 

运行结果:
reactive的打印结果

  • Handler:可以看到 Vue 除重写 set 和 get 外,还重写了deleteProperty、has和ownKeys。

  • Target: 指向一个Object,这是建立reactive实例时的对象。

属性的结构:
reactive的属性打印结果

然后再看一下两个属性的打印结果,因为 contacts 属性是嵌套的对象,所以单独拿出来也是具有响应性的。

而 name 属性由于是 string 类型,所以单独拿出来并不会自动获得响应性,如果单独拿出来还想保持响应性的话,可以使用toRef。

注意:如果在模板里面使用{{personReactive.name}}的话,那么也是有响应性的,因为这种用法是获得对象的属性值,可以被Proxy代理拦截,所以并不需要使用toRef。
如果想在模板里面直接使用{{name}}并且要具有响应性,这时才需要使用toRef。

浅层响应式代理:shallowReactive

有的时候,我们并不需要嵌套属性也具有响应性,这时可以使用shallowReactive 来获得浅层的响应式代理,这种方式只拦截自己的属性的操作,不涉及嵌套的对象属性的操作。

const personShallowReactive = shallowReactive({
  name: 'jykShallowReactive',
  age: 18,
  contacts: {
    QQ: 11111,
    phone: 123456789
  }
})

// 查看 shallowReactive 实例结构
console.log('shallowReactive', objectShallowReactive)

// 获取嵌套对象属性
const contacts = objectShallowReactive.contacts
// 因为浅层代理,所以没有响应性
console.log('contacts属性:', contacts)

// 获取简单类型的属性
let name = objectShallowReactive.name 
// 因为浅层代理且简单类型,所以失去响应性
console.log('name属性:', name) 

shallowReactive的打印结果

shallowReactive 也是用 Proxy 实现响应性的,而单独使用contacts属性并没有响应性,因为 shallowReactive 是浅层代理,所以不会让嵌套对象获得响应性。

注意:objectShallowReactive.contacts.QQ = 123 ,这样修改属性也是没有响应性的。

单独使用的属性的形式:

shallowReactive的属性

嵌套对象和name属性,都没有变成响应式。

做一个不允许响应的标记:markRaw

有的时候我们不希望js对象变成响应式的,这时我们可以用markRaw 做一个标记,这样即使使用 reactive 也不会变成响应式。

如果确定某些数据是不会变化的,那么也就不用变成响应式,这样可以节省一些不必要的性能开销。

// 标记js对象
const object = markRaw({
  name: 'jyk',
  age: 18,
  contacts: {
    QQ: 11111,
    phone: 123456789
  }
})

// 试图对标记的对象做相应性代理
const retObject2 = reactive(object)
// 使用对象的属性做相应性代理
const retObject1 = reactive({
  name: object.name
})
console.log('作为初始值:', retObject1) // 无法变成响应性代理
console.log('无法变成响应式:', retObject2) // 可以变成响应性代理

运行结果:

markRaw的打印结果

做标记后的js对象作为参数,不会变成响应式,但是使用属性值作为参数,还是可以变成响应式。

那么哪些地方可以用到呢?我们可以在给组件设置(引用类型的)属性的时候使用,默认情况下组件的属性都是自带响应性的,但是如果父组件里设置给子组件的属性值永远不会发生变化,那么还变成响应式的话,就有点浪费性能的嫌疑了。

如果想节约一下的话,可以在父组件设置属性的时候加上markRaw标记。

深层只读响应式代理:readonly

有的时候虽然我们想得到一个响应式的代理,但是只想被读取,而不希望被修改(比如组件的props,组件内部不希望被修改),那么这时候我们可以用readonly。

readonly可以返回object、reactive或者ref的深层只读代理,我们来分别测试一下:

// object的只读响应代理
const objectReadonly = readonly(person)
// reactive 的只读响应代理
const reactiveReadonly = readonly(objectReactive)
// 查看 readonly 实例结构
console.log('object 的readonly', objectReadonly)
console.log('reactive 的readonly', reactiveReadonly)

// 获取嵌套对象属性
const contacts = reactiveReadonly.contacts
console.log('contacts属性:', contacts) // 因为深层响应,所以依然有响应性

// 获取简单类型的属性
let name = reactiveReadonly.name 
console.log('name属性:', name) // 属性是简单类型的,所以失去响应性

运行结果:

object的readonly

  • Handler,明显拦截的函数变少了,set的参数也变少了,点进去看源码,也仅仅只有一行返回警告的代码,这样实现拦截设置属性的操作。
  • Target,指向object。

运行结果:

reactive的readonly

  • Handler,这部分是一样的。
  • Target,指向的不是object,而是一个Proxy代理,也就是reactive。

浅层只读响应代理:shallowReadonly

和readonly相对应,shallowReadonly是浅层的只读响应代理,和readonly的使用方式一样,只是不会限制嵌套对象只读。

// object 的浅层只读代理
const objectShallowReadonly = shallowReadonly(person)
// reactive 的浅层只读代理
const reactiveShallowReadonly = shallowReadonly(objectReactive)

shallowReadonly的结构和 readonly 的一致,就不贴截图了。

获取原型:toRaw

toRaw 可以获取 Vue 建立的代理的原型对象,但是不能获取我们自己定义的Proxy的实例的原型。

toRaw大多是在Vue内部使用,目前只发现在向indexedDB里面写入数据的时候,需要先用 toRaw 取原型,否则会报错。

// 获取reactive、shallowReactive、readonly、shallowReadonly的原型
console.log('深层响应的原型', toRaw(objectReactive))
console.log('浅层响应的原型', toRaw(objectShallowReactive))
console.log('深层只读的原型', toRaw(objectReadonly))
console.log('浅层只读的原型', toRaw(objectShallowReadonly))

运行结果都是普通的object,就不贴截图了。

类型判断

Vue提供了三个用于判断类型的函数:

* isProxy:判断对象是否是Vue建立的Proxy代理,包含reactive、readonly、shallowReactive和shallowReadonly创建的代理,但是不会判断自己写的Proxy代理。

  • isReactive:判断是否是reactive创建的代理。如果readonly的原型是reactive,那么也会返回true。

* isReadonly:判断是否是readonly、shallowReadonly创建的代理。这个最简单,只看代理不看target。

我们用这三个函数判断一下我们上面定义的这些Proxy代理,看看结果如何。

我们写点代码对比一下:

    const myProxyObject = myProxy({title:'222', __v_isReactive: false})
    console.log('myProxyObject', myProxyObject)
    const myProxyReactive = myProxy(objectReactive)
    console.log('myProxyReactive', myProxyReactive)

    // 试一试 __v_isReadonly
    console.log('objectReactive', objectReactive)
    console.log('__v_isReadonly'
      , objectReactive.__v_isReadonly
      , objectReactive.__v_isReactive
      )

    return {
      obj: { // js对象
        check1: isProxy(person),
        check2: isReactive(person),
        check3: isReadonly(person)
      },
      myproxy: { // 自己定义的Proxy object
        check1: isProxy(myProxyObject),
        check2: isReactive(myProxyObject),
        check3: isReadonly(myProxyObject)
      },
      myproxyReactive: { // 自己定义的Proxy reactive
        check1: isProxy(myProxyReactive),
        check2: isReactive(myProxyReactive),
        check3: isReadonly(myProxyReactive)
      },
      // 深层响应  reactive(object)
      reto: { // reactive(object)
        check1: isProxy(objectReactive),
        check2: isReactive(objectReactive),
        check3: isReadonly(objectReactive)
      },
      // 浅层响应 参数:object
      shallowRetObj: {
        check1: isProxy(objectShallowReactive),
        check2: isReactive(objectShallowReactive),
        check3: isReadonly(objectShallowReactive)
      },
      // 浅层响应 参数:reactive
      shallowRetRet: {
        check1: isProxy(objectShallowReactive),
        check2: isReactive(objectShallowReactive),
        check3: isReadonly(objectShallowReactive)
      },

      // 深层只读,参数 object =======================
      readObj: { // readonly object
        check1: isProxy(objectReadonly),
        check2: isReactive(objectReadonly),
        check3: isReadonly(objectReadonly)
      },
      // 深层只读,参数 reactive
      readRet: { // readonly reactive
        check1: isProxy(reactiveReadonly),
        check2: isReactive(reactiveReadonly),
        check3: isReadonly(reactiveReadonly)
      },
      // 浅层只读 参数:object
      shallowReadObj: {
        check1: isProxy(objectShallowReadonly),
        check2: isReactive(objectShallowReadonly),
        check3: isReadonly(objectShallowReadonly)
      },
      // 浅层只读 参数:reactive
      shallowReadRet: {
        check1: isProxy(reactiveShallowReadonly),
        check2: isReactive(reactiveShallowReadonly),
        check3: isReadonly(reactiveShallowReadonly)
      },
      person
    }

对比结果:

验证类型的对比测试

总结一下:

  • isReadonly 最简单,只有readonly、shallowReadonly建立的代理才会返回 true,其他的都是 false。

  • isProxy也比较简单,Vue建立的代理才会返回true,如果是自己定义的Proxy,要看原型是谁,如果原型是 reactive(包括其他三个)的话,也会返回true。

  • isReactive就有点复杂,reactive 建立的代理会返回 true,其他的代理(包含自己写的)还要看一下原型,如果是 reactive 的话,也会返回true。

判断依据

那么这三个函数是依据什么判断的呢?自己做的 Proxy 无意中监控到了“__v_isReactive”,难道是隐藏属性?测试了一下,果然是这样。

myProxy({title:'测试隐藏属性', __v_isReactive: true}),这样定义一个实例,也会返回true。

reactive直接赋值的方法

使用的时候我们会发现一个问题,如果直接给 reactive 的实例赋值的话,就会“失去”响应性,这个并不是因为 reactive 失效了,而是因为 setup 只会运行一次,return也只有一次给模板提供数据(地址)的机会,模板只能得到一开始提供的 reactive 的地址,如果后续直接对 reactive 的实例赋值操作,会覆盖原有的地址,产生一个新的Proxy代理地址,然而模板并不会得到这个新地址,还在使用“旧”地址,因为无法获知新地址的存在,所以模板不会有变化。

那么就不能直接赋值了吗?其实还是有方法的,只需要保证地址不会发生变化即可。

对象的整体赋值的方法。

有请 ES6 的 Object.assign 登场,这个方法是用来合并两个或者多个对象的属性的,如果属性名称相同后面的属性会覆盖前面的属性。所以大家在使用的时候要谨慎使用,确保两个对象的属性就兼容的,不会冲突。

代码如下:

Object.assign(objectReactive, {name: '合并', age: 20, newProp: '新属性'})

数组的整体赋值的方法。

数组就方便多了,可以先清空再 push 的方式,代码如下:

// retArray.length = 0 // 这里清空的话,容易照成闪烁,所以不要急
setTimeout(() => {
  const newArray = [
    { name: '11', age: 18 },
    { name: '22', age: 18 }
  ]
  // 等到这里再清空,就不闪烁了。
  retArray.length = 0
  retArray.push(...newArray)
}, 1000)

var 和 let、const

ES6 新增了 let 和 const,那么我们应该如何选择呢?
简单的说,var不必继续使用了。

let 和 const 的最大区别就是,前者是定义“变量”的,后者是定义“常量”的。

可能你会觉得奇怪,上面的代码都是用const定义的,但是后续代码都是各种改呀,怎么就常量了?其实const判断的是,地址是否改变,只要地址不变就可以。

对于基础类型,值变了地址就变了;而对于引用类型来说,改属性值的话,对象地址是不会发生变化的。

而 const 的这个特点整合可以用于保护 reactive 的实例。由Vue的机制决定,reactive的实例的地址是不可以改变的,变了的话模板就不会自动更新,const可以确保地址不变,变了会报错(开发阶段需要eslint支持)。

于是const和reactive(包括 ref 等)就成了绝配。

源码:

https://gitee.com/naturefw/nf-vue-cdn/tree/master/cdn/project-compositionapi

在线演示:

https://naturefw.gitee.io/nf-vue-cdn/cdn/project-compositionapi/

2