Vue CLI 5 系で Pinia 入門

システム開発事業部

Vue CLI 5 系の正式版のv5.0.1が2022/2/17にリリースされました。
今回は、v5.0.1で雛形を作成し、Vueのステート管理ライブラリであるPiniaを使ってみます。

まず、Vue CLI 5 系の変更点について、v4系からのマイグレートガイドを元に軽く触れておきます。

コマンドについては大きな変更はありませんが、デフォルトの設定がいくつか変更されています。
例えば、build コマンドの --moden モードはデフォルトでオンになりました。
また、Vue 3系のプロジェクトでは、IEがブラウザリストから除外されています。
新たに使い始める分には影響は少ない変更ですが、マイグレートすると出力結果が変わりCIで失敗したりするかもしれません。

各種ライブラリのバージョンアップは大幅に行われています。
中でも影響の大きいと思われる点は、Webpack が5系になったことでしょうか。
Webpack 5 がリリースされて1年以上経過していることもあり、特に破壊的なバージョンアップが多いライブラリでは、ビルドに失敗することも多くなってきたのでアップデートは非常にありがたいです。
例えば、Tailwind CSS 3系もWebpack 5系が必須となっているので、今回のバージョンアップで利用できるようになっているはずです。

続いて、今回の本題であるPiniaを使ってみます。
以下のようなショッピングサイトのサンプルを作ってみました。
ローディング表示の制御とカートの管理を行っています。

作成したサンプルはリポジトリに公開しています。

最初にプロジェクトの雛形を作成します。
(個人的には、毎日使うようなものではないライブラリをグローバルインストールすることは極力避けたいので、以下のように一時的に使うようにしています。)

> npx --package=@vue/cli vue create vue-pinia-starter

設定するオプションは、Pinia を利用する際に必須の TypeScript(TS) が必要なこと以外は任意で問題ないと思います。
ここでは、同じくステート管理ライブラリのVuexは、競合するかもしれないので追加しないようにしておくことにします。
以下、今回作成した際の設定内容です。

Vue CLI v5.0.1
? Please pick a preset: Manually select features
? Check the features needed for your project: Babel, TS, Router, CSS Pre-processors, Linter, Unit
? Choose a version of Vue.js that you want to start the project with 3.x
? Use class-style component syntax? No
? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)? Yes
? Use history mode for router? (Requires proper server setup for index fallback in production) Yes
? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): Sass/SCSS (with dart-sass)
? Pick a linter / formatter config: Prettier
? Pick additional lint features: Lint on save
? Pick a unit testing solution: Jest
? Where do you prefer placing config for Babel, ESLint, etc.? In dedicated config files
? Save this as a preset for future projects? No

Piniaをインストールします。

> npm install pinia

以下のようにmain.tsでPiniaを有効化します。(Vue Routerを同じ形式です。)

import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import { createPinia } from "pinia";

createApp(App).use(router)
              .use(createPinia()) // Pinia を有効化
              .mount("#app");

これで、Pinia は使えるようになります。
(余談ですが、Vue CLI v5.0.0-rc.2 が出たばかりの頃は、設定される TypeScript のバージョンが Pinia の要求バージョンよりも低く、先に TypeScript のバージョンを 4.5.3 以上にする必要がありました。)

最初に、API通信時といった場合にユーザに操作させたくないような時に使う、ローディング表示をグローバルに切り替えられるようにするためのストアを作成します。

import { defineStore } from "pinia";

export const useSystemStore = defineStore("systemStore", {
  state: () => {
    return {
      isLoading: false,
    };
  },
  actions: {
    startLoading() {
      this.isLoading = true;
    },
    endLoading() {
      this.isLoading = false;
    },
  },
});

例えば、App.vueでローディング表示を切り替えられるようにすると、以下のように定義できます。(CSSは省略。)
※ここでは直接書いていますが、ローディング用のコンポーネントは別途作った方が良いと思います。

<template>
  <div class="loader-wrap" v-if="systemStore.isLoading">
    <div class="loader">loading</div>
  </div>
  <router-view />
</template>

<script lang="ts">
import { defineComponent } from "vue";
import { useSystemStore } from "./stores/system-store";

export default defineComponent({
  setup() {
    const systemStore = useSystemStore();
    return { systemStore };
  },
});
</script>

利用する側のコンポーネントでは、Vue Routerなどと同じような形式で使うことができ、以下のように定義します。(someAction を実行中にローディング表示を行えます。)

<script lang="ts">
import { defineComponent } from "vue";
import { useSystemStore } from "./stores/system-store";

export default defineComponent({
  setup() {
    const systemStore = useSystemStore();
    const someAction = () => {
      systemStore.startLoading();
      // API通信といった時間のかかる処理
      systemStore.endLoading();
    };
    return { someAction };
  },
});
</script>

ここでは、ストアで定義した Actions を使ってステートの変更をするようにしていますが、Pinia の場合はいわゆる Mutation をストアに定義することはしません。
利用側から、直接変更したり、Mutationを実行したり、ということができるようになっています。
試した限りでは、リアクティブになっているので、複雑な操作をしない限りはストアではステートを定義するだけでも十分に利用できると思います。
(記載時点では、Vue 3ではdev-toolでは捕捉できないようですが、簡単に使うことができるようになっているのは、実装時に試行錯誤する際には十分かと思います。)

次にカートに商品を追加した時にカートに何種類の商品が入っているかや小計がヘッダ部で確認できるように作成します。
ローディング表示の有無のような単純な場合はステートをそのまま利用すれば十分ですが、カート内の小計のように計算が必要な場合、Getters を利用すると利用したいコンポーネントでいちいち計算を定義せずに済みます。

import { defineStore } from "pinia";
import { useSystemStore } from "./system-store";


// カート内商品の型定義
type CartItem = {
  id: string;
  name: string;
  unitPrice: number;

  count: number;
};

export const useUserStore = defineStore("userStore", {
  state: () => {
    return {
      cart: new Array<CartItem>(),
    };
  },
  getters: {
    // カートに何種類の商品が入っているか
    getCartSize: (state) => state.cart.length,
    // カートに入っている商品の小計を計算する
    getTotalAmount: (state) =>
      state.cart.reduce(
        (prev, current) => prev + current.unitPrice * current.count,
        0
      ),
  },
  actions: {
    // カートに追加する
    addItem(item: CartItem) {
      for (const e of this.cart) {
        if (e.id === item.id) {
          e.count += item.count;
          return;
        }
      }
      this.cart.push(item);
    },

    // カートから商品を削除する
    removeItem(id: string) {
      this.cart = this.cart.filter((e) => e.id !== id);
    },
  },
});

画面の基本構成は以下のイメージです。

<template>
  <Header /><!-- ヘッダにカートの概要を表示 -->
  <div>
    <!-- カートに追加するアクション -->
  </div>
</template>

<script lang="ts">
import { computed, defineComponent } from "vue";
import Header from "@/components/Header.vue";
import { useUserStore } from "@/stores/user-store";
import { CartItem } from "@/entities/item";

export default defineComponent({
  components: {
    Header,
  },
  setup() {
    const userStore = useUserStore();
    const addCart = (item: CartItem) => {
      // カートに商品を追加する
      userStore.addItem({ ...item });
    };
    return {
      userStore,
      addCart,
      // ...
    };
  },
});
</script>

ヘッダ部は以下のような実装にしました。
挙動については、記事の先頭のGIFでご確認ください。

<template>
  <div class="header">
    <div>Wonderful Shop</div>
    <div class="cart">

      <!-- マウスオーバーでカート内の情報を表示できる-->
      <i class="bx bx-cart" @mouseover="mouseover" @mouseleave="mouseleave">
        <!-- カートに商品が入っていない場合を考慮する -->
        <div class="cart-abstract" v-if="isShowCart && userStore.getCartSize">
          <div>

            <!-- 小計を表示する -->
            <b> subtotal: {{ userStore.getTotalAmount }}</b>
          </div>

          <!-- カート内の商品一覧を表示する -->
          <div v-for="e in userStore.cart" :key="e.id">
            <div>
              {{ e.name + " x " + e.count }}
              <!-- カート内の特定の商品を削除する -->
              <i
                class="bx bx-trash"
                style="cursor: pointer"
                @click="userStore.removeItem(e.id)"
              ></i>
            </div>
          </div>
        </div>
      </i>

      <!-- カート内の商品の種類を表示する -->
      <span class="cart-text" v-if="userStore.getCartSize">{{
        userStore.getCartSize
      }}</span>
    </div>
  </div>
</template>

<script lang="ts">
import { useUserStore } from "@/stores/user-store";
import { defineComponent, ref } from "vue";

export default defineComponent({
  name: "HeaderComponent",
  setup() {
    const userStore = useUserStore();

    // カートの概要を表示するかどうかのフラグ
    const isShowCart = ref(false);
    const mouseover = () => {
      isShowCart.value = true;
    };
    const mouseleave = () => {
      isShowCart.value = false;
    };

    // ストアについて、サブスクライブが可能
    userStore.$onAction(
      ({
        store,
        after,
      }) => {
        after(() => {

          // カート内に商品がなくなった後に、概要の表示を明示的に解除する
          // カートが空になる瞬間に概要表示が消えてしまうことで、
          // マウスオーバーの解除判定が起動せず(onmouseleaveが発火しない)、
          // 概要表示がオンになったままになってしまう
          // そのため、再度商品をカートに入れた時に概要が表示されてしまう
          if (!store.getCartSize) {

            isShowCart.value = false;
          }
        });
      }
    );

    return { userStore, isShowCart, mouseover, mouseleave };
  },
});
</script>

ところで、Piniaにはストアをサブスクライブする機能があります。(ヘッダ部の実装の最下部)
アプリケーションの作りに依存する部分は大きいと思いますが、グローバルに管理するものが多くなって更に非同期で処理を書いていくと必要になってくる機能かなと思います。

今回のサンプル程度では出番がないかと思っていましたが、予期せず必要になったので記載しました。
(デザインパターンとしては有名ですが、色々なコンポーネントからステートが変更されてくるとどんどん複雑になってくるので使わないで済むに越したことはないと考えています。)
この処理を使わない場合、以下のように少し格好悪い挙動をしています。

以上で、ストアの定義とステート(State)、Getters、Actions、ストアのサブスクライブ(Subscribe)を利用することができました。
ここでは記載できていませんが、Pinia では、ストアを拡張するプラグインの作成Server-Side Rendering (SSR) に対応したりしています。

感想

ステート管理をするというだけであれば、Vuex で十分です。
Pinia の良い点は、特に追加設定しなくとも TypeScript に対応して型安全にできているところだと思います。
例えば、ストアの変数名や関数名を変更した際にビルドエラーが出たり、エディタによっては利用側でも一緒に変更してくれたりするので、確認漏れを少なくすることができるのは非常に良い点だと思います。
Pinia はまだまだ新しいライブラリなので、Vuexに対して機能が足りないといったことや今後破壊的な変更が入って困ることが出てくるかもしれませんが、TypeScript での利点を考えると採用の価値は十分にあると思います。

関連記事

カテゴリー

アーカイブ