🐕

【Vue3】俺のcomputed(() => x.value)の値が子コンポーネントに勝手にアップデートされるんだが【TypeScript】

に公開
追記: 🙅‍♂️->「`core.value`が子コンポーネントに更新される」 | 🙆‍♂️->「`core.value.value`が子コンポーネントに更新される」

追記概要

この記事は

  • core.valueが子コンポーネントに更新される」
    ではなく
  • core.value.valueが子コンポーネントに更新される」
    を意図して書いています!

誤解の防止のために、追記させていただきました🙌

おそらく上述の説明のみで十分かと思いますが、以下に詳細を記します。
通常はこの折り畳みを閉じてもらい、続きを読んでください。
もし後述の本編を読み、上述の追記の意味がわからなければ、説明の順序が前後しますが、下記の追記詳細を読んでください。

追記詳細

core.value.valuecore.value(= proxy.value)がまぎらわしく、誤解させることに気が付きました 🙏

意図としては後述では「core.valueが子コンポーネントに更新される」ということを言いたいわけではなく、「core.value.valueが子コンポーネントに更新される」ということです。

具体的には、以下のようなB.vueのupdateFooBarにより、A.vueのfooComputedごしに、foo.value.barが更新されるということです。

(追記なので、以下は実働を確認しておりません。失礼します!)

A.vue
<template>
  <p>foo.value is {{ foo.value }}</p>
  <!-- foo.value is { bar: 42 } --> <!-- クリックをしていない状態 -->
  <!-- foo.value is { bar: 52 } --> <!-- クリック1回目 -->
  <!-- foo.value is { bar: 62 } --> <!-- クリック1回目 -->
  <!-- ... -->
  <B :foo="fooComputed" />
</template>

<script setup lang="ts">
const foo = ref({ bar: 42 })
const fooComputed = computed(() => foo.value)
</script>
B.vue
<template>
  <button @click="updateFooBar">update</button>
</template>

<script setup lang="ts">
const { foo } = defineProps<{
  foo: { bar: number }
}>()
function updateFooBar() {
  foo.bar += 10
}
</script>

ここでの.vueと、後述の.vueの概念の対応としては、以下のようになります。

  • A.vue <-> HelloWorld.vue
  • B.vue <-> Child.vue

俺のcomputed(() => x.value)の値が子コンポーネントに勝手にアップデートされるんだが

  • type TT extends Record<string, unknown>
  • かつcomputed({ get, set })でなく、computed(() => x.value)の形だった(WritableComputedRefでなかった)

な場合の話。

余談だけど、T extends Objectみたいに、Object型は使ってないですよね?

結論

ComputedRefcomputedな値)のproxyと、Refcomputedの元になる値)のcoreを定義する側:

HelloWorld.vue
<template>
  <p>core.value is {{ core.value }}</p>
  <Child :proxy />
</template>

<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import Child from './Child.vue'

const core = ref({ value: 42 })
const proxy = computed(() => core.value)

// オブジェクトの受け渡しはシャローコピーなので、 core.value.valueは `<Child>` 経由で更新できる。
// ただしそのとき、core.valueが更新されたわけではないので、この`watch()`は実行されない。
watch(core, () => console.log('poi: core is updated'))
</script>

ComputedRefを更新する側(こちらはproxypropsとして(単なる値として)しか認識していないことに注意):

Child.vue
<template>
  <button @click="updateProxyValue">update</button>
</template>

<script setup lang="ts">
const { proxy } = defineProps<{
  proxy: { value: number };
}>();

function updateProxyValue() {
  proxy.value += 10;
}
</script>

これを防ぐ方法。

DeepReadonlyHelloWorld.vue
<template>
  <p>core.value is {{ core.value }}</p>
  <DeepReadonlyChild :proxy />
</template>

<script setup lang="ts">
import { ref, computed, readonly } from 'vue'
import DeepReadonlyChild from './DeepReadonlyChild.vue'

const core = ref({ value: 42 })
const proxy = computed(() => readonly(core.value)) // readonlyを追加
</script>
DeepReadonlyChild.vue
<template>
  <button @click="updateProxyValue">update</button>
</template>

<script setup lang="ts">
import type { DeepReadonly } from "vue"

const { proxy } = defineProps<{
  proxy: DeepReadonly<{ value: number }> // DeepReadonlyで期待
}>()

function updateProxyValue() {
  proxy.value += 10 // typecheck error !
}
</script>

用語の乱用

この記事では以降、わかりやすさを重視するため、慣習・口語での説明にならい、以下の用語の乱用を行います。

  • computed: const x = computed(/* ... */)のような変数x、もしくはcomputed関数そのもの
  • ref: const x = ref(/* ... */)のような変数x、もしくはref関数そのもの
  • props: 子コンポーネントのconst props = defineProps<{ /* ... */ }>()のような変数props

computedを子コンポーネントのpropsに渡しても、変更されないのか?

変更されます。

よく考えたら当たり前で

<Child :proxy />
const proxy = computed(() => core.value)

のような渡し方は、関数でいうシャローコピーの渡しになるからです。

(ソースを読んだ感想です)
const { proxy } = defineProps<{
  proxy: { value: number }
}>()

function updateProxyValue() {
  proxy.value += 10
}

ここでproxyはシャローコピーされた、const proxy = computed(() => core.value)とは別のオブジェクトですが、proxy.value(後者で言うproxy.value.value)は同じ参照先です。

ですので、updateProxyValueconst core = ref({ value: 42 })core.value.valueを更新するというわけですね。

結論

オブジェクト型のcomputedのプロパティは、子コンポーネントで更新され得ます。
それを防ぐためには、vuereadonlyDeepReadonly)を使いましょう。

DeepReadonlyHelloWorld.vue
<template>
  <p>core.value is {{ core.value }}</p>
  <DeepReadonlyChild :proxy />
</template>

<script setup lang="ts">
import { ref, computed, readonly } from 'vue'
import DeepReadonlyChild from './DeepReadonlyChild.vue'

const core = ref({ value: 42 })
const proxy = computed(() => readonly(core.value)) // readonlyを追加
</script>
DeepReadonlyChild.vue
<template>
  <button @click="updateProxyValue">update</button>
</template>

<script setup lang="ts">
import type { DeepReadonly } from "vue"

const { proxy } = defineProps<{
  proxy: DeepReadonly<{ value: number }> // DeepReadonlyで期待
}>()

function updateProxyValue() {
  proxy.value += 10 // typecheck error !
}
</script>

終わり!

Discussion