眾所周知當下是MVVM盛行的時代,從早期的Angular到現在的React和Vue,再從最初的三分天下到現在的兩虎相爭。
無疑不給我們的開發帶來了一種前所未有的新體驗,告別了操作DOM的思維,換上了數據驅動頁面的思想,果然時代的進步,改變了我們許多許多。
啰嗦話多了起來,這樣不好。我們來進入今天的主題
划重點
MVVM 雙向數據綁定 在Angular1.x版本的時候通過的是臟值檢測來處理
而現在無論是React還是Vue還是最新的Angular,其實實現方式都更相近了
那就是通過數據劫持+發布訂閱模式
真正實現其實靠的也是ES5中提供的Object.defineProperty,當然這是不兼容的所以Vue等只支持了IE8+
為什么是它
Object.defineProperty()說實在的我們大家在開發中確實用的不多,多數是修改內部特性,不過就是定義對象上的屬性和值么?干嘛搞的這么費勁(純屬個人想法)
But在實現框架or庫的時候卻發揮了大用場了,這個就不多說了,只不過輕舟一片而已,還沒到寫庫的實力
知其然要知其所以然,來看看如何使用
let obj = {}; let song = '發如雪'; obj.singer = '周傑倫'; Object.defineProperty(obj, 'music', { // 1. value: '七里香', configurable: true, // 2. 可以配置對象,刪除屬性 // writable: true, // 3. 可以修改對象 enumerable: true, // 4. 可以枚舉 // ☆ get,set設置時不能設置writable和value,它們代替了二者且是互斥的 get() { // 5. 獲取obj.music的時候就會調用get方法 return song; }, set(val) { // 6. 將修改的值重新賦給song song = val; } }); // 下面打印的部分分別是對應代碼寫入順序執行 console.log(obj); // {singer: '周傑倫', music: '七里香'} // 1 delete obj.music; // 如果想對obj里的屬性進行刪除,configurable要設為true 2 console.log(obj); // 此時為 {singer: '周傑倫'} obj.music = '聽媽媽的話'; // 如果想對obj的屬性進行修改,writable要設為true 3 console.log(obj); // {singer: '周傑倫', music: "聽媽媽的話"} for (let key in obj) { // 默認情況下通過defineProperty定義的屬性是不能被枚舉(遍歷)的 // 需要設置enumerable為true才可以 // 不然你是拿不到music這個屬性的,你只能拿到singer console.log(key); // singer, music 4 } console.log(obj.music); // '發如雪' 5 obj.music = '夜曲'; // 調用set設置新的值 console.log(obj.music); // '夜曲' 6 復制代碼
以上是關於Object.defineProperty的用法
下面我們來寫個實例看看,這里我們以Vue為參照去實現怎么寫MVVM
// index.html
<body>
<div id="app"> <h1>{{song}}</h1> <p>《{{album.name}}》是{{singer}}2005年11月發行的專輯</p> <p>主打歌為{{album.theme}}</p> <p>作詞人為{{singer}}等人。</p> 為你彈奏肖邦的{{album.theme}} </div> <!--實現的mvvm--> <script src="mvvm.js"></script> <script> // 寫法和Vue一樣 let mvvm = new Mvvm({ el: '#app', data: { // Object.defineProperty(obj, 'song', '發如雪'); song: '發如雪', album: { name: '十一月的蕭邦', theme: '夜曲' }, singer: '周傑倫' } }); </script> </body> 復制代碼
上面是html里的寫法,相信用過Vue的同學並不陌生
那么現在就開始實現一個自己的MVVM吧
打造MVVM
// 創建一個Mvvm構造函數
// 這里用es6方法將options賦一個初始值,防止沒傳,等同於options || {}
function Mvvm(options = {}) { // vm.$options Vue上是將所有屬性掛載到上面 // 所以我們也同樣實現,將所有屬性掛載到了$options this.$options = options; // this._data 這里也和Vue一樣 let data = this._data = this.$options.data; // 數據劫持 observe(data); } 復制代碼
數據劫持
為什么要做數據劫持?
- 觀察對象,給對象增加Object.defineProperty
- vue特點是不能新增不存在的屬性 不存在的屬性沒有get和set
- 深度響應 因為每次賦予一個新對象時會給這個新對象增加defineProperty(數據劫持)
多說無益,一起看代碼
// 創建一個Observe構造函數
// 寫數據劫持的主要邏輯
function Observe(data) { // 所謂數據劫持就是給對象增加get,set // 先遍歷一遍對象再說 for (let key in data) { // 把data屬性通過defineProperty的方式定義屬性 let val = data[key]; observe(val); // 遞歸繼續向下找,實現深度的數據劫持 Object.defineProperty(data, key, { configurable: true, get() { return val; }, set(newVal) { // 更改值的時候 if (val === newVal) { // 設置的值和以前值一樣就不理它 return; } val = newVal; // 如果以后再獲取值(get)的時候,將剛才設置的值再返回去 observe(newVal); // 當設置為新值后,也需要把新值再去定義成屬性 } }); } } // 外面再寫一個函數 // 不用每次調用都寫個new // 也方便遞歸調用 function observe(data) { // 如果不是對象的話就直接return掉 // 防止遞歸溢出 if (!data || typeof data !== 'object') return; return new Observe(data); } 復制代碼
以上代碼就實現了數據劫持,不過可能也有些疑惑的地方比如:遞歸
再來細說一下為什么遞歸吧,看這個栗子
let mvvm = new Mvvm({ el: '#app', data: { a: { b: 1 }, c: 2 } }); 復制代碼
我們在控制台里看下

接下來說一下observe(newVal)這里為什么也要遞歸
還是在可愛的控制台上,敲下這么一段代碼 mvvm._data.a = {b:'ok'}
然后繼續看圖說話


數據劫持已完成,我們再做個數據代理
數據代理
數據代理就是讓我們每次拿data里的數據時,不用每次都寫一長串,如mvvm._data.a.b這種,我們其實可以直接寫成mvvm.a.b這種顯而易見的方式
下面繼續看下去,+號表示實現部分
function Mvvm(options = {}) { // 數據劫持 observe(data); // this 代理了this._data + for (let key in data) { Object.defineProperty(this, key, { configurable: true, get() { return this._data[key]; // 如this.a = {b: 1} }, set(newVal) { this._data[key] = newVal; } }); + } } // 此時就可以簡化寫法了 console.log(mvvm.a.b); // 1 mvvm.a.b = 'ok'; console.log(mvvm.a.b); // 'ok' 復制代碼
寫到這里數據劫持和數據代理都實現了,那么接下來就需要編譯一下了,把{{}}里面的內容解析出來
數據編譯
function Mvvm(options = {}) { // observe(data); // 編譯 + new Compile(options.el, this); } // 創建Compile構造函數 function Compile(el, vm) { // 將el掛載到實例上方便調用 vm.$el = document.querySelector(el); // 在el范圍里將內容都拿到,當然不能一個一個的拿 // 可以選擇移到內存中去然后放入文檔碎片中,節省開銷 let fragment = document.createDocumentFragment(); while (child = vm.$el.firstChild) { fragment.appendChild(child); // 此時將el中的內容放入內存中 } // 對el里面的內容進行替換 function replace(frag) { Array.from(frag.childNodes).forEach(node => { let txt = node.textContent; let reg = /\{\{(.*?)\}\}/g; // 正則匹配{{}} if (node.nodeType === 3 && reg.test(txt)) { // 即是文本節點又有大括號的情況{{}} console.log(RegExp.$1); // 匹配到的第一個分組 如: a.b, c let arr = RegExp.$1.split('.'); let val = vm; arr.forEach(key => { val = val[key]; // 如this.a.b }); // 用trim方法去除一下首尾空格 node.textContent = txt.replace(reg, val).trim(); } // 如果還有子節點,繼續遞歸replace if (node.childNodes && node.childNodes.length) { replace(node); } }); } replace(fragment); // 替換內容 vm.$el.appendChild(fragment); // 再將文檔碎片放入el中 } 復制代碼
看到這里在面試中已經可以初露鋒芒了,那就一鼓作氣,做事做全套,來個一條龍
現在數據已經可以編譯了,但是我們手動修改后的數據並沒有在頁面上發生改變
下面我們就來看看怎么處理,其實這里就用到了特別常見的設計模式,發布訂閱模式
發布訂閱
發布訂閱主要靠的就是數組關系,訂閱就是放入函數,發布就是讓數組里的函數執行
// 發布訂閱模式 訂閱和發布 如[fn1, fn2, fn3]
function Dep() { // 一個數組(存放函數的事件池) this.subs = []; } Dep.prototype = { addSub(sub) { this.subs.push(sub); }, notify() { // 綁定的方法,都有一個update方法 this.subs.forEach(sub => sub.update()); } }; // 監聽函數 // 通過Watcher這個類創建的實例,都擁有update方法 function Watcher(fn) { this.fn = fn; // 將fn放到實例上 } Watcher.prototype.update = function() { this.fn(); }; let watcher = new Watcher(() => console.log(111)); // let dep = new Dep(); dep.addSub(watcher); // 將watcher放到數組中,watcher自帶update方法, => [watcher] dep.addSub(watcher); dep.notify(); // 111, 111 復制代碼
數據更新視圖
- 現在我們要訂閱一個事件,當數據改變需要重新刷新視圖,這就需要在replace替換的邏輯里來處理
- 通過new Watcher把數據訂閱一下,數據一變就執行改變內容的操作
function replace(frag) { // 省略... // 替換的邏輯 node.textContent = txt.replace(reg, val).trim(); // 監聽變化 // 給Watcher再添加兩個參數,用來取新的值(newVal)給回調函數傳參 + new Watcher(vm, RegExp.$1, newVal => { node.textContent = txt.replace(reg, newVal).trim(); + }); } // 重寫Watcher構造函數 function Watcher(vm, exp, fn) { this.fn = fn; + this.vm = vm; + this.exp = exp; // 添加一個事件 // 這里我們先定義一個屬性 + Dep.target = this; + let arr = exp.split('.'); + let val = vm; + arr.forEach(key => { // 取值 + val = val[key]; // 獲取到this.a.b,默認就會調用get方法 + }); + Dep.target = null; } 復制代碼
當獲取值的時候就會自動調用get方法,於是我們去找一下數據劫持那里的get方法
function Observe(data) { + let dep = new Dep(); // 省略... Object.defineProperty(data, key, { get() { + Dep.target && dep.addSub(Dep.target); // 將watcher添加到訂閱事件中 [watcher] return val; }, set(newVal) { if (val === newVal) { return; } val = newVal; observe(newVal); + dep.notify(); // 讓所有watcher的update方法執行即可 } }) } 復制代碼
當set修改值的時候執行了dep.notify方法,這個方法是執行watcher的update方法,那么我們再對update進行修改一下
Watcher.prototype.update = function() { // notify的時候值已經更改了 // 再通過vm, exp來獲取新的值 + let arr = this.exp.split('.'); + let val = this.vm; + arr.forEach(key => { + val = val[key]; // 通過get獲取到新的值 + }); this.fn(val); // 將每次拿到的新值去替換{{}}的內容即可 }; 復制代碼
現在我們數據的更改可以修改視圖了,這很good,還剩最后一點,我們再來看看面試常考的雙向數據綁定吧
雙向數據綁定
// html結構
<input v-model="c" type="text"> // 數據部分 data: { a: { b: 1 }, c: 2 } function replace(frag) { // 省略... + if (node.nodeType === 1) { // 元素節點 let nodeAttr = node.attributes; // 獲取dom上的所有屬性,是個類數組 Array.from(nodeAttr).forEach(attr => { let name = attr.name; // v-model type let exp = attr.value; // c text if (name.includes('v-')){ node.value = vm[exp]; // this.c 為 2 } // 監聽變化 new Watcher(vm, exp, function(newVal) { node.value = newVal; // 當watcher觸發時會自動將內容放進輸入框中 }); node.addEventListener('input', e => { let newVal = e.target.value; // 相當於給this.c賦了一個新值 // 而值的改變會調用set,set中又會調用notify,notify中調用watcher的update方法實現了更新 vm[exp] = newVal; }); }); + } if (node.childNodes && node.childNodes.length) { replace(node); } } 復制代碼
大功告成,面試問Vue的東西不過就是這個罷了,什么雙向數據綁定怎么實現的,問的一點心意都沒有,差評!!!
大官人請留步,本來應該收手了,可臨時起意(手癢),再寫點功能吧,再加個computed(計算屬性)和mounted(鈎子函數)吧
computed(計算屬性) && mounted(鈎子函數)
// html結構
<p>求和的值是{{sum}}</p>
data: { a: 1, b: 9 },
computed: {
sum() { return this.a + this.b; }, noop() {} }, mounted() { setTimeout(() => { console.log('所有事情都搞定了'); }, 1000); } function Mvvm(options = {}) { // 初始化computed,將this指向實例 + initComputed.call(this); // 編譯 new Compile(options.el, this); // 所有事情處理好后執行mounted鈎子函數 + options.mounted.call(this); // 這就實現了mounted鈎子函數 } function initComputed() { let vm = this; let computed = this.$options.computed; // 從options上拿到computed屬性 {sum: ƒ, noop: ƒ} // 得到的都是對象的key可以通過Object.keys轉化為數組 Object.keys(computed).forEach(key => { // key就是sum,noop Object.defineProperty(vm, key, { // 這里判斷是computed里的key是對象還是函數 // 如果是函數直接就會調get方法 // 如果是對象的話,手動調一下get方法即可 // 如: sum() {return this.a + this.b;},他們獲取a和b的值就會調用get方法 // 所以不需要new Watcher去監聽變化了 get: typeof computed[key] === 'function' ? computed[key] : computed[key].get, set() {} }); }); } 復制代碼
寫了這些內容也不算少了,最后做一個形式上的總結吧
總結
通過自己實現的mvvm一共包含了以下東西
- 通過Object.defineProperty的get和set進行數據劫持
- 通過遍歷data數據進行數據代理到this上
- 通過{{}}對數據進行編譯
- 通過發布訂閱模式實現數據與視圖同步
- 通過通過通過,收了,感謝大官人的留步了
補充
針對以上代碼在實現編譯的時候還是會有一些小bug,再次經過研究和高人指點,完善了編譯,下面請看修改后的代碼
修復:兩個相鄰的{{}}正則匹配,后一個不能正確編譯成對應的文本,如{{album.name}} {{singer}}
function Compile(el, vm) { // 省略... function replace(frag) { // 省略... if (node.nodeType === 3 && reg.test(txt)) { function replaceTxt() { node.textContent = txt.replace(reg, (matched, placeholder) => { console.log(placeholder); // 匹配到的分組 如:song, album.name, singer... new Watcher(vm, placeholder, replaceTxt); // 監聽變化,進行匹配替換內容 return placeholder.split('.').reduce((val, key) => { return val[key]; }, vm); }); }; // 替換 replaceTxt(); } } } 復制代碼
上面代碼主要實現依賴的是reduce方法,reduce 為數組中的每一個元素依次執行回調函數
如果還有不太清楚的,那我們單獨抽出來reduce這部分再看一下
// 將匹配到的每一個值都進行split分割
// 如:'song'.split('.') => ['song'] => ['song'].reduce((val, key) => val[key]) // 其實就是將vm傳給val做初始值,reduce執行一次回調返回一個值 // vm['song'] => '周傑倫' // 上面不夠深入,我們再來看一個 // 再如:'album.name'.split('.') => ['album', 'name'] => ['album', 'name'].reduce((val, key) => val[key]) // 這里vm還是做為初始值傳給val,進行第一次調用,返回的是vm['album'] // 然后將返回的vm['album']這個對象傳給下一次調用的val // 最后就變成了vm['album']['name'] => '十一月的蕭邦' return placeholder.split('.').reduce((val, key) => { return val[key]; }, vm); 復制代碼
reduce的用處多多,比如計算數組求和是比較普通的方法了,還有一種比較好用的妙處是可以進行二維數組的展平(flatten),各位不妨來看最后一眼
let arr = [ [1, 2], [3, 4], [5, 6] ]; let flatten = arr.reduce((previous, current) => { return previous.concat(current); }); console.log(flatten); // [1, 2, 3, 4, 5, 6] // ES6中也可以利用...展開運算符來實現的,實現思路一樣,只是寫法更精簡了 flatten = arr.reduce((a, b) => [...a, ...b]); console.log(flatten); // [1, 2, 3, 4, 5, 6] 復制代碼
再次感謝父老鄉親,兄弟姐妹們的觀看了!這回真的是最后一眼了,已經到底了!
作者:chenhongdong
鏈接:https://juejin.im/post/5abdd6f6f265da23793c4458
來源:掘金
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。