🐈

Vue3でオブジェクトをrefでラップしたら内部のリアクティブ値の型が変わってハマった

2023/05/26に公開約4,700字

問題

こんな感じの事をするとcomputedの型が消えてしまいハマりました

const three = ref(3)
const object = {
  one: 1,
  two: 2,
  three: computed(() => {
    return three.value
  }),
}
const objectRef = ref(object)
objectRef.value = object // ここで型エラー


作ろうとした物

YouTube動画とローカル動画の切り替えと再生・停止が可能な機能の実装をしました。

このような構造で、YouTubeとローカル動画の操作をインターフェイスで共通化し、利用側のApp.vueでリアクティブなクラスを切り替える形で実装しました。

├── App.vue             // エントリーポイント
├── IVideoState.ts      // 動画状態のinterface
├── LocalVideoState.ts  // ローカル動画状態
├── YoutubeVideoState.ts// Youtube動画状態

コード

IVideoState.ts
import { ComputedRef } from "vue"
export interface IVideoState {
  play(): void
  stop(): void
  subscription: {
    status: ComputedRef<VideoStatus>
    videoType: ComputedRef<VideoType>
  }
}
export enum VideoStatus {
  NOT_CREATED = "未作成",
  CREATED = "作成済",
  PLAYING = "再生中",
  STOPED = "停止中",
}
export enum VideoType {
  YOUTUBE = "YOUTUBE",
  LOCAL = "LOCAL",
}
LocalVideoState.ts
LocalVideoState.ts
import { InjectionKey, ref, computed } from "vue"
import { IVideoState, VideoStatus, VideoType } from "./IVideoState"
// Youtubeの状態クラス
class UseYoutubeVideoState implements IVideoState {
  private status = ref(VideoStatus.NOT_CREATED)
  constructor() {
	  // ここでYoutube動画のプレイヤーを作成する
    this.status.value = VideoStatus.CREATED
  }
  play = () => {
    this.status.value = VideoStatus.PLAYING
  }
  stop = () => {
    this.status.value = VideoStatus.STOPED
  }
  subscription = {
    status: computed(() => {
      return this.status.value
    }),
    videoType: computed(() => {
      return VideoType.YOUTUBE
    }),
  }
}
const YoutubeVideoStateKey: InjectionKey<IVideoState> = Symbol("YoutubeVideoState")
export { UseYoutubeVideoState, YoutubeVideoStateKey }
YoutubeVideoState.ts
YoutubeVideoState.ts
import { InjectionKey, ref, computed } from "vue"
import { IVideoState, VideoStatus, VideoType } from "./IVideoState"
// ローカル動画
class UseLocalVideoState implements IVideoState {
  private status = ref(VideoStatus.NOT_CREATED)
  constructor() {
	  // ここでローカル動画のプレイヤーを作成する
    this.status.value = VideoStatus.CREATED
  }
  play = () => {
    this.status.value = VideoStatus.PLAYING
  }
  stop = () => {
    this.status.value = VideoStatus.STOPED
  }
  subscription = {
    status: computed(() => {
      return this.status.value
    }),
    videoType: computed(() => {
      return VideoType.LOCAL
    }),
  }
}
const LocalVideoStateKey: InjectionKey<IVideoState> = Symbol("LocalVideoState")
export { UseLocalVideoState, LocalVideoStateKey }
App.vue
<script setup lang="ts">
import { UseYoutubeVideoState } from "./YoutubeVideoState"
import { UseLocalVideoState } from "./LocalVideoState"
import { ref } from "vue"
const video = ref(new UseYoutubeVideoState())
const hundlePlayClick = () => {
  video.value.play()
}
const hundleStopClick = () => {
  video.value.stop()
}
const hundleVideoChange = () => {
  // >>>> ★★★ここで怒られる★★★ <<<<
  video.value = new UseLocalVideoState()
}
</script>

<template>
  <div style="width: 300px; background: gainsboro">
    <div
      style="
        width: 100%;
        height: 150px;
        background: gray;
        text-align: center;
        color: white;
      "
    >
      {{ video.subscription.videoType.value }}
    </div>
    <p>status: {{ video.subscription.status.value }}</p>
    <button @click="hundlePlayClick()">再生</button>
    <button @click="hundleStopClick()">停止</button><br />
    <button @click="hundleVideoChange()">動画を変更</button>
  </div>
</template>

refでラップしたクラスを同じインターフェイスを持つ別のクラスで置き換えようとした場合に、型エラーが発生しました。

subscripitonオブジェクトのプロパティが、なぜか算出プロパティではなくなってしまいました。

なぜ型が変わったのか?

公式にこんな一文があります。

If an object is assigned as a ref's value, the object is made deeply reactive with reactive(). This also means if the object contains nested refs, they will be deeply unwrapped.

DeepLで和訳するとこんな感じ。

オブジェクトがrefの値として割り当てられた場合、そのオブジェクトはreactive()でディープリアクティブにされます。これは、オブジェクトがネストしたrefを含んでいる場合、それらが深くアンラップされることも意味します。

refでオブジェクトをラップすると、オブジェクト内に含まれるrefが解除されてしまい、オブジェクト全体がリアクティブ化されてしまうらしい。
全体をリアクティブ化せずにvideo.valueのみをリアクティブ化するには、shallowRefを使えとの事。

なのでshallowRefを使用して以下のように修正します。

import { UseLocalVideoState } from "./LocalVideoState"
import { ref } from "vue"
- const video = ref(new UseYoutubeVideoState())
+ const video = shallowRef<IVideoState>(new UseYoutubeVideoState())

これでOK。

おわりに

雰囲気でrefを使っていたせいでハマってしまいました・・・・・
公式ドキュメントはちゃんと読みましょう・・・・

GitHubで編集を提案

Discussion

ログインするとコメントできます