实现一个简化版的Vue3数据侦测

 约 13 分钟

前言

距离国庆假期尤大发布vue3前瞻版本发布已经有一个月的时间,大家都知道在vue2x版本中的响应式数据更新是用的defineProperty这个API。

vue2中,针对ObjectArray两种数据类型采用了两种不同的处理方式。

对于Object类型,通过Object.defineProperty通过getter/setter递归侦测所有对象的key,实现深度侦测

对于Array类型,通过拦截Array原型上的几个操作实现了对数组的响应式,但是存在一些问题。

总之,通过defineProperty这种方式存在一定的性能问题

为了解决这个问题,从很早之前vue3就计划将采用ES6 Proxy代理的方式实现数据的响应式。(IE不支持这个API,所以vue3也不支持IE11了,垃圾IE)

关于Proxy

可以先查看MDN Proxy详细用法。
这里主要讲一下基本语法

const obj = new Proxy(target,{
    // 获取对象属性会走这里
    get(target, key, receiver){},
    // 修改对象属性会走这里
    set(target, key, value, receiver){},
    // 删除对象上的方法会走这里
    deleteProperty(target,key){} 
})

尝试使用一下Proxy这个API,尝试几种用法,发现一些问题

  • 代理普通对象
const obj = {
  name: 'ahwgs',
  age: 22,
}
const res = new Proxy(obj, {
  // 获取对象属性会走这里
  get(target, key, receiver) {
    console.log('get', target, key)
    return target[key]
  },
  // 修改对象属性会走这里
  set(target, key, value, receiver) {
    console.log('set', target[key])
    target[key] = value
    return true
  },
  // 删除对象上的方法会走这里
  deleteProperty(target, key) {
    console.log('deleteProperty', target[key])
  },
})

const n = res.name
res.age = 23
console.log(obj)
// get { name: 'ahwgs', age: 22 } name
// set 22
// { name: 'ahwgs', age: 23 }
  • 代理数组
// const obj = {
//   name: 'ahwgs',
//   age: 22,
// }
const obj = [1, 2, 3]
const res = new Proxy(obj, {
  // 获取对象属性会走这里
  get(target, key, receiver) {
    console.log('get', target, key)
    return target[key]
  },
  // 修改对象属性会走这里
  set(target, key, value, receiver) {
    console.log('set', target[key])
    target[key] = value
    return true
  },
  // 删除对象上的方法会走这里
  deleteProperty(target, key) {
    console.log('deleteProperty', target[key])
  },
})

res.push(4)
console.log(obj)
// get [ 1, 2, 3 ] push
// get [ 1, 2, 3 ] length
// set undefined
// set 4
// [ 1, 2, 3, 4 ]

代理数组的时候发现了一个问题,get调用的两次,一次是push一次是length这两个都是数组自身的属性

那么vue3中是如何解决这个问题的呢?

  • 代理深层次对象
const obj = {
  name: 'ahwgs',
  age: 22,
  arr: [1, 2, 3],
}
const res = new Proxy(obj, {
  // 获取对象属性会走这里
  get(target, key, receiver) {
    console.log('get', target, key)
    return target[key]
  },
  // 修改对象属性会走这里
  set(target, key, value, receiver) {
    console.log('set', target, key)
    target[key] = value
    return true
  },
  // 删除对象上的方法会走这里
  deleteProperty(target, key) {
    console.log('deleteProperty', target[key])
  },
})

res.arr.push(4)
console.log(obj)
// get { name: 'ahwgs', age: 22, arr: [ 1, 2, 3 ] } arr
// { name: 'ahwgs', age: 22, arr: [ 1, 2, 3, 4 ] }

发现并没有执行set逻辑,并没有代理到第二层级的对象,那么vue中是如何做到深层次的代理的呢?

解决问题

上面的代码我们遇到了两个问题:

  • 多次触发了get/set
  • 无法代理深层级的对象

我们手写一个简单的vue3尝试解决上面这些问题,具体看下述代码:


const toProxy = new WeakMap() // 存放的是代理后的对象
const toRaw = new WeakMap() // 存放的是代理前的对象

function isObject(target) {
  // 这里为什么!==null 因为typeof null =object 这是js的一个bug
  return typeof target === 'object' && target !== null;
}

// 模拟UI更新
function trigger() {
  console.log('UI更新了!!');
}

// 判断key是否是val的私有属性
function hasOwn(val, key) {
  const { hasOwnProperty } = Object.prototype
  return hasOwnProperty.call(val, key)
}

// 数据代理
// target是要代理的对象,vue中data()return的那个对象
function reactive(target) {
  // 先判断如果不是对象 不需要做代理 直接返回
  if (!isObject(target)) return target;

  // 如果代理表中已经存在 就不需要再次代理 直接返回已存在的代理对象
  const proxy = toProxy.get(target)
  if (proxy) return proxy
  // 如果传入的对象被代理过
  if (toRaw.has(target)) return target

  const handler = {
    set(tar, key, value, receiver) {
      // 触发更新
      // 如果触发的是私有属性的话才去更新视图 用以解决类似于数组操作中多次set的问题
      if (hasOwn(target, key)) {
        trigger()
      }
      // 这里使用ES6 Reflect 为Proxy设置一些属性
      // 用于简化自定义的一些方法
      return Reflect.set(tar, key, value, receiver)
    },
    get(tar, key, receiver) {
      const res = Reflect.get(tar, key, receiver)
      // 判断当前修改的值是否是否是对象 如果是对象的话 递归再次代理 解决深层级代理的问题
      if (isObject(tar[key])) {
        return reactive(res)
      }
      return res
    },
    deleteProperty(tar, key) {
      return Reflect.deleteProperty(tar, key)
    },
  }

  // 被代理的对象
  const observed = new Proxy(target, handler)

  // 将代理过的对象 放入缓存中
  // 防止代理过的对象再次被代理
  // WeekMap因为的key是弱引用关系,涉及到垃圾回收机制,要比Map的效率高
  toProxy.set(target, observed) // 源对象 : 代理后的结果
  toRaw.set(observed, target) //
  return observed
}


const data = {
  name: 'ahwgs',
  age: 22,
  list: [1, 2, 3],
}
let user = reactive(data)
user = reactive(data)
user = reactive(data)
user.list.push(4)

针对上面的几个问题做以下解释:

  • 多次触发了get/set

通过hasOwn这个方法,判断当前修改的属性是否是私有属性,如果是的话才去更新视图。

对于这一点,源码中是这样做的:

 // 判断是否有
  const hadKey = hasOwn(target, key)
  const result = Reflect.set(target, key, value, receiver)
  // don't trigger if target is something up in the prototype chain of original
  if (target === toRaw(receiver)) {
    /* istanbul ignore else */
    if (__DEV__) {
      const extraInfo = { oldValue, newValue: value }
      if (!hadKey) {
        trigger(target, OperationTypes.ADD, key, extraInfo)
      } else if (hasChanged(value, oldValue)) {
        trigger(target, OperationTypes.SET, key, extraInfo)
      }
    } else {
      if (!hadKey) {
        trigger(target, OperationTypes.ADD, key)
      } else if (hasChanged(value, oldValue)) {
        trigger(target, OperationTypes.SET, key)
      }
    }
  }

判断要setkey是否是存在的,如果是存在的就去更新视图(trigger方法),如果不是的话往视图中新增

  • 无法代理深层级的对象

通过在get方法中判断当前的值是否是对象,如果是对象的话再去代理一次,做一个递归的操作

对于源码中是这样的:

const res = Reflect.get(target, key, receiver)
    if (isSymbol(key) && builtInSymbols.has(key)) {
      return res
    }
    if (isRef(res)) {
      return res.value
    }
    track(target, OperationTypes.GET, key)
    return isObject(res)
      ? isReadonly
        ? // need to lazy access readonly and reactive here to avoid
          // circular dependency
          readonly(res)
        : reactive(res)
      : res
  }

总结

  • 整体是通过ES6 Proxy这个新特性去实现的响应式,并且还通过WeakWap去缓存的整个代理数据的保存,提高响应式数据的性能
  • 简单版是这么简单处理的,但是源码中对每一个细节处理的都很细致,并且结构分明,具体可以查看https://github.com/vuejs/vue-next/tree/master/packages/reactivity/src

关于

阅读 278

推荐阅读
w.posts
用户专栏

w候人兮猗的博客文章

1 人关注
27 篇文章
专栏主页
目录