Chapter 09

フロントエンド(Vue3) - 応用編

is_ryo
is_ryo
2020.11.07に更新

ここからは応用編です

ここまでのハンズオンでTODOをDBに保存して、取得して、更新して、削除するという簡単なアプリを、フロントエンド(Vue3)/サーバサイド(Express)/DB(PostgreSQL)を一から構築して開発することができました。
この本の目的である「簡単なTODOアプリを開発して小さな成功体験を得る」はある程度満たしたのではないでしょうか?
もうお腹いっぱいと言う方はここで終了で問題ないと思います。
ですが筆者である私がまだ物足りないので、ここからは「UI/UXを向上させていく」を目標に先ほど作ったアプリのフロントエンドを中心に応用編をしていこうと思います。
あくまで筆者思考でのUI/UXですので、「それは違うだろ…」や「もっとこうした方がいいのでは?」といった点が多々あると思われます。予めご了承ください。

tailwindcssの導入

まずCSSフレームワークとしてtailwindcssを使うので、インストールしていきます。

$ npm i tailwindcss

設定ファイルなどを追加する

$ touch postcss.config.js
$ cd assets
$ touch tailwind.scss
$ cd ..
$ npx tailwindcss init

追加したファイルに下記の内容をコピペしていきます。

postcss.config.js
module.exports = {
  plugins: [require('tailwindcss'), require('autoprefixer')],
};
tailwind.scss
@tailwind base;
@tailwind components;
@tailwind utilities;
tailwind.config.js
/* eslint-disable @typescript-eslint/no-var-requires */
const defaultTheme = require('tailwindcss/defaultTheme');

module.exports = {
  purge: [],
  theme: {
    fontFamily: {
      sans: [
        "'Righteous'",
        "'Hiragino Maru Gothic Pro'",
        "'ヒラギノ角ゴ Pro W3'",
        "'メイリオ'",
        "'Meiryo'",
        "'MS Pゴシック'",
      ],
      serif: [...defaultTheme.fontFamily.serif],
      mono: [...defaultTheme.fontFamily.mono],
    },
    flexGrow: {
      default: 1,
      0: 0,
      1: 1,
      2: 2,
      3: 3,
    },

    extend: {
      colors: {
        'font-color': '#2c3e50',
      },
    },
  },
  variants: {},
  plugins: [],
  future: {
    removeDeprecatedGapUtilities: true,
  },
};

src/main.tsに下記を追記します。

main.ts
import { createApp } from 'vue';
import App from './App.vue';
import './registerServiceWorker';
import router from './router';
import store from './store';
import './assets/tailwind.scss'; // add

createApp(App).use(store).use(router).mount('#app');

ローカルでアプリを立ち上げます。

$ npm run serve

フォントなどが反映されていると思います。
最終的にこんな感じに仕上げていきます。

背景やフォントの色変える

ここはお好みなんですが、背景やフォントの色を変えていきたいと思います。
個人的にダークモードチックにするのが好きなので、背景を黒っぽくしてフォントを白くしていきます。

tailwind.config.jsを少し書き換えます。

tailwind.config.js
...
    extend: {
      colors: {
        'font-color': '#ececec',
      },
    },
...

App.vueのstyleタグ内に下記の内容に書き換えます。

App.vue
<style lang="scss">
body {
  @apply bg-gray-800;
}

#app {
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  @apply text-font-color;
}

#nav {
  padding: 30px;

  a {
    font-weight: bold;

    &.router-link-exact-active {
      color: #42b983;
    }
  }
}
</style>

ここの @apply という書き方はtailwindの文法で、その後ろに指定したclassにあたるstyleを適応してくれます。
また bg-gray-800 と言うのもtailwindの文法で、bgがBackGround(背景)、grayは色、800は色の濃さを表しています。
詳しくはここらへんを参考にしてください。
この感じの色合いになります。

作成UIを作成する

こういった作成UIを作成していきます。

Todo.vueのtemplateタグ内を下記の内容に書き換えます。

Todo.vue
<template>
  <div class="todo">
    <h1>TODO APP</h1>

    <div class="w-2/3 m-auto">
      <div class="grid grid-flow-row grid-cols-3">
        <div class="m-4 px-3 py-2 bg-gray-700 rounded-md shadow">
          <div class="flex flex-wrap content-center h-12">
            <div class="text-left flex-grow-1">
              <input
                v-model="state.title"
                type="text"
                class="w-full px-2 py-1 bg-gray-700 border border-gray-400 rounded-md focus:outline-none"
                placeholder="Title"
              />
            </div>
          </div>
          <div class="flex flex-wrap content-center justify-end h-12">
            <div
              class="w-1/4 py-1 text-blue-500 rounded-md cursor-pointer hover:text-blue-700 bg-gray-200 font-bold border-2 border-blue-500"
              @click="createTodo"
            >
              Create
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

TODOタスクを違うコンポーネントに分ける

取得したTODOタスクの見た目を作成UIに合わせるのと同時にコンポーネントを分けて実装します。
TODOタスク用のTodoItem.vueを作成し、下記内容をコピペします。

$ cd components
$ touch TodoItem.vue
TodoItem.vue
<template>
  <div class="m-4 px-3 py-2 bg-gray-700 rounded-md shadow">
    <div class="flex flex-wrap content-center h-12">
      <div class="flex-grow-3 text-left">
        <div class="text-left flex-grow-1">
          <input
            v-model="state.task.title"
            type="text"
            class="w-full px-2 py-1 bg-gray-700 border border-gray-400 rounded-md focus:outline-none"
            placeholder="Title"
          />
        </div>
      </div>
    </div>
    <div class="flex flex-wrap content-center text-center h-12">
      <select
        class="rounded-md flex-grow-3 font-bold justify-center bg-gray-100 text-gray-700 focus:outline-none"
        v-model="state.task.status"
      >
        <option value="todo">todo</option>
        <option value="wip">wip</option>
        <option value="done">done</option>
      </select>
      <div
        class="py-1 mx-2 text-green-500 rounded-md cursor-pointer flex-grow-1 hover:text-green-700 bg-gray-200 font-bold border-2 border-green-500"
        @click="$emit('update', state.task)"
      >
        Update
      </div>
      <div
        class="py-1 text-red-500 rounded-md cursor-pointer flex-grow-1 hover:text-red-700 bg-gray-200 font-bold border-2 border-red-500"
        @click="$emit('delete', state.task)"
      >
        Delete
      </div>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent, PropType } from 'vue';

type Todo = {
  uuid: string;
  title: string;
  status: 'todo' | 'wip' | 'done';
};

export default defineComponent({
  props: {
    todo: {
      type: Object as PropType<Todo>,
      default: null,
    },
  },
  setup(props) {
    const state = {
      task: JSON.parse(JSON.stringify(props.todo)),
    };

    return {
      state,
    };
  },
});
</script>

作成したTodoItem.vueをTodo.vueで読み込みます。

Todo.vue
<template>
  <div class="todo">
    <h1>TODO APP</h1>

    <div class="w-2/3 m-auto">
      <div class="grid grid-flow-row grid-cols-3">
        <div class="m-4 px-3 py-2 bg-gray-700 rounded-md shadow">
          <div class="flex flex-wrap content-center h-12">
            <div class="text-left flex-grow-1">
              <input
                v-model="state.title"
                type="text"
                class="w-full px-2 py-1 bg-gray-700 border border-gray-400 rounded-md focus:outline-none"
                placeholder="Title"
              />
            </div>
          </div>
          <div class="flex flex-wrap content-center justify-end h-12">
            <div
              class="w-1/4 py-1 text-blue-500 rounded-md cursor-pointer hover:text-blue-700 bg-gray-200 font-bold border-2 border-blue-500"
              @click="createTodo"
            >
              Create
            </div>
          </div>
        </div>
	// add start
        <todo-item
          v-for="item in state.todos"
          :key="item.uuid"
          :todo="item"
          @update="updateTodo"
          @delete="deleteTodo"
        ></todo-item>
	// end
      </div>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent, reactive } from 'vue';
import axios from 'axios';
import TodoItem from '../components/TodoItem.vue'; // add

const baseURL = 'http://localhost:3000/';

type Todo = {
  uuid: string;
  title: string;
  status: 'todo' | 'wip' | 'done';
};

export default defineComponent({
  name: 'Todo',
  components: { TodoItem }, // add
  setup() {
    const state = reactive({
      title: '',
      todos: [],
    });

    const getTodos = () => {
      axios.get(baseURL).then((res) => {
        if (res && res.data) {
          console.log(res);
          state.todos = res.data.resBody;
        }
      });
    };

    getTodos();

    const createTodo = async () => {
      await axios.put(baseURL, { title: state.title });
      getTodos();
    };

    const updateTodo = async (todo: Todo) => {
      await axios.post(baseURL + todo.uuid, {
        title: todo.title,
        status: todo.status,
      });
      getTodos();
    };

    const deleteTodo = async (todo: Todo) => {
      await axios.delete(baseURL + todo.uuid);
      getTodos();
    };

    return {
      state,
      createTodo,
      updateTodo,
      deleteTodo,
    };
  },
});
</script>

このような見た目になります。

いろいろ解説する

ここまでざーっと手順を書いてきましたが主にVueについての要所をまとめて解説していきます。

コンポーネントを分割する

一つのVueファイルにまとめてコンポーネントを書いていってもいいのですが、ある程度の大きさで分割した方がひとつひとつのVueファイルが小さくなって見やすくなると思います。これには好き嫌いがあると思います。また分割する粒度も好きな粒度で分割してください。ほんとに細かく分けるなら、アトミックデザインなどを参考にするといいかもしれません。

親コンポーネントから子コンポーネントに情報を渡す。(Props)

今回の例ではTodo.vueからTodoItem.vueに対して、TODOタスクの情報を渡しています。
Todo.vue内27-33行目あたりで、子コンポーネントにあたるTodoItem.vueに取得したTODOタスクを1件づつ渡しています。

Todo.vue
...
        <todo-item
          v-for="item in state.todos"
          :key="item.uuid"
          :todo="item" // <- here
          @update="updateTodo"
          @delete="deleteTodo"
        ></todo-item>
...

その情報をTodoItem.vueでPropsを使って受け取ります。

TodoItem.vue
...
  props: {
    todo: {
      type: Object as PropType<Todo>,
      default: null,
    },
  },
...

これで親から子に情報を渡すことができます。

子コンポーネントから親コンポーネントにイベントを渡す(Emit)

子コンポーネントで発生したイベントを親コンポーネントに渡したいときにEmitを使います。
今回ではTODOタスクの更新と削除のイベントを子から親に渡しています。
TodoItem.vueの24-35行目あたりが該当の箇所になってます。

TodoItem.vue
...
      <div
        class="py-1 mx-2 text-green-500 rounded-md cursor-pointer flex-grow-1 hover:text-green-700 bg-gray-200 font-bold border-2 border-green-500"
        @click="$emit('update', state.task)" // <- here
      >
        Update
      </div>
      <div
        class="py-1 text-red-500 rounded-md cursor-pointer flex-grow-1 hover:text-red-700 bg-gray-200 font-bold border-2 border-red-500"
        @click="$emit('delete', state.task)" // <- here
      >
        Delete
      </div>
...

Todo.vueでは$emit()の第1引数と同じ名前のv-bind(@hoge)で待ち受けます。

Todo.vue
...
        <todo-item
          v-for="item in state.todos"
          :key="item.uuid"
          :todo="item"
          @update="updateTodo" // <- here
          @delete="deleteTodo" // <- here
        ></todo-item>
...

これで子コンポーネントで発生したイベントを親コンポーネントに渡して、該当する処理を実行することができます。