🙌

Vue3 + TypeScript で考えておきたいコンポーネント設計

2024/05/13に公開

僕は2024年の1月からVue.js(2,3系)の世界に足を踏み入れていたVueビギナーですが、Vue3は"良い"と思う反面、Vueは良くも悪くも"柔軟"かつ次々に新しい変化が起こっている状態です。

何かを1つのことも実現する際にも色々な選択肢を取れることで、「どれを使ったらベストか」で迷うポイントが多い感覚があり、そこにTypeScriptが加わると更に難易度が高まる(相性の問題もある?)と考えています。

例えば、Vue3.4で安定版がリリースされたdefineModelですが、
既存のv-modelemitで実装していた双方向データバインディングの処理を簡単にしてくれるものです。

  const [modelValue, modifiers] = defineModel<string,'uppercase'>("name",{
    set(value) {
      if (modifiers.uppercase) {
        return value.toUpperCase()
      }
      return value
    },
    required: true
  })
// <CardItems v-model:name.uppercase="text" /> Ref<Text>を突っ込んだら魔法のように...

確かに少ないコードで実現できていいね!と思う反面、じゃあどうやって設計に落とし込んだらいいの?という疑問が湧いてくるのです。
また、こういう新しい機能はTsのコンパイラが追いついていないのでtsconfigを変更する必要がある場合もあります。
(defineModelをコンパイルしたい場合はvueCompilerOptions3.3に変更します。)

  "vueCompilerOptions": {
    "target": 3.3
  }

なので、もしVue3 with TypeScript で、アプリケーションを0 → 1 で設計するならば、「個人的にはここは考えて(考慮して)おきたい」という部分を中心にまとめました。

Vue,TypeScript云々が関係がないフロントエンド設計的にベーシックなものもあるとは思います。

設計で悩みそうな主なポイント

  • データバインディング(データフロー)は双方向 or 単一方向どちらを採用していくのか
    • ref(リアクティブ)が色々なところで更新されて煩雑になるのを避けたいし、型がある分、出来るだけ堅く書きたいけど、どうすれば良いだろう?
  • 処理を共通化したいが、プラグイン機能で独自のpluginを書くか?composable(カスタムフック)を書くか?
    • 特にライフサイクル周りやrouter等のPromiseが絡む処理などは、出来るだけ共通化しておかないと、各ページコンポーネントでわちゃ〜となり冗長になりがち。

僕はこういう部分に悩みそうです。
また、ここでは触れませんが、ディレクトリの設計をどうするか?というのも考える必要がありそうです。

設計時に考えておきたいこと

データフロー

Vueでは、全てをpropsで渡しきる親→子への単一方向なデータバインディングにすることもできますが、v-modelディレクティブを使用して双方向データバインディングを実現することもできます。

これは、特にフォーム要素(例えば、inputselect)とデータ間のバインディングに便利な機能で、子コンポーネントはpropsを通じて親からデータを受け取り、emitを使って親にイベントを送信することで、親コンポーネントのデータを更新するというものです。

手軽に使えて便利な機能ですが、しっかりと設計しないで使おうとしてしまう場合に双方向データバインディングのデメリットとして考えられることがあります。
それは、デバッグの難易度が上がり、コードの理解が難しくなることです。

  • データの変更がDOMに自動的に反映されるため、どこでデータが変更されたのか、どの変更がDOMの更新を引き起こしたのかを追跡するのが難しくなる場合がある。
  • データの流れが一方向でないため、コードの流れが理解しにくくなる可能性がある。(特に、大規模なプロジェクトや複数人での開発において問題となる可能性がある。)

emitを採用する場合でも、イベントハンドラーをpropsで渡し切る場合でも、
comopsableとコンテナ/プレゼンショナル パターンを採用し、親→子へデータを渡すのような設計にすると、混乱が少ないのではないかと思います。

comopsable
Vue3のcomposition apiで提供されている機能で、複数のコンポーネント間で共有されるロジックをカプセル化できる謂わゆるReactのカスタムフックです。

type ReturnValues = {
  items: Ref<Todo[]> // Todoのリストを保持するための変数
  newItem: Ref<string> // コンポーネントに入力された値を保持するための変数
  addItem: () => void // ボタンクリック時に新しいTodoを追加する関数
}
type UseTodo = () => ReturnValues

export const useTodo: UseTodo = () => {
  const items = ref<Todo[]>([])
  const newItem = ref('')

  //ボタンクリック時に新しいTodoを追加する関数
  const addItems = () => {  /** anything **/ }

  return {
    items,
    newItem, // emitでバインドする値をcomposableからコンポーネントに渡す
    addItem
  }
}

また、VueのRefは、ミュータブルであり.valueのアクセサで再代入できてしまいます。
Vueには、Refreadonlyに変換する組み込み関数が存在するため、コンポーネントに流されるpropsについてはreadOnlyとしておく方が、型の安全性は向上するかと思います。

export type ReadOnlyRef<T> = Ref<Readonly<T>>

type items = ReadOnlyRef<Todo[]>

const copyItems = readonly(items)

コンテナ/プレゼンショナル パターン
コンポーネントをcontainerpresentationで分け、containercomposableを呼び出しpropsで親→子へデータを流していく設計方針です。(Vueに限らずReactでも頻繁に用いられるパターンです)

コードの見通しが良くなり、単体テストでもロジックとコンポーネント(UI)のテストを分離できます。
コンポーネントでは、propsとして渡されるものだけを想定して振る舞いをテストできるので、テストも書き易くなります。

container

<script setup lang="ts">

import { useTodo } from '@/components/TodoItem/composable/useTodo'
import TodoItems from '@/components/TodoItem/index.vue'

defineOptions({
  name: 'TodoItemsContainer'
})

const { items, newItem, addItem } = useTodo()

</script>

<template>
  <TodoItems :items="items" v-model:newItem="newItem" @add="addItem" />
</template>

containerコンポーネント自体がpropsやストアから状態を受け取る場合でも dependenciesとしてpropsを定義しておくことで依存関係が把握しやすい状態になります。

const { items, newItem, addItem } = useTodo()
const formData = VuexStore.state.formData

const dependencies = { ...items, ...formData } // <TodoItems />へpropsとして渡す

presentation

<script setup lang="ts">
import type { Todo } from '@/types';
import { defineProps, defineEmits } from 'vue'

defineOptions({
  name: 'TodoItems'
})

const props = defineProps<{
  items: Todo[]
  newItem: string
}>()

const emits = defineEmits<{
  (eventName: 'update:newItem', eventValue: string): void
  (eventName: 'add', event: void): void
}>()

const handleInputNewValue = (event: Event) => {
  const target = event.target as HTMLInputElement;
  if (target) {
    emits('update:newItem', target.value);
  }
};


const handleAddItem = () => {
  emits('add')
}

</script>
<template>
  <div v-for="item in props.items" :key="item.id">
    {{ item.text }}
  </div>
  <input :value="newItem" @input="event => handleInputNewValue(event)" type="text" />
  <button @click="handleAddItem">Add Item</button>
</template>

コンポーネント

ディレクトリ構成に依存しますが、上記のコンテナ/プレゼンショナル パターンを採用し、こんな形を考えるかなと思います。テストファイルは composable/ presentationalに作成する形です。

TodoItems
├── composable
│   └── index.ts
├── container
│   └── index.vue
├── presentational
│   └── index.vue

またコンポーネントの書き方で、細かいですが以下も盛り込むようにしたいです。

<Script>と<Template>の位置関係

Vue固有の話、かつ好みによるに尽きますが、個人的にはscripttemplateの流れでデータの流れに沿ってコーディングされている方が可読性が良いと思います。基本的に描画を司るpropsなどの定義によってtemplateの振る舞いが決定されるためです。

// hygenなどでテンプレート化しておくと良さそう
<script setup lang="ts">
//
</script>
<template>
  //
</template>
<style scope>
 //
</style>

defineOptionsでコンポーネント名を明示的にしておく

Vue devtoolsの恩恵を受けてコンポーネントを探しやすくするために明示的に宣言しておくようにしたいです。
definePropsを使用すると、コンポーネントのプロパティが暗黙的に登録され、コンポーネントをvue devtoolsから識別できるようになるようですが、propsを受け取らないコンポーネントがあった場合を踏まえて明示的にしたいという意図です。 (こちらもhygenでテンプレート生成できるよう含めておきたい)

  defineOptions({
    name: "CommonButton"
    ...
  })

プラグインの使い所や注意点

vueにはpluginという機能があり、Vueインスタンスのアプリケーションルートのマウント(app.mount("#app"))前に、app.useで共通処理を実装できるような仕組みで、グローバルに置いておきたい特別な処理などを置けます。(middlewareのようなイメージを持ってます。)

const app = createApp(App)

app.use(FirstPlugin)
app.mount('#app')

FirstPlugin

const FirstPlugin = {
  install(app: App) {
    // ...プラグインの処理
  }
}

export default FirstPlugin;

ただ、vue test utilsjestで単体テストを書くときに、コンポーネントマウント時のoptionsとして依存関係を作り上げないと行けない場合があったりと多少面倒にはなるので、
設計はよく考えて対応する必要がありそうだなと思います。
また、app.useを使った処理はライフサイクル(onMouted等)を含めた処理は実行できないことも注意が必要です。

コンポーネント自体をVueインスタンスに登録し(app.component())、import文 なしで利用することもできますが、
個人的には多少、コンポーネント内でimport文が乱立しても明示的importしておきたいかなと思います。
(エディタの都合かも知れないですが、現状VsCodeではコード内でグローバルに登録されたコンポーネントに飛べないので、それが結構困るのと収集つかなくなりそうだなと思ったためです。)

画面特有の処理については、composableをうまく活用していく形を取りつつ、無理に共通化しないというのも選択肢だとは思っています。(必ず書いて欲しい処理についてはhygenなどでサンプルを出力しておく等)

まとめ

複数人で開発してコードの堅牢さと、高い生産性を出す上で、設計どうするのか?という部分は非常に重要な部分であり、またライブラリがvueであろうとReactであろうと、それらの特徴を踏まえて良い設計ができるようなエンジニアを目指していきたいと思います。

Discussion