サイトトップ

Director Flash 書籍 業務内容 プロフィール

HTML5テクニカルノート

Vue + <script setup> + TypeScript: TodoMVC 01 リスト項目の表示と追加


本シリーズ記事はVue + <script setup> + TypeScriptの構文で、公式サイト「TodoMVC」の例をつくります。Vue 3が広まるとともに、構文もOptions APIではなく、Composition APIが用いられるようになってきました。TypeScriptとの相性も高まっています(「Composition API とともに TypeScript を使用する」参照)。さらに加わったのが、単一ファイルコンポーネント(SFC)の<script setup>構文です。

<script setup>は単一ファイルコンポーネント(SFC)内でComposition APIを使用するコンパイル時のシンタックスシュガー(糖衣構文)です。SFCとComposition APIの両方を使うならば、おすすめの構文です。
(「SFC<script setup>」)

もはや、群雄割拠の様相を呈しています。公式サイトの情報はあちこちにばらけていて、Vue + <script setup> + TypeScriptでどうコードを書けばよいのか、にわかにはわかりません。そこでまとめることにしたのが、本シリーズです。なお、<template>におけるコンポーネント名はパスカルケースを用います。

ケバブケースの <my-component>も同じようにテンプレートで動作します。しかし、一貫性を保つために、パスカルケースのコンポーネントタグを強く推奨します。これはネイティブのカスタム要素と区別するのにも役立ちます。
(「コンポーネントの使用」)

01 Vue + TypeScriptプロジェクトのひな形をつくる

まずは、Vue + TypeScriptのプロジェクトをひな形としてつくりましょう。用いるのはビルドツールVite(ヴィット: フランス語で素早いの意)です(「プロジェクトの雛形の作成(Project Scaffolding)」)。コマンドラインでつぎのように入力してください。


npm init vue@latest

プロジェクト名(今回はvue3-typescript-todomvcとしました)やTypeScriptを使うかどうかは、このあと示される質問で答えます。他の項目は目的に応じて選んでください。本稿では、ESLintとPrettierを加えました。


✔ Project name: … vue3-typescript-todomvc
✔ Add TypeScript? … No / Yes
✔ Add JSX Support? … No / Yes
✔ Add Vue Router for Single Page Application development? … No / Yes
✔ Add Pinia for state management? … No / Yes
✔ Add Vitest for Unit Testing? … No / Yes
✔ Add an End-to-End Testing Solution? › No
? Add ESLint for code quality? › No / Yes
✔ Add Prettier for code formatting? … No / Yes

ひな形がつくられたら、コマンドラインに示されたとおり、つぎの入力をすればローカルサーバーでプロジェクトが表示されます(図001)。今後は、ローカルサーバーを起ち上げるには、コマンドnpm run devを打ち込むだけです。


cd vue3-typescript-todomvc
npm install
npm run lint
npm run dev

図001■ひな形プロジェクトのローカルサーバー表示

図001

ノート01■Vue CLIは現在メンテナンスモード

Vueのひな形プロジェクトをつくる公式ツールとして、これまではVue CLIが使われてきました。けれど、現在はメンテナンスモードとなっており、Viteを用いることが推奨されています。

Vue CLIは、Vueのための公式のwebpackベースのツールチェーンです。現在はメンテナンスモードで、特定のwebpackのみの機能に依存していない限り、新しいプロジェクトはViteで始めることを推奨します。ほとんどの場合においてViteは優れた開発経験を提供します。
(「Vue CLI」)

02 ひな形プロジェクトの整理と準備

アプリケーションをつくり始める前に、CSSの設定です。今回のTodoMVCの作例にはCSSが細かく定められており、unpkgに公開されています。このCSSファイル(index.css)を、ルートモジュールsrc/App.vueにつぎのように読み込んでください。アプリケーション全体で用いますので、<style>scoped属性は加えません。

src/App.vue

<style>
@import url("https://unpkg.com/todomvc-app-css@2.4.2/index.css");
</style>

逆に、ひな形のsrc/main.tsに定められているうるさいCSSのsrc/assets/main.cssは、モジュールから外してください。

src/main.ts

// import './assets/main.css';

プロジェクトのsrcフォルダについても、ファイルはApp.vuemain.ts以外は使いません。ただし、子コンポーネントをsrc/componentsにつくりますので、フォルダだけ残しておいてください。


src
├ assets
│ ├ base.css
│ ├ logo.svg
│ └ main.css
├ components
│ ├ icons ─ …
│ ├ HelloWorld.vue
│ ├ TheWelcome.vue
│ └ WelcomeItem.vue
├ App.vue
└ main.ts

03 データバインディングを定める

ルートモジュールsrc/App.vue<script setup>と<template>は、以下のように書き替えます。<style scoped>は、削除してしまってください。

コンポーネントのリアクティブな値(refオブジェクト)を定めるのがref()メソッドです(「ref」参照)。refオブジェクトのTypeScriptによる型づけは、メソッドの引数に渡した初期値から推論されます。値が単純なプリミティブでなくオブジェクトや複雑な型の場合には、ref()の呼び出し時に型引数を用いるとよいでしょう(「ref()の型付け」)。

フォームの入力要素と対応するJavaScriptの状態を簡単に同期(データバインディング)してくれるのがv-modelディレクティブです(「フォーム入力バインディング」参照)。<header>に差し込んだ<input>要素にv-modelディレクティブを定め、refオブジェクト(todo)のtitleプロパティとデータバインディングしました。すると、<input>要素に入力したテキストが、refオブジェクトのtitleプロパティ値と同期するのです。さらに、そのプロパティは<template>から2重波括弧({{}})の「マスタッシュ構文」で、同期は保ったままテキストとして展開できます。

なお、refオブジェクトに収められた値を参照するとき、<script>ブロック内ではvalueプロパティから取り出さなければなりません。けれど、<template>は自動的にvalueプロパティ値にアクセスします。したがって、<template>内ではrefオブジェクトのvalueプロパテはを参照しないことにご注意ください(「テンプレートでの使用」)。

src/App.vue

<script setup lang="ts">
import { ref } from 'vue';

export interface Todo {
	id: number;
	title: string;
	completed: boolean;
}
const todo = ref<Todo>({
	id: 0,
	title: 'todo item',
	completed: false,
});
</script>

<template>
	<section id="app" class="todoapp">
		<header class="header">
			<h1>todos</h1>
			<input
				class="new-todo"
				autofocus
				autocomplete="off"
				placeholder="What needs to be done?"
				v-model="todo.title"
			/>
		</header>
		<section class="main">
			<ul class="todo-list">
				<li>
					<div class="view">
						<label>{{ todo.title }}</label>
					</div>
				</li>
			</ul>
		</section>
	</section>
</template>

このコードではまず、ヘッダ(<header)の<input>要素に入力されたテキストを、refオブジェクトのリアクティブな変数(todo)のプロパティ(title)にv-modelディレクティブでデータバインディングしました。これだけでは、画面上から同期の結果がわかりません。そこでつぎに、リスト項目(<li>要素)の<label>要素でプロパティ値をテキスト展開したのです。双方向のデータバインディングがこうして確かめられました。

図002■テキストフィールドの入力がリスト項目にデータバインディングされた

図002

ノート02■refreactive

リアクティブな状態をつくるメソッドとしては、ref()のほかにreactive()があります(「リアクティブの基礎」参照)。細かい仕様の違いはあるものの、使い分けが求められる場合は、さほど多くないでしょう。ref()の方が使い勝手はよさそうです(「【Vue.js】ref と reactive どっちを使う?」参照)。ref()で賄いきれない場合にreactive()を用いるので差し支えないと思われます。

04 親子コンポーネント間で双方向にデータバインディングする

ここで、ルートアプリケーション(src/App.vue)からモジュールを切り分けていきまましょう。まずは、Todo項目の入力モジュール(src/components/TodoInput.vue)です。アプリケーションのヘッダ(<header>)に差し込まれていたテキスト入力フィールド(<input>要素)を新たなモジュールとして定めます。ひとつのコンポーネント(App)の中で扱われたデータバインディングが、親子間に分かれるわけです。こういう場合、双方向まとめてではなく、片道ずつ別々に考えた方が見通しはよくなります。

このときの基本は、データの同期は親から子へです。子から親へは、イベントを送って、データの書き替えはあくまで親に委ねます。親コンポーネントへのイベントの送信(emit)を定めるのが、defineEmits()です。「defineEmitsは、<script setup>内でのみ使用可能なコンパイラーマクロです。インポートする必要はなく、<script setup>が処理されるときにコンパイルされます」(「defineProps() & defineEmits()」)。defineEmits()の宣言に渡すのは、リテラルの型引数です(「TypeScriptのみの機能 」)。戻り値は送信(emit)するための関数で、引数オブジェクトにイベント(event)とデータ(todoTitle)を収めれば、親コンポーネントが受け取ります。

src/components/TodoInput.vue

<script setup lang="ts">
const emit = defineEmits<{
	(event: 'addTodo', todoTitle: string): void;
}>();
</script>

テキストフィールドに書き込んだTodo項目のタイトルは、入力が確定したら親コンポーネント(App)に送ることにしましょう。TodoMVCの作例では、確定はボタンを加えたりせず、テキストフィールド内での[return]/[Enter]キーの入力です。つまり、キーボードイベントハンドラを定めなければなりません。イベントハンドラに用いるディレクティブはv-on(省略記法@)です。キーボードイベントには「キー修飾子」が用意されているので、[return]/[Enter]キーの押下に定めるイベントハンドラは、キーのエリアス.enterを添えてつぎのように記述できます。


@keypress.enter="イベントハンドラ"

新たにつくったTodo項目の入力モジュール(src/components/TodoInput.vue)の記述は、以下のコード001のとおりです。[return]/[Enter]キーを打ち込んだとき、@keypress.enterイベントハンドラ(addTodo())からemit関数(event: 'addTodo')が呼び出されます。そして、<input>要素の値(target.value)から取り出した項目タイトル(todoTitle)は、イベントとともに親コンポーネント(App)に送られるのです。

ここで、イベントハンドラ(addTodo())の引数は、Eventで型づけしました。ところが、この場合のtargetプロパティの型はEventTargetです。valueプロパティは定められていません。そこで、instanceof演算子により、target<input>要素(HTMLInputElement型)であることを限定しました(「TypeScriptでEventの取り扱いがめんどくさ過ぎる。。。」参照)。こうして、valueプロパティ値の読み書きができるようになったのです。

コード001■Todo項目の入力モジュール

src/components/TodoInput.vue

<script setup lang="ts">
const emit = defineEmits<{
	(event: 'addTodo', todoTitle: string): void;
}>();
const addTodo = ({ target }: Event) => {
	if (!(target instanceof HTMLInputElement)) return;
	const todoTitle = target.value.trim();
	if (todoTitle) {
		emit('addTodo', todoTitle);
	}
	target.value = '';
};
</script>

<template>
	<input
		class="new-todo"
		autofocus
		autocomplete="off"
		placeholder="What needs to be done?"
		@keypress.enter="addTodo"
	/>
</template>

アプリケーションのルートモジュール(src/App.vue)は、切り分けたTodo項目の入力モジュール(src/components/TodoInput.vue)をimportして、以下のように<template>でコンポーネント(TodoInput)と<input>要素を差し替えました。子コンポーネントが送る(emit)イベントを受け取るのが@addTodoです。ハンドラ関数(addTodo())には項目タイトル(todoTitle)が引数として渡されます。この値は、新たなTodo項目としてデータに加えなければなりません。そのため、Todo項目単体だったrefオブジェクト(todo)は、Todo型オブジェクトの配列(todos)に改めました。

新たな項目タイトルはaddTodo()の引数(todoTitle)をTodo項目オブジェクトのtitleに定めます。Todo項目オブジェクトに必要な一意の整数idを与えるのがrefオブジェクトuidです。こうして、Todo項目の入力コンポーネント(TodoInput)から送られたタイトルは、配列のrefオブジェクト(todos)につぎつぎと加えられます。もっとも、まだ複数項目をリスト表示する準備はできていません。そこで、console.log()メソッドで確かめるようにしました。ひとつしかないリスト項目(<li>要素)には、暫定で配列先頭のタイトルを表示していまます。

src/App.vue

<script setup lang="ts">

import TodoInput from './components/TodoInput.vue';

/* const todo = ref<Todo>({
	id: 0,
	title: 'todo item',
	completed: false,
}); */
const todos = ref<Todo[]>([]);
const uid = ref(0);
const addTodo = (todoTitle: string) => {
	if (!todoTitle) return;
	todos.value.push({
		id: uid.value++,
		title: todoTitle,
		completed: false,
	});
	console.log('addTodo:', todos.value); // 確認用
};
</script>

<template>
	<section id="app" class="todoapp">
		<header class="header">

			<!-- <input
				class="new-todo"
				autofocus
				autocomplete="off"
				placeholder="What needs to be done?"
				v-model="todo.title"
			/> -->
			<TodoInput @addTodo="addTodo" />
		</header>
		<section class="main">
			<ul class="todo-list">
				<li>
					<div class="view">
						<!-- <label>{{ todo.title }}</label> -->
						<label>{{ todos[0]?.title }}</label>
					</div>
				</li>
			</ul>
		</section>
	</section>
</template>

ノート03■v-modelと親子間の双方向データバインディング

v-modelディレクティブを用いても、親子間の双方向データバインディングは定められます(「v-model」)。もっとも、見やすくわかりやすいとはいえず、注意すべきことも少なくありません。それに、v-modelディレクティブは、内部的にふたつの方向のデータを同期する糖衣構文です(「フォーム入力バインディング」参照)。はじめから方向はふたつに分けて、明示的に書いた方が見通しはよくなるでしょう。

ノート04■keypresskeyup

[return]/[Enter]キーの押下に対するキー修飾子として、公式TodoMVCの作例では@keyupを用いています。けれど、日本語入力には適切ではありません。かなから漢字変換して、候補を[return]/[Enter]キーで確定した途端、テキストは続けてタイプできず、入力完了とみなされて親コンポーネントにデータが送られてしまうからです。@keypressに書き替えればこの問題が避けられます(「Vue.js: TodoMVCの例で日本語の項目が正しく入力できるようにする」参照)。

ノート05■isolatedModulesによる警告

前掲コード001には、isolatedModulesにもとづく警告が示されるでしょう(図003)。「isolatedModulesが設定されている場合、すべての実装ファイルはModuleでなくてはなりません(import/exportの形式を利用しているという意味)」(「Moduleでないファイル」)。

'TodoInput.vue' cannot be compiled under '' because it is considered a global script file. Add an import, export, or an empty 'export {}' statement to make it a module.

図003■isolatedModulesにより警告が示される

図003

けれど、最終的にモジュールsrc/components/TodoInput.vueは、正しくコンパイルされます。したがって、気にしなくてもよいでしょう(「ts-jestでcannot be compiled under '--isolatedModules'と出た時の対処法」参照)。どうしても警告を消したい場合には、<script setup>とは別につぎのような<script>プロックを加えれば済みます。ただ、ダミーのexportを加えただけですので、根本的な解決にはなっていません。

src/components/TodoInput.vue

<script>
export {};
</script>

05 配列データをリスト表示する

つぎに、Todoリストの配列(todos)を、要素の項目オブジェクトに分けて、リストとして表示しましょう。ここで、項目リスト表示もルートモジュール(src/App.vue)から、新たなモジュール(src/components/TodoList.vue)に切り分けます。<template>における<ul>要素(実際には親の<section>)のブロックです。ルートコンポーネント(App)から子コンポーネント(TodoList)には、Todo項目配列(todos)をデータバインディングしなければなりまません。

このとき用いるディレクティブがv-bind(省略記法:)です。


:プロパティ名="データ"

今回プロパティ名は、データバインディングするrefオブジェクト(todos)と同じtodosとしました。

src/App.vue

<TodoList :todos="todos" />

親から渡されたプロパティを受け取るには、子コンポーネントが型を宣言しておかなければなりません。そのときに用いるのは、defineProps()です。構文はdefineEmits()と似ていて、<script setup>構文でのみ使えるコンパイラーマクロです(「defineProps() & defineEmits()」)。importは要りません。リテラルの型引数を渡してください(「TypeScriptのみの機能 」)。<script setup>が処理されるときにコンパイルされます。

src/components/TodoList.vue

<script setup lang="ts">
import type { Todo } from '../App.vue';

interface Props {
	todos: Todo[];
}
defineProps<Props>();
</script>

いよいよ、配列データ(todos)からのオブジェクト要素の取り出しです。ディレクティブにはv-forを用います。構文は、標準JavaScriptのfor...in(配列の場合はArray.prototype.forEach())に近いでしょう。もとのデータにもとづいて、DOM要素またはテンプレートブロックをその数だけレンダリングします。値に渡すのは特別な「エイリアス in 式」の構文です。

src/components/TodoList.vue

<li v-for="todo in todos" :key="todo.id">
	<div class="view">
		<label>{{ todo.title }}</label>
	</div>
</li>

なお、v-forで定めた要素(<li>)には、一意のkey特別属性を加えることが強く推奨されています(「キー付きの v-for を使用する」参照)。要素の削除や並べ替えが行われたとき、Vueが識別・追跡するために必要だからです。こうして定めた項目リスト表示のモジュール(src/components/TodoList.vue)は、つぎのようなコードになるでしょう。Todo項目にはチェックボックス(<input type="checkbox">)も加えました。

src/components/TodoList.vue

<script setup lang="ts">
import type { Todo } from '../App.vue';

interface Props {
	todos: Todo[];
}
defineProps<Props>();
</script>

<template>
	<section class="main">
		<ul class="todo-list">
			<li v-for="todo in todos" :key="todo.id">
				<div class="view">
					<input type="checkbox" class="toggle" />
					<label>{{ todo.title }}</label>
				</div>
			</li>
		</ul>
	</section>
</template>

本稿におけるルートモジュールsrc/App.vueは、項目リスト表示のモジュール(src/components/TodoList.vue)をimportして、コンポーネント(TodoList)は<template>で差し替えれば書き上がりです。つぎのコード002に記述全体をまとめました。

コード002■ルートモジュール

src/App.vue

<script setup lang="ts">
import { ref } from 'vue';
import TodoInput from './components/TodoInput.vue';
import TodoList from './components/TodoList.vue';

export interface Todo {
	id: number;
	title: string;
	completed: boolean;
}
const todos = ref<Todo[]>([]);
const uid = ref(0);
const addTodo = (todoTitle: string) => {
	if (!todoTitle) return;
	todos.value.push({
		id: uid.value++,
		title: todoTitle,
		completed: false,
	});
};
</script>

<template>
	<section id="app" class="todoapp">
		<header class="header">
			<h1>todos</h1>
			<TodoInput @addTodo="addTodo" />
		</header>
		<TodoList :todos="todos" />
	</section>
</template>

<style scoped></style>

<style>
@import url("https://unpkg.com/todomvc-app-css@2.4.2/index.css");
</style>

ノート06■v-forで要素に与えるkey属性は一意で不変の値にする

v-forディレクティブで定めた要素には、一意のkey特別属性を加えるべきです。このとき、v-forの構文では、もとデータのインデックスが得られます。インデックスはもちろん一意です。では、このインデックスをkey属性値に与えればよいでしょうか。結論として、適切ではありません。key属性値は一意というだけではなく、不変であることも求められるからです。

key属性値をインデックスにすると、データを削除したり、並べ替えるたびに値が振り直されます。その結果、Vueが要素を識別・追跡するのに役立たなくなってしまうのです。問題の典型例としては、要素をアニメーションさせる場合が挙げられるでしょう(「Vue.js: v-forで項目インデックスをkey属性にしていいのか」)。

06 Todoリストから項目を別モジュールに切り出す

モジュール分けをもうひとつ進めます。項目リスト表示のモジュール(src/components/TodoList.vue)から、Todo項目単体のモジュール(src/components/TodoItem.vue)を切り出しましょう。データ(todo)を親から子に渡すところは、すでにご説明しました。

src/components/TodoList.vue

<script setup lang="ts">

import TodoItem from './TodoItem.vue';

</script>

<template>
	<section class="main">
		<ul class="todo-list">
			<li v-for="todo in todos" :key="todo.id">
				<!-- <div class="view">
					<input type="checkbox" class="toggle" />
					<label>{{ todo.title }}</label>
				</div> -->
				<TodoItem :todo="todo" />
			</li>
		</ul>
	</section>
</template>

新たなTodo項目単体のモジュール(src/components/TodoItem.vue)の定めは、つぎのコード003のとおりです。項目リスト表示のモジュール(src/components/TodoList.vue)の記述全体も併せて掲げます。なお、ご参考までにGitHubのコードを以下のソース001に掲げました。

コード003■Todo項目とリストのモジュール

src/components/TodoItem.vue

<script setup lang="ts">
import type { Todo } from '../App.vue';

interface Props {
	todo: Todo;
}
defineProps<Props>();
</script>

<template>
	<div class="view">
		<input type="checkbox" class="toggle" />
		<label>{{ todo.title }}</label>
	</div>
</template>

src/components/TodoList.vue

<script setup lang="ts">
import type { Todo } from '../App.vue';
import TodoItem from './TodoItem.vue';

interface Props {
	todos: Todo[];
}
defineProps<Props>();
</script>

<template>
	<section class="main">
		<ul class="todo-list">
			<li v-for="todo in todos" :key="todo.id">
				<TodoItem :todo="todo" />
			</li>
		</ul>
	</section>
</template>

ソース01■TodoMVC 01 リスト項目の表示と追加

Vue + <script setup> + TypeScript: TodoMVCシリーズ


作成者: 野中文雄
作成日: 2022年11月28日


Copyright © 2001-2020 Fumio Nonaka.  All rights reserved.