概要
この記事では、Vue.js ver 3.0 で採用予定の Composition API (https://vue-composition-api-rfc.netlify.com) を使って、このRFCの動機のひとつである「Code Organization」がどのようにして実現可能なのかを試しに実装してみた結果をまとめました。
はじめに100行超の単一のSFCでデモアプリを実装し、そのあとに「Function -> Module -> Component」の順番で機能単位での分割を進めた前後のコードを比較しています。
Composition APIでどのように実装すべきかを模索されている方にとっての参考になれば幸いです。
Logic Reuse & Code Organization
The APIs proposed in this RFC provide the users with more flexibility when organizing component code. Instead of being forced to always organize code by options, code can now be organized as functions each dealing with a specific feature. The APIs also make it more straightforward to extract and reuse logic between components, or even outside components.
まだComposition APIをキャッチアップしている途中ではありますが、今回の実装は個人的に手応えがあり、よりComposition APIを深掘りしたいという思いが強くなりました。
バージョン
- @vue/cli v4.1.1
- vue v2.6.10
- @vue/composition-api v0.3.4(https://github.com/vuejs/composition-api)
デモアプリ
デモアプリの機能は以下の4つです。
- タスクの追加
- タスクのステータスごとのリスト表示
- タスクのステータス切り替え
- タスク名でのインクリメンタルサーチ(両方のステータスで絞り込みます)
ソースコード
こちらのリポジトリで公開しています。
https://github.com/snagasawa/vue-composition-api-sample
単一SFC
- src/components/TaskList.vue
<template>
<div>
<div>
<input type="text" v-model="state.taskName" />
<button @click="addTask">Add</button>
</div>
<div>
<input type="text" v-model="state.searchText" />Search
</div>
<div class="task-list-wrapper">
<ul>
<h4>DOING</h4>
<li v-for="(task, index) in state.doingTasks" :key="index">
<input type="checkbox" :checked="task.status" disabled/>
<label>{{ task.name }}</label>
<button @click="toggleTask(task, true)">toggle</button>
</li>
</ul>
<ul>
<h4>COMPLETED</h4>
<li v-for="(task, index) in state.completedTasks" :key="index">
<input type="checkbox" :checked="task.status" disabled/>
<label>{{ task.name }}</label>
<button @click="toggleTask(task, false)">toggle</button>
</li>
</ul>
</div>
</div>
</template>
<script>
import { reactive, computed } from '@vue/composition-api';
export default {
setup() {
const state = reactive({
taskName: '',
searchText: '',
tasks: [],
doingTasks: computed(() => state.searchedTasks.filter(t => !t.status)),
completedTasks: computed(() => state.searchedTasks.filter(t => t.status)),
searchedTasks: computed(() => state.tasks.filter(t => t.name.includes(state.searchText))),
});
function addTask() {
state.tasks.push({
name: state.taskName,
status: false,
});
state.taskName = '';
}
function toggleTask(task, status) {
const index = state.tasks.indexOf(task);
state.tasks.splice(index, 1, { ...task, status: status });
}
return {
state,
addTask,
toggleTask
}
}
}
</script>
<style scoped>
.task-list-wrapper {
display: flex;
justify-content: center;
}
</style>
まずはRFCドキュメントのサンプルコードを参考にしながら実装しました。
https://vue-composition-api-rfc.netlify.com/#usage-in-components
ここではAPIの reactive
と computed
のみ利用しています。
Composition APIでは this
への依存がなくなり、ここではreactive
によってリアクティブ化したオブジェクトを state
として扱っています。
MethodはVueインスタンスの method
に相当するものがなくなり、そのままFunctionとして定義しています。
シンプルなアプリなのでこの実装のままでも見通しは悪くないのですが、ここからコードを分割していきます。
Functionへの分割
- src/components/TaskList.vue
<template>
<div>
<div>
<input type="text" v-model="taskNameRef" />
<button @click="addTask">Add</button>
</div>
<div>
<input type="text" v-model="searchTextRef" />Search
</div>
<div class="task-list-wrapper">
<ul>
<h4>DOING</h4>
<li v-for="(task, index) in doingTasks" :key="index">
<input type="checkbox" :checked="task.status" disabled/>
<label>{{ task.name }}</label>
<button @click="toggleTask(task, true)">toggle</button>
</li>
</ul>
<ul>
<h4>COMPLETED</h4>
<li v-for="(task, index) in completedTasks" :key="index">
<input type="checkbox" :checked="task.status" disabled/>
<label>{{ task.name }}</label>
<button @click="toggleTask(task, false)">toggle</button>
</li>
</ul>
</div>
</div>
</template>
<script>
import { computed, watch, ref, isRef } from '@vue/composition-api';
const useTaskList = () => {
const tasksRef = ref([]);
const toggleTask = (task, status) => {
const index = tasksRef.value.indexOf(task);
tasksRef.value.splice(index, 1, { ...task, status: status });
};
return {
tasksRef,
toggleTask,
};
};
const useAddingTask = (tasksRef) => {
const taskNameRef = ref('');
const addTask = () => {
tasksRef.value.push({
name: taskNameRef.value,
status: false,
});
taskNameRef.value = '';
}
return {
taskNameRef,
addTask,
};
};
const useFilter = (tasks = []) => {
const tasksRef = isRef(tasks) ? tasks : ref(tasks);
const valid = Array.isArray(tasksRef.value);
const doingTasks = valid ?
computed(() => tasksRef.value.filter(t => !t.status)) :
() => { return [] };
const completedTasks = valid ?
computed(() => tasksRef.value.filter(t => t.status)):
() => { return [] };
return {
doingTasks,
completedTasks,
};
};
const useSearcher = (tasks = []) => {
const searchTextRef = ref('');
const tasksRef = ref(tasks);
const valid = Array.isArray(tasksRef.value);
const search = valid ?
computed(() => tasksRef.value.filter(t => t.name.includes(searchTextRef.value))) :
() => { return [] };
return {
searchTextRef,
search,
};
};
export default {
setup() {
const { tasksRef, toggleTask } = useTaskList();
const { taskNameRef, addTask } = useAddingTask(tasksRef);
const { searchTextRef, search } = useSearcher(tasksRef.value);
const { doingTasks, completedTasks } = useFilter(search);
watch([doingTasks, completedTasks], () => {
console.log('doingTasks: ', doingTasks.value);
console.log('completedTasks: ', completedTasks.value);
})
return {
// Mutable state
tasksRef,
taskNameRef,
searchTextRef,
// Functions
addTask,
toggleTask,
// Computed
doingTasks,
completedTasks,
}
}
}
</script>
<style scoped>
.task-list-wrapper {
display: flex;
justify-content: center;
}
</style>
初めの実装では setup
内にまとめて記述していたコードを、機能ごとにFunction化しました。
- タスクの追加(
useAddingTask
) - タスクのステータスごとのリスト表示(
useTaskList
) - タスクのステータス切り替え(
useFilter
) - タスク名でのインクリメンタルサーチ(
useSeacher
)
機能と関数が1:1で対応しています。
特に useFilter
の doingTasks
と completedTasks
は useSearcher
の search
と合成しており、画面では「ステータス毎の表示分割」と「インクリメンタルサーチ」が両立できています。
const { doingTasks, completedTasks } = useFilter(search);
(特に初めの実装では doingTasks
と completedTasks
が state.searchedTasks
に依存していて大変イケてないことにご注目ください(!))
doingTasks: computed(() => state.searchedTasks.filter(t => !t.status)),
completedTasks: computed(() => state.searchedTasks.filter(t => t.status)),
searchedTasks: computed(() => state.tasks.filter(t => t.name.includes(state.searchText))),
APIでは reactive
による state
がなくなり、代わりに各Function内で ref
を使用しています。
Module化
- src/composables/use-filter.js
import { computed, ref, isRef } from '@vue/composition-api';
export default function useFilter(tasks = []) {
const tasksRef = isRef(tasks) ? tasks : ref(tasks);
const valid = Array.isArray(tasksRef.value);
const doingTasks = valid ?
computed(() => tasksRef.value.filter(t => !t.status)) :
() => { return [] };
const completedTasks = valid ?
computed(() => tasksRef.value.filter(t => t.status)):
() => { return [] };
return {
doingTasks,
completedTasks,
};
}
- src/components/TaskList.vue
<script>
-import { computed, watch, ref, isRef } from '@vue/composition-api';
+import { computed, watch, ref } from '@vue/composition-api';
+import useFilter from '../composables/use-filter';
Function化ができればModule化は簡単ですね。
例では src/composables
ディレクトリを切り、 useFilter
関数をModule化しています。
他の useTaskList
, useSeacher
, useAddingTask
でも同様に可能です。
すべてをModule化すると TaskList.vue
の見通しがだいぶ良くなりました。
watch
を消すと TaskList.vue
から @vue/composition-api
ごと削除できます。
- src/components/TaskList.vue
<script>
-import { computed, watch, ref } from '@vue/composition-api';
import useFilter from '../composables/use-filter';
+import useSearcher from '../composables/use-searcher';
+import useAddingTask from '../composables/use-adding-task';
+import useTaskList from '../composables/use-task-list';
export default {
setup() {
const { tasksRef, toggleTask } = useTaskList();
const { taskNameRef, addTask } = useAddingTask(tasksRef);
const { searchTextRef, search } = useSearcher(tasksRef.value);
const { doingTasks, completedTasks } = useFilter(search);
- watch([doingTasks, completedTasks], () => {
- console.log('doingTasks: ', doingTasks.value);
- console.log('completedTasks: ', completedTasks.value);
- })
return {
// Mutable state
tasksRef,
taskNameRef,
searchTextRef,
// Functions
addTask,
toggleTask,
// Computed
doingTasks,
completedTasks,
}
}
}
</script>
Componentで分割
- src/components/TaskList.vue
<template>
<div>
<div>
<add-task :addTask="addTask"></add-task>
</div>
<div>
<input type="text" v-model="searchTextRef" />Search
</div>
<div class="task-list-wrapper">
<task-row title="DOING" :tasks="doingTasks" :toggleTask="toggleTask"></task-row>
<task-row title="COMPLETED" :tasks="completedTasks" :toggleTask="toggleTask"></task-row>
</div>
</div>
</template>
<script>
import TaskRow from '../components/TaskRow'
import AddTask from '../components/AddTask'
import useFilter from '../composables/use-filter';
import useSearcher from '../composables/use-searcher';
import useAddingTask from '../composables/use-adding-task';
import useTaskList from '../composables/use-task-list';
export default {
components: {
TaskRow,
AddTask,
},
setup() {
const { tasksRef, toggleTask } = useTaskList();
const { addTask } = useAddingTask(tasksRef);
const { searchTextRef, search } = useSearcher(tasksRef);
const { doingTasks, completedTasks } = useFilter(search);
return {
// Mutable state
tasksRef,
searchTextRef,
// Functions
addTask,
toggleTask,
// Computed
doingTasks,
completedTasks,
}
}
}
</script>
<style scoped>
.task-list-wrapper {
display: flex;
justify-content: center;
}
</style>
- src/components/TaskRow.vue
<template>
<ul>
<h4>{{ title }}</h4>
<li v-for="(task, index) in tasks" :key="index">
<input type="checkbox" :checked="task.status" disabled/>
<label>{{ task.name }}</label>
<button @click="toggleTask(task, true)">toggle</button>
</li>
</ul>
</template>
<script>
export default {
props: {
title: String,
tasks: Array,
toggleTask: Function,
}
}
</script>
<style scoped>
</style>
- src/components/AddTask.vue
<template>
<div>
<input type="text" v-model="taskNameRef" />
<button @click="addTask(taskNameRef)">Add</button>
</div>
</template>
<script>
import { ref } from '@vue/composition-api';
export default {
props: {
addTask: Function,
},
setup() {
const taskNameRef = ref('');
return {
taskNameRef,
};
}
}
</script>
<style scoped>
</style>
- src/composables/use-adding-task.js
export default function useAddingTask(tasksRef) {
const addTask = (taskName) => {
tasksRef.value.push({
name: taskName,
status: false,
});
}
return {
addTask,
};
}
- src/composables/use-filter.js
import { computed } from '@vue/composition-api';
export default function useFilter(tasksRef) {
const doingTasks = computed(() => tasksRef.value.filter(t => !t.status));
const completedTasks = computed(() => tasksRef.value.filter(t => t.status));
return {
doingTasks,
completedTasks,
};
}
- src/composables/use-searcher.js
import { computed, ref } from '@vue/composition-api';
export default function useSearcher(tasksRef){
const searchTextRef = ref('');
const search = computed(() => {
return tasksRef.value.filter(t => t.name.includes(searchTextRef.value))
});
return {
searchTextRef,
search,
};
}
- src/composables/use-task-list.js
import { ref } from '@vue/composition-api';
export default function useTaskList() {
const tasksRef = ref([]);
const toggleTask = (task, status) => {
const index = tasksRef.value.indexOf(task);
tasksRef.value.splice(index, 1, { ...task, status: status });
};
return {
tasksRef,
toggleTask,
};
}
TaskRow.vue
, AddTask.vue
をComponentとして切り出し、微修正を加えた最終形がこちらです。
結果として7つのファイルに分割できました。
初めはリファクタリングのつもりでしたが、結局リファクタリングではなくなってしまいました。
(タスク追加ボタンを押した時に入力フィールドをクリアするのをやめました。)
いくつかの composables ははじめに実装していた条件分岐が実は必要ではなかったことに気がつき削除しました。
実装当初は条件分岐をしなければエラーが起きてしまっていましたが、ref
化した変数を引数に渡すように統一したことでエラーが解消されたようです。
おかげでコードがスッキリしました。
今回はTaskというドメインに依存した composables が多いですが、実装次第ではより汎用的なコードを実装することで再利用性を高められそうです。
-
LinusBorg/composition-api-demos
- こちらのForm Validation, Pagination, Infinite Scrolling, File Uploadなどのデモを実装したリポジトリではより汎用的なコードのサンプルが公開されています。
つまり Composition API による分割と合成とは何か
さて、コードを見た感想はいかがでしょうか。
最後の TaskList.vue
には一切の状態と振る舞いの記述がなく、 ただ宣言的に子Componentの AddTask.vue
と TaskRow.vue
に対して、外部Moduleとして分割された composables によって返されるFunctionやリアクティブなref化したオブジェクトをそのまま直接渡しているだけです。
すなわち、Composition APIによる合成とは、「親Componentが子ComponentのpropsとしてリアクティブなオブジェクトやFunctionを渡してまとめること」であると解釈しました。
これが Composition API による成果と言えるのではないでしょうか。
今後のきになるところ
「reactive」 vs 「ref」
他の方のブログ記事や実装を見ていて気になったのは、人によって reactive
と ref
をどちらを積極的に使うかが別れており、ここが Composition API での重要な論点かなと思います。
今回は振る舞いのみを抽出したModuleやComponent内で ref
を活用し、 reactive
は途中で使わなくなりました。
実装した感触としてはとても手に馴染んだのですが、ここについては意見の分かれそうなところですのでもう少し様子を見たいところです。
結局Storeは必要になるのか
今回は分割を進めた結果、 状態が各Module・Component内に分散する結果となりましたが、これが中規模・大規模アプリであった場合、はたしてこのような実装方法でも状態管理の秩序を保つことができるのかに興味があります。
Composition APIは reactive
や ref
といった機動力のあるリアクティブ化の手段を提供しますが、システムの大規模化・複雑化の前ではやはりStoreパターンでの中央集権的な状態管理の仕組みの導入が避けられなくなってしまうのでしょうか。
TypeScript化
今回は単にVue.jsとJavaScriptのみで実装しましたが、Composition API は「プレーンな変数と関数での実装が可能になる」ことと「thisへの依存がなくなる」ためにTypeScriptフレンドリーであるとのことなので、次はこのデモアプリでのTypeScript化を試したいと考えています。
終わりに
Composition APIによるデモアプリのコード分割と合成を試みた結果をまとめました。
この記事を読まれた方にとって少しでもお役に立てれば嬉しいです。
明日の記事は @calorie さんによる公開予定です。どうぞお楽しみに。
参考リンク
- Composition API RFC
- vuejs/composition-api
- LinusBorg/composition-api-demos
- Vue 3 – A roundup of infos about the new version of Vue.js
- 先取りVue 3.x !! Composition API を試してみる
- きたるべきvue-nextのコアを理解する
- Vue Composition API の Composition ってなにを Compose するのかピンと来てなかったので考えてみた
- VueCompostionAPIにおける refとreactiveの使い方について
- Vue3.0で導入されるComposition APIを、いち早く使いこなすためのリンク集