既存プロダクトにPiniaとComposition APIを部分導入してみた
はじめに
社内用の既存プロダクト(Vue × Laravel)に対して実験的に Pinia と Composition API を部分導入しリファクタリングしてみたので、その体験を簡単にまとめようと思いました 🍍
目的
なぜ既存コードのリファクタリングを行おうと思ったのかというと、このプロダクトを今後入社するインターン生の教育として活用するためです。
インターン生が担っているプロジェクトは 10 個ぐらいありますが、フロントエンドにフレームワークを用いたものは少なく、今後 Vue を標準とすることを考えていることから、適切なお手本となるコードを提供する必要があると考えました。
今回リファクタリングしたプロダクトは元々 Options API で記述されていましたが、流石に教育用としては不適切だと判断したので、まずは部分的にでも Composition API への書き換えすることにしました。
動作環境
ツール | バージョン | 用途 |
---|---|---|
Vue | 3.5.13 | UI ライブラリ |
ESLint | 8.57.1 | 静的解析ツール |
Pinia | 2.3.0 | 状態管理 |
@typescript-eslint/parser | 8.19.1 | TypeScript の構文解析 |
今回のリファクタリングの中で新たに表の下 2 つを追加しました。
Pinia の導入
リファクタリング第一段階として、状態管理ライブラリ Pinia の導入を試みました。Vuex という選択肢もあると思いますが、以下の理由から Pinia を採用しました。
- 私が他のプロジェクトで既に Pinia を使用しており、親しみがあった
-
Vue Fes 2024
に参加した際、Pinia の注目度が高かった
深ぼればもっと 色々メリット あると思いますが、とりあえずデータの可視化をして一元管理したかったというのが主な目的です。Vue の devtools で状態を可視化できるのは魅力だと思います。
Vue devtools の様子
導入の際には影響範囲が少ない機能から始めました。具体的には、user のデータ管理に Pinia を適用することにしました。
導入手順
- コンテナ内で Pinia を
npm install
- app.js で
createPinia
を import( 参考:https://pinia.vuejs.org/getting-started.html ) -
stores/user.ts
とinterfaces/stores/userInterface.ts
を作成 - ESLint の設定を変更(必要に応じて)
- バックエンド側のレスポンス形式を変更(必要に応じて)
-
vue
ファイルで store にデータを保存
詳細
手順 1,2 の install 関連は、上記の公式サイトを参考にすれば問題なく進められるはずです。
手順 3 では、interface で型定義をしつつ、store に保存やリセットする action を作成しました。以下に State を簡略化した例を示します。
import { defineStore as baseDefineStore, type _GettersTree } from "pinia";
interface StateInterface {
id?: number;
name: string;
}
interface GettersInterface extends _GettersTree<StateInterface> {
}
interface ActionsInterface {
reset: () => void;
store: (v: StateInterface) => void;
}
export const defineStore = baseDefineStore<
string,
StateInterface,
GettersInterface,
ActionsInterface
>;
import { defineStore } from "../interfaces/stores/userInterface";
export const useUserStore = defineStore({
id: "user",
state: () => ({
id: undefined,
name: "",
}),
getters: {},
actions: {
reset() {
this.$reset();
},
store(v) {
this.$state = v;
},
},
});
続いて手順 4 の ESLint の調整ですが、userInterface.ts の_GettersTree でESLint:Parsing error
が発生したため、@typescript-eslint/parser
を導入後、eslintrc.js
に以下を追記しました。
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
ecmaVersion: 'latest',
sourceType: 'module',
},
ここで 1 つ問題が生じました。Pinia で管理するにあたり、バックエンドのレスポンス形式を調整する必要が出てきたことです。users テーブルの全データをフロントに返していたため、不必要なデータやセキュリティ上問題のあるデータが含まれていました(今まで問題だったというのもありますが…)。これに対応するため、手順 5 としてバックエンドの調整をしましたが、これに関しては Laravel の話になるので割愛させていただきます。
手順 6 では、これまで Options API の data()
で定義した変数に user データを格納していましたが、user.ts
の actions で定義した store で格納することで、状態を管理できるように変更しました。これによりconst userStore = useUserStore();
を使えば、どこからでもデータを引っ張ってくることができます。
Composition API の導入
続いて先ほど導入した userStore を有効活用するために、部分的な Composition API の導入をしました。とりあえず第 1 段階として 1 ファイルだけ書き換えました。
Pinia で管理しているデータを取り出せるかを確認をするついでに、<script setup lang="ts">
でconst userStore = useUserStore();
を使用したかったというのが主な目的です。
これまでの Options API では以下のように記述していました。
<script>
export default {
components: {
...
},
props: {
...
}
emits: [
...
],
data(): {
return {
...
}
},
computed: {
...
},
watch: {
...
},
methods: {
...
}
</script>
Composition API に書き換えるにあたり、本来親コンポーネントから渡すべき処理や Emits を使用しなくても済むものなど、リファクタリングしたいところもありましたが、処理内容は変更せずになるべく 1 つのコンポーネント内で完結するように心がけました。
<script setup lang="ts">
// emitsやpropsは必要な時だけ定義(必要ないときはdefineEmitsやdefinePropsのみ)
const emits = defineEmits([...]);
const props = defineProps({
...
});
// use関連
const userStore = useUserStore();
const 変数1 = ...;
// 変数関連
const 変数2 = ref();
const 変数3 = computed(() => ...);
// watchだと1つ指定して監視可能
watchEffect(() => ...);
// メソッド関連
const 変数4 = () => {
...
}
</script>
2 つの書き方を見比べてわかることを以下にまとめます。
- components の定義がなくなった
- defineEmits、defineProps の使用
- use でその他 store や composables などから共通処理を使い回し可能
- (ex) userStore.name のように取り出すこともできます
- ref の使用でリアクティブに監視
- 明確なセクション分けがなくなってしまった
共通処理が使いまわしやすい点や ref の使用はメリットだと思いますが、一部の人にとっては methods などの明確なセクション分けがなくなってしまったことで、コードが読みにくくなったと感じる人もいるかもしれません。この点に関してはチームでコーディングルールを統一していく必要があるのかなと思いました。
今後の展望
今回は 1 ファイルのみで Composition API を導入していますが、今後は他のコンポーネントとの調整をして、デザインパターンに沿ったコードへのリファクタリングも行おうと考えています。
少し古いかもしれませんが、デザインパターンとして Container/Presentational パターン を用いています。現在の実装では、vue ファイルで〇〇Container.vue
を作成し、script 内でロジックを記述しています。
今後は、ロジック部分をcomposables/containers
に切り出して、ts ファイルで Container のロジックを管理することを検討しています。
vue ファイルの方では親コンポーネントで container を呼び出し、子コンポーネントに props でデータを渡すことで可読性の向上を目指します。
// 切り出したロジック部分
export const use〇〇Container = () => {
...
return {
...
};
};
<script setup lang="ts">
const container = use〇〇Container();
</script>
また emits ではなく、React のようになるべく親から関数を props で渡すことで、子から親という導線を削減したいと思います。(コンポーネントのネストが深くなるにつれて複雑になることを防ぐためです。)
まとめ
本記事では Pinia と Composition API を実験的に導入した際の、流れや考えを体験記として綴りました。
まだ私の頭の中の構想の部分もありますが、なるべく本記事で言語化したつもりではあるので、今後のインターン生教育にも反映できるように頑張っていきたいと思います 💪
Discussion
<script setup lang="ts">
なら、defineEmitsとdefinePropsは型引数を利用した方が、TypeScriptの型の資材が使えるので、有用かもしれませんコメントありがとうございます!
今後のリファクタリングの際にも参考にさせていただきます🙏