Vue テンプレート内の式の型チェックと解析ができるまで

Vue の TypeScript 対応は v2.0 から公式に型定義がサポートされるようになったりv2.5 で Vue.extend を使ったときに this の型が推論されるようになったりと、改善が何度も行われています。

しかし、課題はまだたくさんあり、その中でもよく聞くのが、テンプレート内の式の型チェックがされないという課題でした。TypeScript はテンプレートを解釈できないので当たり前ですが、もしそれが解決できたらより安全になるでしょうし、開発体験も向上すると思います。

そしてこの課題は Vetur の最新版で解決されました。

Vetur v0.19.0

VeturVisual Studio Code の拡張機能の一つで、Vue の単一ファイルコンポーネント (.vue ファイル) のための様々な機能を提供しています。例えば、<script> ブロック内の TypeScript コードの型チェック、補完などの機能は Vetur によって提供されています。

先日 Vetur v0.19.0 がリリースされました。大きな更新としては Template Interpolation Service があります。これにより、テンプレート内に書かれている式の型チェック、ホバーによる変数情報の表示、補完、定義ジャンプ、参照の表示ができるようになりました。以下の Pine (Vetur の作者) のツイートの動画を見るとわかりやすいと思います。

この機能はまだ実験的なものなので、設定から vetur.experimental.templateInterpolationService を有効にすることで使えるようになります。

実装には僕も関わっていて、いろいろと試行錯誤をしてやっと完成したという感じです。完成するまでの道のりを書いていこうと思います。

Pine もこの機能についての記事を書いているので、合わせて読むといいと思います。

Generic Vue Template Interpolation Language Features | Pine Wu’s Blog

型チェックの実装を模索

テンプレートの型チェックはずっと前からやりたいと思っていたことで、2017 年から模索を始めていました。いくつかの方法を試してはうまくいかずに失敗するというのを繰り返し、かなり時間がかかってしまいました。

コンパイルした JS コードの利用

最初は最も簡単にできそうだということで、vue-template-compiler によって JavaScript に変換したテンプレートをコンポーネントに差し込み、あとは普通に TypeScript のコンパイルをするというアプローチを試してみました。これに関しては Vue.js Tokyo v-meetup=”#3″ で話しているので、詳細は以下のスライドを見てください。

実装は GitHub にあります。

https://github.com/ktsn/typed-vue-template

この方法は、型チェックは正しく動くのですが、元のソースコードから変換を行っている関係で、エラーが発生した場所が正しく出力できないという問題がありました。vue-template-compiler は source map をサポートしていないので、元のテンプレートの位置を復元することができず、また、vue-template-compiler はブラウザ上で動くことも想定していることから、実装が最小限に抑えられており、source map の機能を入れることはあまり現実的ではなさそうでした。

Vetur に Issue を上げる

そんなときに Pine もテンプレートの型チェックをやろうとしているという話を Evan (Vue.js の作者) から聞き、Vetur のリポジトリに Issue を上げました。

https://github.com/vuejs/vetur/issues/209

僕はあまり TypeScript の内部実装や TypeScript Compiler API には詳しくなかったので、Issue 内で紹介されているドキュメントや、Vetur のコードを読んで勉強しつつ、アプローチを考えていました。

自前の型検査器

当時すでに Angular はテンプレートの型チェックを実装していて、その実装も読んでいました。読み進めてみると、どうやら Angular は自前で簡単な型検査器を書いているようで、Vue のテンプレートでも同じことができないかと試してみました。

型検査器を作るにあたって Angular の実装はもちろん、TAPL を読んだりもしました。また、TypeScript のコードの型情報を取るために TypeScript Compiler API を使うのですが、ドキュメントはほぼなく、API の型定義を見て手探りで進めていくしかないので、かなり苦戦しました。「TypeScript Compiler API の基本的な使い方、コード例と作ってみたもの」を読めばなんとなく雰囲気はわかると思います。学んだことをアウトプットしたりもしてました。

そして作ったものが以下です。

https://github.com/ktsn/vue-template-diagnostic

このアプローチはたしかに型検査もできて、エラー位置も正しく取れるのですが、どうしても簡単な検査しかできないというのがネックでした。例えば、ジェネリックスや関数オーバーロードの実装をしようとしたらとても大変になることが容易に想像できます。実際に、少なくとも当時は Angular もテンプレート内の関数呼び出しの型チェックは完全ではないようでした。

テンプレート → TypeScript AST 変換

最終的に、やはり型検査自体は TypeScript にやらせたほうが良いということで、テンプレートを TypeScript AST (Abstract Syntax Tree: 抽象構文木) に変換し、それを TypeScript のコンパイラーに検査させるというアプローチを取りました。

実装について

処理の流れは大雑把に以下のようになります。

  1. 単一ファイルコンポーネントから <template> 部分を抽出する (すでに Vetur が実装済み)。
  2. vue-eslint-parser を使ってテンプレートをパースし、AST にする。
  3. 2 のテンプレートの AST を対応する TypeScript の AST に変換する。
    • ノードに元のテンプレートの式の位置情報を記録する。
  4. TypeScript のコンパイラーに 3 を処理させ、エラー情報を取得する。

例えば、以下のようなファイルが Hello.vue として存在するとします。

<!-- Hello.vue -->
<template>
  <p>{{ message }}</p>
</template>

<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
  data() {
    return {
      message: 'Hello'
    }
  }
})
</script>

Vetur はこのファイルの <template> ブロックを抽出し、以下のような TypeScript コードを仮想的に生成します (読みやすくするために整形してます)。

// Hello.vue.template
import __Component from "./Hello.vue";
import { __vlsRenderHelper, __vlsComponentHelper } from "vue-editor-bridge";

__vlsRenderHelper(__Component, function () {
    __vlsComponentHelper("p", { props: {}, on: {}, directives: [] }, [this.message]);
});

Vetur はすでに .vue ファイルの <script> ブロックを抽出しているため、./Hello.vue をインポートしたものは <script> ブロック内でエクスポートしているものになります。よって、__Component はコンポーネントの型情報を持っています。

__vlsRenderHelper__Component のインスタンスの型を this に付与するための仮想的なヘルパーで、その定義は以下のようになっています。

export declare const __vlsRenderHelper: {
  <T>(Component: (new (...args: any[]) => T), fn: (this: T) => any): any;
};

これによって、this.message の型をチェックすることができるようになります。

TypeScript の AST には任意の位置の情報を入れることができる (ts.setTextRange) ので、テンプレートの式に対応する TypeScript のノードに、元の位置を記録します。これによって、出力される型検査エラーの位置は元のテンプレートの式に沿ったものになります。上記の例なら、this.message に対応するノードに、元のテンプレートの message の位置 ([38, 45]) を記録します。しかし、後述するように、この実装には問題がありました。

テンプレートのパースには vue-eslint-parser を使っています。Vetur 自身にもテンプレートパーサーはあるのですが、 ほぼ HTML のパーサーそのままで、ディレクティブや (当時は) mustache 式のパースはしていませんでした。一方で vue-eslint-parser はディレクティブの argument や modifier、v-for のような特別な式など、細かく情報が取れるのが利点でした。Vetur のパーサーを拡張することも考えましたが、まずはとにかく本質的な機能に集中して実装してしまいたかったので、vue-eslint-parser を選びました。

型検査も完全だし、エラー時に元のテンプレートの位置も出せるので、元々やりたかったことはすべてカバーできるアプローチでした。

Vetur に PR を出す

そして、最低限動きそうだという段階まで来たときに以下の PR を出しました。一番最初の typed-vue-template のコミットが 2017 年の 3 月で、この PR は 2018 年の 2 月だったので、1 年くらい試行錯誤してたことになります (途中でなにもしてない期間もありましたが……)。そしていろいろあってこの PR は今年の 4 月にマージされたので、実現するまでに 2 年かかったことになります。

https://github.com/vuejs/vetur/pull/681

問題

しかし、この実装が原因で、TypeScript コンパイラーのクラッシュが発生するケースがありました。原因は当時すでにわかっていて、TypeScript コンパイラーに投げた AST が不正な位置情報を持っていることで、内部のアサーションが失敗しているためでした。

PR の時点では、TypeScript Compiler API から生成したノードは位置情報が [-1, -1] となっていて、そのノードが型チェックに引っかかると、アサーションで落ちるということがわかっていました。それを防ぐためにテンプレートの式に関係ないノードにはすべて [0, 0] をセットしたりしていましたが、すべてのクラッシュは解決できていませんでした。また、これをするとソースコードが地獄のように煩雑になるのも問題でした。

これを解決するために、Pine のアイデアで、テンプレートの式と変換後の TypeScript コードのソースマップを生成することになりました。

ソースマップの生成

Pine が以下の PR でテンプレートの式と変換後の TypeScript コードのソースマップの生成を実装しました (一般的な source map とは厳密には違います)。

https://github.com/vuejs/vetur/pull/1215

以下のような流れでソースマップを生成しています。

  1. テンプレートから正しくない位置情報の TypeScript AST (a) に変換する過程で、位置情報を ts.setSourceMapRange を使って書き込む。
  2. 生成された TypeScript AST をプリントして、コードの文字列を取得する。
  3. 2 をパースして、正しい位置情報を持った TypeScript AST (b) を取得する。
  4. (a) と (b) を比較してソースマップを生成する。
    1. (a) と (b) は同じ構造なので、順番に各ノードを比較する。
    2. ts.getSourceMapRange が以前にセットした位置情報を返すノードに関して、ソースマップに書き込む。

テンプレート内の message の位置が [38, 45] で、変換後の this.message が [311, 323] とすると、この処理を行うと以下のようなマッピングが作られます。

SourceMapNode {
  from: {
    start: 38,
    end: 45,
    filename: 'Hello.vue'
  },
  to: {
    start: 311,
    end: 323,
    filename: 'Hello.vue.template'
  },
  offsetMapping: {
    38: 316,
    39: 317,
    40: 318,
    41: 319,
    42: 320,
    43: 321,
    44: 322,
    45: 323
  },
  offsetBackMapping: {
    311: 38,
    316: 38,
    317: 39,
    318: 40,
    319: 41,
    320: 42,
    321: 43,
    322: 44,
    323: 45
  }
}

offsetMapping では変換後の this. の位置情報 ([311, 315]) をスキップしており、offsetBackMapping では、this. の最初 (311) と最後 (316) の位置を元の message の先頭 (38) にマッピングしています。このようにすることで、テンプレートの位置 |message を仮想的な TypeScript コード内では this.|message とマッピングでき、逆に TypeScript コード内の |this.message および this.|message|message にマッピングすることができます。

これにより、TypeScript コンパイラーのクラッシュはなくなり、また、この実装でチェック対象の TypeScript AST が正しくなったことにより、ホバーによる変数情報の表示、補完などの他の機能も提供することができるようになりました。テンプレートから TypeScript AST に変換するコードもかなり読みやすくなりました。

今後の開発

まだこの機能は十分にテストできていないので、解決すべき問題がいろいろ出てきています。また、v-if でチェックした変数から null を取り除く機能 (type narrowing) や、コンポーネントの props の検査など、実装したい機能もたくさんあります。

特に、Vue Language Service (VLS: Vetur の裏側で動いてるモジュール) を CLI から実行できるようにすることはかなりやりたいです。これができるようになるとテンプレートの型チェックを CI で動かすなど、利用の幅がかなり広がります。

この記事を読んでいる Vetur ユーザーの方は、ぜひ vetur.experimental.templateInterpolationService を有効にして、Template Interpolation Service を試してみてください。そして、何かあればフィードバックを Vetur の Issue にもらえると嬉しいです。

謝辞

この機能の実装は Pine の協力と Vetur という土台がなければ実現しなかったと思います。特に TypeScript コンパイラーのクラッシュの問題は僕一人では対処できませんでした。

Nagashima さん が vue-eslint-parser を作ってくれていたおかげで、テンプレートの型チェックの実装がとても容易になりました。

元の Issue で議論に参加してくれた Herrington Darkholme にも感謝したいです。彼のコメントがなかったら今のアプローチに至ることはなかったと思います。