白酒青啤安慕希

image

从Object.defindeProperty与Proxy出发。。。

前言

最近在看一些关于vue3.0的文章,其中提及到vue3.0将使用ES6的Proxy作为其观察者机制,取代之前使用的Object.defineProperty。我们都知道vue实现双向绑定是基于数据劫持从而监听数据变化来响应渲染函数实现dom更新。那么问题来了。为什么要取代呢?他们之前的优劣在哪?分别是怎么实现的数据劫持?他们的具体实现过程(原理)是什么样的?

什么是数据劫持

一般来说就是劫持对象的访问器,在属性值发生变化时我们可以获取变化。或者说通过Object.defineProperty来重写get,set函数。废话有点多,直接上代码。。。

1
2
3
4
5
6
7
8
9
10
11
12
13
const data = {
name:'fuquanmeng'
}
Object.defineProperty(data,'name',{
set : function(newVal) {
console.log(`我修改了变成了${newVal}`)
},
get : function() {
return 'hello word'
}
})
data.name = 'lili' //触发set => 我修改了变成了lili
console.log(data.name)//触发get => hello word

数据劫持的优势

数据劫持的目前应用就在双向绑定上,而双向绑定简单来说无非就是数据变化更新视图,视图变化更新数据。视图变化可以通过事件监听得到,比如input标签就监听input上的事件就行了。重点就是在数据变化更新视图。所以数据劫持的优势就体现了:

  • 可精确得知变化数据。我们劫持了属性的setter,当属性值改变,我们可以精确获知变化的内容newVal。
  • 无需显示调用。例如Vue运用数据劫持+发布订阅,直接可以通知变化后调用渲染函数来驱动视图。

基于Object.defineProperty数据劫持的双向绑定

极简版的双向绑定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<input id="input"></input>   
<span id="span"></span>

const obj = {}

Object.defineProperty(obj,'key',{
set : function(newVal) {
document.getElementById('span').innerHTML = newVal;
}
})

document.getElementById('input').addEventListener('keyup', function(e){
obj.key = e.target.value;
})

但是这个双向绑定貌似问题很多首先这代码耦合性太高数据,方法,dom都连接在一起,而且只能监听对象的一个属性。所以我们要膜拜一下尤大大vue的发布订阅模式来实现。。

发布订阅模式的实现的双向绑定

首先我们要监听所有属性所以要实现一个监听器Observer用来监听所有属性,如果属性变化了就要通知订阅者Watcher看是否需要更新。因为每一个订阅者绑定一个更新函数,所以我们要实现一个订阅器Dep来收集这些订阅者,来负责储存订阅者和消息的分发。所以我们实现的就是:

  • 监听器Observer => 用来劫持并监听所有属性,如果有变动的,就通知订阅者。
  • 订阅者Watcher => 收到属性的变化并执行相应函数来更新视图。
  • 订阅器Dep => 收集所有订阅者,负责消息的分发执行notify。

    实现监听器Observer

    核心方法就是Object.defineProperty()。因为要遍历所有属性(包括子属性),所以要通过递归来遍历监听所有值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
function defineReactive(targetObj,key,val){  //监听的具体方法 
observer(val) //递归遍历子属性的值 如果是复杂类型则继续遍历监听
Object.defineProperty(targetObj,key,{
enumerable: true,
configurable: true,
set: function(newVal){
if (val === newVal) return;
val = newVal
observer(newVal) // 用来监测赋的新值
console.log(`我监听到了${key}发生变化`)
},
get :function(){
return val
}
})
}

function observer(targetObj){ //总方法 监听目标对象
if(!targetObj || typeof targetObj != 'object') return ; // 当值不存在,或者不是复杂数据类型时,不再需要继续深入监听
return new Observer(targetObj);
}

class Observer { //监听者
constructor(targetObj) {
this.targetObj = targetObj;
this.walk(targetObj); //遍历目标对象并监听
}
walk(targetObj){ //遍历目标对象并监听
Object.keys(targetObj).forEach(key => {
defineReactive(targetObj, key, targetObj[key]); //监听的具体方法
})
}
}
var obj = {
name : {
a : ''
},
job : 'js'
}

observer(obj)

obj.name.a = 'blabla' //我监听到了a发生变化
obj.job = 'play' //我监听到了job发生变化

实现订阅器Dep

维护一个数组,用来收集订阅者,数据变化触发notify,再调用订阅者的update方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
function Dep() { //订阅器构造函数
this.subs = []; //存储订阅器的数组
}
Dep.prototype = {
addSub : function(sub) { //添加订阅者Watcher的方法
this.subs.push(sub)
},
notify : function() { //数据变化后通知所有的订阅者Watcher => 所以在监听器里的set方法里加入Dep.notify来通知订阅者Watcher数据变化了
this.subs.forEach(sub => {
sub.update() //触发订阅者Watcher相应的渲染方法
})
}
}

我们知道在初始化vue实例的时候监听器Observer要监听实例的data的所有属性,而每一个属性都要关联一个订阅者Watcher来根据属性的变化来执行相应的函数。而我们的订阅器Dep就是专门来收集订阅者并通知触发各个订阅者的函数。那么问题来了,如何在vue实例化的时候就把订阅者添加到订阅器呢?带着这个疑问我们看一下订阅者Watcher

实现订阅者Watcher

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function Watcher(vm,exp,cb) {
this.vm = vm; //当前vue实例
this.exp = exp; //订阅当前vue实例的属性
this.cb = cb; //数据更新的回调函数
this.value = this.get() //答案在这里:这里有两个作用1.可以保留更新之前的旧值。2.将自己添加到订阅器!!
}
Watcher.propotype = {
get : function() {
Dep.target = this;//把自己(订阅者Watcher)赋值给Dep.target
var value = this.vm.data[exp];//这个操作就是获取实例上data属性的值 => 也就是触发了监听(Object.defineProperty)里的get方法 => 所以我们可以利用这个方法来向dep添加Watcher(也就是Dep.target)
Dep.target = null;//释放自己 用于下一个Watcher使用
return value //获取实例上的值
},
update : function() { //对外暴露接口,当数据更新时候,可以通过Dep.notify通知然后触发update来调用
this.run()
},
run : function() {
var newValue = this.get();//数据更新之后获取的value
var oldValue = this.value;// 保留的更新之前的value
if(newValue != oldValue){
this.value = newValue //更新旧值
this.cb.call(this.vm,newValue,oldValue) //数据更新后的回调函数
}
}
}

所以Dep.target就是连接订阅器Dep和订阅者Watcher的桥梁,并且利用订阅者Watcher实例化的时候触发get方法从而强制触发监听(Object.defineProperty)里的get方法来把自己Watcher添加到订阅器Dep中;所以上面的监听器Observer要做一些修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
function defineReactive(targetObj,key,val){  //监听的具体方法 
observer(val) //递归遍历子属性的值 如果是复杂类型则继续遍历监听
var dep = new Dep(); //实例化订阅器来存储订阅者
Object.defineProperty(targetObj,key,{
enumerable: true,
configurable: true,
set: function(newVal){
if (val === newVal) return;
val = newVal
observer(newVal) // 用来监测赋的新值
dep.notify();//通知所有订阅者,数据改变了
console.log(`我监听到了${key}发生变化`)
},
get :function(){ //触发向订阅器添加订阅者的方法
if(Dep.target){//由上而知Dep.target指向了一个订阅者Watcher,如果有则向订阅器添加
Dep.addSub(Dep.target) //订阅器添加订阅者方法
}
return val
}
})
}

function observer(targetObj){ //总方法 监听目标对象
if(!targetObj || typeof targetObj != 'object') return ; // 当值不存在,或者不是复杂数据类型时,不再需要继续深入监听
return new Observer(targetObj);
}

class Observer { //监听者
constructor(targetObj) {
this.targetObj = targetObj;
this.walk(targetObj); //遍历目标对象并监听
}
walk(targetObj){ //遍历目标对象并监听
Object.keys(targetObj).forEach(key => {
defineReactive(targetObj, key, targetObj[key]); //监听的具体方法
})
}
}

Dep.target = null; //初始化订阅器与订阅者之间的桥梁

到这就可以实现一个简单的从数据响应到dom的绑定了,虽然编译器compile没做,不过不在这篇文章的讨论的范围内。当然Watcher过滤也没做不过都可以暂时忽略。。。 现在我们通过一个简单例子把Observer和Watcher关联起来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<h1 id="name">name</h1>
<button id="change">改变</button>

var element = document.querySelector('#name');
var myVue = new _myVue({'name':'fuquanmeng'},element,'name') // 初始化

document.querySelector('#change').addEventListener('onClick', function(e){
_myVue.data.name = 'lili'
})

function _myVue (data, el, exp) {
this.data = data; //目标对象
observer(data); //监听目标对象
el.innerHTML = this.data[exp]; // 初始化dom模板里的数据
new Watcher(this, exp, function (value) { //目标对象变化后的回调
el.innerHTML = value;
});
return this;
}

到这已经完全实现了基于Object.defineProperty来实现的数据响应到dom的绑定了。Proxy相对于Object.defineProperty来实现数据劫持的优势:

  • 从上面的实现中可以看到Object.defineProperty的数据劫持是要对各个对象的每个属性都要遍历(Object.keys(targetObj).forEach(key => { defineReactive(targetObj, key, targetObj[key]); //监听的具体方法 })),如果属性值也是对象那么需要递归遍历,显然能劫持一个完整的对象是更好的选择。所以Proxy就来了,Proxy可以理解为他在目标对象外层设了一个拦截,外界对目标对象访问时都要通过这层拦截,因此可以对外界的访问进行过滤和改写,也就是所说的数据劫持。所以他的优势就很好体现了。不用递归遍历从而省性能。
  • 如果把上面的{‘name’:’fuquanmeng’}改成{‘name’:[]}然后再onclick函数里写_myVue.data.name.push(111)。显然监听不到了。。。所以Object.defineProperty无法监听数组变化,虽然Vue已经把数组的这些方法[‘push’, ‘pop’, ‘shift’, ‘unshift’, ‘splice’, ‘sort’, ‘reverse’]hack掉了但是其他的数组的属性依旧还是检测不到。而Proxy可以直接监听数组的变化。

还是极简版的双向绑定我们用proxy来改写

基于Proxy的极简双向绑定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<input id="input"></input>   
<span id="span"></span>

const input = document.getElementById('input')
const span = document.getElementById('span')
const obj = {}

const newObj = new Proxy(obj, {
get: function(target, key, receiver) {
console.log(`getting ${key}!`);
return Reflect.get(target, key, receiver);
},
set: function(target, key, value, receiver) {
console.log(target, key, value, receiver);
if (key === 'text') {
span.innerHTML = value;
}
return Reflect.set(target, key, value, receiver);
},
});

input.addEventListener('keyup', function(e) {
newObj.text = e.target.value;
});

监听数组变化就直接const obj = []也是可以监听得到,在这就不列举了。

总结

  • 优势:Proxy相对于Object.defineProperty的优点在于可以检测数组的变化而且不用对目标对象的各个属性遍历监听,而且Proxy返回的是新对象我们可以只操作新对象来达到目的。
  • 劣势:Proxy的劣势就是兼容性问题,而且无法用polyfill来磨平各个浏览器的差异。。。。