乐趣区

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

前言

距离国庆假期尤大发布 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

关于

  • 本文首发于: 实现一个简化版的 Vue3 数据侦测
退出移动版