Vue 3 × TypeScriptで始めるアプリケーション開発入門
はじめに
Vue 3とTypeScriptを組み合わせることで、シンプルな記述を保ちながら、型安全なフロントエンドアプリケーションを開発できます。
Vue 3ではComposition APIや<script setup>が利用でき、従来よりもコンポーネントのロジックを整理しやすくなりました。
この記事では、Vue 3とTypeScriptを使って簡単なTodoアプリを作成しながら、以下の内容を説明します。
- Vue 3プロジェクトの作成
- TypeScriptの基本的な使い方
-
refとcomputed - 型定義
- コンポーネント分割
- PropsとEmit
- フォーム入力の処理
開発環境
今回使用する主な技術は以下です。
- Vue 3
- TypeScript
- Vite
- Node.js
- npm
プロジェクトを作成する
まず、Viteを使用してVue 3のプロジェクトを作成します。
npm create vite@latest vue3-typescript-todo
実行すると、フレームワークと使用言語を選択する画面が表示されます。
以下のように選択します。
Framework: Vue
Variant: TypeScript
プロジェクトのディレクトリに移動します。
cd vue3-typescript-todo
必要なパッケージをインストールします。
npm install
開発サーバーを起動します。
npm run dev
ブラウザで表示されたURLへアクセスし、Vueの初期画面が表示されれば準備完了です。
Todoアプリの完成イメージ
今回作成するTodoアプリでは、次の機能を実装します。
- Todoの追加
- Todoの完了状態の切り替え
- Todoの削除
- 未完了件数の表示
型を定義する
TypeScriptでは、アプリケーション内で扱うデータの構造を型として定義できます。
src/types/Todo.tsを作成します。
export type Todo = {
id: number
title: string
completed: boolean
}
この型を使うことで、Todoデータには必ず以下の値が含まれることを保証できます。
{
id: number
title: string
completed: boolean
}
例えば、titleに数値を代入すると、TypeScriptがエラーを検出します。
const todo: Todo = {
id: 1,
title: 100,
completed: false,
}
titleは文字列として定義されているため、上記のコードは型エラーになります。
App.vueを作成する
src/App.vueを以下のように編集します。
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Todo } from './types/Todo'
const newTodoTitle = ref<string>('')
const todos = ref<Todo[]>([
{
id: 1,
title: 'Vue 3を学習する',
completed: false,
},
{
id: 2,
title: 'TypeScriptを学習する',
completed: true,
},
])
const remainingCount = computed<number>(() => {
return todos.value.filter((todo) => !todo.completed).length
})
const addTodo = (): void => {
const title = newTodoTitle.value.trim()
if (!title) {
return
}
const newTodo: Todo = {
id: Date.now(),
title,
completed: false,
}
todos.value.push(newTodo)
newTodoTitle.value = ''
}
const toggleTodo = (id: number): void => {
const todo = todos.value.find((item) => item.id === id)
if (!todo) {
return
}
todo.completed = !todo.completed
}
const deleteTodo = (id: number): void => {
todos.value = todos.value.filter((todo) => todo.id !== id)
}
</script>
<template>
<main class="container">
<h1>Todoアプリ</h1>
<form class="todo-form" @submit.prevent="addTodo">
<input
v-model="newTodoTitle"
type="text"
placeholder="Todoを入力してください"
/>
<button type="submit">
追加
</button>
</form>
<p>未完了: {{ remainingCount }}件</p>
<ul class="todo-list">
<li
v-for="todo in todos"
:key="todo.id"
class="todo-item"
>
<label>
<input
type="checkbox"
:checked="todo.completed"
@change="toggleTodo(todo.id)"
/>
<span :class="{ completed: todo.completed }">
{{ todo.title }}
</span>
</label>
<button
type="button"
@click="deleteTodo(todo.id)"
>
削除
</button>
</li>
</ul>
</main>
</template>
<style scoped>
.container {
width: min(600px, 90%);
margin: 40px auto;
font-family: sans-serif;
}
.todo-form {
display: flex;
gap: 8px;
margin-bottom: 24px;
}
.todo-form input {
flex: 1;
padding: 10px;
}
.todo-form button {
padding: 10px 16px;
}
.todo-list {
padding: 0;
list-style: none;
}
.todo-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #ddd;
}
.completed {
color: #888;
text-decoration: line-through;
}
</style>
<script setup lang="ts">とは
Vue 3では、次のように記述することでComposition APIを簡潔に利用できます。
<script setup lang="ts">
</script>
setupを指定すると、setup()関数を明示的に書かなくても、定義した変数や関数をテンプレートから利用できます。
lang="ts"を指定すると、script内でTypeScriptを使用できます。
従来の書き方では、次のようにsetup()関数とreturnが必要でした。
export default {
setup() {
const message = ref('Hello')
return {
message,
}
},
}
<script setup>では、以下のように簡潔に書けます。
<script setup lang="ts">
import { ref } from 'vue'
const message = ref<string>('Hello')
</script>
refでリアクティブな値を定義する
Vueでは、画面の状態として変化する値をリアクティブなデータとして管理します。
const newTodoTitle = ref<string>('')
この例では、入力フォームの値を文字列として管理しています。
TypeScriptの型は、ref<string>のように指定できます。
const count = ref<number>(0)
const message = ref<string>('')
const isLoading = ref<boolean>(false)
JavaScript側で値を参照したり変更したりする場合は、.valueを使用します。
newTodoTitle.value = 'TypeScriptを学習する'
一方、Vueのテンプレート内では.valueを書く必要はありません。
<p>{{ newTodoTitle }}</p>
配列に型を付ける
Todoの一覧は、Todo型の配列として定義しています。
const todos = ref<Todo[]>([])
Todo[]は、「Todo型のデータが複数入る配列」という意味です。
以下のようなデータは正しく扱えます。
todos.value.push({
id: 1,
title: 'Vueを学習する',
completed: false,
})
一方、必要なプロパティが不足している場合は型エラーになります。
todos.value.push({
id: 1,
title: 'Vueを学習する',
})
completedが存在しないため、TypeScriptがエラーを検出します。
computedで計算結果を管理する
未完了のTodo件数は、computedを使用して計算しています。
const remainingCount = computed<number>(() => {
return todos.value.filter((todo) => !todo.completed).length
})
computedは、依存している値が変更されたときに、自動的に再計算されます。
今回の場合、todosの追加、削除、完了状態の変更が発生すると、未完了件数も自動的に更新されます。
テンプレートでは次のように表示できます。
<p>未完了: {{ remainingCount }}件</p>
フォームの入力値を受け取る
入力フォームでは、v-modelを使用しています。
<input v-model="newTodoTitle" type="text" />
v-modelを使用すると、入力欄の値とVue側の変数を双方向で同期できます。
ユーザーが文字を入力すると、newTodoTitleの値も更新されます。
フォームの送信処理は、次のように記述しています。
<form @submit.prevent="addTodo">
.preventは、フォーム送信時のページ再読み込みを防止するための修飾子です。
Todo追加処理は次のとおりです。
const addTodo = (): void => {
const title = newTodoTitle.value.trim()
if (!title) {
return
}
const newTodo: Todo = {
id: Date.now(),
title,
completed: false,
}
todos.value.push(newTodo)
newTodoTitle.value = ''
}
戻り値がない関数には、voidを指定できます。
const addTodo = (): void => {
}
コンポーネントを分割する
アプリケーションが大きくなった場合、すべての処理をApp.vueに書くと管理しにくくなります。
Todoの1行を、別のコンポーネントへ分割してみます。
src/components/TodoItem.vueを作成します。
<script setup lang="ts">
import type { Todo } from '../types/Todo'
defineProps<{
todo: Todo
}>()
const emit = defineEmits<{
toggle: [id: number]
delete: [id: number]
}>()
</script>
<template>
<li class="todo-item">
<label>
<input
type="checkbox"
:checked="todo.completed"
@change="emit('toggle', todo.id)"
/>
<span :class="{ completed: todo.completed }">
{{ todo.title }}
</span>
</label>
<button
type="button"
@click="emit('delete', todo.id)"
>
削除
</button>
</li>
</template>
<style scoped>
.todo-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #ddd;
}
.completed {
color: #888;
text-decoration: line-through;
}
</style>
Propsに型を付ける
親コンポーネントから子コンポーネントへ値を渡す場合は、Propsを使用します。
defineProps<{
todo: Todo
}>()
このコードにより、TodoItemコンポーネントは、Todo型のtodoを受け取ることが明確になります。
親コンポーネントでは次のように渡します。
<TodoItem :todo="todo" />
間違った型の値を渡した場合、TypeScriptやエディターがエラーを検出してくれます。
Emitに型を付ける
子コンポーネントから親コンポーネントへイベントを通知する場合は、Emitを使用します。
const emit = defineEmits<{
toggle: [id: number]
delete: [id: number]
}>()
この型定義によって、toggleイベントとdeleteイベントは、数値型のidを引数として渡すことが明確になります。
イベントを送信する処理は次のとおりです。
emit('toggle', todo.id)
存在しないイベント名や、間違った型の引数を渡すと、TypeScriptがエラーを検出します。
App.vueからTodoItemを利用する
コンポーネント分割後のApp.vueは次のようになります。
<script setup lang="ts">
import { computed, ref } from 'vue'
import TodoItem from './components/TodoItem.vue'
import type { Todo } from './types/Todo'
const newTodoTitle = ref<string>('')
const todos = ref<Todo[]>([
{
id: 1,
title: 'Vue 3を学習する',
completed: false,
},
])
const remainingCount = computed<number>(() => {
return todos.value.filter((todo) => !todo.completed).length
})
const addTodo = (): void => {
const title = newTodoTitle.value.trim()
if (!title) {
return
}
todos.value.push({
id: Date.now(),
title,
completed: false,
})
newTodoTitle.value = ''
}
const toggleTodo = (id: number): void => {
const todo = todos.value.find((item) => item.id === id)
if (!todo) {
return
}
todo.completed = !todo.completed
}
const deleteTodo = (id: number): void => {
todos.value = todos.value.filter((todo) => todo.id !== id)
}
</script>
<template>
<main class="container">
<h1>Todoアプリ</h1>
<form class="todo-form" @submit.prevent="addTodo">
<input
v-model="newTodoTitle"
type="text"
placeholder="Todoを入力してください"
/>
<button type="submit">
追加
</button>
</form>
<p>未完了: {{ remainingCount }}件</p>
<ul class="todo-list">
<TodoItem
v-for="todo in todos"
:key="todo.id"
:todo="todo"
@toggle="toggleTodo"
@delete="deleteTodo"
/>
</ul>
</main>
</template>
<style scoped>
.container {
width: min(600px, 90%);
margin: 40px auto;
font-family: sans-serif;
}
.todo-form {
display: flex;
gap: 8px;
margin-bottom: 24px;
}
.todo-form input {
flex: 1;
padding: 10px;
}
.todo-form button {
padding: 10px 16px;
}
.todo-list {
padding: 0;
list-style: none;
}
</style>
TypeScriptを使用するメリット
Vue 3でTypeScriptを使用する主なメリットは以下です。
型の間違いを早い段階で検出できる
JavaScriptでは、実行して初めて気付くエラーがあります。
TypeScriptを使用すると、コーディング中やビルド時に型の間違いを検出できます。
const count = ref<number>(0)
count.value = '1'
countは数値型として定義されているため、文字列を代入するとエラーになります。
エディターの補完が利用しやすい
型が定義されていることで、Visual Studio Codeなどのエディターから補完候補が表示されます。
todo.title
todo.completed
todo.id
存在するプロパティを確認しながら実装できるため、入力ミスを減らせます。
コンポーネント間のデータ構造が明確になる
PropsとEmitに型を付けることで、親コンポーネントと子コンポーネントの責務が分かりやすくなります。
defineProps<{
todo: Todo
}>()
このコンポーネントがどのようなデータを必要としているか、コードから判断できます。
リファクタリングしやすい
型定義を変更すると、影響を受ける箇所がエラーとして表示されます。
例えば、Todo型にpriorityを追加した場合、修正が必要な箇所を見つけやすくなります。
export type Todo = {
id: number
title: string
completed: boolean
priority: 'low' | 'medium' | 'high'
}
Vue 3とTypeScriptを使う際の注意点
必要以上に型を複雑にしない
型安全を意識しすぎて、型定義が複雑になりすぎると、コードが読みにくくなる場合があります。
まずは基本的な型から始めることが重要です。
type User = {
id: number
name: string
}
必要になった段階で、Union型やGenericsなどを導入するとよいでしょう。
anyを多用しない
anyを使用すると、TypeScriptの型チェックが無効になります。
const data: any = {}
型が分からない場合でも、可能であればunknownを使用し、値を確認してから処理します。
const data: unknown = 'Vue'
if (typeof data === 'string') {
console.log(data.toUpperCase())
}
APIレスポンスを信用しすぎない
TypeScriptの型は、実行時のデータを保証するものではありません。
APIから想定外のデータが返される可能性もあります。
type User = {
id: number
name: string
}
型を定義していても、実際のAPIレスポンスにnameが存在するとは限りません。
本格的なアプリケーションでは、バリデーションライブラリを使って実行時にデータを検証する方法もあります。
まとめ
Vue 3とTypeScriptを組み合わせることで、Vueの書きやすさを維持しながら、型安全なアプリケーションを開発できます。
今回紹介した重要なポイントは以下です。
-
<script setup lang="ts">でTypeScriptを利用する -
refでリアクティブな値を管理する -
computedで計算結果を管理する -
typeでデータ構造を定義する -
definePropsでPropsに型を付ける -
defineEmitsでイベントに型を付ける - コンポーネントを適切に分割する
-
anyを多用しない
Vue 3とTypeScriptを学習する際は、最初から大規模なアプリケーションを作るのではなく、Todoアプリのような小さなアプリから始めるのがおすすめです。
基本的なデータ管理、イベント処理、コンポーネント分割を理解した後に、Vue Router、Pinia、API通信などへ進むと、より実践的なアプリケーションを作成できるようになります。