ES6實(shí)戰(zhàn)2-實(shí)現(xiàn) Vue3 effect 源碼
1. 前言
上一節(jié)我們實(shí)現(xiàn)了 Vue3 的數(shù)據(jù)劫持功能,并對(duì)一些邊界值做了處理。但是,當(dāng)數(shù)據(jù)改變了我們希望更新試圖,這個(gè)時(shí)候雖然我們能劫持到數(shù)據(jù)的變化但是沒(méi)有做任何處理,我們需要對(duì)數(shù)據(jù)的獲取和修改增加更新的邏輯,并提供一個(gè) API 給業(yè)務(wù)用來(lái)響應(yīng)式的處理數(shù)據(jù)的變化。Vue3 中提供了 effect,當(dāng) effect 回調(diào)函數(shù)中引用的響應(yīng)式數(shù)據(jù)變化時(shí),會(huì)觸發(fā) effect 回調(diào)函數(shù)的執(zhí)行,相當(dāng)于 vue2 中的 watcher。我們來(lái)看下面的應(yīng)用示例:
// /vue-next/public/index.html
<script src="./vue.reactivity.js"></script>
<script>
const { reactive, effect } = VueReactivity;
const proxy = reactive({
name: 'ES6 Wiki',
})
effect(() => {
document.getElementById('app').innerHTML = proxy.name;
})
setTimeout(() => {
proxy.name = 'imooc ES6 Wiki 實(shí)戰(zhàn)'
}, 1000)
</script>
上面的代碼中我們引入了 Vue3 的 reactivity 庫(kù),初始化網(wǎng)頁(yè)內(nèi)容后,在 1 秒以后更新網(wǎng)頁(yè)中的內(nèi)容。本節(jié)我們就來(lái)實(shí)現(xiàn) effect 這個(gè) API 的功能,本節(jié)源碼參考: ES6 Wiki 。
2. effect 實(shí)現(xiàn)
2.1 創(chuàng)建響應(yīng)式 effect
effect 在 Vue3 的響應(yīng)式系統(tǒng)中是一個(gè)非常關(guān)鍵的函數(shù),后面的 ref、computed 等函數(shù)都會(huì)用到 effect 中的功能。在 Vue3 中的 effect 會(huì)接受不了兩個(gè)參數(shù):
effect(fn, options)
基于 Vue3 響應(yīng)式 API 的 effect 特點(diǎn),需要將 effect 變成一個(gè)響應(yīng)式函數(shù),effect 的響應(yīng)式就是當(dāng)數(shù)據(jù)變化時(shí) fn 會(huì)自動(dòng)執(zhí)行。實(shí)現(xiàn) effect 這個(gè)函數(shù)的一個(gè)目標(biāo)就是,將 effect 回調(diào)函數(shù)中所有引用了響應(yīng)式數(shù)據(jù)的屬性收集起來(lái),并和 effect 的回調(diào)函數(shù)關(guān)聯(lián)上,在數(shù)據(jù)變化時(shí)在執(zhí)行 effect 的回調(diào)函數(shù)。也就是上面的測(cè)試案例中,proxy 對(duì)象的 name 屬性在 effect 的回調(diào)函數(shù)中。要想讓 effect 成為響應(yīng)式的,就需要將 name 和 effect 關(guān)聯(lián)起來(lái),當(dāng) name 的值變化了,就執(zhí)行 effect 的回調(diào)函數(shù)。
在本節(jié) options 沒(méi)用到,但是在 computed 中會(huì)使用到,本節(jié)使用了 options.lazy
屬性,用于判斷是否在第一次的時(shí)候執(zhí)行回調(diào)函數(shù)中的內(nèi)容。effect 中是默認(rèn)執(zhí)行回調(diào)函數(shù)的。
如果要把 effect 變成響應(yīng)式,需要定義一個(gè)創(chuàng)建響應(yīng)式的方法(createReactiveEffect)用于創(chuàng)建一個(gè) effect 函數(shù)。createReactiveEffect 執(zhí)行后會(huì)返回一個(gè) effect 函數(shù),在 createReactiveEffect 函數(shù)中會(huì)默認(rèn)執(zhí)行 fn。
export function effect(fn, options){
const effect = createReactiveEffect(fn, options)
if (!options.lazy) {
effect()
}
return effect
}
function createReactiveEffect(fn, options) {
const effect = function reactiveEffect() {
return fn(); // 用戶(hù)創(chuàng)建的回調(diào)函數(shù),fn函數(shù)內(nèi)部會(huì)對(duì)響應(yīng)式數(shù)據(jù)進(jìn)行取值操作
}
return effect
}
我們定義一個(gè)全局變量 activeEffect,這樣做是為了把 effect 存起來(lái),方便后面調(diào)用,在取值的時(shí)候就可以拿到這個(gè) activeEffect。
let activeEffect;
function createReactiveEffect(fn, options) {
const effect = function reactiveEffect() {
activeEffect = effect;
return fn();
}
return effect
}
2.2 屬性和 effect 關(guān)聯(lián)
怎么才能讓屬性和這個(gè)函數(shù)進(jìn)行關(guān)聯(lián)呢?首先我們要?jiǎng)?chuàng)建一個(gè)收集函數(shù)(track)用于收集屬性 key 和 effect 回調(diào)函數(shù)的關(guān)聯(lián),并且只有在 effect 中使用到的 key,更新時(shí)才會(huì)執(zhí)行 effect 中的回調(diào),所以我們?cè)谑占蕾?lài)時(shí)需要先判斷。
function track(target, key) {
if (activeEffect === viod 0) {
return;
}
}
什么時(shí)候進(jìn)行收集呢?effect 回調(diào)函數(shù)會(huì)默認(rèn)執(zhí)行,在獲取值的時(shí)候?qū)憫?yīng)式對(duì)象上的 key 進(jìn)行依賴(lài)收集,也就是在 createGetter 函數(shù)中進(jìn)行收集。
function createGetter() {
return function get(target, key, receiver) {
const res = Reflect.get(target, key, receiver);
if (isSymbol(key)) {
return res;
}
// 依賴(lài)收集
track(target, key);
if (isObject(res)) {
return reactive(res);
}
return res;
};
}
如何關(guān)聯(lián)呢?就是需要在 target 上的 key 中存放若干個(gè) effect,那這要怎么存放呢?這時(shí)我們想到了 WeakMap,創(chuàng)建一個(gè) WeakMap 來(lái)保持 target 上的需要關(guān)聯(lián) effect 的屬性。同時(shí),
下面的偽代碼數(shù)據(jù)結(jié)構(gòu)是我們希望存放在 WeakMap 中的映射,其中 target 是目標(biāo)對(duì)象。
{
target1: {
key: [effect, effect]
},
target2: {
key: [effect, effect]
}
}
在存放 effect 時(shí)可能還需要給 effect 加上一些標(biāo)識(shí),如:id、deps、options 等,后面會(huì)用到。
Let uid = 0;
function createReactiveEffect(fn, options) {
const effect = function reactiveEffect() {
activeEffect = effect;
return fn();
}
effect.id = uid++;
effect.deps = [];
effect.options = opntions;
return effect
}
const targetMap = new WeakMap();
function track(target, key) {
if (activeEffect === undefined) {
return;
}
// 目標(biāo)是創(chuàng)建一個(gè)映射:{target1: {name: [effect, effect]},target2: {name: [effect, effect]}}
let depsMap = targetMap.get(target); // depsMap存放target的值,是一個(gè)Map對(duì)象
if(!depsMap) { // 如果targetMap中沒(méi)用target對(duì)象,則創(chuàng)建一個(gè)。
targetMap.set(target, (depsMap = new Map()));
}
let dep = depsMap.get(key); // 獲取depsMap對(duì)象中屬性是target上的key值
if(!dep) {
depsMap.set(key, (dep = new Set())); // 存放effect的集合
}
if(!dep.has(effect)) {
dep.add(activeEffect);
activeEffect.deps.push(dep);
}
}
上面的代碼中,收集目標(biāo)對(duì)象上所有的依賴(lài),在 effect 的回調(diào)函數(shù)中沒(méi)有使用到的屬性,就不需要進(jìn)行依賴(lài)收集。在執(zhí)行完創(chuàng)建響應(yīng)式 effec 函數(shù) createReactiveEffect 后需要把 activeEffect 置為 null。
function createReactiveEffect(fn, options) {
const effect = function reactiveEffect() {
try {
activeEffect = effect;
return fn();
} finally {
activeEffect = null;
}
}
return effect
}
上面的代碼中 finally 是一定會(huì)執(zhí)行的。在 effect 回調(diào)函數(shù)中嵌套使用 effect,并且在嵌套的 effect 后還有響應(yīng)式數(shù)據(jù),如果是下面這種寫(xiě)法,state.c = 300
將不會(huì)收集。
effect(() => {
state.a = 100;
effect(() => {
state.b = 200;
})
state.c = 300;
})
這個(gè)時(shí)候我們就需要?jiǎng)?chuàng)建一個(gè)存放棧的數(shù)組(effectStack)來(lái)存放 activeEffect,執(zhí)行完畢后也不用賦值 null 了,通過(guò)出棧的形式把最后一個(gè)移除,讓當(dāng)前的 activeEffect 值等于 effectStack 最后一個(gè)值 effectStack[effectStack.length-1]
。這樣我們?cè)趫?zhí)行完創(chuàng)建響應(yīng)式 effect 函數(shù)時(shí),控制權(quán)又會(huì)交到上一層的 activeEffect 上,這樣上面代碼中的 state.c=300
就會(huì)被收集到第一層的 effect 中去。具體執(zhí)行代碼如下:
const effectStack = [];
function createReactiveEffect(fn, options) {
const effect = function reactiveEffect() {
try {
activeEffect = effect;
effectStack.push(activeEffect);
return fn();
} finally {
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
}
}
return effect
}
使用棧的還有一個(gè)好處可以防止遞歸執(zhí)行,在 effect 如果有數(shù)據(jù)持續(xù)變化是如: state.a++
這樣的邏輯就會(huì)形成遞歸。這時(shí)需要處理為只執(zhí)行一次,增加一個(gè)條件判斷,如下代碼:
function createReactiveEffect(fn, options) {
const effect = function reactiveEffect() {
if (!effectStack.includes(effect)) { // 防止死循環(huán)
try {
activeEffect = effect;
effectStack.push(activeEffect);
return fn();
} finally {
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
}
}
}
return effect
}
2.3 執(zhí)行收集的函數(shù)
上面的內(nèi)容是依賴(lài)收集的過(guò)程,主要在響應(yīng)式數(shù)據(jù)獲取時(shí)執(zhí)行,也就是在調(diào)用 createGetter 的時(shí)候執(zhí)行,那么依賴(lài)收集完后,當(dāng)數(shù)據(jù)發(fā)生變化的時(shí)候,需要讓收集的回調(diào)函數(shù)依次執(zhí)行。而執(zhí)行這樣收集函數(shù)的過(guò)程是在 createSetter 中完成,因?yàn)樵谶@里是更新數(shù)據(jù)的過(guò)程。上節(jié)中我們?cè)?createSetter 中預(yù)留了新增和更新屬性的判斷:
function createSetter() {
return function get(target, key, value, receiver) {
...
if (!hadKey) {
console.log('新增屬性');
trigger(target, 'ADD', key, value)
} else if (hasChanged(value, oldValue)) {
console.log('更新屬性');
trigger(target, 'SET', key, value, oldValue)
}
return result;
};
}
Vue3 中執(zhí)行依賴(lài)的函數(shù)是 trigger,這個(gè)函數(shù)一共接受五個(gè)參數(shù),在執(zhí)行 trigger 時(shí)會(huì)傳入修改數(shù)據(jù)的類(lèi)型:新增(ADD)和更新(SET),這是 Vue 為了處理不同場(chǎng)景而設(shè)置的屬性。這里我們先創(chuàng)建 tigger 函數(shù),首先需要判斷在 targetMap 中是否有被依賴(lài)的對(duì)象,沒(méi)有則直接返回。
export function trigger(target, type, key, newValue, oldValue) {
const depsMap = targetMap.get(target)
if (!depsMap) {
return
}
}
如何讓依賴(lài)的 effect 執(zhí)行呢?
- 首先要判斷 key 是不是 undefined;
- 獲取 key 中的 effect 函數(shù),并執(zhí)行。
export function trigger(target, type, key, newValue, oldValue) {
const depsMap = targetMap.get(target)
if (!depsMap) {
return
}
const run = (effects) => {
if (effects) {
effects.forEarch(effect => effect())
}
}
if (key == void 0) {
run(depsMap.get(key));
}
}
上面是對(duì)對(duì)象的處理,但是在處理數(shù)組的時(shí)候還會(huì)有問(wèn)題,如下代碼:
const state = reactive([1,2,3]);
effect(() => {
document.getElementById('app').innerHTML = state[2];
})
setTimeout(() => {
state.length = 1;
}, 1000)
上面的代碼中,數(shù)據(jù)變化是直接更新數(shù)組的長(zhǎng)度,而在 effect 中沒(méi)有使用 length 屬性,所以在更新 length 屬性時(shí)不會(huì)觸發(fā) run(depsMap.get(key));
的依次執(zhí)行,這樣 length 改變 effect 回調(diào)函數(shù)不會(huì)執(zhí)行,視圖也不會(huì)被更新。這時(shí)就需要對(duì)屬性是 length 的數(shù)組進(jìn)行驗(yàn)證,如果直接更新的是數(shù)組的長(zhǎng)度就需要單獨(dú)處理:
export function trigger(target, type, key, newValue, oldValue) {
const depsMap = targetMap.get(target)
if (!depsMap) {
return
}
const run = (effects) => {
if (effects) {
effects.forEarch(effect => effect())
}
}
if (key === 'length' && isArray(target)) {
depsMap.forEarch((deps, key) => {
if(key === 'length' || key >= newValue) { // newValue是更新后的值,
run(deps)
}
})
} else {
if (key == void 0) {
run(depsMap.get(key));
}
}
}
上面的代碼是在修改數(shù)組 length 屬性時(shí),讓收集依賴(lài)的函數(shù)執(zhí)行。還有一種情況,是在 effect 回調(diào)中沒(méi)有直接取索引的值,而且在修改數(shù)組時(shí),直接在超過(guò)數(shù)組長(zhǎng)度的位置上新增一個(gè)元素。
const state = reactive([1,2,3]);
effect(() => {
document.getElementById('app').innerHTML = state;
})
setTimeout(() => {
state[5] = 5;
}, 1000)
在這種情況下也沒(méi)有索引 key 進(jìn)行收集,但是確實(shí)使用數(shù)組的索引增加了值。這時(shí)我們就需要借助 trigger 中的 type 類(lèi)型來(lái)進(jìn)行處理,當(dāng)對(duì)數(shù)組索引進(jìn)行添加操作時(shí),需要觸發(fā)數(shù)組的更新。
export function trigger(target, type, key, newValue, oldValue) {
const depsMap = targetMap.get(target)
if (!depsMap) {
return
}
const run = (effects) => {
if (effects) {
effects.forEarch(effect => effect())
}
}
if (key === 'length' && isArray(target)) {
depsMap.forEarch((deps, key) => {
if(key === 'length' || key >= newValue) { // newValue是更新后的值,
run(deps)
}
})
} else {
if (key == void 0) {
run(depsMap.get(key));
}
switch (type) {
case 'ADD':
if(isArray(target)) {
if(isIntergerKey) { // 判斷key是否是索引類(lèi)型
run(depsMap.get('length')); // 新增屬性時(shí)直接觸發(fā)length收集的依賴(lài)即可
}
}
break;
}
}
}
這樣我們就基本上實(shí)現(xiàn)了 effect 的響應(yīng)式的源碼。
小結(jié)
本節(jié)我們主要實(shí)現(xiàn)了 Vue3 中 effect 函數(shù),它是一個(gè)響應(yīng)式的函數(shù),在源碼實(shí)現(xiàn)過(guò)程中需要注意幾點(diǎn):
- 使用 WeakMap 數(shù)據(jù)結(jié)構(gòu)來(lái)存放 target 上的 key 和 effect 的關(guān)系;
- 對(duì) effect 的嵌套處理時(shí),引入了棧的方式來(lái)控制當(dāng)前的 activeEffect 值;
- 在使用數(shù)組時(shí),在對(duì) length 直接修改等操作時(shí)進(jìn)行特殊的處理。