Katashin .info

TypeScript の恩恵を受けつつ Vue を使いたい その2 (Value オブジェクトを扱う)

せっかく型のある TypeScript を使うなら、なるべくプリミティブを使わずに Value オブジェクトを使いたいです。この記事では TypeScript + Vue で Value オブジェクトを扱うためにいろいろと考えたり、試したことを述べます。この記事に書かれていることは Vue 公式にはあまり推奨されない (と思われる) 方法なので、採用する場合には注意してください。

Value オブジェクトとは #

Value オブジェクトとは文字通り値を表すオブジェクトのことで、ドメイン駆動設計において用いられる概念です。例えば、料金を表す値を扱いたい時、プリミティブ型 (number) で表現するのではなく、Value オブジェクトとして Price クラスを定義します。Price クラスを作ることによって、単なる数値を表現するだけでなく、料金に関する処理を Price クラスに追加することができます。また、Price という型がつくことによって、料金が入ってほしい部分に料金以外の値が入ることをコンパイル時に防ぐことができます。

具体的には、以下のようなクラスを作ります。

class Price {
  // 数値はプライベートな値として持っておく
  constructor(private value: number) {}

  // 値の表示形式など、その値に関する責務を持たせる
  format() : string {
    return `¥${this.value}`;
  }

  valueOf() : number {
    return this.value;
  }
}

let price: Price = new Price(100);
price = 200; // コンパイルエラーになる。型をつけることで誤った種類の値が入ることを防げる

Vue の VM に渡すデータはプレーンなオブジェクトでなければならない? #

Vue の VM に定義した Value オブジェクトを監視させたいのですが、VM にはプレーンなネイティブオブジェクトを渡す必要があると公式のドキュメントに書いてあります。プレーンなオブジェクトとはおそらく、Object リテラルで作るようなオブジェクト ({ hoge: 'fuga' } みたいな) を指すのだと思いますが、そうなると、Vue で Value オブジェクトを扱えるのかどうかが疑問になります。

なぜプレーンなオブジェクトである必要があると言われているのか #

Vue の issue を眺めていると、getter で取得できる値が監視されないと報告されているものを見かけます。Vue は監視対象のオブジェクトの getter, setter を書き換えて値の監視を実現しているので、元々 getter が設定されているものは監視することができません。また、ソースコードを読んでみると、オブジェクトのメンバが列挙可能でないと監視対象にならないのもわかります。これらは Vue の設計上どうしようもないことなので、これらのケースには対応しないという意思表示のために、プレーンなオブジェクトを渡すように書かれているのだろうと思います。

変更の監視が行われるケース #

まず、重要なのが、オブジェクトの構造がなんであれ、そのオブジェクト全体を入れ替えてしまえば、値の変更が検知されるという点です。よって、どうしても変更の監視をさせたいオブジェクトがあるときは、とりあえず更新があるときに全体を入れ替えるようにするとうまくいきそうです。

具体的なコードは以下のようになります。

class Test {
  constructor(private value: string) {}

  // getter なので Vue に監視されない
  get text() : string {
    return `Test ${this.value}`;
  }
}

const vm = new Vue({
  template: '<div></div>', // getter を指定している
  data: {
    test: new Test('Text1')
  }
})

vm.$mount('#app'); // Test Text1 と表示される

vm.test = new Test('Text2'); // test 全体を入れ替えたので変更が検知され、
                             // 表示が Test Text2 に更新される

また、getter で得られる値や列挙不可能な値でなければ監視できるのではないかと思います。先に書いた Price クラスのインスタンスを VM の data に渡すと、value プロパティが Vue によって置き換えられて、監視されているのがわかります。

Value オブジェクトとして不変かつ拡張不可なオブジェクトを作って Vue で扱う #

上で述べたように、Value オブジェクト自体はプレーンなオブジェクトではないので、これを監視するのは公式には推奨されていません。しかし、オブジェクトの変更ではなく、そのオブジェクト全体を入れ替える時は、オブジェクトの中身がどのようになっていても変更を検知できます。

よって、Value オブジェクトを変更不可にし、値を変更する時はその都度新しい Value オブジェクトを代入し直すようにすれば、Vue でも Value オブジェクトを扱えそうです。Value オブジェクトは状態を持たず不変であるべきなので、この制約によって実装がつらくなることはないかと思います。

また、Value オブジェクト内に列挙可能なプロパティが存在する場合、それが Vue によって監視されますが、Value オブジェクト内のプロパティを監視するのはあまり意味が無いのと、Value オブジェクトが書き換わるのは気持ち悪いのでこれを防ぎたいと思います。Vue の Observer のコードを読むと、オブジェクトが拡張不可能である場合はそれ以降の監視をしないようになっている (Object.isExtensible(value) が false になる時は監視をしない) ので、Object.seal などで Value オブジェクトを不変にすると良さそうです。もしくは、Vue は toString でプレーンなオブジェクトかどうかを判断し、そうでない場合は監視を行わないようになっているので、文字列にした時の表現方法が決まっているのであれば toString をオーバーライドしてしまうのも良いかもしれません。ただ、こちらは isExtensible よりも Vue の実装の細かな部分に依存してしまうように思うので、あまりやらないほうが良いかもしれません。

上記を実装すると、以下の様なコードになります。

// Value オブジェクト
class Price {
  constructor(private value: number) {
    // Object.seal で Vue がこのオブジェクトを書きかえるのを防ぐ
    Object.seal(this);
  }

  format() : string {
    return `¥${this.value}`;
  }

  valueOf() : number {
    return this.value;
  }
}

// VM で使う例
const vm = new Vue({
  el: '#app',
  data: {
    // 100円を表す Value オブジェクトを監視させる
    price: new Price(100)
  }
});

// 200円に更新する
// Value オブジェクトそのものを更新すれば値の変更を検知できる
vm.price = new Price(200);

独自のデータ形式も Vue で扱える? #

Value オブジェクトだけでなく、独自のデータ構造を扱う時でも上記を知っていると役に立ちそうです。例えば、ES2015 の Map の中の値の変更は Vue で監視することができませんが、値の更新時に新たな Map を返す不変な Map を独自に定義すると、Vue でも Map を扱うことができるようになります。ただし、対象とするデータが大きくなるほど、更新の量が多くなり、パフォーマンスが悪化するので注意して使う必要はありそうです。

まとめ #

Vue にオブジェクトを渡すと、それが拡張可能であるときに、getter ではないプロパティ、かつ、列挙可能なプロパティが監視対象となります。Value オブジェクトはプレーンなオブジェクトではないので、公式にはこれを監視させることは推奨されませんが、Value オブジェクトを不変にし、値の更新の度に全体を入れ替えることで、Vue でも変更の監視が可能になります。同様に独自のデータ形式も Vue で扱うことができますが、パフォーマンスが悪化する可能性があるため、注意して使う必要がありそうです。