VuexをVue Composition APIで実装する!【NoVue】

7 min read読了の目安(約7000字

こんにちは。shundroidです。初めてZennを書きます。
大学の授業が始まって早1週間。
最初はガイダンスばかりですが、だんだん大学の授業の面白さが分かってきました。

さて、今回は、vue-composition-apiを使っていきたいと思います。
これが凄いんですよ。
おそらく、最初のモチベーションとしては、肥大化したViewModelを整理する、ということだったと思うんですが、
おなじみの watch や、リアクティブな変数を生成する reactive などをコンポーネントから分離して利用できるようになったことで、後で詳しく述べますが、幅広く使える可能性を備えていると思います。

特に、これはデータバインドを活用したアーキテクチャを設計し、プロトタイプを作るのにも向いていると考えたので、
ここでは、試しにVuexをvue-composition-apiを使って実装してみたいと思います!

Composition API の基本的な機能

詳しくは、Vue.jsの公式ドキュメントが分かりやすくて、APIが作られた背景までわかるようになっているので、そちらを見てください。

https://v3.ja.vuejs.org/guide/composition-api-introduction.html

今回特に使う機能を紹介していきます。

ref

リアクティブな変数を生成します。似たものに reactive というのがありますが、こちらは値がオブジェクトの時に使えて、 .value を省略できます。

コードの説明はTypeScriptで行っていきます。

import { ref, Ref } from '@vue/composition-api';
// (予めVueにComposition APIを登録する必要はある:後のstore.ts参照)

// どういう型になるか説明するために、型指定を行っておきます。
const num1: Ref<number> = ref(0);
const num2 = num1;

console.log(num1.value, num2.value); // 0, 0
num2.value++;
console.log(num1.value, num2.value); // 1, 1

これだと、ただ参照値を渡しているだけで、例えば、次のようにしても良いのではないかと思うかもしれません。

const num1 = { value: 0 };
const num2 = num1;

ですが、 ref の強みは、Vueの便利なリアクティブシステムに値するものがすべて使えるところにあります。
watchcomputedなどです。これを次に説明します。

watch

Vueでおなじみのものと同じです。

import { ref, watch, Ref } from '@vue/composition-api';

const num = ref(0);

watch(num, () => {
  console.log('changed!');
});

num.value--; // changed!

こういった機構を簡単に設計できるのです。
Vueのコンポーネントシステム以上の可能性を感じますよね。

おまけ:computed

今回は使いませんでしたが、 computed もシンプルに使えます。

import { ref, computed, Ref } from '@vue/composition-api';

const num1 = ref(0);
const num2 = computed(() => num1.value + 1);

console.log(num1.value, num2.value); // 0, 1
num1.value++;
console.log(num1.value, num2.value); // 1, 2

すごいなあ。
こういうプログラムが普及すると、プログラムは上から実行されるんだよという説明が難しくなってきそうですよね。
もちろん間違ってはいないですが、このようなcomputed内のコールバックが実行される順番等を踏まえると、もっと別の意味づけのほうが適切なのではないか、と思えてしまいます。

プログラミング言語の命令型→宣言型への移行が垣間見える気がします。
個人的には、データフロープログラミングに興味があるので、この流れは賛成です。

computedが内部的にどのようにコールバックの実行タイミングを見極めているか、については、下の記事が分かりやすかったです。一旦中身を実行してみて、どのリアクティブ変数が取得されたか、を記録しているようですね。

https://qiita.com/neutron63zf/items/506c7493a53cea44860e

条件分岐などがcomputed内であった場合は、一旦実行するのみでは足りないのではないか、と思いましたが、条件自体がリアクティブだとこれで足りそうですね。よくできてるなあ。

VuexをNo Vueで実装していく

ここまでで Composition API の基本的な機能を見てきました。
Vueのコンポーネントに止まらず、もっと汎用的な使い道があるということがわかりました。

というわけで、実際の応用例として、Vuex的アーキテクチャを実装してみましょう。

今回はTodoアプリを作っていきますが、
あまり本質的でない部分は端折ります。
Actionはごめんなさい、さようなら。
そして本当はAction呼び出しは疎結合に行われますが、これはあまりメリットがないので、今回はMutation直呼びで代用します。

store.tsの実装

/store.ts
import Vue from 'vue';
import VueCompositionAPI, { DeepReadonly, reactive, readonly, ref, Ref, UnwrapRef } from '@vue/composition-api';
type UnwrapNestedRefs<T> = T extends Ref ? T : UnwrapRef<T>;
Vue.use(VueCompositionAPI);

interface TodoItem {
  title: string;
  completed: boolean;
}

interface State {
  todoList: Ref<TodoItem[]>;
}

const state: State = {
  todoList: ref([])
};

// mutations
export function addTodo(title: string) {
  state.todoList.value.push({ title, completed: false })
}

const readonlyState: { [P in keyof State]: DeepReadonly<UnwrapNestedRefs<State[P]>> } = {
  todoList: readonly(state.todoList)
}
export default readonlyState;

Composition APIを使うために、仕方なくVueを呼び出していますが、
あくまでVueをアプリケーションで使うわけではないです。

stateの定義に ref を用いています。これは先ほど紹介した通りです。
本当は、Model部分はViewに影響されず、純粋なモデルにするべきですが、リアクティブ化する程度ならばいいかと思ってしまいました。どうですかね。
あと、 reactive([]) では配列はリアクティブになりません。注意してください。

mutationは普通にstateを書き換えています。

そして、ここが肝なのですが、
stateをexportするときに、stateをreadonlyにしています!!!!!
実は、ライブラリのほうのVuexはstateに双方向データバインドがかかっていて、
Mutationを介さずにバインドによってstateが書き換えられてしまうのですが、
これはどう考えてもbadなので、今回はComposition APIの機能をフル活用して
readonlyにしています。

readonlyStateの型ですが、Composition API内部で使われている型を拝借しました。
[P in keyof Type] というのは、Mapped TypesというTypeScriptの機能です。反復処理の型バージョンみたいなやつです。
DeepReadonlyでは、 T extends U ? A : B という、型バージョンの条件分岐を使っているようです(参考:API内部)。
どうしてここまでして型を組みたいかというと、こうすることで、readonlyStateの値に関して保証ができるからです。
つまり、stateにあるのにreadonlyStateにない!とか、readonlyになっていない!とかいうことが、TypeScriptのエラーとして把握できます
このように、ただ型がつくと嬉しい!楽しい!というだけでなく、開発上のミスが無いようにするために型を使うべきです。この使い方は never 型などでよくします。

個々のコンポーネントの実装

まずはインターフェース実装。

/components/component.ts
export default interface Component {
  render(): HTMLElement
}

Todoを追加するやつ。

/components/todoList.ts
import Component from './component';
import state from '../store';
import { watch } from '@vue/composition-api';

export default class TodoList implements Component {
  render() {
    const ul = document.createElement('ul');
    watch(state.todoList, () => {
      ul.innerHTML = '';
      for (let todoItem of state.todoList.value) {
        const li = document.createElement('li');
        li.textContent = `${todoItem.title}`
        ul.appendChild(li)
      }
    });
    return ul;
  }
}

VuexなのにtextContentとかdocumentとか出てきてて草
今回は、Composition APIの汎用性を説明したかったので、
バニラでも十分役立つんだ! ということを説明したくてこうしています。

もちろん、バリバリVue使って実装してもらってもいいです。

ここでは、state.todoListwatchしています。
さすがに直DOMに流すのはリアクティブにはできないので仕方ない。
また、ご確認いただけると幸いですが、state.todoListはしっかりreadonlyになっているので、Viewからは改変できません!わーい
じゃあViewからstateを変えるにはどうするの?というのが次のコンポーネントになります。

/components/addTodo.ts
import Component from './component';
import { addTodo } from '../store';

export default class AddTodo implements Component {
  render() {
    const parent = document.createElement('div');
    const input = document.createElement('input');
    input.type = 'text';
    parent.appendChild(input);
    const button = document.createElement('button');
    button.textContent = 'Add a task';
    button.addEventListener('click', () => {
      addTodo(input.value);
      input.value = '';
    });
    parent.appendChild(button);
    return parent;
  }
}

mutationをimportして使っています。

エントリーポイント

ここはあまり大事ではないですが、コンポーネントを登録(物理)します。

/main.ts
import AddTodo from './components/addTodo';
import TodoList from './components/todoList';

const app = document.querySelector('#app');
app.appendChild(new AddTodo().render());
app.appendChild(new TodoList().render());

Vueが完全に隠れていて気持ちがいいですね。

出来上がったものがこちらになります。

冷凍庫で3時間冷凍したものがGithubにあります。

https://github.com/shundroid/vuex-no-vue-sample/

実装にはViteを用いました。
速いですね。今風でいいですね。webpackもそろそろ引退かな。

デモはこちらで試すことができます。

まとめ

いかがでしたでしょうか。Composition APIの汎用性が伝われば幸いです。
個人的には、アーキテクチャの実装等に興味があるので、
データバインドを活用したアーキテクチャのプロトタイプ作成などにどんどん使っていこうかと思います。

※この記事は僕のブログを移植して書いたものです。