手寫VUE mvvm雙向資料繫結
當你開啟這篇文章時,你肯定已經使用過vue,當你改變資料時,與之繫結的UI自動更新,當你觸發一些表單元素時,與之繫結的資料也會自動更新。我剛開始學vue的時候對vue的雙向資料繫結很好奇,所以今天我給大家實現一個簡單的vue。
首先,你得明白為什麼我們要使用雙向資料繫結,在沒有什麼mvc,mvvm之前,當資料改變,我們總是需要手動通過id class等方式找到我們的DOM,手動的呼叫什麼inner Text,setAtrribute,addClass等去更新DOM的各種屬性,樣式,文字等等,這樣做有兩個問題,第一:程式設計師把太多精力放在UI更新上,也就是資料和UI的同步上。第二:頁面資料的維護也比較困難。如果程式的結構不好,邏輯再複雜點,你會發現程式寫不下去了。第三:UI和js程式碼耦合度太高。對上面這些痛點有所體會的話,能幫助你更好的理解我們實現vue的mvvm究竟要幹些什麼事,是怎樣提高程式設計師的開發效率的!
先給大家上一個圖,這是我在vue官網上截的
這裡Data資料來源都是響應式的,也就是說用Object.defineProperty定義了set和get,這樣可以對資料來源進行劫持,每當你set的時候你就能呼叫notify通知所有的Watcher,每個watcher會有一個update方法更新UI,這裡上圖中是更新(在記憶體中計算)虛擬DOM,再由虛擬DOM更新真實的DOM。但是我的要寫的vue例子中是在watcher的中update中保留了真實DOM的引用,以實現更新,並沒有用到虛擬dom,那麼還有一個問題就是,怎麼根據模板生成watcher,又怎麼把watcher新增到他所觀察的資料的閉包環境中的。下面我們先看看我們要實現的最終效果。
就是當我在輸入框輸入文字的時候,下面能夠同步,並且當我改變一個isshow值,圓相應隱藏或顯示。
一 、實現Observer,實現可響應資料
function observe(data) { if (!data || typeof data !== 'object') { //這裡包括陣列和物件 typeof [] === 'object' 為true return; } Object.keys(data).forEach((key) => { defineReactive(data, key, data[key]); }); }
function defineReactive(obj, key, value) {
observe(value); //遞迴監聽 如果屬性的值為物件 則遞迴監聽
Object.defineProperty(obj, key, {
configurable: false, //不能再define
enumerable: true, //可列舉
set: function (newValue) {
if (newValue == value) return;
value = newValue;
console.log("不好,有人要改變我的值....");
},
get: function () {
console.log("嘿,你觸發我的取值器");
return value;
}
})
}
var data = {name: 'kitty'};
observe(data);
data.name = 'wangwang'; // 不好,有人要改變我的值....
這樣我們的data物件就是可觀測的了,這裡每次呼叫defineReactive實現對物件某屬性進行觀測時,要注意如果此屬性的值還是一個物件或者陣列,那麼需要繼續遞迴處理,直到物件屬性是一個基本型別停止。那麼問題又來了,如果屬性是一個數組,以上程式碼能實現對於陣列的每個元素進行監聽,但是我怎麼實現對陣列push pop splice等方法也進行監聽,這樣當使用這些方法時,也在我們的監聽管轄範圍之內。
對以上程式碼進行如下改造:
function defineReactive(obj, key, value) {
observe(value); //遞迴監聽 如果屬性的值為物件 則遞迴監聽
if (value instanceof Array) {
//對該陣列的push pop splice shift等等可以改變陣列的方法進行裝飾 並掛載到陣列例項上
["push", "pop", "shift", "unfift", "splice"].forEach((method) => {
// let beforeDecorateMethod = Array.prototype[method];
value[method] = function (prop) {
let result = Array.prototype[method].call(value, prop)
//在這裡 你可以插入你的程式碼 這樣你每次push pop splice..的時候就能執行你的程式碼
return result;
}
})
}
Object.defineProperty(obj, key, {
configurable: false, //不能再define
enumerable: true, //可列舉
set: function (newValue) {
if (newValue == value) return;
value = newValue;
},
get: function () {
return value;
}
})
}
原理很簡單,就是先獲得陣列原型上對應的方法,然後對其進行改造(裝飾),然後再把裝飾後的同名方法掛載到陣列勢力上,這樣你通過arr.push獲得的方法就是你裝飾後的push方法了,其原因就是訪問物件方法或者屬性時,會先在物件本身上找,找不到才會去__proto__原型物件上找。這樣,你就能對這些改變陣列方法也進行監聽。。
那麼,我們每當set的時候,我希望能夠通知到該資料的所有觀察者,觀察者收到通知後去更新DOM,這個是觀察者的事我們後面會講到。在defineReactive這個閉包環境裡我新增這個資料的觀察者,由於觀察者很多,所以我乾脆新增一個Dep物件,這是個觀察者容器。程式碼如下:
function defineReactive(obj, key, value) {
var dep = new Dep();
observe(value); //遞迴監聽 如果屬性的值為物件 則遞迴監聽
if (value instanceof Array) {
//對該陣列的push pop splice shift等等可以改變陣列的方法進行裝飾 並掛載到陣列例項上
["push", "pop", "shift", "unfift", "splice"].forEach((method) => {
// let beforeDecorateMethod = Array.prototype[method];
value[method] = function (prop) {
let result = Array.prototype[method].call(value, prop)
dep.notify();
return result;
}
})
}
Object.defineProperty(obj, key, {
configurable: false, //不能再define
enumerable: true, //可列舉
set: function (newValue) {
if (newValue == value) return;
value = newValue;
dep.notify();
},
get: function () {
return value;
}
})
}
顯然,Dep物件上應該有一個觀察者集合,並且和一個notify通知方法,在這個方法裡遍歷所有的watcher ,依次觸發watcher的update方法。Dep的實現如下
function Dep() {
this.subs = [];
}
Dep.prototype = {
addWatcher: function (watcher) {
this.subs.push(watcher);
},
notify: function () {
this.subs.forEach((watcher) => {
watcher.update();
})
}
}
那麼問題又來了,subs數組裡儲存的watcher是怎麼新增進去的???而且dep物件又在一個閉包環境裡面,而watcher又只能是編譯模板時生成的,也就是在閉包外面生成的,所以我現在希望,當new Watcher的時候他能自己把自己新增到dep的subs陣列中,聽起來挺不可思議的。但是你想啊,這可以通過全域性變數傳遞watcher物件呀!,因為set是用來通知的,我們只能在get方法上做文章了,用get來收集watcher。是不是豁然開朗。下面的是程式碼。
get: function () {
if (Dep.target) {
dep.addWatcher(Dep.target);
}
return value;
}
function Wathcer(exp, vm, callback) {
this.exp = exp;
this.vm = vm;
this.callback = callback;
Dep.target = this;
this.get();
Dep.target = null;
this.callback(this.value); //初始化試圖
}
重點看Watcher的加粗部分程式碼,是不是想明白了!!!
二、實現Compile,編譯模板,初始化頁面
在new Vue的時候,我們會編譯el選擇的DOM裡面的所有元素,這是一個模板。比如:<div id="app">
<input v-model="message"/>
<p v-bind:class="style">您輸入的內容是{{message}} {{message}} </p>
<div v-bind:class="style2" v-show="isShow"></div>
</div>
在compile階段,我們需要編譯模板,解析指令,並生成Watcher,傳給watcher它的update函式。並生成初始檢視。lets do it.
//Compile物件做的事情 解析el所有子元素的所有指令 初始化檢視 建立Watcher 並繫結update函式 watcher會把自己加到相應的dep訂閱器中
function Compile(el, vm) {
this.$vm = vm;
this.$el = document.querySelector(el);
this.$fragment = this.elementToFragment(this.$el); //劫持el所有子元素 轉化為fragment文件碎片 以免頻繁在真實DOM樹上讀寫 以提高效能
this.init();
this.$el.appendChild(this.$fragment);
}
Compile.prototype = {
elementToFragment: function (el) {
var container = document.createDocumentFragment();
var child;
while (child = el.firstChild) {
container.appendChild(child);
}
return container;
},
init: function () {
this.compileElement(this.$fragment);
},
......
}
看到一個fragment沒?這個是一個文件碎片的容器,之所以使用它,是我們想先把我們的模板裡面的元素從真實的DOM中劫持到fragment中(fragment在記憶體中,他的改變不會引起瀏覽器的重新渲染),然後在對fragment裡面的元素進行compile操作(這個操作頻繁讀寫),編譯完畢後再加入到真實的DOM樹中,這樣大大提高效能~
那麼我們的compileElement方法又做了什麼呢?compileElement: function (el) {
var childNodes = el.childNodes, vm = this.$vm;
[].slice.call(childNodes).forEach((node) => {
var text = node.textContent;
var reg = /\{\{(.*)\}\}/; // 表示式文字
if (node.nodeType == 1) { //普通標籤
this.compileAttrs(node);
} else if (node.nodeType == 3 && reg.test(text)) {//文字節點 #text
this.compileText(node);
}
if (node.childNodes && node.childNodes.length > 0) {
this.compileElement(node); //遞迴呼叫
}
})
}
在這裡分了兩種情況,屬性編譯,和文字節點編譯,最後,如果元素還有子元素就繼續遞迴呼叫compileElement,如此,就可以保證所有的節點上的v-屬性和包含{{}}的文字都可以被編譯處理。
Compile.prototype = {.....省略
compileText: function (node) { //當然這裡需要匹配所有的{{exp}} 為每個不同的exp生成一個Watcher
var text = node.textContent;
var reg = /\{\{([a-z|1-9|_]+)\}\}/g;
reg.test(text);
var exp = RegExp.$1;
new Wathcer(exp, this.$vm, function (value) {
node.textContent = text.replace(reg, value);
});
},
compileAttrs: function (node) {
var complieUtils = this.complieUtils;
var attrs = node.attributes, me = this;
[].slice.call(attrs).forEach(function (attr) {
if (me.isDirective(attr)) {
var dir = attr.name.substring(2).split(':')[0];
var exp = attr.value;
complieUtils[dir + '_compile'].call(me, node, attr, exp);
}
})
},
isDirective: function (attr) { //通過name value獲取屬性的鍵值
return /v-*/.test(attr.name); //判斷屬性名是否以v-開頭
},
complieUtils: {
model_compile: function (node, attr, exp) {
node.addEventListener("keyup", (e) => {
this.$vm.$data[exp] = e.target.value;
});
node.removeAttribute(attr.name);
new Wathcer(exp, this.$vm, function (value) {
node.value = value;
});
},
bind_compile: function (node, attr, exp) {
var attribute = attr.name.split(':')[1];
node.removeAttribute(attr.name);
new Wathcer(exp, this.$vm, function (value) {
node.setAttribute(attribute, value);
});
},
show_compile: function (node, attr, exp) {
node.removeAttribute(attr.name);
new Wathcer(exp, this.$vm, function (value) {
node.style.visibility = value ? 'visible' : 'hidden';
});
}
}
這裡我添加了complieUtils物件,如果是v-text指令,就會使用呼叫text_compile,如果是v-bind指令,就會呼叫bind_compile函式,這樣設計的目的是如果你想增加vue裡面的指令,只需要擴充套件compileUtils這個物件即可~甚至你還可以給使用者提供自定義屬性指令的介面,然後本質是往complieUtils裡面新增新的函式。
還值得一提的是Watcher物件,你在建立這個物件時需要給它傳遞一個callback,也就是更新時呼叫的函式。這裡callback是個閉包,保留了對DOM的引用,以實現更新。
接下來看看Watcher
三 、實現Watcher,實現資料更新UI
function Wathcer(exp, vm, callback) {
this.exp = exp;
this.vm = vm;
this.callback = callback;
Dep.target = this;
this.get();
Dep.target = null;
this.callback(this.value); //初始化試圖
}
Wathcer.prototype = {
get: function () {
this.value = this.vm.$data[this.exp];
},
update: function () {
this.get(); //先獲得value值
this.callback(this.value);
}
}
Dep.target是一個橋樑,用來傳遞wather例項,在呼叫Watcher的建構函式時,會把自己賦值給Dep.target,然後觸發對應資料的get,在get方法裡會把該watcher新增到觀察者集合裡,最後別忘了將Dep.target置成null.Wacher的更新函式裡面會執行在編譯階段傳遞過來的callback。如果這裡思路有點亂的話,再回顧下一下程式碼。
Object.defineProperty(obj, key, {
configurable: false, //不能再define
enumerable: true, //可列舉
set: function (newValue) {
if (newValue == value) return;
value = newValue;
dep.notify();
},
get: function () {
if (Dep.target) {
dep.addWatcher(Dep.target);
}
return value;
}
})
Dep.prototype = {
addWatcher: function (watcher) {
this.subs.push(watcher);
},
notify: function () {
this.subs.forEach((watcher) => {
watcher.update();
})
}
}
看一下加粗的地方,理一理,就明白了。四 、實現MVVM,封裝Vue物件
最後一步,封裝vue物件,對前三者進行整合。function Vue(options) {
this.$options = options;
var data = options.data;
this.$data = data;
this.$el = options.el;
observe(data); //劫持監聽data所有屬性
this.$compile = new Compile(this.$el, this) //模板解析
}
這樣我們的一個簡單的vue就實現了,當然這真的只是一個簡單的vue mvvm的雙向資料繫結,很多功能是不完善的~~~不過這個思路是挺棒的~
如果你感興趣,可以考慮實現v-for指令,或者{{a.c.b[0]}}這種複雜的解析。
如果你覺得不錯,給個贊吧~~~有問題可以評論~