Stockmark Tech Blog

自然言語処理テクノロジーで社会を進化させる ストックマークのテックブログです。

なぜ分割代入をすると Vue は reactive ではなくなるのか

こんにちは。 Anews の開発に携わっている Engineer の 羽柴 と申します。

Anews はフロントエンドを Vue で開発しています。
自分自身の背景として、Stockmarkに入社するまでは React を使って開発していたので Vue の経験は殆どない状態でした。
そこで理解を深めるために chibivue を使った勉強会を社内で進めています。
その時に気づいたことを share したいなと思い、この記事を書きました。

ja.vuejs.org

chibivueとは

ubugeeei.github.io

Vue を最少単位で作ってみようという project です。
浅い理解でよければ公式 docs をさらっと読んで実装するのが早いのですが、深い理解をしたい場合は車輪の再発明がとてもマッチしますよね。

step by step でコードを書きながら進められますし、discord も運営されており質問や discution できるのもとても嬉しいです。

Vue を書き始めた感想

なんだかんだ React を書いてきたのでかけるっしょ!という舐めたスタンスでしたが、挙動の違いに驚かされました。
特に reactive に関する挙動が全然違いますね。

例えば React では props として渡した値が変わると、子コンポーネント以下のコンポーネントはすべて再レンダリングが走ります。
しかし、Vue では明示的に reactive に指定した値のみ書き変わります。

import React, { useState } from "react";

const GrandChild = () => {
  console.log("render GrandChild");
  return <></>;
};

const Child = ({ count }) => {
  console.log("render Child");

  return (
    <div>
      <span>{count}</span>
      <GrandChild />
    </div>
  );
};

const Parent = () => {
  console.log("render Parent");

  const [count, setCount] = useState(0);

  const addCount = () => {
    setCount(count + 1);
  };

  return (
    <div>
      React
      <div>
        <button onClick={addCount}>
          <Child count={count} />
        </button>
      </div>
    </div>
  );
};

code sandbox

// Parent.vue
<template>
  <button @click="addCount"><Child :count="count" /></button>
</template>

<script setup lang="ts">
import { ref } from "vue";
import Child from "./Child.vue";

const count = ref(0);
const addCount = () => {
  count.value = count.value + 1;
};
console.log("render Parent");
</script>

// Child.vue
<template>
  <span>{{ count }}</span>
  <GrandChild />
</template>

<script setup lang="ts">
import { ref } from "vue";
import GrandChild from "./GrandChild.vue";

defineProps<{ count: number }>();
console.log("render Child");
</script>

<template></template>

// GrandChild.vue
<script setup lang="ts">
console.log("render GrandChild");
</script>

code sandbox

chibivue の勉強会を進めて

そんなギャップを感じながら、chibivue を進めていきました。
すると、わかるわかる。Vue はこのようにして動いているのか!!!!!!
Vue に対する理解度が深まっていく一方でふと疑問に思うことがありました。

「なぜ分割代入をすると Vue は reactive ではなくなるのか」

これを理解しようとすると、Vue がどのようにして reactive を実現しているのかということを知る必要があります。

結論から書くと Vue は reactive を実現するために Proxy を使っています。

function createReactiveObject(
  target: Target,
  isReadonly: boolean,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>,
  proxyMap: WeakMap<Target, any>,
) {
  if (!isObject(target)) {
    if (__DEV__) {
      warn(`value cannot be made reactive: ${String(target)}`)
    }
    return target
  }
  // 中略
  const proxy = new Proxy(
    target,
    targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers,
  )

https://github.com/vuejs/core/blob/530d9ec5f69a39246314183d942d37986c01dc46/packages/reactivity/src/reactive.ts#L273C1-L274C1

Proxyについて

developer.mozilla.org

Proxy オブジェクトにより別なオブジェクトのプロキシーを作成することができ、そのオブジェクトの基本的な操作に介入したり再定義したりすることができます。(引用)

js の標準組み込みオブジェクトです。ざっくり書くと対象の object に対して特定の処理をトラップすることができます。

こんな感じです↓

const target = {
  name: 'John',
  age: 30
};

const handler = {
  get: function(target, property, receiver) {
    console.log(`Getting property: ${property}`);
    return target[property];
  },
  set: function(target, property, value, receiver) {
    console.log(`Setting property: ${property} = ${value}`);
    target[property] = value;
    return true;
  }
};

const proxy = new Proxy(target, handler);

console.log(proxy.name);
// 出力:
// Getting property: name
// John

proxy.age = 31;
// 出力:
// Setting property: age = 31

console.log(proxy.age);
// 出力:
// Getting property: age
// 31

といった形です。

特定の処理という部分を objectに対する書き込み と読み変えてください。
Vueは objectに対する書き込み に対してupdate component の処理をトラップしています。

proxyについてもう少し知りたいた方へ

わかりやすい↓日本語記事。

ja.javascript.info

また、トラップできる処理として [[Get]] といった記載はありますが、これはobject internal method というものです。
簡易的には object.hoge という認識でいいと思いますが、詳しく知りたい方は↓こちらで

tc39.es

でもなんで

ここまでのざっくり理解として

  • Vue は reactive を実現するために Proxy を使っている
  • Proxy は target object の特定の処理をトラップできる
  • 具体的には書き込みがあった際に component を update する

ということがわかりました。

ではなぜ分割代入してしまうと、reactive ではなくなってしまうのでしょうか?

それは js の特性である、参照渡しと値渡しの違いが関係しています。
(厳密に言うと参照渡しなど存在しないみたいな話は今回しません)
そもそも分割代入が何をしているのかという話ですが、元となる object の中の値か参照を、新しい変数に bind しています。
そして、js は値を代入した場合は値を渡して、object を渡した場合は参照を渡します。

具体例(参照渡しと値渡し)

// プリミティブ値の例(値渡し)
let originalNumber = 10;
let copiedNumber = originalNumber;

console.log(originalNumber);    // 出力: 10
console.log(copiedNumber);      // 出力: 10

copiedNumber = 20;

console.log(originalNumber);    // 出力: 10
console.log(copiedNumber);      // 出力: 20

// オブジェクトの例(参照渡し)
let originalObject = { value: 10 };
let copiedObject = originalObject;

console.log(originalObject.value);    // 出力: 10
console.log(copiedObject.value);      // 出力: 10

copiedObject.value = 20;

console.log(originalObject.value);    // 出力: 20
console.log(copiedObject.value);      // 出力: 20

つまり↓のように分割代入してしまうと Proxy によって作られた( = update 処理がトラップされている)object にアクセスしているのではなく、値を直接操作しているため書き換わらないんですね。

<script setup>
import { ref } from "vue";

const obj = {
  num: 0
};
const reactiveObj = ref(obj);
let { num } = reactiveObj.value;


const onClick = () => {
  num += 1;
  console.log(reactiveObj, num)
  // output1回目: _value: Proxy(Object) {num: 0}, 1
  // output2回目: _value: Proxy(Object) {num: 0}, 2
  // output3回目: _value: Proxy(Object) {num: 0}, 3
  // ...
};
</script>

<template>
  <p>{{ num }}</p>
  <button @click="onClick">+1</button>
</template>

完全に余談ですが、分割代入しても reactive を保持する方法が2つあります。

①ネストする

例えば以下のような書き方であれば、値の変更が画面に反映されます。

<script setup>
import { ref } from "vue";

const obj = {
  nest: {
    num: 0,
  },
};
const reactiveObj = ref(obj);
let { nest } = reactiveObj.value;

const onClick = () => {
  nest.num += 1;
  console.log(nest)
  // output1回目: Proxy(Object) {num: 1}
  // output2回目: Proxy(Object) {num: 2}
  // output3回目: Proxy(Object) {num: 3}
  // ...
};
</script>

<template>
  <p>{{ nest.num }}</p>
  <button @click="onClick">+1</button>
</template>

これは reactive に変換されるときに object は再帰的に Proxy でラップされる為ですね。

②toRefs を使う

<script setup>
import { ref, toRefs } from "vue";

const obj = {
  num: 0,
};
const reactiveObj = ref(obj);
const { num } = toRefs(reactiveObj.value);

const onClick = () => {
  num.value = num.value + 1;
  console.log(num, reactiveObj.value);
};
</script>

<template>
  <p>{{ num }}</p>
  <button @click="onClick">+1</button>
</template>

こちらは、num に Proxy object を代入している為 update されます。

まとめ

Q: なぜ分割代入をすると Vue は reactive ではなくなる(ことがある)のか

A: Vue は値を reactive にするために Proxy を使っていて、分割代入によって Proxy から作られた object ではない値を参照 or 書き込みしている為

余談

ちなみにではありますが、Vue で structuredClone を使うと error が表示される時があります。 これは Proxy object を使用しているためですね。

developer.mozilla.org

↑にも書かれているとおり、Proxy は structuredClone の対象ではないため

DOMException: Failed to execute 'structuredClone' on 'Window': #<Object> could not be cloned.

と表示されます。

これに対応するには toRaw を使い Proxy に wrap される前の object を clone するのが良いと思われます。

最後に

最後までお読みいただきありがとうございます。 僕と同じく Vue の理解を深めたい!という方には chibivue はとてもお勧めできる内容でした。 Stockmark社では一緒にプロダクトと組織を成長させていただける方を募集していますので、カジュアル面談 からお気軽にご連絡ください。