不管你有沒有用過 Vue,你都會經常聽到 Vue 是一個響應式的庫。最近看了一下 Vue 的原始碼,實現了一個簡易版的響應式系統。
首先,看下面的例子
<template>
<div id="app">
<p class="name">{{fullName}}</p>
</div>
</template>
<script>
var vm = new Vue({
el: `#app`,
data: {
firstName: `liu`,
lastName: `laohan`
},
computed: {
fullName() {
return this.firstName + ` ` + this.lastName
}
}
})
</script>
複製程式碼
瞭解過 Vue 的都知道,當 firstName
或者 lastName
的值發生變化時,fullName
的值都會發生變化,並且檢視也會更新。
你可能會好奇,Vue 是怎麼知道 firstName
或者 lastName
發生變化,並且也保證 fullName
也得到變化的呢?
這跟我們平時看到的 javascript 的執行並不相同阿,例如
let firstName = `liu`;
let lastName = `laohan`;
let fullName = firstName + ` ` + lastName;
lastName = `datou`;
console.log(`name: ${fullName}`);
複製程式碼
你可能想都不用想就知道上面的 log 會列印出什麼內容
>> name: liu laohan
複製程式碼
但在 Vue 中,我們希望的是 lastName
的內容發生變化時,fullName
的值也更新,即希望上面的 log 會列印出
>> name: liu datou
複製程式碼
不幸的是,上面的 js 並不會是響應式的,如果我們希望 fullName
的內容是響應式的,還需要做一些其他的事情。
現在,我們要解決的問題就是把計算 fullName
的過程保留起來,然後在 lastName
發生變化時,再次執行一次這個計算的過程。
計算 fullName
的過程,其實也就是一個函式嘛,我們可以實現如下
let firstName = `liu`;
let lastName = `laohan`;
let fullName = ``;
target = () => {
fullName = firstName + ` ` + lastName;
};
record();
target();
複製程式碼
這樣子,我們就把計算 fullName
的過程封裝在一個匿名函式中,然後呼叫 record
函式。
record
函式的實現方式也很簡單
let storage = [];
function record() {
storage.push(target);
}
複製程式碼
現在我們把計算的過程儲存在變數 storage
中了。當 lastName
的值發生變化時,我們只需要 replay
一下就可以了
lastName = `datou`;
console.log(fullName); // => liu laohan
replay();
console.log(fullName); // => liu datou
複製程式碼
看起來是不是很簡單,很容易的就實現了我們要達到的效果。 完整程式碼如下
let firstName = `liu`;
let lastName = `laohan`;
let fullName = ``;
let target = null;
let storage = [];
function record() {
storage.push(target);
}
function replay() {
storage.forEach(cb => cb());
}
target = () => {
fullName = firstName + ` ` + lastName;
};
record();
target();
lastName = `datou`;
console.log(fullName); // => liu laohan
replay();
console.log(fullName); // => liu datou
複製程式碼
大概總結一下,其實就是以下兩點:
- 記錄目標值的計算過程,如上述的 target 函式,記錄 fullName 的求值過程
- 在影響目標值的變數(如 firstName、lastName)發生變化時,重新計算目標值
可以看到,上面的實現方式是很簡單粗暴的。如果之後 lastName
的值再次變化時,要想 fullName
的值還是響應的,那就要如下:
lastName = `dahei`;
replay();
複製程式碼
每次變數 lastName 發生變化時,都要在後面跟著呼叫 replay
函式。這會使程式碼看起來很冗餘。
接下來,我們嘗試著用別的方式來實現響應式系統。
在之前,我們已經可以實現 fullName 隨著 lastName 值變化而變化,如果我們想在 firstName 改變時,fullName 的值也跟著變化。按照上面的實現,增加一些程式碼,也是可以實現。只是你會發現,每次改變變數時,都要手動地在後面加一行 replay()
是很繁瑣的。而作為一個喜歡偷懶的人來說,這很讓人反感。
Object.defineProperty
如果你沒有聽過或者不瞭解 Object.defineProperty()
,可以看看 這裡。
Object.defineProperty()
方法允許我們修改一個物件的現有屬性,我們要用到的就是屬性描述符中的 get
和 set
。
get: 一個給屬性提供 getter 的方法,如果沒有 getter 則為 undefined。當訪問該屬性時,該方法會被執行
set: 一個給屬性提供 setter 的方法,如果沒有 setter 則為 undefined。當屬性值修改時,觸發執行該方法。
如上所述,當我們訪問一個物件的屬性時,getter 方法會被呼叫,當修改一個屬性的值時,setter 方法會被呼叫。可以看看下面的一個 demo:
const info = {
firstName: `liu`,
lastName: `laohan`,
};
Object.defineProperty(info, `lastName`, {
get() {
console.log(`getter`);
},
set() {
console.log(`setter`);
},
});
info.lastName; // => getter
info.lastName = `datou`; // => setter
複製程式碼
可以看到,當我們訪問屬性時,get 方法被呼叫了。而修改屬性的值時, set 方法就被呼叫了。
你可能會疑問,這對實現響應式系統有什麼用呢?還記得上面我們每次修改 lastName 時都在後面跟著呼叫 replay()
嗎,這就是因為我們不知道什麼時候 lastName 的值改變了,現在我們就可以通過 set 方法是否被呼叫而知道屬性值是否發生變化了。那既然 get 方法被呼叫了,就表示這個屬性是被訪問了,是不是我們也可以通過在 get 方法裡儲存依賴這個屬性的目標值的計算過程呢,答案是肯定的。
const info = {
firstName: `liu`,
lastName: `laohan`,
};
Object.keys(info).forEach(key => {
let internalValue = info[key];
Object.defineProperty(info, key, {
get() {
// todo: 儲存依賴該屬性的目標值的計算過程,如之前的record
return internalValue;
},
set(val) {
internalValue = val;
// todo: 重新呼叫目標值的計算過程,如之前的 replay
},
});
});
複製程式碼
現在,差的就是在 get 和 set 方法裡面實現類似我們之前的 record 和 replay 方法了。
在這個 demo 中,直接使用上面的方法也不會有什麼問題。但為了有更好的複用,我們換種實現方式。
class Dep {
constructor() {
this.subs = [];
}
depend() {
if (target && !!this.subs.includes(target)) {
this.subs.push(target);
}
}
notify() {
this.subs.forEach(sub => sub());
}
}
複製程式碼
這次,我們就把目標值的計算過程儲存到 subs 陣列中了,然後用 notify 代替之前的 replay。
在實際應用中,target 的值會發生變化的。比如在 vue 中,target 有時候是更新元件,有時候是更新 computed 的值。所以在這裡用一個 watcher 函式封裝一下 target 的行為。
watcher(() => {
fullName = firstName + lastName;
});
複製程式碼
把上面的幾個點整合在一起,完整的程式碼就是
const info = {
firstName: `liu`,
lastName: `laohan`,
};
let target = null;
class Dep {
constructor() {
this.subs = [];
}
depend() {
if (target && !!this.subs.includes(target)) {
this.subs.push(target);
}
}
notify() {
this.subs.forEach(sub => sub());
}
}
Object.keys(info).forEach(key => {
let internalValue = info[key];
const dep = new Dep();
Object.defineProperty(info, key, {
get() {
dep.depend();
return internalValue;
},
set(val) {
internalValue = val;
dep.notify();
},
});
});
function watcher(func) {
target = func;
target();
target = null;
}
watcher(() => {
const fullName = info.firstName + info.lastName;
});
複製程式碼