前言
首先歡迎大家關注我的Github部落格,也算是對我的一點鼓勵,畢竟寫東西沒法獲得變現,能堅持下去也是靠的是自己的熱情和大家的鼓勵。
國內前端算是屬於Vue與React兩分天下,提到Vue,最令人印象深刻的就是雙向繫結了,想要深入的理解雙向繫結,最重要的就是明白響應式資料的原理。這篇文章不會去一字一句的分析Vue中是如何實現響應式資料的,我們只會從原理的角度去考量如何實現一個簡單的響應式模組,希望能對你有些許的幫助。
響應式資料
響應式資料不是憑空出現的。對於前端工程而言,資料模型Model都是普通的JavsScript物件。View是Model的體現,藉助JavaScript的事件響應,View對Model的修改非常容易,比如:
var model = {
click: false
};
var button = document.getElementById("button");
button.addEventListener("click", function(){
model.click = !model.click;
})
複製程式碼
但是想要在修改Model時,View也可以對應重新整理,相對比較困難的。在這方面,React和View提供了兩個不同的解決方案,具體可以參考這篇文章。其中響應式資料提供了一種可實現的思路。什麼是響應式資料?在我看來響應式資料就是修改資料的時候,可以按照你設定的規則觸發一系列其他的操作。我們想實現的其實就是下面的效果:
var model = {
name: "javascript"
};
// 使傳入的資料變成響應式資料
observify(model);
//監聽資料修改
watch(model, "name", function(newValue, oldValue){
console.log("name newValue: ", newValue, ", oldValue: ", oldValue);
});
model.name = "php"; // languange newValue: php, oldValue: javascript
複製程式碼
從上面效果中我們可以看出來,我們需要劫持修改資料的過程。好在ES5提供了描述符屬性,通過方法Object.defineProperty
我們可以設定訪問器屬性。但是包括IE8在內的低版本瀏覽器是沒有實現Object.defineProperty
並且也不能通過polyfill實現(其實IE8是實現了該功能,只不過只能對DOM物件使用,並且非常受限),因此在低版本瀏覽器中沒法實現該功能。這也就是為什麼Vue不支援IE8及其以下的瀏覽的原因。通過Object.defineProperty
我們可以實現:
Object.defineProperty(obj, "prop", {
enumerable: true,
configurable: true,
set: function(value){
//劫持修改的過程
},
get: function(){
//劫持獲取的過程
}
});
複製程式碼
資料響應化
根據上面的思路我們去考慮如何實現observify
函式,如果我們想要將一個物件響應化,我們則需要遍歷物件中的每個屬性,並且需要對每個屬性對應的值同樣進行響應化。程式碼如下:
// 資料響應化
// 使用lodash
function observify(model){
if(_.isObject(model)){
_.each(model, function(value, key){
defineReactive(model, key, value);
});
}
}
//定義物件的單個響應式屬性
function defineReactive(obj, key, value){
observify(value);
Object.defineProperty(obj, key, {
configurable: true,
enumerable: true,
set: function(newValue){
var oldValue = value;
value = newValue;
//可以在修改資料時觸發其他的操作
console.log("newValue: ", newValue, " oldValue: ", oldValue);
},
get: function(){
return value;
}
});
}
複製程式碼
上面的函式observify
就實現了物件的響應化處理,例如:
var model = {
name: "MrErHu",
message: {
languange: "javascript"
}
};
observify(model);
model.name = "mrerhu" //newValue: mrerhu oldValue: MrErHu
model.message.languange = "php" //newValue: php oldValue: javascript
model.message = { db: "MySQL" } //newValue: {db: "MySQL"} oldValue: {languange:"javascript"}
複製程式碼
我們知道在JavaScript中經常使用的不僅僅是物件,陣列也是非常重要的一部分。並且中還有非常的多的方法能夠改變陣列本身,那麼我們如何能夠監聽到陣列的方法對陣列帶來的變化呢?為了解決這個問題我們能夠一種替代的方式,將原生的函式替換成我們自定義的函式,並且在自定義的函式中呼叫原生的陣列方法,就可以達到我們想要的目的。我們接著改造我們的defineReactive
函式。
function observifyArray(array){
//需要變異的函式名列表
var methods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
var arrayProto = Object.create(Array.prototype);
_.each(methods, function(method){
arrayProto[method] = function(...args){
// 劫持修改資料
var ret = Array.prototype[method].apply(this, args);
//可以在修改資料時觸發其他的操作
console.log("newValue: ", this);
return ret;
}
});
Object.setPrototypeOf(array, arrayProto);
}
//定義物件的單個響應式屬性
function defineReactive(obj, key, value){
if(_.isArray(value)){
observifyArray(value, dep);
}else {
observify(value);
}
Object.defineProperty(obj, key, {
// 省略......
});
}
複製程式碼
我們可以看到我們將陣列原生的原型替換成自定義的原型,然後呼叫陣列的變異方法時就會呼叫我們自定義的函式。例如:
var model = [1,2,3];
observify(model);
model.push(4); //newValue: [1, 2, 3, 4]
複製程式碼
到目前為止我們已經實現了我們的需求,其實我寫到這裡的時候,我考慮到是否需要實現對陣列的鍵值進行監聽,其實作為使用過Vue的使用者一定知道,當你利用索引直接設定一個項時,是不會監聽到陣列的變化的。比如:
vm.items[indexOfItem] = newValue
複製程式碼
如果你想要實現上面的效果,可以通過下面的方式實現:
vm.items.splice(indexOfItem, 1, newValue);
複製程式碼
首先考慮這個是否能實現。答案是顯而易見的了。當然是可以,陣列其實可以看做特殊的陣列,而其實對於陣列而言,數值型別的索引都會被最終解析成字串型別,比如下面的程式碼:
var array = [0,1,2];
array["0"] = 1; //array: [1,1,2]
複製程式碼
那要實現對數值索引對應的資料進行修改,其實也是可以通過Object.defineProperty
函式去實現,比如:
var array = [0];
Object.defineProperty(array, 0, {
set: function(newValue){
console.log("newValue: ", newValue);
}
});
array[0] = 1;//newValue: 1
複製程式碼
可以實現但卻沒有實現該功能,想來主要原因可能就是基於效能方面的考慮(我的猜測)。但是Vue提供了另一個全域性的函式,Vue.set
可以實現
Vue.set(vm.array, indexOfItem, newValue)
複製程式碼
我們可以大致猜測一下Vue.set
內部怎麼實現的,對於陣列而言,只需要對newValue
做響應化處理並將其賦值到陣列中,然後通知陣列改變。對於物件而言,如果是之前不存在的屬性,首先可以將newValue
進行響應化處理(比如呼叫observify(newValue)
),然後將對具體屬性定義監聽(比如呼叫函式defineReactive
),最後再去做賦值,可能具體的處理過程千差萬別,但是內部實現的原理應該就是如此(僅僅是猜測)。
不僅如此,在上面的實現中我們可以發現,我們並不能監聽到物件不能檢測物件屬性的新增或刪除,因此如果如果你要監聽某個屬性的值,而一開始這個屬性並不存在,最好是在資料初始化的時候就給其一個預設值,從而能監聽到該屬性的變化。
依賴收集
上面我們講了這麼多,希望大家不要被帶偏了,我們上面所做的都是希望能在資料發生變化時得到通知。回到我們最初的問題。我們希望的是,在Model層資料發生改變的時候,View層的資料相應發生改變,我們已經能夠監聽到資料的改變了,接下來要考慮的就是View的改變。
對於Vue而言,即使你使用的是Template
描述View層,最終都會被編譯成render
函式。比如,模板中描述了:
<h1>{{ name }}</h1>
複製程式碼
其實最後會被編譯成:
render: function (createElement) {
return createElement('h1', this.name);
}
複製程式碼
那現在就存在下面這個一個問題,假如我的Model是下面這個樣子的:
var model = {
name: "MrErHu",
age: 23,
sex: "man"
}
複製程式碼
事實上render
函式中就只用到了屬性name
,但是Model中卻存在其他的屬性,當資料改變的時候,我們怎麼知道什麼時候才需要重新呼叫render
函式呢。你可能會想,哪裡需要那麼麻煩,每次資料改變都去重新整理render
函式不就行了嗎。這樣當然可以,其實如果朝著這個思路走,我們就朝著React方向走了。事實上如果不借助虛擬DOM的前提下,如果每次屬性改變都去呼叫render
效率必然是低下的,這時候我們就引入了依賴收集,如果我們能知道render
依賴了那些屬性,那麼在這些屬性修改的時候,我們再精準地呼叫render
函式,那麼我們的目的不就達到了嗎?這就是我們所稱的依賴收集。
依賴收集的原理非常的簡單,在響應式資料中我們一直利用的都是屬性描述符中的set
方法,而我們知道當呼叫某個物件的屬性時,會觸發屬性描述符的get
方法,當get
方法呼叫時,我們將呼叫get
的方法收集起來就能完成我們的依賴收集的任務。
首先我們可以思考要一下,如果是自己寫一個響應式資料帶依賴收集的模組,我們會去怎麼設計。首先我們想要達到的類似效果就是:
var model = {
name: "MrErHu",
program: {
language: "Javascript"
},
favorite: ["React"]
};
//資料響應化
observify(model);
//監聽
watch(function(){
return '<p>' + (model.name) + '</p>'
}, function(){
console.log("name: ", model.name);
});
watch(function(){
return '<p>' + (model.program.language) + '</p>'
}, function(){
console.log("language: ", model.program.language);
});
watch(function(){
return '<p>' + (model.favorite) + '</p>'
}, function(){
console.log("favorite: ", model.favorite);
});
model.name = "mrerhu"; //name: mrerhu
model.program.language = "php"; //language: php
model.favorite.push("Vue"); //favorite: [React, Vue]
複製程式碼
我們所需要實現的watch
函式的第一個引數可以認為是render
函式,通過執行render
函式我們可以收集到render
函式內部使用了那些響應式資料屬性。然後在對應的響應式資料屬性改變的時候,觸發我們註冊的第二個函式。這樣看我們監聽屬性的粒度就是響應資料的每一個屬性。按照單一職責的概念,我們將監聽訂閱與通知釋出的職責分離出去,由單獨的Dep
類負責。由於監聽的粒度是響應式資料的每一個屬性,因此我們會為每一個屬性維護一個Dep
。與此相對應,我們建立Watcher
類,負責向Dep
註冊,並在收到通知後呼叫回撥函式。如下圖所示:
首先我們實現Dep
和Watcher
類:
//引入lodash庫
class Dep {
constructor(){
this.listeners = [];
}
// 新增Watcher
addWatcher(watcher){
var find = _.find(this.listeners, v => v === watcher);
if(!find){
//防止重複註冊
this.listeners.push(watcher);
}
}
// 移除Watcher
removeWatcher(watcher){
var find = _.findIndex(this.listeners, v => v === fn);
if(find !== -1){
this.listeners.splice(watcher, 1);
}
}
// 通知
notify(){
_.each(this.listeners, function(watcher){
watcher.update();
});
}
}
Dep.target = null;
class Watcher {
constructor(callback){
this.callback = callback;
}
//得到Dep通知呼叫相應的回撥函式
update(){
this.callback();
}
}
複製程式碼
接著我們建立watcher函式並且改造之前響應式相關的函式:
// 資料響應化
function observify(model){
if(_.isObject(model)){
_.each(model, function(value, key){
defineReactive(model, key, value);
});
}
}
//定義物件的單個響應式屬性
function defineReactive(obj, key, value){
var dep = new Dep();
if(_.isArray(value)){
observifyArray(value, dep);
}else {
observify(value);
}
Object.defineProperty(obj, key, {
configurable: true,
enumerable: true,
set: function(newValue){
observify(value);
var oldValue = value;
value = newValue;
//可以在修改資料時觸發其他的操作
dep.notify(value);
},
get: function(){
if(!_.isNull(Dep.target)){
dep.addWatcher(Dep.target);
}
return value;
}
});
}
// 資料響應化
function observify(model){
if(_.isObject(model)){
_.each(model, function(value, key){
defineReactive(model, key, value);
});
}
}
//定義物件的單個響應式屬性
function defineReactive(obj, key, value){
var dep = new Dep();
if(_.isArray(value)){
observifyArray(value, dep);
}else {
observify(value);
}
Object.defineProperty(obj, key, {
configurable: true,
enumerable: true,
set: function(newValue){
observify(value);
var oldValue = value;
value = newValue;
//可以在修改資料時觸發其他的操作
dep.notify(value);
},
get: function(){
if(!_.isNull(Dep.target)){
dep.addWatcher(Dep.target);
}
return value;
}
});
}
function observifyArray(array, dep){
//需要變異的函式名列表
var methods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
var arrayProto = Object.create(Array.prototype);
_.each(methods, function(method){
arrayProto[method] = function(...args){
var ret = Array.prototype[method].apply(this, args);
dep.notify(this);
return ret;
}
});
Object.setPrototypeOf(array, arrayProto);
}
function watch(render, callback){
var watcher = new Watcher(callback);
Dep.target = watcher;
render();
Dep.target = null;
}
複製程式碼
接下來我們就可以實驗一下我們的watch
函式了:
var model = {
name: "MrErHu",
message: {
languange: "javascript"
},
love: ["Vue"]
};
observify(model);
watch(function(){
return '<p>' + (model.name) + '</p>'
}, function(){
console.log("name: ", model.name);
});
watch(function(){
return '<p>' + (model.message.languange) + '</p>'
}, function(){
console.log("message: ", model.message);
});
watch(function(){
return '<p>' + (model.love) + '</p>'
}, function(){
console.log("love: ", model.love);
});
model.name = "mrerhu"; // name: mrerhu
model.message.languange = "php"; // message: { languange: "php"}
model.message = {
target: "javascript"
}; // message: { languange: "php"}
model.love.push("React"); // love: ["Vue", "React"]
複製程式碼
到此為止我們已經基本實現了我們想要的效果,當然上面的例子並不完備,但是也基本能展示出響應式資料與資料依賴的基本原理。當然上面僅僅只是採用ES5的資料描述符實現的,隨著ES6的普及,我們也可以用Proxy(代理)和Reflect(反射)去實現。作為本系列的第一篇文章,還有其他的點沒有一一列舉出來,大家可以關注我的Github部落格繼續關注,如果有講的不準確的地方,歡迎大家指正。