眾所周知當下是MVVM盛行的時代,從早期的Angular到現在的React和Vue,再從最初的三分天下到現在的兩虎相爭。
無疑不給我們的開發帶來了一種前所未有的新體驗,告別了操作DOM的思維,換上了數據驅動頁面的思想,果然時代的進步,改變了我們許多許多。
啰嗦話多了起來,這樣不好。我們來進入今天的主題
劃重點
MVVM 雙向數據綁定 在Angular1.x版本的時候通過的是臟值檢測來處理
而現在無論是React還是Vue還是最新的Angular,其實實現方式都更相近了
那就是通過數據劫持+發布訂閱模式
真正實現其實靠的也是ES5中提供的Object.defineProperty,當然這是不兼容的所以Vue等只支持了IE8+
為什么是它
Object.defineProperty()說實在的我們大家在開發中確實用的不多,多數是修改內部特性,不過就是定義對象上的屬性和值么?干嘛搞的這么費勁(純屬個人想法)
But在實現框架or庫的時候卻發揮了大用場了,這個就不多說了,只不過輕舟一片而已,還沒到寫庫的實力
知其然要知其所以然,來看看如何使用
let obj = {};
obj.singer = '周杰倫';
Object.defintProperty(obj, 'music', {configurable: true, // 可以配置對象,刪除屬性// writable: true, // 可以修改對象enumerable: true // 可以枚舉// value: '七里香',// ☆ get,set設置時不能設置writable和value,它們代替了二者且是互斥的get() { // 獲取obj.music的時候就會調用get方法return '發如雪';},set(val) { // obj.music = '聽媽媽的話'console.log(val); // '聽媽媽的話'}
});console.log(obj); // {singer: '周杰倫', music: '七里香'}delete obj.music; // 如果想對obj里的屬性進行刪除,configurable要設為true
console.log(obj); // 此時為 {singer: '周杰倫'}obj.music = '聽媽媽的話'; // 如果想對obj的屬性進行修改,writable要設為true
console.log(obj); // {singer: '周杰倫', music: "聽媽媽的話"}for (let key in o) { // 默認情況下通過defineProperty定義的屬性是不能被枚舉(遍歷)的// 需要設置enumerable為true才可以// 不然你是拿不到music這個屬性的,你只能拿到singerconsole.log(key); // singer, music
}
以上是關于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上是將所有屬性掛載到上面// 所以我們也同樣實現,將所有屬性掛載到了$optionsthis.$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 = fragment.firstChild) {fragment.appendChild(child); // 此時將el中的內容放入內存中}// 對el里面的內容進行替換function replace(frag) {Array.from(frag.childNodes).forEach(node => {let txt = node.textContent;let reg = /\{\{(.*)\}\}/; // 正則匹配{{}}if (node.nodeType === 3 && reg.test(txt)) { // 即是文本節點又有大括號的情況{{}}console.log(RegExp.$1); // 匹配到的第一個分組 如: a.b, clet arr = RegExp.$1.split('.');let val = vm;arr.forEach(key => {val = val[key]; // 如this.a.b});// 用trim方法去除一下首尾空格node.textContent = txt.replace(reg, val).trim();}// 如果還有子節點,繼續遞歸replaceif (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 typelet exp = attr.value; // c textif (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,noopObject.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上
- 通過{{}}對數據進行編譯
- 通過發布訂閱模式實現數據與視圖同步
- 通過通過通過,收了,感謝大官人的留步了