Vue の基本的な使い方 (6) Vue3 で To-Do アプリを作成

Vue3 で JavaScript を使って簡単な To-Do アプリを作成する方法の解説のような覚書です。

関連ページ

作成日:2022年12月3日

To-Do アプリの作成

作成する To-Do リストのアプリは以下のような機能を持つ簡単なものです。

  • To-Do のアイテム(内容のテキスト)を入力するフォーム(テキストフィールドと追加ボタン)
  • To-Do のアイテムを一覧表示するリスト
  • 各 To-Do のアイテムには削除のボタンと完了状態を表すためのチェックボックスを付ける
  • To-Do のアイテムのリストが空の場合は「現在 To Do リストにアイテムはありません」と表示する

上記の表示のみを単に HTML でマークアップすると以下のように記述できます。

アイテムを入力して追加する部分は form 要素内に input 要素と button 要素を配置しています。

form 要素の場合、デフォルトでは return キーを押すか、フォーム内のボタンをクリックすると submit イベントが発生するので、そのイベント処理で入力された値をリストに出力するようにします。

但し、フォームが送信されてしまうとページが再読込されてしまうので、デフォルトの動作の送信自体はキャンセルする必要があります。

フォームを使わずに、input 要素と button 要素のみでマークアップすることもできます。その場合は、ボタンがクリックされた際に発生する click イベントでリストに出力します。

リストに表示するアイテムでは、チェックボックスを配置して、チェックされると completed というクラスを追加し、CSS を使って表示されている文字列に打ち消し線を表示して完了状態とし、チェックが外されれば completed クラスを削除し、打ち消し線を消すようにします。

また、削除ボタンをクリックすると、クリックイベントの処理でアイテムを削除するようにします。

この例では、アイテムの先頭に番号を表示するように ol 要素を使用していますが、ul 要素を使えば、番号は表示されません。

<div id="app">
  <h1>To Do List</h1>
  <div id="todo-container">
    <form>
      <input type="text"><!-- アイテムを入力するテキストフィールド -->
      <button>追加</button>
    </form>
    <ol class="todo-list"><!-- リスト(アイテムの一覧) -->
      <li>
        <input type="checkbox"><span class="">掃除をする</span>
        <button>削除</button>
      </li>
      <li>
        <!-- チェックボックスがチェックされると completed というクラスを追加 -->
        <input type="checkbox"><span class="completed">洗濯をする</span>
        <button>削除</button>
      </li>
    </ol>
  </div>
</div>

Vue 3 を使って上記のような To-Do リストのアプリを作成します。Vue ではいろいろな方法で記述することができるので、以降では同じ内容(動作)のアプリを以下のような異なる方法で作成しています。

  • CDN で読み込んで、Options API で作成
  • CDN で読み込んで、Composition API で作成(以降は全て Composition API)
  • CDN で読み込んで、コンポーネントに分割して作成
    • props / emit を使う場合
    • provide / inject を使う場合
  • CDN で読み込んで、コンポーネントごとのファイルに分割
    • script タグの src 属性で読み込む方法
    • type 属性に module を指定して読み込む方法
    • ES モジュールビルドの CDN を利用する方法
  • ES モジュールビルドをダウンロードしてコンポーネントごとのファイルに分割
  • Vite でプロジェクトを生成して、SFC(単一ファイルコンポーネント)を使って作成
    • setup() 関数を使う場合
      • Render 関数を使う場合
    • script setup 構文を使う場合
  • Vite でプロジェクトを生成して、Pinia(状態管理ツール)を使って作成

Options API で作成

以下は Vue3 を CDN で読み込んで、Options API を使って作成する例です。

この例ではアプリはルートコンポーネントに定義し、以下のように1つの HTML ファイルにテンプレートと JavaScript を記述します。

todo-options.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vue To Do List App</title>
  <style>
    .completed {
      /* 完了状態の場合は打ち消し線を表示 */
      text-decoration: line-through;
    }
  </style>
</head>
<body>
  <!-- ルートコンポーネントのマウント先 #app -->
  <div id="app">
    <!-- テンプレートを記述 -->
  </div>
<!-- 最新の Vue3 の読み込み ※本番環境では本番向けビルド (末尾が .prod.js) を使用 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
//アプリを生成
Vue.createApp({
  // Options API を使ってアプリを定義
  data() {
    //データ
  },
  computed: {
    //算出プロパティ
  },
  methods: {
    //メソッド
  },
}).mount("#app"); // id="app" の要素にマウント
</script>
</body>
</html>

以下は HTML 部分です。

form 要素には @v-on:)で submit イベントを設定し、送信時(return キーを押すか追加ボタンをクリックした際)にアイテムを追加する関数 addTodo を呼び出します。

また、フォームが送信されないように、.prevent イベント修飾子を指定して、デフォルトの送信動作をキャンセルしています。

input 要素に入力された値は v-model でデータ todoText にバインドします。この値はアイテムを追加する関数 addTodo で使用し、アイテムを追加したら次のアイテムを入力できるようにクリアします。

To-Do リストにアイテムがない場合に表示する p 要素には v-if を指定して isEmptytrue の場合にこの要素を表示し、false の場合は後続の ol 要素を v-elseで表示するようにしています。

各アイテムは v-for を使ってデータとして定義する To-Do アイテムのリスト todoItemList から取得して li 要素で表示します。

todoItemList にはアイテムを表す { text: "掃除をする", id: 0, completed: false } のようなオブジェクトの配列が格納されます。アイテムのオブジェクトは、変数が定義されているわけではなく、todoItemList に追加する際に、textidcompleted というプロパティを持つオブジェクトとして追加されます。

li 要素の key 属性にはアイテムのオブジェクトの id プロパティの値を指定します。

チェックボックスは v-model でアイテムの completed プロパティの値(真偽値)を双方向バインドし、その値を使って span 要素のクラス completed:class でバインドしています(チェックボックスがチェックされると completed が true になり、span 要素に completed クラスが追加されます)。

削除ボタンには @v-on:)で click イベントを設定し、クリックした際にはアイテムの id を引数に渡して deleteTodo を呼び出します。

HTML(テンプレート)
<div id="app">
  <h1>To Do List</h1>
  <div id="todo-container">
    <form @submit.prevent="addTodo">
      <input type="text" v-model="todoText" />
      <button>追加</button>
    </form>
    <p v-if="isEmpty">現在 To Do リストにアイテムはありません</p>
    <ol v-else class="todo-list">
      <li v-for="item in todoItemList" :key="item.id">
        <input type="checkbox" v-model="item.completed" />
        <span :class="{'completed': item.completed}">
          {{item.text}}
        </span>
        <button @click="deleteTodo(item.id)">削除</button>
      </li>
    </ol>
  </div>
</div>

以下は JavaScript 部分です。

この例の場合 Vue を CDN 経由で読み込んでいるので、アプリのインスタンスを生成するメソッド createApp()Veu. でアクセスします。

data にはリアクティブな値を持つ以下のデータ(プロパティ)を定義します。

  • todoText :入力されたテキスト
  • todoIdli 要素の key 属性に指定するアイテムの ID。アイテムを追加する関数 addTodo で、値が重複しないように1増加されます。
  • todoItemList :アイテムのリスト。{ text: "掃除をする", id: 0, completed: false } のようなアイテムのオブジェクトの配列。関数 addTodo によりアイテムがこのリストに追加されます。

computed には data の値を基に算出される以下のプロパティを定義します。

  • isEmpty:アイテムのリスト todoItemList の長さが0の場合は true を返し、そうでなければ false を返す算出プロパティ

methods には以下のイベントリスナを定義します。

  • addTodo():フォームの submit イベントでアイテムを todoItemList に追加する関数。アイテムは以下のようなプロパティを持つオブジェクトとして追加します。
    • アイテム: { text: "入力されたテキスト", id: 整数, completed: false }
  • deleteTodo():削除ボタンの click イベントでアイテムを todoItemList から削除する関数
JavaScript
//アプリのインスタンスを生成
Vue.createApp({
  data() {
    return {
       // 入力されたテキスト(v-model で input 要素に双方向バインド)
      todoText: '',
      // アイテムの ID(アイテム追加時に addTodo でカウントアップ)key 属性に指定
      todoId: 0,
      // アイテムのリスト:オブジェクト(テキスト、ID、完了状態)の配列を格納
      todoItemList: [
        //以下のようなオブジェクトが格納される
        //{ text: "掃除をする", id: 0, completed: false },
        //{ text: "洗濯をする", id: 1, completed: false },
      ],
    }
  },
  computed: {
    //アイテムのリストが空かどうかを表す算出プロパティ(todoListの要素が0の場合はtrueを返す)
    isEmpty() {
      return this.todoItemList.length === 0
    }
  },
  methods: {
    // アイテムを追加する関数(form 要素のイベントリスナ)
    addTodo() {
      // 入力された値が空であれば何もしない
      if (!this.todoText) return
      // 値が空でなければ、アイテムのオブジェクトを todoItemList の配列に追加
      this.todoItemList.push({
        text: this.todoText, // 入力されたテキスト
        id: this.todoId++, // key 属性に指定する整数(重複しないようにカウントアップ)
        completed: false // 完了状態を表す真偽値(初期状態では false)
      });
      // todoItemList に追加したら、テキストフィールドに入力されたテキストをクリア
      this.todoText = ""
    },
    // アイテムを削除する関数(削除ボタンのイベントリスナ)
    deleteTodo(id) {
      // 引数に渡されたアイテムの ID と一致しない要素から成る配列で todoList を置き換える
      this.todoItemList = this.todoItemList.filter((object) => {
        return object.id !== id
      });
    },
  },
}).mount("#app"); // id="app" の要素にマウント
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vue To Do List App with Options API</title>
  <style>
    .completed {
      text-decoration: line-through;
    }
    /* その他のスタイルは省略 */
  </style>
</head>
<body>
<div id="app">
  <h1>To Do List</h1>
  <div id="todo-container">
    <form @submit.prevent="addTodo">
      <input type="text" v-model="todoText" />
      <button>追加</button>
    </form>
    <p v-if="isEmpty">現在 To Do リストにアイテムはありません</p>
    <ol v-else class="todo-list">
      <li v-for="item in todoItemList" :key="item.id">
        <input type="checkbox" v-model="item.completed" />
        <span :class="{'completed': item.completed}">
          {{item.text}}
        </span>
        <button @click="deleteTodo(item.id)">削除</button>
      </li>
    </ol>
  </div>
</div>
<!-- ※本番環境では本番向けビルド (末尾が .prod.js) を使用 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
Vue.createApp({
  data() {
    return {
      todoText: '',
      todoId: 0,
      todoItemList: [],
    }
  },
  computed: {
    isEmpty() {
      return this.todoItemList.length === 0
    }
  },
  methods: {
    addTodo() {
      if (!this.todoText) return
      this.todoItemList.push({
        text: this.todoText,
        id: this.todoId++,
        completed: false
      });
      this.todoText = ""
    },
    deleteTodo(id) {
      this.todoItemList = this.todoItemList.filter((object) => {
        return object.id !== id
      });
    },
  },
}).mount("#app");
</script>
</body>
</html>

Composition API で作成

以下は Vue3 を CDN で読み込んで、Composition API を使って作成する(前述のサンプルを Composition API で書き換えた)例です。

以下が JavaScript の部分です。HTML の部分は Options API の場合と同じなので省略します。

Composition API ではコンポーネントに必要な変数や関数を setup() 関数で定義し、定義した変数や関数を最後に return します。

Options API で data や computed、methods に定義していたものは以下のように定義します。

  • data:setup() 内で refreactive メソッドを使用してリアクティブなオブジェクトとして定義
  • computed: setup() 内で computed メソッドを使用して定義
  • methods: setup() 内で関数として定義

また、Options API では定義したデータやメソッドには this を使ってアクセスしますが、Composition API の setup() 内では this は使えません。Composition API では変数名でアクセスしますが、値にアクセスするには value プロパティを使います。

JavaScript
Vue.createApp({
  setup() {
    // 入力されたテキスト(v-model で input 要素に双方向バインド)
    const todoText = Vue.ref("")
    // アイテムの ID(アイテム追加時に addTodo でカウントアップ)
    const todoId = Vue.ref(0)
    // アイテムのリスト:オブジェクト(テキスト、ID、完了状態)の配列を格納
    const todoItemList = Vue.ref([
      //例 { text: "掃除をする", id: 0, completed: false },
    ])
    // アイテムのリストが空かどうかを表す算出プロパティ
    const isEmpty = Vue.computed(() => {
      // 値にアクセスするには value プロパティを使う
      return todoItemList.value.length === 0
    });
    // アイテムを追加する関数(form 要素のイベントリスナ)
    const addTodo = () => {
      if (!todoText.value) return
      todoItemList.value.push({
        text: todoText.value,
        id: todoId.value++,
        completed: false
      });
      todoText.value = ""
    }
    // アイテムを削除する関数(削除ボタンのイベントリスナ)
    const deleteTodo = (id) => {
      todoItemList.value = todoItemList.value.filter((object) => {
        return object.id !== id
      });
    }
    // 定義したプロパティやメソッドを最後にまとめて return します
    return { todoText, todoId, todoItemList, isEmpty, addTodo, deleteTodo }
  }
}).mount("#app");
todo-composition.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vue To Do List App</title>
  <style>
    .completed {
      text-decoration: line-through;
    }
  </style>
</head>
<body>
<div id="app">
  <h1>To Do List</h1>
  <div id="todo-container">
    <form @submit.prevent="addTodo">
      <input type="text" v-model="todoText" />
      <button>追加</button>
    </form>
    <p v-if="isEmpty">現在 To Do リストにアイテムはありません</p>
    <ol v-else class="todo-list">
      <li v-for="item in todoItemList" :key="item.id">
        <input type="checkbox" v-model="item.completed" />
        <span :class="{'completed': item.completed}">
          {{item.text}}
        </span>
        <button @click="deleteTodo(item.id)">削除</button>
      </li>
    </ol>
  </div>
</div>
<!-- Vue の読み込み ※本番環境では本番向けビルド (末尾が .prod.js) を使用 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
Vue.createApp({
  setup() {
    const todoText = Vue.ref("")
    const todoId = Vue.ref(0)
    const todoItemList = Vue.ref([])
    const isEmpty = Vue.computed(() => {
      return todoItemList.value.length === 0
    });
    const addTodo = () => {
      if (!todoText.value) return
      todoItemList.value.push({
        text: todoText.value,
        id: todoId.value++,
        completed: false
      });
      todoText.value = ""
    }
    const deleteTodo = (id) => {
      todoItemList.value = todoItemList.value.filter((object) => {
        return object.id !== id
      });
    }
    return { todoText, todoId, todoItemList, isEmpty, addTodo, deleteTodo }
  }
}).mount("#app");
</script>
</body>
</html>

コンポーネントに分割

このアプリの場合、以下のようなコンポーネントに分割することができます。

  • TodoForm :フォーム部分のコンポーネント
  • TodoList:リスト(一覧表示)部分のコンポーネント
  • TodoApp:上記2つのコンポーネントを含むアプリ全体のコンポーネント

最初からコンポーネントに分割して作成すればよいのですが、この例では、まず前述のサンプルをコンポーネントに分割してみます。

コンポーネントに分割すると、コンポーネント間でのデータの受け渡しが必要になります。コンポーネント間でデータを受け渡すには以下のような方法があります。

  • props と emit を使う
  • provide と inject を使う
  • Pinia(状態管理ツール)を使う

props / emit

以下はコンポーネント間でのデータの受け渡しに props と emit を利用する例です。

この例では、分割する際に、最初にリスト部分を切り出し、続いてフォーム部分、最後に全体をコンポーネントとして分割していますが、最初に全体をコンポーネントとして分割するなどいろいろなやり方があるかと思います。

TodoList コンポーネント(リスト部分)

リスト(一覧表示)部分を TodoList コンポーネントとして切り出します。

最初は、以下のように TodoList に template オプションを使ってテンプレートのみを定義してみます。

<body>
<div id="app">
  <h1>To Do List</h1>
  <div id="todo-container">
    <form @submit.prevent="addTodo">
      <input type="text" v-model="todoText" />
      <button>追加</button>
    </form>
    <!-- リスト部分の TodoList コンポーネント -->
    <todo-list></todo-list>
  </div>
</div>
<!-- ※本番環境では本番向けビルド (末尾が .prod.js) を使用 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>

<script>

//リスト部分のコンポーネント
const TodoList = {
  // テンプレートのみを定義(バッククォートで文字列を囲むテンプレートリテラルで記述)
  template: `<p v-if="isEmpty">現在 To Do リストにアイテムはありません</p>
  <ol v-else class="todo-list">
    <li v-for="item in todoItemList" :key="item.id">
      <input type="checkbox" v-model="item.completed" />
      <span :class="{'completed': item.completed}">
        {{item.text}}
      </span>
      <button @click="deleteTodo(item.id)">削除</button>
    </li>
  </ol>`
}

Vue.createApp({
  //上記で定義した TodoList コンポーネントを登録
  components: {
    'todo-list': TodoList
  },
  setup() {
    const todoText = Vue.ref("")
    const todoId = Vue.ref(0)
    const todoItemList = Vue.ref([
      //ダミーのデータ(本番では ID が重複してしまうため削除する必要あり)
      { text: "掃除をする", id: 0, completed: false },
      { text: "洗濯をする", id: 1, completed: false },
    ])
    const isEmpty = Vue.computed(() => {
      return todoItemList.value.length === 0
    });
    const addTodo = () => {
      if (!todoText.value) return
      todoItemList.value.push({
        text: todoText.value,
        id: todoId.value++,
        completed: false
      });
      todoText.value = ""
    }
    const deleteTodo = (id) => {
      todoItemList.value = todoItemList.value.filter((object) => {
        return object.id !== id
      });
    }
    return { todoText, todoId, todoItemList, isEmpty, addTodo, deleteTodo }
  }
}).mount("#app");
</script>
</body>

この状態でブラウザで確認すると、「TodoList コンポーネントで isEmpty と todoItemList が定義されていません」というような警告が表示され、リスト部分が表示されません。

これは切り出した TodoList コンポーネント(子コンポーネント)は別のスコープになるため、TodoList コンポーネントのテンプレートから親コンポーネントで定義してあるデータ(isEmptytodoItemList)にアクセスできないためです。

子コンポーネントから親コンポーネントで定義してあるデータにアクセスするには props を利用できます。

言い換えると、props を使って親コンポーネントから子コンポーネントへデータを渡すことができます。

Composition API ではコンポーネントに必要なものを setup メソッド内で定義しますが、propssetup メソッドの外で定義します。

以下のように TodoList コンポーネントで propstodoItemListisEmpty を登録することで、TodoList のテンプレートの中でプロパティとして利用することができます(todoItemListisEmpty の値は親コンポーネントから渡されます)。

また、props を登録する際に型を指定することができます。

const TodoList = {
  // props に todoList と isEmpty を登録
  props: {
    todoItemList: Array,
    isEmpty: Boolean
  },
  template: `<p v-if="isEmpty">現在 To Do リストにアイテムはありません</p>
  <ol v-else class="todo-list">
    <li v-for="item in todoItemList" :key="item.id">
      <input type="checkbox" v-model="item.completed" />
      <span :class="{'completed': item.completed}">
        {{item.text}}
      </span>
      <button @click="deleteTodo(item.id)">削除</button>
    </li>
  </ol>`
}

親コンポーネント側では、子コンポーネントで登録したプロパティ(todoItemListisEmpty)に対応するカスタム属性(todo-item-list 属性 と is-empty 属性)をテンプレートに指定して、値を子コンポーネントに渡します。属性としてテンプレートに指定する際は、ケバブケースに変換します。

この例の場合、カスタム属性に渡される値は文字列ではないので(配列と真偽値なので) v-bind:(または省略形の :)を使用する必要があります。

<todo-list :todo-item-list="todoItemList" :is-empty="isEmpty"></todo-list>

これでリスト部分は表示されますが、イベントリスナの deleteTodo も親コンポーネントで定義されているため、TodoList からはアクセスできず、削除ボタンをクリックするとエラーになってしまいます。

この問題を解決するには emit メソッドを利用することができます。

emit メソッドを使うと指定した名前のイベント(カスタムイベント)を任意の引数とともに発生させることができます。そして発生したカスタムイベントを親コンポーネントで捕捉して何らかの処理を行うことができます。

emit メソッドを使用するには、まず、emits オプションに発行するイベントを宣言します。

この例では、削除ボタンに設定するイベントリスナー deleteItemFromList を定義し、emit を使って deleteItem カスタムイベントを発生させます。そして親コンポーネントでは、このカスタムイベントを v-on: で購読(捕捉)して deleteTodo を呼び出します。

このコンポーネントでのイベントリスナーの名前も親コンポーネントと同じ deleteTodo としても問題ありませんが、この例では識別しやすいように異なる名前を付けています。

Composition API の場合、emit にアクセスするには、setup の第2引数 context のプロパティとしてアクセスします。

テンプレートでは削除ボタンのリスナーに deleteItemFromList を設定し、引数に ID を渡します。

const TodoList= {
  props: {
    todoItemList: Array,
    isEmpty: Boolean
  },
  //emits オプションに発行するイベントを宣言
  emits: ['deleteItem'],
  // setup の第2引数 context から emit にアクセス
  setup(props, context) {
    //イベントリスナを定義して削除ボタンに設定し、クリック時に deleteItem イベントを発生
    const deleteItemFromList = (id) => {
      context.emit("deleteItem", id);
    }
    return { deleteItemFromList }
  },
  template: `<p v-if="isEmpty">現在 To Do リストにアイテムはありません</p>
  <ol v-else class="todo-list">
    <li v-for="item in todoItemList" :key="item.id">
      <input type="checkbox" v-model="item.completed" />
      <span :class="{'completed': item.completed}">
        {{item.text}}
      </span>
      <button @click="deleteItemFromList(item.id)">削除</button>
    </li>
  </ol>`
}

親コンポーネントのテンプレート(HTML)ではリスト部分を定義した TodoList コンポーネントのカスタムタグ todo-list に置き換えます。

カスタムタグでは、v-on:(または @)でカスタムイベント delete-item にイベントリスナー(deleteTodo)を設定します。

HTML
<div id="app">
  <h1>To Do List</h1>
  <div id="todo-container">
    <form @submit.prevent="addTodo">
      <input type="text" v-model="todoText" />
      <button>追加</button>
    </form>
    <!-- リスト部分を TodoList コンポーネントに置き換え -->
    <todo-list
      :todo-item-list="todoItemList"
      :is-empty="isEmpty"
      @delete-item="deleteTodo"
    ></todo-list>
  </div>
</div>
todo-composition2.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vue To Do List App</title>
  <style>
    .completed {
      text-decoration: line-through;
    }
  </style>
</head>
<body>
<div id="app">
  <h1>To Do List</h1>
  <div id="todo-container">
    <form @submit.prevent="addTodo">
      <input type="text" v-model="todoText" />
      <button>追加</button>
    </form>
    <!-- リスト部分のコンポーネント -->
    <todo-list
      :todo-item-list="todoItemList"
      :is-empty="isEmpty"
      @delete-item="deleteTodo"
    ></todo-list>
  </div>
</div>
<!-- ※本番環境では本番向けビルド (末尾が .prod.js) を使用 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>

<script>

const TodoList= {
  props: {
    todoItemList: Array,
    isEmpty: Boolean
  },
  //emits オプションに発行するイベントを宣言
  emits: ['deleteItem'],
  // setup の第2引数 context から emit にアクセス
  setup(props, context) {
    //イベントリスナを定義してクリック時に deleteItem イベントを発生
    const deleteItemFromList = (id) => {
      context.emit("deleteItem", id);
    }
    return { deleteItemFromList }
  },
  template: `<p v-if="isEmpty">現在 To Do リストにアイテムはありません</p>
  <ol v-else class="todo-list">
    <li v-for="item in todoItemList" :key="item.id">
      <input type="checkbox" v-model="item.completed" />
      <span :class="{'completed': item.completed}">
        {{item.text}}
      </span>
      <button @click="deleteItemFromList(item.id)">削除</button>
    </li>
  </ol>`
}

Vue.createApp({
  //TodoListComponent を登録
  components: {
    'todo-list': TodoList
  },
  setup() {
    const todoText = Vue.ref("")
    const todoId = Vue.ref(0)
    const todoItemList = Vue.ref([
      //ダミーのデータ(本番では ID が重複してしまうため削除する必要あり)
      { text: "掃除をする", id: 0, completed: false },
      { text: "洗濯をする", id: 1, completed: false },
    ])
    const isEmpty = Vue.computed(() => {
      return todoItemList.value.length === 0
    });
    const addTodo = () => {
      if (!todoText.value) return
      todoItemList.value.push({
        text: todoText.value,
        id: todoId.value++,
        completed: false
      });
      todoText.value = ""
    }
    const deleteTodo = (id) => {
      todoItemList.value = todoItemList.value.filter((object) => {
        return object.id !== id
      });
    }
    return { todoText, todoId, todoItemList, isEmpty, addTodo, deleteTodo }
  }
}).mount("#app");
</script>
</body>
</html>

TodoForm コンポーネント(フォーム部分)

続いて、フォームの部分を TodoForm コンポーネントとして切り出します。

フォームの部分では input 要素に入力されたテキストをリアクティブな変数 todoText として定義し、v-model で双方向バインドします(親コンポーネントの todoText は削除します)。

そして、 submit イベントのリスナー addItemToList を定義し、emit メソッドを使ってカスタムイベント(addItem)を発生させます。その際に引数として、入力されたテキストを親コンポーネントに渡します。 親コンポーネントでは addItem イベントが発生したら、addTodo メソッドを呼び出し、渡されたテキストを使ってリストにアイテムを追加します。

また、addItemToList では、カスタムイベントを発生させたら、入力された値をクリアします。

テンプレートでは、form 要素の submit イベントのリスナーに addItemToList を指定します。

const TodoForm = {
  //発行するイベントを宣言
  emits: ['addItem'],

  setup(props, context) {
    // 入力されたテキスト(v-model で input 要素に双方向バインド)
    const todoText = Vue.ref("")
    // submit イベントのリスナー(addItem カスタムイベントを発生させる)
    const addItemToList = () => {
      if (!todoText.value) return
      // 引数に todoText.value を渡して addItem カスタムイベントを発生
      context.emit("addItem", todoText.value)
      // テキストフィールドの入力値をクリア
      todoText.value = ''
    }
    return { todoText, addItemToList }
  },
  template:`<form @submit.prevent="addItemToList">
    <input type="text" v-model="todoText" />
    <button>追加</button>
  </form>`
}

親コンポーネントでは、components オプションに TodoForm を追加して登録し、TodoForm コンポーネントに移した todoText を削除します。また、addTodo メソッドの todoText を使った処理の部分を削除します(TodoForm 側で処理)。

Vue.createApp({
  components: {
    'todo-list': TodoList,
    'todo-form': TodoForm  //追加
  },
  setup() {
    //const todoText = Vue.ref("")  //削除
    const todoId = Vue.ref(0)
    const todoItemList = Vue.ref([
      //ダミーのデータ(本番では ID が重複してしまうため削除する必要あり)
      { text: "掃除をする", id: 0, completed: false },
      { text: "洗濯をする", id: 1, completed: false },
    ])
    const isEmpty = Vue.computed(() => {
      return todoItemList.value.length === 0
    });
    const addTodo = (text) => {
      //if (!todoText.value) return   //削除
      todoItemList.value.push({
        text: text,
        id: todoId.value++,
        completed: false
      });
      //todoText.value = ""   //削除
    }
    const deleteTodo = (id) => {
      todoItemList.value = todoItemList.value.filter((object) => {
        return object.id !== id
      });
    }
    //todoText を戻り値から削除
    return { todoId, todoItemList, isEmpty, addTodo, deleteTodo }
  }
}).mount("#app");

親コンポーネントのテンプレート(HTML)では、フォーム部分を定義した TodoForm のカスタムタグ todo-form に置き換え、カスタムイベント add-item にイベントリスナー(addTodo)を設定します。

HTML
<div id="app">
  <h1>To Do List</h1>
  <div id="todo-container">
   <!-- フォーム部分を TodoForm コンポーネントに置き換え -->
    <todo-form @add-item="addTodo"></todo-form>
    <todo-list
      :todo-item-list="todoItemList"
      :is-empty="isEmpty"
      @delete-item="deleteTodo"
    ></todo-list>
  </div>
</div>
todo-composition3.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vue To Do List App</title>
  <style>
    .completed {
      text-decoration: line-through;
    }
  </style>
</head>
<body>
<div id="app">
  <h1>To Do List</h1>
  <div id="todo-container">
    <todo-form @add-item="addTodo"></todo-form>
    <todo-list
      :todo-item-list="todoItemList"
      :is-empty="isEmpty"
      @delete-item="deleteTodo"
    ></todo-list>
  </div>
</div>
<!-- ※本番環境では本番向けビルド (末尾が .prod.js) を使用 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>

<script>
//リスト部分のコンポーネント
const TodoList= {
  props: {
    todoItemList: Array,
    isEmpty: Boolean
  },
  //emits オプションに発行するイベントを宣言
  emits: ['deleteItem'],
  // setup の第2引数 context から emit にアクセス
  setup(props, context) {
    //イベントリスナを定義してクリック時に deleteItem イベントを発生
    const deleteItemFromList = (id) => {
      context.emit("deleteItem", id);
    }
    return { deleteItemFromList }
  },
  template: `<p v-if="isEmpty">現在 To Do リストにアイテムはありません</p>
  <ol v-else class="todo-list">
    <li v-for="item in todoItemList" :key="item.id">
      <input type="checkbox" v-model="item.completed" />
      <span :class="{'completed': item.completed}">
        {{item.text}}
      </span>
      <button @click="deleteItemFromList(item.id)">削除</button>
    </li>
  </ol>`
}

//フォーム部分のコンポーネント
const TodoForm = {
  //発行するイベントを宣言
  emits: ['addItem'],

  setup(props, context) {
    // 入力されたテキスト(v-model で input 要素に双方向バインド)
    const todoText = Vue.ref("")
    // submit イベントのリスナー(addItem カスタムイベントを発生させる)
    const addItemToList = () => {
      if (!todoText.value) return
      // 引数に todoText.value を渡して addItem カスタムイベントを発生
      context.emit("addItem", todoText.value)
      // テキストフィールドの入力値をクリア
      todoText.value = ''
    }
    return { todoText, addItemToList }
  },
  template:`<form @submit.prevent="addItemToList">
    <input type="text" v-model="todoText" />
    <button>追加</button>
  </form>`
}

Vue.createApp({
  components: {
    'todo-list': TodoList,
    'todo-form': TodoForm  //追加
  },
  setup() {
    //const todoText = Vue.ref("")  //削除
    const todoId = Vue.ref(0)
    const todoItemList = Vue.ref([
      //ダミーのデータ(本番では ID が重複してしまうため削除する必要あり)
      { text: "掃除をする", id: 0, completed: false },
      { text: "洗濯をする", id: 1, completed: false },
    ])
    const isEmpty = Vue.computed(() => {
      return todoItemList.value.length === 0
    });
    const addTodo = (text) => {
      //if (!todoText.value) return   //削除
      todoItemList.value.push({
        text: text,
        id: todoId.value++,
        completed: false
      });
      //todoText.value = ""   //削除
    }
    const deleteTodo = (id) => {
      todoItemList.value = todoItemList.value.filter((object) => {
        return object.id !== id
      });
    }
    //todoText を戻り値から削除
    return { todoId, todoItemList, isEmpty, addTodo, deleteTodo }
  }
}).mount("#app");
</script>
</body>
</html>

TodoApp コンポーネント(アプリ全体)

最後にアプリ全体を TodoApp コンポーネントとして分割します。

TodoApp コンポーネントの内容は、これまでのルートコンポーネントとほぼ同じになりますが、template オプションに HTML の <div id="app">〜</div> の内側の部分を指定します。

TodoApp
const TodoApp = {
  components: {
    'todo-list': TodoList,
    'todo-form': TodoForm
  },
  setup() {
    const todoId = Vue.ref(0)
    const todoItemList = Vue.ref([
      //ダミーのデータ(本番では ID が重複してしまうため削除する必要あり)
      { text: "掃除をする", id: 0, completed: false },
      { text: "洗濯をする", id: 1, completed: false },
    ])
    const isEmpty = Vue.computed(() => {
      return todoItemList.value.length === 0
    });
    const addTodo = (text) => {
      todoItemList.value.push({
        text: text,
        id: todoId.value++,
        completed: false
      });
    }
    const deleteTodo = (id) => {
      todoItemList.value = todoItemList.value.filter((object) => {
        return object.id !== id
      });
    }
    return { todoId, todoItemList, isEmpty, addTodo, deleteTodo }
  },
  template:`<h1>To Do List</h1>
  <div id="todo-container">
    <todo-form @add-item="addTodo" />
    <todo-list
      :todo-item-list="todoItemList"
      :is-empty="isEmpty"
      @delete-item="deleteTodo"
    />
  </div>`
}

ルートコンポーネントでは、components オプションに TodoApp を指定して登録し、template オプションに TodoApp のカスタムタグ <todo-app /> を指定します。

ルートコンポーネント
Vue.createApp({
  components: {
    'todo-app': TodoApp,
  },
  template:`<todo-app />`
}).mount("#app");

HTML は以下のようにマウント先の要素のみになります。

HTML
<div id="app"></div>
todo-composition4.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vue To Do List App</title>
  <style>
    .completed {
      text-decoration: line-through;
    }
  </style>
</head>
<body>
<div id="app"></div>
<!-- Vue の読み込み ※本番環境では本番向けビルド (末尾が .prod.js) を使用 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
// リスト部分のコンポーネント
const TodoList= {
  props: {
    todoItemList: Array,
    isEmpty: Boolean
  },
  //emits オプションに発行するイベントを宣言
  emits: ['deleteItem'],
  // setup の第2引数 context から emit にアクセス
  setup(props, context) {
    //イベントリスナを定義してクリック時に deleteItem イベントを発生
    const deleteItemFromList = (id) => {
      context.emit("deleteItem", id);
    }
    return { deleteItemFromList }
  },
  template: `<p v-if="isEmpty">現在 To Do リストにアイテムはありません</p>
  <ol v-else class="todo-list">
    <li v-for="item in todoItemList" :key="item.id">
      <input type="checkbox" v-model="item.completed" />
      <span :class="{'completed': item.completed}">
        {{item.text}}
      </span>
      <button @click="deleteItemFromList(item.id)">削除</button>
    </li>
  </ol>`
}

// フォーム部分のコンポーネント
const TodoForm = {
  //発行するイベントを宣言
  emits: ['addItem'],

  setup(props, context) {
    // 入力されたテキスト(v-model で input 要素に双方向バインド)
    const todoText = Vue.ref("")
    // submit イベントのリスナー(addItem カスタムイベントを発生させる)
    const addItemToList = () => {
      if (!todoText.value) return
      // 引数に todoText.value を渡して addItem カスタムイベントを発生
      context.emit("addItem", todoText.value)
      // テキストフィールドの入力値をクリア
      todoText.value = ''
    }
    return { todoText, addItemToList }
  },
  template:`<form @submit.prevent="addItemToList">
    <input type="text" v-model="todoText" />
    <button>追加</button>
  </form>`
}

// アプリ全体のコンポーネント
const TodoApp = {
  components: {
    'todo-list': TodoList,
    'todo-form': TodoForm
  },
  setup() {
    const todoId = Vue.ref(0)
    const todoItemList = Vue.ref([
      //ダミーのデータ(本番では ID が重複してしまうため削除する必要あり)
      { text: "掃除をする", id: 0, completed: false },
      { text: "洗濯をする", id: 1, completed: false },
    ])
    const isEmpty = Vue.computed(() => {
      return todoItemList.value.length === 0
    });
    const addTodo = (text) => {
      todoItemList.value.push({
        text: text,
        id: todoId.value++,
        completed: false
      });
    }
    const deleteTodo = (id) => {
      todoItemList.value = todoItemList.value.filter((object) => {
        return object.id !== id
      });
    }
    return { todoId, todoItemList, isEmpty, addTodo, deleteTodo }
  },
  template:`<h1>To Do List</h1>
  <div id="todo-container">
    <todo-form @add-item="addTodo" />
    <todo-list
      :todo-item-list="todoItemList"
      :is-empty="isEmpty"
      @delete-item="deleteTodo"
    />
  </div>`
}

Vue.createApp({
  components: {
    'todo-app': TodoApp,
  },
  template:`<todo-app />`
}).mount("#app");
</script>
</body>
</html>

provide / inject

以下は前述のサンプルを Vue3 で導入された provide / inject を使って書き換えた例です。

この例では provideinject を多用するので、毎回 Vue.provide() などと記述するのは面倒なので、先頭で利用するメソッドを分割代入を使って Vue から取得しています。例えば、Vue.provide() ではなく単に provide() と記述できます。

provideinject を使うと、親コンポーネントはそのすべての子孫コンポーネントに対して依存関係を提供するプロバイダーとして機能します。

そのため、親コンポーネントで必要なデータを provide() で提供し、子コンポーネントでは inject() でそれらのデータを注入するだけで利用することができます。

provide()inject()setup() 内で使用します。

JavaScript
// 利用するメソッドを分割代入を使って Vue から取得
const { createApp, inject, provide, ref, computed } = Vue;

// リスト部分のコンポーネント
const TodoList = {
  setup() {
    // setup() 内で inject() を使って親コンポーネントが提供するデータを注入
    const todoItemList = inject('todoItemList')
    const isEmpty = inject('isEmpty')
    const deleteTodo = inject('deleteTodo')

    return {todoItemList, isEmpty, deleteTodo}
  },
  template: `<p v-if="isEmpty">現在 To Do リストにアイテムはありません</p>
  <ol v-else class="todo-list">
    <li v-for="item in todoItemList" :key="item.id">
      <input type="checkbox" v-model="item.completed" />
      <span :class="{'completed': item.completed}">
        {{item.text}}
      </span>
      <button @click="deleteTodo(item.id)">削除</button>
    </li>
  </ol>`
}

// フォーム部分のコンポーネント
const TodoForm = {
  setup() {
    // 入力されたテキスト(v-model で input 要素に双方向バインド)
    const todoText = ref("")
    //親コンポーネントが提供するメソッドを注入
    const addTodo = inject('addTodo')
    //注入したメソッド addTodo を使って、メソッド addItem を定義
    const addItem = () => {
      if (!todoText.value) return
      addTodo(todoText.value)
      todoText.value = ''
    }
    return { todoText, addItem }
  },
  template:`<form @submit.prevent="addItem">
    <input type="text" v-model="todoText" />
    <button>追加</button>
  </form>`
}

// アプリ全体のコンポーネント
const TodoApp = {
  components: {
    'todo-list': TodoList,
    'todo-form': TodoForm
  },
  setup() {
    const todoId = ref(0)
    const todoItemList = ref([
      //ダミーのデータ(本番では ID が重複してしまうため削除する必要あり)
      { text: "掃除をする", id: 0, completed: false },
      { text: "洗濯をする", id: 1, completed: false },
    ])
    //setup() 内で provide() を使って上記の todoItemList を子コンポーネントへ提供
    provide('todoItemList', todoItemList)

    const isEmpty = computed(() => {
      return todoItemList.value.length === 0
    });
    //provide() で上記で定義した isEmpty を子コンポーネントへ提供
    provide('isEmpty', isEmpty)

    const addTodo = (text) => {
      todoItemList.value.push({
        text: text,
        id: todoId.value++,
        completed: false
      });
    }
    //provide() で上記で定義した addTodo を子コンポーネントへ提供
    provide('addTodo', addTodo)

    const deleteTodo = (id) => {
      todoItemList.value = todoItemList.value.filter((object) => {
        return object.id !== id
      });
    }
    //provide() で上記で定義した deleteTodo を子コンポーネントへ提供
    provide('deleteTodo', deleteTodo)

    return { todoId, todoItemList, isEmpty, addTodo, deleteTodo }
  },
  template:`<h1>To Do List</h1>
  <div id="todo-container">
    <todo-form @add-item="addTodo" />
    <todo-list
      :todo-item-list="todoItemList"
      :is-empty="isEmpty"
      @delete-item="deleteTodo"
    />
  </div>`
}

createApp({
  components: {
    'todo-app': TodoApp,
  },
  template:`<todo-app />`
}).mount("#app");
HTML
<div id="app"></div>
todo-composition5.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vue To Do List App</title>
  <style>
    .completed {
      text-decoration: line-through;
    }
  </style>
</head>
<body>
<div id="app"></div>
<!-- Vue の読み込み ※本番環境では本番向けビルド (末尾が .prod.js) を使用 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
// 利用するメソッドを分割代入を使って Vue から取得
const { createApp, inject, provide, ref, computed } = Vue;

const TodoList = {
  setup() {
    // setup() 内で inject() を使って親コンポーネントが提供するデータを注入
    const todoItemList = inject('todoItemList')
    const isEmpty = inject('isEmpty')
    const deleteTodo = inject('deleteTodo')

    return {todoItemList, isEmpty, deleteTodo}
  },
  template: `<p v-if="isEmpty">現在 To Do リストにアイテムはありません</p>
  <ol v-else class="todo-list">
    <li v-for="item in todoItemList" :key="item.id">
      <input type="checkbox" v-model="item.completed" />
      <span :class="{'completed': item.completed}">
        {{item.text}}
      </span>
      <button @click="deleteTodo(item.id)">削除</button>
    </li>
  </ol>`
}

const TodoForm = {
  setup() {
    // 入力されたテキスト(v-model で input 要素に双方向バインド)
    const todoText = ref("")
    //親コンポーネントが提供するメソッドを注入
    const addTodo = inject('addTodo')
    //注入したメソッド addTodo を使って、メソッド addItem を定義
    const addItem = () => {
      if (!todoText.value) return
      addTodo(todoText.value)
      todoText.value = ''
    }
    return { todoText, addItem }
  },
  template:`<form @submit.prevent="addItem">
    <input type="text" v-model="todoText" />
    <button>追加</button>
  </form>`
}

const TodoApp = {
  components: {
    'todo-list': TodoList,
    'todo-form': TodoForm
  },
  setup() {
    const todoId = ref(0)
    const todoItemList = ref([
      //ダミーのデータ(本番では ID が重複してしまうため削除する必要あり)
      { text: "掃除をする", id: 0, completed: false },
      { text: "洗濯をする", id: 1, completed: false },
    ])
    //setup() 内で provide() を使って上記の todoItemList を提供
    provide('todoItemList', todoItemList)

    const isEmpty = computed(() => {
      return todoItemList.value.length === 0
    });
    //provide() で上記で定義した isEmpty を提供
    provide('isEmpty', isEmpty)

    const addTodo = (text) => {
      todoItemList.value.push({
        text: text,
        id: todoId.value++,
        completed: false
      });
    }
    //provide() で上記で定義した addTodo を提供
    provide('addTodo', addTodo)

    const deleteTodo = (id) => {
      todoItemList.value = todoItemList.value.filter((object) => {
        return object.id !== id
      });
    }
    //provide() で上記で定義した deleteTodo を提供
    provide('deleteTodo', deleteTodo)

    return { todoId, todoItemList, isEmpty, addTodo, deleteTodo }
  },
  template:`<h1>To Do List</h1>
  <div id="todo-container">
    <todo-form @add-item="addTodo" />
    <todo-list
      :todo-item-list="todoItemList"
      :is-empty="isEmpty"
      @delete-item="deleteTodo"
    />
  </div>`
}

createApp({
  components: {
    'todo-app': TodoApp,
  },
  template:`<todo-app />`
}).mount("#app");
</script>
</body>
</html>

ネイティブの template 要素を使用

前述の例では、template オプションにテンプレートを定義する際、バッククォート(`)で文字列を囲むテンプレートリテラルで HTML を記述しましたが、template オプションに # から始まる文字列(要素のID)を指定して、ネイティブの <template> 要素を利用することもできます。

template オプションに # から始まる文字列を指定した場合、 querySelector として使用されます。

以下はリスト部分とフォーム部分のコンポーネントを切り出して、それらのテンプレートをネイティブの <template> 要素を利用して記述する例です。

HTML
<div id="app">
  <h1>To Do List</h1>
  <div id="todo-container">
    <todo-form @add-item="addTodo"></todo-form>
    <todo-list
      :todo-item-list="todoItemList"
      :is-empty="isEmpty"
      @delete-item="deleteTodo"
    ></todo-list>
  </div>
</div>

<!-- ネイティブの <template> 要素を使用 -->
<template id="todo-form">
  <form @submit.prevent="addItemToList">
    <input type="text" v-model="todoText" />
    <button>追加</button>
  </form>
</template>
<template id="todo-list">
  <p v-if="isEmpty">現在 To Do リストにアイテムはありません</p>
  <ol v-else class="todo-list">
    <li v-for="item in todoItemList" :key="item.id">
      <input type="checkbox" v-model="item.completed" />
      <span :class="{'completed': item.completed}">
        {{item.text}}
      </span>
      <button @click="deleteItemFromList(item.id)">削除</button>
    </li>
  </ol>
</template>
JavaScript
//リスト部分のコンポーネント
const TodoList= {
  props: {
    todoItemList: Array,
    isEmpty: Boolean
  },
  emits: ['deleteItem'],
  setup(props, context) {
    const deleteItemFromList = (id) => {
      context.emit("deleteItem", id);
    }
    return { deleteItemFromList }
  },
  // id が todo-list の <template> 要素を参照
  template: "#todo-list"
  //または template: document.getElementById("todo-list") などでもOK
}

//フォーム部分のコンポーネント
const TodoForm = {
  emits: ['addItem'],
  setup(props, context) {
    const todoText = Vue.ref("")
    const addItemToList = () => {
      if (!todoText.value) return
      context.emit("addItem", todoText.value)
      todoText.value = ''
    }
    return { todoText, addItemToList }
  },
  // id が todo-form の <template> 要素を参照
  template: "#todo-form"
  //または template: document.getElementById("todo-form") などでもOK
}

Vue.createApp({
  components: {
    'todo-list': TodoList,
    'todo-form': TodoForm
  },
  setup() {
    const todoId = Vue.ref(0)
    const todoItemList = Vue.ref([
      //ダミーのデータ(本番では ID が重複してしまうため削除する必要あり)
      { text: "掃除をする", id: 0, completed: false },
      { text: "洗濯をする", id: 1, completed: false },
    ])
    const isEmpty = Vue.computed(() => {
      return todoItemList.value.length === 0
    });
    const addTodo = (text) => {
      todoItemList.value.push({
        text: text,
        id: todoId.value++,
        completed: false
      });
    }
    const deleteTodo = (id) => {
      todoItemList.value = todoItemList.value.filter((object) => {
        return object.id !== id
      });
    }
    return { todoId, todoItemList, isEmpty, addTodo, deleteTodo }
  }
}).mount("#app");
todo-composition6.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vue To Do List App</title>
  <style>
    .completed {
      text-decoration: line-through;
    }
  </style>
</head>
<body>

<div id="app">
  <h1>To Do List</h1>
  <div id="todo-container">
    <todo-form @add-item="addTodo"></todo-form>
    <todo-list
      :todo-item-list="todoItemList"
      :is-empty="isEmpty"
      @delete-item="deleteTodo"
    ></todo-list>
  </div>
</div>

<!-- ネイティブの <template> 要素を使用 -->
<template id="todo-form">
  <form @submit.prevent="addItemToList">
    <input type="text" v-model="todoText" />
    <button>追加</button>
  </form>
</template>
<template id="todo-list">
  <p v-if="isEmpty">現在 To Do リストにアイテムはありません</p>
  <ol v-else class="todo-list">
    <li v-for="item in todoItemList" :key="item.id">
      <input type="checkbox" v-model="item.completed" />
      <span :class="{'completed': item.completed}">
        {{item.text}}
      </span>
      <button @click="deleteItemFromList(item.id)">削除</button>
    </li>
  </ol>
</template>
<!-- ※本番環境では本番向けビルド (末尾が .prod.js) を使用 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>

<script>
//リスト部分のコンポーネント
const TodoList= {
  props: {
    todoItemList: Array,
    isEmpty: Boolean
  },
  emits: ['deleteItem'],
  setup(props, context) {
    const deleteItemFromList = (id) => {
      context.emit("deleteItem", id);
    }
    return { deleteItemFromList }
  },
  // id が todo-list の <template> 要素を参照
  template: "#todo-list"
  //または template: document.getElementById("todo-list")
}

//フォーム部分のコンポーネント
const TodoForm = {
  emits: ['addItem'],
  setup(props, context) {
    const todoText = Vue.ref("")
    const addItemToList = () => {
      if (!todoText.value) return
      context.emit("addItem", todoText.value)
      todoText.value = ''
    }
    return { todoText, addItemToList }
  },
  // id が todo-form の <template> 要素を参照
  template: "#todo-form"
  //または template: document.getElementById("todo-form")
}

Vue.createApp({
  components: {
    'todo-list': TodoList,
    'todo-form': TodoForm
  },
  setup() {
    const todoId = Vue.ref(0)
    const todoItemList = Vue.ref([
      //ダミーのデータ(本番では ID が重複してしまうため削除する必要あり)
      { text: "掃除をする", id: 0, completed: false },
      { text: "洗濯をする", id: 1, completed: false },
    ])
    const isEmpty = Vue.computed(() => {
      return todoItemList.value.length === 0
    });
    const addTodo = (text) => {
      todoItemList.value.push({
        text: text,
        id: todoId.value++,
        completed: false
      });
    }
    const deleteTodo = (id) => {
      todoItemList.value = todoItemList.value.filter((object) => {
        return object.id !== id
      });
    }
    return { todoId, todoItemList, isEmpty, addTodo, deleteTodo }
  }
}).mount("#app");
</script>
</body>
</html>

オプション: レンダリング template

ファイルに分割

Vue を CDN で読み込んで使用する場合でも、必要であればコンポーネントごとにファイルに分割することもできます。以下のような方法が考えられます。

  • script タグの src 属性でそれぞれのファイルを読み込む方法
  • script タグの type 属性に module を指定して、モジュールとして読み込む方法
  • Vue の ES モジュールビルド(vue.esm-browser.js)を読み込み、モジュールとして読み込む方法

この例では、それぞれのコンポーネントの JavaScript を以下の3つのファイルに分割します。

  • TodoApp.js
  • TodoForm.js
  • TodoList.js

また、Vue アプリのインスタンスの生成とマウントの処理は main.js というファイルに記述し、ルートコンポーネントは App というコンポーネントに切り出して別ファイル(App.js)にします。

※ この例の場合、main.js や App.js に分割する必要はありませんが、Vite などで生成されるプロジェクトの構成と同じような構成にするために分割しています。

以降の例でのファイルの構成は以下のようになっています。

js という JavaScript ファイルのディレクトリを作成し、その中に components というディレクトリを作成します。

main.js と App.js は js ディレクトリの直下に配置し、その他のコンポーネントは components ディレクトリの中に配置します。

todo  //プロジェクトのディレクトリ
├── css
│   └── style.css
├── index.html
└── js
    ├── App.js  // Root Component(ルートコンポーネント)
    ├── components //コンポーネントのディレクトリ
    │   ├── TodoApp.js
    │   ├── TodoForm.js
    │   └── TodoList.js
    └── main.js  //Vue のインスタンスの生成とマウント

script タグの src 属性で読み込む

分割したそれぞれのファイルを script タグの src 属性で指定して読み込むことができます。

但し、ファイルを読み込む順番には注意する必要があります。TodoForm.js と TodoList.js の順番は入れ替えても問題ありませんが、その他のファイルは以下の順番で読み込む必要があります(例えば、TodoApp.js は TodoForm.js と TodoList.js が先に定義されている必要があります) 。

この方法の場合、ローカル環境などは必要ありません(例えば、デスクトップに作成してもファイルをダブルクリックすれば動作します)。

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vue To Do List App</title>
  <link rel="stylesheet" href="css/style.css">
</head>
<body>
  <div id="app"></div>
  <!-- Vue を読み込む ※本番環境では本番向けビルド (末尾が .prod.js) を使用 -->
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  <!-- それぞれのコンポーネントを読み込む -->
  <script src="js/components/TodoForm.js"></script>
  <script src="js/components/TodoList.js"></script>
  <script src="js/components/TodoApp.js"></script>
  <script src="js/App.js"></script>
  <script src="js/main.js"></script>
</body>
</html>
js/main.js
//必要なメソッドをまとめてグローバルビルドから取得
const { createApp, inject, provide, ref, computed } = Vue;

//App コンポーネント(ルートコンポーネント)を渡してアプリのインスタンスを生成してマウント
createApp(App).mount("#app");
js/App.js
//ルートコンポーネント
const App = {
  components: {
    'todo-app': TodoApp,
  },
  template:`<todo-app />`
}

以下のそれぞれのコンポーネントのファイルは、前述の例の JavaScript のそれぞれのコンポーネントの部分を切り出しただけです。

以下は CSS ファイルです。全ての方法で同じなので以降では省略します。

css/style.css
.completed {
  text-decoration: line-through;
}

type 属性に module を指定して読み込む

script タグで main.js を読み込む際に type 属性に module を指定して、モジュールとして読み込む方法です。この方法の場合、ES モジュール構文が使えるので、その他のファイルは importexport を使って読み込むことができます。

但し、MAMP などのローカル環境や開発環境などが必要になります。

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vue To Do List App</title>
  <link rel="stylesheet" href="css/style.css">
</head>
<body>
  <div id="app"></div>
  <!-- Vue を読み込む  ※本番環境では本番向けビルド (末尾が .prod.js) を使用 -->
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  <!-- type 属性に module を指定して、モジュールとして読み込む -->
  <script src="js/main.js" type="module"></script>
</body>
</html>

モジュールとして読み込んでいるので、ES モジュール構文の importexport 文が使えます。また、各モジュールには独自のスコープがあります。

前述の例では、必要なメソッドを main.js でまとめて取得していましたが、この方法の場合は、それぞれのモジュールで取得する必要があります。

js/main.js
// App コンポーネント(ルートコンポーネント)をインポート
import { App } from './App.js'

// createApp メソッドをグローバルビルドから分割代入で取得
const { createApp } = Vue;

//App コンポーネントを渡してアプリのインスタンスを生成してマウント
createApp(App).mount("#app");
js/App.js
//TodoApp コンポーネントをインポート
import { TodoApp } from './components/TodoApp.js'

//ルートコンポーネント(App)を定義してエクスポート
export const App = {
  components: {
    'todo-app': TodoApp,
  },
  template:`<todo-app />`
}

import を使って利用するコンポーネントを読み込み、定義したコンポーネントを export します。コンポーネントの定義自体は前述の例と同じです。

ES モジュールビルドの CDN を利用

ES モジュールビルドの CDN を利用することもできます。

※ index.html では main.js を読み込む script タグの type 属性に module を指定する必要があります。

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vue To Do List App</title>
  <link rel="stylesheet" href="css/style.css">
</head>
<body>
  <div id="app"></div>
  <!-- type 属性に module を指定して、モジュールとして読み込む -->
  <script src="js/main.js" type="module"></script>
</body>
</html>

Vue のメソッドを ES モジュールビルドの CDN からインポートすることができます。

今まで使用してきたグローバルビルドの CDN とは URL が異なり、末尾は vue.esm-browser.js です。また、グローバルビルド同様、本番環境では本番向けビルド (末尾が .prod.js) を使用します。

js/main.js
// Vue のメソッドを ES モジュールビルドの CDN からインポート(※本番環境では .prod.js を使用)
import { createApp } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js'

//App コンポーネント(ルートコンポーネント)をインポート
import { App } from './App.js'

//App コンポーネントを渡してアプリのインスタンスを生成してマウント
createApp(App).mount("#app");
js/App.js
import { TodoApp } from './components/TodoApp.js'
export const App = {
  components: {
    'todo-app': TodoApp,
  },
  template:`<todo-app />`
}

前述の例との違いは、Vue のメソッドを ES モジュールビルドの CDN からインポートしているところだけです。

js/components/TodoForm.js
//Vue のメソッドを ES モジュールビルドの CDN からインポート(※本番環境では .prod.js を使用)
import { inject, ref } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js'

export const TodoForm = {
  setup() {
    // 入力されたテキスト(v-model で input 要素に双方向バインド)
    const todoText = ref("")
    //親コンポーネントが提供するメソッドを注入
    const addTodo = inject('addTodo')
    //注入したメソッド addTodo を使って、メソッド addItem を定義
    const addItem = () => {
      if (!todoText.value) return
      addTodo(todoText.value)
      todoText.value = ''
    }
    return { todoText, addItem }
  },
  template:`<form @submit.prevent="addItem">
    <input type="text" v-model="todoText" />
    <button>追加</button>
  </form>`
}
//Vue のメソッドを ES モジュールビルドの CDN からインポート(※本番環境では .prod.js を使用)
import { inject } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js'

export const TodoList = {
  setup() {
    // setup() 内で inject() を使って親コンポーネントが提供するデータを注入
    const todoItemList = inject('todoItemList')
    const isEmpty = inject('isEmpty')
    const deleteTodo = inject('deleteTodo')

    return {todoItemList, isEmpty, deleteTodo}
  },
  template: `<p v-if="isEmpty">現在 To Do リストにアイテムはありません</p>
  <ol v-else class="todo-list">
    <li v-for="item in todoItemList" :key="item.id">
      <input type="checkbox" v-model="item.completed" />
      <span :class="{'completed': item.completed}">
        {{item.text}}
      </span>
      <button @click="deleteTodo(item.id)">削除</button>
    </li>
  </ol>`
}

Vue をダウンロードして利用

前述の例の場合、各コンポーネントで CDN から Vue のメソッドをインポートしていますが、Vue のファイルをダウンロードして利用することもできます。

最新版の ES モジュールビルドを使用するには、以下のいずれかの URL にアクセスして表示されるコードをコピーし、ファイルを作成して保存します。

例えば以下のように、コードをコピーして作成した(ダウンロードした)ファイルを配置します。

todo  //プロジェクトのディレクトリ
├── css
│   └── style.css
├── index.html
└── js
    ├── App.js
    ├── components
    │   ├── TodoApp.js
    │   ├── TodoForm.js
    │   └── TodoList.js
    ├── main.js
    ├── vue.esm-browser.js //ES モジュールビルド
    └── vue.esm-browser.prod.js //ES モジュールビルド(本番向けビルド)

そして、CDN の URL の代わりに、配置した上記のファイルからインポートするようにします。

例えば、本番向けビルドのファイルを使用する場合、main.js は以下のようになります。

js/main.js
//ダウンロードした ES モジュールビルドのファイルからインポートして Vue を使用
import { createApp } from './vue.esm-browser.prod.js'

//App コンポーネント(ルートコンポーネント)をインポート
import { App } from './App.js'

//App コンポーネントを渡してアプリのインスタンスを生成してマウント
createApp(App).mount("#app");

※ ダウンロードしたファイルには任意の名前を付けられます。この例では URL のファイル名をそのまま使って vue.esm-browser.prod.js として保存していますが、例えば、ファイル名を vue.js とすれば import の記述が短くて済みます。

js/components/TodoApp.js
import { TodoForm } from './TodoForm.js'
import { TodoList } from './TodoList.js'
//ダウンロードした ES モジュールビルドのファイルからインポートして Vue を使用
import { provide, ref, computed  } from '../vue.esm-browser.prod.js'

export const TodoApp = {
  components: {
    'todo-list': TodoList,
    'todo-form': TodoForm
  },
  setup() {
    const todoId = ref(0)
    const todoItemList = ref([
      //ダミーのデータ(本番では ID が重複してしまうため削除する必要あり)
      { text: "掃除をする", id: 0, completed: false },
      { text: "洗濯をする", id: 1, completed: false },
    ])
    //setup() 内で provide() を使って上記の todoItemList を提供
    provide('todoItemList', todoItemList)

    const isEmpty = computed(() => {
      return todoItemList.value.length === 0
    });
    //provide() で上記で定義した isEmpty を提供
    provide('isEmpty', isEmpty)

    const addTodo = (text) => {
      todoItemList.value.push({
        text: text,
        id: todoId.value++,
        completed: false
      });
    }
    //provide() で上記で定義した addTodo を提供
    provide('addTodo', addTodo)

    const deleteTodo = (id) => {
      todoItemList.value = todoItemList.value.filter((object) => {
        return object.id !== id
      });
    }
    //provide() で上記で定義した deleteTodo を提供
    provide('deleteTodo', deleteTodo)

    return { todoId, todoItemList, isEmpty, addTodo, deleteTodo }
  },
  template:`<h1>To Do List</h1>
  <div id="todo-container">
    <todo-form @add-item="addTodo" />
    <todo-list
      :todo-item-list="todoItemList"
      :is-empty="isEmpty"
      @delete-item="deleteTodo"
    />
  </div>`
}
js/components/TodoForm.js
//ダウンロードした ES モジュールビルドのファイルからインポートして Vue を使用
import { inject, ref } from '../vue.esm-browser.prod.js'

export const TodoForm = {
  setup() {
    // 入力されたテキスト(v-model で input 要素に双方向バインド)
    const todoText = ref("")
    //親コンポーネントが提供するメソッドを注入
    const addTodo = inject('addTodo')
    //注入したメソッド addTodo を使って、メソッド addItem を定義
    const addItem = () => {
      if (!todoText.value) return
      addTodo(todoText.value)
      todoText.value = ''
    }
    return { todoText, addItem }
  },
  template:`<form @submit.prevent="addItem">
    <input type="text" v-model="todoText" />
    <button>追加</button>
  </form>`
}
js/components/TodoList.js
//ダウンロードした ES モジュールビルドのファイルからインポートして Vue を使用
import { inject } from '../vue.esm-browser.prod.js'

export const TodoList = {
  setup() {
    // setup() 内で inject() を使って親コンポーネントが提供するデータを注入
    const todoItemList = inject('todoItemList')
    const isEmpty = inject('isEmpty')
    const deleteTodo = inject('deleteTodo')

    return {todoItemList, isEmpty, deleteTodo}
  },
  template: `<p v-if="isEmpty">現在 To Do リストにアイテムはありません</p>
  <ol v-else class="todo-list">
    <li v-for="item in todoItemList" :key="item.id">
      <input type="checkbox" v-model="item.completed" />
      <span :class="{'completed': item.completed}">
        {{item.text}}
      </span>
      <button @click="deleteTodo(item.id)">削除</button>
    </li>
  </ol>`
}

以下のファイルは Vue をインポートしていないので、前述の例と同じです。

js/App.js
import { TodoApp } from './components/TodoApp.js'
export const App = {
  components: {
    'todo-app': TodoApp,
  },
  template:`<todo-app />`
}

※ index.html では main.js を読み込む script タグの type 属性に module を指定する必要があります。

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vue To Do List App</title>
  <link rel="stylesheet" href="css/style.css">
</head>
<body>
  <div id="app"></div>
  <!-- type 属性に module を指定して、モジュールとして読み込む -->
  <script src="js/main.js" type="module"></script>
</body>
</html>

Vite でプロジェクトを生成して作成

Vite は、Vue SFC(単一ファイルコンポーネント)のサポートがある(SFC をコンパイルできる)ビルドツールで、Vue のプロジェクトの雛形を簡単に作成することができます(ツールガイド)。

Vite の使い方などの詳細は Vite と SFC 単一ファイルコンポーネント を御覧ください。

ターミナルなどのコマンドラインで、npm create vite@latestnpm create vue@latest を実行すると対話形式のプロジェクト生成ウィザードが起動するので、プロジェクトの名前などを指定してプロジェクトのベースを作成します。

以下は npm create vite@latest でアプリのプロジェクトの雛形を作成する例です。任意のディレクトリでコマンドを実行すると、指定した名前のディレクトリがその下に作成されます。

ターミナル
% npm create vite@latest  return  //Vue プロジェクトの雛形を作成
✔ Project name: … todo-vite  // プロジェクトの名前を指定
✔ Select a framework: › Vue  // framework として Vue を選択
✔ Select a variant: › JavaScript  // JavaScript を選択

Scaffolding project in /Applications/MAMP/htdocs/vue/todo-vite...

Done. Now run:

cd todo-vite
npm install
npm run dev

続いて、生成したプロジェクトのディレクトリへ cd コマンドで移動して npm install コマンドを実行して必要なパッケージ(依存関係にあるパッケージ)をインストールします。

% cd todo-vite  return  // プロジェクトのディレクトリに移動
% npm install  return   //必要なパッケージをインストール

added 33 packages, and audited 34 packages in 6s

4 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

npm run dev コマンドを実行すると開発サーバを起動することができます。

% npm run dev  return  //開発サーバを起動

> todo-vite@0.0.0 dev
> vite

  VITE v3.2.4  ready in 540 ms

  ➜  Local:   http://127.0.0.1:5173/  //アクセス先の URL
  ➜  Network: use --host to expose

表示された URL(http://127.0.0.1:5173/)にブラウザでアクセスすると、以下のような画面が表示されます(開発サーバを終了するには、control + c を押します。)。

上記により、以下のようなファイルが生成されます。

todo-vite  //プロジェクトのディレクトリ
├── README.md
├── index.html  //表示用ファイル
├── node_modules //インストールされたパッケージのディレクトリ
├── package-lock.json
├── package.json
├── public  //※削除
│   └── vite.svg
├── src
│   ├── App.vue  // ルートコンポーネント
│   ├── assets  //※削除
│   │   └── vue.svg
│   ├── components  //コンポーネントを格納するディレクトリ
│   │   └── HelloWorld.vue  //サンプルのコンポーネント(※削除)
│   ├── main.js //エントリポイント
│   └── style.css
└── vite.config.js //Vite の設定ファイル

この構成に必要なファイルを追加して To Do アプリを作成します。使用しないディレクトリやファイルは削除することができます。

この例では、表示用ファイルの index.html とエントリポイントの main.js はほぼそのまま使います。

index.html

以下は表示用ファイルの index.html です。type 属性に module を指定した script タグでエントリポイントの main.js を読み込んでいます。

lang 属性を必要に応じて変更し(以下では ja に変更)、サンプル表示用のアイコンの読み込みは削除し、title タグの内容も適当に変更します。

index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> -->
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>To Do List App with Vite</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

main.js

以下はエントリポイントの main.js です。createApp メソッドを vue からインポートして、インポートしたルートコンポーネントの App を渡してアプリのインスタンスを生成してマウントしています。

また、グローバルなスタイルを記述する style.css をインポートして読み込んでいます。style.css にはサンプル用のグローバルなスタイルが記述されているので削除し、必要に応じてグローバルなスタイルを設定します。グローバルなスタイルの設定が不要であれば、スタイルの読み込みを削除できます。

src/main.js
import { createApp } from 'vue'
import './style.css'  //必要に応じて削除
import App from './App.vue'  //ルートコンポーネントをインポート

//アプリのインスタンスを生成してマウント
createApp(App).mount('#app')

コンポーネントの追加

不要なファイルやディレクトリを削除し、以下のようにアプリに使用するコンポーネントのファイルを追加します。以降ではコンポーネントは単一ファイルコンポーネント(SFC / Single File Component)の形式で記述します(拡張子は .vue)。

todo-vite  //プロジェクトのディレクトリ
├── index.html //表示用ファイル
├── node_modules
├── package-lock.json
├── package.json
├── src
│   ├── App.vue  // ルートコンポーネント(Root Component)
│   ├── components
│   │   ├── TodoApp.vue  // 追加(拡張子は .vue)
│   │   ├── TodoForm.vue  // 追加(拡張子は .vue)
│   │   └── TodoList.vue  // 追加(拡張子は .vue)
│   ├── main.js //エントリポイント
│   └── style.css  //グローバルスタイル(必要なければ削除)
└── vite.config.js

Single File Component(SFC)

Single File Component(単一ファイルコンポーネント)は、Vue コンポーネントのテンプレート(HTML)、ロジック(JavaScript)、そしてスタイル(CSS)を1つのファイルにまとめることができる特別なファイル形式です。

単一ファイルコンポーネントにはビルドステップが必要ですが、Vite で作成したプロジェクトの場合、Vite をインストールする際に同時にインストールされる開発サーバを起動した状態にしておけば、自動的にコンパイルされるので、開発中は特に何もする必要はありません。

本番環境で使用する場合は、別途 npm run build を実行してビルドする必要があります。

.vue ファイル
<!-- script ブロック(ロジック) -->
<script>
  // JavaScript
</script>

<!-- template ブロック(テンプレート) -->
<template>
  // HTML
</template>

<!-- style ブロック(スタイル) -->
<style>
  // CSS
</style>

TodoForm.vue

以下はフォーム部分のコンポーネントの TodoForm.vue です。

コンポーネントのファイルの拡張子を .vue として、単一ファイルコンポーネント(.vue ファイル)の形式で記述します。

Vue のメソッドは 'vue' からインポートすることができます。

setup() メソッドを使う場合は、単一ファイルコンポーネント(SFC)に含まれるコンポーネント定義は1つだけなので、デフォルトメンバーとして定義してエクスポート(export default)します。

テンプレートは <template> ブロックに記述します。

SFC ではスタイルも同じファイルに設定でき、style 要素に scoped 属性を指定すると、現在のコンポーネントの要素にだけ適用されるローカルなスタイルを設定できます。

scripttemplate の内容は、ファイルに分割した際のそれぞれのコンポーネントと同じです。

src/components/TodoForm.vue
<script>
// inject と ref メソッドをインポート
import { inject, ref } from 'vue'

export default {
  setup() {
    // 入力されたテキスト(v-model で input 要素に双方向バインド)
    const todoText = ref("")
    //親コンポーネントが提供するメソッドを注入
    const addTodo = inject('addTodo')
    //注入したメソッド addTodo を使って、メソッド addItem を定義
    const addItem = () => {
      if (!todoText.value) return
      addTodo(todoText.value)
      todoText.value = ''
    }
    return { todoText, addItem }
  }
}
</script>

<template>
  <form @submit.prevent="addItem">
    <input type="text" v-model="todoText" />
    <button>追加</button>
  </form>
</template>

<style scoped>
/* フォーム部分のスタイル(以下は適当なスタイル) */
form input {
  min-width: 200px;
}
</style>

TodoList.vue

以下はリスト部分のコンポーネントの TodoList.vue です。

完了状態のクラス .completed のスタイルはこのファイルの style ブロックで設定することができます。

src/components/TodoList.vue
<script>
import { inject } from 'vue';

export default {
  setup() {
    // setup() 内で inject() を使って親コンポーネントが提供するデータを注入
    const todoItemList = inject('todoItemList')
    const isEmpty = inject('isEmpty')
    const deleteTodo = inject('deleteTodo')

    return {todoItemList, isEmpty, deleteTodo}
  }
}
</script>

<template>
  <p v-if="isEmpty">現在 To Do リストにアイテムはありません</p>
  <ol v-else class="todo-list">
    <li v-for="item in todoItemList" :key="item.id">
      <input type="checkbox" v-model="item.completed" />
      <span :class="{'completed': item.completed}">
        {{item.text}}
      </span>
      <button @click="deleteTodo(item.id)">削除</button>
    </li>
  </ol>
</template>

<style scoped>
/* 完了状態のクラスのスタイル */
.completed {
  text-decoration: line-through;
}
/* その他のリスト部分のスタイル(以下は適当なスタイル) */
.todo-list {
  padding-left: 1.5rem;
  margin-top: 10px;
}
.todo-list span {
  display: inline-block;
  margin: 0.25rem 1rem;
  min-width: 100px;
}
</style>

TodoApp.vue

以下はアプリ全体のコンポーネントの TodoApp.vue です。

単一ファイルコンポーネントのテンプレート内では、コンポーネント名は常に TodoForm や TodoList のようにパスカルケースにすることが推奨されています(DOM テンプレートの中ではケバブケース)。

src/components/TodoApp.vue
<script>
import TodoForm from './TodoForm.vue'
import TodoList from './TodoList.vue'

import { provide, ref, computed  } from 'vue'

export default {
  components: {
    TodoList,  // TodoList: TodoList と同じこと
    TodoForm  // TodoForm : TodoForm と同じこと
  },
  setup() {
    const todoId = ref(0)
    const todoItemList = ref([
      //ダミーのデータ(本番では ID が重複してしまうため削除する必要あり)
      { text: "掃除をする", id: 0, completed: false },
      { text: "洗濯をする", id: 1, completed: false },
    ])
    //setup() 内で provide() を使って上記の todoItemList を提供
    provide('todoItemList', todoItemList)

    const isEmpty = computed(() => {
      return todoItemList.value.length === 0
    });
    //provide() で上記で定義した isEmpty を提供
    provide('isEmpty', isEmpty)

    const addTodo = (text) => {
      todoItemList.value.push({
        text: text,
        id: todoId.value++,
        completed: false
      });
    }
    //provide() で上記で定義した addTodo を提供
    provide('addTodo', addTodo)

    const deleteTodo = (id) => {
      todoItemList.value = todoItemList.value.filter((object) => {
        return object.id !== id
      });
    }
    //provide() で上記で定義した deleteTodo を提供
    provide('deleteTodo', deleteTodo)

    return { todoId, todoItemList, isEmpty, addTodo, deleteTodo }
  }
}
</script>

<template>
  <h1>To Do List</h1>
  <div id="todo-container">
    <!-- テンプレート内ではコンポーネント名は常にパスカルケース -->
    <TodoForm @add-item="addTodo" />
    <TodoList
      :todo-item-list="todoItemList"
      :is-empty="isEmpty"
      @delete-item="deleteTodo"
    />
  </div>
</template>

<style scoped>
/* 以下は適当なスタイル */
h1 {
  color: green;
}
#todo-container {
  border: 1px solid #ccc;
  padding: 20px;
}
</style>

App.vue

ルートコンポーネント(Root Component)の App.vue には初期状態では、サンプル表示用の記述があるので、全て削除して以下のように書き換えます。

src/App.vue
<script>
import TodoApp  from './components/TodoApp.vue'

export default {
  components: {
    TodoApp
  }
}
</script>

<template>
  <TodoApp />
</template>

単一のコンポーネント

以下はコンポーネントを分割せずに1つのコンポーネントととして SFC で記述した例です。

内容は最初に作成した CDN で読み込んで Composition API を使った例と同じですが、SFC の形式で記述し、メインのコンポーネント(ルートコンポーネント)の App.vue でインポートして使います。

src/components/TodoApp.vue
<script>
import { ref, computed  } from 'vue'

export default {
  setup() {
    // 入力されたテキスト(v-model で input 要素に双方向バインド)
    const todoText = ref("")
    // アイテムの ID(アイテム追加時に addTodo でカウントアップ)
    const todoId = ref(0)
    // アイテムのリスト:オブジェクト(テキスト、ID、完了状態)の配列を格納
    const todoItemList = ref([])
    // アイテムのリストが空かどうかを表す算出プロパティ
    const isEmpty = computed(() => {
      // 値にアクセスするには value プロパティを使う
      return todoItemList.value.length === 0
    });
    // アイテムを追加する関数(form 要素のイベントリスナ)
    const addTodo = () => {
      if (!todoText.value) return
      todoItemList.value.push({
        text: todoText.value,
        id: todoId.value++,
        completed: false
      });
      todoText.value = ""
    }
    // アイテムを削除する関数(削除ボタンのイベントリスナ)
    const deleteTodo = (id) => {
      todoItemList.value = todoItemList.value.filter((object) => {
        return object.id !== id
      });
    }
    // 定義したプロパティやメソッドを最後にまとめて return します
    return { todoText, todoId, todoItemList, isEmpty, addTodo, deleteTodo }
  }
}
</script>

<template>
  <h1>To Do List</h1>
  <div id="todo-container">
    <form @submit.prevent="addTodo">
      <input type="text" v-model="todoText" />
      <button>追加</button>
    </form>
    <p v-if="isEmpty">現在 To Do リストにアイテムはありません</p>
    <ol v-else class="todo-list">
      <li v-for="item in todoItemList" :key="item.id">
        <input type="checkbox" v-model="item.completed" />
        <span :class="{'completed': item.completed}">
          {{item.text}}
        </span>
        <button @click="deleteTodo(item.id)">削除</button>
      </li>
    </ol>
  </div>
</template>

<style scoped>
/* 完了状態のクラスのスタイル */
.completed {
  text-decoration: line-through;
}
</style>

以下は後述の script setup 構文を使った場合の例です。

src/components/TodoApp.vue(テンプレートとスタイルは同じなので省略)
<script setup>
import { ref, computed  } from 'vue'
// 入力されたテキスト(v-model で input 要素に双方向バインド)
const todoText = ref("")
// アイテムの ID(アイテム追加時に addTodo でカウントアップ)
const todoId = ref(0)
// アイテムのリスト:オブジェクト(テキスト、ID、完了状態)の配列を格納
const todoItemList = ref([])
// アイテムのリストが空かどうかを表す算出プロパティ
const isEmpty = computed(() => {
  // 値にアクセスするには value プロパティを使う
  return todoItemList.value.length === 0
});
// アイテムを追加する関数(form 要素のイベントリスナ)
const addTodo = () => {
  if (!todoText.value) return
  todoItemList.value.push({
    text: todoText.value,
    id: todoId.value++,
    completed: false
  });
  todoText.value = ""
}
// アイテムを削除する関数(削除ボタンのイベントリスナ)
const deleteTodo = (id) => {
  todoItemList.value = todoItemList.value.filter((object) => {
    return object.id !== id
  });
}
</script>

<template>
  <!-- 省略 -->
</template>

<style scoped>
  /* 省略 */
</style>

このコンポーネントはルートコンポーネントの App.vue でインポートします。App.vue は前述の例と同じです。

Render 関数の使用

必要であれば、<template> ブロックを使用する代わりに、Render 関数を使用することもできます。

以下は Render 関数(VNode を生成して返す関数)を使って書き換えた例です。

TodoForm.vue

以下の <template> ブロック内を h() 関数を使って VNode として作成して返します。

<template>
  <form @submit.prevent="addItem">
    <input type="text" v-model="todoText" />
    <button>追加</button>
  </form>
</template>

h() 関数を使って VNode を生成する際に、v-onv-model は使えないので、onSubmitonChange を使っています。

src/components/TodoForm.vue
<script>
// h メソッドを追加でインポート
import { inject, ref, h } from 'vue'

export default {
  setup() {
    // 入力されたテキスト(v-model で input 要素に双方向バインド)
    const todoText = ref("")
    //親コンポーネントが提供するメソッドを注入
    const addTodo = inject('addTodo')
    //注入したメソッド addTodo を使って、メソッド addItem を定義
    const addItem = () => {
      if (!todoText.value) return
      addTodo(todoText.value)
      todoText.value = ''
    }
    // Vnode を生成
    const vnode = h('form', {
      // @submit.prevent と同等
      onSubmit: (e) => {
        addItem()
        e.preventDefault()
      }},
      [
        // v-model と同等
        h('input', {
          value: todoText.value,
          onChange:(e)=> {
            todoText.value = e.target.value
            e.target.value = ''
          }
        }),
        h('button', {}, '追加')
      ]
    )
    // Render 関数を返す
    return () => vnode
  }
}
</script>

<style scoped>
form input {
  min-width: 200px;
}
</style>

@submit.prevent は以下のように、withModifiers を利用することもできます。

// withModifiers を追加でインポート
import { inject, ref, h, withModifiers } from 'vue'

・・・中略・・・

const vnode = h('form', {
  // withModifiers を利用(v-on.prevent と同等)
  onSubmit: withModifiers(() => {
    addItem()
  }, ['prevent'])},
  [
    // v-model と同等
    h('input', {
      value: todoText.value,
      //この場合、onInput ではうまくいかない
      onChange:(e)=> {
        //e.target.value は入力された値
        todoText.value = e.target.value
        e.target.value = ''
      }
    }),
    h('button', {}, '追加')
  ]
)
// Render 関数を返す
return () => vnode
・・・以下省略・・・

テンプレート機能(v-ifv-for など)を同等の render 関数で実装する例は以下に掲載されています。

TodoList.vue

以下の <template> ブロック内を h() 関数を使って VNode として生成して返します。

<template>
  <p v-if="isEmpty">現在 To Do リストにアイテムはありません</p>
  <ol v-else class="todo-list">
    <li v-for="item in todoItemList" :key="item.id">
      <input type="checkbox" v-model="item.completed" />
      <span :class="{'completed': item.completed}">
        {{item.text}}
      </span>
      <button @click="deleteTodo(item.id)">削除</button>
    </li>
  </ol>
</template>

上記のテンプレートの li 要素の v-for は、render 関数で実装する場合、配列(todoItemList.value)の各要素に対してコールバック関数を呼び出し、結果を含む配列を返す map() を利用することができます。

引数 item に受け取ったオブジェクトのプロパティを使って key 属性や子要素を生成しています。※ 引数にそれぞれのプロパティを todoItemList.value.map(({id, completed, text}) => {} のように分割代入してしまうと、リアクティビティが失われてしまいます。

src/components/TodoList.vue
<script>
// h メソッドを追加でインポート
import { inject, h } from 'vue';
export default {
  setup() {
    // setup() 内で inject() を使って親コンポーネントが提供するデータを注入
    const todoItemList = inject('todoItemList')
    const isEmpty = inject('isEmpty')
    const deleteTodo = inject('deleteTodo')

    // Render 関数を返す(全体を div 要素でラップする必要あり)
    return () => h('div',
    [isEmpty.value ? h('p', '現在 To Do リストにアイテムはありません') : h(
      'ol', { class: 'todo-list' },
      todoItemList.value.map((item) => {
        return h('li', { key: item.id },
          [
            h('input', {
              type: 'checkbox',
              onChange:(e)=> {
                //チェックされているかどうかの真偽値
                item.completed = e.currentTarget.checked
              }
            }),
            h('span', { class: item.completed ? 'completed': ''}, item.text ),
            h('button', {onClick: () => { deleteTodo(item.id) }}, '削除')
          ])
        })
      )
    ])
  }
}
</script>

<style scoped>
.completed {
  text-decoration: line-through;
}
</style>

TodoApp.vue

以下の <template> ブロック内を h() 関数を使って VNode として生成して返します。

<template>
  <h1>To Do List</h1>
  <div id="todo-container">
    <TodoForm @add-item="addTodo" />
    <TodoList
      :todo-item-list="todoItemList"
      :is-empty="isEmpty"
      @delete-item="deleteTodo"
    />
  </div>
</template>
src/components/TodoApp.vue
<script>
import TodoForm from './TodoForm.vue'
import TodoList from './TodoList.vue'
// h メソッドを追加でインポート
import { provide, ref, computed, h } from 'vue'

export default {
  components: {
    TodoList,  // TodoList: TodoList と同じこと
    TodoForm  // TodoForm : TodoForm と同じこと
  },
  setup() {
    const todoId = ref(0)
    const todoItemList = ref([
      //ダミーのデータ(本番では ID が重複してしまうため削除する必要あり)
      { text: "掃除をする", id: 0, completed: false },
      { text: "洗濯をする", id: 1, completed: false },
    ])
    //setup() 内で provide() を使って上記の todoItemList を提供
    provide('todoItemList', todoItemList)

    const isEmpty = computed(() => {
      return todoItemList.value.length === 0
    });
    //provide() で上記で定義した isEmpty を提供
    provide('isEmpty', isEmpty)

    const addTodo = (text) => {
      todoItemList.value.push({
        text: text,
        id: todoId.value++,
        completed: false
      });
    }
    //provide() で上記で定義した addTodo を提供
    provide('addTodo', addTodo)

    const deleteTodo = (id) => {
      todoItemList.value = todoItemList.value.filter((object) => {
        return object.id !== id
      });
    }
    //provide() で上記で定義した deleteTodo を提供
    provide('deleteTodo', deleteTodo)

    // Render 関数を返す(全体を div 要素でラップする必要あり)
    return () => h('div', [
      h('h1', 'To Do List'),
      h('div', {id: 'todo-container'}, [
        h(TodoForm),
        h(TodoList)
      ])
    ])
  }
}
</script>

<style scoped>
/* 以下は適当なスタイル */
h1 {
  color: green;
}
#todo-container {
  border: 1px solid #ccc;
  padding: 20px;
}
</style>

App.vue

以下の <template> ブロック内(TodoApp コンポーネント)を h() 関数を使って VNode として生成して返します。

<template>
  <TodoApp />
</template>
src/App.vue
<script>
import TodoApp  from './components/TodoApp.vue'

import { h } from 'vue'

export default {
  components: {
    TodoApp
  },
  setup() {
  return () => h(TodoApp)
  }
}
</script>

script setup 構文

単一ファイルコンポーネントで Composition API を使用している場合、script setup 構文が使えます。

script setup 構文を使うと、より簡潔なコードで記述できます。

<script setup> 構文は、通常の <script> 構文と比較をすると以下の点が異なっています。

  • setup() メソッドの内容としてコンパイルされるので setup() を省略
  • 直下で宣言された変数や関数、import されたメンバーはテンプレートに公開され、<template> 内で使用できるので、return する必要がない
  • import されたメンバーはテンプレートに公開されるので、import するだけでコンポーネントを直接使える(components オプションで登録する必要がない)
  • export default でコンポーネント定義をデフォルトエクスポートする必要がない
  • propsemits を宣言するには、defineProps と defineEmits を使用する必要がある

以下は script setup 構文を使って書き換えた例です。

TodoForm.vue

src/components/TodoForm.vue
<script setup>
import { inject, ref } from 'vue'

// 入力されたテキスト(v-model で input 要素に双方向バインド)
const todoText = ref("")
//親コンポーネントが提供するメソッドを注入
const addTodo = inject('addTodo')
//注入したメソッド addTodo を使って、メソッド addItem を定義
const addItem = () => {
  if (!todoText.value) return
  addTodo(todoText.value)
  todoText.value = ''
}
</script>

<template>
  <form @submit.prevent="addItem">
    <input type="text" v-model="todoText" />
    <button>追加</button>
  </form>
</template>

<style scoped>
form input {
  min-width: 200px;
}
</style>

TodoList.vue

src/components/TodoList.vue
<script setup>
import { inject } from 'vue';

// setup() 内で inject() を使って親コンポーネントが提供するデータを注入
const todoItemList = inject('todoItemList')
const isEmpty = inject('isEmpty')
const deleteTodo = inject('deleteTodo')
</script>

<template>
  <p v-if="isEmpty">現在 To Do リストにアイテムはありません</p>
  <ol v-else class="todo-list">
    <li v-for="item in todoItemList" :key="item.id">
      <input type="checkbox" v-model="item.completed" />
      <span :class="{'completed': item.completed}">
        {{item.text}}
      </span>
      <button @click="deleteTodo(item.id)">削除</button>
    </li>
  </ol>
</template>

<style scoped>
/* 完了状態のクラスのスタイル */
.completed {
  text-decoration: line-through;
}
/* その他のリスト部分のスタイル(省略) */
</style>

TodoApp.vue

src/components/TodoApp.vue
<script setup>
import TodoForm from './TodoForm.vue'
import TodoList from './TodoList.vue'

import { provide, ref, computed  } from 'vue'

const todoId = ref(0)
const todoItemList = ref([
  //ダミーのデータ(本番では ID が重複してしまうため削除する必要あり)
  { text: "掃除をする", id: 0, completed: false },
  { text: "洗濯をする", id: 1, completed: false },
])
//setup() 内で provide() を使って上記の todoItemList を提供
provide('todoItemList', todoItemList)

const isEmpty = computed(() => {
  return todoItemList.value.length === 0
});
//provide() で上記で定義した isEmpty を提供
provide('isEmpty', isEmpty)

const addTodo = (text) => {
  todoItemList.value.push({
    text: text,
    id: todoId.value++,
    completed: false
  });
}
//provide() で上記で定義した addTodo を提供
provide('addTodo', addTodo)

const deleteTodo = (id) => {
  todoItemList.value = todoItemList.value.filter((object) => {
    return object.id !== id
  });
}
//provide() で上記で定義した deleteTodo を提供
provide('deleteTodo', deleteTodo)
</script>

<template>
  <h1>To Do List</h1>
  <div id="todo-container">
    <!-- テンプレート内ではコンポーネント名は常にパスカルケース -->
    <TodoForm @add-item="addTodo" />
    <TodoList
      :todo-item-list="todoItemList"
      :is-empty="isEmpty"
      @delete-item="deleteTodo"
    />
  </div>
</template>

<style scoped>
</style>

App.vue

src/App.vue
<script setup>
import TodoApp  from './components/TodoApp.vue'
</script>

<template>
  <TodoApp />
</template>

Pinia を使った To-Do アプリ

Vue の状態管理ツール Pinia を使って To-Do アプリを作成する例です。

Pinia を利用すると、Vue 3 の状態管理(コンポーネント間のデータの受け渡しやデータの共有など)が簡単に行なえます。

関連ページ:Pinia を使って状態管理

前述の例に、npm install pinia で Pinia を追加することもできますが、以下では新たに Pinia を追加したプロジェクトを作成します。

任意のディレクトリで npm create vue@latest を実行すると対話形式のプロジェクト生成ウィザードが起動するので、Add Pinia for state management? で矢印キーで Yes を選択して Pinia を追加したプロジェクトを作成します。

プロジェクト生成ウィザードではデフォルトが No なのでそのまま return キーを押せばその項目はインストールされません。

以下ではプロジェクト名を todo-pinia としたので、コマンドを実行したディレクトリの下に todo-pinia というディレクトリが作成されます。

% npm create vue@latest return // Vue プロジェクトの枠組みを生成
Need to install the following packages:
  create-vue@3.4.1
Ok to proceed? (y) y //必要なパッケージがあればインストール

Vue.js - The Progressive JavaScript Framework

✔ Project name: … todo-pinia  //プロジェクト名を指定
✔ Add TypeScript? … No / Yes  //そのまま return キーを押せば No(インストールされない)
✔ Add JSX Support? … No / Yes
✔ Add Vue Router for Single Page Application development? … No / Yes
✔ Add Pinia for state management? … No / Yes //矢印キーで Yes を選択
✔ Add Vitest for Unit Testing? … No / Yes
✔ Add an End-to-End Testing Solution? › No
✔ Add ESLint for code quality? … No / Yes

Scaffolding project in /Applications/MAMP/htdocs/vue/todo-pinia...

Done. Now run:

  cd todo-pinia
  npm install
  npm run dev

続いて、生成されたプロジェクトのディレクトリに移動し、npm install を実行して必要なパッケージをインストールします。

% cd todo-pinia return //生成されたプロジェクトのディレクトリに移動

% npm install return //必要なパッケージをインストール

added 36 packages, and audited 37 packages in 8s

6 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

% npm run dev return //開発サーバーを起動

> todo-pinia@0.0.0 dev
> vite


  VITE v3.2.4  ready in 260 ms

  ➜  Local:   http://127.0.0.1:5173/  // ← アクセスする URL
  ➜  Network: use --host to expose

npm run dev を実行すると開発サーバーを起動することができます。

開発サーバーを終了するには control + c を押します。

上記により以下のようなプロジェクトのファイル(一部省略)が生成されます。ストアを格納するディレクトリ stores が生成され、その中にサンプルのストアのファイル(counter.js)があります。

todo-pinia //プロジェクトのディレクトリ
├── index.html  // 表示用フィル
├── node_modules
├── package-lock.json
├── package.json
├── public
├── src
│   ├── App.vue  // ルートコンポーネント
│   ├── assets
│   ├── components  // コンポーネントのディレクトリ
│   │   ├── HelloWorld.vue // サンプルのコンポーネント
│   │   ├── TheWelcome.vue //同上
│   │   ├── WelcomeItem.vue //同上
│   │   └── icons //アイコンのコンポーネント
│   ├── main.js  // エントリポイント(Pinia のセットアップを含む)
│   └── stores  // ストアのディレクトリ
│         └── counter.js  // ストアのサンプル
└── vite.config.js //Vite の設定ファイル

main.js

main.js には、以下のように createPinia() で生成した Pinia を createApp() に渡して Pinia を読み込んで Vue のインスタンスを生成する処理が記述されていまるのでこのまま使用できます。

main.css の読み込みは不要なので削除します。

src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'  //pinia から createPinia をインポート
import App from './App.vue'

//import './assets/main.css'  //不要なので削除

const app = createApp(App)  //Vue アプリのインスタンスを生成

app.use(createPinia())  //Pinia を生成してアプリで読み込む

app.mount('#app')

以下ではストアを定義するファイル(todoList.js)と3つのコンポーネントのファイル(TodoApp.vue、TodoForm.vue、TodoList.vue)を追加し、App.vue を編集します。

todo-pinia //プロジェクトのディレクトリ
├── index.html
├── node_modules
├── package-lock.json
├── package.json
├── public
├── src
│   ├── App.vue  // 編集
│   ├── assets
│   ├── components
│   │   ├── TodoApp.vue // 追加
│   │   ├── TodoForm.vue // 追加
│   │   └── TodoList.vue // 追加
│   ├── main.js
│   └── stores
│         └── todoList.js  // 追加(または counter.js の名前を変更)
└── vite.config.js 

ストアの定義

コンポーネント間で共有するデータを専用のファイルに定義します(ストアを定義します)。

ストアを定義するファイルは、通常、src/stores ディレクトリの中に配置します。

ストアを定義するには、まず、Pinia から defineStore 関数をインポートします。

インポートした defineStore() を使ってストアを定義し、名前を付けてエクスポートします。ストアの名前は慣例的に use で始まり、最後に Store を付けます。

defineStore() は以下の2つの引数を取ります。

  • 第1引数: ID (ストアを識別するための文字列)
  • 第2引数:オブジェクト (Option Stores) またはオブジェクトを返す関数(Setup Stores

この例では todoList.js というファイルをストアのディレクトリ src/stores に作成してストアを定義し、名前を useTodoListStore としてエクスポートしています。

また、defineStore() の第1引数の ID にはファイル名と同じ todoList を指定しています(ID をファイル名と同じにする必要はありませんが、わかりやすくなります)。

src/stores/todoList.js
import { defineStore } from 'pinia'

// ストアを定義して名前を付けてエクスポート
export const useTodoListStore = defineStore('todoList', {
  // Option Stores 構文の場合
  // state(データ本体)
  // getters(state のデータに対する算出プロパティ)
  // actions(state の値を更新する関数)
})

内部的には、defineStore() はコンポーネント側でストアを取得するために必要な useStore 関数を作成します。useStore 関数は、defineStore() の戻り値に指定した名前の関数になります。

この例の場合、コンポーネント側では、上記で名前を付けてエクスポートしている useTodoListStore をインポートして useStore 関数 useTodoListStore() として実行することでストアのインスタンスを生成して、このインスタンス経由でストアにアクセスします。

defineStore() の第2引数に指定する Options オブジェクトは以下のプロパティを持ちます。

第2引数の Options オブジェクト(Option Stores)
プロパティ
state データ本体(Vue の Options API の data オプションに相当)。state はデータの初期状態(初期値)を返すアロー関数として定義します。
getters state のデータに対する算出プロパティ(Vue の computed オプションに相当)
actions state の値を更新する関数(Vue の methods オプションに相当)。

以下がこの例のストアの定義です。コンポーネント間で共有するデータや関数を定義します。

src/stores/todoList.js
// defineStore を pinia からインポート
import { defineStore } from 'pinia'

// ストアを useTodoListStore という名前(関数)で定義してエクスポート
export const useTodoListStore = defineStore('todoList', {
  //state にはデータの初期値を定義(アロー関数で返す)
  state: () => ({
    //アイテム(オブジェクト)を格納する配列:(初期状態は空の配列)
    todoItemList: [],
    //アイテムの ID(アイテム追加時に addTodo でカウントアップ。初期値は0)
    todoId : 0,
  }),

  //getters には state の値をもとに算出した結果を取得するゲッターを定義
  getters: {
    //アイテムのリストが空かどうかの真偽値を返すゲッター(算出プロパティ)
    isEmpty: (state) => state.todoItemList.length === 0,
  },

  //actions には state の値を更新する関数を定義(state の各データには this でアクセス)
  actions: {
    // state の todoItemList(配列)にアイテムを追加する関数
    addTodo(text) {
      //追加する際に state の todoId(ID)の値を増加
      this.todoItemList.push({
        text, //text:text と同じこと
        id: this.todoId++,
        completed: false
      })
    },

    //アイテムを state の todoItemList(配列)から削除する関数
    deleteTodo(id) {
      this.todoItemList = this.todoItemList.filter((object) => {
        return object.id !== id
      })
    },
  },
})

state

アイテムのリスト(todoItemList)とアイテムの ID(todoId)の初期値を定義。todoItemList{ text: "掃除をする", id: 0, completed: false } のようなアイテムのオブジェクトの配列ですが、初期状態ではまたアイテムはないので空の配列です。

todoId はアイテムのリストを v-for で表示する際に、key 属性に指定する値でアイテムを追加するたびに1増加する整数です(初期値は0)。

getters

アイテムのリストが空かどうかを表すゲッター(isEmpty)を定義。gettersstate を引数に受け取るので、アイテムのリストの配列の長さは state.todoItemList.length で取得できます。

actions

state のリスト(todoItemList)にアイテムを追加する関数(addTodo)とアイテムを削除する関数(deleteTodo)を定義。state の各データには this でアクセスできます。

これらのデータや関数はストアをインポートしてインスタンス化すれば、どのコンポーネントからでもアクセスすることができます。言い換えると、コンポーネント間で共有するデータを1つのストア(ファイル)で管理することができます。

Setup Stores

以下は前述のストアの定義を Setup 構文を使って書き換えた例です(詳細 Setup Syntax)。

この構文では、defineStore() でストアを定義する際に、第2引数にリアクティブなプロパティとメソッドを定義してそれらを含むオブジェクトを返す関数を渡します。

src/stores/todoList.js
// defineStore を pinia からインポート
import { defineStore } from 'pinia'
// ref と computed を vue からインポート
import { ref, computed } from 'vue'

// Option Stores 同様、ストアを定義してエクスポート(第2引数はオブジェクトを返す関数)
export const useTodoListStore = defineStore('todoList', () => {

  // ref() を使ってリアクティブなデータの初期値を定義(state に相当)
  //アイテム(オブジェクト)を格納する配列:(初期状態は空)
  const todoItemList = ref([])
  //アイテムの ID(アイテム追加時に addTodo でカウントアップ)
  const todoId = ref(0)

  // computed() を使用して算出プロパティを定義(getters に相当)
  const isEmpty = computed( () => todoItemList.value.length === 0 )

  // 関数を定義(actions に相当)※ リアクティブな値には .value でアクセス
  // アイテムを追加する関数
  const addTodo = (text) => {
    todoItemList.value.push({
      text, //text:text と同じこと
      id: todoId.value++,
      completed: false
    })
  }
  //アイテムを削除する関数
  const deleteTodo = (id) => {
    todoItemList.value = todoItemList.value.filter((object) => {
      return object.id !== id
    })
  }

  // 公開したいプロパティとメソッドを含むオブジェクトを返す
  return { todoItemList, todoId, isEmpty, addTodo, deleteTodo }
})

ストアにアクセス

各コンポーネントでストアにアクセスするには、ストアの定義(useStore 関数)をインポートします。

そしてインポートした useStore 関数を実行してストアのインスタンスを生成し、そのインスタンスを介してストアの state や getters、actions などで定義した個々のプロパティにアクセスします。

この例では、ストア(stores/todoList.js)から useStore 関数の useTodoListStore をインポートし、useTodoListStore() を実行してストアのインスタンスを生成して変数 store に代入します。そして、この変数 store を介してストアの個々のプロパティにアクセスします。

例えば、ストアの actions に定義した関数 addTodo を使用するには store.addTodo() とします。

TodoForm.vue

フォーム部分のコンポーネント TodoForm.vue では、フォームに入力されたテキストを格納するリアクティブなデータを定義し、変数 todoText に代入して、input 要素に v-model でバインドします。

ストアのインスタンスを生成して変数 store に代入します。

フォームの submit イベントのリスナ addItem では、入力された値が空でなければストアのアクション store.addTodo() に入力されたテキストを渡します。また、テキストフィールドをクリアする処理も行います。

src/components/TodoForm.vue
<script>
import { ref } from "vue";
//ストアから useStore 関数をインポート
import { useTodoListStore } from '../stores/todoList'

export default {
  setup() {
    // テキストフィールドに入力される値(アイテムのテキスト)のリアクティブなデータ
    const todoText = ref('');
    // ストアのインスタンスを生成して変数に代入
    const store = useTodoListStore()
    // ストアの addTodo を呼び出し、テキストフィールドをクリアする関数
    const addItem = (text) => {
      //テキストが空であれば何もしない
      if (text.length === 0) return // if (!text) return でも同じ
      // テキストを引数にストアの addTodo アクションを呼び出す(アイテムが追加される)
      store.addTodo(text)
      // テキストフィールドをクリア(空にする)
      todoText.value = ''
    }
    return { todoText, addItem, store };
  },
};
</script>

<template>
  <div>
    <form @submit.prevent="addItem(todoText)">
      <input v-model="todoText" type="text" /><button>Add</button>
    </form>
  </div>
</template>

<style scoped>
form input {
  min-width: 200px;
}
</style>
src/components/TodoForm.vue
<script setup>
import { ref } from "vue";
//ストアから useStore 関数をインポート
import { useTodoListStore } from '@/stores/todoList'
// テキストフィールドに入力される値のリアクティブなデータ
const todoText = ref('');
// ストアのインスタンスを生成して変数に代入
const store = useTodoListStore()
// ストアの addTodo を呼び出し、テキストフィールドをクリアする関数
const addItem = (text) => {
  //テキストが空であれば何もしない
  if (text.length === 0) return
  // テキストを引数にストアの addTodo アクションを呼び出す(アイテムが追加される)
  store.addTodo(text)
  // テキストフィールドをクリア(空にする)
  todoText.value = ''
}
</script>

<!-- テンプレートとスタイルは同じなので省略 -->

TodoList.vue

アイテムを一覧表示するリスト部分のコンポーネント TodoList.vue では、ストアのインスタンスを生成して、statetodoItemListgettersisEmpty をインスタンスから分割代入しています。

分割代入する際は、piniastoreToRefs メソッドを使います。単に分割代入してしまうと、データのリアクティビティが失われます。但し、actions の関数はそのまま分割代入します。

src/components/TodoList.vue
<script>
// ストアをインポート
import { useTodoListStore } from '../stores/todoList'
// pinia から storeToRefs メソッドをインポート
import { storeToRefs } from "pinia"

export default {
  setup() {
    // ストアを生成
    const store = useTodoListStore()
    // pinia の storeToRefs を使ってリアクティビティを保持しながら分割代入
    const { todoItemList, isEmpty } = storeToRefs(store)
    // アクションの関数は直接分割代入します
    const { deleteTodo } = store
    return { todoItemList, isEmpty, deleteTodo }
  },
}
</script>

<template>
  <p v-if="isEmpty">現在 To Do リストにアイテムはありません</p>
  <ol v-else class="todo-list">
    <li v-for="item in todoItemList" :key="item.id">
      <input type="checkbox" v-model="item.completed" />
      <span :class="{'completed': item.completed}">
        {{item.text}}
      </span>
      <button @click="deleteTodo(item.id)">削除</button>
    </li>
  </ol>
</template>

<style scoped>
/* 完了状態のクラスのスタイル */
.completed {
  text-decoration: line-through;
}
</style>

分割代入をしない場合は、以下のようになります。この場合ストアのプロパティにアクセスするには、インスタンスを代入した変数 store を介してアクセスします。

<script>
// ストアをインポート
import { useTodoListStore } from '../stores/todoList'

export default {
 setup() {
   // ストアを生成
   const store = useTodoListStore()
   return { store }
 },
}
</script>

<template>
 <!-- store.isEmpty や store.todoItemList、store.deleteTodo() でアクセス -->
 <p v-if="store.isEmpty">現在 To Do リストにアイテムはありません</p>
 <ol v-else class="todo-list">
   <li v-for="item in store.todoItemList" :key="item.id">
     <input type="checkbox" v-model="item.completed" />
     <span :class="{'completed': item.completed}">
       {{item.text}}
     </span>
     <button @click="store.deleteTodo(item.id)">削除</button>
   </li>
 </ol>
</template>
<script setup>
// ストアをインポート
import { useTodoListStore } from '@/stores/todoList'
// pinia から storeToRefs メソッドをインポート
import { storeToRefs } from "pinia"
// ストアを生成
const store = useTodoListStore();
// pinia の storeToRefs を使ってリアクティビティを保持しながら分割代入
const { todoItemList, isEmpty } = storeToRefs(store)
// アクションの関数は直接分割代入します
const { deleteTodo } = store
</script>

<!-- テンプレートとスタイルは同じなので省略 -->

TodoApp.vue

共有するデータや関数はストアに定義してあるので、アプリ全体のコンポーネント TodoApp では、TodoForm と TodoList をインポートしてテンプレートで出力するだけです。

src/components/TodoApp.vue
<script>
import TodoForm from './TodoForm.vue'
import TodoList from './TodoList.vue'
export default {
  components: {
    TodoForm,
    TodoList
  }
}
</script>

<template>
  <div  class="todo-app">
    <h1>To Do List</h1>
    <TodoForm />
    <TodoList />
  </div>
</template>
<script setup>
import TodoForm from './TodoForm.vue'
import TodoList from './TodoList.vue'
</script>

<!-- テンプレートとスタイルは同じなので省略 -->

App.vue

以下はルートコンポーネントの App.vue です。

src/App.vue
<script>
import TodoApp from './components/TodoApp.vue'
export default {
  components: {
    TodoApp
  }
}
</script>

<template>
  <div class="content">
    <TodoApp />
  </div>
</template>
<script setup>
import TodoApp from './components/TodoApp.vue'
</script>

<!-- テンプレートとスタイルは同じなので省略 -->