vue的雙向綁定


擼一個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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM