Vue 进阶系列丨Array 的变化侦测

Vue 进阶系列教程将在本号持续发布,一起查漏补缺学个痛快!若您有遇到其它相关问题,非常欢迎在评论中留言讨论,达到帮助更多人的目的。若感本文对您有所帮助请点个赞吧!


2013年7月28日,尤雨溪第一次在 GItHub 上为 Vue.js 提交代码;2015年10月26日,Vue.js 1.0.0版本发布;2016年10月1日,Vue.js 2.0发布。

最早的 Vue.js 只做视图层,没有路由, 没有状态管理,也没有官方的构建工具,只有一个库,放到网页里就可以直接用了。

后来,Vue.js 慢慢开始加入了一些官方的辅助工具,比如路由(Router)、状态管理方案(Vuex)和构建工具(Vue-cli)等。此时,Vue.js 的定位是:The Progressive Framework。翻译成中文,就是渐进式框架。

Vue.js2.0 引入了很多特性,比如虚拟 DOM,支持 JSX 和 TypeScript,支持流式服务端渲染,提供了跨平台的能力等。Vue.js 在国内的用户有阿里巴巴、百度、腾讯、新浪、网易、滴滴出行、360、美团等等。

Vue 已是一名前端工程师必备的技能,现在就让我们开始深入学习 Vue.js 内部的核心技术原理吧!


为什么数组和对象的侦测方式不同

上一篇文章我们介绍了对象的侦测方式,本文我们将介绍数组的侦测方式。那么为什么数组和对象的侦测方式不一样呢?

我们知道对象的侦测是根据 Object.defineProperty 的 getter/setter 来实现的,我们先看对于数组这种方式是否适用。

let arr = {}
let prop;
Object.defineProperty(arr,'prop',{
    set:function(value){
        // 转存 value 值,用于 get 的返回
        prop = value  
        console.log("set",value);
    },
    get:function(){
        // 输出转存的 prop 值
        console.log('get',prop)  
        return prop;
    }
})
arr.prop = 1  // 'set' 1 
arr.prop = 2  // 'set' 2 
arr.prop = [] // "set" Array []
arr.prop = [1,2] // "set" Array [1, 2]
console.log(arr.prop) // Array [1, 2]
arr.prop.push(3) // "get" Array [1, 2]
console.log(arr.prop) // Array [1, 2, 3]
arr.prop.splice(1,1) // "get" Array [1, 2, 3]
console.log(arr.prop) //  Array [1, 3]
arr.prop.length = 4 // "get" Array [1, 2, 3]
console.log(arr.prop) // "get" Array [1, 3, undefined, undefined]
arr.prop = 4 // "set" 4
console.log(arr.prop) // "get" 4  // 4  
// 第一个是 get 方法的输出,第二个是数据值的输出

通过上面代码我们可以得出以下结论:

  • 通过 Object.defineProperty 拦截对象的改变,是可以监听到的,如15~16行;

  • 监听重新赋值一个新的数组对象的改变也可以监听到,如17~18行;

  • 监听通过数组原型上的方法来改变数组对象,是监听不到的,如20、22行;

  • 通过改变数组的 length 属性来改变数组的长度,是监听不到的,如24行;

  • 但是虽然监听不到数组的变化,数组本身实际是改变了的,只是在 set 方法中监听不到而已,如21、23、25行。

由此可见,通过数组原型上的方法来改变数组对象,使用 Object 上的监听方式是无法监听到的。那么应该怎么做,才能监听到呢?


如何监听数组的改变

我们上面讲了,通过使用数组原型上的方法来操作数组,是监听不到的,那么我们应该怎么做呢?

解决方法是通过改变数组原型链的指向,指向我们自定义的数组原型对象上,我们通过在自定义的数组原型上去监听数组的改变,这样就可以了。

以 push 方法为例:

const myArray= {};
Object.defineProperty(myArray, 'push', {
  // args 为参数数组
  value: function(...args) {
    console.log('这里监听数组的 push 方法')
    return Array.prototype.push.apply(this, args)
  },
  enumerable: false,
  writable: true,
  configurable: true
})
var arr = [];
// 改变arr数组原型链的指向为我们自定义的对象身上
arr.__proto__ = myArray;
arr.push(1);
console.log(arr); // '这里监听数组的 push 方法' // 1  
// 第一个是 myArray 对象的 push 方法体内容的输出
// 第二个是 myArray 对象的 push 方法返回值的输出

在上面代码中,我们创建了一个新的对象 myArray,然后定义了 myArray 的 push 属性,在属性里调用了原生的 Array.prototype.push 方法,最后将arr变量的原型链指向改为了我们新创建的 myArray 对象身上。

当执行第15行时,arr.push(1) 首先会去 arr 对象上找 push 方法,显然没有找到,那么他就会往他的上一层去找,在我们没有改变 arr 数组原型链的指向前,他的上一层就是 Array.prototype,现在我们改变了原型链的指向,那么他的上一层就变为了我们自定义的 myArray 对象身上,然后调用 myArray 对象身上的 push 方法。

apply 方法可以改变函数的this指向,将this指向了 Array.prototype.push 方法身上,args 是参数数组,将参数也一同传递给了 Array.prototype.push方法。

通俗的说,就是在 myArray.push 方法内部调用了原生的 push 方法,一同把参数也给了过去,myArray 只是在调用原生 push 方法之前加了一层拦截,输出了一句话而言。

那么我们也就知道了在哪里可以拦截数组原型链上的方法了,在调用原生方法之前,做一层拦截,然后在拦截上去监听数组的变化。


对数组原型链上的方法做拦截

对于 Array 原型中可以改变数组自身内容的方法有 7 个,分别是 push、pop、shift、unshift、splice、sort 和 reverse。我们只需要拦截这 7 个方法即可。

const myArray = Object.create(Array.prototype);
;[
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
].forEach(function(item){
  Object.defineProperty(myArray, item , {
    // args 为参数数组
    value: function(...args) {
      console.log('这里监听数组的方法',item)
      return Array.prototype[item].apply(this, args)
    },
    enumerable: false,
    writable: true,
    configurable: true
  })
})
var arr = [];
// 改变 arr 数组原型链的指向为我们自定义的对象身上
arr.__proto__ = myArray;
arr.push(1); // '这里监听数组的方法 '  'push'
arr.pop(1); // '这里监听数组的方法 '  'pop'
arr.splice(1,1); // '这里监听数组的方法 '  'splice'
...

Object.create(Array.prototype)  是创建一个继承自 Array.prototype 的对象,就是说我们创建的 myArray 对象继承自  Array.prototype,当 myArray 对象身上没有某个属性时,会向他的原型上找,也就是会去 Array.prototype 上找。


将拦截的方法挂载到数组的属性上

对于一些不支持 __proto__ 的浏览器而言,Vue.js 直接将拦截的方法全部设置到被侦测的数组身上,作为数组的属性存在。

// 判断当前浏览器是否支持 __proto__
const hasPrototype = '__proto__' in {}
// 拿到拦截对象上的所有属性名数组,用于后续遍历添加操作
const proKeys = Object.getOwnPropertyNames(myArray)
class Observer{
  constructor(){
    this.value=value
    if(Array.isArray(value)){
      const doFn = hasPrototype ? protoFn : copyFn
      doFn(value,myArray,proKeys)
    }else{
      // 对象的变化侦测
    }
  }
}


// 改变目标对象的原型链指向
function protoFn(mubiao,lanjie){
  mubiao.__proto__==lanjie
}


// 为数组添加拦截对象上的所有方法,作为数组的属性存在
function copyFn(mubiao,lanjie,keys){
  for(let i=0;i<keys.length;i++){
     const key = keys[i]
     // 为数组添加属性
  }
}

依赖的收集

上面我们已经介绍了如何在数组变化的时候,能够监听到。通过创建一个拦截器对象,就是上方的 myArray。

现在我们已经知道了怎么监听数组的变化,那么数组变化的时候应该通知谁呢,应该让哪一个 DOM 节点重新渲染呢?也就是说数组的依赖,是怎么收集起来的。

我们知道对于对象来说,依赖是在 defineProperty 的 getter 方法中收集起来的,那么对于数组而言,也是如此。

因此,Array 在 getter 中收集依赖,在拦截器中触发依赖,进行 DOM 的重新渲染。


依赖收集到了哪里

我们知道了在 getter 中收集依赖了之后,那么我们要将这个依赖收集到哪里呢?Vue.js 把 Array 的依赖存放到了 Observer 中。

class Dep{
  constructor(){
    this.yilaiArray = []
  }
  addYilai(yilai){
    this.yilaiArrat.push(yilai)
  }
  removeYilai(yilai){
    if(yilaiArray.length){
       const index = yilaiArray.indexOf(yilai)
       if(index>-1){
         yilaiArray.splice(index,1)
       } 
    }
  }
  undateYilai(yilai){
    // 向 DOM 发送通知,进行 DOM 更新操作
  }
}


class Observer{
  constructor(value){
    this.value = value
    this.dep = new Dep()  // 新增 dep,用来存储依赖
    if(Array.isArray(value)){
      const doFn = hasPrototype ? protoFn : copyFn
      doFn(value,myArray,proKeys)
    }else{
      // 对象的变化侦测
    }
  }
}

将依赖保存到 Observer 实例上,是为了确保在 Array 的拦截方法中和在Object.defineProperty 的 getter 函数中都可以访问到依赖。


在 getter 中收集依赖

我们可以向下面这样在 getter 中收集依赖:

function defineReactive (data, key, val) {
  // 拿到 Observer 实例
  let childOb = observe(val)
  // 创建存储依赖的 Dep 实例对象
  let dep = new Dep()
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
    get: function () {
      dep.addYilai()
      if (childOb) {
        // 收集数组依赖
        childOb.dep.addYilai()
      }
      return val
    },
    set: function (newVal) {
      if (val === newVal) {
        return
      }
      // 更新依赖        
     val = newVal
    }
  })
}


//为 value 创建 Observer 实例,有则返回,无则新建
function observe (value) {
  if (!isObject(value)) {
    return
  }
  let ob
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else {
    ob = new Observer(value)
  }
  return ob
}

在拦截方法中获取 Observer 实例

前面我们介绍过,拦截方法是对原型的一种封装,所以可以在拦截方法中访问到 this,也就是当前正在被操作的数组。

我们通过在存储依赖时,将 Observer 的 this 存储起来,方便之后的使用。

class Observer{
  constructor(value){
    this.value = value
    this.dep = new Dep()  // 新增 dep,用来存储依赖
    
    Object.defineProperty(value,'__ob__',{
      value:this,  // 将 this 保存在了 __ob__ 上
      enumerable:false,
      writable:true,
      configuable:true
    })
    
    if(Array.isArray(value)){
      const doFn = hasPrototype ? protoFn : copyFn
      doFn(value,myArray,proKeys)
    }else{
      // 对象的变化侦测
    }
  }
}

这样我们就可以通过数组数据的 __ob__ 属性拿到 Observer 实例了,从而拿到实例上的 dep 依赖对象了。

;[
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
].forEach(function(item){
  Object.defineProperty(myArray, item , {
    // args 为参数数组
    value: function(...args) {
      console.log('这里监听数组的方法',item)
      const ob = this.__ob__  // 在这里拿到 Observer 实例
      return Array.prototype[item].apply(this, args)
    },
    enumerable: false,
    writable: true,
    configurable: true
  })
})

向数组的依赖发送通知

当数组发生变化的时候,会向依赖发送通知,然后进行一系列的 DOM 渲染操作。我们已经在依赖方法中可以访问到 Observer 实例了,这里我们只需要拿到 dep 属性,然后发送通知就可以了。

;[
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
].forEach(function(item){
  Object.defineProperty(myArray, item , {
    // args 为参数数组
    value: function(...args) {
      console.log('这里监听数组的方法',item)
      const ob = this.__ob__  // 在这里拿到 Observer 实例
      ob.dep.undateYilai() // 向依赖发送通知
      return Array.prototype[item].apply(this, args)
    },
    enumerable: false,
    writable: true,
    configurable: true
  })
})

关于 Array 的问题

由于是通过拦截原型的方式实现,所以通过下标赋值和通过数组 length 改变数组操作无法侦测到数组的变化。在 Vue.js 3.0采用 ES6 提供的 Proxy 来实现这部分时,就解决了这一问题。


Vue 进阶系列教程将在本号持续发布,一起查漏补缺学个痛快!若您有遇到其它相关问题,非常欢迎在评论中留言讨论,达到帮助更多人的目的。若感本文对您有所帮助请点个赞吧!

叶阳辉

HFun 前端攻城狮

往期精彩:

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值