Vue 3.0 の reactive と effect は一体何をしたのか#
vue-next (vue 3.0) が登場した後、皆さんはソースコードに非常に興味を持っていると思いますし、vue 3.0 は Composition API に変更されました。もちろん、皆さんが最も興味を持っているのは、Vue 3.0 が Proxy API をどのように活用しているのかということです。したがって、本記事の主な焦点は、vue 3.0 の reactive が一体何をしているのかということです。
まずは test case を見てみましょう#
vue 3.0 は Composition API に変更されたため、Composition API を先に確認することを強くお勧めします。
以下の図はユニットテストのコードで、{ num: 0 }
を reactive として設定し、num の値を設定するだけで、4 行目の () => (dummy = counter.num)
が呼び出されることがわかります。
it('should observe basic properties', () => {
let dummy
const counter = reactive({ num: 0 })
effect(() => (dummy = counter.num))
expect(dummy).toBe(0)
counter.num = 7
expect(dummy).toBe(7)
})
reactive の実装#
reactive という関数は、reactive object を返すことがわかります。
export function reactive(target: object) {
// 読み取り専用のプロキシを観察しようとした場合、読み取り専用バージョンを返す。
if (readonlyToRaw.has(target)) {
return target
}
// ユーザーによって target が明示的に読み取り専用としてマークされている
if (readonlyValues.has(target)) {
return readonly(target)
}
return createReactiveObject(
target,
rawToReactive,
reactiveToRaw,
mutableHandlers,
mutableCollectionHandlers
)
}
createReactiveObject は、まず target がすでに observe されているか、またはプロキシであるかを判断します。次に、target.constructor が Set, Map, WeakMap, WeakSet の場合は collectionHandlers を使用し、それ以外の場合は baseHandlers を使用します。このテストケースの例では mutableHandlers が使用されます。
function createReactiveObject(
target: any,
toProxy: WeakMap<any, any>,
toRaw: WeakMap<any, any>,
baseHandlers: ProxyHandler<any>,
collectionHandlers: ProxyHandler<any>
) {
if (!isObject(target)) {
if (__DEV__) {
console.warn(`value cannot be made reactive: ${String(target)}`)
}
return target
}
// target はすでに対応する Proxy を持っています
let observed = toProxy.get(target)
if (observed !== void 0) {
return observed
}
// target はすでに Proxy です
if (toRaw.has(target)) {
return target
}
// 観察できる値のタイプのホワイトリストのみが観察可能です。
if (!canObserve(target)) {
return target
}
const handlers = collectionTypes.has(target.constructor)
? collectionHandlers
: baseHandlers
observed = new Proxy(target, handlers)
toProxy.set(target, observed)
toRaw.set(observed, target)
if (!targetMap.has(target)) {
targetMap.set(target, new Map())
}
return observed
}
次に mutableHandlers を見ていきます。前述の通り、mutableHandlers はプロキシハンドラである必要があります。vue2.x の経験に基づいて、getter が依存関係を収集するために特化しているため、さらに見ていきます。
export const mutableHandlers: ProxyHandler<any> = {
get: createGetter(false),
set,
deleteProperty,
has,
ownKeys
}
ここでは、通常の状況では track という関数が呼び出され、以前と同様に、res が Object であることが検出されると、再帰的に reactive 操作が行われます。
function createGetter(isReadonly: boolean) {
return function get(target: any, key: string | symbol, receiver: any) {
const res = Reflect.get(target, key, receiver)
if (typeof key === 'symbol' && builtInSymbols.has(key)) {
return res
}
if (isRef(res)) {
return res.value
}
track(target, OperationTypes.GET, key)
return isObject(res)
? isReadonly
? // 読み取り専用と reactive に遅延アクセスする必要がある
// 循環依存を避けるため
readonly(res)
: reactive(res)
: res
}
}
track 関数の中には、Vue 2.x の Dep.target スタックに似た操作があり、activeReactiveEffectStack から最後の effect を取り出して targetMap の設定を行います。targetMap は WeakMap で、要するに target のすべての deps を保存し、effect を deps にプッシュします。
- targetMap = target に対応する depsMap
- depsMap = key に対応する deps
export const targetMap: WeakMap<any, KeyToDepMap> = new WeakMap()
export function track(
target: any,
type: OperationTypes,
key?: string | symbol
) {
if (!shouldTrack) {
return
}
const effect = activeReactiveEffectStack[activeReactiveEffectStack.length - 1]
if (effect) {
if (type === OperationTypes.ITERATE) {
key = ITERATE_KEY
}
let depsMap = targetMap.get(target)
if (depsMap === void 0) {
targetMap.set(target, (depsMap = new Map()))
}
let dep = depsMap.get(key!)
if (dep === void 0) {
depsMap.set(key!, (dep = new Set()))
}
if (!dep.has(effect)) {
dep.add(effect)
effect.deps.push(dep)
if (__DEV__ && effect.onTrack) {
effect.onTrack({
effect,
target,
type,
key
})
}
}
}
}
しかし、この時点で皆さんは疑問に思うかもしれません。activeReactiveEffectStack はどこから来たのでしょうか?
effect(() => (dummy = counter.num))
のコードを思い出してください。
effect の実装の詳細を見てみると、遅延がない場合は createReactiveEffect (fn, options) の返り値を直接呼び出すことがわかります。
つまり、この部分 run(effect as ReactiveEffect, fn, args)
です。
export function effect(
fn: Function,
options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect {
if ((fn as ReactiveEffect).isEffect) {
fn = (fn as ReactiveEffect).raw
}
const effect = createReactiveEffect(fn, options)
if (!options.lazy) {
effect()
}
return effect
}
function createReactiveEffect(
fn: Function,
options: ReactiveEffectOptions
): ReactiveEffect {
const effect = function effect(...args): any {
return run(effect as ReactiveEffect, fn, args)
} as ReactiveEffect
effect.isEffect = true
effect.active = true
effect.raw = fn
effect.scheduler = options.scheduler
effect.onTrack = options.onTrack
effect.onTrigger = options.onTrigger
effect.onStop = options.onStop
effect.computed = options.computed
effect.deps = []
return effect
}
run の中では、重要なスタックプッシュ activeReactiveEffectStack.push(effect)
と return fn(...args)
があり、もちろん finally の中でスタックポップ activeReactiveEffectStack.pop()
があります。
ここで理解しておくべきことは、finally ブロックは常に実行されるということです。たとえ return の後でも。
function run(effect: ReactiveEffect, fn: Function, args: any[]): any {
if (!effect.active) {
return fn(...args)
}
if (activeReactiveEffectStack.indexOf(effect) === -1) {
cleanup(effect)
try {
activeReactiveEffectStack.push(effect)
return fn(...args)
} finally {
activeReactiveEffectStack.pop()
}
}
}
さて、理解すべきことはすべて理解しました。テストケースを振り返って、reactive がどのように作業を完了するのかを見てみましょう。
it('should observe basic properties', () => {
let dummy
const counter = reactive({ num: 0 })
effect(() => (dummy = counter.num))
expect(dummy).toBe(0)
counter.num = 7
expect(dummy).toBe(7)
})
まず、3 行目で{ num: 0 }
が reactive に変換されます。
前の例に従って、counter は createReactiveObject の返り値になります。つまり、
new Proxy({ num: 0 }, mutableHandlers)
この時点で 4 行目に進むと、effect に関数 () => (dummy = counter.num)
が渡され、lazy ではないことがわかります。
したがって、effect は直接呼び出され、次の行が実行されます。
const effect = function effect(...args): any {
return run(effect as ReactiveEffect, fn, args)
} as ReactiveEffect
run が実行されると、3 つのことが行われます。
- activeReactiveEffectStack にプッシュ
- fn を実行
- activeReactiveEffectStack からポップ
したがって、2 番目のステップに進むと、スタックにはすでに 1 つの effect のデータがあり、fn は dummy = counter.num
です。この時点で counter のプロキシ getter 関数が呼び出されます。
何が起こるか覚えていますか?track 関数が呼び出され、
もし覚えているなら(ここまで来ると皆さんは忘れてしまったと思いますが)、track はスタックのトップ effect を取り出して、target の特定の key とその effect の対応関係を確立します。
さて、ここまで来たらほぼ終わりですが、もう一つの重要なことがあります。前述の通り、set が何をするのかを見てみましょう。
counter.num = 7
まず、set ハンドラのコードを見てみましょう。
function set(
target: any,
key: string | symbol,
value: any,
receiver: any
): boolean {
value = toRaw(value)
const hadKey = hasOwn(target, key)
const oldValue = target[key]
if (isRef(oldValue) && !isRef(value)) {
oldValue.value = value
return true
}
const result = Reflect.set(target, key, value, receiver)
// 元のプロトタイプチェーンの上にあるターゲットの場合はトリガーしない
if (target === toRaw(receiver)) {
/* istanbul ignore else */
if (__DEV__) {
const extraInfo = { oldValue, newValue: value }
if (!hadKey) {
trigger(target, OperationTypes.ADD, key, extraInfo)
} else if (value !== oldValue) {
trigger(target, OperationTypes.SET, key, extraInfo)
}
} else {
if (!hadKey) {
trigger(target, OperationTypes.ADD, key)
} else if (value !== oldValue) {
trigger(target, OperationTypes.SET, key)
}
}
}
return result
}
一般的に、プロキシが配列を代理する場合、基本的な push を行うと、多くの get と set(set value, set length)がトリガーされる可能性があります。
何も追加の処理を行わなければ、トリガーが何度も実行される可能性があります。
ここで著者は少しの知恵を使っています。たとえば、['hello']
に 'world'
を push した場合、最初に set [1] = "world"
を行い、この時点で hadKey は false になります。これにより、trigger(target, OperationTypes.ADD, key, extraInfo)
に進み、次に set length = 2
を行うと、hadKey は true になり、value は oldValue と等しいため、多くのトリガーを回避することができます。
さて、話を戻すと、trigger は何をするのでしょうか?
export function trigger(
target: any,
type: OperationTypes,
key?: string | symbol,
extraInfo?: any
) {
const depsMap = targetMap.get(target)
if (depsMap === void 0) {
// まだ追跡されていない
return
}
const effects: Set<ReactiveEffect> = new Set()
const computedRunners: Set<ReactiveEffect> = new Set()
if (type === OperationTypes.CLEAR) {
// コレクションがクリアされている場合、ターゲットのすべての effect をトリガー
depsMap.forEach(dep => {
addRunners(effects, computedRunners, dep)
})
} else {
// SET | ADD | DELETE のために実行をスケジュール
if (key !== void 0) {
addRunners(effects, computedRunners, depsMap.get(key))
}
// ADD | DELETE の場合、イテレーションキーでも実行
if (type === OperationTypes.ADD || type === OperationTypes.DELETE) {
const iterationKey = Array.isArray(target) ? 'length' : ITERATE_KEY
addRunners(effects, computedRunners, depsMap.get(iterationKey))
}
}
const run = (effect: ReactiveEffect) => {
scheduleRun(effect, target, type, key, extraInfo)
}
// 重要: computed effects は最初に実行され、computed getters
// は通常の effects が実行される前に無効化される必要があります。
computedRunners.forEach(run)
effects.forEach(run)
}
もちろん、賢いあなたはどうすればよいか知っているでしょう。targetMap の対応表を取り出し、すべての effect に対して run を実行すればよいのです。
これは reactive の表面的な部分に過ぎません。次回は computedRunners の実装方法と readonly 特性について議論します。