HTML5テクニカルノート
Vue + <script setup> + TypeScript: TodoMVC 01 リスト項目の表示と追加
- ID: FN2211001
- Technique: ECMAScript 2015
- Package: Vue 3.2
本シリーズ記事は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■ひな形プロジェクトのローカルサーバー表示

ノート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.vue
とmain.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■テキストフィールドの入力がリスト項目にデータバインディングされた

ノート02■ref
とreactive
リアクティブな状態をつくるメソッドとしては、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■keypress
とkeyup
[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
により警告が示される

けれど、最終的にモジュール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>
<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シリーズ
- TodoMVC 01 リスト項目の表示と追加
- TodoMVC 02 リスト項目の削除とスタイル変更
- TodoMVC 03 算出プロパティとデータのローカルへの保存
- TodoMVC 04 リスト表示する項目を選び出して切り替える
- TodoMVC 05 チェックをまとめてオン/オフしたり削除する
- TodoMVC 06 項目のテキストをダブルクリックで再編集する
- TodoMVC 07 フォーカスをコントロールする
- TodoMVC 08 要素にアニメーションを加える
- TodoMVC 09 コンポーネントからロジックをコンポーザブル関数に切り分ける
作成者: 野中文雄
作成日: 2022年11月28日
Copyright © 2001-2020 Fumio Nonaka. All rights reserved.