秋招保驾护航——vue篇之数据双向绑定

282 阅读2分钟

这是我参与8月更文挑战的第1天,活动详情查看:8月更文挑战

vue的双向绑定博客有很多,之前自己也看了很多遍,但也只是有一个模糊的概念。有些东西只靠死记硬背是无法彻底理解的,需要自己亲手去实现一遍才能更好的理解。

看他人走过的风景不如自己去体验,同时为了面试时有一个更惊艳的回答,写下本文。

发布订阅模式 && 观察者模式

数据双向绑定的原理用一句简单的话来概括就是:Vue在初始化数据时,会使用Object.defineProperty重新定义data中的所有属性,当页面使用对应属性时,首先会进行依赖收集(收集当前组件的watcher)如果属性发生变化会通知相关依赖进行更新操作(发布订阅)。

因此在深入理解Vue双向绑定原理之前,先来了解一下这两种设计模式。

1.观察者模式

当对象间存在一对多关系时,则使用观察者模式。比如,当一个对象被修改时,则会自动通知依赖它的对象。观察者模式属于行为型模式。

class Observer {
  /**
   * 构造器
   * @param {Function} cb 回调函数,收到目标对象通知时执行
   */
  constructor(cb){
      if (typeof cb === 'function') {
          this.cb = cb
      } else {
          throw new Error('Observer构造器必须传入函数类型!')
      }
  }
  /**
   * 被目标对象通知时执行
   */
  update() {
      this.cb()
  }
}

class Subject {
  constructor() {
      // 维护观察者列表
      this.observerList = []
  }
  /**
   * 添加一个观察者
   * @param {Observer} observer Observer实例
   */
  addObserver(observer) {
      this.observerList.push(observer)
  }
  /**
   * 通知所有的观察者
   */
  notify() {
      this.observerList.forEach(observer => {
          observer.update()
      })
  }
}

const observerCallback = function() {
  console.log('我被通知了')
}
const observer = new Observer(observerCallback)

const subject = new Subject();
subject.addObserver(observer);
subject.notify();

2.发布订阅模式

发布订阅模式是观察者模式的一种实现,两者有细微的差别。

  • 观察者模式由具体目标调度,每个被订阅的目标里面都需要有对观察者的处理,会造成代码的冗余。
  • 发布订阅模式则统一由调度中心处理,消除了发布者和订阅者之间的依赖。

image.png

实现一个发布订阅模式:

  • clientList是一个缓存列表,存放订阅者的回调函数。注意这里是一个对象,而不是一个数组,这是因为我们传不同的key值调度的任务不同
  • listen是添加消息的方法,也就是订阅的方法
  • remove是取消订阅的方法
  • trigger是发布消息,每当发布一个key类型的事件,会执行列表里面的所有函数
 var even= {
  clientList: {},
  listen: function(key, fn) {
    if (!this.clientList[key]) {
      this.clientList[key] = []
    }
    this.clientList[key].push(fn)
  },
  remove: function(key, fn) {
    var fns = this.clientList[key];
    
    if (!fn) {
      fns && (fns.length = 0);
    } else {
      for (let i = 0; i < fns.length; i++) {
        if (fn === fns[i]) {
          fns.splice(i, 1)
        }
      }
    }
  },
  trigger: function(key) {
    var fns = this.clientList[key];
  
    if (!fns || fns.length === 0) {
      return;
    }
    
    var args = Array.prototype.slice.call(arguments, 1);
    console.log(fns.length)
    for(var i = 0; i < fns.length; i++) {
      fns[i].call(this, ...args);
    }
  }
}

var installEvent = function(obj) {
  for (var i in even) {
    if (typeof even[i] === 'object') {
      obj[i] = JSON.parse(JSON.stringify(even[i]))
    } else {
      obj[i] = even[i];
    }
  }
}

var salesOffices1 = {}
installEvent(salesOffices1);


// 小明订阅售楼处1的88平米房子
salesOffices1.listen('squareMeter88', fn1 = function(price, squareMeter) {
  console.log('price = ' + price);
});
salesOffices1.trigger('squareMeter88', 20000, 88) // price = 20000

salesOffices1.remove('squareMeter88', fn1);
salesOffices1.trigger('squareMeter88', 20000, 88)

再看一道面试题加强一下,描述如下:

  • on:相当于订阅一个消息
  • run:相当于发布一个消息
  • off:取消发布订阅
  • once:是订阅后,发布第一次可以接受消息,后面就不再执行
let Even = {
  clientList: {},
  onceList: {},
}
Even.on = function(type, fn) {
  if (!this.clientList[type]) {
    this.clientList[type] = [];
  }
  this.clientList[type].push(fn);
}
Even.run = function(type) {
  let fns = this.clientList[type];
  let fnsOnce = this.onceList[type];
  
  if(!fns && !fnsOnce) return;
  if (fns) {
    for (let i = 0; i < fns.length; i++) {
      fns[i].apply(this, Array.prototype.slice(arguments, 1))
    }
  }
  if (fnsOnce) {
    for (let i = 0; i < fnsOnce.length; i++) {
      fnsOnce[i].apply(this, Array.prototype.slice(arguments, 1))
    }
    this.onceList = []
  }
}
Even.off = function(type) {
  this.clientList[type] = []
}
Even.once = function(type, fn) {
  if (!this.onceList[type]) {
    this.onceList[type] = []
  }
  this.onceList[type].push(fn);
}

Even.on('a', fn = function() {
  console.log('a')
})

Even.once('b', function() {
  console.log('b')
})

Even.run('a') // a
Even.run('b') // b
Even.run('b') // 第二次,不执行
Even.run('a') // a
Even.off('a') // 卸载a
Even.run('a') // a已被卸载,不执行

vue数据双向绑定

在了解了发布订阅模式后我们再来看看vue的数据双向绑定原理。

  • 当视图变化时,我们需要通知数据进行变化。这里可以通过事件监听实现。
  • 当数据变化时,通知视图进行改变,这一点是我们需要实现的重点内容。

要实现数据变化,更新所有的视图。根据发布订阅的思想:

  • 执行的视图更新事件的地方就是订阅者
  • 我们关注的对象(可以理解为vue模板中的data里定义的所有数据)就是发布者,该对象内部的某个数据的变化就是发布一个消息,并通知相关的订阅者(也就是使用到了该数据的地方)进行视图更新。

明白了这两点就思路明确,目标清晰。

1. Object.defineProperty

考虑第一个问题,我们如何知道数据会进行更新呢?在vue2.x中是使用Object.defineProperty进行数据侦测来实现的。(不了解的可以去MDN或者红宝书上看相关的使用方法)

function defineReactive (data, key, val) {
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
    get: function() {
      dep.push(window.target);
      return val;
    },
    set: function(newVal) {
      if (newVal === val) {
        return
      }
      val = newVal
    }
  })
}

我们在开头分析的地方了解了,数据双向绑定要实现的其实就是先收集data对象中每个数据的所有订阅者,当data对象中的某个数据更新时,再通知对应的所有依赖进行视图更新。

在了解了Object.defineProperty后,我们的目标更加明确,就是在getter中收集依赖(订阅者),setter中触发依赖(发布消息)。

2. 依赖收集在哪里?

我们已经知道了要在getter中收集依赖,那么收集的依赖放在哪呢?我们新定义一个Dep类,用于data每个数据收集的依赖存放的地方。

class Dep {
  constructor () {
    this.subs = []
  }

  // 添加订阅
  addSub(sub) {
    this.subs.push(sub)
  }

  // 取消订阅
  removeSub(sub) {
    for (let i = 0; i < this.subs.length; i++) {
      if (this.subs[i] === sub) {
        this.subs.slice(i, 1)
      }
    }
  }

  depend() {
    if (window.target) {
      this.addSub(window.target)
    }
  }

  notify() {
    const subs = this.subs.slice()
    for (let i = 0; i < subs.length; i++) {
      subs[i].update()
    }
  }
}

对应的修改defineReactive

function defineReactive (data, key, val) {
  let dep = new Dep();
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
    get: function() {
      dep.depend();
      return val;
    },
    set: function(newVal) {
      if (newVal === val) {
        return
      }
      val = newVal
      dep.notify()
    }
  })
}

我们看到,当data的某个数据被调用时,会触发get,进而调用Depdepend方法。也就是说我们收集的依赖是这个window.target。当数据进行更新时,还会触发每个依赖里面收集的update方法。

3. 什么是window.target ? Watcher 闪亮登场

看到上面可能你已经长舒一口气,大致了解了数据双向绑定的原理。我们继续深挖,收集的依赖(window.target)究竟是什么?

其实这个window.target是一个中介角色Watcher,当数据变化时通知它,它再去通知其他地方。

class Watcher {
 constructor(vm, exp, cb) {
   this.vm = vm;
   this.exp = exp;
   this.cb = cb;
   this.value = this.get(); // 将自己添加到订阅器
 }

 get() {
   window.target = this; // 全局变量 订阅者 赋值
   let value = this.getter.call(this.vm, this.vm) // 强制执行监听器里的get函数,将window.target添加到Dep中
   window.target = undefined; // 全局变量 订阅者 释放
   return value;
 }

 update() {
   const oldValue = this.value;
   this.value = this.get()
   this.cb.call(this.vm, this.val, this.oldValue);
 }
}

这里比较巧妙的地方就是当依赖使用到data中的某个数据时,会创建一个Watcher对象,并且会触发Watcher里的get。再通过在全局设计一个window.target,强行触发数据的getter,达到将依赖添加到Dep中的目的。

4. 封装所有属性 Observer

前面其实已经可以侦测数据中的属性的变化了,但是只能封装对象中的某一个属性,我们希望封装一个对象的所有属性,所以在添加一个Observer

class Observer {
 constructor(value) {
   this.value = value

   if (!Array.isArray(value)) {
     this.walk(value)
   }
 }

 walk(obj) {
   const keys = Object.keys(obj)
   for (let i = 0; i < keys.length; i++) {
     defineReactive(obj, keys[i], obj[keys[i]])
   }
 }
}

function defineReactive (data, key, val) {
 // 递归对象属性
 if (typeof val === 'object') {
   new Observer(val)
 }
 let dep = new Dep();
 Object.defineProperty(data, key, {
   enumerable: true,
   configurable: true,
   get: function() {
     dep.depend();
     return val;
   },
   set: function(newVal) {
     if (newVal === val) {
       return
     }
     val = newVal
     dep.notify()
   }
 })
}

Array的变化侦测

vue2.x对象的变化侦测是通过Object.defineProperty进行数据劫持完成的,但是如this.list.push(1)这种对数组的操作是通过Array原型上的方法来改变数组的内容,不会触发gettersetter

在ES6之前,js没有提供元编程的能力,也就是没有提供可以拦截原型的方法。但我们可以使用一个拦截器覆盖Array.prototype,因此每次访问push等原型上的方法时,相当于执行拦截器上提供的方法。

image.png

1. 拦截器

下面就是一个拦截器arrayMethods继承自Array.prototype,具备其拥有的功能。然后将那些可以更改数组自身的方法进行封装,这样当一个数组调用Array.prototype.push时,就相当于调用了arrayMethods.push。最后我们可以在mutator中执行原有的Array.prototype.push,还可以做一些其他的事情,比如说发变化通知。

const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)

let a = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse',
].forEach(function (method) {
  const original = arrayProto[method]
  Object.defineProperty(arrayMethods, method, {
    value: function mutator(...args) {
      return original.apply(this, args)
    },
    enumerable: true,
    writable: true,
    configurable: true,
  })
})

有了拦截器后,再对数组进行拦截

function defineReactive (data, key, val) {
  // 递归对象属性
  if (typeof val === 'object') {
    new Observer(val)
  }
  let dep = new Dep();
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
    get: function() {
      dep.depend();
      return val;
    },
    set: function(newVal) {
      if (newVal === val) {
        return
      }
      val = newVal
      dep.notify()
    }
  })
}

class Observer {
  constructor(value) {
    this.value = value

    if (Array.isArray(value)) {
      value.__proto__ = arrayMethods
    } else {
      this.walk(value)
    }
  }

  walk(obj) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i], obj[keys[i]])
    }
  }
}

总结

vue的双向绑定包含了两方面,首先是视图更新,通知数据进行变化,这个可以通过js中的事件绑定实现。另一方面,数据进行变化时,需要通知视图进行更新。简单的概括就是:Vue在初始化数据时,会使用Object.defineProperty重新定义data中的所有属性的gettersetter,接下来的过程就是一个发布订阅的过程。当页面使用对应属性时首先会进行依赖收集(收集当前组件的watcher),被收集的依赖会被存放到Dep中,当data对象中的某个属性发生变化会通知所有被收集的依赖,也就是所有的watcher,而watcher是一个中介人,他会通知对应的视图进行更新。

其中重要的几点:

  • 收集依赖的巧妙设计:watcher在被创建时,也就是某个数据的依赖创建时,会设置一个全局的变量window.target存放自身这个watcher,接着强行触发该数据的getter,将这个全局的变量window.target存放到Dep的依赖队列中。
  • 消息通知的过程:当数据有了变化时,它会触发getter函数,getter函数会依次通知自己Dep队列中的每一个watcher,而watcher作为中介人,则会执行相应的函数,通知视图进行更新。

参考文章: