前言
學習vue也有一段時間了,對雙向綁定原理的實現一直有所瞭解,但是並沒有深入瞭解其實現原理。所以花了時間和查閱了一些資料,自己動手嘗試實現了簡單的vue的雙向綁定。
本文主要分爲兩部分:
- vue的數據雙向綁定的實現原理
- 實現簡單版的vue的雙向綁定,主要實現{{}}、v-model和事件指令的功能。
Vue的數據雙向綁定原理
vue的數據綁定是通過數據劫持結合發佈者和訂閱者模式來實現的。那麼vue是如何進行數據劫持的。可以先看一下vue初始化數據上的對象在控制檯上輸出的是什麼?
var vm = new Vue({
data:{
obj:{
a:1
}
},
created(){
console.log(obj);
}
})
結果:
上圖中可以看到屬性a有兩個對應的get和set方法,爲什麼會多出這兩個方法呢?那是因爲Vue是通過Object.defineProperty()
方法進行數據劫持的。
Object.defineProperty( )是用來做什麼的?它可以來控制一個對象屬性的一些特有操作,比如讀寫權、是否可以枚舉,這裏我們主要先來研究下它對應的兩個描述屬性 get 和 set。更多內容可以點擊這裏。
在平常,我們可以很容易打印出一個對象的屬性
var Book = {
name: 'node權威指南',
}
console.log(Book.name);
當我想要在執行console.log(Book.name)的時候,我想要輸出的內容加上書名號,或者說我們該通過什麼方法來實現監聽數據的變化,這時候Object.defineProperty()
方法就派上了用場。
Object.defineProperty(Book, 'name', {
set:function(value) {
name = value;
console.log(`you have got a book named:${name}`);
},
get:function() {
return '《'+name+'》';
}
})
Book.name = '深入淺出webpack'; //you have got a book named:深入淺出webpack
console.log(Book.name);//《深入淺出webpack》
我們通過 Object.defineProperty( )設置了對象Book的name 屬性,對其get和set進行重寫操作,顧名思義,get就是在讀取name屬性這個值觸發的函數,set就是在設置name屬性這個值觸發的函數,所以當執行Book.name = '深入淺出webpack'
這個語句時,控制檯會打印出 “you have got a book named:深入淺出webpack”,緊接着,當讀取這個屬性時,就會輸出 “《深入淺出webpack》”,因爲我們在 get 函數裏面對該值做了加工了。如果這個時候我們執行console.log(Book)
的語句,控制檯會輸出什麼?
結果:
乍一看,是不是跟我們在上面打印 vue 數據長得有點類似,說明 vue 確實是通過這種方法來進行數據劫持的。接下來我們通過其原理來實現一個簡單版的 mvvm 雙向綁定代碼。
思路分析
實現mvvm主要包含兩個方面,數據變化更新視圖,視圖變化更新數據。
關鍵點在於data如果更新view,因爲view更新data。其實可以通過事件監聽即可,比如監聽’input’事件就可以實現了。所以着重分析一下,當數據改變如何更新視圖的。
數據更新視圖的重點是如何知道數據變了,只要知道數據變了,那麼接下去的事情都好處理。如何知道數據變了,其實上文我們已經給出答案了,就是通過Object.defineProperty()
對屬性設置了一個set函數,當數據改變了就會觸發這個函數,所以只要我們將一些需要更新的方法放在這裏面就可以實現data更新view了。
實現過程
我們已經知道實現數據的雙向綁定,首先要對數據進行劫持監聽,我們需要設置一個監聽器Observer,用來監聽所有屬性。如果屬性上發生了變化,就需要告訴訂閱者Watcher是否需要更新。因爲訂閱者是有很多個,所以我們需要有一個消息訂閱器Dep來專門收集這些訂閱者,然後在監聽器Observer和訂閱者Watcher之間進行統一管理的,接着,我們還需要一個指令解析器compile,對每一個節點元素進行掃描和解析。將相關指令對應初始化成一個訂閱者Watcher,並替換模板數據或者綁定相應的函數,此時訂閱者Watcher接收到相應屬性的變化,就會執行相應的更新函數,從而更新視圖,因此接下去我們執行以下3個步驟,並實現數據的雙向綁定。
-
實現一個監聽器 Observer,用來劫持並監聽所有屬性,如果有變動的,就通知訂閱者。
-
實現一個訂閱者 Watcher,可以收到屬性的變化通知並執行相應的函數,從而更新視圖。
-
實現一個解析器 Compile,可以掃描和解析每個節點的相關指令,並根據指令 初始化模板數據以及初始化相應的訂閱器。
流程圖如下:
- 實現一個Observe
Observer 是一個數據監聽器,其實現核心方法就是前文所說的 Object.defineProperty( )。如果要對所有屬性都進行監聽的話,那麼可以通過遞歸方法遍歷所有屬性值,並對其進行 Object.defineProperty( )處理。如下代碼,實現了一個 Observer
defineReactive(object, key, value) {
observe(value);
Object.defineProperty(object, key, {
enumerable:true,
configurable:true,
get:function() {
return value;
},
set:function(newVal) {
value = newVal;
console.log(`property ${key} has been listened, now value is ${newVal}`);
}
});
}
function observe(val) {
if(!val || typeof val !== 'object') {
return;
}
Obejct.keys(val).forEach(e => {
defineReactive(val, e, val[e])
});
}
var book = {
name:'node',
author:{
firstName:'lee',
lastName: 'wang'
}
}
observe(book);
book.name = 'webpack';
book.author.firstName = 'Tian';
思路分析中,需要創建一個可以容納訂閱者的消息訂閱器 Dep,訂閱器 Dep 主要負責收集訂閱者,然後再屬性變化的時候執行對應訂閱者的更新函數。所以顯然訂閱器需要有一個容器,這個容器就是 list,將上面的 Observer 稍微改造下,植入消息訂閱器:
function defineReactive(obj, key, value) {
observe(value);
var dep = new Dep();
Object.defineProperty(obj, key, {
enumerable:true,
configurable:true,
get: function() {
if(是否需要添加訂閱) {
dep.addSub(watcher); //在這裏需要添加一個訂閱者
}
return val;
},
set: function(newVal) {
if(val === newVal) {
return;
}
console.log(`property ${key} has been listened, now value is ${newVal}`);
dep.notify();
}
})
}
function Dep() {
this.subs = [];
}
Dep.prototype = {
addSub:function(key) {
this.subs.push(key);
},
notify: function() {
this.subs.forEach(e => {
e.update();
})
}
}
從代碼上看,我們將訂閱器 Dep 添加一個訂閱者設計在 getter 裏面,這是爲了讓 Watcher 初始化進行觸發,因此需要判斷是否要添加訂閱者,至於具體設計方案,下文會詳細說明的。在 setter 函數裏面,如果數據變化,就會去通知所有訂閱者,訂閱者們就會去執行對應的更新的函數。到此爲止,一個比較完整 Observer 已經實現了,接下來我們開始設計 Watcher。
2.實現Watcher
訂閱者 Watcher 在初始化的時候需要將自己添加進訂閱器 Dep 中,那該如何添加呢?我們已經知道監聽器 Observer 是在 get 函數執行了添加訂閱者 Wather 的操作的,所以我們只要在訂閱者 Watcher 初始化的時候出發對應的 get 函數去執行添加訂閱者操作即可,那要如何觸發 get 的函數,再簡單不過了,只要獲取對應的屬性值就可以觸發了,核心原因就是因爲我們使用了 Object.defineProperty( )進行數據監聽。這裏還有一個細節點需要處理,我們只要在訂閱者 Watcher 初始化的時候才需要添加訂閱者,所以需要做一個判斷操作,因此可以在訂閱器上做一下手腳:在 Dep.target 上緩存下訂閱者,添加成功後再將其去掉就可以了。訂閱者 Watcher 的實現如下:
function Watcher(vm, exp, cb) {
this.cb = cb;
this.vm = vm;
this.cb = cb;
this.value = this.get();
}
Watcher.prototype = {
update:function() {
this.run();
},
run:function() {
var value = this.vm.data[this.exp];
var oldVal = this.value;
if(value !== oldVal) {
this.value = value;
this.cb.call(this.value, value, oldVal);
}
},
get:function(){
Dep.target = this; //自己綁定自己
var value = this.vm.data[this.exp]; //強制執行監聽器裏的get函數
Dep.target = null; //釋放自己
return value;
}
}
這時候,我們需要對監聽器 Observer 也做個稍微調整,主要是對應 Watcher 類原型上的 get 函數。需要調整地方在於 defineReactive 函數:
function defineReactive(object, key, value) {
observe(value);
var dep = new Dep();
Object.defineProperty(object, key, {
enumerable:true,
configurable:true,
set:function(newVal){
if (val === newVal) {
return;
}
val = newVal;
console.log('屬性' + key + '已經被監聽了,現在值爲:“' + newVal.toString() + '”');
dep.notify(); // 如果數據變化,通知所有訂閱者
},
get:function() {
if (Dep.target) {. // 判斷是否需要添加訂閱者
dep.addSub(Dep.target); // 在這裏添加一個訂閱者
}
return val;
}
});
}
到此爲止,簡單版的 Watcher 設計完畢,這時候我們只要將 Observer 和 Watcher 關聯起來,就可以實現一個簡單的雙向綁定數據了。因爲這裏沒有還沒有設計解析器 Compile,所以對於模板數據我們都進行寫死處理,假設模板上又一個節點,且 id 號爲’name’,並且雙向綁定的綁定的變量也爲’name’,且是通過兩個大雙括號包起來(這裏只是爲了掩飾,暫時沒什麼用處),模板如下:
<body>
<h1 id="name">{{name}}</h1>
</body>
這時候我們需要將Observer和Watcher關聯起來:
function selfVue(data, el, exp) {
this.data = data;
observe(data);
el.innerHTML = this.data[exp]; // 初始化模板數據的值
new Watcher(this, exp, function(value) {
el.innerHTML = value;
});
return this;
}
然後在頁面上 new 以下 SelfVue 類,就可以實現數據的雙向綁定了:
<body>
<h1 id="name">{{name}}</h1>
</body>
<script src="js/observer.js"></script>
<script src="js/watcher.js"></script>
<script src="js/index.js"></script>
<script type="text/javascript">
var ele = document.querySelector("#name");
var selfVue = new SelfVue(
{
name: "hello world"
},
ele,
"name"
);
window.setTimeout(function() {
console.log("name值改變了");
selfVue.data.name = "canfoo";
}, 2000);
</script>
這時候打開頁面,可以看到頁面剛開始顯示了是’hello world’,過了 2s 後就變成’canfoo’了。到這裏,總算大功告成一半了,但是還有一個細節問題,我們在賦值的時候是這樣的形式 selfVue.data.name = ‘canfoo’ 而我們理想的形式是selfVue.name = 'canfoo’爲了實現這樣的形式,我們需要在 new SelfVue 的時候做一個代理處理,讓訪問 selfVue 的屬性代理爲訪問 selfVue.data 的屬性,實現原理還是使用 Object.defineProperty( )對屬性值再包一層:
function SelfVue(data, el, exp) {
var self = this;
this.data = data;
Object.keys(data).forEach(function(key) {
self.proxyKeys(key); // 綁定代理屬性
});
observe(data);
el.innerHTML = this.data[exp]; // 初始化模板數據的值
new Watcher(this, exp, function(value) {
el.innerHTML = value;
});
return this;
}
SelfVue.prototype = {
proxyKeys: function(key) {
var self = this;
Object.defineProperty(this, key, {
enumerable: false,
configurable: true,
get: function proxyGetter() {
return self.data[key];
},
set: function proxySetter(newVal) {
self.data[key] = newVal;
}
});
}
};
這下我們就可以直接通過selfVue.name = 'canfoo’的形式來進行改變模板數據了。如果想要迫切看到現象的童鞋趕快來獲取代碼!
3.實現compile
雖然上面已經實現了一個雙向數據綁定的例子,但是整個過程都沒有去解析 dom 節點,而是直接固定某個節點進行替換數據的,所以接下來需要實現一個解析器 Compile 來做解析和綁定工作。解析器 Compile 實現步驟:
-
解析模板指令,並替換模板數據,初始化視圖
-
將模板指令對應的節點綁定對應的更新函數,初始化相應的訂閱器
爲了解析模板,首先需要獲取到 dom 元素,然後對含有 dom 元素上含有指令的節點進行處理,因此這個環節需要對 dom 操作比較頻繁,所有可以先建一個 fragment 片段,將需要解析的 dom 節點存入 fragment 片段裏再進行處理:
function nodeToFragment(el) {
var fragment = document.createDocumentFragment();
var child = el.firstChild;
while (child) {
// 將 Dom 元素移入 fragment 中
fragment.appendChild(child);
child = el.firstChild;
}
return fragment;
}
接下來需要遍歷各個節點,對含有相關指定的節點進行特殊處理,這裏咱們先處理最簡單的情況,只對帶有 ‘{{變量}}’ 這種形式的指令進行處理,先簡到難嘛,後面再考慮更多指令情況:
function compileElement (el) {
var childNodes = el.childNodes;
var self = this;
[].slice.call(childNodes).forEach(function(node) {
var reg = /\{\{(.*)\}\}/;
var text = node.textContent;
if (self.isTextNode(node) && reg.test(text)) { // 判斷是否是符合這種形式{{}}的指令
self.compileText(node, reg.exec(text)[1]);
}
if (node.childNodes && node.childNodes.length) {
self.compileElement(node); // 繼續遞歸遍歷子節點
}
});
},
function compileText (node, exp) {
var self = this;
var initText = this.vm[exp];
this.updateText(node, initText); // 將初始化的數據初始化到視圖中
new Watcher(this.vm, exp, function (value) { // 生成訂閱器並綁定更新函數
self.updateText(node, value);
});
},
function (node, value) {
node.textContent = typeof value == 'undefined' ? '' : value;
}
獲取到最外層節點後,調用 compileElement 函數,對所有子節點進行判斷,如果節點是文本節點且匹配{{}}這種形式指令的節點就開始進行編譯處理,編譯處理首先需要初始化視圖數據,對應上面所說的步驟 1. 接下去需要生成一個並綁定更新函數的訂閱器,對應上面所說的步驟 2. 這樣就完成指令的解析、初始化、編譯三個過程,一個解析器 Compile 也就可以正常的工作了。爲了將解析器 Compile 與監聽器 Observer 和訂閱者 Watcher 關聯起來,我們需要再修改一下類 SelfVue 函數:
function SelfVue(options) {
var self = this;
this.vm = this;
this.data = options;
Object.keys(this.data).forEach(function(key) {
self.proxyKeys(key);
});
observe(this.data);
new Compile(options, this.vm);
return this;
}
更改後,我們就不要像之前通過傳入固定的元素值進行雙向綁定了,可以隨便命名各種變量進行雙向綁定了:
<body>
<div id="app">
<h2>{{title}}</h2>
<h1>{{name}}</h1>
</div>
</body>
<script src="js/observer.js"></script>
<script src="js/watcher.js"></script>
<script src="js/compile.js"></script>
<script src="js/index.js"></script>
<script type="text/javascript">
var selfVue = new SelfVue({
el: "#app",
data: {
title: "hello world",
name: ""
}
});
window.setTimeout(function() {
selfVue.title = "你好";
}, 2000);
window.setTimeout(function() {
selfVue.name = "canfoo";
}, 2500);
</script>
如上代碼,在頁面上可觀察到,剛開始 title 和 name 分別被初始化爲 ‘hello world’ 和空,2s 後 title 被替換成 ‘你好’ 3s 後 name 被替換成 ‘canfoo’ 了。
到這裏,一個數據雙向綁定功能已經基本完成了,接下去就是需要完善更多指令的解析編譯,在哪裏進行更多指令的處理呢?答案很明顯,只要在上文說的 compileElement 函數加上對其他指令節點進行判斷,然後遍歷其所有屬性,看是否有匹配的指令的屬性,如果有的話,就對其進行解析編譯。這裏我們再添加一個v-model 指令和事件指令的解析編譯,對於這些節點我們使用函數 compile 進行解析處理:
function compile(node) {
var nodeAttrs = node.attributes;
var self = this;
Array.prototype.forEach.call(nodeAttrs, function(attr) {
var attrName = attr.name;
if (self.isDirective(attrName)) {
var exp = attr.value;
var dir = attrName.substring(2);
if (self.isEventDirective(dir)) {
// 事件指令
self.compileEvent(node, self.vm, exp, dir);
} else {
// v-model 指令
self.compileModel(node, self.vm, exp, dir);
}
node.removeAttribute(attrName);
}
});
}
上面的 compile 函數是掛載 Compile 原型上的,它首先遍歷所有節點屬性,然後再判斷屬性是否是指令屬性,如果是的話再區分是哪種指令,再進行相應的處理,處理方法相對來說比較簡單,這裏就不再列出來.
最後我們在稍微改造下類 SelfVue,使它更像 vue 的用法:
function SelfVue(options) {
var self = this;
this.data = options.data;
this.methods = options.methods;
Object.keys(this.data).forEach(function(key) {
self.proxyKeys(key);
});
observe(this.data);
new Compile(options.el, this);
options.mounted.call(this); // 所有事情處理好後執行mounted函數
}
這時候我們可以來真正測試了,在頁面上設置如下東西:
<body>
<div id="app">
<h2>{{title}}</h2>
<input v-model="name" />
<h1>{{name}}</h1>
<button v-on:click="clickMe">click me!</button>
</div>
</body>
<script src="js/observer.js"></script>
<script src="js/watcher.js"></script>
<script src="js/compile.js"></script>
<script src="js/index.js"></script>
<script type="text/javascript">
new SelfVue({
el: "#app",
data: {
title: "hello world",
name: "canfoo"
},
methods: {
clickMe: function() {
this.title = "hello world";
}
},
mounted: function() {
window.setTimeout(() => {
this.title = "你好";
}, 1000);
}
});
</script>
這時候看起來就更像Vue了。