タグ別アーカイブ: TypeScript

Vue のテンプレートの型チェックについて

静的型が好きな人と話していると大体テンプレートの型をチェックしたいという話を聞くのですが、Vue には今のところそれをうまく行う方法はありません。

すこし前に Vue のテンプレートの型チェックについて LT したのですが、これは vue-class-component などの Vue 標準の API から離れた書き方を強制するのでちょっと微妙な感じでした。これは、以前は Vue のコンポーネントの this の型を得るためには、クラス構文を使う必要があったためです。

しかし、TypeScript v2.3 に導入された ThisType によって、オブジェクトリテラル内部のメソッドの this の型推論が行えるようになったのと、 Vue v2.5 から、TypeScript の型定義が大きく改善されたことで、Vue 標準の API を使っても this の型をうまく得ることができるようになりました。

良い機会なので、上記の LT をした時に作ったものを更新して、標準の API でもテンプレートの型チェックをできるようにしてみました。

  • typed-vue-template – テンプレートを TypeScript として script ブロックに挿入する実装部分
  • typed-vue-loader – typed-vue-template を webpack loader のインターフェースとして提供してるもの

typed-vue-loader をクローンして、npm i && npm run example:dev した後に、example ディレクトリのソースを編集すると動作がわかると思います。

ただし、この実装ではコンパイル時に型チェックができるだけであり、エラーが発生している場所はわからないですし、エディタ上で型情報を利用した補完機能を利用するということはできないです。そのあたりを頑張ろうとして作ったのが以下です。

vue-template-diagnostic

vue-template-diagnostic の発想は Angular と同じで、自分で型チェッカーを作ってしまおうというものです。しかし、やはり型チェッカーを再実装するのは結構つらいのと、Vue のテンプレート内部の式はほぼ JavaScript と互換性を持っているので、うまいこと TypeScript で処理させたほうが良さそうだなーと感じ、開発は止まってます。

エラーの場所がわからないということに目をつぶれば、typed-vue-template でやっていることを AST に対して行う Language Service を作れば、テンプレートの型チェック処理を TypeScript に丸投げしつつエディタからその情報を取れそうな気がするので、次はそれを試してみようかなと考えてます。

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>{{ test.text }}</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 で扱うことができますが、パフォーマンスが悪化する可能性があるため、注意して使う必要がありそうです。

TypeScript の恩恵を受けつつ Vue を使いたい その1

最近 TypeScript の恩恵を受けつつ Vue を使うためにいろいろと試行錯誤しています。この記事ではコンポーネントの定義と、コンポーネント内のロジックを再利用可能にするための Mixin の定義を TypeScript でどのように書けばよいかを述べます。

vue-class-component を使う

TypeScript で Vue を使う時は vue-class-component はほぼ必須だと思います。なぜなら、Vue のコンポーネントの生成の仕方では、TypeScript コンパイラがコンポーネントの型を解釈することが難しいからです。

例えば、コンポーネントの生成を行うコードは以下のように書けますが、reverseMessage メソッドの中では、this がそのコンポーネントであることがわからないですし、this.message が存在していることもわかりません。

const App = Vue.extend({
  data() {
    return {
      message: 'Hello Vue.js!'
    };
  },
  methods: {
    reverseMessage() {
      this.message = this.message.split('').reverse().join('');
    }
  }
});

const vm = new App({ el: '#app' });

上記のコードを vue-class-component を用いて書き換えると以下のようになります。

import Component from 'vue-class-component';

@Component
class App extends Vue {
  message: string;

  data() {
    return {
      message: 'Hello Vue.js!'
    };
  }

  reverseMessage() {
    this.message = this.message.split('').reverse().join('');
  }
}

const vm = new App({ el: '#app' });

vue-class-component を使用すると、class 構文を使ってコンポーネントを生成できるため、先に述べた、this やそれに生えているプロパティの型がわからないという問題が解消されます。

Vue の Mixin を活用して、責務を分割する

Vue には mixins オプションがあり、あらかじめ定義しておいた機能を一つのコンポーネントに継承する事ができます。これを利用することで、コンポーネントから機能を細かく分割することができます。

例えば、バリデーションのロジックは特定のコンポーネントに持たせるよりは、Mixin にして、再利用可能にするのが良いでしょう。また、同じ機能でも、デザインの都合でコンポーネントを再利用できない場合に、ロジックの部分のみを Mixin にして再利用できるようにするのも良いと思います。

Mixin を作る

Mixin はコンポーネントと同様の書き方で書きます。コンポーネントを作る時と同じように、持たせたいメソッドや、プロパティの定義などを vue-class-component の書き方で書いていきます。

// ユーザー情報の入力値をバリデーションする機能を持たせる Mixin
@Component
class UserValidationMixin extends Vue {
  validateName(name: string) : boolean {
    return name.test(/^[0-9a-zA-Z]+$/);
  }
}

// ユーザー情報の入力を親に通知する機能を持たせる Mixin
@Component({
  props: {
    onChangeName: {
      type: Function,
      required: true
    }
  }
})
class UserUpdateMixin extends Vue {
  onChangeName:  (name: string) => void;
}

Mixin を使うコンポーネントに Mixin の型を持たせる

Mixin を利用するには、コンポーネントの mixins オプションに、Mixin のコンストラクタを渡せば良いです。また、TypeScript に Mixin で定義したプロパティやメソッドを認識させたい時は、Mixin をインタフェースとして渡せば良いです。

以下の例では、先に定義した UserValidationMixinUserUpdateMixin を利用したコンポーネントを定義しています。

@Component({
  // Mixin を適用する
  mixins: [
    UserValidationMixin,
    UserUpdateMixin
  ],

  props: {
    user: {
      type: Object,
      required: true
    }
  },

  template: `
  <form action="#" method="post">
    <input
      type="text"
      name="name"
      :data-error="nameError"
      :value="user.name"
      @input="handleInputName">
  </form>`
})
class UserForm 
    extends Vue
    implements UserValidationMixin, UserUpdateMixin {

  user: IUser;

  validateName: (name: string) => boolean;

  onChangeName: (name: string) => void;

  get nameError() : boolean {
    return this.validateName(this.user.name);
  }

  handleInputName(event: Event) {
    this.onChangeName(event.target.value);
  }
}

Vue v1.0.24 以下での注意点

現在の Vue のバージョン (v1.0.24) では、Vue のコンストラクタを mixins オプションに指定することはできませんが、それを可能にするパッチがマージされたので、次のバージョンでは上の書き方が可能になると思います。

現在のバージョンでこの記事の書き方を実践するには、以下の様な、vue-class-component のラッパーを作ると良いです。

import originalDecorator from 'vue-class-component';

// vue-class-component のデコレータをラップする
function Component(options) {
  if (typeof options === 'function') {
    return originalDecorator(options);
  }
  return function(target) {
    const mixins = options.mixins || [];

    // Vue のコンストラクタは自身が生成された時の options を保持している
    options.mixins = mixins.map(m => m.options);

    return originalDecorator(options)(target);
  };
}

// vue-class-component のように使える
@Component({
  mixins: [MixinA, MixinB]
})
class SomeComponent extends Vue implements MixinA, MixinB {}

つらい部分

この記事のように書くことで、TypeScript で Vue を使うのはかなり良い感じになったのですが、まだまだつらい部分があります。

Mixin の継承先に型を書くこと

Mixin で定義したプロパティやメソッドの型を、継承先のコンポーネントで改めて書かないといけないのがとてもつらいです。TypeScript 自体が Mixin 構文をサポートすればこのつらさもなくなるような気がしますが、TypeScript 公式でこのめんどくさい Mixin のやり方を紹介しているので望みは薄いような気も……。

ただ、ECMAScript の class 式を使えば Mixin っぽいことができるので、この辺の書き方がもっと楽に書けるようになれば良いような気もします。

View の方はまったく型に守られないこと

当たり前なのですが、コンポーネントの template の内部では型が合っているかまったくチェックされません。なので、template の中からメソッドを呼び出すときに、そのメソッドが本当に存在するのか、引数の型が合っているのか、などは人間が注意して書く必要があります。この辺りもちゃんとやりたいなら React とか別のライブラリを使えという話になると思います。

まとめ

TypeScript の利点を受けつつ Vue を使うためには、vue-class-component を使うと良いです。また、コンポーネント内の機能を再利用可能にするために、Vue の Mixin を活用するのも良いでしょう。Mixin を使う時は、mixins オプションに Mixin のコンストラクタを渡すとともに、コンポーネントの宣言時に implements で Mixin を渡すことで、Mixin の型も TypeScript に認識させることができるようになります。