不好意思!耽誤你的十分鐘,讓MVVM原理還給你

chenhongdong發表於2018-04-01

時間在嘀嗒嘀嗒的走著

既然來了就繼續看看吧

  • 這篇文章其實沒有什麼鳥用,只不過對於現在的前端面試而言,已經是一個被問煩了的考點了
  • 既然是考點,那麼我就想簡簡單單的來給大家劃一下重點

眾所周知當下是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: '七里香'
} // 1delete obj.music;
// 如果想對obj裡的屬性進行刪除,configurable要設為true 2console.log(obj);
// 此時為 {singer: '周杰倫'
}obj.music = '聽媽媽的話';
// 如果想對obj的屬性進行修改,writable要設為true 3console.log(obj);
// {singer: '周杰倫', music: "聽媽媽的話"
}for (let key in obj) {
// 預設情況下通過defineProperty定義的屬性是不能被列舉(遍歷)的 // 需要設定enumerable為true才可以 // 不然你是拿不到music這個屬性的,你只能拿到singer console.log(key);
// singer, music 4
}console.log(obj.music);
// '發如雪' 5obj.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
}
});
複製程式碼

我們在控制檯裡看下

不好意思!耽誤你的十分鐘,讓MVVM原理還給你

被標記的地方就是通過遞迴observe(val)進行資料劫持新增上了get和set,遞迴繼續向a裡面的物件去定義屬性,親測通過可放心食用

接下來說一下observe(newVal)這裡為什麼也要遞迴

還是在可愛的控制檯上,敲下這麼一段程式碼 mvvm._data.a = {b:’ok’
}

然後繼續看圖說話

不好意思!耽誤你的十分鐘,讓MVVM原理還給你

通過observe(newVal)加上了

不好意思!耽誤你的十分鐘,讓MVVM原理還給你

現在大致明白了為什麼要對設定的新值也進行遞迴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);
// 1mvvm.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賦了一個新值 // 而值的改變會呼叫setset中又會呼叫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一共包含了以下東西

  1. 通過Object.defineProperty的get和set進行資料劫持
  2. 通過遍歷data資料進行資料代理到this上
  3. 通過{{
    }
    }對資料進行編譯
  4. 通過釋出訂閱模式實現資料與檢視同步
  5. 通過通過通過,收了,感謝大官人的留步了

補充

針對以上程式碼在實現編譯的時候還是會有一些小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]複製程式碼

再次感謝父老鄉親,兄弟姐妹們的觀看了!這回真的是最後一眼了,已經到底了!

來源:https://juejin.im/post/5abdd6f6f265da23793c4458?utm_medium=fe&utm_source=weixinqun

相關文章