Vue3 + TypeScript で考えておきたいコンポーネント設計
僕は2024年の1月からVue.js(2,3系)の世界に足を踏み入れていたVueビギナーですが、Vue3は"良い"と思う反面、Vueは良くも悪くも"柔軟"かつ次々に新しい変化が起こっている状態です。
何かを1つのことも実現する際にも色々な選択肢を取れることで、「どれを使ったらベストか」で迷うポイントが多い感覚があり、そこにTypeScriptが加わると更に難易度が高まる(相性の問題もある?)と考えています。
例えば、Vue3.4で安定版がリリースされたdefineModel
ですが、
既存のv-model
やemit
で実装していた双方向データバインディングの処理を簡単にしてくれるものです。
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
をコンパイルしたい場合はvueCompilerOptions
を3.3
に変更します。)
"vueCompilerOptions": {
"target": 3.3
}
なので、もしVue3 with TypeScript で、アプリケーションを0 → 1 で設計するならば、「個人的にはここは考えて(考慮して)おきたい」という部分を中心にまとめました。
Vue,TypeScript云々が関係がないフロントエンド設計的にベーシックなものもあるとは思います。
設計で悩みそうな主なポイント
- データバインディング(データフロー)は双方向 or 単一方向どちらを採用していくのか
-
ref
(リアクティブ)が色々なところで更新されて煩雑になるのを避けたいし、型がある分、出来るだけ堅く書きたいけど、どうすれば良いだろう?
-
- 処理を共通化したいが、プラグイン機能で独自の
plugin
を書くか?composable
(カスタムフック)を書くか?- 特にライフサイクル周りや
router
等のPromise
が絡む処理などは、出来るだけ共通化しておかないと、各ページコンポーネントでわちゃ〜となり冗長になりがち。
- 特にライフサイクル周りや
僕はこういう部分に悩みそうです。
また、ここでは触れませんが、ディレクトリの設計をどうするか?というのも考える必要がありそうです。
設計時に考えておきたいこと
データフロー
Vueでは、全てをprops
で渡しきる親→子への単一方向なデータバインディングにすることもできますが、v-modelディレクティブ
を使用して双方向データバインディングを実現することもできます。
これは、特にフォーム要素(例えば、input
やselect
)とデータ間のバインディングに便利な機能で、子コンポーネントは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には、Ref
をreadonly
に変換する組み込み関数が存在するため、コンポーネントに流されるprops
についてはreadOnly
としておく方が、型の安全性は向上するかと思います。
export type ReadOnlyRef<T> = Ref<Readonly<T>>
type items = ReadOnlyRef<Todo[]>
const copyItems = readonly(items)
コンテナ/プレゼンショナル パターン
コンポーネントをcontainer
とpresentation
で分け、container
でcomposable
を呼び出し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固有の話、かつ好みによるに尽きますが、個人的にはscript
→ template
の流れでデータの流れに沿ってコーディングされている方が可読性が良いと思います。基本的に描画を司る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 utils
やjest
で単体テストを書くときに、コンポーネントマウント時のoptions
として依存関係を作り上げないと行けない場合があったりと多少面倒にはなるので、
設計はよく考えて対応する必要がありそうだなと思います。
また、app.use
を使った処理はライフサイクル(onMouted
等)を含めた処理は実行できないことも注意が必要です。
コンポーネント自体をVueインスタンスに登録し(app.component()
)、import
文 なしで利用することもできますが、
個人的には多少、コンポーネント内でimport
文が乱立しても明示的import
しておきたいかなと思います。
(エディタの都合かも知れないですが、現状VsCodeではコード内でグローバルに登録されたコンポーネントに飛べないので、それが結構困るのと収集つかなくなりそうだなと思ったためです。)
画面特有の処理については、composable
をうまく活用していく形を取りつつ、無理に共通化しないというのも選択肢だとは思っています。(必ず書いて欲しい処理についてはhygen
などでサンプルを出力しておく等)
まとめ
複数人で開発してコードの堅牢さと、高い生産性を出す上で、設計どうするのか?という部分は非常に重要な部分であり、またライブラリがvueであろうとReactであろうと、それらの特徴を踏まえて良い設計ができるようなエンジニアを目指していきたいと思います。
Discussion