アナログ金木犀

つれづれなるまままにつれづれする

Vue + Vuex + TypeScriptでTODOアプリを作ろうとして、registerModuleとかで試行錯誤した話

この記事は 28日目の DMM.comアドベントカレンダー(?)の記事です。

1ヶ月前くらいから作ってみたいサービスができまして、一旦webだけで、雑なやつでいいやと思い Vue.js + TypeScript でこつこつと作り始めております。 私、普段はAndroidエンジニアをやっているため、Vue.js も TypeScriptも普段触れておらず、ドキュメントを読んだだけの初心者なため、正しいことを書けている自信はないので、その点はご注意くださいm m 「できてへんやんけ、基本的なことが〜〜〜!」と思う箇所が記事中にいくつも出てくるかも知れません。もしそう言うのが耐えれなそうであれば、ここでぜひ離脱を...。

この記事は、一言で言うと「Vuexを導入してみて、動的にモジュールをStoreに追加する方法を試行錯誤してみたときのメモ」です。 ベストプラクティスや、他の方がどうやってるかなどを調べてもうまく見つけられなかったので、ゴリゴリやって見たよ、こんな感じかしら?と言う記事です。 もしベストプラクティスや知見をお持ちの方いらっしゃれば、ご教授いただけると幸いです。

Vuexを使う理由

サービスづくりを開始してみて、ある程度の規模になりそうなことが見えてきたので、 秩序立てた設計が必要だなと思い始めたのが理由の一つです。

Vuex は Vue.js アプリケーションのための 状態管理パターン + ライブラリです。

Vuexはライブラリでありながら、ある程度設計を矯正できるもので、またVue.jsを使う上でメジャーであるため採用しました。

作るTodoアプリのコンポーネント構成

そもそもなぜTodoを作りたかったのかと言うと、作りたいサービスで 動的にモジュールを追加・削除すること が必要になってきたからです。 Todoのサンプル自体は巷にたくさん出回ってるのですが、コンポーネントが1つだったり、モジュールを使ってなかったりで自分の条件を満たすものはありませんでした。

f:id:kgmyshin:20181227172730p:plain

上図の左のようなものではなく、右のように細かくコンポーネントを分けていきます。 このままの仕様であれば、左の構成でも問題ないのですが、例えば一つのTodoに対して、 編集できたり、コメントできたり、リマインダーをつけれるようになったり、さらにサブタスクをつけれるようになってくると耐えれなくなってしまいます。

設計あるいはルール

どのコンポーネントでも、何も考えなくてもスラスラかけるくらいには統一した書き方ができるようなルールを目指しました。 現状は下記で落ち着いてきました。

ComponentとStoreの対応関係

Component一つに、一つのStore(実際はStoreに登録されているModule)を用意する

f:id:kgmyshin:20181227172831p:plain

一つのStoreで複数のComponentを見るようになった場合、コンテキストの違うイベントやStateを持たなければならず、可読性が下がります。 一つのComponetに対して一つのStoreと細かくすることで、Storeを簡潔にすることができます。

f:id:kgmyshin:20181227172848p:plain

Storeの書き方

Storeは https://github.com/ktsn/vuex-type-helper を使って書く

趣味なところもあります。よりTypeScriptな方向に寄せたかったためです。READMEにある書き方をそのまま踏襲しました。

Componentの書き方

先に例を。最終的にこの書き方に落ち着きました。

@Component
export default class NewTodo extends Vue {
  public beforeCreate() {
    const { mapState, mapGetters, mapActions } = createNamespacedHelpers('todos/new');
    this.$options.computed = {
      ...mapState(['body']),
      ...mapGetters(['submittable']),
    };
    this.$options.methods = {
      ...mapActions(['submit', 'update']),
    };
  }
}

一言で言うと コンポーネントでは「どのStore(Module)を使って、どのState, Getters, Actionsを使うかを定義するだけにする」です。

beforeCreatecreateNamespacedHelpers を使って mapXXX を生成してオブジェクトスプレット演算子でcomputedやmethodsに突っ込むのみです。 下記がポイントです。

コンポーネントでローカル変数はなるべく使わない

「Vuex を使うということは、全ての状態を Vuex の中に置くべき、というわけではありません。」

と公式に書かれているのですが、「Vuexに置くべきか否か」の閾値が見えそうになかったので、一旦全てVuexに寄せることにしました。

namespaceに使うものを props で受け取る場合は例外。

関数も定義しない

一旦はどんなユーザーイベントも全てアクションを投げれば良いはず、表示に必要なものはStateやGettersにあるはずという方針でやっていきます。

mapXXXには原則、文字列配列を渡す

Storeは vuex-type-helper 形式で別ファイルに定義しているはずなので、ここで改めて定義することはないはず。

mapMutationsは使わない

MutationへのCommitはコンセプトの図通りActionからだけにしたい。

Storeは別の場所で実装されており、基本的にComponentのHTML部分からは、どのユーザーイベントがどのActionになるかを書くかだけなので、ComponentでMutationにコミットすることは必要ないはず。

beforeCreateでやる理由

あとで詳細について触れますが、propsから取得したものからcreateNamespacedHelpersを使ってnamespaceに含めたいことがあるから です。 (ただ、これは他にやりようあるかもしれないし、できるならば@Component Decorator で記述したい)

もちろん実績がないので、大きな規模になれば例外も出てくるルールだと思いますが、TODOアプリレベルなら全然おさまったし、今回はチーム開発でもないので良しとしました()。 例外もたくさん出てくるでしょうが、それは対面した時に考えていくとして、進めていきます。

悩んだところ

動的にモジュールを追加・削除する

コンポーネントと、それに紐づく Store(Module)の パス は下記のようになっています。

f:id:kgmyshin:20181227172920p:plain

やりたいことはタスクを追加した際に、パスが todos/{タスク5のid} のモジュールが新しくStoreに追加されて、 タスク5のコンポーネントが描画されて他のコンポーネント同様に正しい挙動をすることです。

新しいモジュールを追加する処理は Store#registerModule でできます。 悩んだのはこのメソッドを どこで呼ぶか でした。

一番初めは、下記のように TodoListStore の Mutation の中で呼ぼうかなとうっすら考えてました。

const mutations: DefineMutations<ITodoListMutations, ITodoListState> = {
  add(state, { todo }) {
    state.todos.push(todo);
    store.registerModule('todos/' + todo.id, createTodoModule(todo));
  },
  ...
};

ただ、このままでは storeインスタンスをどこから引っ張ってくるかという問題と、 そもそも、 Mutation は State に対する Mutation なので、 Storeの変更処理もここに書くのは違うかなと思いました。

最終的には Plugin で解決することにしました。 Pluginは Mutation への commitのフックをできることができるものです。

下記のような関数を作り、storeに登録することができます。

const plugin = (store: Vuex.Store<any>) => {
  // 初期化時に存在する todo から TodoStoreを生成して registerModuleする
  state.todos.forEach((todo) => {
    store.registerModule('todos/' + todo.id, createTodoModule(todo));
  });
  store.subscribe((mutation, _) => {
    if (mutation.type === 'todos/add') {
      // 追加時
      const todo = mutation.payload.todo as Todo;
      store.registerModule('todos/' + todo.id, createTodoModule(todo));
    } else if (mutation.type === 'todos/remove') {
      // 削除時
      const todo = mutation.payload.todo as Todo;
      store.unregisterModule('todos/' + todo.id);
    }
  });
};

これを、ルートの Storeにスッと登録することで動きはするのですが、 本来であれば TodoListStore のための Pluginであるはずなのに、それを大元の Storeに置くのはこれいかにと思い、 モジュールにも Pluginを持てるようにすることで解決しました。

正直このやり方はフレームワークを拡張している点が、あまりスマートではないと思っています。 ただし、個人開発だしな...ということで一旦これで良しとしました。

propsから渡したものを使って動的にネームスペースを指定したい

Todoコンポーネントで、todos/{タスクnのid}のパスのStoreを指定する方法を考えていきます。 まずは親の TodoListコンポーネントでidをTodoコンポーネントに渡します。

      <li v-for="todo in todos" :key="todo.id">
        <Todo
          v-bind:id="todo.id"
        />
      </li>

そしてTodoコンポーネントでは、次のように id を元に namespace を作り、各 mapXXX を作ります。

@Component
export default class Todo extends Vue {
  @Prop() private id!: string;
  public beforeCreate() {
    const { mapState, mapGetters, mapActions } = createNamespacedHelpers(`todos/${this.id}`);
    this.$options.computed = ...
  }
}

このように書きたかったのですが、このコードは動きません。 まず id には beforeCreate の段階ではまだ値が入っていません。 また逆に、created() では this.$optionscomputed などを差し込むことができません。 「あ、詰みかな...」と思ったのですが、同様のことをやりたい人はそこそこいるらしく、関連したissueがありました。

github.com

ありがたいことに、このissueにてktsnさんから解決策が提示されております。

{
  props: ['namespace'],

  computed: mapState({
    state (state) {
      return state[this.namespace]
    },
    someGetter (state, getters) {
      return getters[this.namespace + '/someGetter']
    }
  }),

  methods: {
    ...mapActions({
      someAction (dispatch, payload) {
        return dispatch(this.namespace + '/someAction', payload)
      }
    }),
    ...mapMutations({
      someMutation (commit, payload) {
        return commit(this.namespace + '/someMutation', payload)
      })
    })
  }
}

このように、関数実行時に namespace を取得するようにすれば(その時点では propsは取得できてるので)いけるよとのことのようです。 初めはこのやり方でやっていたのですが、書くこと多いな...と思い始めて、createNamespacedHelpersのコードを参考にしながら、 namespaceを返す関数 を引数に受け取るヘルパーを作成しました。 ヘルパーを通して見栄えをよくしてるだけで、実質的にはktsnさんのソリューションとそんなに変わらないです。 こんな感じです。

export default class Todo extends Vue {
  @Prop() private id!: string;
  public beforeCreate() {
    const { mapState, mapGetters, mapActions } = createNamespacedFnHelpers(() => `todos/${this.id}`);
    this.$options.computed = ...
  }
}

使うヘルパー関数が違うだけで、props から namespace を作るときは createNamespacedFnHelpers を、 固定値の場合は createNamespacedHelpers を使うという簡単なルールなので、迷うこともないでしょう。

見比べるように、固定値で namespace 指定バージョンのコンポーネントも置いておきます。

@Component
export default class NewTodo extends Vue {
  public beforeCreate() {
    const { mapState, mapGetters, mapActions } = createNamespacedHelpers('todos/new');
    this.$options.computed = ...
}

できた!

f:id:kgmyshin:20181227173007g:plain

リポジトリはこちら

ちゃんと学んで整理したあとに公開したかった気持ちもあるのですが、やっぱり見れないと意味なと思うので一応置いておきます

github.com