Feb 8, 2019

TSXで型安全なVueアプリケーションを体験する

先日開催されたVue FesおよびVueConf TOにて、Evanから正式にVue 3.0のいくつかの機能がアナウンスされました。

その中で特に興味を引いたのは公式なTSXのサポートアナウンスです。

Literal Templateを標準スタイルとしているが故に「Vueで型安全なアプリケーションは書けない」と長らく叫ばれていましたが、ようやくその汚名(?)も晴らせそうそうですね。

今回は@wonderful-panda氏の提供しているvue-tsx-supportを利用して、一足先に型安全なVueアプリケーションの世界を体験してみたいと思います。

Embedded content: https://github.com/wonderful-panda/vue-tsx-support

vue create app

今回はGitHubのレポジトリ情報をGraphQLで取得するサンプルアプリケーションをVue CLIで作成しました。

レポジトリはこちら↓

Embedded content: https://github.com/andoshin11/vue-tsx-apollo-example

tsx環境の設定

vue-tsx-supportを導入するためにはwebpackの設定を書き換えてbabel-loaderにJSX用のloaderを設定したりするなどいくつかの準備が必要なのですが、幸いなことに@chanlito氏がVue CLI用のプラグインを作ってくれました。

Embedded content: https://github.com/chanlito/vue-cli-plugin-tsx-support

そのため、アプリケーションルートに移動して下記のコマンドを実行するだけで簡単に導入は終了。

$ vue add tsx-support

ちなみにNuxtで使いたい方はWebpackの設定をこんな感じに書けば動くんじゃないでしょうか。知らんけど

  config.resolve.extensions.push('.tsx')
  config.module.rules.push({
    test: /\.tsx$/,
    use: [
      {
        loader: 'babel-loader',
        options: {
          presets: [['@babel/env', { modules: 'commonjs' }]],
          plugins: [
            'babel-plugin-vue-jsx-modifier',
            'babel-plugin-transform-vue-jsx',
            '@babel/plugin-syntax-dynamic-import',
            [
              '@babel/plugin-transform-runtime',
              {
                regenerator: true
              }
            ]
          ]
        }
      },
      {
        loader: 'ts-loader',
        options: {
          appendTsSuffixTo: [/\.vue$/],
          transpileOnly: process.env.NODE_ENV === 'development' ? true : false
        }
     }
    ]
  })

tsconfig.jsonにも "jsx": preserveの設定を追記するのをお忘れなく。

Nuxt 2.4からはTSXがデフォルトサポートされているのでBabelの設定だけで十分かもです。

tsxでコンポーネントを記述する

詳しいAPIについてはドキュメントを読むのが一番ですが、基本的には下記のようにRender Function内にTSXを記述するだけ。

その他のAPIについては通常のVue with TSアプリケーションと同様です

// components/Title.tsx
import * as tsx from 'vue-tsx-support'

export default tsx.component({
  name: 'Title',
  props: {
    label: {
      type: String,
      required: true as true
    }
  },
  render() {
    return (
      <div>
        <h1>{this.label}</h1>
      </div>
    )
  }
})

RequiredなPropsについては true as trueとしている点に注意してください。Boolean型ではなくTrue型を明示的に指定することで、このコンポーネントを親コンポーネントから呼び出すときに当該PropsがOptionalではないことに気がつけます。

スクリーンショット 2019-01-24 11.01.24

プロパティの補完もバッチリです!

スクリーンショット 2019-01-24 11.01.43

イベントハンドラ

v-on:click, @clickなどで指定していたイベントハンドラは、JSX記法では on${Event Name}となります。

// components/Buttons.tsx
import * as tsx from 'vue-tsx-support'

export default tsx.component({
  name: 'Button',
  methods: {
    sayHi() {
      alert('Hi!')
    }
  },
  render() {
    return <button onClick={this.sayHi}>Test</button>
  }
})

Event Type

Emitするイベントを親コンポーネント側で指定したいときには、 componentFactoryOf関数を利用して対象のイベント型を指定します。

// components/Button.tsx
import * as tsx from 'vue-tsx-support'

interface Events {
  onSuccess: (msg: string) => void
}

export default tsx.componentFactoryOf<Events>().create({
  name: 'Button',
  render() {
    return <button onClick={() => this.$emit('success', 'test')}>Test</button>
  }
})

// container.tsx
...
return (
  <Button onSuccess={(msg: string) => alert(msg)} />
)
...

シンタックスの留意点

JSXでは基本的にJavaScriptのパダライムでDOMを構築していきます。そのため要素の繰り返しや、条件分岐によるDOMの表示切り替えには Array#mapや三項演算子を利用する点に注意してください。

主に気をつけるべきは下記の項目でしょうか。

  • v-if, v-else → JSのif文もしくは三項演算子で表現
  • v-for → Iterable#map
  • v-model → 元々が糖衣構文なので、値の伝達はvalue propへ、変更の検知はonInputフックで取るように

JSXからRender Treeへの変換にはbabel-plugin-transform-vue-jsxが使われているので細かい仕様が知りたい方はそちらを参考にどうぞ。

また createElement関数の第二引数にはVueのData Objectが呼ばれるなど、ReactのJSXとは微妙に違う部分もあるので、一度こちらに目を通しておくと良いでしょう。

コンポーネントのスタイリング

現代のフロントエンドにおいて、GlobalなCSSを愚直にBEMで定義する人は少ないかと思います。Vueのプロジェクトでも一般的にはvue-loaderを利用してScoped CSSを活用している人が殆どでしょう。 しかし残念ながらvue-loaderを通さないTSXではその機能も利用できません。

そのため今回はCSS Modulesを利用することにしました。

その他のCSS in JSの手法としてはStyled ComponentsEmotionを利用することも検討しましたが、既存のVueコンポーネントから資産を使いまわせることやLint周りの利点を考慮してピュアなCSSを書く方式に落ち着きました。

Vue CLIで作成したアプリケーションでCSS Modulesを利用するには、 vue.config.jsを作成して下記の内容を記述してください。

// vue.config.js
module.exports = {
  css: {
    loaderOptions: {
      css: {
        modules: true,
        importLoaders: 1,
        localIdentName: '[local]_[hash:base64:5]'
      }
    }
  }
}

設定が完了したらスタイリングを行いたいコンポーネントディレクトリを以下のように設定します。

src/components/
└── Title
    ├── Title.tsx
    ├── index.ts
    ├── styles.css
    └── styles.css.d.ts

今回はTitleコンポーネントを例にとりました。それぞれのファイルの中身はこんな感じ

// Title.tsx
import * as tsx from 'vue-tsx-support'
import styles from './styles.css'

export default tsx.component({
  name: 'Title',
  props: {
    label: {
      type: String,
      required: true as true
    }
  },
  render() {
    return <div class={styles.Title}>{this.label}</div>
  }
})
// index.ts
import Title from './Title'

export default Title
/* styles.css */
.Title {
  font-size: 24px;
}
// styles.css.d.ts
export const Title: string;

TypeScriptでCSS Modulesを利用する上で重要なのが最後のDeclaration fileです。当然ながらimportしたCSSファイルはそのままではTS Compilerからmoduleとして認識されないだけでなく、CSSファイル内にどのようなセレクタが定義されているのかもTSからは知るすべがありません。

そこでセレクタ(Class Name)を1つずつstring型のconstとしてexportしてあげることで、TSX上で安全にCSS Modulesを利用することが可能になります。

当然、補完も有効です。

スクリーンショット 2019-01-24 14.39.54

流石に毎度すべてのセレクタを定義するのは骨が折れるので、開発時には@Quramyさんの作成したtyped-css-modulesを利用しています。

// package.json
...
"scripts": {
  ...
  "watch:css": "tcm src -w"
  ...
}

Watch modeでCLI上で起動しておくと、CSSファイルを更新するたびに最新のDeclaration fileを生成してくれる神ツールなのでオススメです。

SSRする際の注意点

NuxtでCSS Modulesを利用する際は、SSR時のCSSのインライン化がデフォルトでは行われない点に注意してください。クライアントサイドでInject用のスクリプトが読み込まれてからHTML Headへのスタイル埋め込みが実行されるので、初回レンダリング時にサイトが「ガタッ」となります。

First Contentful Paintのタイミングが遅くなってしまうのは悲しいですね(泣)

対策としては nuxt.config.js内の build.loaders.vueStyle.manualInjectオプションをtrueに設定した上で、各コンポーネント内に下記の処理を追加します。

// SomeComponent.tsx
import styles from './styles.css'
...
  beforeCreate() {
    if ((styles as any).__inject__) {
      ;(styles as any).__inject__(this.$ssrContext)
    }
  },
...

全コンポーネントに上記を記述するのは流石に面倒なので、そのうち何かしらのloaderを書きたいです。

今回調査するまで知らなかったのですが、普段はvue-loaderが同様の処理をしてくれるため、あまり意識することはありませんでした。当該処理はこの辺。ありがたや

おまけ

vue-tsx-supportを使わなくてもVue 2.xでProps補完が効くように、Pull Requestをいくつか作っています。

どうしてもVue 3.0が待ちきれない方は上のPRにコメントいただくか、ブランチをローカルビルドしてご利用していただければと思います。

最後に

今回は全編をTypeScriptでお届けしました。ここまでくるともうReactとそんなに書き味は変わんねぇなーという気持ちです。

補完がゴリゴリに効いてかつ型安全なDOMが書ける快適さを噛みしめる一方で、将来的にそれらのコードをHTMLベースの何か(Web Compoennts?)にextractする際は、やはり旧来のVueのdirectiveでDOMを組み立てる方が楽な気もしました。 v-forとかv-ifで制御する事に対して「キモっ」と思う人たちの気持ちも分かるのでこの辺りのトレードオフはプロジェクトごとに見極める必要がありそうです。

Vueのエラーレポート機能は非常に優秀なのでPropsの指定漏れ程度なら開発時にほぼほぼ潰せるのですが、やはり静的解析の恩恵も捨てがたく...

もうしばらくプライベートで検証してみるつもりなので、興味ある人はその辺のトピックについて語り(飲み)ましょう。

今回のサンプルアプリケーションはNetlifyにホストしてあるので、ローカルで立ち上げるのが面倒な人は下記のリンクからどうぞ↓

https://boring-leavitt-d5a060.netlify.com/

Embedded content: https://boring-leavitt-d5a060.netlify.com/