Vue3.2 で追加された effectScope の使用例のメモ

Vue 3.2 より `Effect Scope API` が追加されました。

こちらについての個人用メモです。

公式ドキュメントはこちら

- [エフェクトスコープ API(日本語)](https://v3.ja.vuejs.org/api/effect-scope.html)
- [RFC](https://github.com/vuejs/rfcs/blob/master/active-rfcs/0041-reactivity-effect-scope.md)

一度これらのドキュメントに目を通すことをおすすめします。(特にRFC)

実際このメモもRFCの内容に近いです

## Effect Scope の使用例
具体例を交えて説明していきます。

以下のような `useWindowSize` 関数を考えます。

```js:useWindowSize.js
import { ref, readonly, onUnmounted } from "vue";

/**
 * windowのリサイズを監視し、ウィンドウサイズを返す
 */
function useWindowSize() {
  const width = ref(0);
  const height = ref(0);

  const onResize = (ev) => {
    const t = ev.target;
    width.value = t.innerWidth;
    height.value = t.innerHeight;

  };
  
  // 初期値
  width.value = window.innerWidth;
  height.value = window.innerHeight;

  // リスナー登録
  window.addEventListener("resize", onResize);

  onUnmounted(() => {
    window.removeEventListener("resize", onResize);
  });

  return {
    width: readonly(width),
    height: readonly(height),
  };
}

export {
  useWindowSize
}
```
この関数は 実行時に ウィンドウの `resize` イベント監視を開始し、コンポーネント廃棄時に監視を終了するようにしています。

利用する側は以下のように 戻り値の `width`, `height` を使います。

```vue:QiitaSandbox.vue
<script>
import { useWindowSize } from "./useWindowSize";

export default {
  name: "QiitaSandbox",
  setup() {
    const size = useWindowSize();

    // size.height, size.width を使う

    // 以下省略
  },
};
</script>
```

### 問題点
上記のコードでも特に問題なく動きますが、気になる点があります。

`useWindowSize` 関数ですが 使用箇所それぞれで `window` にリスナー登録が行われます。
極端な話、とあるコンポーネントで `useWindowSize`が使われており、そのコンポーネントが100個使われてると、100個のリスナーが登録されることになります。

これを回避する方法の例としては ルートに近いコンポーネントで `useWindowSize` を使い、サイズを `Vuex` などのストアで管理する方法も考えられます。
ただし、利用するコンポーネントが存在しない状況でもリッスンされ続けるという問題があります。

これらの問題を独自で解決する方法もありますが、`Effect Scope API` を使うと解決することができるようになります。

## 書き換えてみる
まず `useWindowSize` 関数を書き換えます

```js:useWindowSize.js
import { ref, readonly, onScopeDispose } from "vue";

/**
 * windowのリサイズを監視し、ウィンドウサイズを返すuse
 */
function useWindowSize() {
  const width = ref(0);
  const height = ref(0);

  const onResize = (ev) => {
    const t = ev.target;
    width.value = t.innerWidth;
    height.value = t.innerHeight;

    console.log(`resize(${width.value}, ${height.value})`);
  };
  
  // 初期値
  width.value = window.innerWidth;
  height.value = window.innerHeight;

  // リスナー登録
  window.addEventListener("resize", onResize);

  // 解除はonScopeDispose で行う
  onScopeDispose(() => {
    window.removeEventListener("resize", onResize);
  });

  return {
    width: readonly(width),
    height: readonly(height),
  };
}


export {
  useWindowSize
}
```

`onUnmounted` が `onScopeDispose` に変わっています。
これは スコープが廃棄されるときに呼ばれるコールバックです。

次にスコープを作るための関数`wrapScope`と、それを利用した新しいウィンドウサイズ用の関数`useWindowSizeEx`を定義します。

```js:wrapScope.js
import { effectScope, onScopeDispose } from "vue";

function wrapScope(fn) {
  let count = 0;
  let state = null;
  let scope = null;

  const dispose = () => {
    count--;
    if (count <= 0) {
      // count が0 
      // つまり、使用中のものがなくなった時に実体を廃棄する。

      state = null;
      scope.stop();
      scope = null;
    }
  };
  
  return (...args) => {
    count++;
    if (state == null) {
      //trueにするとスコープの親子関係が切り離される
      scope = effectScope(true);
      state = fn(...args);
    }

    //おもな呼び出しタイミングはコンポーネント廃棄の時
    onScopeDispose(dispose);

    return state;
  };
}

export {
  wrapScope
}
```

```js:useWindowSizeEx.js
import { wrapScope } from "./wrapScope";
import { useWindowSize } from "./useWindowSize";

// wrapScope でラップすることで、
// 関数呼び出し毎にグローバルリスナーが登録されるのではなく
// グローバルリスナーは一つのみ
// 参照がなくなればグローバルのリスナーも削除される
// ということができるようになる。
const useWindowSizeEx = wrapScope(useWindowSize);

export { useWindowSizeEx };
```

ポイントとなるのは `wrapScope` 関数でこれは関数を返す関数です。

これが生成した関数は 実行されるたびに `count` が+1されていきます。また0→1の時に `effectScope` でスコープが 作成されます。 `effectScope` の引数は `true` にする事で 現在のスコープとは切り離された独立したスコープになるようです。また0→1のタイミングで`fn` が実行されることで1回だけ リスナー登録がされるようになります。

`onScopeDispose` はそれを利用しているスコープが廃棄されたとき(典型的な例ではコンポーネント廃棄時)に実行されます。
この時に `dispose` が実行され `count`が-1されていきます。全ての利用箇所がなくなった時に `scope.stop()`が実行されスコープを廃棄します。
この関数が実行されることで `useWindowSize` の `onScopeDispose` が実行され、リスナーが解除されます。

利用する`vue` コンポーネントは `useWindowSize` のかわりに `useWindowSizeEx` を使うだけで済みます。

こうすることで、リスナー登録は1回にしつつ、利用するコンポーネントがある時だけリスナーが存在し続けるという事ができるようになります。

## おわりに
ここで紹介した利用例はほんの一例です。
`Effect Scope` の利用場面はこれ以外にもたくさんあると思います。

- [Pinia](https://pinia.vuejs.org/)
- [VueUse](https://vueuse.org/)

これらのライブラリでも `Effect Scope` は使われているので ソースを見ると使い方の参考になると思います。