🔖

Vite(Vue3.2)で始めるTodoアプリ開発

14 min read

はじめに

今回は、Vueのビルド高速版、Viteでナビゲーション付きToDoアプリの開発を行っていきます。UIは一般的なBootstrap5.0を採用しています。
前回、Vue2+Vuetifyで公開しましたが、Vue3対応は先が長そうですし、今後はVue3.2(2021/8リリース)のscript setupを利用しようかと思い、こちらの記事を作成しました。

プロジェクト作成

Vueとは異なり、事前インストールがなく、セットアップもシンプルです。

> npm init vite@latest
√ Project name: ... todo
√ Select a framework: » vue
√ Select a variant: » vue-ts
> cd todo
> npm install
> npm run dev
→ 起動確認。http://localhost:3000/

VSCodeは、以下の拡張機能がインストールされている前提です。

  • Japanese Language Pack for Visual Studio ... 日本語化
  • ESLint ... 構文チェック、コード整形
  • Prettier ... コード整形
  • Volar ... Vue3対応。強調表示、型チェックなど(Veturは無効にすること)

環境設定

ESLint, Prettierをインストールし、設定ファイルを追加します。
ESLintとPretterの設定は、いろいろな記事があって混乱するのですが、結局これだけでも十分だと思います。他、必要なものがあれば教えてください!

> npm i -D eslint @vue/eslint-config-typescript
→ @vue/eslint-config-typescript ... typescript対応
> npm i -D prettier eslint-config-prettier
→ eslint-config-prettier ... eslintとprettierの競合を防ぐ
  • フォーマッタをPrettierにする。jsonやtsは、vscode標準が適用されるため、上書き
  • 保存、ペースト、改行時に整形する。ただし、Vueファイルは保存時のみ。
.vscode/settings.json
{
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "[json]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[typescript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "editor.formatOnSave": true,
  "editor.formatOnPaste": true,
  "editor.formatOnType": true
}
  • printWidth ... 単純に行数が減るため。
  • endOfLine ... 新規vue時の「Delete ␍」対策、要VSCode再起動
  • htmlWhitespaceSensitivity ... タグ改行時の空白を許可する。
.prettierrc
{
  "printWidth": 150,
  "endOfLine": "auto",
  "htmlWhitespaceSensitivity": "ignore"
}
  • 命名規則の追加、ソースの記述に統一感が出る。あとはお好みで。
.eslintrc.js
module.exports = {
  root: true,
  env: {
    // ブラウザ、node.jsを利用
    browser: true,
    node: true,
    // es2021を利用
    es2021: true,
  },
  // prettierを入れないと、eslintで競合してwarningになる。
  extends: ["plugin:vue/vue3-recommended", "@vue/typescript/recommended", "prettier"],
  parserOptions: {
    ecmaVersion: 2021,
  },
  rules: {
    // console.logを許可
    "no-console": "off",
    // ネーミングルールを追加
    "@typescript-eslint/naming-convention": [
      "error",
      {
        selector: "default",
        format: ["camelCase"],
      },
      {
        selector: ["property"],
        format: ["camelCase", "PascalCase"],
      },
      {
        selector: ["class", "enum", "interface", "typeAlias", "typeParameter"],
        format: ["PascalCase"],
      },
      {
        selector: "variable",
        modifiers: ["const"],
        format: ["camelCase", "UPPER_CASE"],
      },
    ],
  },
};
  • eslintとpretterを一括実行できるようにする。
package.json
  "scripts": {
    ...
    "lint": "eslint --ext .ts,vue --ignore-path .gitignore .",
    "lint-fix": "eslint --ext .ts,vue --ignore-path .gitignore . && prettier --write src/**/*.{ts,vue}"
  },

Bootstrap 5 + SB Admin + Font Awesome

次にBootstrap 5で開発するための準備を行います。とはいっても、画面を1からデザインするのは大変なので、Start Bootstrapの中からダウンロード数が多いSB AdminをTemplateにして、作成していきます。

その際、Font Awesomeというアイコンフォントが使われているので、一緒にインストールします。Vue.jsで使うときは、vue-fontawesomeを使います。

> npm i startbootstrap-sb-admin 
→ bootstrap5も含まれる。
> npm i @popperjs/core
→ linux上でビルドする際に使用。
> npm i --save @fortawesome/fontawesome-svg-core
> npm i --save @fortawesome/free-solid-svg-icons
> npm i --save @fortawesome/vue-fontawesome@prerelease
> npm i -D sass
→ sass/scssを利用ときは追加する。
`` ` ``

次に、インストールしたファイルを使えるようにmain.tsを編集していきます。
```typescript:main.ts
import { createApp } from "vue";
import App from "./App.vue";

// Font Awesome
import { library } from "@fortawesome/fontawesome-svg-core";
import { fas } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { dom } from "@fortawesome/fontawesome-svg-core";

// Bootstrap 5 + SB Admin
import "startbootstrap-sb-admin/src/scss/styles.scss";

// アイコンをライブラリに追加して、DOM経由(class)で参照できるようにする。
library.add(fas);
dom.watch();

// componentを追加
createApp(App).component("font-awesome-icon", FontAwesomeIcon).mount("#app");

ブラウザのコンソールに出てくる「DevTools がソースマップの読み込みに失敗しました(http://localhost:3000/bootstrap.min.css.map のコンテンツを読み込めませんでした」が気になる方は、Chromeの場合は、デベロッパーツールの設定にある「CSSソースマップを有効にする。」をOffにしてください。他のブラウザにもあります。無視しても大丈夫です。

これで、SB Adminが利用できる準備ができました。試しに、dist/layout-static.htmlのbodyタグを、src/App.vueのtemplateタグ内にコピー(scriptタグは不要)し、他をすべて削除した状態で、画面を確認してみてください。ナビゲーションバーやスライドバーが表示されたと思います。

ToDo画面作成

今回、ナビゲーション付きのToDo画面を、以下の手順で作成していきます。

  1. vue-routerでルーティング
  2. ナビゲーションメニュー
  3. Todo画面作成

vue-routerでルーティング

以下のパッケージをインストールし、vue-routerを動かしていきます。

> npm i vue-router@4
  1. 一旦、HomeやToDo画面を空で作成し、App.vueをrouter-viewのみにします。今回は、404ページも、SB Adminを参考に作成してみます。
views/AppHome.vue
<template>
  <h1 class="mt-4">Home</h1>
</template>
views/TodoList.vue
<template>
  <h1 class="mt-4">TodoList</h1>
</template>
components/NotFound.vue
<template>
  <div class="container">
    <div class="row justify-content-center">
      <div class="col-lg-6">
        <div class="text-center mt-4">
          <h1 class="display-1">404</h1>
          <p class="lead">Page Not Found</p>
          <p>ページが見つかりません。</p>
          <a href="/">
            <i class="fas fa-arrow-left me-1"></i>
            Back
          </a>
        </div>
      </div>
    </div>
  </div>
</template>
App.vue
<template>
  <router-view />
</template>
  1. routerを作成します。
router/index.ts
import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
import AppHome from "../views/AppHome.vue";
import TodoList from "../views/TodoList.vue";
import NotFound from "../components/NotFound.vue";

const routers: Array<RouteRecordRaw> = [
  {
    path: "/",
    component: AppHome,
  },
  {
    path: "/todo",
    component: TodoList,
  },
  {
    path: "/:pathMatch(.*)*",
    component: NotFound,
  },
];

const router = createRouter({
  history: createWebHistory(),
  routes: routers,
});

export default router;

vue-routerには、Hashモード(createHashWebHistory(), http://localhost:3000/#/todo)とHistoryモード(createWebHistory()、http://localhost:3000/todo)の2種類が存在する。
一般的にはHistoryモードを利用するが、Webサーバへリリースする際、/todoを/に読み替える設定をしないと、404エラーとなる。利用できない場合は、Hashモードに切り替えるとよい。

  1. main.tsにrouterを追加する。
main.ts
...
import router from "./router";
...
createApp(App).use(router).component("font-awesome-icon", FontAwesomeIcon).mount("#app");
  1. routerで設定したURLが表示されるか確認する。存在しないページは404が表示される。

ナビゲーションメニュー

SB Adminのdist/layout-static.htmlを参考に、ナビゲーションメニューを部品化していきます。

  1. ナビゲーションメニューを部品として作成する。
components/AppNavi.vue
<script lang="ts">
// メニュー用のinterfaceを追加
export interface MenuItem {
  type: "heading" | "menu";
  title: string;
  icon?: string;
  url?: string;
}
</script>

<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";

// タイトルとメニューアイテムを設定できるようにする。
defineProps<{ title: string; menuItems: MenuItem[] }>();

const router = useRouter();

const isToggle = ref(false);

const goToUrl = (url?: string) => {
  if (url != undefined) {
    router.push(url);
  }
};
</script>

<template>
  <!-- js/script.jsを参考にbodyにtoggleを追加 -->
  <body class="sb-nav-fixed" :class="isToggle ? 'sb-sidenav-toggled' : ''">
    <nav class="sb-topnav navbar navbar-expand navbar-dark bg-dark">
      <!-- titleを可変にし、hrefをrouterで切り替えるように修正 -->
      <a class="navbar-brand ps-3" @click="goToUrl('/')">{{ title }}</a>
      <!-- isToggleでスライドバーの表示/非表示を切り替える -->
      <button id="sidebarToggle" class="btn btn-link btn-sm order-1 order-lg-0 me-4 me-lg-0" @click="isToggle = !isToggle">
        <i class="fas fa-bars"></i>
      </button>
      <!-- 不要な項目は削除 -->
    </nav>
    <div id="layoutSidenav">
      <div id="layoutSidenav_nav">
        <nav id="sidenavAccordion" class="sb-sidenav accordion sb-sidenav-dark">
          <div class="sb-sidenav-menu">
            <!-- menuItemsからメニューが生成されるように修正, サブメニューも改良すれば対応可能 -->
            <div v-for="(item, index) in menuItems" :key="index" class="nav">
              <div v-if="item.type == 'heading'" class="sb-sidenav-menu-heading">{{ item.title }}</div>
              <a v-if="item.type == 'menu'" class="nav-link" @click="goToUrl(item.url)">
                <div class="sb-nav-link-icon"><i class="fas fa-tachometer-alt" :class="item.icon"></i></div>
                {{ item.title }}
              </a>
            </div>
          </div>
        </nav>
      </div>
      <div id="layoutSidenav_content">
        <main>
          <div class="container-fluid px-4">
            <!-- コンテンツをrouter-viewに変更 -->
            <router-view />
          </div>
        </main>
      </div>
    </div>
  </body>
</template>
  1. App.vueからAppNavi.vueを呼び出す。アイコンはFont AwesomeのIconsから検索。
App.vue
<script setup lang="ts">
import AppNavi, { MenuItem } from "./components/AppNavi.vue";

// メニューを設定する。
const menuItems: MenuItem[] = [
  {
    type: "heading",
    title: "Main",
  },
  {
    type: "menu",
    title: "ToDo",
    icon: "fa-list",
    url: "/todo",
  },
];
</script>

<template>
  <AppNavi title="Todo App" :menu-items="menuItems"></AppNavi>
</template>
  1. ナビゲーションバーが表示され、メニューで画面が切り替わることを確認する。

Todo画面作成

ここからは、BootstrapのLayout, Content, Formsを見ながら、Todo画面の作成を行います。
ファイル構成としては、各部品はコンポーネント化し、ロジックはTodoList.vueにまとめています。

models/Task.vue
// interfaceは、別ファイルに分けないと重複エラーになる。
export interface Task {
  id: number;
  title: string;
  done: boolean;
}
components/TaskList.vue
<script setup lang="ts">
import { Task } from "../models/Task";

defineProps<{ tasks: Task[] }>();

const emit = defineEmits<{
  (eventName: "done", id: number): void;
  (eventName: "delete", id: number): void;
}>();
</script>

<template>
  <table class="table table-striped align-middle mt-4">
    <thead>
      <tr>
        <th scope="col" width="50">#</th>
        <th scope="col" width="600">Task</th>
        <th scope="col">Action</th>
      </tr>
    </thead>
    <tbody>
      <template v-for="(task, index) in tasks" :key="index">
        <tr :class="{ 'table-primary': task.done }" @click="emit('done', task.id)">
          <td>
            <input class="form-check-input" type="checkbox" :checked="task.done" />
          </td>
          <td>
            <label v-if="task.done" class="form-check-label">
              <del>{{ task.title }}</del>
            </label>
            <label v-else class="form-check-label">
              {{ task.title }}
            </label>
          </td>
          <td>
            <button type="button" class="btn btn-danger" @click="emit('delete', task.id)"><i class="fas fa-trash-alt"></i></button>
          </td>
        </tr>
      </template>
    </tbody>
  </table>
</template>
components/TaskList.vue
<script setup lang="ts">
import { ref } from "vue";

const emit = defineEmits<{
  (eventName: "add", newTaskTitle: string): void;
}>();

const newTaskTitle = ref("");

const addTask = () => {
  // テキストが1文字以上のとき、イベントを起こす。
  if (newTaskTitle.value.length > 0) {
    emit("add", newTaskTitle.value);
    newTaskTitle.value = "";
  }
};
</script>

<template>
  <form class="form-floating">
    <input v-model="newTaskTitle" type="text" class="form-control mt-4" placeholder="Add Task" @keydown.enter.prevent="addTask()" />
    <label>Add Task</label>
  </form>
</template>
views/TodoList.vue
<script setup lang="ts">
import { reactive } from "vue";
import { Task } from "../models/Task";
import TaskAdd from "../components/TaskAdd.vue";
import TaskList from "../components/TaskList.vue";

const tasks: Task[] = reactive([
  {
    id: 1,
    title: "起きる",
    done: false,
  },
  {
    id: 2,
    title: "着替える",
    done: false,
  },
]);

// taskを検索し、フラグを更新する。
const addTask = (newTaskTitle: string) => {
  let newTask: Task = {
    id: Date.now(),
    title: newTaskTitle,
    done: false,
  };
  tasks.push(newTask);
};

// taskを検索し、フラグを更新する。
const doneTask = (id: number) => {
  let task = tasks.find((t) => t.id === id);
  if (task !== undefined) {
    task.done = !task.done;
  }
};

// taskを削除する。
const deleteTask = (id: number) => {
  tasks.forEach((task, index) => {
    if (task.id == id) tasks.splice(index, 1);
  });
};
</script>

<template>
  <h1 class="mt-4">Todo List</h1>
  <div class="row">
    <div class="col-xl-6 col-md-6">
      <TaskAdd @add="(newTaskTitle) => addTask(newTaskTitle)"></TaskAdd>
      <TaskList :tasks="tasks" @delete="(id) => deleteTask(id)" @done="(id) => doneTask(id)"></TaskList>
    </div>
  </div>
</template>

終わりに

Bootstrap 5.0からJQueryが取り除かれ、BootstrapVueを利用しなくても、Bootstrapのサイトを参考にしながら、Vue.jsで利用できるようになりました。
また、viteでビルドが高速になり、Vue3.2から導入されたscript setup構文で、CompostionAPIがシンプルな実装になりました。いくつか出てきた新しい記述(defineEmits、defineProps、useRouterなど)は、たぶんソースで理解できると思います。

今後は、本プログラムをベースに、まずはAzureのサーバレス化を公開している予定です。

Discussion

ログインするとコメントできます