RAKUS Developers Blog | ラクス エンジニアブログ

株式会社ラクスのITエンジニアによる技術ブログです。

Vue初学者のためのTodoListチュートリアル【入門】

はじめに

こんにちは。新卒1年目のrksmskです。本記事はVue.jsを学び始めたばかりで実際に手を動かして簡単なアプリケーションを作成してみたい方のためのハンズオンチュートリアルとなっております。

是非手を動かしながら本記事をお読みください。なお、本記事はVue3でコードを記述しています。Vue.jsはVue2とVue3で記述方法が大きく異なるため、ご留意ください。

目標

Vue.jsを使ってTodoListの表示と追加が出来る簡単なTodoListアプリケーションが作れるようになること。

前提条件

  • Node.jsがインストールされていること(Node.jsの説明やインストール方法については以下の記事に詳しく記載されています)

雛形作成

まず、Vueの環境を作成して雛形の画面を表示します。

  1. TodoList作業用ディレクトリを作成します。
  2. ここからはターミナル上で作業を行います。TodoList作業用ディレクトリ下でターミナルからnpm install -g @vue/cli@nextを実行し、Vueをインストールします(インストールが上手くいかない場合にはNode.jsのバージョン等をご確認ください)。
  3. vue create todo-listを実行し、Vueの環境を作成します。その際にVueのバージョンを確認されるので、Vue3を選択します。パッケージマネージャはnpmを選択します。
  4. 環境作成が完了しましたら、cd todo-listを実行してVueの環境に移った後、npm run serveを実行してローカルのテストサーバを起動します。
  5. 立ち上がったサーバにブラウザからアクセスします。基本的にはhttp://localhost:8080/にサーバが立ち上がります。
  6. 下記のような雛形の画面が表示されることを確認します。
    雛形画面

これで雛形画面の作成が完了しました。

APIモック作成

続いて、TodoListのアイテムを作成します。今回はJSON Serverを使用してAPIモックを作成し、作成したAPIモックを通じてアイテムのCRUD(Create、Read、Update、Delete)操作を行います。

  1. ここからはターミナル上で作業を行います。npm i json-serverを実行し、json-serverをインストールします。
  2. mkdir webapiを実行し*1、webapiディレクトリを作成します。
  3. cd ./webapiを実行した後、touch db.jsonを実行し*2、db.jsonファイルを作成します。
  4. db.jsonファイルに以下を記入します。
    {
      "todos": [
        {
          "id": 1,
          "text": "働く",
          "categoryId": 1
        },
        {
          "id": 2,
          "text": "寝る",
          "categoryId": 1
        },
        {
          "id": 3,
          "text": "起きる",
          "categoryId": 2
        }
      ],
      "categories": [
        {
          "id": 1,
          "title": "やること"
        },
        {
          "id": 2,
          "title": "やったこと"
        }
      ]
    }
    
  5. webapi下でnpx json-server --watch db.jsonを実行し、APIモックサーバーを起動します。
  6. 立ち上がったサーバにアクセスします。基本的にはhttp://localhost:3000/にサーバが立ち上がります。
  7. 下記のようなJSON Serverの起動画面が表示されることを確認します。
    JSON Server起動画面

これでAPIモックの起動が完了しました。

Vuex導入

ここまでで、雛形画面の作成とAPIモックの起動が完了しました。ですので、ここからはAPIモックを通じてTodoListを取得し、雛形画面に表示することを行います。その際に、後々状態管理を行う上で便利になるため、Vuexを導入したいと思います。Vuexは共有状態の管理を行うライブラリで、異なるコンポーネント間で同一のデータを共有する際に重宝します。

  1. ここからはターミナル上で作業を行います。現在のディレクトリをtodo-listに変更した後、npm install vuex@next --saveを実行し、Vuexライブラリをインストールします。
  2. cd ./srcを実行した後、mkdir storeを実行し、todo-list/srcディレクトリ下にstoreディレクトリを作成します。
  3. touch store.jsを実行し、store.jsファイルを作成します。本ファイルがVuexで共有状態を管理するコードを記述するファイルとなります。
  4. store.jsを以下のように書き換えます。
    import { createStore } from "vuex";
    
    export default createStore({
        state() {
            return {
                categoryList: [],
                cardList: []
            }
        },
        mutations: {
            setCategoryList(state, categoryList) {
                state.categoryList = categoryList;
            },
            setCardList(state, cardList) {
                state.cardList = cardList;
            }
        },
        actions: {
            async fetchCategoryList(context) {
                const categoryList = await fetchItems("http://localhost:3000/categories");
                context.commit("setCategoryList", categoryList);
            },
            async fetchCardList(context) {
                const cardList = await fetchItems("http://localhost:3000/todos");
                context.commit("setCardList", cardList);
            },
        }
    });
    
    actionsに記載されているfetchXXXメソッドではAPIから情報を取得し、取得した情報を引数にmutationsのメソッドを呼び出しています。mutationsではstateの変更を行っています。 ここで注意したいこととして、mutationsでは非同期処理を行わないこと、actionsではstateの更新を直接行わないことです。これらの注意点の詳しい説明は以下の記事に詳しく記載されているため、そちらも併せてご覧ください。
  5. Vuexの設定をアプリケーションに反映させるため、main.jsファイルを以下のように書き換えます。
    import { createApp } from 'vue'
    import Store from './store/store.js';
    import App from './App.vue'
    
    createApp(App).use(Store).mount('#app')
    
    これでVuexでTodoListの状態管理をする仕組みが出来たため、後はAPIからデータを取得するfetchXXXメソッドを作成し、雛形画面に表示させます。
  6. 現在のディレクトリをtodo-listに変更した後、mkdir utilsを実行し、todo-list下にutilsディレクトリを作成します。*3
  7. cd ./utilsを実行した後、touch http.jsを実行し、todo-list/utilsディレクトリ下にhttp.jsファイルを作成します。
  8. http.jsを以下のように書き換えます。
    export const fetchItems = async (url) => {
        try {
            // API通信でデータを取得する
            const response = await fetch(url);
            // 取得したデータをjson形式で返す
            return await response.json();
        } catch (error) {
            // API通信でデータを上手く取得できなかった場合、コンソールにエラーを表示
            console.error("データを取得出来ませんでした");
            console.error(error);
        }
    };
    
  9. store.jsにhttp.jsをインポートするため、store.jsの二行目に以下を追加します。
    import { fetchItems } from "../../utils/http";
    
  10. App.vueを以下のように書き換えます。
    <template>
      <div>
        カード一覧:{{ cardList }}<br />
        カテゴリ一覧:{{ categoryList }}
      </div>
    </template>
    
    <script>
    import { computed, onMounted } from "vue";
    import { useStore } from "vuex";
    
    export default {
      name: "App",
      components: {},
      setup() {
        // Vuexを使う設定
        const store = useStore();
        // コンポーネントがマウントされた時にcategoryListとcardListをAPIから取得
        onMounted(store.dispatch("fetchCategoryList"));
        onMounted(store.dispatch("fetchCardList"));
        return {
          cardList: computed(() => store.state.cardList),
          categoryList: computed(() => store.state.categoryList)
        };
      }
    };
    </script>
    
    <style></style>
    
    上記の記法は単一ファイルコンポーネントと呼ばれる記述方法で、
    • templateタグ内に画面に表示するHTMLを記述する。
    • scriptタグ内にデータの定義や処理を記述する。
    • styleタグ内にcssでスタイルを記述する。
    といったようにHTML、JavaScriptCSSの処理をまとめて一つのファイルに記述することが出来ます。これにより、一つ一つのコンポーネントの保守がし易くなっています。
  11. ローカルのテストサーバを再起動し、下記のようなデータが画面に表示されることを確認します。
    カード一覧:[ { "id": 1, "text": "働く", "categoryId": 1 }, { "id": 2, "text": "寝る", "categoryId": 1 }, { "id": 3, "text": "起きる", "categoryId": 2 } ]
    カテゴリ一覧:[ { "id": 1, "title": "やること" }, { "id": 2, "title": "やったこと" } ]

これでTodoListの表示が完了しました。

コンポーネント作成

ここまででTodoListの表示は出来ましたが、現状はデータがそのまま表示されているだけで、非常に見づらいです。

ですので、ここからはTodoListをカード形式で表示するように変更していきたいと思います。その際に、Vueに備わっている一つ一つの部品をコンポーネントとして切り出すことで管理し易くする機能を利用します。

今回のコンポーネントは以下のようにボード、リスト、カードで分割を行います。

コンポーネント分割例

  1. components下のHelloWorld.vueを削除します。
  2. components下にBoard.vue、List.vue、Card.vueを作成します。
  3. App.vueを以下のように書き換えます。
    <template>
      <div>
        <board></board>
      </div>
    </template>
    
    <script>
    import Board from "./components/Board.vue";
    
    export default {
      name: "App",
      components: { Board }
    };
    </script>
    
    <style></style>
    
    少しだけVue2とVue3の違いを説明すると、Vue2では値はdataプロパティに、関数はmethodsにといったようにプロパティ毎に役割を分けていましたが、Vue3では新しく追加されたComposition APIの機能によってsetup関数にこれらの処理をまとめて記述することが出来ます。 そのsetup関数内ではVuexで定義したactions内のメソッドをstore.dispatch("メソッド名")によって呼び出します。その結果、VuexのcardListとcategoryListの状態が変化するため、その状態変化をcomputed関数によって検知し、データの反映を行っています。
  4. Board.vueを以下のように書き換えます。
    <template>
      <div class="board-style">
        <list
          v-for="category in categoryList"
          :key="category.id"
          :category="category"
        ></list>
      </div>
    </template>
    
    <script>
    import { computed, onMounted } from "vue";
    import { useStore } from "vuex";
    import List from "./List.vue";
    
    export default {
      components: { List },
      setup() {
        const store = useStore();
        onMounted(store.dispatch("fetchCategoryList"));
        return {
          categoryList: computed(() => store.state.categoryList)
        };
      }
    };
    </script>
    
    <style scoped>
    .board-style {
      display: flex;
      gap: 10px;
    }
    </style>
    
    Board.vueではv-forディレクティブによってカテゴリ毎にListコンポーネントを生成しています。その際にListコンポーネントにはカテゴリの情報を渡しています。
  5. List.vueを以下のように書き換えます。
    <template>
      <div class="list-style">
        {{ category.title }}
        <card v-for="card in cardList" :key="card.id" :card="card"></card>
      </div>
    </template>
    
    <script>
    import { computed, onMounted } from "vue";
    import { useStore } from "vuex";
    import Card from "./Card.vue";
    
    export default {
      components: { Card },
      props: {
        category: Object
      },
      setup(props) {
        const store = useStore();
        onMounted(store.dispatch("fetchCardList"));
        const cardList = computed(() =>
          store.state.cardList.filter(card => card.categoryId === props.category.id)
        );
        return {
          cardList,
        };
      }
    };
    </script>
    
    <style scoped>
    .list-style {
      display: inline-flex;
      flex-direction: column;
      text-align: center;
      background-color: silver;
      min-width: 200px;
      min-height: 400px;
    }
    </style>
    
    ListコンポーネントはBoardコンポーネントから渡ってきたカテゴリの情報をもとに、カテゴリIDと一致するカードをArrayオブジェクトの標準ライブラリであるfilterを用いて抽出し、v-forディレクティブによって抽出したカード毎にCardコンポーネントを生成しています。
  6. Card.vueを以下のように書き換えます。
    <template>
      <div class="card-style">
        {{ card.text }}
      </div>
    </template>
    
    <script>
    export default {
      props: {
        card: Object,
      }
    };
    </script>
    
    <style scoped>
    .card-style {
      display: flex;
      flex-direction: column;
      background-color: yellowgreen;
      margin: 10px;
      height: 10vh;
      align-items: center;
      justify-content: center;
      border-radius: 10px;
    }
    </style>
    
    CardコンポーネントはListコンポーネントから渡ってきたカードのテキストを表示しています。
  7. ローカルのテストサーバを再起動し、下記のような完成画面が表示されることを確認します。
    完成画面

これで、コンポーネント分割が完了し、TodoListが見やすくなりました。

アイテム追加機能作成

現状、アイテムの追加はwebapi下のdb.jsonをエディタ等で直接書き換えるか、POSTメソッドでHttpRequestを送る必要があり、少々手間がかかります。

そこで、空のカードを用意し、そのカードにテキストを入力して追加ボタンを押したら新しいカードが追加されるようにして利便性を上げたいと思います。

  1. http.jsを以下のように書き換えます。
    export const fetchItems = async (url) => {
        try {
            // API通信でデータを取得する
            const response = await fetch(url);
            // 取得したデータをjson形式で返す
            return await response.json();
        } catch (error) {
            // API通信でデータを上手く取得できなかった場合、コンソールにエラーを表示
            console.error("データを取得出来ませんでした");
            console.error(error);
        }
    };
    
    +export const insertItems = async (url, data) => {
    +   const response = await fetch(url, {
    +         // json形式でPOSTでデータを送る
    +        method: 'POST',
    +        headers: {
    +            'Content-Type': 'application/json;charset=utf-8'
    +        },
    +        body: JSON.stringify(data)
    +    }).catch(() => {
    +        // 上手くいかなかった場合、コンソールにエラーを表示
    +        console.error(response.json())
    +        return;
    +    })
    +};
    
  2. store.jsを以下のように書き換えます。
    import { createStore } from "vuex";
    -import { fetchItems } from "../../utils/http";
    +import { fetchItems, insertItems } from "../../utils/http";
    
    export default createStore({
        state() {
            return {
                categoryList: [],
                cardList: []
            }
        },
        mutations: {
            setCategoryList(state, categoryList) {
                state.categoryList = categoryList;
            },
            setCardList(state, cardList) {
                state.cardList = cardList;
            },
        },
        actions: {
            async fetchCategoryList(context) {
                const categoryList = await fetchItems("http://localhost:3000/categories");
                context.commit("setCategoryList", categoryList);
            },
            async fetchCardList(context) {
                const cardList = await fetchItems("http://localhost:3000/todos");
                context.commit("setCardList", cardList);
            },
    +        async addCard(context, data) {
    +            await insertItems("http://localhost:3000/todos", data);
    +        }
        }
    });
    
  3. List.vueを以下のように書き換えます。
    <template>
      <div class="list-style">
        {{ category.title }}
    -     <card v-for="card in cardList" :key="card.id" :card="card"></card>
    +    <card
    +      v-for="card in cardList"
    +      :key="card.id"
    +      :card="card"
    +      :isNew="false"
    +    ></card>
    +    <card :card="newCard" :isNew="true"></card>
      </div>
    </template>
    
    <script>
    import { computed, onMounted } from "vue";
    import { useStore } from "vuex";
    import Card from "./Card.vue";
    
    export default {
      components: { Card },
      props: {
        category: Object
      },
      setup(props) {
        const store = useStore();
        onMounted(store.dispatch("fetchCardList"));
        const cardList = computed(() =>
          store.state.cardList.filter(card => card.categoryId === props.category.id)
        );
    +    const newCard = {
    +      id: -1,
    +      text: "",
    +      categoryId: props.category.id
    +    };
        return {
          cardList,
    +      newCard
        };
      }
    };
    </script>
    
    <style scoped>
    .list-style {
      display: inline-flex;
      flex-direction: column;
      text-align: center;
      background-color: silver;
      min-width: 200px;
      min-height: 400px;
    }
    </style>
    
    Listコンポーネントでは、新しく追加するカードの雛形オブジェクトであるnewCardを定義し、Cardコンポーネントに渡しています。また、Cardコンポーネントには対象のカードが新しく追加するカードなのか否かを識別できるようにboolean型の変数isNewも渡しています。
  4. Card.vueを以下のように書き換えます。
    <template>
      <div class="card-style">
    +    <input type="text" v-if="isNew" v-model="text" />
    +    <button @click="addCard" v-if="isNew">追加</button>
    +    <span v-else>
          {{ card.text }}
    +    </span>
      </div>
    </template>
    
    <script>
    +import { ref } from "@vue/reactivity";
    +import { useStore } from "vuex";
    export default {
      props: {
        card: Object,
    +    isNew: Boolean
    +  },
    +  setup(props) {
    +    const store = useStore();
    +    let text = ref("");
    +    const addCard = async () => {
    +      const data = {
    +        text: text.value,
    +        categoryId: props.card.categoryId
    +      };
    +      await store.dispatch("addCard", data);
    +      await store.dispatch("fetchCardList");
    +      text.value = "";
    +    };
    +   return {
    +      text,
    +      addCard
    +    };
      }
    };
    </script>
    
    <style scoped>
    .card-style {
      display: flex;
      flex-direction: column;
      background-color: yellowgreen;
      margin: 10px;
      height: 10vh;
      align-items: center;
      justify-content: center;
      border-radius: 10px;
    }
    </style>
    
    Cardコンポーネントのtemplateタグ内では、isNewがTrueの場合に【追加】ボタンが表示されるようにしています。 scriptタグ内では、【追加】ボタンが押された場合にaddCardメソッドを実行する処理を記述しています。addCardメソッドではカードの情報をdataオブジェクトに格納し、Vuexのactionsをdisptach関数によって呼び出しています。 なお、dataオブジェクトのidプロパティについては、json-serverでは自動で割り振られるようになっているため追加していません。
  5. ローカルのテストサーバを再起動し、下記のように新しいカードの追加が出来ることを確認します。*4
    カード追加前
    カード追加後

これで、カードの追加機能が完成しました。

おわりに

今回はVue.jsを使ったTodoListチュートリアルを紹介しました。本記事ではTodoListの表示とアイテムの追加という基本機能だけでしたが、その他にも

  • カードの更新、削除機能
  • カテゴリ追加機能
  • カードがAPIを通じて読み込まれるまでの間のロード画面
  • カード移動機能(ドラッグ&ドロップ可能だとより良いと思います)
  • タイトルの色変更機能
  • デザイン変更(Vuetify等のマテリアルデザインフレームワークを利用するだけでもリッチになります)

等々様々な拡張が可能だと思うので、是非拡張してみてください。


◆TECH PLAY
techplay.jp

◆connpass
rakus.connpass.com

*1:ディレクトリが作成出来ればmkdirコマンドでなくても問題ありません。

*2:ファイルが作成出来ればtouchコマンドでなくても問題ありません。

*3:API通信のような汎用的に使われるメソッドはutilsディレクトリ下に配置することが多いです。

*4:Board.vueには変更はありません。

Copyright © RAKUS Co., Ltd. All rights reserved.