時間在嘀嗒嘀嗒的走著
既然來了就繼續看看吧
- 這篇文章其實沒有什麼鳥用,只不過對於現在的前端面試而言,已經是一個被問煩了的考點了
- 既然是考點,那麼我就想簡簡單單的來給大家劃一下重點
眾所周知當下是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(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]
複製程式碼
再次感謝父老鄉親,兄弟姐妹們的觀看了!這回真的是最後一眼了,已經到底了!