实现双向绑定Proxy比defineproperty优劣如何?
关于双向绑定,其实只要涉及到MVVM框架就不得不提到的问题,双向绑定是Vue三要素之一。
Vue三要素
- 响应式: 例如如何监听数据变化,其中的实现方法就是我们提高的双向绑定
- 模板引擎: 如何解析模板
- 渲染: Vue如何将监听到的数据变化和解析后的HTML进行渲染。
下面介绍的双向数据绑定的方法是基于数据劫持的双向绑定。
常见的基于数据挟持的双向绑定有两种实现
一个是目前Vue2.0的
Object.defineProperty
另一个是Vue3.0将要使用的Proxy
通过下面的讲述,会为你揭晓为啥Proxy
会替代Object.defineProperty
1. 基于数据劫持实现的双向绑定特点
1.1 什么是数据劫持
数据劫持比较好理解,通常我们利用Object.defineProperty
劫持对象的访问器,在属性值发生变化时我们可以获取变化,从而进一步进行操作。
const data = {
name: ''
}
function say(name) {
if (name === '古天乐') {
console.log('给大家推荐一款超好玩的游戏');
} else if (name === '渣渣辉') {
console.log('戏我演过很多,可游戏我只玩贪玩懒月');
} else {
console.log('来做我的兄弟');
}
}
Object.keys(data).forEach((key) => {
Object.defineProperty(data, key, {
enumerable: true, // 可遍历
configurable: true, // 属性描述符可改变
get: function () {
console.log('get')
},
set: function (newValue) {
console.log(`大家好,我是${newValue}`)
say(newValue)
}
})
})
data.name = 'huong'
// 大家好,我是huong ---- 数据变化的时候可以监听得到
// 来做我的兄弟
1.2数据劫持的优势
目前业界上存在的两种数据劫持,一种是单向劫持,一种是双向劫持。不过在此我们暂且不讨论单向或者双向的优劣,我们需要讨论一下对比其他双向绑定的实现方法,数据劫持的优势所在。
- 无需显式调用:例如Vue运用数据劫持 + 发布订阅,直接可以通知变化并驱动视图。就上我们上面所展示的例子,
data.name="huong"
设置后直接就触发变更,而其他的比如react需要显示调用setStare
不过本人没有了解过其他的框架,但是我看文章这样的表达,似乎优点类似微信小程序的setData
?。这部分还有待考究
- 可以精确得知变化数据:还是上面的例子,我们劫持了属性的setter,当属性值改变,我们可以精确的获知改变的
newValue
,因此在这部分不需要额外的diff
操作。否则如果我们只知道数据变化了,但是不知道具体哪里发生了变化的时候就需要大量的diff
找出变化之,这是需要额外的消耗的。
而小程序的数据变化使用的就是diff
来对比变化,然后再更新到视图之上的。
1.3基于数据劫持双向绑定的思路
数据劫持是双向绑定的各种方案中比较流行的一种,最著名的实现就是Vue。
基于数据劫持的双向绑定离不开proxy
和Object.defineProperty
等方法对对象/对象属性的"劫持",我们要实现一个完整的双向绑定需要以下几个要点:
- 利用
proxy
或Object.defineProperty
生成的Observe针对对象/对象的属性进行劫持,在属性发生改变后通知到订阅者 - 解析器Compile解析模板中的
Directive
(指令),收集指令所依赖的方法和数据,等待数据变化然后进行渲染。 - Watcher属于Observer和Compile桥梁,它将接收到Observer产生的数据变化,并根据Compile提供的指令进行视图渲染,使得数据变化促使视图变化
我们可以看到,虽然Vue运用了数据劫持,但是仍然离不开发布订阅模式。如果不熟悉,需要学习一下。
2.基于Object.defineProperty双向绑定的特点
关于Object.defineProperty
的文章在网络上有很多,以下我们主要讲解Object.defineProperty
的特点,方便接下来与propxy
作对比
2.1 极简版的双向绑定
我们都知道.Object.defineProperty
的作用就是劫持一个对象的属性,通常我们对属性的getter
和setter
方法进行劫持,在对象的属性发生改变时进行特定的操作
我们就对对象obj
的text
属性进行劫持,在获取此属性的值时,打印get val
,在更改属性值的时候对DOM进行操作,这就是一个极简的双向绑定
const obj = {}
Object.defineProperty(obj,'text',{
get:function(){
console.log('get val')
},
set:function(newValue){
console.log('set val' + newValue)
document.getElementById('input').value = newValue;
document.getElementById('span').innerHTML = newValue
}
})
const input = document.getElementById('input')
input.addEventListener('keyup', function(e){
obj.text = e.target.value
})
虽然上面实现了双向绑定,但其实这样仅仅只对一个属性的双向绑定,是没有太大用处的,下面将对它进行升级。
2.2升级改造
关于升级原因如下:
- 我们只监听了一个属性,一个对象不可能只有一个属性,我们需要对对象每个属性都进行监听
- 违反了开放封闭原则,我们每次的修改都需要进入方法内部,这是需要坚决杜绝的。
- 代码耦合性严重,可以看到数据和方法还有DOM都是耦合在一起的,这就是所谓的面条代码
下面将解决上面所描述的问题
Vue的操作就是加入了发布订阅者模式,结合Object.defineProperty
的劫持能力,实现了可用性很高的双向绑定
从发布订阅的角度看我们刚才编写的代码,可以很明显的发现,它的监听,发布和订阅都是写在一起的。所以我们首先需要作的就是解耦。
我们先实现一个订阅发布中心,即消息管理员(Dep),它负责存储订阅者和消息的分发,不管订阅者还是发布者都需要依赖于它
let uid = 0;
// 用于储存订阅者并发布消息
class Dep {
constructor() {
// 设置id,用于区分新Watcher和只改变属性值后新产生的Watcher
this.id = uid++;
// 储存订阅者的数组
this.subs = [];
}
// 触发target上的Watcher中的addDep方法,参数为dep的实例本身
depend() {
Dep.target.addDep(this);
}
// 添加订阅者
addSub(sub) {
this.subs.push(sub);
}
notify() {
// 通知所有的订阅者(Watcher),触发订阅者的相应逻辑处理
this.subs.forEach(sub => sub.update());
}
}
// 为Dep类设置一个静态属性,默认为null,工作时指向当前的Watcher
Dep.target = null;
现在我们需要实现监听者(Observer),用于监听属性的变化
// 监听者,监听对象属性值的变化
class Observer {
constructor(value) {
this.value = value;
this.walk(value);
}
// 遍历属性值并监听(每一个值)
walk(value) {
Object.keys(value).forEach(key => this.convert(key, value[key]));
}
// 执行监听的具体方法
convert(key, val) {
defineReactive(this.value, key, val);
}
}
function defineReactive(obj, key, val) {
const dep = new Dep();
// 给当前属性的值添加监听
let chlidOb = observe(val); // 进一步监听到obj的子节点上的obj(递归,直到子元素不再是一个对象)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: () => {
// 如果Dep类存在target属性,将其添加到dep实例的subs数组中
// target指向一个Watcher实例,每个Watcher都是一个订阅者
// Watcher实例在实例化过程中,会读取data中的某个属性,从而触发当前get方法
if (Dep.target) {
dep.depend();
}
return val;
},
set: newVal => {
if (val === newVal) return;
val = newVal;
// 对新值进行监听
chlidOb = observe(newVal);
// 通知所有订阅者,数值被改变了
dep.notify();
},
});
}
function observe(value) {
// 当值不存在,或者不是复杂数据类型时,不再需要继续深入监听
if (!value || typeof value !== 'object') {
return;
}
return new Observer(value);
}
接下来我们再实现一个订阅者(Watcher)
class Watcher {
constructor(vm, expOrFn, cb) {
this.depIds = {}; // hash储存订阅者的id,避免重复的订阅者
this.vm = vm; // 被订阅的数据一定来自于当前Vue实例
this.cb = cb; // 当数据更新时想要做的事情
this.expOrFn = expOrFn; // 被订阅的数据
this.val = this.get(); // 维护更新之前的数据
}
// 对外暴露的接口,用于在订阅的数据被更新时,由订阅者管理员(Dep)调用
update() {
this.run();
}
addDep(dep) {
// 如果在depIds的hash中没有当前的id,可以判断是新Watcher,因此可以添加到dep的数组中储存
// 此判断是避免同id的Watcher被多次储存
if (!this.depIds.hasOwnProperty(dep.id)) {
dep.addSub(this);
this.depIds[dep.id] = dep;
}
}
run() {
const val = this.get();
console.log(val);
if (val !== this.val) {
this.val = val;
this.cb.call(this.vm, val);
}
}
get() {
// 当前订阅者(Watcher)读取被订阅数据的最新更新后的值时,通知订阅者管理员收集当前订阅者
Dep.target = this;
const val = this.vm._data[this.expOrFn];
// 置空,用于下一个Watcher使用
Dep.target = null;
return val;
}
}
我们最后完成Vue,将上述方法挂在再Vue上
class Vue {
constructor(options = {}) {
// 简化了$options的处理
this.$options = options;
// 简化了对data的处理
let data = (this._data = this.$options.data);
// 将所有data最外层属性代理到Vue实例上
Object.keys(data).forEach(key => this._proxy(key));
// 监听数据
observe(data);
}
// 对外暴露调用订阅者的接口,内部主要在指令中使用订阅者
$watch(expOrFn, cb) {
new Watcher(this, expOrFn, cb);
}
_proxy(key) {
Object.defineProperty(this, key, {
configurable: true,
enumerable: true,
get: () => this._data[key],
set: val => {
this._data[key] = val;
},
});
}
}
至此,一个简单的双向绑定算是实现了。
2.3Object.defineProperty的缺陷
其实上面的版本还是存在缺陷的,如果将值改为数组,则无法监听其变化。但是Vue文档中的检测方法是用以下几种方式
push()
pop()
shift()
unshift()
splice()
sort()
reverse()
Object.defineProperty
的第二个缺陷是,只能挟持对象的属性,因此,我们需要对每个对象进行遍历,甚至如果属性的值是对象,那就需要深度遍历
Object.keys(value).forEach(key => this.convert(key, value[key]));
3.Proxy实现的双向绑定的特点
Proxy 在ES2015规范中被正式发布,它就像一层拦截。外界对该对象的访问,都必须先通过这层拦截。因此它提供了一种机制,可以对外界的访问进行过滤和改写
3.1 Proxy可以直接监听对象而非属性
我们把上面极简版的双向绑定改写成Proxy
的版本
const input = document.getElementById('input');
const p = document.getElementById('p');
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') {
input.value = value;
p.innerHTML = value;
}
return Reflect.set(target, key, value, receiver);
},
});
input.addEventListener('keyup', function(e) {
newObj.text = e.target.value;
});
可以看到,Proxy是直接挟持的是整个对象,并且返回的是一个新对象。比起Object.defineProperty
显然是更方便的。
3.2 Proxy可以直接监听数组的变化
当我们对数组进行操作(push、shift、splice等)时,会触发对应的方法名称和length的变化,我们可以借此进行操作,以上文中Object.defineProperty
无法生效的列表渲染为例。
const list = document.getElementById('list');
const btn = document.getElementById('btn');
// 渲染列表
const Render = {
// 初始化
init: function (arr) {
const fragment = document.createDocumentFragment();
for (let i = 0; i < arr.length; i++) {
const li = document.createElement('li');
li.textContent = arr[i];
fragment.appendChild(li);
}
list.appendChild(fragment);
},
// 我们只考虑了增加的情况,仅作为示例
change: function (val) {
const li = document.createElement('li');
li.textContent = val;
list.appendChild(li);
},
};
// 初始数组
const arr = [1, 2, 3, 4];
// 监听数组
const newArr = new Proxy(arr, {
get: function (target, key, receiver) {
console.log(key);
return Reflect.get(target, key, receiver);
},
set: function (target, key, value, receiver) {
console.log(target, key, value, receiver);
if (key !== 'length') {
Render.change(value);
}
return Reflect.set(target, key, value, receiver);
},
});
// 初始化
window.onload = function () {
Render.init(arr);
}
// push数字
btn.addEventListener('click', function () {
newArr.push(6);
});
很显然,Proxy不需要那么多hack(即使hack也无法完美实现监听)就可以无压力监听数组的变化,我们都知道,标准永远优先于hack。
3.3 Proxy的其他优势
Proxy有多达13种拦截方法,不限于apply、ownKeys、deleteProperty、has等等是Object.defineProperty不具备的。
Proxy返回的是一个新对象,我们可以只操作新的对象达到目的,而Object.defineProperty只能遍历对象属性直接修改。
Proxy作为新标准将受到浏览器厂商重点持续的性能优化,也就是传说中的新标准的性能红利。
当然,Proxy的劣势就是兼容性问题,而且无法用polyfill磨平,因此Vue的作者才声明需要等到下个大版本(3.0)才能用Proxy重写。
网友评论