===
Vue 3.0 reactive, computed 原始碼分析 #
このファイルでは多くのコードが使用されているため、全体のコードをトレースするために検索 (Ctrl+F) を活用してください。
Vue 3.0 では、computed の書き方も変更されており、書き方は Composition APIを参照できます。提供されている例は以下の通りです。
const state = reactive({
count: 0,
double: computed(() => state.count * 2)
})
テストケースを使って computed が何をできるか見てみましょう。#
computed には関数を渡すことができ、その関数内の値が変更されたり、値が与えられたりすると、computed の返り値の value が更新されます。
以下の例では、computed(() => value.foo)
の中でvalue.foo
が使用されているため、value.foo = 1
のとき、cValue
の中のvalue
が1
に更新されることがわかります。
it('should return updated value', () => {
const value = reactive<{ foo?: number }>({})
const cValue = computed(() => value.foo)
expect(cValue.value).toBe(undefined)
value.foo = 1
expect(cValue.value).toBe(1)
})
Computed#
computed の機能を理解した後、computed がどのように更新されるのか、その原始コードを追ってみましょう。
まず、computed という関数の実装を直接見てみましょう。元のテストケースに基づいて、渡されたメソッドがgetterOrOptions: (() => T) | WritableComputedOptions<T>
という型に一致していることがわかります。したがって、setter function
はありません。次に、23 行目でeffect
が宣言されており、この時点でlazy: true
です。
export function computed<T>(getter: () => T): ComputedRef<T>
export function computed<T>(
options: WritableComputedOptions<T>
): WritableComputedRef<T>
export function computed<T>(
getterOrOptions: (() => T) | WritableComputedOptions<T>
): any {
const isReadonly = isFunction(getterOrOptions)
const getter = isReadonly
? (getterOrOptions as (() => T))
: (getterOrOptions as WritableComputedOptions<T>).get
const setter = isReadonly
? __DEV__
? () => {
console.warn('Write operation failed: computed value is readonly')
}
: NOOP
: (getterOrOptions as WritableComputedOptions<T>).set
let dirty = true
let value: T
const runner = effect(getter, {
lazy: true,
// mark effect as computed so that it gets priority during trigger
computed: true,
scheduler: () => {
dirty = true
}
})
前の章でeffect
の実際の効果について少し触れました。コードを復習してみましょう。
options.lazy
がtrue
であれば、起動のタイミングは外部の人によって制御されます。
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
}
computed に戻ると、expect(cValue.value).toBe(undefined)
が実行されると、getter function get value()
が実行されます。この時、dirty
がtrue
であれば、runner が起動し、effect が依存関係の収集を開始します。
依存関係の収集方法は前の章で説明しました。理論的には、push activeReactiveEffectStack
、run getter
、pop activeReactiveEffectStack
です。
この時点で、expect(cValue.value).toBe(undefined)
が実行されると、value
という reactiveObject が収集され、依存関係の収集が完了します。
const runner = effect(getter, {
lazy: true,
// mark effect as computed so that it gets priority during trigger
computed: true,
scheduler: () => {
dirty = true
}
})
return {
[refSymbol]: true,
// expose effect so computed can be stopped
effect: runner,
get value() {
if (dirty) {
value = runner()
dirty = false
}
// When computed effects are accessed in a parent effect, the parent
// should track all the dependencies the computed property has tracked.
// This should also apply for chained computed properties.
trackChildRun(runner)
return value
},
set value(newValue: T) {
setter(newValue)
}
}
}
したがって、value.foo = 1
というステートメントが実行されると、依存関係がトリガーされ、以前に説明したtrigger function
が起動します。
addRunners という関数を見てみると、effect が computed の場合、この effect が computedRunners Set に追加されます。これは、後で computedRunners が通常の effects よりも先に実行されるためです。なぜこれを行うのでしょうか?
一般的な effect が computedObject の情報を使用する必要がある場合、まず computedObject の dirty を true にする必要があります。つまり、scheduler function
を優先的に実行し、effect が後で実行されるときに computedObject の情報が最新であることを保証します。
export function trigger(
target: any,
type: OperationTypes,
key?: string | symbol,
extraInfo?: any
) {
const depsMap = targetMap.get(target)
if (depsMap === void 0) {
// never been tracked
return
}
const effects = new Set<ReactiveEffect>()
const computedRunners = new Set<ReactiveEffect>()
if (type === OperationTypes.CLEAR) {
// collection being cleared, trigger all effects for target
depsMap.forEach(dep => {
addRunners(effects, computedRunners, dep)
})
} else {
// schedule runs for SET | ADD | DELETE
if (key !== void 0) {
addRunners(effects, computedRunners, depsMap.get(key))
}
// also run for iteration key on 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)
}
// Important: computed effects must be run first so that computed getters
// can be invalidated before any normal effects that depend on them are run.
computedRunners.forEach(run)
effects.forEach(run)
}
function addRunners(
effects: Set<ReactiveEffect>,
computedRunners: Set<ReactiveEffect>,
effectsToAdd: Set<ReactiveEffect> | undefined
) {
if (effectsToAdd !== void 0) {
effectsToAdd.forEach(effect => {
if (effect.computed) {
computedRunners.add(effect)
} else {
effects.add(effect)
}
})
}
}
話がそれましたが、ここで追跡が完了した結果、value.foo = 1
が実行されると、cValue の dirty が true になります。次に、expect(cValue.value).toBe(1)
が実行されると、cValue getter function
がトリガーされ、最新の値が更新されて返されます。
このテストケースはここで終了します。
Computed Chained#
しかし、computed chained の場合はどうでしょうか?
以下のコードは、c2 -> c1 -> value
の依存関係を示しており、computed の依存関係の構築は、常にgetter function
から収集されることを覚えておいてください。この例では、c2.value
がそれに該当します。この時、computedObject の runner が実行されます。ここでは少し複雑なので、簡単な言葉で表現します。
push c2, activeReactiveEffectStack => [ c2 ]
c2 run fn // which is c1.value + 1
push c1, activeReactiveEffectStack => [ c2, c1 ]
c1 run fn // which is value.foo
value 依賴於 activeReactiveEffectStack top // 也就是 c1
pop c1, activeReactiveEffectStack => [ c2 ]
c1 run trackChildRun,將 activeReactiveEffectStack top 也就是 c2 依賴於 c1 的所有依賴
pop c2, activeReactiveEffectStack => []
このようにして、親ノードは子ノードのすべての依存点に依存することになります。これは再帰的に有効であり、親ノードはすべての子孫ノードに依存することができ、依存関係はフラットになります。
この利点は、階層構造が多くても、依存関係のトリガーによる更新性能に影響を与えないことです。
この例では、value.foo++
だけでc1
とc2
の dirty が true に更新されるため、expect(c2.value).toBe(2)
とexpect(c1.value).toBe(1)
の順序に関係なく、両方の値が更新されます。
it('should work when chained', () => {
const value = reactive({ foo: 0 })
const c1 = computed(() => value.foo)
const c2 = computed(() => c1.value + 1)
expect(c2.value).toBe(1)
expect(c1.value).toBe(0)
value.foo++
expect(c2.value).toBe(2)
expect(c1.value).toBe(1)
})
function trackChildRun(childRunner: ReactiveEffect) {
const parentRunner =
activeReactiveEffectStack[activeReactiveEffectStack.length - 1]
if (parentRunner) {
for (let i = 0; i < childRunner.deps.length; i++) {
const dep = childRunner.deps[i]
if (!dep.has(parentRunner)) {
dep.add(parentRunner)
parentRunner.deps.push(dep)
}
}
}
}
### [](https://hackmd.io/ZfQArpjoQ8GrGqCABYpPpQ?view#Triggered-effect-when-chained "Triggered-effect-when-chained")チェーン時のトリガー効果
次に、一般的なeffectがcomputedに依存する場合に何が起こるかについて説明します。
この例では、依存関係は`effect -> c2 -> c1 -> value`です。effectはlazyではないため、直接実行されます。以下は以前のテキストの順序で表現します。
1. `push effect, activeReactiveEffectStack => [ effect ]`
2. `effect run fn // which is dummy = c2.value`
3. `push c2, activeReactiveEffectStack => [ effect, c2 ]`
4. `c2 run fn // which is c1.value + 1`
5. `push c1, activeReactiveEffectStack => [ effect, c2, c1 ]`
6. `c1 run fn // which is value.foo`
7. `value 依賴於 activeReactiveEffectStack top // 也就是 c1`
8. `pop c1, activeReactiveEffectStack => [ effect, c2 ]`
9. `c1 run trackChildRun,將 activeReactiveEffectStack top 也就是 c2 依賴於 c1 的所有依賴`
10. `pop c2, activeReactiveEffectStack => [ effect ]`
11. `c2 run trackChildRun,將 activeReactiveEffectStack top 也就是 effect 依賴於 c2 的所有依賴 (而又此時 c2 擁有 c1 的所有依賴,所以也就是 c1, c2 兩者的所有依賴`
12. `pop effect, activeReactiveEffectStack => []`
この時点で、dummyは12行目で1になります。`getter1`と`getter2`も一度実行され、次にvalue.foo++が実行され、3つの依存者`effect`、`c2`、`c1`がトリガーされます。そしてもちろん、`computedRunners`と`effects`は集合なので、必ず一度だけ実行され、それぞれの関数がトリガーされます。このテストケースは完了しました。
it('should trigger effect when chained', () => {
const value = reactive({ foo: 0 })
const getter1 = jest.fn(() => value.foo)
const getter2 = jest.fn(() => {
return c1.value + 1
})
const c1 = computed(getter1)
const c2 = computed(getter2)
let dummy
effect(() => {
dummy = c2.value
})
expect(dummy).toBe(1)
expect(getter1).toHaveBeenCalledTimes(1)
expect(getter2).toHaveBeenCalledTimes(1)
value.foo++
expect(dummy).toBe(2)
// should not result in duplicate calls
expect(getter1).toHaveBeenCalledTimes(2)
expect(getter2).toHaveBeenCalledTimes(2)
})
これで、computedのすべての状況がどれほど複雑で困難であるかを説明できますが、困難はまだ続きます。このシリーズの記事では、徐々にコアや仮想DOMの方に進んでいきます。