視圖模板引擎——Vue【雙向綁定】原理剖析
首先我們來了解一下MVC、MVP、MVMM這三大架構(gòu)模式在前端角度上的理解。
MVC分別是 Model(模型)、View(視圖)、Controller(控制器)三個(gè)模塊。View(視圖層)最主要完成前端的數(shù)據(jù)展示,Controller(控制層)是對(duì)數(shù)據(jù)的接收和觸發(fā)事件的接收和傳遞,Model(模型層)則是對(duì)數(shù)據(jù)的儲(chǔ)存和處理,再傳遞給視圖層相應(yīng)或者展示。如下圖所示,整個(gè)過程是單鏈條的傳遞,在前端開發(fā)中多將業(yè)務(wù)邏輯寫在View層,使得View層比較厚,而Controller層比較薄。
缺點(diǎn):
1、 開發(fā)者在代碼中大量調(diào)用相同的 DOM API,處理繁瑣 ,操作冗余,使得代碼難以維護(hù)。
2、大量的DOM 操作使頁面渲染性能降低,加載速度變慢,影響用戶體驗(yàn)。
3、 當(dāng) Model 頻繁發(fā)生變化,開發(fā)者需要主動(dòng)更新到View ;當(dāng)用戶的操作導(dǎo)致 Model 發(fā)生變化,開發(fā)者同樣需要將變化的數(shù)據(jù)同步到Model 中,這樣的工作不僅繁瑣,而且很難維護(hù)復(fù)雜多變的數(shù)據(jù)狀態(tài)。
MVP是Model(模型)、View(視圖)、Presenter(表示器)組成。MVP架構(gòu)模式最主要是針對(duì)Android的MVC架構(gòu)模式進(jìn)行改進(jìn)的,MVP與MVC最不同的一點(diǎn)是M與V是不直接關(guān)聯(lián)的也是就Model與View不存在直接關(guān)系,這兩者之間間隔著的是Presenter層,其負(fù)責(zé)調(diào)控View與Model之間的間接交互。
MVVM 架構(gòu)模式最主要是針對(duì)前端和iOS的MVC架構(gòu)模式進(jìn)行改進(jìn)的,減輕Controller層或者View層的壓力,實(shí)現(xiàn)更加清晰化代碼。通過對(duì)ViewModel層的封裝:封裝業(yè)務(wù)邏輯處理,封裝網(wǎng)絡(luò)處理、封裝數(shù)據(jù)緩存等,讓邏輯處理分離出來,并且不需要處理Model數(shù)據(jù),使得Controller層或者View層結(jié)構(gòu)簡(jiǎn)單,條理清晰。MVVM模式的優(yōu)點(diǎn)在于當(dāng)view和viewmodel的雙向綁定,當(dāng)數(shù)據(jù)改變后不需要改修改DOM結(jié)構(gòu)。MVVM的實(shí)現(xiàn)原理:利用Object.defineProperty(),該方法有g(shù)et、set兩個(gè)屬性方法,從而獲取對(duì)象屬性的值,給對(duì)象屬性重新賦值。
在MVVM架構(gòu)下,View 和 Model 之間并沒有直接的聯(lián)系,而是通過ViewModel進(jìn)行交互,Model 和 ViewModel 之間的交互是雙向的, 因此View 數(shù)據(jù)的變化會(huì)同步到Model中,而Model 數(shù)據(jù)的變化也會(huì)立即反應(yīng)到View 上。
視圖變化更新數(shù)據(jù),數(shù)據(jù)變化更新視圖
Vue是框架嗎?算是吧。
Vue的核心定位并不是一個(gè)框架,設(shè)計(jì)上也沒有完全遵循MVVM模式,Vue的核心功能強(qiáng)調(diào)的是狀態(tài)到界面的映射,對(duì)于代碼的結(jié)構(gòu)組織并不重視, 所以單純只使用其核心功能時(shí),它并不是一個(gè)框架,而更像一個(gè)視圖模板引擎。
Vue的使用方式——漸進(jìn)式,那么漸進(jìn)式是什么意思?
在聲明式渲染(視圖模板引擎)的基礎(chǔ)上,我們可以通過添加組件系統(tǒng)、客戶端路由、大規(guī)模狀態(tài)管理、構(gòu)建工具來構(gòu)建一個(gè)完整的框架。更重要的是,這些功能相互獨(dú)立,你可以在核心功能的基礎(chǔ)上任意選用其他的部件,不一定要全部整合在一起。
那么我們來分析一下Vue的雙向綁定到底是什么?
下面的代碼是使用v-model命令實(shí)現(xiàn)的雙向綁定。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>v-model</title>
</head>
<body>
<div id="app">
<input type="text" v-model="msg">
<p>{{msg}}</p>
<select v-model="leval">
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
</select>
<p>{{leval}}</p>
</div>
</body>
<script src="https://unpkg.com/vue@2.6.10/dist/vue.js"></script>
<script>
let vm=new Vue({
el:"#app",
data:{
msg:"",
leval:1
}
})
</script>
</html>
那么這樣實(shí)現(xiàn)的原理是什么?
Vue的雙向綁定是由數(shù)據(jù)劫持結(jié)合發(fā)布者-訂閱者模式實(shí)現(xiàn)的,那么什么是數(shù)據(jù)劫持?vue是如何進(jìn)行數(shù)據(jù)劫持的?說白了就是通過Object.defineProperty()來劫持對(duì)象屬性的setter和getter操作,在數(shù)據(jù)變動(dòng)時(shí)做你想要做的事情。
我們可以看一下通過控制臺(tái)梳理一個(gè)定義在vue初始化數(shù)據(jù)上的對(duì)象是什么.
let vm=new Vue({
el:"#app",
data:{
msg:"",
leval:1,
name:{
le:'maomin'
}
},
created(){
console.log(this.name)
}
})
如上圖所示:name對(duì)象有兩個(gè)方法。分別是get、set。那么這兩個(gè)方法就是Vue通過Object.definePreperty()進(jìn)行數(shù)據(jù)劫持得到的。
這個(gè)方法會(huì)直接在一個(gè)對(duì)象上定義一個(gè)新屬性,或者修改一個(gè)對(duì)象的現(xiàn)有屬性, 并返回這個(gè)對(duì)象。
Object.definePreperty(data,key,desriptor)
//data 要在其上定義屬性的對(duì)象;
//key 要定義或修改的屬性的名稱;(這里的類型是String)
//descriptor 將被定義或修改的屬性描述符;(一般為set、get方法的對(duì)象組合)
那么我們給個(gè)關(guān)于Object.definePreperty()方法的例子看一下。
先來一個(gè)這個(gè)。
var person = {
name:'maomin'
};
console.log('這個(gè)帥哥叫'+person.name)//這個(gè)帥哥叫maomin
但是你想在執(zhí)行console.log的同時(shí),加上他的女朋友是M。那么可以使用Object.defineProperty()方法來執(zhí)行。
var person = {
name:'maomin'
}
var name1 = ''
Object.defineProperty(person,'name',{
set:function (val) {
name1=val
console.log('這是set方法')
console.log('他的名字叫'+val)
},
get:function () {
console.log('這是get方法')
return name1 +'他的女朋友叫M'
}
})
console.log('這個(gè)帥哥叫'+person.name)
/*
輸出結(jié)果:
這是get方法
這個(gè)帥哥叫他的女朋友叫M
*/
但你會(huì)發(fā)現(xiàn)沒有執(zhí)行set()方法,那么我們這樣試試。
var person = {}
var name1 = ''
Object.defineProperty(person,'name',{
set:function (val) {
name1=val
console.log('這是set方法')
console.log('他的名字叫'+val)
},
get:function () {
console.log('這是get方法')
return name1 +'他的女朋友叫M'
}
})
person.name ='maomin' //在Object.defineProperty()后賦初始值
console.log('這個(gè)帥哥叫'+','+person.name)
/*
輸出結(jié)果:
這是set方法
他的名字叫maomin
這是get方法
這個(gè)帥哥叫maomin,他的女朋友叫M
*/
這樣set方法才會(huì)執(zhí)行。
所以你在針對(duì)一個(gè)對(duì)象修改屬性值的時(shí)候一定要在Object.defineProperty()方法后給屬性賦初始值。
那么講完初步了解數(shù)據(jù)劫持了,那么什么是發(fā)布者—訂閱者設(shè)計(jì)模式呢?
比如說根據(jù)微信訂閱號(hào)的例子來講,如果你是訂閱號(hào)文章的發(fā)表者,那么怎樣才能讓別人實(shí)時(shí)看到你的發(fā)表的文章呢?那就是這個(gè)人已經(jīng)關(guān)注了你的訂閱號(hào)。這樣你發(fā)表了文章,關(guān)注的人才會(huì)看到。所以,我們的思路大體是:
1、初始化發(fā)布者、訂閱者。
2、訂閱者需要注冊(cè)到發(fā)布者,發(fā)布者發(fā)布消息時(shí),依次向訂閱者發(fā)布消息。
我們?cè)谇懊婵吹搅薕bject.defineProperty()中的set()方法當(dāng)屬性變化時(shí)會(huì)觸發(fā)。那么可以利用這個(gè)特性可以把需要更新的方法放在set里面,這樣就實(shí)現(xiàn)數(shù)據(jù)層更新,進(jìn)而頁面層就更新了。頁面層更新,數(shù)據(jù)層更新使用事件監(jiān)聽就可以了。
實(shí)現(xiàn)了雙向綁定,那么我們?cè)趺礃訉?shí)現(xiàn)數(shù)據(jù)劫持監(jiān)聽呢?
這里需要三步:
1.實(shí)現(xiàn)一個(gè)監(jiān)聽器Observer,用來劫持并監(jiān)聽所有屬性,如果有變動(dòng)的,就通知訂閱者。
2.實(shí)現(xiàn)一個(gè)訂閱者Watcher,可以收到屬性的變化通知并執(zhí)行相應(yīng)的函數(shù),從而更新視圖。
3.實(shí)現(xiàn)一個(gè)解析器Compile,可以掃描和解析每個(gè)節(jié)點(diǎn)的相關(guān)指令,并根據(jù)初始化模板數(shù)據(jù)以及初始化相應(yīng)的訂閱器。
1、observer監(jiān)聽器
下面我們將通過observer()方法進(jìn)行遍歷向下找到所有的屬性,并通過defineReactive()方法進(jìn)行數(shù)據(jù)劫持監(jiān)聽。
//對(duì)所有屬性都要蔣婷,遞歸遍歷所有屬性
function defineReactive(data,key,val) {
observer(val); //遞歸遍歷所有的屬性,如果不加的話,遍歷不了book1內(nèi)部的元素
Object.defineProperty(data,key,{
// writable: false, //當(dāng)且僅當(dāng)該屬性的writable為true時(shí),value才能被賦值運(yùn)算符改變。默認(rèn)為 false。當(dāng)定義了一個(gè)屬性的set、get描述符,則JavaScript會(huì)忽略該屬性的value、writable屬性。也就是說這倆對(duì)兒屬于互斥的關(guān)系.
enumerable:true, //當(dāng)且僅當(dāng)該屬性的 enumerable 為 true 時(shí),該屬性描述符才能夠被改變,同時(shí)該屬性也能從對(duì)應(yīng)的對(duì)象上被刪除。
configurable:true, //當(dāng)且僅當(dāng)該屬性的configurable為true時(shí),該屬性才能夠出現(xiàn)在對(duì)象的枚舉屬性中
set:function(value) {
val = value;
console.log('屬性'+key+'已經(jīng)被監(jiān)聽,現(xiàn)在值為:"'+value.toString()+'"');
},
get:function() {
return val;
},
})
}
function observer(data) {
if(!data || typeof data !== 'object') {
return;
}
Object.keys(data).forEach(function(key){
defineReactive(data,key,data[key]);
});
}
var language = {
book1: {
name: ''
},
book2: '',
name2:'',
name3:''
};
observer(language);
language.book1.name = 'Vue';
language.name2 = 'HTML';
language.name2 = 'js';
language.book2 = 'css';
/*
輸出結(jié)果:
屬性name已經(jīng)被監(jiān)聽了,現(xiàn)在值為:“Vue”
屬性name2已經(jīng)被監(jiān)聽了,現(xiàn)在值為:“HTML”
屬性name2已經(jīng)被監(jiān)聽了,現(xiàn)在值為:“js”
屬性book2已經(jīng)被監(jiān)聽了,現(xiàn)在值為:“css”
*/
我們需要一個(gè)可以容納消息訂閱者的消息訂閱器Dep,訂閱器主要收集消息訂閱者,然后在屬性變化時(shí)執(zhí)行相應(yīng)訂閱者的更新函數(shù),那么消息訂閱器Dep需要有一個(gè)容器,用來存放消息訂閱者.
//對(duì)所有屬性都要蔣婷,遞歸遍歷所有屬性
function defineReactive(data,key,val) {
observer(val); //遞歸遍歷所有的屬性,如果不加的話,遍歷不了book1內(nèi)部的元素
var dep = new Dep();
Object.defineProperty(data,key,{
// writable: false, //當(dāng)且僅當(dāng)該屬性的writable為true時(shí),value才能被賦值運(yùn)算符改變。默認(rèn)為 false。當(dāng)定義了一個(gè)屬性的set、get描述符,則JavaScript會(huì)忽略該屬性的value、writable屬性。也就是說這倆對(duì)兒屬于互斥的關(guān)系.
enumerable:true, //當(dāng)且僅當(dāng)該屬性的 enumerable 為 true 時(shí),該屬性描述符才能夠被改變,同時(shí)該屬性也能從對(duì)應(yīng)的對(duì)象上被刪除。
configurable:true, //當(dāng)且僅當(dāng)該屬性的configurable為true時(shí),該屬性才能夠出現(xiàn)在對(duì)象的枚舉屬性中
set:function(value) {
if (val === value) {
return;
}
val = value;
console.log('屬性' + key + '已經(jīng)被監(jiān)聽了,現(xiàn)在值為:“' + value.toString() + '”');
dep.notify(); // 如果數(shù)據(jù)變化,通知所有訂閱者
// val = value;
// console.log('屬性'+key+'已經(jīng)被監(jiān)聽,現(xiàn)在值為:"'+value.toString()+'"');
},
get:function() {
if (Dep.target) { //Watcher初始化觸發(fā)
dep.addSub(Dep.target); // 在這里添加一個(gè)訂閱者
}
return val;
},
})
}
function observer(data) {
if(!data || typeof data !== 'object') {
return;
}
Object.keys(data).forEach(function(key){
defineReactive(data,key,data[key]);
});
}
function Dep() {
this.subs = [];
}
Dep.prototype = {
addSub:function(sub) {
this.subs.push(sub);
},
notify:function() {
this.subs.forEach(function(sub) {
sub.update(); //通知每個(gè)訂閱者檢查更新
})
}
}
Dep.target = null;
var language = {
book1: {
name: ''
},
book2: '',
name2:'',
name3:''
};
observer(language);
language.book1.name = 'Vue';
language.name2 = 'HTML';
language.name2 = 'js';
language.book2 = 'css';
為了后面的watcher,我們先將上面所有代碼整體修改一下。得到下面最終的Observer:
function Observer(data) {
this.data = data;
this.walk(data);
}
Observer.prototype = {
walk: function(data) {
var that = this;
//遍歷所有的屬性
Object.keys(data).forEach(function(key) {
that.defineReactive(data, key, data[key]);
});
},
defineReactive: function(data, key, val) {
var dep = new Dep();
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
set: function(newVal) {
if (newVal === val) {
return;
}
val = newVal;
dep.notify(); // 如果數(shù)據(jù)變化,通知所有訂閱者
},
get: function () {
if (Dep.target) { //是否添加訂閱者,Watcher初始化觸發(fā)
dep.addSub(Dep.target); //添加訂閱者
}
return val;
}
});
}
};
function observe(value, vm) {
if (!value || typeof value !== 'object') {
return;
}
return new Observer(value);
};
function Dep () {
this.subs = [];
}
//prototype 屬性使您有能力向?qū)ο筇砑訉傩院头椒?br>//prototype這個(gè)屬性只有函數(shù)對(duì)象才有,具體的說就是構(gòu)造函數(shù)具有.只要你聲明定義了一個(gè)函數(shù)對(duì)象,這個(gè)prototype就會(huì)存在
//對(duì)象實(shí)例是沒有這個(gè)屬性
Dep.prototype = {
addSub: function(sub) {
this.subs.push(sub);
},
notify: function() {
this.subs.forEach(function(sub) {
sub.update(); //通知每個(gè)訂閱者檢查更新
});
}
};
Dep.target = null;
2、watcher訂閱者
訂閱者Wahcher在初始化時(shí)要將自己添加到訂閱器Dep中。
我們已經(jīng)知道監(jiān)聽器Observer是在get函數(shù)中執(zhí)行了添加訂閱者的操作的,所以我們只需要在訂閱者Watcher在初始化時(shí)觸發(fā)相對(duì)應(yīng)的get函數(shù)來執(zhí)行添加訂閱者的操作即可。那么怎么觸發(fā)對(duì)應(yīng)的get函數(shù)呢?我們只需要獲取對(duì)應(yīng)的屬性值,就可以通過Object.defineProperty( )觸發(fā)對(duì)應(yīng)的get了。
在這里需要注意一個(gè)細(xì)節(jié),我們只需要在訂閱者初始化時(shí)才執(zhí)行添加訂閱者,所以我們需要一個(gè)判斷,在Dep.target上緩存一下訂閱者,添加成功后去除就行了。
function Watcher(vm, exp, cb) {
this.cb = cb; //閉包
this.vm = vm; //指向SelfVue的作用域
this.exp = exp; //綁定屬性的key值
this.value = this.get(); // 將自己添加到訂閱器的操作
}
Watcher.prototype = {
update: function() {
this.run();
},
run: function() {
var value = this.vm.data1[this.exp];
var oldVal = this.value;
if (value !== oldVal) {
this.value = value;
this.cb.call(this.vm, value, oldVal);
}
},
get: function() {
Dep.target = this; // 緩存自己
var value = this.vm.data1[this.exp] // 強(qiáng)制執(zhí)行監(jiān)聽器里的get函數(shù)
Dep.target = null; // 釋放自己
return value;
}
};
在這里我們已經(jīng)將上面的Observer已經(jīng)修改,這里不再講述。
3、整合(將訂閱者跟監(jiān)聽器連接起來)
<body>
<div id="name">{{name}}</div>
</body>
var ele = document.querySelector('#name');
var selfVue = new SelfVue({
name: '你好,maomin'
}, ele, 'name');
window.setTimeout(function () {
selfVue.name = '你也好';
}, 5000);
以上代碼是展示頁面。
接下來我們將寫創(chuàng)建初始對(duì)象的邏輯代碼:
function SelfVue (data, el, exp) {
var that = this;
console.log(this)
this.data1 = data;
//遍歷所有data里的屬性
Object.keys(data).forEach(function(key) {
that.proxyKeys(key);
});
//將Observer和Watcher關(guān)聯(lián)起來
observe(data);
console.log(this.data1[exp])
el.innerHTML = this.data1[exp]; // 初始化模板數(shù)據(jù)的值
new Watcher(this, exp, function (value) {
el.innerHTML = value;
});
return this;
}
SelfVue.prototype = {
proxyKeys: function (key) {
var that = this;
console.log(this)
Object.defineProperty(this, key, {
enumerable: false,
configurable: true,
set: function proxySetter(newVal) {
that.data1[key] = newVal;
},
get: function proxyGetter() {
return that.data1[key];
}
});
}
}
這樣一個(gè)超簡(jiǎn)易的Vue 雙向綁定這樣就實(shí)現(xiàn)了,這里都是死的數(shù)據(jù),沒有寫compile指令解析器。
上面的雙向綁定demo中,我們發(fā)現(xiàn)整個(gè)過程都沒有解析dom節(jié)點(diǎn),而是固定某個(gè)節(jié)點(diǎn)進(jìn)行替換數(shù)據(jù)
4、Compile指令解析器
指令解析器作用:
1.解析模板指令,并替換模板數(shù)據(jù),初始化視圖
2.將模板指令對(duì)應(yīng)的節(jié)點(diǎn)綁定對(duì)應(yīng)的更新函數(shù),初始化相應(yīng)的訂閱器
function Compile(el, vm) {
this.vm = vm;
this.el = document.querySelector(el);
this.fragment = null;
this.init();
}
Compile.prototype = {
init: function () {
if (this.el) {
this.fragment = this.nodeToFragment(this.el);
this.compileElement(this.fragment);
this.el.appendChild(this.fragment);
} else {
console.log('Dom元素不存在');
}
},
//首先要獲得dom元素, 然后對(duì)含有dom元素上含有指令的節(jié)點(diǎn)進(jìn)行處理
nodeToFragment: function (el) {
var fragment = document.createDocumentFragment(); //createdocumentfragment()方法創(chuàng)建了一虛擬的節(jié)點(diǎn)對(duì)象,節(jié)點(diǎn)對(duì)象包含所有屬性和方法。
var child = el.firstChild;
while (child) {
// 將Dom元素移入fragment中
fragment.appendChild(child);
child = el.firstChild
}
return fragment;
},
//接下來需要遍歷所有節(jié)點(diǎn), 對(duì)含有指令的節(jié)點(diǎn)進(jìn)行特殊的處理, 這里我們先處理最簡(jiǎn)單的情況, 只對(duì)帶有 '{{變量}}'這種形式的指令進(jìn)行處理
compileElement: function (el) {
var childNodes = el.childNodes; //childNodes屬性返回節(jié)點(diǎn)的子節(jié)點(diǎn)集合,以 NodeList 對(duì)象。
var that = this;
//slice() 方法可從已有的數(shù)組中返回選定的元素。
[].slice.call(childNodes).forEach(function(node) {
var reg = /\{\{(.*)\}\}/;
var text = node.textContent;
if (that.isTextNode(node) && reg.test(text)) { // 判斷是否是符合這種形式{{}}的指令
//exec() 方法用于檢索字符串中的正則表達(dá)式的匹配。
//返回一個(gè)數(shù)組,其中存放匹配的結(jié)果。如果未找到匹配,則返回值為 null。
that.compileText(node, reg.exec(text)[1]);
}
if (node.childNodes && node.childNodes.length) {
that.compileElement(node); // 繼續(xù)遞歸遍歷子節(jié)點(diǎn)
}
});
},
compileText: function(node, exp) {
var that = this;
var initText = this.vm[exp];
this.updateText(node, initText); // 將初始化的數(shù)據(jù)初始化到視圖中
new Watcher(this.vm, exp, function (value) { // 生成訂閱器并綁定更新函數(shù)
that.updateText(node, value);
});
},
updateText: function (node, value) {
node.textContent = typeof value == 'undefined' ? '' : value;
},
isTextNode: function(node) {
return node.nodeType == 3;
}
}
這時(shí)在創(chuàng)建初始對(duì)象修改
function SelfVue (options) {
var that = this;
this.vm = this;
this.data1 = options.data;
//遍歷所有data里的屬性
Object.keys(this.data).forEach(function(key) {
that.proxyKeys(key);
});
observe(this.data);
new Compile(options.el, this.vm); //加入這段代碼
return this;
}
SelfVue.prototype = {
proxyKeys: function (key) {
var that = this;
Object.defineProperty(this, key, {
enumerable: false,
configurable: true,
get: function proxyGetter() {
return that.data1[key];
},
set: function proxySetter(newVal) {
that.data1[key] = newVal;
}
});
}
}
展示頁面中修改:
<body>
<div id="app">
<h1>{{title}}</h1>
<h2>{{name}}</h2>
<h3>{{content}}</h3>
</div>
</body>
<script src="js/observer2.js"></script>
<script src="js/Watcher1.js"></script>
<script src="js/compile1.js"></script>
<script src="js/index3.js"></script>
<script>
var selfVue = new SelfVue({
el:'#app',
data:{
title:'title1',
name:'name1',
content:'content1'
}
});
window.setTimeout(function() {
selfVue.title = 'title2';
selfVue.name = 'name2';
selfVue.content = 'content2'
},5000);
</script>
最后,一個(gè)基本的Vue數(shù)據(jù)雙向綁定就這樣實(shí)現(xiàn)了。
作者:Vam的金豆之路
主要領(lǐng)域:前端開發(fā)
我的微信:maomin9761
微信公眾號(hào):前端歷劫之路