サイトトップ

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

HTML5テクニカルノート

Vue + <script setup> + TypeScript: TodoMVC 03 算出プロパティとデータのローカルへの保存


Vue + <script setup> + TypeScriptの構文で、公式サイト「TodoMVC」の例をつくるシリーズ第3回です。はじめのお題の算出プロパティは、リアクティブなデータが含まれるロジックから結果を導いて返します。ふたつめは、Web Storage APIを用いたローカルへのデータの読み書きです。おまけとして、データの変更をリアクティブに追跡するwatchEffect()の使い方についてご説明します。

01 算出プロパティを使う

入力したTodoリストから未了の項目数を数えて、フッタに表示しましょう。Todo項目の数や終了・未了の状態はリアクティブに変わります。リアクティブなデータの含まれるロジックから、結果を導いて返すのが「算出プロパティ」です。関数computed()を用い、算出されたrefオブジェクトが返されます。引数はgetter関数です。computed()の型づけは戻り値から推論されますので、通常は気にしなくてかまいせん。

算出プロパティは、自動的にリアクティブな依存関係を追跡します。ルートモジュールsrc/App.vueにつぎのように算出プロパティremainingを加えれば、todos.valueに依存すると認識され、データの更新が同期されるのです。フッタは新たなコンポーネント(TodoController)として、このあと定めます。

src/App.vue

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

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

const remaining = computed(() => getActive(todos.value).length);

const getActive = (todos: Todo[]) => {
	return todos.filter((todo) => !todo.completed);
};
</script>

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

		<TodoController :todos="todos" :remaining="remaining" />
	</section>
</template>

フッタとして加えるコントローラのモジュールsrc/components/TodoController.vueは、ルートコンポーネント(App)からデータバインディングされた算出プロパティremainingの値を<template>に差し込んで表示するだけです。<footer>要素にはv-showディレクティブを加えました。右辺式の真偽値評価がtrueでなければ要素は表示されません(なお「v-show」参照)。そもそもTodoリストの入力項目がひとつもないときに、残り件数0と示すのは冗長だからです。

src/components/TodoController.vue

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

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

<template>
	<footer class="footer" v-show="todos.length">
		<span class="todo-count">
			<strong>{{ remaining }}</strong> items left
		</span>
	</footer>
</template>

本シリーズではVue公式の英語作例(TodoMVC)をお題にしています。そこで、細かいことを気にすると、残り項目1なら単数型の"item"とするのが正しいでしょう。これも、算出プロパティを用いれば、解決はつぎのように簡単です。Todoリストの未了項目数が表示されるようになりました(図001)。

src/components/TodoController.vue

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

// defineProps<Props>();
const props = defineProps<Props>();
const pluralize = computed(() => (props.remaining === 1 ? 'item' : 'items'));
</script>

<template>
	<footer class="footer" v-show="todos.length">
		<span class="todo-count">
			<!-- <strong>{{ remaining }}</strong> items left -->
			<strong>{{ remaining }}</strong> {{ pluralize }} left
		</span>
	</footer>
</template>

図001■Todoリストの未了項目数を表示

図001

コード001■Todoリストの未了項目数を示すコントローラモジュール

src/components/TodoController.vue

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

interface Props {
	todos: Todo[];
	remaining: number;
}
const props = defineProps<Props>();
const pluralize = computed(() => (props.remaining === 1 ? 'item' : 'items'));
</script>

<template>
	<footer class="footer" v-show="todos.length">
		<span class="todo-count">
			<strong>{{ remaining }}</strong> {{ pluralize }} left
		</span>
	</footer>
</template>

ノート01■算出プロパティとメソッドの違い

算出プロパティは、実質がgetter関数です。したがって、メソッドとして定めた同じ関数を呼び出しても結果は同じになります。

しかしながら、算出プロパティはリアクティブな依存関係にもとづきキャッシュされるという違いがあります。算出プロパティは、リアクティブな依存関係が更新されたときにだけ再評価されます。(「算出プロパティ vs. メソッド」)

前掲コードでは、todos.valueが変わらないかぎり、remainingを何度参照しても、getter関数は再実行されません。以前計算された結果がそのま返されるのです。それに対して、メソッドは再描画のたびに関数を実行します。とくにロジックが複雑であったり、データ量の多い場合、キャッシュを使って負荷の下げられる算出プロパティは有効です。

ノート02■Vue 3ではfiltersがサポートされない

単語の単数型・複数形の切り替え(pluralize)に、Vue 2の作例ではfiltersを使いました(「フィルタを使う」参照)。けれど、Vue 3ではfiltersはサポートされません(「3.x での更新」)。本文でご説明したとおり、算出プロパティを用いることが推奨されています。

ノート03■英語で0個の名詞は複数形で表す

前掲コードで、Todoリストの項目をすべてチェックすると、「0 items left」と複数形で示されることが気になったかもしれません。英語では0個の名詞は複数形で表すのです。詳しくは、「英語では『0個の』もの(名詞)は複数形で表現する」をお読みください。

02 データをローカルに読み書きする

Todoリストの項目データはローカルにもつことにしましょう。用いるのはWeb Storage APIです。Window.localStorageに保存すれば、ブラウザを閉じてもデータが残り、つぎに開いたときにふたたび表示できます。

使うメソッドはデータの読み込がStorage.getItem()、書き込みはStorage.setItem()です。データの形式はJSONにしますので、保存の前にJSON.stringify()で文字列にし、取り出したらJSON.parse()によりJSONデータに戻さなければなりません。Todoリストの項目データを読み書きするための新たなTypeScriptモジュールsrc/TodoStorage.tsがつぎのコード002です。一意のid値を収める変数uidの処理は、ルートコンポーネント(App)から移しました。

コード002■データをローカルに読み書きするモジュール

src/TodoStorage.ts

import type { Todo } from './App.vue';

const STORAGE_KEY = 'vue3-typescript-todomvc';
let uid = 0;
export const fetch = (): Todo[] => {
	const todos = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
	todos.forEach((todo: Todo, index: number) => {
		todo.id = index;
	});
	uid = todos.length;
	return todos;
};
export const save = (todos: Todo[]) => {
	localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
};
export const getNewId = () => {
	const newId = uid++;
	return newId;
};

Todo項目をローカルに読み書きするモジュール(src/TodoStorage.ts)からルートモジュール(src/App.vue)がimportするのは、データの読み込み(fetch())と書き込み(save())、およびid取得(getNewId())の関数です。Todoリストの初期値(todos)は読み込み関数で取り出します。また、Todo項目の追加と削除、およびチェックのオン・オフ時には、データを書き込まなければなりません。項目を加えるとき与える一意のidは取得関数から得ました。

src/App.vue

<script setup lang="ts">

import { fetch, getNewId, save } from './TodoStorage';

// const todos = ref<Todo[]>([]);
const todos = ref(fetch());
// const uid = ref(0);

const addTodo = (todoTitle: string) => {

	todos.value.push({
		// id: uid.value++,
		id: getNewId(),

	});
	save(todos.value);
};
const removeTodo = (todo: Todo) => {

	save(todos.value);
};
const done = (todo: Todo, completed: boolean) => {

	save(todos.value);
};

</script>

これで、他のページに遷移したり、ブラウザを終了しても、直近のデータがローカルに残り、つぎに開いたときそのまま表示されるようになりました。

03 watchEffect()を使う

前掲コードでは、データの保存をメソッドごとに加えています。データを扱うメソッドはこのあとも増えるかもしれません。関数ごとに保存をするかどうか決めたいときは、この考え方でよいでしょう。けれど今回は、ページを移っても、いきなりブラウザが落とされても、直前のデータを残したい場合です。このようなときには、データの変更をリアクティブに追跡するwatchEffect()が使えます。

watchEffect()の引数は実行するエフェクト関数(コールバック)です。算出プロパティと同じように、リアクティブな依存先を自動的に追跡して、副作用をすぐに実行します(「watchEffect()」参照)。モジュールsrc/App.vueにつぎのようにwatchEffect()を定めれば、依存するtodos.valueが変わるたび、コールバックのsave()は再実行されるのです。データの保存をメソッドごとにしなくて済むようになりました。

src/App.vue

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

watchEffect(() => save(todos.value));
const addTodo = (todoTitle: string) => {

	// save(todos.value);
};
const removeTodo = (todo: Todo) => {

	// save(todos.value);
};
const done = (todo: Todo, completed: boolean) => {

	// save(todos.value);
};

</script>

書き改めたモジュールsrc/App.vueの記述全体は、つぎのコード003のとおりです。

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

src/App.vue

<script setup lang="ts">
import { computed, ref, watchEffect } from 'vue';
import { fetch, getNewId, save } from './TodoStorage';
import TodoInput from './components/TodoInput.vue';
import TodoList from './components/TodoList.vue';
import TodoController from './components/TodoController.vue';

export interface Todo {
	id: number;
	title: string;
	completed: boolean;
}
const todos = ref(fetch());
const remaining = computed(() => getActive(todos.value).length);
watchEffect(() => save(todos.value));
const addTodo = (todoTitle: string) => {
	if (!todoTitle) return;
	todos.value.push({
		id: getNewId(),
		title: todoTitle,
		completed: false,
	});
};
const removeTodo = (todo: Todo) => {
	todos.value = todos.value.filter((item) => item !== todo);
};
const done = (todo: Todo, completed: boolean) => {
	todo.completed = completed;
};
const getActive = (todos: Todo[]) => {
	return todos.filter((todo) => !todo.completed);
};
</script>

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

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

ソース01■TodoMVC 03 算出プロパティとデータのローカルへの保存

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


作成者: 野中文雄
作成日: 2022年12月10日


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