wheatandcatの開発ブログ

React Nativeで開発しているペペロミア & memoirの技術系記事を投稿してます

NuxtでComposition API とTSとTSX + CSS Modulesの組み合わせについて考えてみる

github.com

上記のプロジェクトでComposition API とTypeScriptを組み込んでみたので、その時の考えをまとめて記事にしました。

前置き

Composition API とは

Composition API RFC | Vue Composition API

Composition APIについては上記の公式ホームページの概要を読むのが良いと思います。

メリット的には以下みたいなものがあげられます

  • 型推論がしやすい形式でVueが書ける
  • Vueのコードを分割しやすくなる

Vue v3では標準で使用できます。Vue v2でも以下のライブラリを使用すれば実装できます

github.com

Vue.jsのTSXサポート

以下のライブラリを使用することでvueをtsxで書くことができます。

github.com

vueでComposition API + TSXで書かれたデモが、こちらになります。

github.com

ファイルの拡張子をtsxにするか、vueファイルで <script lang="tsx">にするかで利用可能になります。

NuxtでTypeScriptを採用した時の実装パターン

上記の事を踏まえた上で、現状Nuxtでの実装パターン考えると、こんな感じ

  • パターン①. Composition API + TSXファイル + CSS Modules
  • パターン②. Composition API + Vueファイル + <script lang="tsx">
  • パターン③. Composition API + Vueファイル + <script lang="ts">

各パターンについては以下でデモを作成したので、これを元に解説

github.com

では、各パターンについて解説していきます。

パターン①. Composition API + TSXファイル + CSS Modules

コード的に、こんな感じになります。

tsx-demo/pages/login.tsx

import { defineComponent, SetupContext } from '@vue/composition-api'
import firebase from 'firebase'
import Login from '~/components/templates/login/index.tsx'

export type LoginType = {
  fbGoogleLogin: () => Promise<void>
}

export default defineComponent({
  layout: 'simple',
  setup(_, context: SetupContext) {
    const fbGoogleLogin = async () => {
      try {
        const googleProvider = new firebase.auth.GoogleAuthProvider()
        await context.root.$fireAuth.signInWithPopup(googleProvider)
        context.root.$fireAuth.onAuthStateChanged(() => {
          context.root.$router.push('/')
        })
      } catch (e) {
        console.log(e)
      }
    }
    return () => (
      <div>
        <Login fbGoogleLogin={fbGoogleLogin} />
      </div>
    )
  },
})

setupのreturnに、そのままTSX形式で記載します。
TSXにする場合は、Vueの<template><style>は使用できません。

なので、TSXにする場合のstyleの宣言にはCSS Modulesを使用します。 書き方は以下の通り

tsx-demo/components/templates/login/index.tsx

import { defineComponent } from '@vue/composition-api'
import style from './index.module.scss?module'
import { LoginType } from '~/pages/login.tsx'

export default defineComponent({
  props: {
    fbGoogleLogin: { type: Function, required: true },
  },
  setup(props: LoginType) {
    return () => (
      <div class={style.root}>
        <div class={style.icon}>
          <v-img
            src="/logo.png"
            alt="logo"
            width="50px"
            height="50px"
            contain
          />
        </div>
        <v-sheet
          tile
          class="mx-auto"
          height="300"
          width="100%"
          max-width="480"
          elevation="2"
        >
          <div class={style['form-container']}>
            <div class={style.title}>新規会員登録</div>
            <v-divider class="divider" />
            <div class={style['button-container']}>
              <v-btn
                x-large
                class="text-transform-none"
                block
                outlined
                onClick={props.fbGoogleLogin}
              >
                <v-icon left color="secondary">
                  mdi-google-plus
                </v-icon>
                Googleで登録する
              </v-btn>
            </div>
            <div class={style.guide}>
              <div class={style.link}>
                <v-btn
                  x-small
                  class="text-transform-none"
                  text
                  color="primary"
                  href="https://amazing-hawking-a280c3.netlify.com/general/account/"
                  target="_blank"
                >
                  会員登録するとできること
                  <v-icon small left>
                    mdi-chevron-right
                  </v-icon>
                </v-btn>
              </div>
            </div>
          </div>
        </v-sheet>
        <div class={style.links}>
          <v-btn
            x-small
            class="text-transform-none"
            text
            href="https://peperomia.app/tos"
            target="_blank"
          >
            利用規約
          </v-btn>
          |
          <v-btn
            x-small
            class="text-transform-none"
            text
            href="https://peperomia.app/policy"
            target="_blank"
          >
            プライバシーポリシー
          </v-btn>
        </div>
      </div>
    )
  },
})

tsx-demo/components/templates/login/index.module.scss

@import '~/assets/variables.scss';

.root {
  display: flex;
  justify-content: center;
  align-items: center;
  flex-direction: column;
  height: 100%;
}

.icon {
  display: flex;
  justify-content: center;
  align-items: center;
  padding: 2rem;
}

.form-container {
  display: flex;
  justify-content: center;
  flex-direction: column;

  .title {
    text-align: center;
    padding-top: 15px;
  }

  .divider {
    margin: 15px;
  }

  .button-container {
    display: flex;
    justify-content: center;
    flex-direction: column;
    padding: 25px 60px;
    width: 100%;
  }

  .guide {
    position: relative;
    height: 100px;

    .link {
      position: absolute;
      left: 35%;
      bottom: 0;
    }
  }
}

.links {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 50px;
  font-size: 12px;
}

内容的には以下の部分でscssファイルをimportして

import style from './index.module.scss?module'

importしたstyleをclassに設定して使用できます。

 <div class={style.title}>新規会員登録</div>

また、TSXは一部Vueのテンプレート構文とは異なる構文があります。

例えばvueファイルでは、クリックイベントのハンドラーを以下のように書きますが、

 <v-btn @click="onClick">登録する</v-btn>

TSXでは@clickonClickに変わります

 <v-btn onClick={props.onClick}>登録する</v-btn>

この辺の微妙な違いはドキュメントに記載してあります。

GitHub - wonderful-panda/vue-tsx-support: TSX (JSX for TypeScript) support library for Vue

パターン②. Composition API + Vueファイル + <script lang="tsx">

コードはほぼパターン①と変わりませんが、vueファイル内にtsxを宣言しているためVueの<style>を使用できます。 コードは以下の通りです。

vue-script-tsx-demo/components/templates/login/index.vue

<script lang="tsx">
import { defineComponent } from '@vue/composition-api'
import { LoginType } from '~/pages/login.vue'
export default defineComponent({
  props: {
    fbGoogleLogin: { type: Function, required: true },
  },
  setup(props: LoginType) {
    return () => (
      <div class="root">
        <div class="icon">
          <v-img
            src="/logo.png"
            alt="logo"
            width="50px"
            height="50px"
            contain
          />
        </div>
        <v-sheet
          tile
          class="mx-auto"
          height="300"
          width="100%"
          max-width="480"
          elevation="2"
        >
          <div class="form-container">
            <div class="title">新規会員登録</div>
            <v-divider class="divider" />
            <div class="button-container">
              <v-btn
                x-large
                class="text-transform-none"
                block
                outlined
                onClick={props.fbGoogleLogin}
              >
                <v-icon left color="secondary">
                  mdi-google-plus
                </v-icon>
                Googleで登録する
              </v-btn>
            </div>
            <div class="guide">
              <div class="link">
                <v-btn
                  x-small
                  class="text-transform-none"
                  text
                  color="primary"
                  href="https://amazing-hawking-a280c3.netlify.com/general/account/"
                  target="_blank"
                >
                  会員登録するとできること
                  <v-icon small left>
                    mdi-chevron-right
                  </v-icon>
                </v-btn>
              </div>
            </div>
          </div>
        </v-sheet>
        <div class="links">
          <v-btn
            x-small
            class="text-transform-none"
            text
            href="https://peperomia.app/tos"
            target="_blank"
          >
            利用規約
          </v-btn>
          |
          <v-btn
            x-small
            class="text-transform-none"
            text
            href="https://peperomia.app/policy"
            target="_blank"
          >
            プライバシーポリシー
          </v-btn>
        </div>
      </div>
    )
  },
})
</script>

<style lang="scss" scoped>
@import '~/assets/variables.scss';
.root {
  display: flex;
  justify-content: center;
  align-items: center;
  flex-direction: column;
  height: 100%;
}
.icon {
  display: flex;
  justify-content: center;
  align-items: center;
  padding: 2rem;
}
.form-container {
  display: flex;
  justify-content: center;
  flex-direction: column;
  .title {
    text-align: center;
    padding-top: 15px;
  }
  .divider {
    margin: 15px;
  }
  .button-container {
    display: flex;
    justify-content: center;
    flex-direction: column;
    padding: 25px 60px;
    width: 100%;
  }
  .guide {
    position: relative;
    height: 100px;
    .link {
      position: absolute;
      left: 35%;
      bottom: 0;
    }
  }
}
.links {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 50px;
  font-size: 12px;
}
</style>

<script lang="tsx">を記載することでtsx形式で書くことができます。 vueファイルで実装しているのでパターン①と違いファイルを1つにまとめられます。 また、tsxで書くので、<template>はありません。

パターン③. Composition API + Vueファイル + <script lang="ts">

3パターンの中で最も標準的なVueのコードに近いと思います。 コードは以下の通りです。

vue-script-ts-demo/components/templates/login/index.vue

<template>
  <div class="root">
    <div class="icon">
      <v-img src="/logo.png" alt="logo" width="50px" height="50px" contain />
    </div>
    <v-sheet
      tile
      class="mx-auto"
      height="300"
      width="100%"
      max-width="480"
      elevation="2"
    >
      <div class="form-container">
        <div class="title">新規会員登録</div>
        <v-divider class="divider" />

        <div class="button-container">
          <v-btn
            x-large
            class="text-transform-none"
            block
            outlined
            @click.prevent="fbGoogleLogin"
          >
            <v-icon left color="secondary">mdi-google-plus</v-icon>
            Googleで登録する
          </v-btn>
        </div>
        <div class="guide">
          <div class="link">
            <v-btn
              x-small
              class="text-transform-none"
              text
              color="primary"
              href="https://amazing-hawking-a280c3.netlify.com/general/account/"
              target="_blank"
            >
              会員登録するとできること
              <v-icon small left>mdi-chevron-right</v-icon>
            </v-btn>
          </div>
        </div>
      </div>
    </v-sheet>
    <div class="links">
      <v-btn
        x-small
        class="text-transform-none"
        text
        href="https://peperomia.app/tos"
        target="_blank"
      >
        利用規約
      </v-btn>
      |
      <v-btn
        x-small
        class="text-transform-none"
        text
        href="https://peperomia.app/policy"
        target="_blank"
      >
        プライバシーポリシー
      </v-btn>
    </div>
  </div>
</template>

<style lang="scss" scoped>
@import '~/assets/variables.scss';
.root {
  display: flex;
  justify-content: center;
  align-items: center;
  flex-direction: column;
  height: 100%;
}
.icon {
  display: flex;
  justify-content: center;
  align-items: center;
  padding: 2rem;
}
.form-container {
  display: flex;
  justify-content: center;
  flex-direction: column;
  .title {
    text-align: center;
    padding-top: 15px;
  }
  .divider {
    margin: 15px;
  }
  .button-container {
    display: flex;
    justify-content: center;
    flex-direction: column;
    padding: 25px 60px;
    width: 100%;
  }
  .guide {
    position: relative;
    height: 100px;
    .link {
      position: absolute;
      left: 35%;
      bottom: 0;
    }
  }
}
.links {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 50px;
  font-size: 12px;
}
</style>

<script lang="ts">
import { defineComponent } from '@vue/composition-api'
import { LoginType } from '~/pages/login.vue'
export default defineComponent({
  props: {
    fbGoogleLogin: { type: Function, required: true },
  },
  setup(props: LoginType) {
    return {
      fbGoogleLogin: props.fbGoogleLogin,
    }
  },
})
</script>

<script lang="ts">を記載することでTypeScriptでscript部分を書くことができます。 scriptがTypeScriptになっただけなので、Vueの<template><style>を使用できます。

各パターンの評価

■ TypeScriptの恩恵が受けられる

パターン① ≧ パターン② > パターン③

パターン①はTSXファイルなので、TSX部分でも型推論ができて、VSCodeでも補完が効きます。 それに比べるとパターン③はVueの<template>には型は適用されないので、型チェックやVSCodeの補完は効きません。

(※追記)

パターン③はVueの<template>には型は適用されないので、型チェックやVSCodeの補完は効きません。

github.com

↑の通り実はveturのコマンドラインでチェックする方法があるみたいなので、ここは後で別途記事を書こうと思います

■ 書きやすさ( + Vueっぽさ)

パターン③ > パターン② > パターン①

やはり素のVueと同様の書き方ができるパターン③が書きやすいと思います。 パターン①とパターン②を比べるとcssをわけなくてよいので、パターン②の方がVueっぽく書けます。

最終的に採用したパターン

最終的にペペロミアのWeb版ではパターン③の Composition API + Vueファイル + <script lang="ts">を採用することにしました。

TSXを採用しなかった1番の理由は、Vueのテンプレート構文とTSXで書き方が変わってしまうところです。

Vueのテンプレート構文とTSXで書き方が変わる例

例①: クリックイベントはonClickを使用する

これは上記でも記載していますが、以下の違いがあります。

■ Vueのテンプレート構文

<v-btn @click="onClick">登録する</v-btn>

■ TSX

<v-btn onClick={props.onClick}>登録する</v-btn>

例②: v-slotはscopedSlotsを使用する

■ Vueのテンプレート構文

<v-calendar
   :now="dayjs().format('YYYY-MM-DD')"
   :value="dayjs(calendarDate).format('YYYY-MM-DD')"
   >
     <template v-slot:day="{ date }">
        <div> {{ date }} </div>
     </template>
</v-calendar>

■ TSX

 <v-calendar
     now={dayjs().format('YYYY-MM-DD')}
     value={dayjs(props.calendarDate).format('YYYY-MM-DD')}
     scopedSlots={{
         day: ({ date }: { date: string }) => {
               return (<div> {{ date }} </div>)
         },
     }}
/>

例③: v-if、v-for、v-modelが使用できない

v-ifは、JSのif文や三項演算子を使用する。 v-forは、mapで書く。

https://github.com/vuejs/babel-plugin-transform-vue-jsx/issues/38

v-modelは使用できないのでonChange等を使用して値を受け渡す形式にする

と、こんな感じに違いがあります。

TSXを採用しなかった理由

上記のVueのテンプレート構文とTSXの差異TSXで得られる型推論のメリットを比較して、Vueのテンプレート構文が使えるメリットの方が勝っていると感じためTSXの採用は見送りました。

実際に実装してみた感想

  • TypeScriptに関しては、やはりVueより、ReactやAngularの方が相性が良さそう
  • SSRを必須でTypeScriptもガンガン使いたいというプロジェクトならNuxt + TSXの組み合わせは候補になるかも(逆にそれ以外の状況では、個人的に採用は無さそう
  • また、TSXを採用するとVuetifyのdemoページのコピペで動かなくなるとかも、わりかし辛いなと思う

demoのレポジトリを整理したらqiitaにも同じような内容で投稿しようかと思います