前言:
學習前端也有半年多了,個人的學習欲望還比較強烈,很喜歡那種新知識在自己的演練下一點點實作的過程。最近一直在學vue架構,像網上大佬說的,入門容易深究難。不管是跟着開發文檔學還是視訊教程,按步驟操作總是最膚淺,想要把這門功課做好畢竟得下足功夫。是以,特意花了好幾天時間閱讀相關技術部落格和源碼,簡單實作了一個資料雙向綁定的vue架構,希望能讓各位有點啟發...
1.什麼是MVVM
MVVM即modle-view-viewmole,MVVM最早由微軟提出來,在前端頁面中,把Model用純JavaScript對象表示,View負責顯示,兩者做到了最大限度的分離。把Model和View關聯起來的就是ViewModel。ViewModel負責把Model的資料同步到View顯示出來,還負責把View的修改同步回Model。
2.資料的雙向綁定
在vue架構中,通過控制台或者Vue Devtools修改data裡的一些屬性時會看到頁面也會更新,而在頁面修改資料時,data裡的屬性值也會發生改變。我們就把這種model和view同步顯示稱為是雙向綁定。其實單向綁定原理也差不多,視圖改變data更新通過事件監聽就能輕松實作了,重點都在希望data改變視圖也發生改變,而這就是我們下面要講的原理。
3.vue資料雙向綁定原理
3.1 Object.defineProperty()方法
首先要知道的是vue的資料綁定通過資料劫持配合釋出訂閱者模式實作的,那麼什麼是資料劫持呢?我們可以在控制台看一下它的初始化對象是什麼樣的:
let vm = new Vue({
el:"#app",
data:{
obj:{
a:1
}
},
created() {
console.log(this.obj)
},
})
可以看到屬性a分别對應着一個get 和set方法,這裡引申出Object.defineProperty()方法,傳遞三個參數,obj(要在其上定義屬性的對象)、prop(要定義或修改的屬性的名稱)、descriptor(将被定義或修改的屬性描述符)。該方法更多資訊參考:參考更多用法,着重強調一下get和set這兩個屬性描述鍵值。
- get 存取描述符的可選鍵值,一個給屬性提供 getter 的方法,如果沒有 getter 則為 undefined。當通路該屬性時,該方法會被執行,方法執行時沒有參數傳入,但是會傳入this對象(由于繼承關系,這裡的this并不一定是定義該屬性的對象)。
- set 存取描述符的可選鍵值,一個給屬性提供 setter 的方法,如果沒有 setter 則為 undefined。當屬性值修改時,觸發執行該方法。該方法将接受唯一參數,即該屬性新的參數值。
平常我們在列印一個對象屬性時會這樣做:
var obj = {
name:"tnagj"
}
console.log(obj.name) //tangj
如果我們想要在輸出的同時監聽obj的屬性值,并且輸出的是tangjSir呢?這時候我們的set和get屬性就起到了很好的作用
var obj ={};
var name = '';
Object.defineProperty(obj,'name',{
set:function(value){
name = value
console.log('我叫:' + name)
},
get:function(){
console.log(name + 'Sir')
}
})
obj.name = 'tangj'; //我叫tangj
obj.name; //tangjSir
首先我們定義了一個obj空對象以及name空屬性,再用一個Object.defineProperty()方法來判斷obj.name的通路情況,如果是讀值則調用get函數,如果是指派則調用set函數。在這兩個函數裡面我們分别對輸出的内容作了更改,是以在get方法調用時列印tangjSir,在set方法調用時列印我叫tangj。
其實這就是vue資料綁定的監聽原理,我們能通過這個簡單實作MVVM雙向綁定。
3.2 MVVM雙向綁定分析
view的變化,比如input值改變我們很容易就能知道通過input事件反應到data中,資料綁定的關鍵在于怎樣讓data更新view。首先我們要知道資料什麼時候變的,上文提過可以用Object.defineProperty()的set屬性描述鍵值來監聽這個變化,當資料改變時就調用set方法。
那麼我們可以設定一個監聽器Observe,用來監聽所有的屬性,當屬性變化的時候就需要告訴訂閱者Watcher看是否需要更新。因為訂閱者是有很多個,是以我們需要有一個消息訂閱器Dep來專門收集這些訂閱者,然後在監聽器Observer和訂閱者Watcher之間進行統一管理的。當然我們還需要有一個指令解析器Compile,對每個節點元素進行掃描和解析,将相關指令對應初始化成一個訂閱者Watcher,并替換模闆資料或者綁定相應的函數,此時當訂閱者Watcher接收到相應屬性的變化,就會執行對應的更新函數,進而更新視圖。是以,我們大緻可以把整個過程拆分成五個部分,MVVM.html,MVVM.js,compile.js,observer.js,watcher.js,我們在MVVM.js中建立所需要的執行個體,在.html檔案中引入這些js檔案,這樣拆分更容易了解也更好維護。
4.分步拆分
4.1 MVVM.JS
為了和Vue保持一緻,我們向MVVM.js傳入一個空對象options,并讓vm.$el = options.el,vm.$data = options.data,如果能取到vm.$el再進行編譯和監聽
class MVVM {
constructor(options){
this.$el = options.el, //把東西挂載在執行個體上
this.$data = options.data
if(this.$el){ // 如果有要編譯的就開始編譯
new Observer(this.$data); //資料劫持,就是把對象的所有屬性改成get和set方法
new Compile(this.$el,this);//用資料和元素進行編譯
}
}
}
4.2 Compile.js
編譯的時候有一個問題需要注意,如果直接操作DOM元素會特别消耗性能,是以我們希望先把DOM元素都放在記憶體中即文檔碎片,待編譯完成再把文檔碎片放進真實的元素中
class Complie{
constructor(el,vm){
this.el = this.isElementNode(el)?el:document.querySelector(el);
this.vm = vm;
if(this.el){//如果這個元素能擷取到,我們才開始編譯
let fragment = this.nodeToFragment(this.el); //1.先把真實的DOM移入到記憶體中,fragment
this.compile(fragment); //2.編譯=>提取想要的元素節點v-modle 和文本節點{{}}
this.el.appendChild(fragment) //3.把編譯好的fragment塞回頁面
}
nodeToFragment(el){ //需要el元素放到記憶體中
let fragment = document.createDocumentFragment();
let Child;
while(Child = el.firstChild){
fragment.appendChild(Child);
}
return fragment;
}
}
}
接下來我們要判斷需要編譯的是元素節點還是文檔節點,還記得Vue中有很多很有用的指令嗎?比如"v-modle"、"v-for"等,是以我們還要判斷元素節點内是否包含指令,如果是指令,它應該包含一些特殊的方法
/* 省略.... */
isElementNode(node){ //是不是元素節點
return node.nodeType === 1;
}
isDirective(name){ //是不是指令
return name.includes('v-')
}
compileElement(node){
//帶v-modle
let attrs = node.attributes;
Array.from(attrs).forEach(
attr =>{
let attrName = attr.name;
if(this.isDirective(attrName)){
// 取到對應的值放到節點中
let expr = attr.value;
// node vm.$data expr
let [,type] = attrName.split('-') //解構指派
CompileUtil[type](node,this.vm,expr)
}
}
)
}
compileText(node){
// 帶{{}}
let expr = node.textContent; //取文本的内容
let reg = /\{\{([^}]+)\}\}/g //全局比對
if(reg.test(expr)){
// node this.vm.$data expr
CompileUtil['text'](node,this.vm,expr)
}
}
compile(fragment){ //需要遞歸,拿到的childNodes隻是第一層
let childNodes = fragment.childNodes;
Array.from(childNodes).forEach(
node=>{
if(this.isElementNode(node)){ //是元素節點,還需要遞歸檢查
this.compileElement(node) //編譯元素
this.compile(node) //箭頭函數this指向上一層的執行個體
}else{ //文本節點
this.compileText(node) //編譯文本
}
}
)
}
根據擷取的節點類型不同,執行不同的方法,我們可把這些方法統一都放到一個對象裡面去
CompileUtil = {
getVal(vm,expr){ //擷取執行個體上的資料
expr = expr.split('.'); //如果遇到vm.$data[a.a],希望先拿到vm.$data[a]
return expr.reduce((prev,next)=>{
return prev[next]
},vm.$data)
},
getTextVal(vm,expr){ //擷取編譯文本以後的結果
return expr.replace(/\{\{([^}]+)\}\}/g,(...arguments)=>{
return this.getVal(vm,arguments[1]);
})
},
text(node,vm,expr){ // 文本處理
let updateFn = this.updater['textUpdater'];
let value = this.getTextVal(vm,expr);
updateFn && updateFn(node,value);
},
modle(node,vm,expr){ // 輸入框處理
let updateFn = this.updater['modleUpdater']
updateFn && updateFn(node,this.getVal(vm,expr))
},
updater:{
textUpdater(ndoe,value){
ndoe.textContent = value //文本更新
},
modleUpdater(node,value){
node.value = value
}
}
}
4.3 Oberver.js
編譯的時候我們還需要一個監聽者,當資料變化調用get和set方法
class Observer{
constructor(data){
this.observer(data)
}
observer(data){
if(!data || typeof data !== 'object') return;
Object.keys(data).forEach(key =>{
this.defineReactive(data,key,data[key]);
this.observer(data[key])
})
}
defineReactive(obj,key,value){
let that = this;
Object.defineProperty(obj,key,{
enumerable:true,
configurable:true,
get(){
Dep.target && dep.addSub(Dep.target);
return value;
},
set(newvalue){
if(value === newvalue) return;
that.observer(newvalue); //如果新值是對象,繼續劫持
value = newvalue;
},
})
}
}
4.4 Watcher訂閱者和Dep監聽器
前面已經實作了監聽和編譯,但是怎麼樣才能讓它們之間進行通信呢,也就是當監聽到變化了怎麼通知呢?這裡就用到了釋出訂閱模式。預設觀察者watcher有一個update方法,它會更新資料。Dep裡面建立一個數組,把觀察者都放在這個數組裡面,當監聽到變化,一個個調用監聽者update方法。
// Watcher.js
//觀察者的目的就是給需要變化的那個元素增加一個觀察者,當資料變化後執行對應的方法
class Watcher{
constructor(vm,expr,cb){
this.vm = vm;
this.expr = expr;
this.cb = cb;
//先擷取老的值
this.value = this.get()
}
getVal(vm,expr){ //擷取執行個體上的資料
expr = expr.split('.'); //如果遇到vm.$data[a.a],希望先拿到vm.$data[a]
// console.log(expr)
return expr.reduce((prev,next)=>{
return prev[next]
},vm.$data)
}
get(){
Dep.target = this; //緩存自己
let value = this.getVal(this.vm,this.expr);
Dep.target = null; //釋放自己
return value;
}
update(){
let newValue = this.getVal(this.vm,this.expr);
let oldValue = this.value;
if(newValue != oldValue){
this.cb(newValue);
}
}
}
//Dep.js
class Dep{
constructor(){
//訂閱的數組
this.subs = []
}
addSub(watcher){
this.subs.push(watcher)
}
notify(){
this.subs.forEach(watcher =>{
watcher.update()
})
}
}
watcher邏輯: 當建立watcher執行個體的時候,先拿到這個值,資料變化又拿到一個新值,如果新值和老值不一樣,那麼調用callback,實作更新;
dep邏輯:建立數組把觀察者放在這個數組裡,當監聽到變化,執行watcher.update()
我們再它們分别添加到Observer和compile中
// complie.js
// 省略....
text(node,vm,expr){ // 文本處理
let updateFn = this.updater['textUpdater'];
//{{message.a}} => tangj
let value = this.getTextVal(vm,expr);
expr.replace(/\{\{([^}]+)\}\}/g,(...arguments)=>{
new Watcher(vm,arguments[1],(newVaule)=>{
// 如果資料變化,文本節點需要重新擷取依賴的屬性更新文本的的内容
updateFn && updateFn(node,this.getTextVal(vm,expr));
})
})
updateFn && updateFn(node,value);
},
modle(node,vm,expr){ // 輸入框處理
let updateFn = this.updater['modleUpdater']
// 'message.a' => [message.a] vm.$data['message'].a
// 這裡應該加一個監控,資料變化,調用這個watch的cb
new Watcher(vm,expr,(newVaule)=>{
//當值變化後将調用cb,将新的值傳遞過來
updateFn && updateFn(node,this.getVal(vm,expr))
});
node.addEventListener('input',(e)=>{
let newVaule = e.target.value;
this.setVal(vm,expr,newVaule)
})
updateFn && updateFn(node,this.getVal(vm,expr))
}
// 省略...
// observer.js
class Observer{
constructor(data){
this.observer(data)
}
observer(data){
//要對這個data資料原有屬性改成set和get的形式
if(!data || typeof data !== 'object') return;
Object.keys(data).forEach(key =>{
this.defineReactive(data,key,data[key]);
this.observer(data[key])
})
}
defineReactive(obj,key,value){
let that = this;
let dep = new Dep(); //每個變化的資料都會對應一個數組,這個資料存放了所有資料的更新
Object.defineProperty(obj,key,{
enumerable:true,
configurable:true,
get(){
Dep.target && dep.addSub(Dep.target);
return value;
},
set(newvalue){
if(value === newvalue) return;
that.observer(newvalue); //如果新值是對象,繼續劫持
value = newvalue;
dep.notify(); //通知所有人資料更新
},
})
}
}
class Dep{
constructor(){
//訂閱的數組
this.subs = []
}
addSub(watcher){
this.subs.push(watcher)
}
notify(){
this.subs.forEach(watcher =>{
watcher.update()
})
}
}
到這裡我們就實作了資料的雙向綁定,MVVM作為資料綁定的入口,整合Observer、Compile和Watcher三者,通過Observer來監聽自己的model資料變化,通過Compile來解析編譯模闆指令,最終利用Watcher搭起Observer和Compile之間的通信橋梁,達到資料變化 -> 視圖更新;視圖互動變化(input) -> 資料model變更的雙向綁定效果。
當然我們還需要資料代理,用vm代理vm.$data,也是通過Object.defineProperty()實作
this.proxyData(this.$data);
proxyData(data){
Object.keys(data).forEach(key =>{
let val = data[key]
Object.defineProperty(this,key,{
enumerable:true,
configurable:true,
get(){
return val
},
set(newval){
if(val == newval){
return;
}
val = newval
}
})
})
}
5.最終效果
本次學習源碼已上傳github:https://github.com/Tangjj1996/MVVM,喜歡的朋友可以stars
參考部落格:基于vue實作一個簡單的MVVM架構(源碼分析)
PS:MVVM是學習架構非常重要的一步,掌握了這些原理才能更好地運用,知其然更要知其是以然,水準有限有錯誤的地方煩請多多指教