タグ別アーカイブ: Vue

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 にも感謝したいです。彼のコメントがなかったら今のアプローチに至ることはなかったと思います。

Nuxt.js のような自動ルーティングを可能にする Vue CLI プラグインを作った

Nuxt.js という Vue.js で SSR をするアプリケーションが簡単に書けるフレームワークがあります。Nuxt.js は SSR だけでなく、webpack の設定やディレクトリ構造なども最初から決められており、規約がすでに存在することによる開発の効率化の面においても注目されています。

個人的に Nuxt.js で便利だと感じている機能にルーティングの自動解決レイアウト機能があります。通常の Vue Router を使ったアプリではルーティングの設定は自分で書く必要がありますが、Nuxt.js では pages/ ディレクトリ以下の構造から自動的にルーティングの設定を生成してくれます。また、Rails のレイアウトのように、各ページごとにレイアウトファイルを指定することができます。

これらの機能に慣れてしまうと、Nuxt.js を使っていないプロジェクトにおいて自分でルーティングの設定を書くのがとても面倒になってきたので、Nuxt.js じゃなくてもいい感じにする Vue CLI プラグインを書きました。

vue-cli-plugin-auto-routing

また、vue-cli-plugin-auto-routing は独立したパッケージを組み合わせているので Vue CLI プラグインを使えない環境の場合は以下の2つを直接使うと良いです。

vue-auto-routing: ルーティングの設定をディレクトリ構造から生成する webpack プラグイン
vue-router-layout: レイアウト機能を提供するコンポーネント

vue-cli-plugin-auto-routing の使い方

Vue CLI v3 用のプラグインなので、それで構築したプロジェクトでのみ使用することができます。Vue CLI v3 でプロジェクトを生成するには以下のようにします。

# vue-cli v3 をインストール
$ npm install -g @vue/cli

# プロジェクトを作成
$ vue create new-project

vue-cli-plugin-auto-routing を使うにはプロジェクトのディレクトリ内で以下のコマンドを実行します。

$ vue add auto-routing

これを実行すると router.js が書き換えられ、layouts/, pages/ ディレクトリが追加されます。これらのディレクトリ内にファイルを追加すると、Nuxt.js と同様に動作することがわかると思います。

vue-auto-routing と vue-router-layout

vue-cli-plugin-auto-routing で生成された router.js を見ると以下のように記述されています。

import Vue from 'vue'
import Router from 'vue-router'
import routes from 'vue-auto-routing'
import { createRouterLayout } from 'vue-router-layout'

Vue.use(Router)

const RouterLayout = createRouterLayout(layout => {
  return import(`@/layouts/${layout}.vue`)
})

export default new Router({
  routes: [
    {
      path: '/',
      component: RouterLayout,
      children: routes
    }
  ]
})

vue-auto-routingpages/ 以下のディレクトリ構造からルーティングを生成しています。vue-router-layout は現在表示されているコンポーネントに指定されているレイアウトを layouts 以下から選択して描画するためのコンポーネントです。createRouterLayout のコールバックでレイアウトのコンポーネントを返しています。

Nuxt.js と異なり、Vue Router の設定は隠蔽されていないので、カスタマイズの幅は広いと思います。例えば、createRouterLayout のコールバックを書き換えてレイアウトの解決の仕方を変えたり、routes に追加のルーティングを増やしたりできます。

Vue CLI v3 が使えない場合はこれらのライブラリを直接使うことができます。詳細な使い方は vue-auto-routingvue-router-layout の README を読んでください。

まとめ

Nuxt.js を使っていないプロジェクトにおいても pages/layouts/ 機能を使えるようにする Vue CLI プラグイン vue-cli-plugin-auto-routing を作りました。Vue CLI プラグインが使えない環境では vue-auto-routingvue-router-layout を使うことで同じ機能を使えるようにできます。

Vue Router のルーティングはいろいろと細かく設定できますが、実際に開発していると Nuxt.js のルーティングで十分に感じます。これからはルーティングの設定を手で書くことは少なくなっていくのではないかなーと思います。

vue-thin-modal v1.0.0 をリリースしました

去年から作っていた Vue のモーダルコンポーネント vue-thin-modal の v1.0.0 をリリースしました。仕事でも結構使っていて、特に大きな問題もなく、API も安定しているのでメジャーバージョンを上げました。

vue-thin-modal は世の中の多くのつらいモーダル実装を見て、つらくならなくするために作ったライブラリです。主に以下のような特徴があります。

  • モーダルはどこに置いても DOM の実態は <body> 直下にマウントされる (いわゆる Portal)。
  • モーダルが開くと通常のコンテンツ部分はスクロールが止まる。モーダル内のコンテンツがウィンドウサイズを超えてもスクロールできる。
    • これで発生する、スクロールバーが消えることによるガタツキを防ぐ実装もしている。
  • モーダルを閉じたときに元のコンテンツにフォーカスを戻す。
  • デフォルトの CSS スタイルが提供されていて、何もしなくてもそれっぽく動く。
  • 背景とモーダルコンテンツのトランジションが独立していて、柔軟に設定できる。
  • モーダルの表示はスタックで管理していて、モーダルの上にモーダルとかもやろうと思えばできる (UI 的にどうなんだというのは置いといて)。

モーダルのつらさについては CodeGrid 5周年記念パーティーで話しているので、そのスライドも見てみてください。

vue-thin-modal を作る際には Bootstrap ModalModaal をかなり参考にしました。特に Bootstrap Modal の作り込みはすごくて、その完成度の高さに驚いた覚えがあります。

関連ライブラリとして vuex-modal というものもあります。こちらは vue-thin-modal を作る前に作ったものですが、今は内部で vue-thin-modal を使用しています。

自分の欲しい機能はすべて実装したので、これからの展望は特にないですが、バグレポートや機能要望などは歓迎です!

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 に認識させることができるようになります。

Vue のコンポーネントと Vuex Store を繋げるためのヘルパ vuex-connect を作った

某勉強会中にネタを思いついて、急いで作って LT してその日のうちに npm にアップしたら、翌日Vue 公式に紹介されていてだいぶビビったやつです。VueVuex のヘルパなので、この二つを理解していることが前提になります。

vuex-connect (Github)
vuex-connect (npm)

vuex-connect の機能

vuex-connect は connect 関数のみ提供しており、やっていることは react-reduxconnect と同じです。connect は第一引数に Vuex の getters、第二引数に actions を受け取ります。 また、戻り値として別の関数を返し、こちらの関数には、コンポーネント名と、コンポーネントのコンストラクタを渡します。 最終的に、渡したコンポーネントのプロパティに getter と action をつなげた上位コンポーネント (コンテナ) を返します。

// コンポーネントを定義
const HelloComponent = Vue.extend({
  props: {
    message: {
      type: String,
      required: true
    },
    updateInput: {
      type: Function,
      required: true
    }
  },
  template: `
  <div>
    <p>{{ message }}</p>
    <input type="text" :value="message" @input="updateInput">
  </div>
  `
});

// コンポーネントと Store をつなげる
const getters = {
  message: (state) => state.message
};

const actions = {
  updateInput: ({ dispatch }, event) => dispatch('UPDATE_INPUT', event.target.value)
};

const HelloContainer = connect(
  getters,
  actions
)('hello', HelloComponent);

なぜ有用なのか?

これも react-redux で言われていることと同じですが、開発の定石として、フレームワークやライブラリへの依存をなるべく少なくするというものがあります。UI コンポーネントと、状態管理を司るフレームワークを疎結合にするために、Redux では、UI の見た目の実装を行うためのコンポーネントと、Store 内の状態とやり取りするためのコンポーネントを分けるという方針をとっています。これらに関して、前者は Presentational Component、後者は Container Component と呼ばれています (Usage with React | Redux)。

Vue と Vuex で例えると、Presentational Component、Container Component のどちらも Vue のコンポーネントとして実装されます。Presentational Component は Vuex への依存関係を持っておらず、Vue のみで完結しています。Container Component は Vue と Vuex の両方に依存しており、両者をつなげる役割のみを担います。ここで、Vuex を使うのをやめて別の状態管理のフレームワークを使うケースを考えます。Vuex に依存しているコンポーネントは Container Component のみであり、また、その役割は Vue と Vuex をつなげるということのみであるため、Container Component を交換するのみで良くなります。すなわち、Vue と Vuex の依存を最小限にすることで、Presentational Component として実装した Vue コンポーネントが再利用できるようになっているということです。

Vuex の公式ドキュメントを見てみると、上記のようなことは考慮されておらず、Vue と Vuex が密結合になっているように感じたため、vuex-connect を作りました。Vue だと props でバケツリレーするのはちょっと違和感あるのですが、アプリケーションのあちこちで状態を変えられるよりは良いのかなと……。もしくは、action を props に渡すのではなく、events の方に結びつけても良いかもしれないですね。

実装

実装したての頃は react-redux とインターフェースを合わせようとして四苦八苦していたこともあり、Vue.prototype._init を上書きしたりしてました。しかし、よくよく考えてみると vuex オプションにそのまま受け取った引数を渡せばいいことに気が付き、かなり簡潔な実装になりました。

https://github.com/ktsn/vuex-connect/commit/f0a254ea5c7b330bcc2446167b970940d67a724b

コンポーネントの props に getter と action を渡したいので、template の部分をがんばって生成しています。

悩み

connect で生成したコンポーネントに props を渡せるようにするべきかを悩んでいます。react-redux だと渡せるようにしているのですが、正直あんまりそうするケースが思い浮かばないし、データの流れが二股になって複雑になることを懸念しています。また、React の場合は react-router などが props にデータを渡してくるので、それに対処するために props の値を読めるのは有用なのですが、Vue の場合はそういうケースで props を使うことがないので、やるとしても別のアプローチをするべきなのかなーとも思ってます。あと、Vue の props はちゃんとコンポーネント側で定義してあげる必要があるので実装がめんどくさそう……。

最後に

なんとなく Vuex 使ってる人はものすごく少ないような感じがするのですが、Vuex を使うことがあったら vuex-connect も一緒に使っていただけると嬉しいです。そして contribution もウェルカムです!

Vue.js における methods の this は自動的に VM に束縛される

執筆当時の環境

  • Vue.js v1.0.17

JavaScript で以下の様なコードを書いた時、onResize 内の this はグローバルオブジェクト (window) となり、this.log('resized') はエラーとなります。

const obj = {
  log: function(str) {
    console.log(str);
  },

  onResize: function(event) {
    this.log('resized');
  }
};

window.addEventListener('resize', obj.onResize); // this.log('resized') でエラー

上記のコードの obj を、以下のように Vue.js の VM にするとエラーが起きなくなり、意図した通りに動作するようになります。また、this の値は obj に束縛されています。

const obj = new Vue({
  methods: {
    log: function(str) {
      console.log(str);
    },

    onResize: function(event) {
      this.log('resized');
    }
  }
});

window.addEventListener('resize', obj.onResize); // エラーが発生しない

このことから、Vue.js では methods に渡された関数を VM に束縛していると言えます。実際にコードを追って確かめてみます。

methods で Github のリポジトリを検索すると、 _initMethods というメソッドが存在しているのがわかります。このメソッドは src/instance/internal/state.js の中に定義されています。_initMethods の中を見てみると、bind(methods[key], this) という記述があります。JavaScript ネイティブの bind ではないですが、どうやらここで this を束縛しているように見えます。

vue/state.js at 521e8d2754c2e7f172c3c9702fdb74fe993027fb · vuejs/vue

なぜわざわざ独自の bind 関数を使っているのかを調べるために、この bind の定義も見てみました。bindsrc/util/lang.js に定義されています。

vue/lang.js at 521e8d2754c2e7f172c3c9702fdb74fe993027fb · vuejs/vue

関数の上のコメントには、ネイティブの bind よりも早いと書いてあります。少し調べてみたところネイティブの bind は、this の束縛の他に型チェックや、引数の束縛なども行うため遅くなっているとのことでした。ただ、遅いとはいってもほとんどのケースでは気にならない違いだと思うので、普通にアプリケーションを作る際には気にしなくても良さそうです。

javascript – Why is bind slower than a closure? – Stack Overflow

Vue.js を使った中規模 Web アプリ向けのディレクトリ構造を考えた

最近 Vue.js を使って Web アプリを書いていて、どんなディレクトリ構造だと良いんだろうなーということを考えた結果を書きとめようと思います。Angular Best Practice for App Structureっぽい感じです。

ディレクトリ構造

├── app
│   ├── components
│   │   ├── component1
│   │   │   ├── component1.html
│   │   │   ├── component1.js
│   │   │   └── component1.css
│   │   ├── component2
│   │   │   ├── component2.html
│   │   │   ├── component2.js
│   │   │   └── component2.css
│   │   ├── component3
│   │   │   ...
│   │   ...
│   ├── filters
│   │   └── filter1.js
│   ├── directives
│   │   └── directive1.js
│   ├── plugins
│   │   └── plugin1.js
│   ├── images
│   │   ...
│   ├── index.html
│   ├── main.js
│   └── main.css
├── gulpfile.js
├── package.json
├── webpack.conf.js
...

コンポーネントごとにディレクトリを分け、コンポーネントを構成する html, css, js ファイルを同一のディレクトリ内に入れるのが良いと思います。html や css ファイルも一緒にすることで、関連するファイルを探すのが楽になります。コンポーネントを取り除くときや、リファクタリングする時も、一つにまとまってるので楽です。

vueifyvue-loader によってコンポーネントを一つの .vue ファイルで構成するというやり方もありますが、個人的には分割するほうが良いのではないかなーと思います。まず、.vue ファイル一つだとコードが長くなると見通すのがつらいです。vueify や vue-loader の例を見るとすっきりとして見やすいなーと思いますが、実際のコンポーネントはもっと量が多く、スクロールで行ったり来たりするのが結構つらいです。また、エディタが対応してないのがつらいというのもあります。html として扱えばシンタックスハイライトはしてくれますが、各種 Lint 系ツールが動かなかったり、動いても不具合があったりしてつらかったです。

app/ 直下の main.js には各種プラグインの読み込みや、設定、ルーティングなどを書いてます。main.js をエントリポイントにして、ここから各種コンポーネントなどを読み込んでいく形になります。また、main.css にはコンポーネントには入らない、リセット系のスタイルなどを定義するようにします。

また、filter や directive、plugin などは components と同じ階層にディレクトリを作って、それぞれ分けています。この辺りはまだ全然実装してないので、コンポーネントと合わせれば良いのではないかなーという程度の考えです。

具体的にどう実装するのか

ディレクトリ構造を決めた後は、実際にその構造で動くように実装をしました。具体的には、webpack を用いてコンポーネント間の依存関係を解決し、各コンポーネントの css, js も読みこむようにしました。ここでは、webpack の具体的な設定を説明するのではなく、最終的にどのように書けるようにしたかを書きます。

// component1.js の例
require('./component1.css');

module.exports = {
  template: require('./component1.html'),

  ...

  components: {
    component2: require('../component2/component2')
  },
  filters: {
    filter1: require('../../filters/filter1')
  },
  ...
};

それぞれの js ファイルはコンポーネントのオプションオブジェクトを export するように書きます。Vue.component() メソッドは使用しません。filter や directive なども同様です。export させることで、コンポーネント間の依存関係を webpack に解決させてます。また、そういった書き方をしているので、各コンポーネントのオプションオブジェクトの中で、依存するコンポーネントなどを指定する必要があります。コンポーネントごとに依存関係を指定するのは面倒ですが、ソースコード内に依存関係が明示されるのは良いのではないかと考えています。

html, css ファイルは、そのコンポーネントの js ファイルの中で読み込ませます。webpack の html-loader を使えば html を文字列として取得し、template に渡すことができます。また、style-loader を使えば、読み込んだ css を style 要素としてページに追加することができます。html や css は普通に書けば良いです。

もうちょっと何とかしたい点

以下の点をもうちょっと何とかしたいです。

  • あるコンポーネントから別のコンポーネントや、フィルター、ディレクティブを参照する時の表現が冗長
  • CSS Module に対応したい
  • いろんな部分で使い回すようなモジュールはどういう扱いにするのか?

例えば上の例だと、component2 を参照する時は component2 を二回書かないとダメですし、filter1 に関しては ../../ がつらい感じです。前者についてはファイル名をすべて index.(ext) にすると、一回の記述ですみます。しかし、すべて index.(ext) にしてしまうと、エディタで開いた時がつらいです。後者については、webpack の resolve.root の設定に app/ ディレクトリを入れると省略はできますが、ライブラリ系以外で resolve.root 使うのはどうなんだろうなーという感じです。

また、css に関して、コンポーネントごとに分けてはいますが、普通に衝突します。css-loader は CSS Module にも対応しているようなので、試してみたいです。

いろんな部分で使い回すようなモジュールに関しては、とりあえず Vue.js のプラグインとして書いてみていますが、普通に Vue.js とは独立した書き方で書いて、それを import させても良さそうな気がします。

最後に

Web ページのディレクトリ構造といえばファイルの種類ごとに分けるのが一般的だったと思うのですが、各種フレームワークとか webpack とかを使ってコンポーネント化を意識すると、ファイルの種類ごとに分けると依存関係がわからなくなってつらくなることが多いように感じます。初めは少し抵抗を覚えますが、コンポーネントごとに分けるのも良いのではないかと考えます。

今回のディレクトリ構造を中規模向けとしたのは、おそらく大規模になってくるとコンポーネントの数が多すぎて、うまく目的のものを見つけられないということが起こると考えたからです。大規模な Web アプリの場合は、ページごとにさらにディレクトリを分けるなどする必要があるんじゃないかなと思います。