Red Huang

Red Huang

Vue 3.0 のリアクティブ、エフェクトは一体何をしたのか

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 オブジェクトを返すことがわかります。

 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 つのことが行われることがわかります。

  1. activeReactiveEffectStack にプッシュ
  2. fn を実行
  3. 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' を追加した場合、最初に 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) {
    // コレクションがクリアされている場合、ターゲットのすべてのエフェクトをトリガーします
    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 は最初に実行される必要があります。そうしないと、依存する通常の effects が実行される前に computed getters が無効化されます。
  computedRunners.forEach(run)
  effects.forEach(run)
} 

もちろん、賢いあなたはどうすればよいかを理解しているはずです。targetMap の対応表を取り出し、すべての effect に対して一度 run すれば良いのです。

もちろん、これは reactive の表面的な部分に過ぎません。次回は computedRunners の実装方法と readonly 特性について議論します。

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。