🤯

Nuxt3 with TSX

2023/06/04に公開

動機

もともとNuxt 2ばかり書いていた。あるときNextを書く機会があり、TSXの型サポートの強力さに魅了され、Nuxtが途端に雑魚に見えてきた。
いやいやそんなことはない。NuxtにはNuxtの良さがある。NuxtでTSXが書ければ両者のいいとこ取りができるはずだ。

先行研究

こちらの記事でもNuxt3 x TSXの組み合わせを試している。beta版の頃のもの(2021/12/22)ではあるが、composition apiとうまいこと同居させながらTSXを書いてみたり、拡張子が.tsxであるような完全なTSXを書いてみたりと、基本的なことは一通りできそうである。Nuxt 3の正式リリースからそれなりに経った今、ここで紹介されていることにどのような変化があったのか確認する必要がある。
https://zenn.dev/e_chan1007/articles/070d3b337cd606

そのおよそ半年前(2021/7/1)の記事であるこちらは、Vueのわかりやすさを活かしたコードを考えている。ここで述べられているstyleの書きやすさは確かに捨て難いものである。しかし、この記事も約2年前である。Nuxt 3が正式リリースされた今、再びこの件について調査する価値はあるだろう。
https://tech.andpad.co.jp/entry/2021/07/01/170000

やりたいこと

「型に強いNuxt」を作りたい。そのために、

  • Vueのtemplate記法ではなく、TSXの記法を用いる
  • それ以外の部分は、なるべく普通のNuxt Composition APIに寄せた書き方をする。でないとNextの下位互換になってしまう。
  • 拡張子は.vueとする。Reactのように拡張子が.tsxとなる所謂「完全なTSX」は、useStateのようなものがない以上実用性に欠ける(refで代用できそうに見えて、実はできない)。

Volarで十分じゃないかと言われそうだが、選択肢は多いに越したことはない。

Get started

公式ウェブサイトにあるコマンドでリポジトリを作成する。

$ npx nuxi@latest init nuxt-tsx
Need to install the following packages:
  nuxi@3.5.2Ok to proceed? (y)
Nuxi 3.5.2
✨ Nuxt project is created with v3 template. Next steps:
 › cd nuxt-tsx
 › Install dependencies with npm install or yarn install or pnpm install
 › Start development server with npm run dev or yarn dev or pnpm run dev

yarnを使ってパッケージをインストールする。

$ cd nuxt-tsx
$ yarn install

ここまでは普通のNuxtである。

TSXを導入

まずはHello, world

早速先行研究との違いが現れた。先行研究ではJSXを変換するために色々と追加設定を行っていたが、Nuxt@3.5.2ではいきなりTSXを書き始められる。
Vue2のComposition APIとVue3のscript setupのどちらに寄せるかによって何通りか書き方があるが、まずはVue2に近い書き方を試す。

app.vue
 <template>
   <div>
-    <NuxtWelcome />
+    <NuxtPage />
   </div>
 </template>
pages/index.vue
<script lang="tsx">
export default defineComponent({
  setup() {
    const message = ref('Hello, world!')
    return () => (
      <div class='hello'>{message.value}</div>
    )
  },
})
</script>

<style scoped>
.hello {
  font-weight: bold;
}
</style>

注意点は

  • script lang="ts"ではなくscript lang="tsx"とする
  • classNameではなくclassを用いる
  • マスタッシュ{{}}ではなく、TSXの記法{}を用いる
  • .valueをちゃんと書く

といったところ。

ここでとんでもない落とし穴がある。上のコードを少し変えて

pages/index.vue
const world = ref('world')
return () => (
  <div class='hello'>Hello, {world.value}!</div>
)

してはいけない。ターミナルにエラーは出ず、ブラウザ上でも一見ちゃんと表示されるのだが、コンソールを見るとエラーを吐いている。

複数のテキストノードが連続している場合、サーバ側では最初のノードしかレンダーされないようである。
「テキストノードが」連続するのがダメなのでフラグメントで囲ったり、

<div>
  Hello, <>{world.value}</>!
</div>

テキストノードが「連続する」のがダメなので全体を一つのノードにしたり、

<div>
  {'Hello, ' + world.value + '!'}
</div>

あるいはかなりテクいが間にnullを挟んだり、

<div>
  Hello, {null}{world.value}{null}!
</div>

一応対処可能ではあるがかなり非自明である。issueが立っているので解決されるのを待とう。

devtoolでrender functionを見てみると...

Vue.js devtoolを使うと、当該部分のrender functionを覗き見できる。
先ほどエラーを吐いていたコードでは

() => _createVNode(
  "div",
  { "class": "hello" },
  [
    _createTextVNode("Hello, "),
    world.value,
    _createTextVNode("!")
  ]
)

フラグメントを使うコードでは

() => _createVNode(
  "div",
  { "class": "hello" },
  [
    _createTextVNode("Hello, "),
    _createVNode(_Fragment, null, [world.value]),
    _createTextVNode("!")
  ]
)

一つのノードにまとめるコードでは

() => _createVNode(
  "div",
  { "class": "hello" },
  ['Hello, ' + world.value + '!']
)

となっている。ちなみに

pages/index.vue
export default defineComponent({
  setup() {
    return () => (
      <div class="hello">Hello, {}world!</div>
    )
  },
})

と書くとdevtoolから見えるrender functionは

() => _createVNode(
  "div",
  { "class": "hello" },
  [
    _createTextVNode("Hello, "),
    _createTextVNode("world!")
  ]
)

となり、これもHydration text mismatchとなる。
確かにテキストノードが連続する時にエラーとなることが確認できる。

ところで、ここまで実際に手を動かしながら読んできた方はもう一つバグに気づいているかもしれない。TSXを使ったコンポーネントでHot Reloadingが壊れているのである。これもissueが立っているので、気長に待とう。

コンポーネントとprops、v-bind

さて、色々あったがなんとかHello worldを表示することができた。次はpropsを用いてコンポーネントに値を渡してみる。
ここで悲しいお知らせだ。記事執筆時点ではTSX記法でコンポーネントの自動importができず、一つ一つ丁寧に真心こめてimportしなければならない。これもissueが立っているが、自動importができなくても実装はできるので、しょうがない、次に進む。
さて、ここでもVue2のComposition APIに寄せるかVue3のscript setupに寄せるかで書き方がかなり変わる。前者の場合

components/Child.vue
<script lang="tsx">
export default defineComponent({
  props: {
    count: {
      type: Number,
      default: -1
    }
  },
  setup(props) {
    return () => <div>props.count: <>{props.count}</></div>
  }
})
</script>

のように書ける。後者の場合

components/Child2.vue
<script setup lang="tsx">
const props = withDefaults(defineProps<{
  count?: number
}>(), {
  count: -1
})
const render = () => (
  <div>props.count: <>{props.count}</></div>
)
</script>

<template>
  <render />
</template>

のように書ける。呼び出す側も同様の選択肢があり、Hello worldのときのように

pages/index.vue
<script lang="tsx">
import { Child, Child2 } from '#components'
export default defineComponent({
  setup() {
    const count = ref(0)
    const increment = () => ++count.value
    return () => (
      <div>
        <button onClick={increment}>Click me!</button>
        <Child count={count.value} />
        <Child2 count={count.value} />
      </div>
    )
  },
})
</script>

と書いてもいいし、script setupを使って

pages/index.vue
<script setup lang="tsx">
import { Child, Child2 } from '#components'
const count = ref(0)
const increment = () => ++count.value
const render = () => (
  <div>
    <button onClick={increment}>Click me!</button>
    <Child count={count.value} />
    <Child2 count={count.value} />
  </div>
)
</script>

<template>
  <render />
</template>

と書いてもいい。TSXではpropsの値に{}を使えるので、v-bindの出番はない。

Vue2の記法を用いる方法は、templateを使わずscriptだけで完結できるシンプルさがある一方、propsの型指定は煩雑である。script setupを用いる方法はpropsの型指定がすっきりする一方、どうしてもtemplateを書かなくてはいけなくなる。

以下では記事が長くなりすぎないよう、script setupの記法のみ記す。

v-for、v-if

v-forは配列のmapを使い、v-ifは{条件 ? <foo /> : null}などとすればよい。
これに関しては何も問題はない。

emit、v-on

emitの書き方は普通のVueとほぼ変わらない。生のDOMイベントを購読する場合は普通のTSXの書き方に従えばよい。

components/Child3.vue
<script setup lang="tsx">
const emit = defineEmits(['someEvent'])
const render = () => <div onClick={() => emit('someEvent')}>Click me!</div>
</script>

<template>
  <render />
</template>

といった書き方ができる。カスタムイベントを購読する場合は

pages/index.vue
<Child3 onSomeEvent={handler} />

のように書ける。
問題はイベント修飾子である。DOMイベントもカスタムイベントも、イベント名の後ろに修飾子をキャメルケースでつければよいと公式ドキュメントには書いてある。実際それで動くのだが、VSCode上では赤波線が引かれる。

v-model

v-modelといえばinputタグだが、初っ端から壊れている。

pages/index.vue
const text = ref('')
return () => (
  <div>
    <input v-model={text.value} />
    <div>{text.value}</div>
  </div>
)

このように書いてブラウザ上で値を変更しようとするとエラーを吐く。

Uncaught (in promise) TypeError: Cannot set properties of null (setting 'nodeValue')

非常に不思議なことに、これはtextの初期値に空でない文字列を指定すれば回避できる。
もしデフォルト値を指定したくない場合は、とりあえず半角スペースでも入れておいてonFocusOnceで空文字列にするという荒技もあるが、スマートとはいえない。

コンポーネントにv-modelを用いる場合、以下のようにすればよい。

pages/index.vue
<script setup lang="tsx">
import { Child4 } from '#components'
const count = ref(0)
const render = () => (
  <div>
    <button onClick={() => --count.value}>Click me!</button>
    <Child4 v-model:count={count.value} />
  </div>
)
</script>

<template>
  <render />
</template>
components/Child4.vue
<script setup lang="tsx">
const props = defineProps<{
  count: number
}>()
const emit = defineEmits(['update:count'])

const render = () => (
  <div>
    <button onClick={() => emit('update:count', props.count + 1)}>Click me!</button>
    <div>{props.count}</div>
  </div>
)
</script>

<template>
  <render />
</template>

slot

公式ドキュメントによれば、slotを受け入れる側は以下のように書ける。

components/CustomWrapper.vue
<script setup lang="tsx">
const slot = defineSlots<{
  default(): any
  named(props: { foo: string }): any
}>()
const render = () => (
  <div>
    <div>{slot.default?.()}</div>
    <div>{slot.named?.({ foo: 'bar' })}</div>
  </div>
)
</script>

<template>
  <render />
</template>

slotを挿入する側は次のように書ける。

pages/index.vue
<script setup lang="tsx">
import { CustomWrapper } from '#components'
const render = () => (
  <div>
    <CustomWrapper>
      default slot
    </CustomWrapper>
    <CustomWrapper>
      {{ named: () => 'named slot' }}
    </CustomWrapper>
    <CustomWrapper>
      {/* `{"foo":"bar"}` */}
      {{ named: (props) => JSON.stringify(props) }}
    </CustomWrapper>
  </div>
)
</script>

<template>
  <render />
</template>

slotのデフォルト値を指定するときは、

slot.default?.() || <foo />

とか

slot.default ? slot.default() : <foo />

とか書けばよい。

悲しいことに、named: (props) => JSON.stringify(props)の箇所にあるpropsにはdefineSlotsで定義した型がついていないため、上記のコードではパラメーター 'props' の型は暗黙的に 'any' になります。と怒られる羽目になる。

組み込みコンポーネント

公式ドキュメントによれば、Transionなどの組み込みコンポーネントはTSXでは明示的なimportが必要である。そもそもコンポーネントの自動importが効いていなかったのだから今更驚かないが、どこからimportするのかは触れておくべきだろう。とはいっても単純で、Vueの組み込みコンポーネントは'vue'からimportすればよく、

import { KeepAlive, Teleport, Transition, TransitionGroup } from 'vue'

Nuxtの組み込みコンポーネントは'#components'からimportすればよい。

import { ClientOnly, NuxtLink /* 以下略 */ } from '#components'

まとめ

Vue3のさまざまな機能をTSXに移植してみた。正直なところ、あまりにもバグが多すぎて今のところこれを実際に使う気にはなれない。
VSCode拡張機能Volarがそこそこ頑張っているようなので、むしろいかに質の良いtemplateを書くか考えた方が有益かもしれない。どうしてもTSXが書きたいなら大人しくNextなどのReact系フレームワークを使った方がよいだろう。
とはいっても、この記事で紹介した多くの不具合は既にissueが立っており、これらが解決された頃に再び検証してみれば違った結果が得られるかもしれない。

Discussion