Vue.js 3 の TSX を TypeScript Compiler だけで動かす方法について

先日の v-tokyo #11 の懇親会で質問されたので、Native TSX Support される Vue 3 でなぜ tsc だけで TSX が動作しないのかを聞かれたのでメモとして残しておこうと思います。

ちなみに Vue 3.0 beta が出た頃に既に検証し終えているコードは以下にあります。

https://github.com/potato4d/vue-next-tsx-only-tsc

TL;DR

  • Vue 3 にて、render function の h 関数が分離された
  • h 関数の分離に伴い、 API が React のに近いインターフェースとなった
  • この2点によって tsc だけで Vue TSX が動くようになったが、 近いだけで微妙に違う仕様によって実用は難しい
    • 具体的には children のとり方が VNode[]...VNode かの違いがある

Vue 3 の Native TSX Support について

まず前提として、 Vue Fes Japan 2018 での発表の頃 から、 Vue.js が TypeScript JSX をサポートすることは明言されていました。

それから月日は流れ、執筆時点(2020/08/30)での vue-next では、既に TSX がサポートされています。

具体的には Vue 3 向けの専用の Babel プラグインの開発と、 runtime-dom パッケージでの JSX の型定義が追加されている状態です。

これらがあることによって、現状 Babel + TSX の環境を用意することで、 Vue.js において TSX を利用することが可能となっています。

なお、 Vue 2.x の時代でも、babel-plugin-transform-vue-jsx というパッケージが存在し、 vue-tsx-support と併用することで、現実的な開発が可能ではありました。

実際にどう使うかは https://github.com/potato4d/pokemon63 とか見てください。

ただ一方で、こうなってくると Native TSX Support が、 Babel 抜きでなし得ないのかが気になるところ。

UIT INSIDE で多少語りましたが、これが実際にできないかを試してみることとしました。

TypeScript の jsxFactory について

TypeScript Compiler (以下 tsc) で JSX をトランスパイルする場合、React 以外では jsx および jsxFactory オプションが重要となります。

これらのオプションを使ったことがない人のほうが多いかもしれませんが、Web 標準ではない JSX という独自仕様について、 TypeScript がどのように解釈するかを設定するためのオプションです。

詳しい説明は オフィシャルのドキュメント にもあるので個別で参照してもらうこととして、重要なのは tsconfig に指定できる jsxjsxFactory のふたつ。

jsx のオプションは "preserve" | "react" を受け取ることができ、 preseve を指定するとそのまま出力を、 react を指定すると、 React.createElement へとハードコーディングされた結果へと変換できます。

つまりは <div /> というコードが preserve では <div /> に、 react では React.createElement('div') に変換されることとなります。

これによって TypeScript では Babel を利用する場合・利用しない場合どちらのケースでも TSX React を利用可能としているのですが、昨今では preact をはじめとした、 React とほぼ Compatible な API を有する Alternative が増加しました。

そういった技術をうまく扱うために、 TypeScript jsx とあわせて jsxFactory オプションを用意しています。

これは "jsx": "react" のときのファクトリ関数を置き換えるための設定であり、 “React.createElement” の部分を完全に書き換えることができます。たとえば、 preact.h のような設定にすれば、 preact に対応できる。といった具合です。

これを利用することで、 React と Compatible な API を持っている場合に限り、他のフレームワークでも TSX が利用可能となります。

なぜ TypeScript が web 標準から大きく逸脱した React の記法を取り扱っているのか?   

調べた結果、 TypeScript 1.6 から JSX がサポートされていることまではわかりました。
当時の PR もすんなりマージされているものと膨大な議論がおこなれているものが両方あり、全て追うことは諦めましたが、もし事情を理解している人が居たらご連絡ください。 https://www.typescriptlang.org/docs/handbook/release-notes/typescript-1-6.html

ただこれまで Vue.js は、 Render Function の API の都合上。これを生かして開発することは実質できない状況でした。

Vue 2.x では、React.createElement に相当する h 関数が export されておらず、また、Render Function 内部の h 関数の API も違うという状況です。

Vue 3 の Render Function の React 化

そんな状況が Vue 3 において、この状況は一変します。

Vue 3 において import { h } from 'vue' が可能となり、また、 h 関数の仕様が React に非常に近いような API 形式を取ることができるようになりました。

https://github.com/vuejs/rfcs/blob/master/active-rfcs/0008-render-function-api-change.md

これによって、以下の 2 点を満たすことにより、 Vue でも TSX が利用可能となります。

  • import { h } from 'vue' をファイル先頭に記述する
  • "jsxFactory": "h" を指定する

実際にやってできあがったものが以下です。

https://github.com/potato4d/vue-next-tsx-only-tsc

これで毎回 import { h } from 'vue' を指定することを許容すれば、実用できるようになりました。

render function の些細な違いによる壁

というわけでもなく、そのまま開発していると大きな違いにぶつかります。

実は似通っている両者の API には、微妙な違いがあります。一番大きいのは、 childNode の取り扱い。実は Vue 3 の API においても、 React と Vue では children の受け取りかたに違いがあります。

|Framework|API||Type| |-|-| |React|h('div', {}, h(), h())|h(VNode | string, Option, ...VNode)| |Vue|h('div', {}, [h(), h()])|h(VNode | string, Option, VNode[])|

...VNode なのか、 VNode[] なのか程度の些細な違いですが、この違いがあるだけでも、複数の childNode を持つコンポーネントを取り扱うことができなくなります。

そのため、実際に使うのであれば、例えば以下のような工夫が必要となります。詳細な型定義はここでは一旦省略します。

// h.tsx
import { VNode, h as render } from 'vue'

type FIXME<T> = any
// React: `h(Counter, {}, h(), h())`
// Vue  : `h('div', {}, [h(), h()])`
export function h(c: FIXME<VNode | string>, o: FIXME<{}>, ...args: FIXME<VNode>): VNode {
  const arg = [...args] as VNode[];
  return render(c, o, arg)
}

こういったコードを書いた上で、 import { h } from '~/utils/jsx/h' のようなコードを経て、ようやく記述することができるようになります。

適当にイベントや state を定義したり、 Composition API を使ってみたりした分にはこれ以外に問題は起こりませんでしたが、本当に Native TSX 出やりたい場合、今後も似たような API の差異を吸収し続ける必要があります。

おそらく Babel プラグインが提供している mergeProps などもどこかで困りごととして出てくるはずです。

そういったものを都度対応するとなると、簡単に最低限の互換性は担保できる上、コードとしては実現可能であるが、実用性の観点では難しいといえる状態かなと思います。

最終的にどうすればよいか

つまるところ、実現は可能となり、夢はあるが実用性の観点ではあまり現実的とはいえないという具合に落ち着くかなと思います。

ただ例えば Storybook のような React 資産が多い上にユーザーインターフェースに関わる部分のみに関心があるツールの場合、もしかしたら React 向けに作られたプラグインを Vue で利用する。みたいな夢のある使い方ができるようになるのかなぁ?と思ったりしています。

ただサンプルを書いてはや半年。まだそういったシチュエーションは訪れていません。

あと、個人的には最近出たばかりの jsx-next に気持ちが傾いていて、十分な利用例が伴ったらまた共有します。

Buy Me A Coffee

もしこの記事が役に立ったなら、
こちらから ☕ を一杯支援いただけると喜びます