うしろのこの本ください

なんでもかきます

computedはどうやって読み取り専用オブジェクトを返すか

最近他でブログを書く事が増えてこちらが寂しいので最近vue-nextのコード読んで得た知見をメモする。正直読んだらわかることをブログにするのも微妙なんだけど、なんとなく書いてる。そのうち refreactive がどう動いているのかも書くつもり。

今回は computed プロパティが読み取り専用オブジェクトを返す挙動についてのメモです。

computed のしくみ

読めばわかる

vue-next/computed.ts at ffdb05e1f1b69e737e249b081e7049c74aaf25e8 · vuejs/vue-next · GitHub

で終わっても面白くないので簡単に解説します。Composition API版の computed はOptions APIとは違い、基本的にReadonlyなリアクティブオブジェクトを返却する。v3的に言うと Readonly<Ref<T>> となって返ってくる。 そのため、直接上書こうとするとランタイムで怒られる。TSを使っている場合はVeturが検知するのでエディタ上でも静的に検出できる。

import { computed } from 'vue';

export default {
  setup() {
    const count = ref(1)

    const immutable = computed(() => {
      return count * 2
    })

    console.log(immutable.value = 0) // => Write operation failed: computed value is readonly
  }
}

Options APIではこのような挙動をしていなかったので、単純に置き換えた場合壊れる可能性がある。例えば methods でcomputedの返却値を加工してからAPIリクエストとして送るものすごいお行儀の悪いコードがあるかもしれない。そうした場合読み取り専用だと対応できないので、Composition API版では引数にgetter/setterを持つオブジェクトをとることもできる。

import { computed } from 'vue';
import { api } from '~/api'

export default {
  setup() {
    const text = ref('foo')

    const mutable = computed({
      get: () => {
        return text.value;
      },
      set: (newValue) => {
        text.value = newValue;
      },
    });

    function postText() {
      mutable.value + Date.now()
      api.post('/path/to/api', mutable.value)
    }

    return {
      mutable,
      postText
    }
  }
}

set の引数型は get の返却値から自動的に推論されるため型注釈は不要。Options APIでの挙動を再現したいなら一旦こちらに書き換えると後方互換性を保つ事ができる。

これをどう実現しているかだが、早い話引数に関数が渡ってくるかオブジェクトが渡ってくるかで処理を変えるだけで、以下が実装部分。

export function computed<T>(
  getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>
) {
  let getter: ComputedGetter<T>
  let setter: ComputedSetter<T>

  if (isFunction(getterOrOptions)) {
    getter = getterOrOptions
    setter = __DEV__
      ? () => {
          console.warn('Write operation failed: computed value is readonly')
        }
      : NOOP
  } else {
    getter = getterOrOptions.get
    setter = getterOrOptions.set
  }

  return new ComputedRefImpl(
    getter,
    setter,
    isFunction(getterOrOptions) || !getterOrOptions.set
  ) as any
}

isFunction で引数が関数かどうかをチェックし、関数であればcomutedを生成するクラスは空のsetterで初期化される。関数でない場合オブジェクトのはずなので、その set プロパティが利用される。computed を生成するクラスは以下のようなもの。

class ComputedRefImpl<T> {
  private _value!: T
  private _dirty = true

  public readonly effect: ReactiveEffect<T>

  public readonly __v_isRef = true;
  public readonly [ReactiveFlags.IS_READONLY]: boolean

  constructor(
    getter: ComputedGetter<T>,
    private readonly _setter: ComputedSetter<T>,
    isReadonly: boolean
  ) {
    this.effect = effect(getter, {
      lazy: true,
      scheduler: () => {
        if (!this._dirty) {
          this._dirty = true
          trigger(toRaw(this), TriggerOpTypes.SET, 'value')
        }
      }
    })

    this[ReactiveFlags.IS_READONLY] = isReadonly
  }

  get value() {
    if (this._dirty) {
      this._value = this.effect()
      this._dirty = false
    }
    track(toRaw(this), TrackOpTypes.GET, 'value')
    return this._value
  }

  set value(newValue: T) {
    this._setter(newValue)
  }
}

リアクティブな値を追跡するための処理や最適化のための処理があるので少しごちゃついているが基本的には渡されたgetter/setterで初期化して ref 相当のオブジェクトを返却している。computed の関数型は引数によって返却する型を変えていて、それによって読み取り専用か上書き可能かが型で判別できるようになっている。

export declare function computed<T>(getter: ComputedGetter<T>): ComputedRef<T>;

export declare function computed<T>(options: WritableComputedOptions<T>): WritableComputedRef<T>;

TSを使っていないとランタイムでしかわからないのでComposition APIの恩恵をちゃんと受けたいのであれば導入を検討すると良い。

余談

computedref っぽいオブジェクトを返すが、これはProxyではない。実は ref 自体もProxyを利用しておらずVueが持つイベント管理の仕組みを利用したクラスになっている。こちらの解説は別の記事でやります。