背景
弊社ではフロントの実装でVueを利用しているのですが、私自身は直近一年間でほぼ触ってなかったので、最近復習してます。
元々Vueに対しては、**親子・兄弟コンポーネント間でのデータ連携が大変^^;**という印象が強くありました。「振る舞い・見た目・構造」がまとまったコンポーネント達を「状態」を考慮しながら組み合わせていくには、vuexを使うしかないのかなと思っていました。
ところがこちらの記事 で、
兄弟コンポーネントでのバケツリレーを防ぐだけであれば Vuex がなくても状態管理をストアパターンで問題なくできる
という事を知り、自分でも試してみたい!と思い、実装してみました。
実装を通して、以前よりかは可読性のあるVueコンポーネントを書く感覚は得られたので、どう実装したのかを記事として残しておく事にしました。
もっと良いStoreの使い方がある!こう分けた方がもっと読みやすく・わかりやすく・メンテしやすいコンポーネントになるなど、教えていただけたら嬉しいです。
やった事
- 親・子・孫の三世代分をコンポーネント化をする
- Storeパターンで状態を管理する
- Storeに対するコンポーネントの責務を明確にする
- 親・子・孫コンポーネントはStoreの状態は変えられない
- 親は子にStoreを提供する
- 子はStoreの変数と関数を自身で利用、場合によってはそれらを孫に受け渡す
- 孫はStoreの存在を知らない。あくまで子から渡された変数や関数を利用するだけ
開発環境
- CodeSandbox上で実装
- Vueのバージョンは
2.6.11
実装したもの
機能としては、以下の4つのみ実装しています。
(1) 上部にフォルダ一覧、下部にファイル一覧が表示される
(2) フローティングアクションボタンから「Folder」または「File」を選択すると、新規追加ダイアログが表示される
(3) ダイアログで名称を入力し、新規追加すると、「Folder」または「File」一覧に表示される
(4) 一覧上のアイテムはゴミ箱アイコンで削除できる
こちらがソースコードです。
実装の大まかな流れ
- 一枚岩のHTML・JSを用意する
- コンポーネントの家系図を決める
- 一枚岩のHTML・JSをコンポーネントに分ける
- 兄弟間の状態管理用Storeを用意する
- Storeの提供と利用を行うための「key」を用意する
- 親の実装:Storeを提供する
- 子の実装:Storeを利用
- 孫の実装:子から受け取った変数や関数を利用する
この流れに従って、各項目の詳細を書いていきます。
また、VueのAPIとしては、Vue Composition APIを利用しました。
※ 使い方は公式Docや以下のような記事を参考にしました。わかりやすくて助かりました!
- https://qiita.com/tmy/items/a545e44100247c364a71
- https://qiita.com/ryo2132/items/f055679e9974dbc3f977
1.一枚岩のHTML・JSを用意する
VuetifyのButtons:Floating action buttonsのサンプルをベースに用意しました。
2. コンポーネントの家系図を決める
用意したHTMLを以下のような家系図でコンポーネント化するように決めました。
それぞれ、以下のような場所を担当するコンポーネントになります。
- Uploader:上部のGIFで見せたファイルアップロード画面全体
- Toolbar:アップロード画面上部
- Floating Action Button:アップロード画面上部のピンクのボタン
- Dialog:新規追加ダイアログ
- List:上部にフォルダ一覧、下部にファイル一覧が表示される部分
- List Item:フォルダまたはファイルの一行分
3. 一枚岩のHTML・JSをコンポーネントに分ける
HTMLを上記の家系図通りに、.vue
ファイルに分けました。
上記の図の左側の親(Uploader)・子(Toolbar)・孫(Floating Action Button)でいうと、以下のような感じです。
# Uploader.vue (親)
<template>
<v-row>
<v-col cols="12" sm="6" offset-sm="3">
<v-card>
<UploaderToolbar/> (子)
<UploaderList/> (子)
<UploaderDialog/> (子)
</v-card>
</v-col>
</v-row>
</template>
# Toolbar.vue (子)
<template>
<v-toolbar color="light-blue" light extended>
<v-app-bar-nav-icon></v-app-bar-nav-icon>
<v-toolbar-title class="white--text">My files</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn icon>
<v-icon>mdi-magnify</v-icon>
</v-btn>
<v-btn icon>
<v-icon>mdi-view-module</v-icon>
</v-btn>
<template v-slot:extension>
<floting-action-button/> (孫)
</template>
</v-toolbar>
</template>
# FloatingActionButton.vue (孫)
<template>
<v-menu>
<template v-slot:activator="{ on: menu }">
<v-btn fab color="pink accent-2" bottom left absolute v-on="{ ...menu }">
<v-icon color="white">mdi-plus</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item @click="">
<v-list-item-title>Folder</v-list-item-title>
</v-list-item>
<v-list-item @click="">
<v-list-item-title>File</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</template>
コンポーネントに分けた後のディレクトリ構造は、以下のような形です。
src
|_ main.js
|_ App.vue
|_ components
|_ uploader
|_ Uploader.vue (親)
|
|_ Toolbar
| |_ Toolbar.vue (子)
| |_ FlotingActionButton.vue (孫)
|
|_ Dialog
| |_ Dialog.vue (子)
|
|_ List
|_ List.vue (子)
|_ ListItem.vue (孫)
4. 兄弟間の状態管理用Storeを用意する
続いて、3で分けた子コンポーネント間で共有したい変数やそれらを操作する関数をStoreとして登録していきます。
src/stores
配下にuploader.js
という状態管理用ファイルにuploaderStore
という関数を定義しました。(この関数を呼び出す処理を「Storeの提供・利用」と呼んでいます。)
関数内では、まず子の間で共有したい変数をreacive
APIを使って定義します。今回は以下のような変数を定義しました。
(1) ダイアログの表示フラグ(Toolbar, Dialogで共有)
(2) フォルダ一覧(Toolbar, Dialogで共有)
(3) ファイル一覧(Toolbar, Dialogで共有)
(4) カテゴリ一覧(Toolbar, Dialogで共有)
(5) ユーザが選択したカテゴリ(Toolbar, Dialogで共有)
(6) アイテムの名称(Dialog, Listで共有)
// src/stores/uploader.js
import { reactive, computed } from "@vue/composition-api";
export default function uploaderStore() {
const state = reactive({
dialog: false, // ... (1)
folders: [ // ... (2)
...
],
files: [ // ... (3)
...
],
categories: ["Folder", "File"], // ... (4)
category: "", // ... (5)
name: "" // ... (6)
});
...
}
そして、上記の変数を操作する関数(Getter/Setter、追加・削除系)をこの関数の返り値にセットします。長くなってしまったので一部抜粋して掲載します。
// src/stores/uploader.js
export default function uploaderStore() {
const state = reactive({
...
});
return {
name: computed({
get: () => {
return state.name;
},
set: val => {
state.name = val;
}
}),
...
deleteFolder(folder) {
// Folderを削除する処理
},
...
};
5. Storeの提供と利用を行うための「key」を用意する
次に 4.で用意したStoreを提供・利用するために「Key」を用意します。
Storeパターンに加え、今回初めて「Key」という概念も知ったのですが、こちらも公式DocでStoreと一緒に実装するよう提案されているパターンのようです。
公式Docに従って、provide
とinject
というAPIを利用して実装しました。
今回はsrc/keys
配下のuploader.js
というファイルに、
(1) 親がStoreを提供するために使う関数
(2) 子がStoreを利用するために使う関数
を定義しました。
// src/keys/uploader.js
import { provide, inject } from "@vue/composition-api";
const StoreSymbol = Symbol("UploaderStore");
export function provideStore(store) { // ... (1)
provide(StoreSymbol, store);
}
export function useStore() { // ... (2)
const store = inject(StoreSymbol);
if (!store) {
throw new Error(`StoreSymbol is not provided`);
}
return store;
}
6. 親の実装:Storeを提供する
ではここからコンポーネントの実装に戻ります。
先程用意した「key」と「store」を使って.親にStoreを提供させます。
(1) まずは提供するStoreとそれを提供する関数をimportします
(2) setUp内で実行します。
これで親配下のスコープでStoreを利用できるようになります。
コード上では以下のような感じです。
// Uploader.vue (親)
<script>
import uploaderStore from "@/stores/uploader"; // ... (1)
import { provideStore } from "@/keys/uploader"; // ... (1)
import UploaderToolbar from "./Toolbar/Toolbar";
import UploaderList from "./List/List";
import UploaderDialog from "./Dialog/Dialog";
export default {
components: {
UploaderToolbar,
UploaderList,
UploaderDialog
},
setup() {
provideStore(uploaderStore()); // ... (2)
return {};
}
};
</script>
7. 子の実装:Storeを利用
続いて、子コンポーネントでStoreを利用していきます。
7.1 まずは子自身で利用
Toolbarコンポーネントを例にすると
(1) src/keys/uploader.js
からStoreを利用するための関数useStore
をimportします
(2) setup
内でStoreを呼び出します。
(3) Storeから必要な変数や関数を取り出します。Toolbarでは、選択されたカテゴリ(FolderまたはFile)のダイアログを表示する機能を持つので、store.categories
とstore.showDialog
を取り出しました。
// Toolbar.vue
<script>
import { useStore } from "@/keys/uploader"; // ・・・ (1)
import FlotingActionButton from "./FlotingActionButton";
export default {
components: {
FlotingActionButton
},
setup() {
const store = useStore(); // ・・・ (2)
return {
categories: store.categories, // ・・・ (3)
showDialog: store.showDialog
};
}
};
</script>
7.2 孫に変数や関数を提供するのに、Storeを利用
孫に何かしらのアクションをさせたい場合は、テンプレートの属性に変数や関数を与えると思います。そこにStoreから取り出したものを入れていきます。
(1) 今回のToolbar
では 7.1 で取り出したcategories
とshowDialog
を孫であるfloting-action-buttun
の属性に追加しました。
<template>
<v-toolbar color="light-blue" light extended>
<v-app-bar-nav-icon></v-app-bar-nav-icon>
<v-toolbar-title class="white--text">My files</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn icon>
<v-icon>mdi-magnify</v-icon>
</v-btn>
<v-btn icon>
<v-icon>mdi-view-module</v-icon>
</v-btn>
<template v-slot:extension>
<floting-action-button :items="categories" :doAction="showDialog"/> // ・・・ (1)
</template>
</v-toolbar>
</template>
8 孫:子から受け取った変数や関数を利用する
7.2 で属性に追加された変数や関数を孫で利用できるようにします。ここからは、よくあるprops
の利用パターンと同じです。
(1) 子から変数や関数を受け取れるように、propsを追加します。
(2) 追加したpropsをtemplateで利用します。下のFloatingActionButton.vue
では「追加可能なカテゴリ」が生成されるようにitems
をlist-item
の部分に利用し、そのlist-item
に対するクリックイベントの部分にdoAction
利用してます。
Storeの参照は子にまかせ、孫ではpropsを利用する事で、末端までStoreに依存するという事態を避けました。
// FloatingActionButton.vue
<template>
<v-menu>
<template v-slot:activator="{ on: menu }">
<v-btn fab color="pink accent-2" bottom left absolute v-on="{ ...menu }">
<v-icon color="white">mdi-plus</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item v-for="item in items" :key="item" @click="doAction(item)"> // ・・・ (2)
<v-list-item-title>{{ item }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</template>
<script>
export default {
props: { // ・・・ (1)
items: {
type: Array,
default: () => []
},
doAction: {
type: Function,
default: () => {}
}
}
};
</script>
大体この手順を繰り返して、三世代分をコンポーネント化しました。
実装後の所感
- Storeパターンを知って、1年前よりコンポーネント化していくのを楽しむ余裕が生まれました♪
- もっとコンポーネントが増やしてStoreを太らせて、どう切り分けていくと食べやすくなるか考える練習もしてみたいです
- Storeが外部とやり取りを行う場所にもなると思うので、今後はGraphQLなどを使った実装もやってみたいです