簡單理解MVVM——實現Vue的MVVM模式

1.總的思路:先有3個主要的js文件,分別寫模板編譯Compile( )和數據劫持Observer( ),最後靠MVVM( )來整合的。這樣數據比較清晰,而且可以把複用的方法全部抽離出來,方便代碼維護,否則全部寫在一起就會很混亂。

##MVVM.js

class MVVM { constructor(options) { this.$el = options.el; this.$data = options.data; //是否存在要編譯的模板,存在則開始編譯 if (this.$el) { new Observer(this.$data); new Compile(this.$el, this); } }}

2.有了元素後就可以開始編譯了,

先拆分這個流程:

2.1)首先遍歷節點,看是否存在“v-”或者“{{ }}”,所以就要頻繁地操作DOM,考慮一個問題,這樣性能很消耗;有個好方法,就是先把這些需要操作的真實DOM移入到內存中(文檔碎片 fragement),此時移除的DOM不在頁面上了,操作完後再把編譯好的DOM放回頁面,操作內存是不會導致頁面的重繪重渲染的,肯定比直接在頁面上操作快。

##compile.js

class Compile{ constructor(el,vm){ this.el = this.isElementNode(el)?el:document.querySelector(el); this.vm = vm; if(this.el){ //如果這個元素能獲取到 我們才開始編譯 //1.先把這些真實的DOM移入到內存中 fragement let fragment = this.nodeToFragment(this.el); //2.編譯 => 提取想要的元素節點 v-model 和文本節點 {{}} this.compile(fragment); //3.把編譯好的fragement再返回頁面去 this.el.appendChild(fragment); } }……}
/** * 將節點el裡的全部內容放到內存裡面 */ nodeToFragment(el) { let fragment = document.createDocumentFragment(); let firstChild; while (firstChild = el.firstChild){ fragment.appendChild(firstChild); } //內存中的節點 return fragment; }
簡單理解MVVM——實現Vue的MVVM模式

沒有把編譯好的文本碎片放回到頁面前,頁面是為空的,可以和入口文件對比一下,節點app裡面的內容變為空了

2.2)節點添加到文檔碎片後,就可以使用compile( )編譯模板了, 在這裡面需要遞歸所有節點,使用isElementNode( )判斷是 元素類型/文本類型 的節點,分別使用不同的編譯方法

isElementNode(node){ return node.nodeType === 1; }
compile(fragment){ let childNodes = fragment.childNodes; Array.from(childNodes).forEach(node=>{ if(this.isElementNode(node)){ //元素節點,它裡面有可能會繼續嵌套子節點,所以需要深入遞歸 //這裡需要編譯元素節點 this.compileElement(node); this.compile(node); }else{ //文本節點 //這裡需要編譯文本節點 this.compileText(node); } }); }

2.2.1)對於元素節點,只取屬性中有'v-'的元素節點,用ES6中字符串的include( )來判斷

 /** * 判斷屬性名字是不是包含'v-' * @param name * @returns {*|void} */ isDirective(name){ return name.include('v-'); } /** * 編譯帶'v-'屬性的元素節點,DOM元素不能用正則判斷 * @param node */ compileElement(node){ let attrs = node.attributes; Array.from(attrs).forEach(attr => { let attrName = attr.name; if (this.isDirective(attrName)){ let expr = attr.value; let type = attrName.slice(2); //編譯工具方法,後面詳解 CompileUtil[type](node,this.vm,expr); } }); }

2.2.2)對於文本節點,可能是“{{a}} {{b}} {{c}}”或者 "{{abc}}"這樣的形式,所以要把它當作一個整體來編譯。

compileText(node){ let text = node.textContent; let reg = /\{\{([^}]+)\}\}/g; if (reg.test(text)){ //node this.vm.$data text //編譯工具方法,後面詳解 CompileUtil['text'](node,this.vm,text); } }

2.2.3)最後,為了代碼解耦和方法複用,我們通過一個工具方法對象CompileUtil來編譯模板,以後如果還有新的指令需要編譯的,就不需要修改上面的代碼了,只需在對象CompileUtil添加方法就行了。

在對象CompileUtil中我們要把模板替換成它在實例上對應的值,看一下前面的入口文件,如message -> 'Hello I am Yimi' , info.a -> 'How are you' ;

有一個問題:vm.$data["message"] 是ok的,可以拿到'Hello I am Yimi',但是 info.a不能直接vm.$data[" info.a"] 這樣寫,因為實例vm.$data 中沒有 "info.a"這個屬性,必須先把字符串轉換成數組,再把他們連接起來。

所以在對象CompileUtil中要添加一個方法實現 'info.a' => [info,a] vm.$data.info.a

/** * 獲取實例上對應的數據,返回 vm.$data.xxx.yyy * @param vm * @param expr * @returns {T} */ getVal(vm,expr){ expr = expr.split('.'); return expr.reduce((pre,next)=>{ return pre[next]; },vm.$data) },

還需要一個公共邏輯對象,為節點更新數據

/*公共邏輯的複用*/ updater:{ textUpdater(node,value){ node.textContent = value; }, modelUpdater(node,value){ node.value = value; } }

元素節點編譯:對於元素節點,可以直接使用getValue( )來取得對應的值

/** * 帶v-model屬性的元素節點編譯 * @param node * @param vm * @param expr */model(node,vm,expr){ let updateFn = this.updater['modelUpdater']; //這個方法存在再去調用 updateFn && updateFn(node,this.getVal(vm,expr)); },

文本節點編譯:對於文本節點,我們傳進去的是 {{xxx}},不能直接使用,要把xxx匹配出來才能取值

/** * 文本節點編譯 * @param node * @param vm * @param text */text(node,vm,text){ let updateFn = this.updater['textUpdater']; let value = text.replace(/\{\{([^}]+)\}\}/g, (...arguments)=>{ //拿到第一個分組,並且要取得沒有空格的字符串,否則會報錯 return this.getVal(vm,arguments[1].trim()); }); console.log(value); //這個方法存在再去調用 updateFn && updateFn(node,value); },

2.2.4)到這裡Compile就寫完了,接下來我們再看一下圖,然後開始寫數據劫持Observer。

3.在編譯之前,要做數據劫持,由於是對數據進行劫持,所以創建實例的時候只傳入數據。

new Observer(this.$data);

##observer.js

class Observer { constructor(data){ this.observe(data); }……}

3.1)接下來,要觀察數據,然後考慮一個問題,看前面的入口文件index_mvvm.html ,

數據對象的屬性有可能也是一個對象,比如info,所以我們要做深度數據劫持(遞歸)

/** * 將所有data數據改成set和get的形式 * @param data */ observe(data){ //數據不存在或者數據不是對象 if (!data || typeof data !== 'object'){ return; } //將數據一一劫持 先獲取到data的key和value Object.keys(data).forEach(key => { //劫持,若data[key]是個對象,則時需要遞歸劫持 //響應式 為屬性添加get set 在下文定義 this.defineReactive(data,key,data[key]); this.observe(data[key]); }); }

3.2)通過Object.defineProperty( ) , 把data對象的所有屬性,改成訪問器類型屬性,添加get和set方法;

比如現在想取data對象的message值,而message被下面的方法定義了,他就會走get( )方法

那麼,新對象會劫持嗎?和上面一樣,新的數據對象的屬性有可能也是一個對象,所以我們也要做深度數據劫持(遞歸)

/** * 定義響應式,在賦新值的時候加點中間過程 * @param obj 數據對象 * @param key 數據對象屬性 * @param value 屬性值 */ defineReactive(obj,key,value){ let that = this; Object.defineProperty(obj,key,{ enumerable:true, configurable:true, /*取值時調用的方法*/ get(){ return value; }, /*給data屬性中設置值時,更改獲取的屬性的值*/ set(newValue){ if(newValue !== value){ //這裡的this不是實例 //如果是新值是對象則繼續劫持 that.observe(newValue); value = newValue; } } }); } 

3.3)現在值改了,但是模板沒有重新編譯,我們希望的是,數據變了,會讓模板重新編譯,所以我們這裡需要一個觀察者Watcher,使得數據和模板之間有關聯

4.Watcher觀察者的目的:

在於給需要變化的那個元素增加一個觀察者,當數據變化後執行對應的方法,用新值和舊值進行對比,如果發生變化,就調用更新方法。

所以實例化Watcher的時候,傳入3個值vm, expr, cb( )

##watcher.js

class Watcher{ constructor(vm,expr,cb){ this.vm = vm; this.expr = expr; this.cb = cb; //先獲取老的值 this.value = this.get(); }……}

4.1)先獲取舊值

get(){ let value = this.getVal(this.vm,this.expr); return value; }//這個方法和compile.js裡的一樣 getVal(vm,expr){ expr = expr.split('.'); return expr.reduce((pre,next)=>{ return pre[next]; },vm.$data); }

4.2)然後什麼時候更新新值?

對外暴露的更新方法update( ),拿舊值和新值作比較,如果不一樣就執行cb( )傳入新值

update(){ let newValue = this.getVal(this.vm,this.expr); let oldValue = this.value; if (newValue !== oldValue){ this.cb(newValue); } }

4.3)在哪裡調用watcher?

模板編譯的時候,還記得compile.js裡面的CompileUtil.text( )和Compile.model( )嗎?

它們分別是文本節點編譯和帶v-model屬性的元素節點編譯

文本節點編譯添加Watcher,傳入新值編譯:

 text(node,vm,text){ let updateFn = this.updater['textUpdater']; let value = this.getTextVal(vm,text); //為每一個文本添加觀察者,{{a}},{{b}} text.replace(/\{\{([^}]+)\}\}/g, (...arguments)=>{ new Watcher(vm,arguments[1].trim(),(newValue) => { updateFn && updateFn(node,this.getTextVal(vm,newValue)); }); }); //這個方法存在再去調用 updateFn && updateFn(node,value); },

元素節點編譯:這裡應該加一個監控,數據變化了,就調用watcher的回調函數cb(),將新的值傳遞過來,強調一下,他默認不會調用cb( ),要調用Watcher.update()時,才會調用.

model(node,vm,expr){ let updateFn = this.updater['modelUpdater']; //編譯傳入的新值,不會主動編譯,直到調用Watcher.update(),才會調用cb() new Watcher(vm,expr,(newValue)=>{ updateFn && updateFn(node,this.getVal(vm,expr)); }); //這個方法存在再去調用 updateFn && updateFn(node,this.getVal(vm,expr)); },

4.4)那什麼時候調用update( )?

這裡涉及到了一個新內容--發佈訂閱Dep

5.發佈訂閱者Dep

5.1)兩個功能:

a.用數組存放watcher

b.通知全體watcher添加成功,調用watcher.update( )

class Dep{ constructor(){ //訂閱的數組 this.subs = []; } /** * 添加訂閱 * @param watcher */ addSub(watcher){ this.subs.push(watcher); } /** * 通知全體完成添加訂閱,循環每一個watcher,調用watcher的update(),文本節點和表單全部重新賦值 */ notify(){ this.subs.forEach(watcher => watcher.update()); }}

5.2)在哪裡調用Dep?

a.創建Watcher實例,它就要去獲取舊值this.get( ),在哪裡獲取呢?

##watcer.js

let value = this.getVal(this.vm,this.expr); 

b.去實例vm上獲取expr這個值,在哪裡獲取expr?

在屬性的get( )方法獲取,所以在這裡將在compile.js裡面實例化的Watcher賦給Dep.target

##watcher.js

get(){ Dep.target = this;//只要一創建Watcher實例,就把實例賦給Dep.target let value = this.getVal(this.vm,this.expr);//這裡一取屬性就會調用屬性的get()方法,在observer.js //更新完後後,要取消掉 Dep.target = null; return value; }

c.然後它要去取屬性數據了,就會去調用observer.js中定義的該屬性的get( ),這時候就在定義響應式defineReactive( )這裡實例化Dep,重點是在調用get( )時將Watcher實例加入到Dep的數組中

##observer.js

defineReactive(obj,key,value){ let that = this; let dep = new Dep();//每個變化的數據都會對應一個數組,這個數組存放所有更新的操作 Object.defineProperty(obj,key,{ enumerable:true, configurable:true, /*取值時調用的方法*/ get(){ //Dep.target是Watcher實例,實例化Watcher後,才有Dep.target,只有Dep.target存在才執行這條語句 Dep.target && dep.addSub(Dep.target); return value; }, /*給data屬性中設置值時,更改獲取的屬性的值*/ set(newValue){ if(newValue !== value){ //這裡的this不是實例 //如果是對象繼續劫持 // that.observe(newValue); value = newValue; dep.notify();//通知全體,數據更新了 } } });

為什麼要在get( )中添加這條語句 Dep.target && dep.addSub(Dep.target);?

第一次從vm中取屬性值調用get( )時,Watcher沒有實例化, 所以Dep.target不存在

##compile.js

text(node,vm,text){…… let value = this.getTextVal(vm,text);……}

第二次調用get( )時,Watcher才實例化,Dep.target存在 ,將Watcher實例加入到訂閱者Dep的數組中

text(node,vm,text){ …… new Watcher(vm,arguments[1].trim(),(newValue) => { //若數據變化,文本節點要重新獲取依賴的屬性,更新文本中的內容 updateFn && updateFn(node,this.getTextVal(vm,newValue)); }); …… },

d.那麼當Watcher實例加進去後,Dep.target繼續執行就一直都是這個值了,這樣是不行的,不是每次更新都是這個值,所以用完後要還回去,把值幹掉

##watcher.js

get(){ Dep.target = this;//只要一創建Watcher實例,就把實例賦給Dep.target let value = this.getVal(this.vm,this.expr);//這裡一取屬性就會調用屬性的get()方法,在observer.js //更新完後後,要取消掉 Dep.target = null; return value; }

e.到這裡後,當值改變就可以更新所有數據變化,文本節點和表單節點更新數據

##observer.js

set(newValue){ if(newValue !== value){ //這裡的this不是實例 //如果是對象繼續劫持 that.observe(newValue); value = newValue; dep.notify();//通知全體,數據更新了 } }

到這裡後,頁面效果: 從控制檯改變數據,頁面數據改變

簡單理解MVVM——實現Vue的MVVM模式

簡單理解MVVM——實現Vue的MVVM模式

6.最後要實現,從表單輸入新值,文本節點跟著表單更新

給表單節點添加一個事件處理,實現輸入新值並賦值

##compile.js

 model(node,vm,expr){  …… node.addEventListener('input',(e)=>{ let newValue = e.target.value; this.setVal(vm,expr,newValue); }); ……}setVal(vm,expr,value){ //expr => [info,a] expr = expr.split('.'); return expr.reduce((pre,next,currentIndex)=>{ if (currentIndex === expr.length-1){ return pre[next] = value; } return pre[next]; },vm.$data); },

到這裡後,頁面效果: 在輸入框輸入新值,文本節點跟著改變

簡單理解MVVM——實現Vue的MVVM模式

簡單理解MVVM——實現Vue的MVVM模式

到此,Vue的MVVM模式就完成了,這是我根據自己看書看視頻的理解所得,篇幅很長,十分感謝讀者們的閱讀,本文還有很多不足,希望大家多多指出文章錯誤,期待一起討論~~

祝可愛的你夏天快樂~~

簡單理解MVVM——實現Vue的MVVM模式

Hello Summer~~

github源碼地址:

Yimilh/Vue--MVVM

引用文章出處:

Understanding MVVM - A Guide For JavaScript Developers


分享到:


相關文章: