redux-workerized で Redux と Vue を接続する

mizchi/redux-workerized

作った。 yarn add redux-workerize で入る。

元々は react-hooks で redux へのアダプタを書いていただけのライブラリだったが…

  • TypeScript フレンドリーなAPIにする
  • ReactRedux.Provider の異なるAPI表現だけじゃ面白くない
  • じゃあ Redux.Store を worker に置いて postMessage で更新しよう
  • mapStateToProps や更新処理の抑制の処理もCPU使うから、worker に置こう
  • JSON飛び交ってるだけだし、 React だけじゃつまらないから Vue Plugin も提供しよう

結果、ビジネスロジックが Worker に切り出された上で、 React と Vue が同じ Store を共有するようになった。

どうなってるかというと

Worker が redux を抱えて、それぞれに Snapshot を切り出し、Snapshot を購読する各 Component がいる、という感じ

コード

import "@babel/polyfill";
import { RootState, increment, Increment, INCREMENT } from "./reducer";

const worker = new Worker("./worker.ts");

// React

import React, { useCallback } from "react";
import ReactDOM from "react-dom";
import { createWorkerContext } from "redux-workerize/react";

const { WorkerContext, useSnapshot, useDispatch, ready } = createWorkerContext<
  RootState,
  { value: number }
>(worker, async (state: RootState) => {
  return state.counter;
});

function CounterApp() {
  const value = useSnapshot(state => state.value);
  const dispatch = useDispatch<Increment>();

  const onClick = useCallback(() => {
    dispatch(increment());
  }, []);

  return <button onClick={onClick}>{value}</button>;
}

ready.then(() => {
  ReactDOM.render(
    <WorkerContext>
      <CounterApp />
    </WorkerContext>,
    document.querySelector(".react-root")
  );
});

// Vue
import Vue from "vue";
import Vuex from "vuex";
import App from "./App.vue";
import { workerPlugin, proxy, SYNC } from "redux-workerize/vue";

Vue.use(Vuex);

type CounterSnapshot = {
  value: number;
};

export type State = {
  remote: CounterSnapshot;
};

const store = new Vuex.Store<State>({
  state: {
    remote: {
      value: 0
    }
  },
  mutations: {
    [SYNC](state, payload: CounterSnapshot) {
      state.remote = { ...state.remote, ...payload };
    },
    ...proxy([INCREMENT])
  },
  plugins: [
    workerPlugin(
      worker,
      (state: RootState): CounterSnapshot => {
        return state.counter;
      }
    )
  ]
});

new Vue({
  store,
  el: ".vue-root",
  render(h) {
    return h(App);
  }
});

worker が Redux.Store を持つ実体で、フレームワークごとにラップするとその State を購読できる。第二引数で与える selector と snapshot は Worker 側で実行される。どうせWorkerを超えて非同期なので、非同期でも取れるようになってる。

React は hooks を使ってるので 16.7.0-alpha.0 以上じゃないと動かない。

Vue から直接 Redux 使うわけには行かないので Vuex を通してアクセスするようにした。Vuex.Store が ReduxStore の Snapshot を購読している。Redux が主なのは、型付けしやすいのと、Redux にはフレームワーク依存がないため。自分の好みといったらそう。

やってみたが、たぶん Vue Way ではないと思う。

Worker 側

import * as Comlink from "comlinkjs";
import { createStore } from "redux";
import reducer from "./reducer";
import { createWorkerizedStore } from "redux-workerized/worker";

const store = createStore(reducer);
const storeAPI = createWorkerizedStore(store);

Comlink.expose({ ...storeAPI }, self);

好きなように Redux.createStore して、createWorkerizedStore でラップして、 Comlink でクライアントに向けてAPIを公開する。

この結果、 Vuex から間接的に Redux Middleware を使える状態になっている。

やってみた感想

Worker でやってるのは、パフォーマンス目的もあるが、DOMに触れないという矯正ギプス的な意味のが大きい。

React と Vue でコミュニケーションできるようになったのは、単に結果であって、Worker に 共通の Store があるという設計に意味がある。

SerivecWorker でも動くので、複数タブでの状態の共有なんかにも使えるかもしれない。

本筋と関係ないが、Vue/Vuex の API の TypeScript との相性の悪さが絶望的で辛かった