💬

Vue2+Vuetify+Typescriptで始めるはじめてのToDoアプリ開発(Vue3を配慮)

2021/10/27に公開

はじめに

2022/11にVuetify 3.0を公開されたのですが、あまり使えない感じです。ここではTypescript化について紹介していますが、Vue3ではVuetify → Quaserへの切り替えをおすすめします。こちらにQuaser版も掲載しました。

UIフレームワーク(Vue3)の主な対応状況

  • Vuetify ... 2022/11 3.0リリース(おすすめしません)
  • Quasar ... 2021/6/21 2.0から対応(おすすめ)
  • Bootstrap5(日本語) ... 2021/5/5リリース。jQueryが不要で、BootstrapVueがなくても使えるようになった。参考URL
  • element-plus ... Alibabaグループが開発しているUI Framework。機能は充実している。

そこで、2022年以降、Vue3での利用可能なCompositionAPI+TypescriptでのToDoアプリ開発を、Vue2ベースで作ってみました。Vue3版で作ろうとしたのですが、現時点のVuetifyでやれることが少なかったので、Vue2ベースにしました。

対象読者

Vue2でフロントエンド開発する人向け
(少しづつ開発を進めながら、Vuetifyの使い方や便利ツールも紹介します)

利用ツール

開発環境構築

作業フォルダで、以下のコマンドを実行してください。

Vueインストール

# vueを新規インストール
> npm i vue
# バージョン確認(最新であれば大丈夫)
> vue --version
@vue/cli 4.5.14
# vueが古くてバージョンアップする場合
> npm i -g @vue/cli

プロジェクト作成

VetifyとCompositionAPIは、プロジェクト作成後に追加します。

> vue create todo
? Please pick a preset: Manually select features
→ Manuallyを選択
? Check the features needed for your project: Choose Vue version, Babel, TS, Router, Linter
→ TypeScript, Routerを追加
? Choose a version of Vue.js that you want to start the project with 2.x
→ そのまま。
? Use class-style component syntax? No
→ Noに変更。3.xからclass-styleが非推奨になる?3.xを選択すると、Noがデフォルトですw
? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)? Yes
→ そのまま。Babelは、ES6に対応していないブラウザ用にES5に変換してれる。
? Use history mode for router? (Requires proper server setup for index fallback in production) Yes
→ そのまま。
? Pick a linter / formatter config: Prettier
→ Prettierを選択。ESLintだけでも整形できるが、Prettierを入れたほうが強力。
? Pick additional lint features: Lint on save
→ そのまま。保存時にLint(ソースコードチェック)を行う。
? Where do you prefer placing config for Babel, ESLint, etc.? In dedicated config files
→ そのまま。
? Save this as a preset for future projects? No
→ そのまま。上記の設定を保存するかどうか。

> cd todo
> vue add vuetify
→ Defaultのまま。

> npm install -D @vue/composition-api

設定ファイル追加

VSCodeを起動し、作成したフォルダを開き、以下のファイルを追加する。

  • 保存時、ペースト時、改行時に自動整形する設定。プロジェクト個別に設定できる。
.vscode/settings.json
{
  // フォーマッタをPrettierにする。jsonやtsは、vscode標準が適用されるため、上書き
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "[json]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[typescript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  // 保存、ペースト、改行時に整形する。ただし、Vueファイルは保存時のみ。
  "editor.formatOnSave": true,
  "editor.formatOnPaste": true,
  "editor.formatOnType": true
}
  • ソース整形の設定、あとはお好みで。
    • printWidth ... 単純に行数が減るため。
    • endOfLine ... 新規vue時の「Delete ␍」対策、要VSCode再起動
.prettierrc
{
  "printWidth": 120,
  "endOfLine": "auto"
}
  • 命名規則の追加、ソースの記述に統一感が出る。あとはお好みで。
.eslintrc.js
module.exports = {
  extends: ['plugin:vue/vue3-essential', 'prettier'],
  rules: {
    "@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"],
      },
    ],
  },

動作確認として、Vueやtsファイルで適当に改行を入れて、Ctrl-Sで保存すると、自動整形されます。また、一括で反映する場合は、以下のコマンドを実行してください。

> npm run lint --fix

サービス起動、ビルド

VSCode上で新しいターミナルを起動し、以下のコマンドを実行する。URLをクリックすると、「Welcome to Vuetify」が表示されればOK。

> npm run serve
  App running at:
  - Local:   http://localhost:8080/ 
  - Network: http://192.168.1.67:8080/
→ ブラウザで、変更内容がリアルタイムで反映される。Ctrl-Cで停止。

> npm run build
→ リリース時に使用。distフォルダに、html+javascriptが生成される。

メニュー追加

VuetifyのワイヤフレームでBaseを選択し、右下にあるGitHubアイコンからソースをコピーして、<template/>と<script/>のdata部分を、App.vueに貼り付ける。

App.vue
<template>
  <v-app id="inspire">
    <!-- statelessを追加すると、勝手にメニューが消えない -->
    <v-navigation-drawer v-model="drawer" app stateless>
      <!--  -->
    </v-navigation-drawer>

    <v-app-bar app>
      <v-app-bar-nav-icon @click="drawer = !drawer"></v-app-bar-nav-icon>

      <v-toolbar-title>Application</v-toolbar-title>
    </v-app-bar>

    <v-main>
      <!-- 追加 -->
      <router-view />
    </v-main>
  </v-app>
</template>

<script lang="ts">
import Vue from "vue";

export default Vue.extend({
  name: "App",

 <!-- この部分のみ書き換え -->
  data: () => ({ drawer: null }),
});
</script>

次に、VuetifyのUIコンポーネントからNavigation drawersで一番上のサンプルソースをコピーし、v-navigation-drawerの中を以下のように書き換える。

App.vue
    <v-navigation-drawer v-model="drawer" app>
      <v-list-item>
        <v-list-item-content>
	  <!-- v-list-item-subtitleを削除、タイトルをMenuに変更 -->
          <v-list-item-title class="text-h6"> Menu </v-list-item-title>
        </v-list-item-content>
      </v-list-item>

      <v-divider></v-divider>

      <v-list dense nav>
        <!-- :toを追加 -->
        <v-list-item v-for="item in items" :key="item.title" :to="item.to" link>
          <v-list-item-icon>
            <v-icon>{{ item.icon }}</v-icon>
          </v-list-item-icon>

          <v-list-item-content>
            <v-list-item-title>{{ item.title }}</v-list-item-title>
          </v-list-item-content>
        </v-list-item>
      </v-list>
    </v-navigation-drawer>

Scriptに関しては、menu内容をhomeとaboutに変更し、URLを追加する。todoアイコンは、アイコンのMaterial Design Iconsからtodoで検索したアイコンを張り付けている。

App.vue
<script lang="ts">
import Vue from "vue";

export default Vue.extend({
  name: "App",

  data: () => ({
    drawer: null,
    <!-- 以下を追加 -->
    items: [
      { title: "Todo", icon: "mdi-format-list-checks", to: "/" },
      { title: "About", icon: "mdi-help-box", to: "/about" },
    ],
  }),
});
</script>

ここまでで、Menuから正常にリンクが動作するか確認する。aboutの内容は適当に書き換えてみてください。

Todoリスト作成

VuetifyのUIコンポーネントからListsで一番下にあるチェックボックス付きリストやアイコン付きリストのソースを参考にし、Home.vueを以下のように書き換える。
また、背景色はColors、取り消し線はText and typographyを参考する。

追加用のテキストボックスは、Inputsを元にカスタマイズしている。パディング設定は、Spacingを参照のこと。

views/Home.vue
<template>
  <div>
    <!-- task追加用のテキストボックス -->
    <v-text-field
      v-model="newTaskTitle"
      class="mt-3 ml-3"
      label="Add Task"
      @keyup.enter="addTask"
      clearable
      hide-details
      counter
      maxlength="20"
      prepend-icon="mdi-plus"
    ></v-text-field>
    <v-list>
      <!-- divでtasksをループで出力する。templateは使えない。 -->
      <div v-for="task in tasks" :key="task.id">
        <!-- クリックしたら、背景色を変更し、tasksを更新するイベントを追加 -->
        <v-list-item @click="doneTask(task.id)" :class="{ 'blue lighten-5': task.done }">
          <template>
            <!-- タイトル、チェックボックスをtasksと連動させる -->
            <v-list-item-action>
              <v-checkbox :input-value="task.done"></v-checkbox>
            </v-list-item-action>

            <v-list-item-content>
              <!-- タスクが完了したら取り消し線にする。 -->
              <v-list-item-title :class="{ 'text-decoration-line-through': task.done }">{{ task.title }}</v-list-item-title>
            </v-list-item-content>

            <!-- deleteアイコンに変更し、tasksを削除するイベント追加 -->
            <v-list-item-icon>
              <v-icon color="primary" @click="deleteTask(task.id)"> mdi-delete </v-icon>
            </v-list-item-icon>
          </template>
        </v-list-item>
        <v-divider></v-divider>
      </div>
    </v-list>
  </div>
</template>

<script lang="ts">
import Vue from "vue";

export default Vue.extend({
  name: "Home",
  // tasksを追加
  data: () => ({
    newTaskTitle: "",
    tasks: [
      {
        id: 1,
        title: "起きる",
        done: false,
      },
      {
        id: 2,
        title: "着替える",
        done: false,
      },
    ],
  }),
  methods: {
    // taskを検索し、フラグを更新する。
    doneTask(id: number) {
      let task = this.tasks.find((t) => t.id === id);
      if (task !== undefined) {
        task.done = !task.done;
      }
    },
    // taskを削除する。
    deleteTask(id: number) {
      this.tasks.forEach((task, index) => {
        if (task.id == id) this.tasks.splice(index, 1);
      });
      // 以下の書き方もできるが、部品化すると更新できなくなるので却下
      // this.tasks = this.tasks.filter((t) => t.id !== id);
    },
    // taskを追加する。
    addTask() {
      if (this.newTaskTitle.length > 0) {
        let newTask = {
          id: Date.now(),
          title: this.newTaskTitle,
          done: false,
        };
        this.tasks.push(newTask);
        this.newTaskTitle = "";
      }
    },
  },
});
</script>

CompositionAPIとは?

ここからはVue3で標準になるCompositionAPIの紹介です。Vue2ではdataやmethodなどは、それぞれを1つのまとまりとして、実装する必要があり、機能が増えてくると可読性が悪く、大規模開発には向かないといわれています。

そこで、まずは今の実装を単純にCompositionAPI化してみます。

main.ts
import VueCompositionApi from "@vue/composition-api"; // 追加

Vue.use(VueCompositionApi); // 追加
views/Home.vue
<script lang="ts">
// Vueを削除して、defineComponent, ref, reactiveを追加。
// Vue3では、@vue/composition-apiがvueになる。
import { ref, reactive, defineComponent } from "@vue/composition-api";

// defineComponentに変更する。
export default defineComponent({
  name: "Home",
  setup() {
    // dataを書き換え
    // constにrefをつけることでvalueでのリアクティブな更新ができる。
    // letでデータ更新してもリアクティブにはならない。
    const newTaskTitle = ref("");
    // reactiveをつけないと、更新されない。
    // プロパティオブジェクトの場合は、toRefs()で変換が必要。
    const tasks = reactive([
      {
        id: 1,
        title: "起きる",
        done: false,
      },
      {
        id: 2,
        title: "着替える",
        done: false,
      },
    ]);
    // method書き換え、thisが不要になる。
    // 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);
      });
    };
    // taskを追加する。
    const addTask = () => {
      if (newTaskTitle.value.length > 0) {
        let newTask = {
          id: Date.now(),
          title: newTaskTitle.value,
          done: false,
        };
        tasks.push(newTask);
        newTaskTitle.value = "";
      }
    };
    // templateで使うdataやmethodはreturnに追加する。
    return { tasks, newTaskTitle, doneTask, deleteTask, addTask };
  },
});
</script>

これにより、dataやmethodが増えても、機能ごとに分類できるような実装になりました。書き方も統一されて見やすくなった感じです。
さらに部品化を進めていきます。TodoList.vue, TodoAdd.vueを作成し、Home.vueの内容をコピーしてから、以下のように編集していきます。

components/TodoList.vue
<template>
  <!-- リストのみにする -->
  <v-list>
    <div v-for="task in tasks" :key="task.id">
      <v-list-item @click="doneTask(task.id)" :class="{ 'blue lighten-5': task.done }">
        <template>
          <v-list-item-action>
            <v-checkbox :input-value="task.done"></v-checkbox>
          </v-list-item-action>

          <v-list-item-content>
            <v-list-item-title :class="{ 'text-decoration-line-through': task.done }">{{ task.title }}</v-list-item-title>
          </v-list-item-content>

          <v-list-item-icon>
            <v-icon color="primary" @click="deleteTask(task.id)"> mdi-delete </v-icon>
          </v-list-item-icon>
        </template>
      </v-list-item>
      <v-divider></v-divider>
    </div>
  </v-list>
</template>

<script lang="ts">
// 不要なimport削除
import { defineComponent } from "@vue/composition-api";

// 仕様を明確にするためにintarfaceを定義。typs/Task.tsを作ってもよい。
export interface Task {
  id: number;
  title: string;
  done: boolean;
}

export default defineComponent({
  // 名前変更
  name: "TaskList",
  // dataを削除し、代わりにデータ参照用にpropsを追加
  props: {
    tasks: {
      // 型定義、Composition-APIはPropTypeが使えない。
      type: Array as () => Task[],
      // 必須項目、必須ではない場合はdefaultを設定しておくとよい。
      required: true,
    },
  },
  // setupにpropsを追加、props経由でtasksを参照する。
  setup(props) {
    // taskを検索し、フラグを更新する。
    const doneTask = (id: number) => {
      let task = props.tasks.find((t) => t.id === id);
      if (task !== undefined) {
        task.done = !task.done;
      }
    };
    // taskを削除する。
    const deleteTask = (id: number) => {
      props.tasks.forEach((task, index) => {
        if (task.id == id) props.tasks.splice(index, 1);
      });
    };
    // 不要な定義を削除
    return { doneTask, deleteTask };
  },
});
</script>
components/TodoAdd.vue
<template>
  <!-- テキストボックスのみ -->
  <v-text-field
    v-model="newTaskTitle"
    class="mt-3 ml-3"
    label="Add Task"
    @keyup.enter="addTask"
    clearable
    hide-details
    counter
    maxlength="20"
    prepend-icon="mdi-plus"
  ></v-text-field>
</template>

<script lang="ts">
// 型定義を追加、不要なimport削除
import { Task } from "@/components/TodoList.vue";
import { ref, defineComponent } from "@vue/composition-api";

export default defineComponent({
  // 名前変更
  name: "TaskAdd",
  // dataを削除し、代わりにデータ参照用にpropsを追加
  props: {
    tasks: {
      type: Array as () => Task[],
      required: true,
    },
  },
  setup(props) {
    const newTaskTitle = ref("");
    // taskを追加する。
    const addTask = () => {
      if (newTaskTitle.value.length > 0) {
        let newTask: Task = {
          id: Date.now(),
          title: newTaskTitle.value,
          done: false,
        };
        props.tasks.push(newTask);
        newTaskTitle.value = "";
      }
    };
    // 不要な定義を削除
    return { newTaskTitle, addTask };
  },
});
</script>
views/Home.vue
<template>
  <!-- ToDoListに変更 -->
  <div>
    <TodoAdd :tasks="tasks"></TodoAdd>
    <TodoList :tasks="tasks"></TodoList>
  </div>
</template>

<script lang="ts">
// 作成したコンポーネント、型定義を追加
import TodoList, { Task } from "@/components/TodoList.vue";
import TodoAdd from "@/components/TodoAdd.vue";
import { reactive, defineComponent } from "@vue/composition-api";

export default defineComponent({
  name: "Home",
  // コンポーネントを追加
  components: { TodoList, TodoAdd },
  setup() {
    // 型定義を追加
    const tasks: Task[] = reactive([
      {
        id: 1,
        title: "起きる",
        done: false,
      },
      {
        id: 2,
        title: "着替える",
        done: false,
      },
    ]);
    // 不要な記述を削除
    return { tasks };
  },
});
</script>

どうでしょうか。Typescriptでコンポーネント間のデータ型が明確になり、CompositionAPIのsetupで<template />内で利用するモジュールのみを設定することにより、可読性の高い実装ができるようなったと思います。

おわりに

今回は、Vuetifyの一部の機能を使ったTypescript版のToDoアプリの作り方の紹介をしました。
まだ、v-formやvalidationをTypescriptで使う際の制約事項や、データ保存(Vuex)なども書き足そうかと思ったのですが、記事が長くなるので、また別の機会にしたいと思います。

Discussion