ここ 1 年くらいで作った有象無象まとめ

私が個人的に作ったものはいくつかはこのブログで紹介したりしていますが, そのように個別に紹介するほどでもない有象無象たちにも出番を与えてみようという企画です. だいたい 2019 年末ぐらいからのものが入っています.

tyde

github.com

型安全で自分好みなイベントエミッタが欲しくなったので作りました.

使い方は普通. event-kit のイベント購読をまとめる API が気に入っていたのでそれも実装しました.

import { Emitter, CompositeSubscription } from "@susisu/tyde";

const emitter = new Emitter<{ updated: number }>();
const sub = new CompositeSubscription();

sub.add(emitter.on("updated", value => {
  console.log(value);
}));
sub.add(emitter.on("updated", value => {
  console.log(value * 2);
}));

emitter.emit("updated", 42); // -> 42, 84

sub.unsubscribe();

その他の特徴として, バグの温床となる, あるいはリファクタリングの妨げとなる設計をしにくいように, API を調整したり意図的に機能を制限したりしています.

例えば, イベントハンドラの呼び出しは非同期に行われます (多くのイベントエミッタでは同期的).

次のようなコードを見てみましょう.

function update(): void {
  begin();

  doSomething();
  emitter.emit("updated");

  commit();
}

トランザクションのようなものの中で何かを変更してイベントを発行しています.

もしここでイベントハンドラが同期的に呼び出されていると, イベントハンドラ側から別の更新処理が実行され, トランザクションに予期しないものが混ざってしまう, あるいは多重にトランザクションが開始されることによるエラーなどが起こり得ます. これは単純にバグの原因となるだけではなく, それを回避するために約束事を作ったりすると, イベントの発行元とハンドラの結合性が増してしまい, 設計上も好ましくないと考えられます.

(commit() の後でイベントを発行すれば良いのはその通りです. そうすべきです. ただしフレームワークの側で begin()commit() を呼び出しているために, 利用者側では回避が難しいという場合もあります. 具体的には AngularJS の $digest() とか.)

このような問題は, イベントハンドラの呼び出しを非同期にして, 実行順を常に commit() の後にすることで一般的に解決できます. 私個人の経験では同期的で困ったことはあっても非同期で困ったことはないので, デフォルトで非同期で良いのではと考えています.

また, イベントハンドラの呼び出し順はいかなる方法でも保証できません (他のイベントエミッタでは priority を指定できることもある).

これはイベントハンドラ同士が呼び出し順に依存してしまうことで, リファクタリングなどで予期せず実行結果が変わってしまったり, あるいは暗黙的に密結合な箇所が作られてしまうということ回避するためのものです. 順序を保証したい場合は, ひとつのイベントハンドラの中で順序が重要な処理をあらわに記述するか, あるいはイベントを複数のステージに分割するべきです.

という感じです. イベントエミッタにまつわる不可視のスパゲッティコードを生み出すと本当に辛いので...

react-use-source-sink

github.com

React の useRef で作られる RefObject<T> ないし MutableRefObject<T> は読み書きが可能なオブジェクトであるため, 型引数 T に関して invariant です (厳密には TypeScript の仕様上 covariant として扱われてしまいますが). そのため以下のように, 一般的な型の ref を作って具体的な要素に渡すということはできません.

import { FC, useRef, useEffect } from "react";

const Hello: FC = () => {
  const ref = useRef<HTMLElement | null>(null);
  useEffect(() => {
    const elem = ref.current;
    // ...
  });
  return <p ref={ref}>Hello!</p>; // ここでエラー
};

これを回避するために, 読み取り専用の source と書き込み専用の sink に分離することで, このあたりをいい感じにしようというのが useSourceSink です.

import { FC, useEffect } from "react";
import { useSourceSink } from "@susisu/react-use-source-sink";

const Hello: FC = () => {
  const [source, sink] = useSourceSink<HTMLElement | null>(null);
  useEffect(() => {
    const elem = source();
    // ...
  });
  return (
    <p ref={sink}>Hello!</p>
  );
};

これだけ.

catcher

github.com

非同期のデータ取得の結果をキャッシュしたり, 複数同時に実行されるのを一つにまとめたりしたくなったので作りました.

import { Catcher } from "@susisu/catcher";

const catcher = new Catcher({
  fetcher: () => fetchFromSource(params),
});

// `fetchFromSource` が呼ばれてデータが取得される
const data1 = await catcher.fetch();

// キャッシュされたデータが返ってくる
const data2 = await catcher.fetch();

// `expire()` を呼び出すと再度 `fetchFromSource` が呼ばれる
catcher.expire();
const data3 = await catcher.fetch();

なんかこの手の実装は無限にありそうなんですが, フレームワーク非依存でパッと使えるものが見つけられなかったので...

tesseract

github.com

トランザクションみたいなものを実装するとき, 実行中に起こったエラーのハンドリングなどは大体パターン化されるものの毎回実装すると大変なので, その部分だけを抜き出して再利用可能にしたというものです.

README にいくつか例を書いていますが, たとえば複数の処理を実行して, 最終的に変更があったときだけイベントを発行すると行った処理を以下のように書くことができます.

import { Session } from "@susisu/tesseract";

const session = new Session<State>({
  initialize() {
    return dumpState();
  },
  finalize(oldState: State) {
    emitIfUpdated(oldState);
  },
  handleError(error, phase, oldState: State | undefined) {
    if (oldState === undefined) {
      return;
    }
    emitIfUpdated(oldState);
  },
});

function emitIfUpdated(oldState: State): void {
  const newState = dumpState();
  if (!newState.equals(oldState)) {
    emit();
  }
}

session.transact(() => {
  updateA(session);
  updateB(session);
});

なんか不意に作ったけど使わずにそれっきりになっている.

type-of-schema

github.com

TypeScript で JSON Schema をオブジェクトとして書いて, それを元に対応するデータの TypeScript 上の型を導出してやろうという試みです.

なんかこういう感じ. 実際のバリデーションには ajv とかを使う.

import { TypeOfSchema } from "@susisu/type-of-schema";

const schema = {
  type: "object",
  properties: {
    "a": { type: "number" },
    "b": { type: "string" },
  },
  required: ["b"],
} as const;

// T = { a?: number, b: string }
type T = TypeOfSchema<typeof schema>;

まあ TypeScript フレンドリーな (JSON Schema を使わない) バリデータもいくつかあると思うので, ふつうはそういうのを使った方が良いです.

use-debounced

github.com

なんか React コンポーネントで入力を debounce したいときに良いのが見つからなかったので作りました.

いくつか hooks を提供していますが, 例えば入力を debounce してサーバーに検索リクエストを送るみたいなのは以下のように書けます.

import { FC } from "react";
import { useDebouncedAsyncCall } from "@susisu/use-debounced";

const MyComponent: FC = () => {
  const [user, call, isWaiting] = useDebouncedAsyncCall({
    func: name => fetchUser(name).catch(() => "NOT FOUND"),
    init: "",
    wait: 1000,
  });
  return (
    <div>
      <p>
        <input
          type="text"
          defaultValue=""
          onChange={e => {
            call(e.target.value);
          }}
        />
      </p>
      <p>{isWaiting ? "..." : user}</p>
    </div>
  );
};

hookshelf

github.com

Context 経由で React Hooks を提供するやつです. 危なそうですね. 実際取り扱いにはそれなりに注意が必要です.

私は Custom Hooks の良いところとして, 抽象化レイヤーとしてはたらくということがあると思っています. 実際に複数のより基本的な Hooks を束ねた Custom Hooks を定義することで, 利用側からは実装の詳細を気にしなくてもよくなり, 人間の認知の上ではとても役に立ちます.

一方で実装上は単に複数の関数呼び出しをまとめただけなので, まったく抽象化レイヤーにはなってはいません. インターフェースがあるからといって, 我々が代替の実装を挟み込む余地はありません. 最終的には React が提供する useState などの基本的な Hooks に分解され, それらはすべて React の内部でハンドリングされます.

hookshelf はこの欠点を解消するために作りました. たとえば以下のような Hook があるとします.

import { hooks } from "./lib";

const { useBrowserFeature, useNetworkFetch, useComplexState } = hooks;

export function useMyHook() {
  const id = useBrowserFeature();
  const { data, error } = useNetworkFetch(id);
  const { state, dispatch } = useComplexState();
  // do something with data, error, state, and dispatch
  return ...;
}

useBrowserFeature などの Hooks は内部実装を気にすることなく使えているように見えます.

ところがこれについて例えばテストを書こうとすると, 利用している Hooks の内部の実装を完全に把握した上で, かなり大掛かりな実装をすることになります.

test("It works", () => {
  ... // ブラウザの機能やネットワークをモック

  const { result } = renderHook(useMyHook);
  expect(result.current).toEqual(...);

  ... // データが非同期に取得されるのを待つ
  expect(result.current).toEqual(...);

  ... // 状態を更新する
  expect(result.current).toEqual(...);

  ... // 状態を更新して assert を繰り返す
});

これでは大変なのでどうにかしたいですね.

ということで, なんかこういう感じで hooks に含まれる Hooks をラップした proxyHooks を作っておきます.

import { createHookshelf } from "@susisu/hookshelf";
import { hooks } from "./lib";

export const [HooksProvider, proxyHooks] = createHookshelf(hooks);

これらを使うのは単純に元の実装を置き換えればよいだけです. これだけで useMyHook は元と同じように動作します.

import { proxyHooks } from "./shelf"

const { useBrowserFeature, useNetworkFetch, useComplexState } = proxyHooks;

export function useMyHook() {
  const id = useBrowserFeature();
  const { data, error } = useNetworkFetch(id);
  const { state, dispatch } = useComplexState();
  // do something with data, error, state, and dispatch
  return ...;
}

テストでは先程 proxyHooks と同時に作った HooksProvider を使ってモック実装を差し込むことで, 元々の useBrowserFeature などの内部の詳細については踏み込むことなく, テストしたい状態をその場で作ってテストすることができます.

import { HooksProvider } from "./shelf";

function prepare({ id, data, error, state, dispatch }) {
  const hooks = {
    useBrowserFeature: jest.fn(() => id),
    useNetworkFetch: jest.fn(() => ({ data, error })),
    useComplexState: jest.fn(() => ({ state, dispatch })),
  };
  const Wrapper = ({ children }) => (
    <HooksProvider hooks={hooks}>{children}</HooksProvider>
  );
  return { hooks, Wrapper };
}

test("It returns some data created from fetched data and state", () => {
  const { hooks, Wrapper } = prepare({
    id: 42,
    data: { ... },
    error: undefined,
    state: { ... },
    dispatch: () => {},
  });
  const { result } = renderHook(useMyHook, { wrapper: Wrapper });
  expect(result.current).toEqual(...);
});

test("It returns another data in another state", () => {
  const { hooks, Wrapper } = prepare({
    id: 42,
    data: { ... },
    error: undefined,
    state: { ... },
    dispatch: () => {},
  });
  const { result } = renderHook(useMyHook, { wrapper: Wrapper });
  expect(result.current).toEqual(...);
});

よかったですね. ただし Rules of Hooks は守って楽しくデュエルしましょう.

まとめ

有象無象って言葉面白いですよね. 2020 年版の象の新種につけたい名前ランキングでは第 4 位だったそうです.