相信大家對mvvm並不陌生吧,下面看下實現的程式碼,參考vue原始碼,整理出來的小demo。 想看整體程式碼 猛戳 github,如果要是覺得有對您有幫助 麻煩給個star。
<div id="app">
<input type="text" v-model="message.a" />
{{message.a}}
</div>
<script src="./mvvm/watcher.js"></script>
<script src="./mvvm/observer.js"></script>
<script src="./mvvm/compile.js"></script>
<script src="./mvvm/mvvm.js"></script>
<script>
//將標籤放到記憶體中去,然後 編譯 => 提前想要的元素元素節點 v-model 和文字節點 {{}}
let vm = new MVVM({
el : "#app",
data:{
message:{
a:'1212'
}
}
})
</script>
複製程式碼
幾種實現雙向繫結的做法
1.釋出訂閱模式
一般通過sub,pub的方法實現資料和檢視的繫結監聽,更新資料方法通常做法是 vm.set('property', value)。
2.髒檢查
angular.js 就是通過髒值檢測的方法對資料是否有變更,來決定更新檢視,最簡單的方式就是setInterval()定時輪詢檢測資料變動,當然Google不會這麼low,angular只有在指定的事件觸發時進入髒值檢測,大致如下:
- DOM事件,比如使用者輸入文字,點選按鈕等。(ng-click)
- XHR響應事件(
$http
) - 瀏覽器Location變更事件(
$location
) - Timer事件(
$timeout
,$interval
) - 執行
$digest()
或$apply()
3.資料劫持
vue.js 採用的就是資料劫持結合釋出訂閱模式,通過Object.defineProperty()
來劫持各個屬性的 setter
, getter
,在資料變動時釋出訊息給訂閱者,觸發相應的監聽回撥。
思路整理
- mvvm 會初始化兩個方法
Observer
- 劫持所有屬性,Compile
- 解析指令 Compile
生成檢視的同時 會訂閱資料變化new Watcher
。生成更新檢視的回撥, (這個回撥什麼時候呼叫呢?)new Watcher
會新增訂閱者到Dep陣列
中,方便修改資料的時候通知變化。Observer
如果劫持到變化會通知Dep
,Dep
會執行Dep陣列
裡面所有的通知(new Watcher
)。
1. Observer
我們知道 可以利用 Obeject.defineProperty()
來監聽 setter, getter。
class Observer{
constructor(data){
this.observer(data);
}
observer(data){
//要對這個data資料將原有的屬性改成set和get的形式 所以必須要陣列
if(!data || typeof data !== 'object'){
return;
}
//要將資料 一一劫持 先獲取到 data 到 key 和 value
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); // 由於需要在閉包內新增watcher,所以通過Dep定義一個全域性target屬性,暫存watcher, 新增完移除
return value;
},
set(newValue){ //當給data屬性中設定值到時候,更改獲取的屬性到值
if(newValue != value){
//這裡的this不是例項
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());
}
}
複製程式碼
要點總結
- 利用遞迴 深度監聽(由於Object.defineProperty 無法深度監聽)
- get() 的時候也就是誰需要展示的時候, 要把
new watcher
push到陣列中去(訂閱),方便修改值去通知所有的訂閱者(釋出) - set()的時候,要通知所有的訂閱者,你們要修改值到檢視啦(釋出)
2. Compile
compile 主要做的事情就是解析模版指令,將模版中的變數替換成資料,然後初始化渲染頁面檢視,並將每個指令對應的節點繫結更新函式,新增監聽資料的訂閱者,一旦資料有變動,收到通知,更新檢視。
因為操作中需要多次操作dom節點,為了提高效率及效能,先將文件轉化為文件片段fragment
進行解析編譯操作,解析完成,再將fragment
新增回原來的真實dom節點中
//將真實的DOM移入到記憶體中 fragment
//同樣定義一個類
class Compile{
constructor(el,vm){
//el可能是 #app or dom,所以要進行判斷
this.el = this.isElementNode(el)?el:document.querySelector(el);
this.vm = vm;
if(this.el){
//如果這個元素能獲取到,我們才開始編譯
//1.先把這些真實的DOM移入到記憶體中 fragment
//2、編譯 =》 提前想要的元素元素節點 v-model 和文字節點 {{}}
//3、把編譯好的 fragment 在塞回到頁面裡去
//1.先把這些真實的DOM移入到記憶體中 fragment
let fragment = this.node2fragment(this.el);
//2、編譯 =》 提前想要的元素元素節點 v-model 和文字節點 {{}}
this.compile(fragment);
//3、把編譯的fragment在賽回到頁面中去
this.el.appendChild(fragment);
}
}
/*專門寫一些輔助方法*/
//判斷是否是元素節點
isElementNode(node){
return node.nodeType === 1;
}
isDirective(name){
return name.includes('v-');
}
/*核心的方法*/
//1、需要將el中的內容全部放到記憶體中
node2fragment(el){
//文件碎片 記憶體中的dom節點
let fragment = document.createDocumentFragment();
let firstChild;
while(firstChild = el.firstChild){
fragment.appendChild(firstChild);
}
return fragment; //記憶體中的節點
}
//2、編譯 =》 提前想要的元素元素節點 v-model 和文字節點 {{}}
compile(fragment){
//需要遞迴
let childNodes = fragment.childNodes;
//
Array.from(childNodes).forEach(node => {
if(this.isElementNode(node)){
//是元素節點,還需要深入的檢查
//這裡需要編譯元素
this.compileElement(node);//編譯 帶 v-model 的元素
this.compile(node);
}else{
//文字節點
//這裡需要編譯文字
this.compileText(node);
}
});
}
compileElement(node){
//帶v-model
let attrs = node.attributes;//取出當前節點的屬性
Array.from(attrs).forEach(attr => {
//判斷屬性名字是不是包含v-
let attrName = attr.name;
if(this.isDirective(attrName)){
//取到對應的值放到節點中
let expr = attr.value;
//解構負值,將v-model中的model擷取處理
let [,type] = attrName.split('-');
//node this.vm.$data expr v-model v-text v-html
//todo ...
CompileUtil[type](node,this.vm,expr);
}
})
}
compileText(node){
//帶{{}}
let expr = node.textContent;//取文字中的內容
let reg = /\{\{([^}]+)\}\}/g; //{{a}}、{{b}}、{{c}}
if(reg.test(expr)){
// node this.vm.$data text
//todo ...
CompileUtil['text'](node,this.vm,expr);
}
}
}
CompileUtil = {
//獲取示例上對應的示例
getVal(vm,expr){
expr = expr.split('.');
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'];
//{{message.a}} => 'hello,123獲取編譯文字後的結果
let value = this.getTextVal(vm,expr);
expr.replace(/\{\{([^}]+)\}\}/g,(...arguments)=>{ // arguments ["{{message.a}}", "message.a", 9, "↵ {{message.a}}↵ "]
new Watcher(vm,arguments[1],(newValue)=>{
//如果資料變化了,文字節點需要重新依賴的屬性更新文字中的內容
updateFn && updateFn(node,this.getTextVal(vm,expr));
})
return arguments[1];
});
updateFn && updateFn(node,value);
},
setVal(vm,expr,value){ //[message,a]
expr = expr.split('.');
//收斂
return expr.reduce((prev,next,currentIndex)=>{
if(currentIndex === expr.length-1){
return prev[next] = value;
}
return prev[next];
},vm.$data)
},
model(node,vm,expr){ //輸入框處理
let updateFn = this.updater['modelUpdater'];
//這裡應該加一個監控,資料變化了 應該呼叫這個watch的callback
new Watcher(vm,expr,(newValue)=>{
//當值變化後會呼叫 cb,將新的值傳遞過去 ()
updateFn && updateFn(node,this.getVal(vm,expr));
});
node.addEventListener('input',(e)=>{
let newValue = e.target.value;
this.setVal(vm,expr,newValue)
})
updateFn && updateFn(node,this.getVal(vm,expr));
},
updater:{
//文字更新
textUpdater(node,value){
node.textContent = value;
},
//輸入框更新
modelUpdater(node,value){
node.value = value;
}
}
};
複製程式碼
總結
先放入程式碼片段裡面,在用 compile
方法遍歷元素節點,解析文字節點, 而且在遍歷節點的同時,會 new watcher
新增回撥來接受資料變化的通知。
3. Watcher
Watcher訂閱者作為Observer和Compile之間通訊的橋樑,先看程式碼
// 觀察者的目的就是給需要變化的那個元素增加一個觀察這,
//當資料變化後,執行對應的方法
//目的:用新值和老值進行比對,如果發生變化,就呼叫更新方法
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('.')
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); //呼叫watch的callback
}
}
}
複製程式碼
總結
- 在自身例項化的時候, 往Dep 裡面 push 自身
- 自身有個 update() 方法 以供呼叫 更新檢視回撥
- 在 dep.notice() 通知的時候,能呼叫自身的update()方法,並且觸發Compile中繫結的回撥。
4. mvvm
//因為 MVVM 可以 new,所以 MVVM 肯定是一個類
//用 es6寫法定義
class MVVM{
//在類裡面接受引數,例如,el,和data
constructor(options){
//首先,先把可用的東西掛載在例項上
this.$el = options.el;
this.$data = options.data;
//然後,判斷如果有要編譯的模版再進行編譯
if(this.$el){
//資料劫持,就是把對想的所有屬性 改成 get 和 set 方法
new Observer(this.$data);
this.proxyData(this.$data);
//用 元素 和 資料 進行編譯
new Compile(this.$el,this);
}
}
proxyData(data){
Object.keys(data).forEach(key=>{
Object.defineProperty(this,key,{
get(){
return data[key]
},
set(newValue){
data[key] = newValue;
}
})
})
}
}
複製程式碼
主要是還是用Object.defineProperty
方法來劫持資料,這邊使用代理,實現 this.xxx 代替 this.data.xxx 的效果。
總結
本文主要是參考 vue原始碼 ,來寫的一個mvvm 小demo, 相信文中肯定有一些不嚴謹的思考和錯誤, 希望大家指出來,和大家共同進步。