衆所周知當下是MVVM盛行的時代,從早期的Angular到如今的React和Vue,再從最初的三分天下到如今的兩虎相爭。html
無疑不給咱們的開發帶來了一種史無前例的新體驗,告別了操做DOM的思惟,換上了數據驅動頁面的思想,果真時代的進步,改變了咱們許多許多。前端
囉嗦話多了起來,這樣很差。咱們來進入今天的主題vue
MVVM 雙向數據綁定 在Angular1.x版本的時候經過的是髒值檢測來處理node
而如今不管是React仍是Vue仍是最新的Angular,其實實現方式都更相近了es6
那就是經過數據劫持+發佈訂閱模式面試
真正實現其實靠的也是ES5中提供的Object.defineProperty,固然這是不兼容的因此Vue等只支持了IE8+設計模式
Object.defineProperty()說實在的咱們你們在開發中確實用的很少,多數是修改內部特性,不過就是定義對象上的屬性和值麼?幹嗎搞的這麼費勁(純屬我的想法)數組
But在實現框架or庫的時候卻發揮了大用場了,這個就很少說了,只不太輕舟一片而已,還沒到寫庫的實力bash
知其然要知其因此然,來看看如何使用app
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構造函數
// 這裏用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);
}
複製代碼
爲何要作數據劫持?
多說無益,一塊兒看代碼
// 建立一個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
複製代碼
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(鉤子函數)吧
// 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一共包含了如下東西
針對以上代碼在實現編譯的時候仍是會有一些小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]
複製代碼
再次感謝父老鄉親,兄弟姐妹們的觀看了!這回真的是最後一眼了,已經到底了!