react-redux と useContext を組み合わせて使う実験

react-redux v6 で中身が新しい方の Context API になったと聞いたので、コード読んでみたらContext自体が外部に export されていた。じゃあ hooks の useContext と組み合わせて使えるじゃん、と思って実験してみた。

useContext で connect 相当の処理を置き換えてみる。

https://github.com/reduxjs/react-redux/blob/master/src/index.js

import React, { useContext } from "react";
import ReactDOM from "react-dom";
import { createStore, Store } from "redux";
import { ReactReduxContext, Provider } from "react-redux";

type State = {
  count: number;
};

// いつもの reducer
const initialState: State = { count: 0 };

function reducer(state: State = initialState, action: any) {
  switch (action.type) {
    case "reset": {
      return initialState;
    }
    case "increment": {
      return { count: state.count + 1 };
    }
    case "decrement": {
      return { count: state.count - 1 };
    }
    default: {
      return state;
    }
  }
}

function Counter() {
  // useContext で store を取り出す
  const {
    storeState,
    store: { dispatch }
  } = useContext(ReactReduxContext);
  const counterValue = storeState.count;
  return (
    <div>
      Count: {counterValue}
      <button onClick={() => dispatch({ type: "reset" })}>Reset</button>
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
    </div>
  );
}

function App(props: { store: Store<State> }) {
  return (
    <Provider store={props.store}>
      <Counter />
    </Provider>
  );
}

const store: Store<State> = createStore(reducer);
ReactDOM.render(<App store={store} />, document.querySelector(".root"));

これで動いた。が、いくつか問題がある。

react-redux の connect は map された値の変更を監視していて、HOCが自分に関係ない値の更新処理を抑制してくれていた。だが、この素朴に useContext している版は、 store のすべての値に反応してしまう。

useContext した時点ですべての更新を listen してしまうので、結局これ自身が子への抑制処理を行う必要がある。

色々頑張った結果、こうなった

import React, { useContext, useCallback } from "react";
import ReactDOM from "react-dom";
import { createStore, Store, Dispatch, AnyAction } from "redux";
import { ReactReduxContext, Provider } from "react-redux";

// helper
function useDispatch(): Dispatch<AnyAction> {
  const {
    store: { dispatch }
  } = useContext(ReactReduxContext);
  return dispatch;
}

function useConnect<T, U>(fn: (t: T) => U): U {
  const { storeState } = useContext(ReactReduxContext);
  return fn(storeState);
}

type State = {
  count: number;
  resetCount: number;
};

const initialState: State = { count: 0, resetCount: 0 };

function reducer(state: State = initialState, action: AnyAction) {
  switch (action.type) {
    case "reset": {
      return { ...initialState, resetCount: state.resetCount + 1 };
    }
    case "increment": {
      return { ...state, count: state.count + 1 };
    }
    case "decrement": {
      return { ...state, count: state.count - 1 };
    }
    default: {
      return state;
    }
  }
}

const CounterInner = React.memo(function CounterInner(props: {
  counterValue: number;
  onClickReset: () => void;
  onClickIncrement: () => void;
  onClickDecrement: () => void;
}) {
  console.log("update counter inner");
  return (
    <div>
      Count: {props.counterValue}
      <button onClick={props.onClickReset}>Reset</button>
      <button onClick={props.onClickIncrement}>+</button>
      <button onClick={props.onClickDecrement}>-</button>
    </div>
  );
});

function Counter() {
  console.log("update counter", Date.now());

  const dispatch = useDispatch();
  const counterValue = useConnect<State, number>(state => state.count);
  const onClickReset = useCallback(() => dispatch({ type: "reset" }), []);
  const onClickIncrement = useCallback(
    () => dispatch({ type: "increment" }),
    []
  );
  const onClickDecrement = useCallback(
    () => dispatch({ type: "decrement" }),
    []
  );
  return (
    <CounterInner
      counterValue={counterValue}
      onClickReset={onClickReset}
      onClickIncrement={onClickIncrement}
      onClickDecrement={onClickDecrement}
    />
  );
}

function App(props: { store: Store<State> }) {
  return (
    <Provider store={props.store}>
      <Counter />
    </Provider>
  );
}

const store: Store<State> = createStore(reducer as any);
ReactDOM.render(<App store={store} />, document.querySelector(".root"));

Counter は自分自身で element の詳細を生成せず、CounterInner に props を渡す。その際の関数は、useCallback を使ってメモ化する。

CounterInner 側では React.memo を使って shallow equal の比較によって更新処理を抑制している。CounterInner は count に興味があるが、 resetCount には興味がない。reset を連打した際は CounterInner の更新が行われないようにすることができた。

感想

やってみたけど、メモ化を駆使して自力でuseContextでとった値の更新をコントロールするのは結構だるい。useContext に mapState 相当の処理がほしい。

たぶん react-redux の大きなグローバル変数を持つみたいな考え方じゃなくて、connect される Context を都度生成する、みたいな発想の転換が必要な気がする。もうちょっと研究が必要。

Edge 終了に寄せて

初報を聞いたとき、描画系だけ blink に入れ替えて処理系は V8 使わず ChakraCore などに入れ替えるのかな、と思っていたが、どうも V8、というか chromium 一式を使うらしい。

正直に言って、Edge が死ぬことに、そこまで強く思うところはない。Edge は内部的に自身のベンダープレフィックスwebkit と名乗るぐらい (標準ではなく) webkit との互換性の意識が高いので、お前自分のことを webkit だと思ってるもんな、みたいな気持ちがあった。

webkit みたいなブラウザが消えて、webkit 後継のブラウザをベースに作り直される。開発者として追うべきは、個別の実装ではなく依然として標準仕様であって、それだけの話。

リリースサイクル

僕が思うに、 MS の抱えていた真の問題は、Windows に紐付けられたリリースサイクルとサポートにあって、Windows Update によって環境を破壊されるんじゃないか、というユーザーの不信を生んでしまったことにある。とくにタブブラウザ化した IE7 の不出来は時代を大きく変えた潮目の一つだと思っていて、自動更新をオフにすることが情報強者だ、といった風潮を作ってしまった。

現状、Web の進化のスピードは、IE のサポートが順次切れるスピードで律速となっている。EdgeHTML から Chromium に移ることで、すべての現行ブラウザが事実上ローリングアップデートを採用することになるだろう。その点は歓迎できる。

Chromium の成功した理由の一つは、バックグラウンドの自動アップデートと、それを気づかせない後方互換性の担保と、それが可能な品質にある。なんか最近は急に UI がまるっこくなったりしたが、その程度だ。

仕様策定について

実装の多様性が失われることで仕様に与える影響については他の人が言ってる通り、Google の発言力が強くなりすぎることを懸念する。あまりに UI というものを規定しすぎる WebComponents の初期仕様や、ブラウザに Dart を採用する Dartium は採用されなかったが、今後誰が止められるだろうか。とりあえず今は Mozilla なのだろうが…

これは、Chrome が標準に準拠するか否かではなく、Chromeが標準を私物化しはしないか、という懸念。

今後は Desktop/AndroidChrome と、iOSSafari という対立に収斂されると思われるが、iOSChrome を受け入れるか、Safari が Web の仕様から乖離したときが真に Web というものが試されるときだと思う。

本音を言うと、一開発者としては、優秀なエンジンの寡占こそが、短期的には一番「楽」な状況ではあるが、これが IE の二の舞にならないかというと、その自信は全くない。Chromeで動けばよいみたいなトレンドが生まれてしまうのは、誰がどんな高尚なことを言おうが、状況が生む必然だと思う。水は低きに流れる。

Webpack の考え方について

www.slideshare.net

この記事バズってたけど、わからない人がよりわからなくなる、という点で問題だと思っていて、webpack の目的の本質的な部分から整理する必要があると思います。

(あと友人が webpack に挑戦していたので入門資料も兼ねてる)

Webpack の本質的な部分は次の3つです。それ以外は全部おまけ機能だと思ってよいです。

  • ES Modules のエミュレート
  • node_modules のリンカ
  • 拡張子ごとの変形

Webpack が本当にやりたいこと

こういうコードがあるとします。

// src/a.js
export default () => console.log('hello');
// src/index.js
import a from './a.js'
a();

このコードは、今現在 IE 以外のブラウザで既に実行可能ですが、IE と、あと Google のクローラ は Chrome41相当となっていて、実行できません。また現行のブラウザのサポートがあると言っても、とりあえず実行できるという程度で、パフォーマンスはよくありません。(ネットワーク越しに一個一個ダウンロードして依存を判明させるのを想像してみてください)

とりあえず、これを node 環境を使って実行してみましょう。

$ npm install esm --save-dev
$ node -r esm  src/index.js
hello

(esm は node で import/export を有効化するおまじないと思ってください)

ブラウザで実行する際は、これらを一つのファイルに結合します。つまりこの import/export を使わない形式に書き換えた上で結合する必要があります。

$ npm install --save-dev webpack webpack-cli
$ npm run webpack --mode development

これは次のようなコードを出力します。(全部読まなくていいです。雰囲気で)

/******/ (function(modules) { // webpackBootstrap
/******/    // The module cache
/******/    var installedModules = {};
/******/
/******/    // The require function
/******/    function __webpack_require__(moduleId) {
/******/
/******/        // Check if module is in cache
/******/        if(installedModules[moduleId]) {
/******/            return installedModules[moduleId].exports;
/******/        }
/******/        // Create a new module (and put it into the cache)
/******/        var module = installedModules[moduleId] = {
/******/            i: moduleId,
/******/            l: false,
/******/            exports: {}
/******/        };
/******/
/******/        // Execute the module function
/******/        modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/        // Flag the module as loaded
/******/        module.l = true;
/******/
/******/        // Return the exports of the module
/******/        return module.exports;
/******/    }
/******/
/******/
/******/    // expose the modules object (__webpack_modules__)
/******/    __webpack_require__.m = modules;
/******/
/******/    // expose the module cache
/******/    __webpack_require__.c = installedModules;
/******/
/******/    // define getter function for harmony exports
/******/    __webpack_require__.d = function(exports, name, getter) {
/******/        if(!__webpack_require__.o(exports, name)) {
/******/            Object.defineProperty(exports, name, { enumerable: true, get: getter });
/******/        }
/******/    };
/******/
/******/    // define __esModule on exports
/******/    __webpack_require__.r = function(exports) {
/******/        if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/            Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/        }
/******/        Object.defineProperty(exports, '__esModule', { value: true });
/******/    };
/******/
/******/    // create a fake namespace object
/******/    // mode & 1: value is a module id, require it
/******/    // mode & 2: merge all properties of value into the ns
/******/    // mode & 4: return value when already ns object
/******/    // mode & 8|1: behave like require
/******/    __webpack_require__.t = function(value, mode) {
/******/        if(mode & 1) value = __webpack_require__(value);
/******/        if(mode & 8) return value;
/******/        if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
/******/        var ns = Object.create(null);
/******/        __webpack_require__.r(ns);
/******/        Object.defineProperty(ns, 'default', { enumerable: true, value: value });
/******/        if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));
/******/        return ns;
/******/    };
/******/
/******/    // getDefaultExport function for compatibility with non-harmony modules
/******/    __webpack_require__.n = function(module) {
/******/        var getter = module && module.__esModule ?
/******/            function getDefault() { return module['default']; } :
/******/            function getModuleExports() { return module; };
/******/        __webpack_require__.d(getter, 'a', getter);
/******/        return getter;
/******/    };
/******/
/******/    // Object.prototype.hasOwnProperty.call
/******/    __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
/******/
/******/    // __webpack_public_path__
/******/    __webpack_require__.p = "";
/******/
/******/
/******/    // Load entry module and return exports
/******/    return __webpack_require__(__webpack_require__.s = "./src/index.js");
/******/ })
/************************************************************************/
/******/ ({

/***/ "./src/a.js":
/*!******************!*\
  !*** ./src/a.js ***!
  \******************/
/*! exports provided: default */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony default export */ __webpack_exports__[\"default\"] = (() => console.log('hello'));\n\n\n//# sourceURL=webpack:///./src/a.js?");

/***/ }),

/***/ "./src/index.js":
/*!**********************!*\
  !*** ./src/index.js ***!
  \**********************/
/*! no exports provided */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _a_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./a.js */ \"./src/a.js\");\n\nObject(_a_js__WEBPACK_IMPORTED_MODULE_0__[\"default\"])();\n\n\n//# sourceURL=webpack:///./src/index.js?");

/***/ })

/******/ });

前半のコメント付きの部分が webpack 自体のランタイムで、あとは __webpack_require__ によって、単なるパスとそれに対応する辞書が組み立てられ、素朴な関数として実行されているのがわかります。

webpack は本当に、ただこの結合をやりたいだけ、というライブラリです。

(ここで一つ暗黙的な知識を使っていて、無設定の webpack は src/index.jsdist/main.jsコンパイルするようになっています)

node_modules の解決

上の例は素朴な相対パスの解決でしたが、これだけだと、例えば lodash みたいなnode_modulesから参照するライブラリは解決できません。

node の慣習だと、これは const _ = require('lodash') とすると、node_modules/lodash/package.jsonmain に指定されたモジュールが手に入りますが、webpack はこの名前解決ルールをエミュレーションしています。

index.js を次のようにします

// npm install lodash --save してから
import { flatten } from 'lodash';
console.log(flatten([[1], [2]]));
$ node -r esm src/index.js
[1, 2]

そうすると、ビルド済みファイルの一部がこんな感じ

/***/ "./node_modules/lodash/lodash.js":
/*!***************************************!*\
  !*** ./node_modules/lodash/lodash.js ***!
  \***************************************/
/*! no static exports found */
/***/ (function(module, exports, __webpack_require__) {
// 以下実装

require と ESM の import/export の関係もまたややこしいのですが、とりあえず今は import/export だけ使うようにしてればいいはずです。

transform

webpack 環境下では、実際には plane な js を書くことはなく、babel か typescript を使うことが多いと思います。babel-loader を使うと .babelrc を参照するし、ts-loader を使うと tsconfig.json を参考にします。

これを実現するのが module.rules で、babel を使うだけだったら単にこれだけです。

//webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: "babel-loader",
        exclude: /node_modules/
      }
    ]
  }
};

test の正規表現でヒットした rule が、 use で指定された babel-loader を通して(その過程で .babelrc の設定を参照して) .js を変形します。

基本的には「拡張子ごとに特定のloader を通して変形する」という考え方でいいと思います。バンドラーがトランスフォームまで行うのは、これらを役割を兼ねた方がパフォーマンスがよくなり、結果としての設定も少なくなる、という webpack 以前に使われていた browserify からの伝統です。

細々とした最適化

webpack を使っていて、これら以外は基本的に学習者にとっての「ノイズ」に映ることと思います。具体的には、他にこのようなことをやるわけです。

  • 複数のリソースから共有できるビルドステージを作る
  • インメモリ上に展開
  • 最終出力をどう最適化するか(uglifyjs)、どう出力パスを決定するか(webpack-manfiest-plugin)
  • SPA 用のエントリポイントを生成 (html-webpack-plugin)
  • JavaScript 以外、 css を js から読み込んだり(style-loader/css-loader), 画像を base64 化したり…
  • 未使用モジュールの削除

このうち、特に js 以外を扱う操作が曲者だと思っていて、「ES Modules をエミュレートしたい」というwebpack本来のスコープに含まれていません。その結果、実現方法がどれも歪です。

Webpack が嫌がられるのは、これらを覚えることでプログラミングの深淵に迫れるわけでもなければ、他に転用できる知識があるわけではない点です。単なる設定の組み合わせにすぎません。だるいですね。

要約

  • JS を ESM に沿った形で変形/結合するのは、本来の目的に沿うので OK
  • それ以外は Webpack 以外での実現も考えてはどうか

難しい(ややこしい)部分は大抵後者なので、触れずに済むならそれでいいはずです。

style-loader + css-loader 程度だったらライブラリのJSと協調するCSS引くのに有用だけど、これに scss や postprefixer とかはいれたくないな、というのが個人的な気持ち。

将来的に Webpack を捨てられるか

Webpack を捨てるのに、大きなブロッカーが3つあります

  • IE
  • 使ってる npm モジュールが ES Modules の形式で配布されるか
  • JSX/TypeScript のような拡張構文

IEはまあ死んでもらうのをまつとして、IEが死んだあとに素の ESModules を使えるかどうかは、使ってるライブラリ次第です。lodash や react などは一応対応していますが、現状作者側にとっても対応の敷居が高いので、対応は後回しになりがち。(rollupを使う)

むしろ、将来的に難しいのは TypeScript の問題で、最近のトレンドだと、もはや JS = TypeScript になりそうなぐらいの勢いですが、ただし当然のごとく非標準な文法なので、依存してしまうと捨てるのが難しいのではないか、という気がしています。同様の理由で非標準の JSX も厳しい気がします。

一応今tc39にプロポーザルが出ているのですが、ジェネリクスみたいなセマンティクスの議論の余地がある文法も含んだ提案が、すんなり通るとは思えません。

https://github.com/samuelgoto/proposal-optional-types

プリコンパイル一回するなら二回するのも中間生成物があるのは変わらないし、手間はそう変わらない、ということで、 Webpack + TypeScript or Babel がしばらく生き残ってしまうのではないでしょうか。

おまけ: Parcel

webpack の alternative として parcel があります。

https://github.com/parcel-bundler/parcel

webpack ユーザー的な解釈だと、便利なデフォルト設定集なんですが、実際には webpack の慣習やお気持ちに深く精通して初めて parcel の挙動が理解できる、という感じが強いです。 その結果 webpack を完全に理解した仙人なら設定をサボるのに parcel を使うことが出来る、という状態になっています。勉強したくないからこれで!とやると、おそらく何が起こるのか、その理由がまったくわからなくなって、結果的に辛い感じになると思います。

WebComponents: ReactNative.View のような CSS の既定値を持つだけの x-view を作ってみる

ReactNative 触った事ある人なら、ReactNative.View の iOS の UIView や Android View みたいな一貫性のある基底クラスが羨ましい人も多いと思う。 今の Webでは実質 div がそれを担っているわけだが、div になんでも押し付けるのはよくないという意見もわかるところがあり、それ専用の基底となる x-view 要素を作るとどうなるだろうか。

ほしいもの

  • デフォルトが display: flex;
  • デフォルトが box-sizing: border-box;

flex は便利だが Web のプリミティブ要素として既定値でflex を持つものはないので、毎度手数が多くなって嫌だなぁぐらいの気持ちがあった。

実装

とりあえず ReactNativeWeb がどう実装してるか真似しつつ実装した

const defaultViewStyle = document.createElement("style");
defaultViewStyle.textContent = `
  :host {
    border-width: 0;
    border-style: solid;
    box-sizing: border-box;
    display: flex;
    flex-direction: column;
    align-items: stretch;
    margin: 0;
    padding: 0;
    position: relative;
    z-index: 0;
    min-height: 0;
    min-width: 0;
  }
`;

customElements.define(
  "x-view",
  class extends HTMLElement {
    constructor() {
      super();
      const shadow = this.attachShadow({ mode: "open" });
      shadow.appendChild(document.importNode(defaultViewStyle, true));
      shadow.appendChild(document.createElement("slot"));
    }
  }
);

children を展開する slot を持ち、自分自身(:host)にのみ適用するstyleを持つ。div を継承せず直接 HTMLElement の子要素になってる

style は毎度インラインのHTMLから生成するより、style を clone したほうが速いかも?と思ってこうしてるが、特に根拠はない。どうするのがいいんですかね。

これでこういうHTMLが動くようになる

<x-view style='height: 200px;'>
  <x-view style='flex: 1'>a</x-view>
  <x-view style='flex: 2'>b</x-view>
</x-view>

デフォルト display: flex なので flex 値の付与だけでいい。(実際こんなstyleを直接書く書き方はしないが、簡単のため)

TypeScript

この要素を使って、React で

ReactDOM.render(<x-view>a</x-view>, element); 

しようとすると、未定義要素を生成しようとしてるので怒られる。

React の型定義から、これを通す型宣言を追加する。

declare namespace JSX {
  interface IntrinsicElements {
    "x-view": React.DetailedHTMLProps<
      React.HTMLAttributes<HTMLElement>,
      HTMLElement
    >;
  }
}

たぶんこんんあ感じ。

x-pane

x-view は ReactNative 互換を意識したのであんまり攻めた感じではなかったのだが、実際自分がCSS書く際は width: 100%; height: 100%; justify-content: center; align-items: center; を規定値として持つように作ることが多い。一般的なCSS仕草かは知らない。

名前思いつかないが、とりあえず x-pane とした。

const defaultPaneStyle = document.createElement("style");
defaultPaneStyle.textContent = `
  :host {
    align-items: center;
    border-width: 0;
    border-style: solid;
    box-sizing: border-box;
    display: flex;
    flex-direction: column;
    height: 100%;
    justify-content: center;
    margin: 0;
    min-height: 0;
    min-width: 0;
    padding: 0;
    position: relative;
    width: 100%;
    z-index: 0;
  }
`;

customElements.define(
  "x-pane",
  class extends HTMLElement {
    constructor() {
      super();
      const shadow = this.attachShadow({ mode: "open" });
      shadow.appendChild(document.importNode(defaultPaneStyle, true));
      shadow.appendChild(document.createElement("slot"));
    }
  }
);

declare namespace JSX {
  interface IntrinsicElements {
    "x-pane": React.DetailedHTMLProps<
      React.HTMLAttributes<HTMLElement>,
      HTMLElement
    >;
  }
}
<x-view style="height: 300px;">
  <x-view style="flex: 2; background: gray;">
    <x-pane style="color: green;">Pane0</x-pane>
  </x-view>
  <x-view style="flex: 1;">
    <x-view style="flex-direction: row; height: 100%;">
      <x-view style="flex: 3; background: #a88;">
        <x-pane>Pane1</x-pane>
      </x-view>
      <x-view style="flex: 1; background: #8a8;">
        <x-pane>Pane2</x-pane>
      </x-view>
    </x-view>
  </x-view>
</x-view>

https://camo.githubusercontent.com/259981a9bbd9f14b8d8429af76514ad480c2b7e0/68747470733a2f2f6779617a6f2e636f6d2f65653934353133346366373934313561343337326535623230366666356331632e706e67

みたいな感じになる。

感想

IE webcomponents ポリフィルフレンドリーな書き方するとまた別の書き方になると思う。さして手が増えるわけではないが。 正直、この程度だったら .view {} でよい。webcomponents があふれかえる時代になったら、基底要素としてこういう書き方しても怒られないかもしれない、ぐらいの気持ち。

minfront: フロントエンド実験用のSPAボイラープレート

なんでそんな手が速いのか聞かれたので、最近使ってるSPAボイラープレートを紹介しておきます。

github.com

$ git clone git@github.com:mizchi-sandbox/minfront.git --depth 1 myspa
$ cd myspa
$ yarn install
$ yarn dev    # Start app server

終わり。

src/main.tsxsrc/index.html を編集して、 yarn build && yarn deploy で netlify にアップロードできます。(要 netlify アカウント)

こんな感じ

https://gallant-yalow-997008.netlify.com

プロダクションで使えるちゃ使えるかもしれないけど、議論の余地があるようなものを含まない最小構成。

含まれるもの

  • yarn
  • parcel
  • typescript
  • jest
  • netlify

含まれないもの

言いたいこと

parcel のデフォルト設定がセンスよくて便利。ちょっと凝ったことやろうとすると無力。

ぶっちゃけこれ使うより、自分でボイラープレートを作るという体験が一番勉強になる感じ…

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 との相性の悪さが絶望的で辛かった

React Hooks をどう使っていくか

大きく、末端コンポーネントと全体アーキテクチャの視点がある。

末端コンポーネントでの Hooks

ここはあまり議論の余地なく、setState で local state を持っているものや、 componentDidMount していたものを置き換えることが出来ると思う。

FC を class にせずにちょっとリッチにするのが簡単になる。

class の setState 相当

function Counter() {
  const [count, setCount] = useState(0);
  const onClick = useCallback(() => setCount(s => s + 1), []);
  return <button onClick={onClick}>{count}</button>
}

componentDidMount / componentWillUnmount 相当

function KeyListenerer {
  useEffect(() => {
    const onClick = event => console.log(event.keyCode);
    window.addEventListener('keydown', onClick);
    return () => window.removeEventListener('keydown', onClick);
  }, []);
  return <span>...</span>
}

[] は返り値を memoize するためのキーで [] を与えると常に同じ参照を返す。

これは慣れてないとちょっと難しい。useEffect で [] を与えないと effect の実行は componentDidUpdate 相当になる。

一旦は memoize keys なしで書いて、再描画を抑制する必要があったら、ちょっと頭を捻ってkeysを与える感じになりそう。

(翻訳) React Hooks は魔法ではなく、ただの配列だ

React.memo と useCallback

useCallback の便利なところは、今まで class で this.onClickBound = this.onClick.bind(this) のような書き方をしていた箇所が、pure(or memo) の shallow equal 比較で memoize された関数参照なので true になる。React.memo と組み合わせることで、子に関数を参照を渡す時に render を抑制することが簡単になる。

こういうケースで有効。

const Button = React.memo(props => {
  return <button onClick={props.onClick}>{props.value}</button>
});

function App() {
  const onClick = useCallback(() => console.log('xxx'), []);
  return <Button onClick={onClick} value={"foo"}/>
}

function Root() {
  const [data, setData] = useState({})
  useEffect(() => {
    const id = setInterval(() => { setData(v => v + 1) });
    return () => clearInterval(id);
  });
  return <App {...data}/>
}

App の親から App の render が掛かっても、Button の shallow equal は true なので、 Button は value が変わらない限り更新されない。

アーキテクチャ上の React Hooks and Suspense

ここは無限に議論の余地がある。誰もベストプラクティスを持ってない。

一応、トレンドとしてはマイクロフロントエンドがある。 [翻訳記事]マイクロフロントエンド - マイクロサービスのフロントエンドへの応用

React コアチームはマイクロフロントエンド的なものを志向しているように見える。Component がそれ自体でどんどん賢くなる方向性。Component に処理を書かず、一箇所に集約する Redux の Single Source 的な方向性は、素朴に使うとマイクロフロントエンドと対立する。

すごく大雑把に言うと、 organisms の接続先が redux store になるか、何らかの API を経由した先になるか。という違いになるのではないか。あるいは Component 自体が自身の接続ロジックを密に知ることになる。

Organisms 相当は、こんな風になるだろうか。

const Child = React.lazy(() => import('./Child'))

function MyOrg() {
  const [state, setState] = useState(null)
  useEffect(() => {
    const data = readFooData();
    setState(data)
  });
  return <Suspense fallback="loading..."><Child /></Suspense>
}

Suspense のことを考えると、一緒に非同期を解決してしまうのは確かに便利ではある。しかし、アプリケーション全体を協調させるための State が一つ欲しい。rcombineReducers は不要かもしれない。そうなると、 redux は使わず、 useReducer だけで済ませられる、かも。

[Fizz] New Server Rendering Infra by sebmarkbage · Pull Request #14144 · facebook/react

実験

手を動かして考えるためにこんなものを実装した。(npm に publish はしてない)

https://github.com/mizchi/redux-worker-context

react-redux の connect を useSelector(state => state.foo) と書きたかっただけだが、それだけでは面白くないので、MainTheard との関係を希薄にするために WebWorker に store の実体を移してみた。MainThread は worker の store から 自分に関係ある snapshot を受け取り、その利用者は更に snapshot を select して自分自身にマッピングする。

シングルトンだとしても、connect の mapStateToState で参照を絞るのはいいアイデアだと思うので、とりあえずそのまま redux を使った。

RootState => ComponentSnapshot => LocalSnapshot みたいなイメージ。これは Worker をサーバーに見立てて、クライアントだけでクライアント/サーバーモデルを擬似的に表現している。

これは、 middleware も redux のものになるので、既存資産はそのまま使える。WebWorker に処理が移ってるので CPU ヘヴィな操作がある程度許容される。

WorkerDOM みたいなものが実現可能になったら面白いことができるかもしれない、という期待もある。

Redux and Hooks

Redux は、まず消極的に、まず内部 API を変えるのに用いる、とのこと。useStore みたいな API が生えるとしても、だいぶ後。

https://github.com/reduxjs/react-redux/issues/1063#issuecomment-436479804

自分でhooksで遊びたかったら、現状自分で書くしかない。