「明けない夜に落ちてゆく前に、
僕の手を掴んでほら。」——Ayase
上回書說道,為了記錄每個響應式變數被哪些effect函數提及,我們必須執行以下步驟:
我們還在/src/vue/effect.ts暴露了一個方法,其可以記錄當前正在作用的ReactiveEffect對象,並調用其run方法。
接下來我們將來復刻reactive。
首先我們要建立兩個文件,/src/vue/reactive.ts和/src/vue/baseHandler.ts。
baseHandler.ts的部分要做依賴收集,會比較複雜,我們先把依賴收集以外的部分寫好:
import { reactive } from './reactive';
export const enum ReactiveFlags {
IS_REACTIVE = '__v_isReactive'
}
export default {
get: (
target: Record<string, any>,
key: string,
receiver: any
) => {
if (key === ReactiveFlags.IS_REACTIVE) return true;
const res = Reflect.get(target, key, receiver);
if (res && typeof res === 'object') return reactive(res);
return res;
},
set: (target: Record<string, any>, key: string, value: any) => {
target[key] = value;
return true;
}
};
先暴露一個枚舉ReactiveFlags,其中有IS_REACTIVE屬性,表示這個對象是否被代理過,稍後會在reactive.ts提到。
enum是ts的關鍵字,其代表枚舉,可在enum中寫入許多key,乍看之下和物件很像,但當ts被編譯為js後,枚舉會被全部替換成key對應的value,並不會在堆內存產生物件。
譬如上述的例子ReactiveFlags,在ts被編譯成js後,不會在內存產生一個有IS_REACTIVE屬性的物件,而所有用到ReactiveFlags.IS_REACTIVE的地方都會被替換為'__v_isReactive'(如果枚舉的key沒有值,將會被依序換成0、1、2......類推的數字)。
而這邊之所以使用枚舉,是因為IS_REACTIVE具備可讀性,但這個屬性不像__v_isReactive,撞名的可能性相對較高。因此透過枚舉,實現開發時具可讀性,打包時不易撞名的效果。
至於下方默認暴露的,包含get和set方法的對象,就是接下來要給reactive傳入Proxy的第二個參數的handler了。
基本上目前還沒甚麼重點,重點在之後才會往這裡面寫的依賴收集,唯一值得一提的只有深度代理的部分:
const res = Reflect.get(target, key, receiver);
if (res && typeof res === 'object') return reactive(res);
Reflect.get(target, key, receiver)和target[key]基本無異,就只差在能給之後深度代理的屬性的this指向receiver。
而如果目前訪問到的target[key]是一個物件,便return reactive(target[key]),遞歸調用reactive
將對象內的每一個屬性都代理成響應式變數,即可實現深度代理。
相較之下reactive.ts就簡單得多:
import baseHandler, { ReactiveFlags } from './baseHandler';
const reactiveMap = new WeakMap();
export const reactive = (target: Record<string, any>) => {
if (target && typeof target === 'object') {
if (target[ReactiveFlags.IS_REACTIVE]) return target;
const existingProxy = reactiveMap.get(target);
if (existingProxy) return existingProxy;
const proxy = new Proxy(target, baseHandler);
reactiveMap.set(target, proxy);
return proxy;
}
};
WeakMap是一種類似物件的結構,其差別在於物件的key必須是string或Symbol,而WeakMap的key必須是物件。
因此可以透過WeakMap,替被代理的原對象及代理後的響應式對象建立映射關係。
以下程式碼都是在檢查reactive接收的對象是否已被代理。已被代理過就直接返回原對象,反正它已經被代理過了,沒必要代理第二次。
if (target[ReactiveFlags.IS_REACTIVE]) return target;
const existingProxy = reactiveMap.get(target);
if (existingProxy) return existingProxy;
確認未被代理,便new一個Proxy對象,將target記錄到reactiveMap中(用於判斷target已被代理過了),最後再return proxy。
const proxy = new Proxy(target, baseHandler);
reactiveMap.set(target, proxy);
return proxy;
至此,前置準備工作便完成了。接下來我們將利用/src/vue/baseHandler.ts中默認暴露的get方法來進行依賴收集。
讓我們先回到/src/vue/effect.ts,在最下面加上以下程式碼吧。
type Dep = Set<ReactiveEffect>;
type DepsMap = Map<string | symbol, Dep>;
type TargetMap = WeakMap<object, DepsMap>;
const targetMap: TargetMap = new WeakMap();
const trackEffects = (dep: Dep) => {
if (activeEffect && !dep.has(activeEffect)) {
dep.add(activeEffect);
}
};
export const track = (target: object, key: string | symbol) => {
if (!activeEffect) return;
let depsMap: DepsMap = targetMap.get(target);
if (!depsMap) targetMap.set(target, depsMap = new Map());
let dep: Dep = depsMap.get(key);
if (!dep) depsMap.set(key, dep = new Set());
trackEffects(dep);
};
TargetMap的型別很複雜,把它展開來會長這樣:
type TargetMap = WeakMap<
object,
Map<
string | symbol, Set<ReactiveEffect>
>
>
它代表一個兩層的映射關係,首先將響應式對象
和觸發get的key
映射在一起,再將這個key
和有提及它的所有ReactiveEffect
映射在一起,形成一個 WeakMap{ target: Map{ key: Set{ ...deps } } } 的結構。
目前看起來可能還很抽象,待我們先繼續往下看,把下面的程式碼看完,或許就會比較清晰了。
接著我們先跳過trackEffects,從track開始看起。
export const track = (target: object, key: string | symbol) => {
if (!activeEffect) return;
let depsMap: DepsMap = targetMap.get(target);
if (!depsMap) targetMap.set(target, depsMap = new Map());
let dep: Dep = depsMap.get(key);
if (!dep) depsMap.set(key, dep = new Set());
trackEffects(dep);
};
我們可以看到track接收一個普通對象和一個對象的屬性,我們之後可以把觸發了get的代理對象及使get觸發的key
傳入track方法,進行依賴收集。
track方法接收到觸發get的對象及使get觸發的key後,會先判斷當前是否有正在作用的effect,若沒有也就不需要依賴收集了,直接return。
if (!activeEffect) return;
接著檢查targetMap是否已經收集過target了,若無,意即這個target是第一次在effect中觸發get,就將它收集到targetMap
中,給它映射到一個新的Map實例對象。
let depsMap: DepsMap = targetMap.get(target);
if (!depsMap) targetMap.set(target, depsMap = new Map());
然後檢查key是否也已被映射在其中,若無,意即這個target的key是第一次觸發target的get,我們同理需要將它收集到targetMap的depsMap
之中,給它映射到一個新的Set,之後用這個Set來儲存跟這個提及target[key]的effect
。
let dep: Dep = depsMap.get(key);
if (!dep) depsMap.set(key, dep = new Set());
如此一來,我們便完成了targetMap的雙層映射關係,讓target映射到key,key映射到儲存effect的Set
,也就是上面說的 WeakMap{ target: Map{ key: Set{ ...deps } } } 這種結構。
最後讓我們看看trackEffects:
const trackEffects = (dep: Dep) => {
if (activeEffect && !dep.has(activeEffect)) {
dep.add(activeEffect);
}
};
只要dep這個儲存effect的set不包含當前正在作用的activeEffect,便將其放入set當中
。
最後,再到/src/vue/baseHandler.ts的get方法中加上track,修改程式碼如下:
get: (
target: Record<string, any>,
key: string,
receiver: any
) => {
if (key === ReactiveFlags.IS_REACTIVE) return true;
track(target, key);
const res = Reflect.get(target, key, receiver);
if (res && typeof res === 'object') return reactive(res);
return res;
}
如此一來,當template中有任何reactive對象的任何key被提及
,便會觸發這個reactive對象的get,在get中會將代理的對象以及被提及的key傳入track方法中,在track方法建立對象、key及當前正在作用的activeEffect的兩層映射關係
,我們就成功實現了一個陽春的依賴收集。
最後為了方便我們之後在/src/index.ts測試reactive,我們創建一個新文件/src/vue/index.ts:
export * from './reactive';
之後的ref、computed、watch也都從這裡導出,在/src/index.ts就只要import { reactive } from './vue'即可引入,之後會寫的ref、computed、watch亦同。
照慣例,這次的程式碼我也會放上我github的main分支的commit「[Day 04]依賴收集 - 2——利用響應式變數的get收集當前effect」。
明天我們再向數據驅動視圖邁進。