TypeScriptでVueコンポーネントを開発する方法

はじめに

今回は、TypeScriptでVue.jsのコンポーネント(以下 Vueコンポーネント)を開発する方法について紹介したいと思います。

JavaScriptとVueコンポーネント

今から作成するコードは、SFCというブラウザが理解できない構造のファイルが含まれており、言語もJavaScriptではありません。ブラウザでデバッグするときに見えるコードは、ブラウザが実際に使用するコードではなく、ソースマップが再構成したコードです。

SFC(Single File Component)は、Vueが推奨するVueコンポーネント専用のファイル形式で、ファイルの中にテンプレート、JavaScript、CSSを定義します。Vueは、開発者がコンポーネントを開発する際、クラスを定義するというよりは、クラスを作成できるオプションを定義する形式で開発します。

<template>
  <div>
    <input type="text" v-model="newTodo" @keyup.enter="onEnter">
    <ul>
      <li v-for="todo in todos" :key="todo">{{todo}}</li>
    </ul>
  </div>
</template>
<script>
export default {
  data() {
    return {
      todos: ['TASK1'],
      newTodo: ''
    };
  },
  methods: {
    onEnter(ev) {
      this.addTodo(this.newTodo);
    },
    addTodo(title) {
      this.todos.push(title);
    }
  }
};
</script>

簡単なTodoリストのコンポーネントを実装してみました。Todoリストを持っているデータは、todosという配列です。配列内にstring型にして保存します。テンプレートでは、todosを巡回してli要素でTodoリストを表します。インプットボックスに新しいTodoを入力してEnterキーを押すと、todos配列を更新します。ここにTypeScriptを適用してみましょう。

Vue.extend

TypeScriptをVueコンポーネントに適用するには、2つの方法があります。Vue.extendを利用してオブジェクトとして作成する方法と、クラスにする方法があります。長短を比較するため、コンポーネントの性質に応じた区分を設けて両方使ってみましょう。まず、Vue.extendを試してみます。Vue.extendでコンポーネントを定義する方法は、TypeScriptを使用しないときのコンポーネントとほぼ同じです。

<template>
  <div>
    <input type="text" v-model="newTodo" @keyup.enter="onEnter">
    <ul>
      <li v-for="todo in todos" :key="todo">{{todo}}</li>
    </ul>
  </div>
</template>
<script lang="ts">
import Vue from 'vue';

export default Vue.extend({
  data() {
    return {
      todos: ['TASK1'],
      newTodo: ''
    };
  },
  methods: {
    onEnter(ev: UIEvent) {
      this.addTodo(this.newTodo);
    },
    addTodo(title: string) {
      this.todos.push(title);
    }
  }
});
</script>

JavaScriptでのVueコンポーネントを定義する方法と同じように、コンポーネントを作成するオプションのオブジェクトとして、コンポーネントを定義します。ただし、タイプが宣言されたVue.extendを使用することで、タイプのサポートを受けることができます。vscodeはextendの上にマウスオーバーすることで簡単にタイプ情報が確認でき、下図のようにPeek Definitionでextendがどのようなシグネチャを保有しているか確認できます。

Vueプロジェクト@typeディレクトリに定義された型宣言により、extendを使用することでコンポーネントを開発するときに、TypeScriptの力が発揮されます。許可されていないコンポーネントのオプションには、警告やAPI、コンポーネントメンバーの誤使用に対するアラートが動作します。そしてデータも正常にタイプが推論されます。この程度のテストなら非常に円滑に進行できますが、まもなく大きな問題にぶつかります。

interface Todo {
  title: string;
}

コンポーネントデータとして使用するTodoタイプを宣言しました。Todoタイプのデータを親コンポーネントからプロップに伝達する際、コンポーネントでプロパティを定義すると、typeでTodoタイプが使用できるだろうと予想しました。そこで配列のジェネリック型のTodoを使用するという意味でTodo []を使用しました。

export default Vue.extend({
  props: {
    todos: {
      type: Todo[],
      required: true,
      default: []
    }
  },
  ...

しかし、すぐにエラーが出ました。

タイプとして使用されるべきTodoが値として使用されていると、エラーが出力されました。つまり、type: Todo[]は、型を宣言したのではなく、typeTodo[]という値を割り当てたものでした。TypeScriptのタイプは、開発中あるいはコンパイル過程で役立ちますが、実際にトランスファイルされたJavaScriptコードには残っていないため、値には使用できません。JavaScriptの基本タイプを使用したときは、NumberArrayのように、実際の値として使用できる対象があったので可能でした。

TypeScriptにおいて、オブジェクト形態でコンポーネントを定義するとき、propsのタイプにTypeScriptのタイプを使用できないという致命的な欠点は、現在も解答が見つかっていません。ここではタイプを使わずにJavaScriptの基本タイプとして解いてみましょう。

export default Vue.extend({
  props: {
    title: {
      type: String,
      required: true,
      default: []
    }
  },

Vuexを連動していると、別の問題に直面しました。この問題は、異なる様相のクラスベースのコンポーネントでも発生します。Vuexのマッピングヘルパーは、ストアに定義された各種データの関連機能を簡単にコンポーネントメンバーとしてマッピングしてくれます。マッピングヘルパーは、JavaScript基盤で作業する際、ほぼすべてのストアの機能に使用されています。しかし、マッピングヘルパーをTypeScriptで使用すると、メソッドやデータがすべてString値とその他のマッピングヘルパーオプションで選択されてしまい、TypeScriptは当該メンバーがコンポーネントに存在するか推論ができません。

methods: {
  ...mapActions(['addTodo']),
  onEnter(ev: UIEvent) {
    this.addTodo(this.newTodo);
  }
}

したがって、マッピングヘルパーは使用できません。直接、インダイレクションメソッドを作成したり、アクションの場合は、dispatchを直接実行する方法でストアからアクセスしなければなりません。

methods: {
  addTodo(todo: string) {
    this.$store.dispatch(‘addTodo’, todo);
  },
  onEnter(ev: UIEvent) {
    this.addTodo(this.newTodo);
  }
}

よって、「Vue.extendは、TypeScriptと良い組み合わせではない」という結論に至りました。

クラスベースのコンポーネント

Vue.extendとオブジェクトを利用する方法は、TypeScriptに問題がありましたが、TypeScript環境では、コンポーネントもクラスで作成する方が適切なのでは?という考えもありました。Vue.extendはVueフレームワークに重点を置き、クラスベースのコンポーネントは言語に重点を置いている印象があります。ES6でもクラスベースのコンポーネントを使用できますが、Vueはコンポーネントをコンポーネント生成オプションオブジェクトとして定義する方法に基づいてデザインされているため、現在のES6でもオブジェクトの形態が最も適しています。クラスベースのVueコンポーネントは、Vueコミュニティの中でも、答えを模索している過程にあるようです。おそらくクラスベースのコンポーネントは、TypeScriptがなかったら全く考慮されていなかったでしょう。

<template>…</template>
<script lang="ts">
import {Component, Vue, Prop} from 'vue-property-decorator';
import {mapActions} from 'vuex';

@Component({
  methods: {
    ...mapActions(['addTodo'])
  }
})
export default class Todolist extends Vue {
  public newTodo: string = '';

  public addTodo!: (title: string) => void;

  @Prop({required: true})
  public todos!: Todo[];

  public onEnter(ev: UIEvent) {
    this.addTodo(this.newTodo);
  }
}
</script>

クラスでは、getsetにcomputedを定義し、メソッドはクラスメソッドで直接使用され、クラス内でのデータフィールドはそのままVueデータとして使用されます。その他のwatcherやpropsのようなVueのコンセプトは、デコレーターを使用します。デコレーターにVueのオプションをクラスベースのコンポーネントとして提供しています。クラスベースのコンポーネントへ移行したことで、コンポーネントのプロパティタイプの問題は、非常にすっきりと解決しました。

@Prop({required: true})
public todos!: Todo[];

Propデコレーターを使ってtodosがpropであることを定義し、propオプションを引数として渡します。タイプはオプションではなく、TypeScriptの完全な構文で宣言することができます。Propデコレーターがあるvue-property-decoratorがVueの公式モジュールではないという点を除けば、安定的で信頼性のある明確な方法だと言えます。ここでtodosプロップをコンポーネントで使用する場合に、タイプの力を借りることができます。

しかし、今回もVuexマッピングヘルパーを使用するときに問題が発生しました。コンポーネントでmapActionヘルパーを使用するため、Componentデコレーターを利用しました。

@Component({
  methods: {
    ...mapActions(['addTodo'])
  }
})

ストアのaddTodoというアクションは、TypeScriptのタイプを使って、正確にシグネチャ定義をしていると仮定しましょう。しかし、今回もaddTodoはマッピングヘルパーの文字列引数で選択されてしまったため、コンポーネントはaddTodoの存在を推測することができません。そのため、addTodoをコンポーネントで使用しようとすると、コンポーネントにないメソッドを使用しているとタイプエラーが発生します。Vue.extendのような場合は、マッピングヘルパーを使用できる手段が全くありませんでしたが、クラスベースのコンポーネントでは、使用する方法があります。それは、クラスでもう一度シグネチャを定義するというやり方です。

public addTodo!: (title: string) => void;

メソッドのシグネチャを重複として定義し、無理矢理合わせました。重複の問題は、ロジックがコードだけでなくタイプ定義でも同様に発生します。タイプの定義は一度だけにして、コンパイラが類推できるようにする必要があります。

結論

悩んだ末、コードの重複だけは避けなければならないという結論に至り、マッピングヘルパーを使用しないことにしました。マッピングヘルパーにマップされているストア要素は、コンポーネントによっては大量になる可能性があり、タイプまで重複して定義すると、コードが無駄に複雑になってしまいます。さらにタイプ変更の対応を考えると採用できませんでした。現在のプロジェクトのコンポーネントは、このような形になっています。

<template>…</template>
<script lang="ts">
import {Component, Vue, Prop} from 'vue-property-decorator';

@Component
export default class Todolist extends Vue {
  public newTodo: string = '';

  @Prop({required: true})
  public todos!: Todo[]

  get schedule(): Schedule {
    return this.$store.state.schedule;
  }

  public onEnter(ev: UIEvent) {
    this.$store.dispatch('addTodo', this.newTodo);
  }
}
</script>

マッピングヘルパーを使用しないことの他にも、以下のようなVuex使用ルールを決めました。

  • actionやmutationは、特別な場合を除き、インダイレクションメソッドとして使用せず、それぞれdispatchとcommitメソッドで直接呼び出します。不適切なタイプの値が引数として使用された場合、タイプのサポートを受けることができませんが、タイプの重複が除去でき、少なくとも間違った名前のactionやmutationが実行されると、フレームワークがエラーとして出力されるので、十分価値があります。
  • stateやgetterは、状況に応じてcomputedに定義するか、あるいは$storeから直接使用します。ただし、テンプレートでは$storeの使用は避けましょう。テンプレートが複雑になる可能性があるためです。

これらを遵守すれば、問題なくTypeScript環境でVueコンポーネントが開発できるでしょう。

現時点では、TypeScriptはVueよりもReactとよりマッチするように感じられます。TypeScriptは、TSXという名前でJSXをサポートするため、JSXのコンポーネントタイプまで検証できる反面、VueのSFCやテンプレートにはまだ対応していません。そのため、コンポーネントPropsのタイプをいくら上手に指定しても、まだ半分しか使用できないでしょう。TypeScriptとVueフレームワークは、もう少し時間が必要そうです。しかし、Vue 3はすべてのコードベースがTypeScriptで開発されるほど、TypeScriptはコミュニティで好まれて使用されています。おそらくVue 3は、より良い方法が提示されることでしょう。

TOAST Meetup 編集部

TOASTの技術ナレッジやお得なイベント情報を発信していきます
pagetop