LoginSignup
19
8

More than 5 years have passed since last update.

Vue, TypeScript and JSX

Last updated at Posted at 2017-09-04

TL, DR

Vue + TypeScriptのプロジェクトでJSX(TSX)を使うためのサポートライブラリを作った。
vue-tsx-support

使ってみて意見とかもらえるとうれしい。

はじめに

TypeScript + Vueでちょっとしたアプリケーションを作っていて、基本的にはpugでテンプレートを書いているのだけど、なんだかんだと細かいコンポーネントで render() メソッドを書くケースが増えてきたので、JSX(というかTSX)を使った方が楽ができるのではないかと思った。

TypeScriptでやる以上は静的な型チェックが効かないと嬉しくない。JSX名前空間内でいくつかインターフェイスを定義することでTSXに対する型チェックが可能になるのだけど、Vue向けにライブラリとして提供されているものが見当たらなかったので、 vue-tsx-support を作りました。

vue-tsx-support

ライブラリといいつつ、98%くらいは型定義。

セットアップ

VueでJSX(TSX)を使うための基本的な設定については、こちら
tsconfig.json では "jsx": "preserve" をコンパイラオプションに追加しておきます。
TypeScriptとbabelを併用するのはやや抵抗があるけど、仕方ないですね。

ぼくのプロジェクトの.babelrcはこんな感じです。参考までに。

.babelrc
{
    "presets": ["env"],
    "plugins": [
        "transform-vue-jsx",
        ["transform-runtime", {
            "polyfill": false,
            "regenerator": true
        }]
    ]
}

vue-tsx-support を有効にするには、プロジェクトのどこかで importしてください。1回インポートすればプロジェクト全体で有効になります。

// ※有効にするだけなら import "vue-tsx-support" でもいいけど、どのみち vue-tsx-support のメソッド使うので
import * as vts from "vue-tsx-support";

さらにいくつかオプションの型定義があって、それを追加でimportすることで挙動が変わります。例えばこんな感じ。

// コンポーネントのタグに未定義のpropが使われていてもエラーにならないようにする
import "vue-tsx-support/options/allow-unknown-props";
// HTMLエレメントのタグに未定義の属性が使われていてもエラーにならないようにする
import "vue-tsx-support/options/allow-element-unknown-attrs";
// コンポーネントのタグにHTMLエレメントの属性を直接指定できるようにする
import "vue-tsx-support/options/enable-html-attrs";
// コンポーネントのタグにnativeOnClickなどのネイティブイベントハンドラを直接指定できるようにする
import "vue-tsx-support/options/enable-nativeon";
// router-link、router-viewの定義を追加する
import "vue-tsx-support/options/enable-vue-router";

これも、どこでimportしてもプロジェクト全体に影響します。

静的型チェックの詳細

Intrinsic Elements

とりあえず、各HTML要素に対する定義を(Reactの型定義を元に)書いているので、エディタでの補完や型チェックが効くようになります。

const vnode = <div
                id="sample"  // id の型が string であることがコンパイラによってチェックされる
                onKeydown={ e => console.log(e.keyCode) }    // e はKeyboardEventとして扱われる
                ref="sample"  key={ 1 }  // ref や key、staticClass など、VNodeDataに含める属性も指定可能
              />;

const vnode2 = <foo prop="foo" />; // OK: 未定義の要素名が指定された場合も、エラーにはしない

通常のコンポーネント

コンポーネントについては、propsを指定せずに使うだけなら何もしなくてもよいのだけど、そのままではpropを指定できません。

const MyComponent = Vue.extend({ props: ["a"], /* 以下略 */ });

const a = <MyComponent />;  // これはOK
const b = <MyComponent ref="mycomponent" />; // これもOK。ref、key、slotなどのVue向けの属性はあらかじめ定義済み

 // これはNG: コンパイラはMyComponentにaというpropがあることを知らないので、コンパイルエラーになる
const c = <MyComponent a="foo" />; 

これを解決するためには、 allow-unknown-props オプションを有効にするか、各コンポーネントに適切に型を与える必要があります。

コンポーネントへの型指定

propsへの型指定

さて、コンポーネントに型を与える方法について、

まずはコンポーネントのpropsを表現するインターフェイスを定義します。
例えばこんなコンポーネントなら、

const MyComponent = Vue.extend({
    props: {
      text: { type: String, required: true }, // 文字列型 必須
      important: Boolean // bool型 省略可能
    },
    /* 以下略 */
});

定義するインターフェイスはこう。

interface MyComponentProps {
   text: string;
   important?: boolean;
}

ここから先は3通り

Vue.extend を使ってコンポーネントを定義している場合、Vue.extendcreateComponent に置き換えます。

import * as vts from "vue-tsx-support";

const MyComponent = vts.createComponent<MyComponentProps>({
    props: {
        text: { type: String, required: true },
        important: Boolean
    },
    /* 略 */
});

vue-class-component を使っている場合、vue-tsx-support が提供する Component クラスから継承するように変更します。

import component from "vue-class-component";
import * as vts from "vue-tsx-support";

@component({ /*略 */ })
class MyComponent extends vts.Component<MyComponentProps> {
    /* 略 */
}

元のコンポーネントを変更できない or したくない場合(例えばサードパーティ製コンポーネントの場合とか) 、ofTypeconvert を使用して型を与えることができます。

import OriginalComponent from "original-component";
import * as vts from "vue-tsx-support";

const MyComponent = vts.ofType<MyComponentProps>().convert(OriginalComponent);

これで、propsが正しく型チェックされるようになります。

// NG: textは必ず指定する必要がある
const a = <MyComponent />; 
// OK
const b = <MyComponent text="foo" />; 
// OK
const c = <MyComponent text="foo" important />; 
// NG: textは文字列でなければならない
const d = <MyComponent text={ 1 } />;

カスタムイベントへの型付け

さて、上の対応でpropsが正しく指定できるようになったとはいえ、他にも指定される可能性がある属性があります。

たとえばコンポーネントがカスタムイベントを発行する場合(this.$emit で発行するやつ)、それに対するリスナーを指定できなくてはなりませんが、このままではこれはコンパイルエラーになります。

// NG: コンパイラはMyComponentがchangeイベントを発行することを知らないため、onChangeは不明なprop扱いになる
const a = <MyComponent text="foo" onChange={ this.onChange } />;

単にコンパイルエラーを回避するだけなら、以下のように属性名を onChange から on-change に変更するという手があります。(コンパイラの仕様として、ケバブケースな属性名は未定義であってもエラーとはしないようになっています)

const a = <MyComponent text="foo" on-change={ this.onChange } />;

しかし、これだと、たとえば on-change をtypoしていたりしてもエラーにならないなどの弊害があるので、できればカスタムイベントに対しても正しく型付けをしたいところです。
そのためには、以下のようにイベントのインターフェイスを定義して2番目の型引数に指定します。

// この例はcreateComponentを使っていますが、ほかのパターン(vts.Component と vts.ofType)でも同じように
// 型引数を指定することができます。

// イベントリスナー名:リスナーの引数の型 でイベントのインターフェイスを定義する
interface MyComponentEvents {
    onChange: string;
}
const MyComponent = vts.createComponent<MyComponentProps, MyComponentEvents>({ /* 略 */ });

これで、カスタムイベントリスナーの属性名と型をコンパイラがチェックできるようになります。

const a = <MyComponent text="foo" onChange={ this.onChange } />;

コンポーネントへの型指定ではカバーされない属性について

propsとカスタムイベント、Vueコンポーネントの共通属性(ref, key, class, style, slotなど)についてはここまでの対応で正しくチェックできるようになりますが、他にもコンポーネントに指定されうる属性は色々あります。
代表的なものとして、以下の3つがあげられます。

// `nativeOn` というプレフィックスで、コンポーネントのルート要素上で発生するネイティブイベントに対する
// リスナーを登録する
<MyComponent nativeOnClick={ this.onClick } />;

// `domProp` というプレフィックスで、コンポーネントのルート要素のDOMプロパティの値を設定する
<MyComponent domPropInnerHTML="<span>foo</span>" />;

// コンポーネントのルート要素に付加する属性を指定する
<MyComponent min={ 0 } max={ 100 } />;

デフォルトでは(オプションを指定していなければ)、これらはすべてコンパイルエラーになります。
allow-unknown-props オプションを有効にすれば手っ取り早くエラーにならないようにすることができますが、prop名のtypoなどが検出できなくなってしまうなどの副作用もあるので、できれば別の方針で対応したいところです。

ネイティブイベントとDOMプロパティについては、カスタムイベントの場合と同じようにケバブケースな属性名で指定することでコンパイルエラーを回避できます。

<MyComponent nativeOn-click={ this.onClick } />;

// `domProp` というプレフィックスで、コンポーネントのルート要素のDOMプロパティの値を設定する
<MyComponent domProp-innerHTML="<span>foo</span>" />;

HTML属性の指定については、以下のようにJSX-spreadスタイルで指定することでコンパイルエラーを回避できます。

// JSX-spreadを用いてattrsを指定する
<MyComponent { ...{ attrs: { min: 0, max: 100 } } } />;
// babel-plugin-transform-vue-tsx の仕様により、↓はコンパイルは通るが動作しないことに注意
// <MyComponent attrs={ { min: 0, max: 100 } } />;

また、ネイティブイベントとHTML属性については、対応するオプションを有効にすることで元の記述のまま型チェックも可能になります。

//  `nativeOnClick` などのネイティブイベントハンドラを各コンポーネントに指定できるように定義を拡張する
import "vue-tsx-support/options/enable-nativeon";
// HTML要素に指定されうる属性を各コンポーネントに指定できるように定義を拡張する
import "vue-tsx-support/options/enable-html-attrs";

どちらも、(実際には意味のないものも含めて)補完候補が大幅に増えるので、使用頻度が少なければかえって邪魔になるかもしれません。その辺りがトレードオフになります。

特に、 enable-html-attrs の方は、意味がないだけでなく、指定すると壊れるようなものも候補に追加されてしまうので、注意が必要です。
(例えば、type = "number" のinput要素を生成することを想定しているコンポーネントに対して、外から別のtypeを指定したらまともに動かなくなるとか)

おまけ (2017.9.13 追記)

型定義をさらに追加する

ofType() を呼んだ後に使えるメソッドとして、 convert の他に extend というのを追加してみました。
これを使うことで、既に上記のいずれかの方法で型を指定したコンポーネントに対して、さらに型定義を追加することができます。

const OriginalComponent = Vue.extend({ ... });
// MyComponentには、属性fooが定義される
const MyComponent = vts.ofType<{ foo: string }>().convert(OriginalComponent);
// MyComponentExには、fooに加えて属性barが定義される
const MyComponentEx = vts.ofType<{ bar?: number }>().extend(MyComponent);

// OK
<MyComponentEx foo="foo" bar={ 1 } />;

ユースケースとしては、共通のコンポーネント定義としては最大公約数的なものにとどめておき、特定の場所でだけ例外的に必要になるような属性はその場で extend を使って付与するような使い方を想定しています。

また、前に出てきた enable-nativeonallow-unknown-props などの各オプションはプロジェクト全体に影響を及ぼしますが、特定のコンポーネントに対してだけこれらのオプションと同じ作用を与えるメソッドも追加してみました。

const MyComponent = ...;

// enable-nativeon オプションと同等
const MyComponentEx1 = vts.withNativeOn(MyComponent);
// enable-html-attrs と同等
const MyComponentEx2 = vts.withHtmlAttrs(MyComponent);
// allow-unknown-props と同等
const MyComponentEx3 = vts.withUnknownProps(MyComponent);

// OK
<MyComponentEx1 nativeOnClick={ e => console.log(e) } />;
// OK
<MyComponentEx2 min={ 0 } max={ 100 } />;
// OK
<MyComponentEx3 unknown="unknown" />;

イベントハンドラの修飾子

テンプレートを書く場合、イベントハンドラに色々と修飾子(.stop みたいなやつ)を指定することができます。
例えばこんな感じ。

<!-- stop: イベントハンドラの実行前にstopPropagation() を呼び出す -->
<div @click.stop="onClick" />

<!-- tab: タブキーが押されたときだけイベントハンドラを実行する -->
<div @keydown.tab="onTabKeyDown" />

<!-- shift + tab: シフトとタブが押されたときだけイベントハンドラを実行する -->
<div @keydown.shift.tab="onShiftTabKeyDown" />

<!-- tab + prevent: タブキーが押されたときの動作を抑制する( preventDefault() を呼び出す) -->
<div @keydown.tab.prevent />

JSX(TSX)を使うにしろ使わないにしろ、render関数を自分で書くとなったときはこれらの修飾子はほとんど使えなくなります。
公式ドキュメント曰く、 そんなもんイベントハンドラの中にif文書けよ簡単だろ??(意訳)

そうはいってもなかなか面倒なので、ついでにこいつらの代替になるAPIを足しておきました。
上の例と同じことをした場合の例は以下の通りです。

import * as m from "vue-tsx-support/lib/modifiers";

// stop: イベントハンドラの実行前にstopPropagation() を呼び出す
<div onClick={ m.stop(this.onClick) } />;

// tab: タブキーが押されたときだけイベントハンドラを実行する
<div onKeydown={ m.tab(this.onTabKeyDown) } />;

// shift + tab: シフトとタブが押されたときだけイベントハンドラを実行する
<div onKeydown={ m.shift.tab(this.onShiftTabKeyDown) } />;

// tab + prevent: タブキーが押されたときの動作を抑制する( preventDefault() を呼び出す)
<div onKeydown={ m.tab.prevent } />

ちょっとしたコードだけどあればあったで割とべんり。

で、VueとTSXの組み合わせはどうなの?

※個人の感想です。

  • テンプレートを書くのとくらべたら格段に安心感があるし、普通に render() を書くのと比べてもかなり型安全性が高くなるので、その点はよい。

  • 記述自体はpubでテンプレートを書くのと比べると当然だけどかなりダルい。この辺はemmetとかを使えばマシにはなるはず。

  • 広い範囲のレイアウトを受け持つようなコンポーネントはテンプレートで、状態やインタラクションを持つややこしい部品はTSXでみたいな使い分けがいいかなという感じ。

TODO

SVG関連要素の定義が全くないので、追加する。

19
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
8