【JS】508- MVVM原理介紹
- 2020 年 3 月 2 日
- 筆記
作者:chenhongdong https://juejin.im/post/5abdd6f6f265da23793c4458
今天花上 10 分鐘,針對 MVVM 這個面試必考點,簡簡單單的來給大家劃一下重難點
劃重點
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(val)進行數據劫持添加上了get
和set
,遞歸繼續向a裡面的對象去定義屬性,親測通過可放心食用
接下來說一下observe(newVal)
這裡為什麼也要遞歸
還是在可愛的控制台上,敲下這麼一段程式碼 mvvm._data.a = {b:'ok'}
然後繼續看圖說話

通過observe(newVal)
加上了

現在大致明白了為什麼要對設置的新值也進行遞歸observe了吧,哈哈,so easy
數據劫持已完成,我們再做個數據代理
數據代理
數據代理就是讓我們每次拿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]
再次感謝父老鄉親,兄弟姐妹們的觀看了!這回真的是最後一眼了,已經到底了!