撸一个vue的双向绑定
1、前言
说起双向绑定可能大家都会说:Vue内部通过Object.defineProperty方法属性拦截的方式,把data对象里每个数据的读写转化成getter/setter,当数据变化时通知视图更新。虽然一句话把大概原理概括了,但是其内部的实现方式还是值得深究的,本文就以从简入繁的形式给大家撸一遍,让大家了解双向绑定的技术细节。
2、来一个简单的版本
让我们的数据变得可观测,实现原理不难,利用Object.defineProperty重新定义对象的属性描述符。
/** * 把一个对象的每一项都转化成可观测对象 * @param { Object } obj 对象 */ function observable(obj) { if (!obj || typeof obj !== 'object') { return; } let keys = Object.keys(obj); keys.forEach((key) => { defineReactive(obj, key, obj[key]) }) return obj; } /** * 使一个对象转化成可观测对象 * @param { Object } obj 对象 * @param { String } key 对象的key * @param { Any } val 对象的某个key的值 */ function defineReactive(obj, key, val) { Object.defineProperty(obj, key, { get() { console.log(`${key}属性被读取了`); return val; }, set(newVal) { console.log(`${key}属性被修改了`); val = newVal; } }) } let car = observable({ 'brand': 'BMW', 'price': 3000 }) //测试 console.log(car.brand);
3、一步一步实现一个观察者模式的双向绑定
先给一张思维导向图吧(图盗的,链接:https://www.cnblogs.com/libin-1/p/6893712.html),本文章不涉及Compile部分。
这张图我就不解释,我们先跟着一步一步的把代码撸出来,再回头来看这张图,问题不大。
建议在读之前一定要了解观察者模式和发布订阅模式以及其区别,一篇简单的文章总结了一下两种模式的区别(链接:https://www.cnblogs.com/chenlei987/p/10504956.html),Vue的双向绑定使用的就是观察者模式,其中Dep对象就是观察者的目标对象,而Watcher就是观察者,然后等待Dep对象的通知更新的,其中update方法是由watcher自己管理的,并非如发布订阅模式由目标对象去管理,在观察者模式中,目标对象管理的订阅者列表应该是Watcher本身,而不是事件/订阅主题。
3.1、声明一个Vue类,并将data里面的数据代理到Vue实例上面。
var Vue = (function (){ class Vue{ constructor (options = {}){ //简化处理 this.$options = options; let data = (this._data = typeof this.$options.data == 'function' ? this.$options.data() : this.$options.data); Object.keys(data).forEach(key =>{ this._proxy(key) }); // 监听数据 //observe(data); } _proxy (key){ //用this这个对象去代理 this._data这个对象里面的key Object.defineProperty(this, key, { configurable: true, enumerable: true, set: (val) => { this._data[key] = val }, get: () =>{ return this._data[key] } }) } } return Vue; } let VM = new Vue({ data (){ return { a: 1, arr: [1,2,3,4,5,6] } }, }); //说明 _proxy代理成功了 console.log(VM.a); VM.a = 2; console.log(VM.a);
3.2、让data里面的数据变得可观测,开启observe之旅
注:下面我所说的"data里面"就是指vue实例的data属性。
上面代码Vue类的constructor里面我注释了一行代码,下面我取消注释,并且开始定义observe函数
// 监听数据 observe(data);
在定义observe方法之前,首先明白我们observe要做什么?
实参是data数据,我们要遍历整个data数据的key,为data数据的每一个key都用Object.defineProperty去重新定义它的 getter和setter函数,从而使其可观测。
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); Object.defineProperty(obj, key, { enumerable: true, configurable: true, get: () => { //do something // if (Dep.target) { // dep.depend(); //} return val; }, set: newVal => { if (val === newVal) return; val = newVal; //do something // 对新值进行监听 //chlidOb = observe(newVal); // 通知所有订阅者,数值被改变了 //dep.notify(); }, }); } function observe(value) { // 当值不存在,或者不是复杂数据类型时,不再需要继续深入监听 if (!value || typeof value !== 'object') { return; } return new Observer(value); }
看到在get和set函数里面的do something了吗,可以理解为在data里面的每个key的设置和获取都被我们截取到了,在每个key的设置和获取时我们可以干些事情了。比如更数据对应的DOM。
要做什么呢?
get函数: 从思维图图1可以看出需要把当前的Watcher添加进Dep对象,等待数据更新,调用回调。
set函数: 数据更新,Dep对象通知所有订阅的watcher更新,调用回调,更新视图。
3.3、Watcher
先声明一个Watcher类,用于添加进Dep对象并通知更新视图使用。
let uid = 0; class Watcher { constructor(vm, expOrFn, cb) { // 设置id,用于区分新Watcher和只改变属性值后新产生的Watcher this.id = uid++; 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被多次储存 //这里要是不限制重复,你会发现在响应的过程中,Dep实例下的subs会成倍的增加watcher。多输入几个字浏览器就卡死了。 if (!dep.depIds.hasOwnProperty(this.id)) { dep.addSubs(this); dep.depIds[this.id] = dep; } } run() { const val = this.get(); if (val !== this.val) { this.val = val; this.cb.call(this.vm, val); } } get() { // 当前订阅者(Watcher)读取被订阅数据的最新更新后的值时,通知订阅者管理员收集当前订阅者 Dep.target = this; //注意:在这里获取该属性 从而就触发了defineProperty的get方法,该watcher已经进入Dep的subs队列了 const val = this.vm._data[this.expOrFn]; //初始化执行一遍回调 this.cb.call(this.vm, val); // 置空,用于下一个Watcher使用 Dep.target = null; return val; } }
上面代码我们先从constructor看起,接受三个参数,vm当前的vue实例,expOrFn实例化时该watcher实例所 代表/处理 的"data里面"(‘data里面’上面有解释,这里提醒一下)的哪个值,cb,回调函数,也就是当数据更新后需要做什么(自然是更新DOM咯)。
然后在constructor里面还调用了 this.get()。详细看一下get函数的定义,两行代码需要注意:
// 当前订阅者(Watcher)读取被订阅数据的最新更新后的值时,通知订阅者管理员收集当前订阅者 Dep.target = this; //注意:在这里获取该属性 从而就触发了defineProperty的get方法,该watcher已经进入Dep的subs队列了 const val = this.vm._data[this.expOrFn];
Dep.target = this;
确定了当前的活动的watcher实例,Dep.target
我们可以认为它是一个全局变量,用于存放当前活动的watcher实例。const val = this.vm._data[this.expOrFn];
获取数据,这句话其实就已经触发了其自身的getter方法(这点要注意,不然你连流程都理解不通)。
进入了getter方法,也就把当前活动的实例的watcher添加进dep对象等待更新。
添加进Dep对象后,置空,用于下一个Watcher使用 Dep.target = null;
3.4、Dep
一直在说dep对象,我们一定要知道dep对象就是观察者模式里面的目标对象,用于存放watcher和负责通知更新的。
下面来定义一个Dep对象,放到class Watcher前面。 注意Dep的作用范围.
class Dep{
constructor (){
this.depIds = {}; // hash储存订阅者的id,避免重复的订阅者 //订阅者列表 watcher实例列表 this.subs = []; } depend (){ Dep.target.addDep(this);//相当于调用this.addSubs 将 watcher实例添加进订阅列表 等待通知更新 //本来按照我们的理解,在denpend里面是需要将watcher添加进 Dep对象, 等待通知更新的,所以应该调用 this.addSubs(Dep.target) //但是由于需要解耦 所以 先调用 watcher的addDep 在addDep中调用Dep实例的addSubs //简化理解就是 将 watcher实例添加进订阅列表 等待通知更新 } addSubs (sub) { //这里的sub肯定是watcher实例 this.subs.push(sub); } notify (){ //监听到值的变化,通知所有订阅者watcher更新 this.subs.forEach((sub) =>{ sub.update(); }); } } Dep.target = null;//存储当前活动的watcher
再改改defineReactive,把注释打开
function defineReactive(obj, key, val) { const dep = new Dep(); // 给当前属性的值添加监听 let chlidOb = observe(val); 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(); }, }); }
然后起一个watcher来监听
3.5、让数据响应起来
先给Vue暴露一个方法 $watcher 可以调用实例化Watcher。
class Vue{ constructor (options = {}){ //简化处理 this.$options = options; let data = (this._data = typeof this.$options.data == 'function' ? this.$options.data() : this.$options.data); Object.keys(data).forEach(key =>{ this._proxy(key) }); // 监听数据 observe(data); } // 对外暴露调用订阅者的接口,内部主要在指令中使用订阅者 $watch(expOrFn, cb) { //property需要监听的属性 cb在监听到更新后的回调 new Watcher(this, expOrFn, cb); } _proxy (key){ //用this这个对象去代理 this._data这个对象里面的key Object.defineProperty(this, key, { configurable: true, enumerable: true, set: (val) => { this._data[key] = val }, get: () =>{ return this._data[key] } }) } }
3.6、测试: 声明一个实例
html部分
<h3>Vue双向绑定</h3> <input type="text" id="input"> <p id="react"></p> <h3>Vue数组双向绑定</h3> <input type="text" id="arr-input"> <p id="arr-reat"></p>
let reactElement = document.querySelector("#react"); let input = document.getElementById('input'); input.addEventListener('keyup', function (e) { VM.a = e.target.value; }); VM.$watch('a', val => reactElement.innerHTML = val); //监听属性 a 当a发生改变时 //数组的响应并不能实现 let arrReactElement = document.querySelector("#arr-reat"); let arrInput = document.getElementById('arr-input'); arrInput.addEventListener('keyup', function (e) { VM.arr.push(e.target.value); console.log(VM.arr); }); VM.$watch('arr', val => arrReactElement.innerHTML = val); //监听属性 a 当a并没有发生改变时
VM.$watch就可以实例化一个watcher,从而去劫持data里面某个属性的改变,在改变时调用回调函数。
数组的改变并没有实现。上面的代码见https://gitee.com/cchennlleii/MyTest/blob/master/vueReactive/vue-reactive%E7%AE%80%E5%8D%95%E5%AE%9E%E7%8E%B0.html
4、对数组的支持
在说这个之前我们先去看一看vue官网对于数组更新检测的说明,链接:https://cn.vuejs.org/v2/guide/list.html#%E6%95%B0%E7%BB%84%E6%9B%B4%E6%96%B0%E6%A3%80%E6%B5%8B
总的来说,对于数组支持更新的只是数组原型上的方法,对于vm.items[index] = newValue是不支持的。
其实Object.defineProperty对于数组都是不支持的,根据消息vue3.0用的proxy对于数组得到了完美的支持,但是兼容性不怎么样。
既然vue实现了对数组原型方法的支持,那么我们也来让我们的例子对数组方法也支持吧。
原理不难,vue对于所有的数组原型方法都写了一层hack,让其支持更新。那么下面我们就一步一步来实现。
4.1、准备一套数组原型方法的hack
/** * Define a expOrFn. */ function def(obj, key, val, enumerable) { Object.defineProperty(obj, key, { value: val, enumerable: !!enumerable, writable: true, configurable: true }); } //数组改变的监听 var arrayProto = Array.prototype; var arrayMethods = Object.create(arrayProto); var methodsToPatch = [ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse' ]; /** * Intercept mutating methods and emit events */ methodsToPatch.forEach(function (method) { // cache original method var original = arrayProto[method]; def(arrayMethods, method, function mutator() { var args = [], len = arguments.length; while (len--) args[len] = arguments[len]; var result = original.apply(this, args); var ob = this.__ob__; var inserted; switch (method) { case 'push': case 'unshift': inserted = args; break case 'splice': inserted = args.slice(2); break } if (inserted) { ob.observeArray(inserted); } // notify change ob.dep.notify(); //调用该数组下的 __ob__.dep 详细可见class Observer的constructor里的注释 return result }); });
上面代码准备了一个arrayMethods的对象,它继承自Array.prototype,并且对methodsToPatch里面的方法进行了改写,后面我们会把arrayMethods这个对象挂到"data里面"每个数组下,让该数组调用数组原生方法,比如[].push其实调用的是arrayMethods里面被改写的方法,从而在该数组改变时获取到该数组的更新。
下面开始挂载arrayMethods对象,在挂载我之前我们看到有一个this.__ob__属性,这里的this指向要观测的数组。这个__ob__就是前面的observe对象,并且每个observe下面还有一个dep对象。下面我们来理清楚这层关系。
class Observer{ constructor (value){ this.value = value; //下面两行代码虽然很简单,但是我们需要从这里理清楚关系 //假如 有数据如 {a: [1,2,3], b: 1}, 然后调用oberve(vm.a),vm当前vue实例 //会自动挂载 __ob__ 和 __ob__.dep // 那么对数组a进行oberserve的对象就是a.__ob__, 它所对应的dep对象就是 a.__ob__.dep //详细使用可以在对数组的方法进行hack的时候 使用到 def(value, '__ob__', this);//让被监听的数据都带上一个不可枚举的属性 __ob__ 代表observe对象 this.dep = new Dep();//首先每个oberserve实例下有一个dep对象 //在这里处理数组 if (Array.isArray(value)){ //调用数组的hack方法, 让数组也能被监听 arrayMethods var arrayKeys = Object.getOwnPropertyNames(arrayMethods); for (var i = 0, l = arrayKeys.length; i < l; i++) { var key = arrayKeys[i]; def(value, key, arrayMethods[key]); } } else{ //对象 遍历key 添加监听 this.walk(value); } } //Observer的其他方法 //... }
上面代码首先给每个值挂载__ob__属性(不可枚举),然后给每个Obeserve对象挂载Dep对象。然后根据value的类型,如果是数组就会挂载arrayMethods方法。
现在我们来理清数组在哪里依赖收集,在哪里通知更新的。
在对数组hack的方法里面(上上一段代码)有一段ob.dep.notify(); 这里通知更新,所以依赖收集也一定要收集到value.__ob__.dep对象里面,两个dep对象应该是相同的,下面我们来看看依赖收集写在哪里的。
function defineReactive(obj, key, val) { const dep = new Dep(); // 给当前属性的值添加监听 let childOb = observe(val); Object.defineProperty(obj, key, { enumerable: true, configurable: true, get: () => { // 如果Dep类存在target属性,将其添加到dep实例的subs数组中 // target指向一个Watcher实例,每个Watcher都是一个订阅者 // Watcher实例在实例化过程中,会读取data中的某个属性,从而触发当前get方法 if (Dep.target) { dep.depend(); if (childOb) { childOb.dep.depend(); if (Array.isArray(val)) { dependArray(val); } } } return val; }, set: newVal => { if (val === newVal) return; val = newVal; // 对新值进行监听 childOb = observe(newVal); // 通知所有订阅者,数值被改变了 dep.notify(); }, }); } function dependArray(value) { for (var e = (void 0), i = 0, l = value.length; i < l; i++) { e = value[i]; e && e.__ob__ && e.__ob__.dep.depend(); if (Array.isArray(e)) { dependArray(e); } } }
数组虽然在Object.defineProperty里面set方法无法响应,但是get方法是没有问题的,所以在数组get的时候,判断val如果是array,会调用value.__ob__.dep.depend进行依赖收集。与上面依赖通知使用了同意个dep对象,也就是挂载在自身的__ob__.dep。
写到这里我们就完全实现对数组原生方法的支持了。
下面看一下效果 代码地址:https://gitee.com/cchennlleii/MyTest/blob/master/vueReactive/vue-reactive%E5%AF%B9%E6%95%B0%E7%BB%84%E7%9A%84%E6%94%AF%E6%8C%81.html
4.2测试代码
html部分
<h3>Vue双向绑定</h3> <input type="text" id="input"> <p id="react"></p> <h3>Vue数组双向绑定</h3> <input type="text" id="arr-input"> <p id="arr-reat"></p> <h3>Vue对nextTick实现</h3> <button id="addBtn">加100000次</button> <p id="react-tick"></p>
let reactElement = document.querySelector("#react"); let input = document.getElementById('input'); input.addEventListener('keyup', function (e) { VM.a = e.target.value; }); VM.$watch('a', val => reactElement.innerHTML = val); //监听属性 a 当a发生改变时 //数组的响应并能实现 let arrReactElement = document.querySelector("#arr-reat"); let arrInput = document.getElementById('arr-input'); arrInput.addEventListener('keyup', function (e) { VM.arr.push(e.target.value); console.log(VM.arr); }); VM.$watch('arr', val => arrReactElement.innerHTML = val); //监听属性 a 当a发生改变时 let reactTick = document.querySelector("#react-tick"); VM.$watch('tickData', val => { console.log(val); reactTick.innerHTML = val; }); //监听属性 a 当a发生改变时 document.querySelector('#addBtn').addEventListener('click', function () { for (let i = 0; i < 100000; i++) { VM.tickData = i; } }, false)
效果:
5、对nextTick的支持
vue官网对nextTick的解释:
nextTick如果自己实现就是在下一个envet loop执行,不在本次同步任务中执行。
自己实现一个简单的:
//nextTick的实现 let callbacks = []; let pending = false; function nextTick(cb) { callbacks.push(cb); if (!pending) { pending = true; setTimeout(flushCallbacks, 0); } } function flushCallbacks() { pending = false; const copies = callbacks.slice(0); callbacks.length = 0; for (let i = 0; i < copies.length; i++) { copies[i](); } }
简单理解: 在本次event loop中收集cb(任务),放到下一个event loop去执行。 关于不知道event loop的可以参考这篇文章:https://www.cnblogs.com/chenlei987/p/10479433.html,我总结的很简单。我参考的http://www.ruanyifeng.com/blog/2014/10/event-loop.html。
在理解event loop的同时也需要同时了解 microtask和macrotask的区别。
好了言归正传,在vue的'data里面'某个属性发生了改变,并被观测到后,调用了watcher.update,并不会立即调用watcher.run去更新视图,它会经过nextTick之后再更新视图,说起来有点牵强。
还是第四部=步的代码,没有实现对nextTick的优化。
代码:
<h3>Vue双向绑定</h3> <input type="text" id="input"> <p id="react"></p> <h3>Vue数组双向绑定</h3> <input type="text" id="arr-input"> <p id="arr-reat"></p> <h3>Vue对nextTick实现</h3> <button id="addBtn">加1000次</button> <p id="react-tick"></p> let reactTick = document.querySelector("#react-tick"); VM.$watch('tickData', val => { console.log(val); reactTick.innerHTML = val; }); //监听属性 a 当a发生改变时 document.querySelector('#addBtn').addEventListener('click', function () { for (let i = 0; i < 1000; i++) { VM.tickData = i; } }, false)
效果是这样的:
现在的效果是VM.tickData加1000次,那么cb(回调)就会调用1000次,这样是非常影响性能的,我们想要的效果是无论VM.tickData在本次event loop加多少次,都不会触发回调,只需要在VM.tickData加完之后,触发一次最终的cb(回调)就ok了。
下面我们就来实现这种优化,代码不多。
//nextTick的实现 let callbacks = []; let pending = false; function nextTick(cb) { callbacks.push(cb); if (!pending) { pending = true; setTimeout(flushCallbacks, 0); } } function flushCallbacks() { pending = false; const copies = callbacks.slice(0); callbacks.length = 0; for (let i = 0; i < copies.length; i++) { copies[i](); } } let has = {}; let queue = []; let waiting = false; function queueWatcher(watcher) { const id = watcher.id; if (has[id] == null) { has[id] = true; queue.push(watcher); if (!waiting) { waiting = true; nextTick(flushSchedulerQueue); } } } function flushSchedulerQueue() { let watcher, id; for (index = 0; index < queue.length; index++) { watcher = queue[index]; id = watcher.id; has[id] = null; watcher.run(); } waiting = false; }
然后更改Watcher里面的update方法,并不直接调用watcher.run,而是经过queueWatcher控制
update() { queueWatcher(this); // this.run(); }
代码地址:https://gitee.com/cchennlleii/MyTest/blob/master/vueReactive/vue-reactive%E5%AF%B9nextTck%E7%9A%84%E6%94%AF%E6%8C%81.html
6、总结
如果面试官问我关于双向绑定的问题,从这三个方面去回答,Object.definproperty,观察者模式,nextTick,当然,你需要把这三个点联系起来去描述,相信我你把上面的看懂了,联系起来完全没问题的,你是最棒的!
7、本文参考:
https://codepen.io/xiaomuzhu/pen/jxBRgj/
https://www.jianshu.com/p/2df6dcddb0d7