Help us understand the problem. What is going on with this article?

vue-composition-apiを使って機能単位のコード分割と合成を試してみた

概要

この記事では、Vue.js ver 3.0 で採用予定の Composition API (https://vue-composition-api-rfc.netlify.com) を使って、このRFCの動機のひとつである「Code Organization」がどのようにして実現可能なのかを試しに実装してみた結果をまとめました。
はじめに100行超の単一のSFCでデモアプリを実装し、そのあとに「Function -> Module -> Component」の順番で機能単位での分割を進めた前後のコードを比較しています。
Composition APIでどのように実装すべきかを模索されている方にとっての参考になれば幸いです。

https://vue-composition-api-rfc.netlify.com/#logic-reuse-code-organization

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を深掘りしたいという思いが強くなりました。

バージョン

デモアプリ

composition api sample demo.gif

デモアプリの機能は以下の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の reactivecomputed のみ利用しています。

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で対応しています。
特に useFilterdoingTaskscompletedTasksuseSearchersearch と合成しており、画面では「ステータス毎の表示分割」と「インクリメンタルサーチ」が両立できています。

    const { doingTasks, completedTasks } = useFilter(search); 

特に初めの実装では doingTaskscompletedTasksstate.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.vueTaskRow.vue に対して、外部Moduleとして分割された composables によって返されるFunctionやリアクティブなref化したオブジェクトをそのまま直接渡しているだけです。
すなわち、Composition APIによる合成とは、「親Componentが子ComponentのpropsとしてリアクティブなオブジェクトやFunctionを渡してまとめること」であると解釈しました。
これが Composition API による成果と言えるのではないでしょうか。

今後のきになるところ

「reactive」 vs 「ref」

他の方のブログ記事や実装を見ていて気になったのは、人によって reactiveref をどちらを積極的に使うかが別れており、ここが Composition API での重要な論点かなと思います。
今回は振る舞いのみを抽出したModuleやComponent内で ref を活用し、 reactive は途中で使わなくなりました。
実装した感触としてはとても手に馴染んだのですが、ここについては意見の分かれそうなところですのでもう少し様子を見たいところです。

結局Storeは必要になるのか

今回は分割を進めた結果、 状態が各Module・Component内に分散する結果となりましたが、これが中規模・大規模アプリであった場合、はたしてこのような実装方法でも状態管理の秩序を保つことができるのかに興味があります。
Composition APIは reactiveref といった機動力のあるリアクティブ化の手段を提供しますが、システムの大規模化・複雑化の前ではやはりStoreパターンでの中央集権的な状態管理の仕組みの導入が避けられなくなってしまうのでしょうか。

TypeScript化

今回は単にVue.jsとJavaScriptのみで実装しましたが、Composition API は「プレーンな変数と関数での実装が可能になる」ことと「thisへの依存がなくなる」ためにTypeScriptフレンドリーであるとのことなので、次はこのデモアプリでのTypeScript化を試したいと考えています。

終わりに

Composition APIによるデモアプリのコード分割と合成を試みた結果をまとめました。
この記事を読まれた方にとって少しでもお役に立てれば嬉しいです。

明日の記事は @calorie さんによる公開予定です。どうぞお楽しみに。

参考リンク

s_nagasawa
https://github.com/snagasawa https://twitter.com/sh_ngsw
zozotech
70億人のファッションを技術の力で変えていく
https://tech.zozo.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした