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; }
沒有把編譯好的文本碎片放回到頁面前,頁面是為空的,可以和入口文件對比一下,節點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();//通知全體,數據更新了 } }
到這裡後,頁面效果: 從控制檯改變數據,頁面數據改變
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); },
到這裡後,頁面效果: 在輸入框輸入新值,文本節點跟著改變
到此,Vue的MVVM模式就完成了,這是我根據自己看書看視頻的理解所得,篇幅很長,十分感謝讀者們的閱讀,本文還有很多不足,希望大家多多指出文章錯誤,期待一起討論~~
祝可愛的你夏天快樂~~
Hello Summer~~
github源碼地址:
Yimilh/Vue--MVVM
引用文章出處:
Understanding MVVM - A Guide For JavaScript Developers
閱讀更多 前端小學生 的文章
關鍵字: DIY 模式 JavaScript