Vue2.x与Vue3响应式实现核心代码对比__Vue.js
发布于 4 年前 作者 banyungong 1310 次浏览 来自 分享
粉丝福利 : 关注VUE中文社区公众号,回复视频领取粉丝福利

Object.defineProperty实现响应式

首先需要知道是Object.defineProperty只能监听对象,并且这个对象不是指对象类型(数组也是对象类型),而是Object构造器对象,也就是{}

结构分为三个部分:

  • updateView:更新视图的函数
  • defineReactive:监听对象数据改变的函数
  • observer:分解数据每一项属性,以便进行深度监听

监听对象

Vue2.x实现响应式视图更新,大概流程就是下面这样:

//设置简单的函数表示视图更新
function updateView() {
  console.log("视图更新")
}

// 分解对象属性函数
function observer(target) {
  if (typeof target !== "object" || target === null) {
    // 不是对象或数组
    return target
  }

  // 监听target每一个属性
  for (let key in target) {
    defineReactive(target, key, target[key])
  }
}

function defineReactive(target, key, value) {
  // 深度监听:如果value是对象就继续分解
  observer(value)

  // 核心API:Object.defineProperty()
  Object.defineProperty(target, key, {
    get() {
      return value
    },
    set(newValue) {
      if (newValue !== value) {
        // 设置新值也要监听是否是对象和数组
        observer(newValue)
        // 设置新值
        // 注意value一直在闭包中,此处设置完后再get时也是会获取到value的值
        value = newValue

        // 触发更新视图
        updateView()
      }
    },
  })
}

由于JavaScript的限制,Vue里的数据不能够动态添加根级响应式 property。也就是说必须在初始化实例前声明所有根级响应式 property,哪怕只是一个空值,不然是不会触发视图更新虽然值已经变了。

大家可以通过上面的代码运行一下。👆

监听数组

为了能够监听数组,Vue2重写了部分数组方法去实现视图更新,但是这部分和Object.defineProperty就没有关系了。

👇大概实现是这样实现的:

//设置简单的函数表示视图更新
function updateView() {
  console.log("视图更新")
}

// 重新定义数组原型
const oldArrayProperty = Array.prototype
//这样新增方法也不会影响到Array原型
const arrPrototype = Object.create(oldArrayProperty)

// 假设添加了这些方法,那么如果数组调用这些方法就会触发视图更新
;["push", "pop", "unshift", "shift", "splice"].forEach(
  (method) =>
    (arrPrototype[method] = function () {
      // 如果调用以上的方法就触发视图更新
      updateView()
      oldArrayProperty[method].call(this, ...arguments)
    })
)

// 分解对象属性函数
function observer(target) {
  if (typeof target !== "object" || target === null) {
    // 不是对象或数组
    return target
  }

  // 为了不污染全局Array原型:需要重新定义数组原型
  if (Array.isArray(target)) {
    target.__proto__ = arrPrototype
  }

  // 重新定义各个属性
  for (let key in target) {
    defineReactive(target, key, target[key])
  }
}

因为Object.defineProperty的限制,Vue2中对数组的操作非常有限制。

Vue 不能检测以下数组的变动:

  1. 当你利用索引直接设置一个数组项时,例如:vm.items[indexOfItem] = newValue
  2. 当你修改数组的长度时,例如:vm.items.length = newLength

官方推荐最好用splice方法对数组进行增删操作就是因为内部改写方法时改写了splice方法。或者使用Vue.set()|vm.$set()强行将数据添加到响应式里。

Proxy和Reflect实现响应式

在贴上代码之前必须要好好讲一下Proxy,感觉很多人都对Proxy有误解,以为Proxy只是ES6语法用来代替ES5语法中Object.defineProperty的语法糖,这种想法是大错特错的。ProxyObject.defineProperty是两个完全不同的东西。所以Object.defineProperty也不能代替Proxy

Proxy

Proxy 可以理解为:在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。Proxy 这个词的原意是代理,用在这里表示由它来“代理”某些操作,可以译为“代理器”。

ES6 原生提供 Proxy 构造函数,用来生成 Proxy 实例。

先上代码看看基本操作👇

var arr = [1,2]

var arrProxy = new Proxy(
  arr,
  {
    get(target, propKey) {
      console.log(`getting ${propKey}!`)
    },
    set(target, propKey, value) {
      console.log(`setting ${propKey}!`)
    },
  }
)

//设置值
arrProxy[0] = 'change'   //setting 0!
//读取值
arrProxy[1]  //getting 1!

上面的代码对arr数组架设了一层拦截,重定义了属性的读取(get)和设置(set)行为。

作为构造函数,Proxy接受两个参数:

  • 第一个参数是所要代理的目标对象(上例是一个arr对象),即如果没有Proxy的介入,操作原来要访问的就是这个arr对象。这里的对象指对象类型(数组也是对象类型)。
  • 第二个参数是一个配置对象handler,对于每一个被代理的操作,需要提供一个对应的处理函数,该函数将拦截对应的操作。比如,上面代码中,配置对象有一个get方法,用来拦截对目标对象属性的访问请求。get方法的两个参数分别是目标对象和所要访问的属性。

注意: 要使Proxy起作用,必须针对Proxy实例(上例是arrProxy对象)进行操作,而不是针对目标对象(上例是arr对象)进行操作。

下面是 Proxy 支持的拦截操作一览,一共 13 种。

  • get(target, propKey, receiver):拦截对象属性的读取,比如proxy.fooproxy['foo']
  • set(target, propKey, value, receiver):拦截对象属性的设置,比如proxy.foo = vproxy['foo'] = v,返回一个布尔值。
  • has(target, propKey):拦截propKey in proxy的操作,返回一个布尔值。
  • deleteProperty(target, propKey):拦截delete proxy[propKey]的操作,返回一个布尔值。
  • ownKeys(target):拦截Object.getOwnPropertyNames(proxy)Object.getOwnPropertySymbols(proxy)Object.keys(proxy)for...in循环,返回一个数组。该方法返回目标对象所有自身的属性的属性名,而Object.keys()的返回结果仅包括目标对象自身的可遍历属性。
  • getOwnPropertyDescriptor(target, propKey):拦截Object.getOwnPropertyDescriptor(proxy, propKey),返回属性的描述对象。
  • defineProperty(target, propKey, propDesc):拦截Object.defineProperty(proxy, propKey, propDesc)Object.defineProperties(proxy, propDescs),返回一个布尔值。
  • preventExtensions(target):拦截Object.preventExtensions(proxy),返回一个布尔值。
  • getPrototypeOf(target):拦截Object.getPrototypeOf(proxy),返回一个对象。
  • isExtensible(target):拦截Object.isExtensible(proxy),返回一个布尔值。
  • setPrototypeOf(target, proto):拦截Object.setPrototypeOf(proxy, proto),返回一个布尔值。如果目标对象是函数,那么还有两种额外操作可以拦截。
  • apply(target, object, args):拦截 Proxy 实例作为函数调用的操作,比如proxy(...args)proxy.call(object, ...args)proxy.apply(...)
  • construct(target, args):拦截 Proxy 实例作为构造函数调用的操作,比如new proxy(...args)

可以看出Proxy不仅可以实现Object.defineProperties的功能,还有其他的操作也可以拦截。

我有关Proxy的内容基本都是参考阮一峰的ES6教程,讲的特别好,大家可以看看。

Reflect

说完Proxy就必须要说一说Reflect这个ES6新增的API。Reflect对象和Proxy对象一样也是用来操作对象的,但是Reflect对象的设计目的有重大的意义。

  1. Object对象的一些明显属于语言内部的方法(比如Object.defineProperty),放到Reflect对象上。现阶段,某些方法同时在ObjectReflect对象上部署,未来的新方法将只部署在Reflect对象上。也就是说,从Reflect对象上可以拿到语言内部的方法。这样做的好处就是使Object类更纯粹,JavaScript更像一门语言,Object更像一个类,而不是什么乱七八糟的方法都往Object上塞。

  2. 修改某些Object方法的返回结果,让其变得更合理。比如,Object.defineProperty(obj, name, desc)在无法定义属性时,会抛出一个错误,而Reflect.defineProperty(obj, name, desc)则会返回false

    // 老写法
    try {
      Object.defineProperty(target, property, attributes);
      // success
    } catch (e) {
      // failure
    }
    
    // 新写法
    if (Reflect.defineProperty(target, property, attributes)) {
      // success
    } else {
      // failure
    }
    
  3. Object操作都变成函数行为。某些Object操作是命令式,比如name in objdelete obj[name],而Reflect.has(obj, name)Reflect.deleteProperty(obj, name)让它们变成了函数行为。

    // 老写法
    'assign' in Object // true
    
    // 新写法
    Reflect.has(Object, 'assign') // true
    
  4. (核心)Reflect对象的方法与Proxy对象的方法一一对应,只要是Proxy对象的方法,就能在Reflect对象上找到对应的方法。这就让Proxy对象可以方便地调用对应的Reflect方法,完成默认行为,作为修改行为的基础。也就是说,不管Proxy怎么修改默认行为,你总可以在Reflect上获取默认行为。

Reflect对象一共有 13 个静态方法。

  • Reflect.apply(target, thisArg, args)
  • Reflect.construct(target, args)
  • Reflect.get(target, name, receiver)
  • Reflect.set(target, name, value, receiver)
  • Reflect.defineProperty(target, name, desc)
  • Reflect.deleteProperty(target, name)
  • Reflect.has(target, name)
  • Reflect.ownKeys(target)
  • Reflect.isExtensible(target)
  • Reflect.preventExtensions(target)
  • Reflect.getOwnPropertyDescriptor(target, name)
  • Reflect.getPrototypeOf(target)
  • Reflect.setPrototypeOf(target, prototype)

上面这些方法的作用,大部分与Object对象的同名方法的作用都是相同的,而且它与Proxy对象的方法是一一对应的。

我们来改写之前proxy的例子🌰,加上Reflect

var arr = [1,2]

var arrProxy = new Proxy(
  arr,
  {
    get(target, propKey, receiver) {
      console.log(`getting ${propKey}!`)
      return Reflect.get(target, propKey, receiver)
    },
    set(target, propKey, value, receiver) {
      console.log(`setting ${propKey}!`)
      return Reflect.set(target, propKey, receiver)
    },
  }
)

//设置值
arrProxy[0] = 'change'   //setting 0! change
//读取值
arrProxy[1]  //getting 1! 2

上面的代码中,每一个Proxy对象的拦截操作(getset),内部都调用对应的Reflect方法,保证原生行为能够正常执行。ReflectProxy中调用还有一个重要的原因是recevier参数。

来,看一个🌰来说明recevier参数作用👇

let p = {
  a: "a",
}

let handler = {
  set(target, key, value, receiver) {
    console.log("set")
    Reflect.set(target, key, value, receiver)
  },
  defineProperty(target, key, attribute) {
    console.log("defineProperty")
    Reflect.defineProperty(target, key, attribute)
  },
}

let obj = new Proxy(p, handler)
obj.a = "A"
// set
// defineProperty

上面代码中,Proxy.set拦截里面使用了Reflect.set,而且传入了receiver,导致触发Proxy.defineProperty拦截。

这是因为Proxy.setreceiver参数总是指向当前的 Proxy实例(即上例的obj),而Reflect.set一旦传入receiver,就会将属性赋值到receiver上面(即obj),导致触发defineProperty拦截。如果Reflect.set没有传入receiver,那么就不会触发defineProperty拦截。

所以receiver的作用就是让Proxy里的对象操作都指向当前的Proxy实例,这样就能拦截到所有对实例的操作。(嗯,非常严谨无懈可击:+1:

Proxy对比Object.defineProperty

看到这里,我相信大家已经明白了Object.definePropertiesProxy的区别。但是我还是要再啰嗦一句Proxy并不是Object.defineProperties的语法糖!!!!

babel通过@babel/polyfill(corejsre-generator)将ES6语法转译为大部分浏览器都支持的ES5语法。原理说得简单点就是一些ES6的特性可以用ES5代替,只不过用ES5写会很麻烦或者不直观。

比如我们看一段代码👇:

// ES6定义类
class Person{
  constructor(name){
    this.name = name
  }
  sayName(){
    console.log(this.name)
  }
}

这个定义类的方法为了让它能够被大部分浏览器识别,我们一般会用babel将它转译:

// 通过babel的polyfill后
function Person(name){
  this.name = name
}
Person.prototype.sayName = function(){
  console.log(this.name)
}

转译后的ES5代码和之前的ES6版本代码实现的功能完全一致。我们其实也可以说class定义类的方法是通过function定义类(构造函数)的语法糖。

但是Proxy是不能通过babel转译的,因为在ES5中完全没有一种语法可以模拟出Proxy的特性。因此Vue3.x版本没有办法兼任一些低版本浏览器。

响应式代码

function reactive(target = {}) {
  if (typeof target !== "object" || target == null) {
    return target
  }

  // 代理配置
  const proxyConf = {
    get(target, key, receiver) {
      //只监听对象本身(非原型)属性
      const ownKeys = Reflect.ownKeys(target)
      if (ownKeys.includes(key)) {
        //如果是本身的属性就监听,如果是对象原型的属性就不监听
        console.log("get", key)
      }
        
      const result = Reflect.get(target, key, receiver)
      //(惰性)深度监听-->提升性能
      return reactive(result)
    },
    set(target, key, val, receiver) {
      // 重复的数据不处理
      if (val === target[key]) {
        return true
      }

      // 监听是否是新增的key
      const ownKeys = Reflect.ownKeys(target)
      if (ownKeys.includes(key)) {
        console.log("已有的key", key)
      } else {
        console.log("新增的key", key)
      }

      const result = Reflect.set(target, key, val, receiver)
      console.log("set", key, val)
      return result //通过return的值可以看出是否设置成功
    },
    deleteProperty(target, key) {
      const result = Reflect.deleteProperty(target, key)
      console.log("delete property", key)
      return result //是否删除成功
    },
  }

  // 生成代理对象
  const observed = new Proxy(target, proxyConf)
  return observed
}

Vue3的响应式逻辑如何一步一步构造出来的,我放在另一篇博文Vue3响应式实现逻辑

结语

ok,在最后我想说一些话。

有很多人觉得Vue3为了性能舍弃了部分浏览器是得不偿失的行为。但是,我并不觉得。

首先,Vue3现目前还在ra阶段,到真正发布估计还要一段时间。而且并不是Vue3发布了,我们做项目就必须用3,中间应该是漫长的Vue2.x到3的过渡时期。随着时间的拉长,我相信会有越来越多浏览器支持Proxy属性,我们也会抛弃对越来越古老的浏览器版本的兼容。

其次不能因为现目前部分浏览器的不兼容就不去考虑Vue的性能提升,技术的推动是慢慢迭代的。要是都以现状做导向的话,互联网也不会发展得这么快。

嗯,就这样吧。

版权声明:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 作者: Liqiuyue 原文链接:https://juejin.im/post/6855129007412346888

回到顶部