unstated-nextとredux-actionsを組み合わせると素敵

github.com

unstated-nextは、Reactの新しいContext APIの極薄のラッパー。ソースコードもたった38行(空行込み)。

github.com

redux-actionsは、FLUXスタイルのアクションを作る極薄ライブラリ。名前にReduxが入ってるのでRedux専用かというと、そうではない。元々Reduxも普通のJavaScript(/TypeScript)関数を組み合わせる仕組みなのでその便利ライブラリたるredux-actionsも普通の関数を作る。

これら二つは、React Hookの一つReact.useReducerで組み合わせる。

ja.reactjs.org

組み合わせると、こんな感じのコードができました。

import React from 'react';
import { createContainer } from 'unstated-next';
import { ActionFunctions, createAction, handleAction, Reducer } from 'redux-actions';
import * as R from 'ramda';

import { ConcreteState } from './types';

type State = ConcreteState;

interface ReducerFactory<Payload> {
    (initialState: State): Reducer<State, Payload>;
}

// あまり美しくない。単にカリー化したいだけなのにR.curry(handleAction)(a, r)ではPayloadの型を失う。
const createReducer = <Payload = void>(
    actionType: ActionFunctions<Payload>,
    reducer: Reducer<State, Payload>,
): ReducerFactory<Payload> => (initialState: State) => handleAction(
    actionType,
    reducer,
    initialState,
);

const useContainer = (reducers: ReducerFactory<any>[]) => (initialState?: State) => {
    if (!initialState) {
        throw new Error('"initialState" is undefined.');
    }
    // 前にやったことと同じ see: http://mk.hatenablog.com/entry/2017/09/09/070852
    const combineded = R.reduce<Reducer<State, any>, Reducer<State, any>>(
        (pre, cur) => (state, action) => cur(pre(state, action), action),
        state => state,
        R.map(r => r(initialState), reducers), // ReducerFactoryの配列からReducerの配列を作る
    );
    // [ State, React.Dispach<Action<any>> ] というTupleを返す。これをアプリから利用する。
    return React.useReducer(combineded, initialState);
}

// ——— Here we go! ———

// redux-actionsでFLUXスタイルのActionを作る
const doSomethingAction = createAction<number>('DO_SOMETHING');
const doSomethingReducer = createReducer(
    doSomethingAction,
    (state, { payload }) => {
         // (具体的な操作は省略) payloadがしっかり型付けできている。
        return state;
    },
);

const doAnotherAction = createAction<boolean>('DO_ANOTHER');
const doAnotherReducer = createReducer(
    doAnotherAction,
    (state, { payload }) => {
        // (具体的な操作は省略) Ramdaを活用してスマートにstateを処理する
        return state;
    },
);

// importが楽なようにActionをまとめた
export const actions = {
    doSomethingAction,
    doAnotherAction,
}

// unstated-nextのReactコンポーネントファクトリでコンテナを作る
export default createContainer(useContainer([
    doSomethingReducer,
    doAnotherReducer,
    // あとはパターンを利用して量産したReducerFactoryを追加していく。
]));

すごく素敵。さらにStateをいじるのにこれまではImmutable.jsとかunderscore.jsを使ってたけど、ここ一年ちょっとぐらいはめちゃ関数型プログラミングないいやつ愛用中。作りがImmutable.jsのように副作用ないのは同じながらImmutable.jsがコレクションを提供していたのでアプリケーション丸ごと染まるのに対して、underscoreのようなコレクション操作等での提供なので、使い始めたいところだけ部分でも使える。ライブラリ全てがカリー化に対応している。

github.com

ここまで全て関数型プログラミングなライブラリ群で、当然ReactもHook使ったFunctionConponentでゴリ押しすると美しい。React.useEffectがあるのでライフサイクル必要でもClassComponentを使う必要なくなった。

そして、UIの見てくれ的なものはこれまでも今もMaterial-UIが良かったのだけど、ここ数日は違うのに浮気中。

github.com

Semantic-UIはCSSライブラリで、JSXのclassName属性へ直書きするだけで十分に使える。さらにSemantic-UI-ReactというReactコンポーネントも用意されていてそれはそれで便利。ただしMaterial-UIのようながっつりコンポーネントで作り込んでるのではないのでCSSの範囲の効果にほぼ限る。複雑なものはMaterial-UIのソース見てだいたい仕組み把握したら、Semantic-UI-Reactのコンポーネントを組み合わせながらSemantic-UIのCSSを使えば結構簡単に良い感じのものができる。

Denoプラグインが普通のTypeScriptプロジェクトに干渉する

VSCodeにDenoプラグインをインストールすると、普通のTypeScriptのプロジェクトでlibの解決ができなくなったりするなど、干渉していました。だいたい問題ないのですけど、たまたまブラウザ環境のグローバル変数の型が解決できなくて困ってました。

const selection = window.getSelection();

こんなのとか、

(node as HTMLElement).appendChild(div);

こんなのでエラー。

前者はtsconfig.jsonにlib: ["dom"]を加えるように示唆までしてくれますが、当然そんなことはやってるって。後者はHTMLElementを@types/reactの方に見に行き、その定義が空っぽなのでアウト。ググっても解決方法見つからなく困ってたところで、独立したまっさらなVSCode環境作って確かめたら問題なく動いたので、やっと気がつきました。プラグインが悪いのだと。そしてそいつはDenoプラグインだと。

プラグインワークスペース単位で無効にできるので、それですぐに解決。Denoはたまに書くためにこのプラグイン自体は大変有用なので残します。

f:id:masataka_k:20191101203617p:plain

このプラグイン詳細画面によると、はっきりと"This extension works using VS Code's built-in version of TypeScript."って書いてありました。いくらtsconfig.jsonにlibを設定しても反映しないはずですね(しかし前のエントリで紹介したtypeRootsやemitDecoratorMetadataの設定は効いてたのでまるっきり見てない訳でもなく、単純な問題じゃなさそう。typeRootsはDenoプラグインを外せば記述の必要もなくなった)。また、Denoプラグインダウンロード回数がたった2016回しかない中で、同じVSCodeで普通のTypeScriptを利用したlibdom.d.tsを参照してもらわないと困るようなプロジェクトをやらない限りぶつからない問題なのでググっても出てこないわけだ。少なくともこの2016回のうち2回は私だし。

vscode typescript tsconfig.json lib dom window document global ts2584 ... この辺のキーワードでググっても出てこないです。今調べたら deno plugin conflict あたりでも全く出てこなかった。世界初のハマりかもしれない。

NestJSの設定で漏れるとダメなやつ

つまらないところでハマったので、未来の私のために備忘。NestJSCLIを使わずに手でプロジェクトを作ったとき、tsconfig.jsonに以下をいれないとダメ。

// tsconfig.json
{
    "compilerOptions": {
        "target": "es5",
        "experimentalDecorators": true,
        "emitDecoratorMetadata": true
    }
}
  • experimentalDecorators をいれないと、未来で仕様変わるかも?と警告が出てトランスパイル止められる。これは絶対気がつくので問題なし。
  • emitDecoratorMetadata をいれないと、一見したところトランスパイルも実行も問題なく動くように見えるけど、@Injectableが解決されずサイレントにundefinedで全滅。NestJSのIoTには実行時にTypeScriptの型を保持するというこのオプションが必要。NestJSのガイドの通りにreflect-metadataパッケージを追加 していたけど、emitDecoratorMetadataはガイドに書いてなかった。

VSCodeでJest

最近はどうしてもコード書く機会が少ないので、JetBrains製品のサブスクリプションを維持するのを諦めてこれまで食わず嫌いだったVSCodeに移行したのですが、改めてコード書き始めるとちょいちょいハマったのでメモっておかねば。for 未来の私。

課題

TypeScript + Jestが今までの作法で動かなかった。

解決

必要パッケージを追加する。本体に必要なパッケージ群は別に、Jestで必要なものたちは...

$ yarn add typescript jest ts-jest @types/jest

http://mk.hatenablog.com/archive/2019/05/17

おなじみts-jestはpreset一発で細々としたjest設定が終わり超楽。ドキュメントによるとプリセットは混在する.jsの処理の違いで複数用意されている。テストコードがTypeScriptオンリーなら値は"ts-jest"。そういえばこれ、前にやりました

// package.jsonの関係あるとこだけ抜粋
{
    "jest": {
        "preset": "ts-jest"
    },
    "devDependencies": {
        "@types/jest": "^24.0.19",
        "jest": "^24.9.0",
        "ts-jest": "^24.1.0",
        "typescript": "^3.6.3"
    }
}

ここで問題発生。VSCodeのIntellisenseが期待通りに動作せず、グローバルスコープの、describe・it・jestの名前解決ができない。WEBを調べるとプロジェクトルートにjsconfig.jsonを書けとか見つけたので、VSCodeのドキュメントの該当ページにあたって設定してみたりするも改善しない。

その後にtsconfig.jsonを試行錯誤する中で、以下が当たりだった。 "typeRoots": [ "./node_modules/@types" ] で型ファイルを明示的に取り込んでる。非グローバルはimportでコード上に明示されるのでそもそもに解決できるけど、暗黙となってしまうグローバルの型はここで探索する。これまで経験してきたJetBrains製品(CLion、WebStorm、GoLand)では、"types": [ "jest", "node" ] で読み込んでたのだけど、現在の私のVSCode環境ではそれが上手くなく、 ”typeRoots" にプロジェクトローカルなパス設定となった。もちろん "types" でも方法はあるのだと思うが、こちらの方が堅い上に万一の追加パッケージに柔軟なように考えるのでいいかな。

さらに "types"では、アプリケーション本体に必要なパッケージ群で複数取り込まれていた @types/node で重複エラーが出るという謎動作が出た。パッケージ識別名で捉えるのではなくパスで誤解なく指示するのは、こちらの解決にも効いてる。

// tsconfig.jsonの現在
{
    "compilerOptions": {
        "moduleResolution": "node",
        "resolveJsonModule": true,
        "lib": [ "es6", "esnext" ],
        "module": "commonjs",
        "target": "es5",
        "sourceMap": true,
        "strict": true,
        "typeRoots": [ "./node_modules/@types" ]
    },
    "exclude": [ "node_modules" ]
}

ちなみにlambdaアプリケーションをwebpackでビルドしているので上記その他の設定です。

追記 2019/11/1

これ、Denoプラグインのせいだった。プラグイン外せばtypeRootsではなくこれまでのtypesを利用する設定でもOK。

VSCodeでdeno

愛用するGoLandのサブスクリプションが切れてしまいました。すでにGoLand乗り換えの際にWebStormは捨ててたので、JetBrainsのIDEは無くなった。継続すればいい話なのですが、最近は違う種類の仕事で時間取られて開発作業に関わることが少なく、あってもAtomで仕様書いたりSketchでワイヤー引いたりする程度だったので維持するのをやめました。

そんな中でdenoのプラグインVSCodeにあると本家サイトに書いてあったのを見て、VSCodeにしようかなと思います。退路を断つためAtomはアンインストール。VSCodeを入れて、チュートリアルサイトの学習動画を見て、会社にあったSoftware Design 4月号のVSCode特集をナナメ読み中。

f:id:masataka_k:20190727131558p:plain

プラグインで、deno対応を入れたら、ちゃんとdeno独特なimportを解決してくれました。node上のTypeScriptと異なるのはここだけなのでもう問題ない。デバッガは...どうなるのかな。テスト書けばいいとはいえブレイクポイント置いてインクリメンタルデバッグもできたら最高なのだが。

denoがHomeBrew入りしてたよ

たまにbrew upgradeを打ってますが、今日たまたま気がついた。denoがHomeBrewのFormulaeになってました。何度かあった破壊的変更も最近はなく、手元で書いた自分的便利ツール群が元気にdenoで動いています。

$ brew info deno
deno: stable 0.9.0 (bottled)
Command-line JavaScript / TypeScript engine
https://deno.land/
Not installed
From: https://github.com/Homebrew/homebrew-core/blob/master/Formula/deno.rb
==> Dependencies
Build: llvm ✘, ninja ✘, node ✔, rust ✘
==> Requirements
Required: macOS >= 10.11==> Analytics
install: 753 (30 days), 826 (90 days), 1,031 (365 days)
install_on_request: 751 (30 days), 824 (90 days), 1,029 (365 days)
build_error: 0 (30 days)

enzymeがまたしばらくみないうちに変わってた

mk.hatenablog.com

前回は15ヶ月ぶりでしたが、さらに今回は5ヶ月後。enzymeのセットアップ周りが改善されて過去の面倒な作業がなくなってる。ついでにts-jestもちょっと変わってる。

// enzyme.setup.js
const enzyme =  require('enzyme');
const Adapter = require('enzyme-adapter-react-16');

enzyme.configure({ adapter: new Adapter() });

こんなセットアップファイルを用意します。enzymeのサイトの通り。ES6だと動く環境選ぶので、ES5が間違いない。

// package.json
{
    "scripts": {
        "test": "jest",
    },
    "jest": {
        "preset": "ts-jest",
        "moduleNameMapper": {
            "@/(.+)": "<rootDir>/src/$1"
        },
        "setupFilesAfterEnv": [
            "./enzyme.setup.js"
        ]
    },
    "devDependencies": {
        "@types/enzyme": "^3.9.2",
        "@types/jest": "^24.0.13",
        "@types/ramda": "^0.26.8",
        "@types/react": "16.8.17",
        "enzyme": "^3.9.0",
        "enzyme-adapter-react-16": "^1.13.0",
        "jest": "^24.8.0",
        "react-test-renderer": "^16.8.6",
        "ts-jest": "^24.0.2",
        "typescript": "^3.4.5"
    },
    "dependencies": {
        "ramda": "^0.26.1",
        "react": "^16.8.6",
        "react-dom": "^16.8.6"
    }
}

必要なところだけ絞ると、上記の感じ。JSDomを引っ張ってくるような細かな作業が一切なくなってました。jest.moduleNameMapperは、tsconfig.jsonで似たようなことを書いてあるにも関わらず設定を引いてくれないので、こちらでも書いてます。

が、しかし。これはCreate React App(CRA)を用いてない場合。CRAは色々やってくれて便利で、テスト環境も整えてくれているのだけどそれだけではすまない場合もある。Enzymeの場合がそれ。

CRAを利用している場合

Enzymeに限らず、Jestのセットアップファイルは、src/setupTest.tsもしくはsetupTest.jsで書いておくと、react-scriptsが無設定で引っ掛けてくれる。楽チンな一方で、jest.moduleNameMapperなどの仮想で絶対パスを作る機能はreact-scriptsによって利用禁止にされている。CRAはほぼ無設定でReactのボイラープレートを整えてくれて、さらにその後の開発環境にもなってくれているけど、こういったところでブラックボックスや謎仕様はあるので悩ましくなってきました。