🤕

Vue3でTS使ってもクラスなんか使うなよ、という話

2023/10/29に公開
1

導入:Vue3がTSと相性良いというのでクラス使ったら絶望した

どうもお世話になっております、絶望しているたぬき教祖です。
最近Nuxt3を本格的に使い始め、TSとクラスを使い始めたら心が折れそうになり、諸々調査したので共有です。
Vue3 × TS でクラスを使用していて「あれ?リアクティブにならない」「ん?画面更新が一手遅れる」と思ったらご覧ください。

Vue3とTypeScriptはまだまだ知識不足なところがありますので、誤り等ありましたら指摘してください。

問題提起

Vue3×TSでクラスを使用していたら、当然クラス(のインスタンス)をリアクティブに利用したい場面があると思います。

let instance = ref(new Class());

まずこの時点で、refとするか、reactiveとするか、それともobjectにするかの選択肢があります。

当然クラスの定義も必要です。例えば

class Test() {
    field: any = {
        name: "test"
    }
}

取り合えず"any"にしてますがまあ気にしないでください。
この時、このfieldをrefとするか、reactiveとするか、それともobject(クラスは実質的にはオブジェクト)にするか、ここでも3つの選択肢があります。

つまり、クラスをリアクティブに扱いたい場合、3×3で都合9種類(object × object を除けば8種類)の定義方法があります。
問題は、そのそれぞれに特徴があり用い方が異なることと、最適解となるものがない事です。

根本問題

ここには予想的内容を含みますが、こうなってしまう原因を考えると以下のようなものがあるでしょう。

  • そもそもrefとreactiveの2種類あるのが煩わしい
  • TSは実行時はJSになる、JSには"TSのような"クラスは無く、あくまでクラスライクな動作しかできない
  • ref(new Class())する際、new Class()時には、まだリアクティブでない
  • つまり、コンストラクタで登録した操作(watchなど)の操作はリアクティブでない
  • フィールドは宣言時リアクティブでなくともref時に強制的にリアクティブにされる
  • ref以外の宣言ではリアクティビティのリンクが切れる

*今回はクラスのプロパティ(変数)はフィールドと呼びます。

ここらで答え的な物

ちょっと奥さんこれを見てくださいこれ。
かなり大変でしたが、まあこれが答えのようなものです。

端的に答え

上の図を見るのもめんどくさいという方に、「最適解」と呼べるものはない、と言いましたが、準最適解的な物はあります。図の左に〇つけてますが、色々考えるのがめんどくさい、という方は、取り合えず
「ref」×「reactive」
を使用してください。
「ref」×「reactive」とは以下のような形式です。

class Test {
    field: any = reactive({
        name: "test",
    });
}

let test = ref(new Test());

ポイントとして コンストラクタで触るクラスフィールドはreactiveにして ください!

9種類見ていこう

さて、それではここで、3×3の9種類について、それぞれ考察しましょう。
〇〇 × □□ と書きますが、〇〇はsetup(コンポーネント、script)での宣言、□□はクラス内でのフィールドの宣言方法です。

object × object

当たり前ですがリアクティブにはなりません。終わり。

object × ref

構造としては割とすっきりします。
ただし、テンプレートで変な挙動をします。

<script setup lang="ts">
class Test() {
    field: string = "test";
}
</script>
<template>
    {{ test.field.value }}
</template>

お気づきと思いますが、テンプレート内にもかかわらず、".value"が必要になります。
valueを付けない場合、以下のようにv-modelではobject、出力としては"{{内容}}"という形になります。

また以下のように、一見リアクティブでないにもかかわらず、.valueを用いた記述が必要になり、管理が煩雑になります。

let test = new Test();
test.name.value = "update";

〇 object × reactive

この方式は一見問題は無く、実際特に大きな問題はありません。
ですが、reactiveを使う特性上、フィールドをobjectにする必要があります。
また、大本がreactiveで宣言されているため、以下のように変数を再定義した場合にはリアクティビティが失われます。

let test = reactive(new Test());
test = reactive(new Test());//再定義 ここでリアクティブなリンクが途切れる

とはいえ、再定義しない変数であれば大きな問題は無いため「〇」、準お勧めです。

ref × object

このケースは、クラス内部のフィードは全て通常通りの宣言をし、インスタンス全体を後からリアクティブにする方法です。
問題は先にあげた通り、「ref(new Class())する際、new Class()時にはインスタンス及びフィールドはまだリアクティブでない」ことです。
例えば以下のケース、コンストラクタで設定したwatchは上手く動作しません。この時点ではまだfieldがリアクティブでないためです。また、watch内で他のフィールドを操作した場合も、この時点ではそのフィールドもリアクティブでないため、画面に反映されません。

class Test {
    field: any = {
        name: 
    };
    constructor() {//こちらはリアクティブでない
        watch(field, () => {
	    console.log("changed");
	});
    }
    constr() {//こちらは上手くいく
        watch(field, () => {
	    console.log("changed");
	});
    }
}
let test = ref(new Test());
test.constr();

逆に、フィールドがリアクティブになるのを待って(上記test.constr()のように)watchを付与した場合は正常に動作します。操作が煩雑になるのでお勧めしませんが。
コンストラクタでは特に操作しない、という場合には使ってもよいと思います。

ref × ref

このパターンは実質的には大きな問題はありませんが、構造的にはかなり問題があり、下手すると意味不明な挙動になります。
まずもって、refの中ではrefは存在できません。最初にrefで宣言されたフィールドはref(new Test())時にreactiveになります。
つまり、refで宣言しているのに".value"を付けて参照しない、ということになります。

class Test {
    field: string = ref("test");//refで宣言しているが
    some() {
        this.field = "update";//.valueはつけない
    }
}
let test = ref(new Test());
test.value.some();//ここでvalueがついている

上記のパターンであれば、誤ってvalueを付けてもエラーで止まるので問題ありません。
しかし、コンストラクタではまだあくまでもrefの状態です。この状態で誤ってvalueを付けずに代入したりしてしまうと,,,
場合によっては悲惨なことになります。

◎ ref × reactive

これが一応最もお勧めのパターンです。
外部的にはrefで".value"を付けるのにも納得がありますし、再定義してもリアクティビティが保たれます。
また、クラス内部では見た目も実質もreactiveなので直感的で、new Test()宣言時に既にフィールドはreactiveであり、watchもできます。
ただ、このパターンでも最適ではないと考える点には以下の理由があります。

  • reactiveを二重に宣言していて無駄がある
  • reactiveで宣言していないフィールドも実質的にはreactiveである
  • reactiveで宣言するためにはObjectにする必要がある

結局、このパターンを、構造をちゃんと理解したうえで使うのが良いかなあと思います。

reactive × object

ref × objectのパターンと同じく、「ref(new Class())する際、new Class()時にはインスタンス及びフィールドはまだリアクティブでない」問題があります。
コンストラクタで設定したwatchは上手く動作しません。

reactive × ref

ref × refのパターンと同じく、refで宣言しているのに".value"を付けて参照しない、という問題があります。

〇 reactive × reactive

object × reactiveのパターンと同様、一見問題は無く、実際特に大きな問題はありませんが、reactiveを使う特性上、フィールドをobjectにする必要があります。
また、大本がreactiveで宣言されているため、変数を再定義した場合にはリアクティビティが失われます。

object × reactiveと比較して、reactive宣言が重複するという欠点がありますが、外部でも明示的にリアクティブであるという利点があります。

再定義しない変数であれば大きな問題は無いため「〇」、準お勧めです。

終わりに

refかreactiveか問題

Vue3を使用する際、refを使うべきかreactiveを使うべきか、という問題はしばしば議論されます。
その議論の多くが、refを使っとけばまあいいんじゃない?という結論に落ち着きますが、
refには、2重に重ねられない、という特性があります。
Classのフィールドように、リアクテビティが重複する可能性が高い要素については、reactiveの出番かもしれません。

参考:
https://qiita.com/Yametaro/items/2a37f18fb52f7565b2cb
https://zenn.dev/azukiazusa/articles/ref-vs-article

議論の話

Vue3でクラスを使用する話、は世界的にはたまに議題に上がるようですが、日本ではあまり取り上げられておりませんでした。日本ではVue3は浸透しなかったのか、プログラマが少ないのか、TSを使わないのか、クラスを使わないのか、どんな感じなんですかね。

参考:
https://stackoverflow.com/questions/68149695/vue3-reactivity-inside-a-class
https://devpress.csdn.net/vue/6316c12826059229d1c83b33.html

まあ、TS使うぞクラス使うぞ、という所謂商業的な?プログラマというのは、ほとんどReactを使っているというのが実態とは思いますが。うーん、私は思想はVue2時代が一番好きです。

今回思ったのも easy is simple ということです。

簡単だからこそコードの見通しが良くなりバグが減る、と思います。例えばTSでは型の定義が必要ですが、その為に様々な警告がでます。それを回避するために様々な処理やキャストを含めます。そうすると全体が膨大になり煩雑になり、逆にバグが増えているように感じます。
勿論、ライブラリなどはTSで作成されているほうがありがたいです。大規模な開発でもTSが良いでしょう。ただし、個人、或いは少数精鋭型のプロジェクトではどうでしょうか。
有名なライブラリでも、圧倒的優秀な個人の開発によるものはかなり多いと思います。
officeやら会計システムやら半端ない規模のアプリでもなければ、ほとんどの仕組みは個人開発でeasyを目指すのが良いと思わなくもない今日この頃。
知らんけど。

自動リアクテビティの限界か

にわかに話題沸騰中?のNue.js、或いはその参考?になっているRiot.jsはリアクティブではないようです(使ったことはない)。
それを最初きいたときには「えー自動じゃないの、めんどくさい」と思いましたが、今回の様な件を経験すると、或いはWEB上の様々な記事を見ると、「リアクティブ」が諸悪の根源であるようにも感じます。自動で更新したいがために、ことあるごとに再計算を行ったり、或いはその回避策を講じたり、、、
であれば、もはや自動更新は捨てて、任意のタイミングで画面を更新できるようになったほうが寧ろeasy & simple、なのではと思ってしまいます。
風当たりは割と強そうですが、私は意外と期待しています、Nue.js

参考:https://blog.webgoto.net/969/

コメントを頂きました

Zennでコメントを頂いたのは初めてかもしれません、これ、どのような返信でも失礼に当たりそうで怖いと思いました。
多分おっしゃっていることが違うかなあ、と思うのでコメントは非表示にしてここで補足をしたいと思います。

クラスは避けたほうが良い

「Vue3でTS使ってもクラスなんか使うなよ、という話」のタイトルに示す通り、面倒ごとを避けたい方にお勧めするものではありません。
クラスを使うと大変だ、それでも使うならこうだ、という内容です。

バージョンアップで動かなくなるかも

TSでクラスインスタンスとして扱っているものの実態はJSのオブジェクトと思われます。
Vue3がオブジェクトに対してリアクティブを提供している以上、Vueの仕様変更でこれが動かなくなる可能性はまずないと考えています。

composable function のほうがリアクティブにするのに効率的である

クラスインスタンスをまとめてリアクティブにすることでフィールドそれぞれを手動でリアクティブにする必要はなくなります。
「composable」では返す値は全てrefにすることが推奨されていたと思います。変数を一つ一つリアクティブにする必要があるのは寧ろ「composable」であって、「composable」の利点はあくまでもカプセル化して再利用に有利にすることと、値を共有することではないでしょうか。

ただし、クラスにしてしまうと定義時に扱えるのは静的な値となってしまいます。初期状態に用意するクラスで、コンストラクタで動的な値を用いたい場合は、「composable」に内包し、「composable」内で定義するほうが良いと思われます。

ざっくりいうと例えば、

class/A.ts
class A {
    name: string = "";
    old: number = 20;
}
class AS {
    _as: A[] = [];
    newA: A = new A();
}

より、

composables/A.ts
class A {
    name: string = "";
    old: number = 20;
}
export const useA = () => {
    let _as: Ref<A[]> = useState("_as", () => []);
    let newA: Ref<A> = useState("newA", () => new A());
    return {_as, newA};
}

の方が、諸々便利になると思います。
まあ、この書き方はNuxt3限定なので、移植まで考えると必ずしも優位ではないですが。

Discussion

3bc3bc

TSは実行時はJSになる、JSには"TSのような"クラスは無く、あくまでクラスライクな動作しかできない

ES6でclass定義自体はJS側でできるようになっているので、クラスで定義する場合の話はTSに限らず、JSでも同じ話なのかなと思いました。
https://caniuse.com/es6-class

JS側でも基本的には昔のプロトタイプによるクラスっぽい挙動をさせる書き方のシンタックスシュガーでしかないので、クラスライクな動作であることはその通りかと思います。