Nuxt × TypeScript でTodoListとユーザ認証を実装してFirebase Hostingにデプロイ [Tutorial - Part 2/5 - TodoListを実装]
概要
Nuxt×TypeScriptでTodoListを実装し、Firebase Hostingにデプロイするチュートリアルです。簡単なCRUD(Create, Read, Update, Delete)アプリを作成することで、NuxtとTypeScriptの概要を掴んでもらうことが目的です。
Part1では「環境構築とHelloWorldの表示」までを行いました。このPart2では、TodoListを実装していきます。この Part が最重要です!!
このチュートリアルの全容は下記です。
Part1: 環境構築とHelloWorldの表示
Part2: TodoListを実装する
Part3:Bulmaを使ってデザインを整える
Part4:Firebase Hostingにデプロイする
Part5:Firebase Authを用いてユーザ認証機能を追加する
Part2の完成物であるソースと動画を先に貼っておきます。
では早速、Part2の内容に入っていきます。Nuxtの実装方法を色々と調べたのですが、色々な方法があるようで。。結局、公式に倣っておけば間違いないだろうと思い、下記のExampleを参考に実装していくことにしました。
状態の管理に Vuex を用います。下記の図をなんとなく雰囲気で理解すれば大丈夫です。
・Vue Components -> 表示の部分。ここからActionを呼ぶ。
・Action -> Mutationを呼び出す。別にバックエンドAPIを使わない場合でもActionを発火させてMutationを使う感じになると思います。
・Mutation -> Stateに変更を加える
・State -> 状態。ここの場合はTodoの状態を指します。
また、Stateの管理は Store というもので行い、単純にStateの中身を呼び出す場合はStoreに定義したGetterを使います。
色々書きましたが、たぶんコード見た方が分かりやすいです。
あと、Nuxtの概要くらいは知っておいても良さそうです。
1. gitのブランチを切る
Part1ではmasterブランチに直接pushしていましたが、実際の開発では、masterブランチは常に正確に動くものを置いておく必要があります。大まかな目的毎にgitのブランチを切り、masterブランチに対するプルリクエストを投げて、レビューを受け、マージする、という過程を辿ります。
~/workspace/nuxt/nuxt-ts-app-tkugimot(master ✔)
⚾️ Toshi
😪 💤 $ git checkout -b feature/todo
Switched to a new branch 'feature/todo'
~/workspace/nuxt/nuxt-ts-app-tkugimot(feature/todo ✔)
⚾️ Toshi
😪 💤 $
余談ですが、Terminal を iTerm2 にしたり、シェルを bash -> zsh にしたりすると色々と便利です。
2. root.ts, index.ts を store 直下に準備する
storeの下にrootの設定を用意します。
# store/root.ts
export interface State {}
export const state = (): State => ({})
# store/index.ts
import Vuex from 'vuex';
import * as root from './root';
import * as todos from './modules/todos';
export type RootState = root.State;
const createStore = () => {
return new Vuex.Store({
state: root.state(),
modules: {
}
})
}
export default createStore;
3. Todoの module と Component を準備する
拡張性を考え易いコードになるように、Todo関連のコードは module というもので作成していきます。まず store 直下に modules というディレクトリを作成します。
$ cd store
$ mkdir modules
次に、todos の下に todoTypes.ts と todos.ts を作成します。
# store/modules/todoTypes.ts
export interface Todo {
id: number
content: string
}
export interface State {
todos: Todo[]
}
# store/modules/todos.ts
import { State } from './todoTypes';
import { RootState } from 'store'
export const name = 'todos';
export const namespaced = true;
export const state = (): State => ({
todos: []
});
更に、TodoList.vue という名前で TodoList を表示するコンポーネントを作成します。
# components/TodoList.vue
<template>
<div>
<h1>{{ message }}</h1>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'
@Component
export default class TodosList extends Vue {
message: string = 'Todo List 😎'
}
</script>
最後に、pages/index.vue から呼び出していた HelloWorld コンポーネントと Todos コンポーネントを置き換えます。
# pages/index.vue
<template>
<Todos />
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import TodoList from '~/components/TodoList.vue';
@Component({
components: {
TodoList
}
})
export default class Home extends Vue {}
</script>
この段階で npm run dev を実行して localhost:3000 にアクセスすると、下記のような表示になっているはずです。
キリの良いところまで来たので一旦コミットしておきます。
$ git add .
$ git commit -m 'Add Store and Todo Component'
4. TodoList を表示する
まず全ての Todo を取得する Getter を用意します。
# store/modules/todos.ts
import { State } from './todoTypes';
import { RootState } from 'store'
import { GetterTree } from 'vuex';
export const name = 'todos';
export const namespaced = true;
export const state = (): State => ({
todos: []
});
export const getters: GetterTree<State, RootState> = {
allTodos: state => {
return state.todos;
}
}
次に、Todo 一つ一つを表示するコンポーネントを作成します。
# components/Todo.vue
<template>
<div>
<p>{{ todo.content }}</p>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator'
@Component
export default class Todo extends Vue {
@Prop() todo
}
</script>
最後に、TodoList.vue を修正します。
# components/TodoList.vue
<template>
<div>
<ul v-for="todo in allTodos" :key='todo.id'>
<li>
<Todo :todo="todo" />
</li>
</ul>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import Todo from '~/components/Todo.vue';
import { namespace } from 'vuex-class';
import * as todos from '~/store/modules/todos';
const Todos = namespace(todos.name);
@Component({
components: {
Todo
}
})
export default class TodoList extends Vue {
@Todos.Getter allTodos
}
</script>
※「Classic mode for store/ is deprecated and will be removed in Nuxt 3.」と警告が出ますが、これは下記のモジュールモードに関する記述が関連あるようです。要はもっと簡潔にモジュールを表現できるようなのですが、TypeScriptでは上手くモジュールモードを利用できなかったのと、公式のサンプルでもここに書いてあるのと同じクラシックモードで書かれていたので、一旦警告はスルーします。恐らく、しばらく待ったらまた知見が公開されるはずです。
ここで、npm run dev を実行すると、localhost:3000 に真っ白のページが表示されるはずです。これは、まだ Todo が一つも存在しないからです。
6. Todoの作成
まず Todo の interfaceを実装した TodlClass を作成します。
# store/modules/todoTypes.ts
export interface State {
todos: Todo[]
}
export interface Todo {
id: number
content: string
}
export class TodoClass implements Todo {
id: number;
content: string;
constructor(id: number, content: string) {
this.id = id;
this.content = content;
}
}
次に、 Todo を追加する Action と Mutation を定義します。
# store/modules/todos.ts
import { State } from './todoTypes';
import { RootState } from 'store'
import { GetterTree, ActionTree, ActionContext, MutationTree } from 'vuex';
import { TodoClass } from './todoTypes';
export const name = 'todos';
export const namespaced = true;
export const state = (): State => ({
todos: []
});
export const getters: GetterTree<State, RootState> = {
allTodos: state => {
return state.todos;
}
}
export const types = {
ADDTODO: 'ADDTODO'
}
export interface Actions<S, R> extends ActionTree<S, R> {
addTodo (context: ActionContext<S, R>, document): void
}
export const actions: Actions<State, RootState> = {
addTodo ({ commit }, document) {
const target: HTMLInputElement = <HTMLInputElement>document.target;
console.log(target.value);
commit(types.ADDTODO, target.value)
target.value = '';
}
}
export const mutations: MutationTree<State> = {
[types.ADDTODO] (state, content: string) {
let id = 0
if (state.todos.length > 0) {
id = state.todos[state.todos.length - 1].id + 1;
}
state.todos.push(new TodoClass(id, content));
}
}
次に、NewTodo.vue を追加します。
# components/NewTodo.vue
<template>
<div>
<h2>Todo Input</h2>
<input type="text" @keyup.enter="addTodo"/>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator'
import * as todos from '~/store/modules/todos'
import { namespace } from 'vuex-class'
const Todos = namespace(todos.name)
@Component
export default class NewTodo extends Vue {
@Todos.Action addTodo
}
</script>
最後に、pages/index.vue を修正し、NewTodo コンポーネントを追加します。
# pages/index.vue
<template>
<div>
<NewTodo />
<TodoList />
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import TodoList from '~/components/TodoList.vue';
import NewTodo from '~/components/NewTodo.vue';
import * as todos from '~/store/modules/todos';
@Component({
components: {
TodoList,
NewTodo
}
})
export default class Home extends Vue {
}
</script>
一旦 npm run dev で起動してみます。localhost:3000 にアクセスすると、下記のような画面が現れます。適当な文字を入力してエンターを押すと、リストにTodoが追加されていきます。
7. DONEボタンの追加
まず REMOVETODO のActionとMutationを追加します。
# store/modules/todos.ts
import { State } from './todoTypes';
import { RootState } from 'store'
import { GetterTree, ActionTree, ActionContext, MutationTree } from 'vuex';
import { TodoClass } from './todoTypes';
export const name = 'todos';
export const namespaced = true;
export const state = (): State => ({
todos: []
});
export const getters: GetterTree<State, RootState> = {
allTodos: state => {
return state.todos;
}
}
export const types = {
ADDTODO: 'ADDTODO',
REMOVETODO: 'REMOVETODO',
}
export interface Actions<S, R> extends ActionTree<S, R> {
addTodo (context: ActionContext<S, R>, document): void
removeTodo (context: ActionContext<S, R>, id: number): void
}
export const actions: Actions<State, RootState> = {
addTodo ({ commit }, document) {
const target: HTMLInputElement = <HTMLInputElement>document.target;
console.log(target.value);
commit(types.ADDTODO, target.value)
target.value = '';
},
removeTodo ({ commit }, id: number) {
commit(types.REMOVETODO, id)
}
}
export const mutations: MutationTree<State> = {
[types.ADDTODO] (state, content: string) {
let id = 0
if (state.todos.length > 0) {
id = state.todos[state.todos.length - 1].id + 1;
}
state.todos.push(new TodoClass(id, content, false));
},
[types.REMOVETODO] (state, id: number) {
// 各Todoはidを持っており、そのidを持つTodoのindexを取得する。
const todoIndex = state.todos.findIndex(todo => todo.id == id);
state.todos.splice(todoIndex, 1);
}
}
次に、Todo.vue に removeTodo とDONEボタンを追加します。
# components/Todo.vue
<template>
<div>
<p>{{ todo.content }}</p>
<button @click="removeTodo(todo.id)">DONE</button>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator'
import * as todos from '~/store/modules/todos'
import { namespace } from 'vuex-class'
const Todos = namespace(todos.name)
@Component
export default class Todo extends Vue {
@Prop() todo
@Todos.Action removeTodo
}
</script>
DONEボタンを押すとTodoがリストから削除できるようになりました。
8. Todoの編集
まず、Todo の interface とクラスに isEditing: boolean を追加します。EDITボタンで各Todoの状態をToggleさせ、現在が編集状態であるか、そうでないかを表現します。
# store/modules/todoTypes.ts
export interface Todo {
id: number
content: string
isEditing: boolean
}
export class TodoClass implements Todo {
id: number;
content: string;
isEditing: boolean;
constructor(id: number, content: string, isEditing: boolean) {
this.id = id;
this.content = content;
this.isEditing = isEditing;
}
}
export interface State {
todos: Todo[]
}
次に、Todoの状態をToggleさせる編集ボタンと、inputに文字を入力してエンターを押した時に発火しTodoの内容を更新する Update に対応する、ActionとMutationを追加します。
# store/modules/todos.ts
import { State } from './todoTypes';
import { RootState } from 'store'
import { GetterTree, ActionTree, ActionContext, MutationTree } from 'vuex';
import { Todo, TodoClass } from './todoTypes';
export const name = 'todos';
export const namespaced = true;
export const state = (): State => ({
todos: []
});
export const getters: GetterTree<State, RootState> = {
allTodos: state => {
return state.todos;
}
}
export const types = {
ADDTODO: 'ADDTODO',
REMOVETODO: 'REMOVETODO',
EDITTODO: 'EDITTODO',
UPDATETODO: 'UPDATETODO'
}
export interface Actions<S, R> extends ActionTree<S, R> {
addTodo (context: ActionContext<S, R>, document): void
removeTodo (context: ActionContext<S, R>, id: number): void
editTodo (context: ActionContext<S, R>, id: number): void
updateTodo (context: ActionContext<S, R>, document): void
}
export const actions: Actions<State, RootState> = {
addTodo ({ commit }, document) {
const target: HTMLInputElement = <HTMLInputElement>document.target;
console.log(target.value);
commit(types.ADDTODO, target.value)
target.value = '';
},
removeTodo ({ commit }, id: number) {
commit(types.REMOVETODO, id)
},
editTodo ({ commit }, id: number) {
commit(types.EDITTODO, id)
},
updateTodo ({ commit }, document) {
const target: HTMLInputElement = <HTMLInputElement>document.target;
const todo: TodoClass = new TodoClass(Number(target.id), target.value, false)
commit(types.UPDATETODO, todo);
target.value = '';
}
}
export const mutations: MutationTree<State> = {
[types.ADDTODO] (state, content: string) {
let id = 0
if (state.todos.length > 0) {
id = state.todos[state.todos.length - 1].id + 1;
}
state.todos.push(new TodoClass(id, content, false));
},
[types.REMOVETODO] (state, id: number) {
// 各Todoはidを持っており、そのidを持つTodoのindexを取得する。
const todoIndex = state.todos.findIndex(todo => todo.id == id);
state.todos.splice(todoIndex, 1);
},
[types.EDITTODO] (state, id: number) {
const todoIndex = state.todos.findIndex(todo => todo.id == id);
state.todos[todoIndex].isEditing = true;
},
[types.UPDATETODO] (state, todo: Todo) {
const todoIndex = state.todos.findIndex(el => el.id == todo.id);
// update前の要素を削除
state.todos.splice(todoIndex, 1);
// 新しい要素を同じ index に追加
state.todos.splice(todoIndex, 0, todo);
}
}
最後に、components/Todo.vue を修正します。
# components/Todo.vue
<template>
<div>
<div v-show="!todo.isEditing">
<p>Text : {{ todo.content }}</p>
<button @click="editTodo(todo.id)">Edit</button>
</div>
<div v-show="todo.isEditing">
<input :id="todo.id" type="text" @keyup.enter="updateTodo" :value="todo.content"/>
</div>
<button @click="removeTodo(todo.id)">DONE</button>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator'
import * as todos from '~/store/modules/todos'
import { namespace } from 'vuex-class'
const Todos = namespace(todos.name)
@Component
export default class Todo extends Vue {
@Prop() todo
@Todos.Action removeTodo
@Todos.Action editTodo
@Todos.Action updateTodo
}
</script>
ついに完成しました! 完成物は冒頭の動画です。
次の Part3 では、もう少しデザインを整えていこうと思います。(_ _).。o○
この記事が気に入ったらサポートをしてみませんか?