这是我参与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.发布订阅模式
发布订阅模式是观察者模式的一种实现,两者有细微的差别。
- 观察者模式由具体目标调度,每个被订阅的目标里面都需要有对观察者的处理,会造成代码的冗余。
- 发布订阅模式则统一由调度中心处理,消除了发布者和订阅者之间的依赖。
实现一个发布订阅模式:
- 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
,进而调用Dep
中depend
方法。也就是说我们收集的依赖是这个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
原型上的方法来改变数组的内容,不会触发getter
和setter
。
在ES6之前,js没有提供元编程的能力,也就是没有提供可以拦截原型的方法。但我们可以使用一个拦截器覆盖Array.prototype
,因此每次访问push
等原型上的方法时,相当于执行拦截器上提供的方法。
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中的所有属性的getter
和setter
,接下来的过程就是一个发布订阅的过程。当页面使用对应属性时首先会进行依赖收集(收集当前组件的watcher
),被收集的依赖会被存放到Dep
中,当data对象中的某个属性发生变化会通知所有被收集的依赖,也就是所有的watcher,而watcher是一个中介人,他会通知对应的视图进行更新。
其中重要的几点:
- 收集依赖的巧妙设计:watcher在被创建时,也就是某个数据的依赖创建时,会设置一个全局的变量window.target存放自身这个watcher,接着强行触发该数据的getter,将这个全局的变量window.target存放到Dep的依赖队列中。
- 消息通知的过程:当数据有了变化时,它会触发getter函数,getter函数会依次通知自己Dep队列中的每一个watcher,而watcher作为中介人,则会执行相应的函数,通知视图进行更新。
参考文章:
- 《JavaScript设计模式与开发实践》
- 《深入浅出Vue.js》
- 从一道面试题简单谈谈发布订阅和观察者模式
- 0 到 1 掌握:Vue 核心之数据双向绑定