Vue用了有一段時間了,每當有人問到Vue雙向綁定是怎麼回事的時候,總是不能給大家解釋的很清楚,正好最近有時間把它梳理一下,讓自己理解的更清楚,下次有人問我的時候,可以侃侃而談😄。
一、首先介紹Object.defineProperty()方法
//直接在一個對象上定義一個新屬性,或者修改一個已經存在的屬性, 並返回這個對象
Object.defineProperty(obj,prop,descriptor)
參數
- obj 需要定義屬性的對象。
- prop 需被定義或修改的屬性名。
- descriptor 需被定義或修改的屬性的描述符。
1.1 屬性描述符默認值
屬性 | 默認值 | 說明 |
---|---|---|
configurable | false | 描述屬性是否可以被刪除,默認爲 false |
enumerable | false | 描述屬性是否可以被for...in或Object.keys枚舉,默認爲 false |
writable | false | 描述屬性是否可以修改,默認爲 false |
get | undefined | 當訪問屬性時觸發該方法,默認爲undefined |
set | undefined | 當屬性被修改時觸發該方法,默認爲undefined |
value | undefined | 屬性值,默認爲undefined |
// Object.defineProperty(對象,屬性,屬性描述符)
var obj={}
console.log('obj:',obj);
Object.defineProperty(obj, 'name', {
value: 'James'
});
console.log('obj的默認值:',obj);
delete obj.name;
console.log('obj刪除後:', obj);
console.log('obj枚舉:', Object.keys(obj));
obj.name = '庫裏';
console.log('obj修改後:', obj);
Object.defineProperty(obj, 'name', {value: '庫裏'});
運行結果:
從運行結果可以發現,使用Object.defineProperty()定義的屬性,默認是不可以被修改,不可以被枚舉,不可以被刪除的。可以與常規的方式定義屬性對比一下:如果不使用Object.defineProperty()定義的屬性,默認是可以修改、枚舉、刪除的:
const obj = {};
obj.name = 'James';
console.log('枚舉:', Object.keys(obj));
obj.name = ' 庫裏';
console.log('修改:', obj);
delete obj.name;
console.log('刪除:', obj);
運行結果:
1.2 修改屬性描述符
const o = {};
Object.defineProperty(o, 'name', {
value: 'James', // name屬性值
writable: true, // 可以被修改
enumerable: true, // 可以被枚舉
configurable: true, // 可以被刪除
});
console.log(o);
console.log('枚舉:', Object.keys(o));
o.name = '科比';
console.log('修改:', o);
Object.defineProperty(o, 'name', {
value: 'Po'
});
console.log('修改:', o);
delete o.name;
console.log('刪除:', o);
運行結果:
結果表明,修改writable、enumerable、configurable這三個描述符爲true時,屬性可以被修改、枚舉和刪除。
注意:
1、如果writable爲false,configurable爲true時,通過o.name = "科比"是無法修改成功的,但是使用Object.defineProperty()修改是可以成功的
2、如果writable和configurable都爲false時,如果使用Object.defineProperty()修改屬性值會報錯:Cannot redefine property: name
1.3 enumerable
const o = {};
Object.defineProperty(o, 'name', { value: 'James', enumerable: true });
Object.defineProperty(o, 'contact', { value: (str) => { return str+' baby' }, enumerable: false });
Object.defineProperty(o, 'age', { value: '18' });
o.skill = '前端';
console.log('枚舉:', Object.keys(o));
console.log('trim: ', o.contact('nihao'))
console.log(`o.propertyIsEnumerable('name'): `, o.propertyIsEnumerable('name'));
console.log(`o.propertyIsEnumerable('contact'): `, o.propertyIsEnumerable('contact'));
console.log(`o.propertyIsEnumerable('age'): `, o.propertyIsEnumerable('age'));
運行結果:
![image-20220628151547662](/Users/james/Library/Application Support/typora-user-images/image-20220628151547662.png)
1.4 get和set
注:設置set或者get,就不能在設置value和wriable,否則會報錯
const o = {
__email: ''
};
Object.defineProperty(o, 'email', {
enumerable: true,
configurable: true,
// writable: true, // 如果設置了get或者set,writable和value屬性必須註釋掉
// value: '', // writable和value無法與set和get共存
get: function () { // 如果設置了get 或者 set 就不能設置writable和value
console.log('get', this);
return 'My email is ' + this.__email;
},
set: function (newVal) {
console.log('set', newVal);
this.__email = newVal;
}
});
console.log(o);
o.email = '[email protected]';
o.email;
console.log(o);
o.email = '[email protected]';
console.log(o);
運行結果:
二、原理分析
2.1 最簡單的雙向綁定
<!DOCTYPE html>
<head>
<title>最簡單的雙向綁定</title>
</head>
<body>
<div>
<input type="text" name="name" id="name" />
</div>
</body>
<script>
var data={
__name:''
};
Object.defineProperty(data,'name',{
enumerable: true,
configurable: true,
// writable: true, // 如果設置了get或者set,writable和value屬性必須註釋掉
// value: '', // writable和value無法與set和get共存
get: function () { // 如果設置了get 或者 set 就不能設置writable和value
return this.__name;
},
set: function (newVal) {
this.__name=newVal; //更新屬性
document.querySelector('#name').value = newVal; //更新視圖
}
});
//監聽input事件,更新name
document.querySelector('#name').addEventListener("input",(event)=>{
data.name=event.currentTarget.value
})
</script>
</html>
運行結果:
文本框輸入"老王",查看name屬性變爲"老王";修改name屬性爲"老張",文本框變爲“老張”;
最簡單的雙向綁定完成了😊
2.2 Vue雙向綁定
vue.js 則是採用數據劫持結合發佈者-訂閱者模式的方式,通過Object.defineProperty()來劫持各個屬性的setter,getter,在數據變動時發佈消息給訂閱者,觸發相應的監聽回調。讀完這句話是不是還有50%的懵逼,接下來繼續分析。
雙向綁定的經典示例圖,各位細品:
分析每個模塊的作用:
Observer:數據監聽器,對每個vue的data中定義的屬性循環用Object.defineProperty()實現數據劫持,以便利用其中的setter和getter,然後通知訂閱者,訂閱者會觸發它的update方法,對視圖進行更新
Compile:指令解析器,對每個元素節點的指令進行掃描和解析,根據指令模板替換數據,以及綁定相應的更新函數
Watcher:作爲連接Observer和Compile的橋樑,能夠訂閱並收到每個屬性變動的通知,執行指令綁定的相應回調函數,從而更新視圖
Dep:依賴收集,每個屬性都有一個依賴收集對象,存儲訂閱該屬性的Watcher
Updater:更新視圖
結合原理,自定義實現Vue的雙向綁定
1、首先創建index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>2.0雙向綁定原理</title>
<script src="./Dep.js"></script>
<script src="./MYVM.js"></script>
<script src="./Observer.js"></script>
<script src="./Watcher.js"></script>
<script src="./TemplateCompiler.js"></script>
</head>
<body>
<div id="app">
<!--模擬vue指令綁定name屬性 -->
<span v-text="name"></span>
<!--模擬vue指令v-model雙向綁定 -->
<input type="text" v-model="name">
<!-- 模擬{{}} -->
{{name}}
</div>
<script>
//假設已經有MYVM對象,實例化該對象
//params是一個對象 el是要掛載的dom data是一個對象包含響應式屬性
var vm = new MYVM({
el: '#app',
data: {
name: 'James'
}
})
</script>
</body>
</html>
2、創建MYVM.js,主要作用是調用Observer進行數據劫持和調用TemplateCompiler進行模板解析
function MYVM(options){
//屬性初始化
this.$vm=this;
this.$el=options.el;
this.$data=options.data;
//視圖必須存在
if(this.$el){
//添加屬性觀察對象(實現數據挾持)
new Observer(this.$data)
//創建模板編譯器,來解析視圖
this.$compiler = new TemplateCompiler(this.$el, this.$vm)
}
}
3、創建Observer.js,實現數據劫持
//數據解析,完成對數據屬性的劫持
function Observer(data){
//判斷data是否有效且data必須是對象
if(!data || typeof data !=='object' ){
return
}else{
var keys=Object.keys(data)
keys.forEach((key)=>{
this.defineReactive(data,key,data[key])
})
}
}
Observer.prototype.defineReactive=function(obj,key,val){
Object.defineProperty(obj,key,{
//是否可遍歷
enumerable: true,
//是否可刪除
configurable: false,
//取值
get(){
return val
},
//修改值
set(newVal){
val=newVal
}
})
}
上面代碼完成了數據屬性的劫持,讀取和修改屬性會執行get、set,運行結果:
4、給Observer.js增加訂閱和發佈功能,新建Dep.js,進行訂閱和發佈管理
//創建訂閱發佈者
//1.管理訂閱
//2.集體通知
function Dep(){
this.subs=[];
}
//添加訂閱
//參數sub是watcher對象
Dep.prototype.addSub=(sub)=>{
this.subs.push(sub)
}
//集體通知,更新視圖
Dep.prototype.notify=()=>{
this.subs.forEach((sub) => {
sub.update()
})
}
5、把Dep安裝到Observer.js,代碼如下
//數據解析,完成對數據屬性的劫持
function Observer(data){
//判斷data是否有效且data必須是對象
if(!data || typeof data !=='object' ){
return
}else{
var keys=Object.keys(data)
keys.forEach((key)=>{
this.defineReactive(data,key,data[key])
})
}
}
Observer.prototype.defineReactive=function(obj,key,val){
//創建Dep實例
var dep=new Dep();
Object.defineProperty(obj,key,{
//是否可遍歷
enumerable: true,
//是否可刪除
configurable: false,
//取值
get(){
//watcher創建時,完成訂閱
//檢查target是否有watcher,有的話進行訂閱
var watcher = Dep.target;
watcher && dep.addSub(watcher)
return val
},
//修改值
set(newVal){
val=newVal
dep.notify()
}
})
}
var dep=new Dep() 創建了Dep的實例
get的時候檢查是否有watcher,有就添加到訂閱數組
set的時候通知所有的訂閱者,進行視圖更新
至此屬性數據劫持,訂閱和發佈就已經實現完了
6、接下來實現模板編譯器,首先創建TemplateCompiler.js
// 創建模板編譯工具
// el 要編譯的dom節點
// vm MYVM的當前實例
function TemplateCompiler(el,vm){
this.el = this.isElementNode(el) ? el : document.querySelector(el);
this.vm = vm;
if (this.el) {
//將對應範圍的html放入內存fragment
var fragment = this.node2Fragment(this.el)
//編譯模板
this.compile(fragment)
//將數據放回頁面
this.el.appendChild(fragment)
}
}
//是否是元素節點
TemplateCompiler.prototype.isElementNode=function(node){
return node.nodeType===1
}
//是否是文本節點
TemplateCompiler.prototype.isTextNode=function(node){
return node.nodeType===3
}
//轉成數組
TemplateCompiler.prototype.toArray=function(arr){
return [].slice.call(arr)
}
//判斷是否是指令屬性
TemplateCompiler.prototype.isDirective=function(directiveName){
return directiveName.indexOf('v-') >= 0;
}
//讀取dom到內存
TemplateCompiler.prototype.node2Fragment=function(node){
var fragment=document.createDocumentFragment();
var child;
//while(child=node.firstChild)這行代碼,每次運行會把firstChild從node中取出,指導取出來是null就終止循環
while(child=node.firstChild){
fragment.appendChild(child)
}
return fragment;
}
//編譯模板
TemplateCompiler.prototype.compile=function(fragment){
var childNodes = fragment.childNodes;
var arr = this.toArray(childNodes);
arr.forEach(node => {
//判斷是否是元素節點
if(this.isElementNode(node)){
this.compileElement(node);
}else{
//定義文本表達式驗證規則
var textReg = /\{\{(.+)\}\}/;
var expr = node.textContent;
if (textReg.test(expr)) {
//獲取綁定的屬性
expr = RegExp.$1;
//調用方法編譯
this.compileText(node, expr)
}
}
});
}
//解析元素節點
TemplateCompiler.prototype.compileElement=function(node){
//獲取節點所有屬性
var arrs=node.attributes;
this.toArray(arrs).forEach(attr => {
//獲取屬性名稱
var attrName=attr.name;
if(this.isDirective(attrName)){
//獲取v-modal的modal
var type = attrName.split('-')[1]
//獲取屬性對應的值(綁定的屬性)
var expr = attr.value;
CompilerUtils[type] && CompilerUtils[type](node, this.vm, expr)
}
});
}
//解析文本節點
TemplateCompiler.prototype.compileText=function(node,expr){
CompilerUtils.text(node, this.vm, expr)
}
TemplateCompiler的主要邏輯:
a、dom節點讀入到內存
b、遍歷所有節點,判斷節點類型,元素節點和文本節點分別使用不同方法編譯
c、元素節點編譯,遍歷所有屬性,根據指令名稱稱找到CompilerUtils對應的指令處理方法,執行視圖初始化和訂閱
d、文本節點編譯,正則匹配找到綁定的屬性,使用CompilerUtils的text執行初始化和訂閱
7、創建CompilerUtils編輯工具對象,實現視圖初始化和訂閱
//編譯工具
CompilerUtils = {
//對應視圖v-modal指令,使用該方法進行視圖初始化和訂閱
//params node當前節點 vm myvm對象 expr綁定的屬性
//modal方法執行一次,進行視圖初始化、事件訂閱,添加視圖到模型的事件
model(node, vm, expr) {
//節點更新方法
var updateFn = this.updater.modelUpdater;
//初始化,更新node的值
updateFn && updateFn(node, vm.$data[expr])
//實例化一個訂閱者,添加到訂閱數組
new Watcher(vm, expr, (newValue) => {
//發佈的時候,按照之前的規則,對節點進行更新
updateFn && updateFn(node, newValue)
})
//視圖到模型(觀察者模式)
node.addEventListener('input', (e) => {
//獲取新值放到模型
var newValue = e.target.value;
vm.$data[expr] = newValue;
})
},
//對應視圖v-text指令,使用該方法進行視圖初始化和訂閱
//params node當前節點 vm myvm對象 expr綁定的屬性
//text方法執行一次,進行視圖初始化、事件訂閱
text(node, vm, expr) {
//text更新方法
var updateFn = this.updater.textUpdater;
//初始化,更新text的值
updateFn && updateFn(node, vm.$data[expr])
//實例化一個訂閱者,添加到訂閱數組
new Watcher(vm, expr, (newValue) => {
//發佈的時候,按照之前的規則,對文本節點進行更新
updateFn && updateFn(node, newValue)
})
},
updater: {
//v-text數據更新
textUpdater(node, value) {
node.textContent = value;
},
//v-model數據更新
modelUpdater(node, value) {
node.value = value;
}
}
}
CompilerUtils的主要邏輯:
a、根據指令對節點進行數據初始化,實例化觀察者Watcher到訂閱數組
b、不同的指令進行不同的邏輯處理
8、創建Watcher.js,實現訂閱者邏輯
//聲明一個訂閱者
//vm 全局vm對象
//expr 屬性名稱
//cb 發佈時需要執行的方法
function Watcher(vm, expr, cb) {
//緩存重要屬性
this.vm = vm;
this.expr = expr;
this.cb = cb;
//緩存當前值,爲更新時做對比
this.value = this.get()
}
Watcher.prototype.get=function(){
//設置全局Dep的target爲當前訂閱者
Dep.target = this;
//獲取屬性的當前值,獲取時會執行屬性的get方法,get方法會判斷target是否爲空,不爲空就添加訂閱者
var value = this.vm.$data[this.expr]
//清空全局
Dep.target = null;
return value;
}
Watcher.prototype.update=function(){
//獲取新值
var newValue = this.vm.$data[this.expr]
//獲取老值
var old = this.value;
//判斷後
if (newValue !== old) {
//執行回調
this.cb(newValue)
}
}
Watcher的主要邏輯:
a、get 把當前訂閱者添加到屬性對應的依賴數組,保存值
b、update 發佈的時候執行,進行新老值對比,更新節點內容
到此一個簡單的MVVM框架就完成了,整體運行效果如下:
梳理過程中參考很多大佬文章,感謝各位。看完基本能把VUE2.0的雙向綁定原理講清楚了,希望能幫助有緣人,😄!