Panda Noir

JavaScript の限界を究めるブログでした。最近はいろんな分野を幅広めに書いてます。

React.forwardRef でジェネリクスを使いたい

たとえば、以下のようなジェネリクスを使ったコンポーネントを考えます。

<Component<string> prop1="string" prop2="string" ref={ref} />

Component の prop1 と prop2 は同じ T 型とします(上の例では T は string)。

ref がなければカンタンに実装できる

ref さえなければ簡単に実装できます。

type Props<T> = {
  prop1: T;
  prop2: T;
};
const Component = <T,>({ prop1, prop2 }: Props<T>) => (
  <ul>
    <li>prop1: {JSON.stringify(prop1)}</li>
    <li>prop2: {JSON.stringify(prop2)}</li>
  </ul>
);

<Component prop1="foo" prop2="bar" /> // 通る
<Component prop1="foo" prop2={42} /> // ちゃんと型エラーになる

codesandbox

シンプルですね。

forwardRef が挟まるとジェネリクスが消える

forwardRef でラップするとジェネリクスが効かなくなります。

const ComponentWithRef = React.forwardRef(Component);

<ComponentWithRef prop1="foo" prop2={42} ref={ref}/>; // 型エラーになってくれない…

codesandbox

forwardRef が返すコンポーネントの型を指定する

これを回避するために、自分で forwardRef が返すコンポーネントの型を指定します。

type Props<T> = {
  prop1: T;
  prop2: T;
};
const Component = <T,>({ prop1, prop2 }: Props<T>) => (
  <ul>
    <li>prop1: {JSON.stringify(prop1)}</li>
    <li>prop2: {JSON.stringify(prop2)}</li>
  </ul>
);
const ComponentWithRef: <T,>(
  props: Props<T> & { ref: Ref<unknown> }
) => JSX.Element | null = forwardRef(Component);

codesandbox

おまけ: React.memo の場合

上の手法は React.memo でも使えますが、React.memo の場合は単に typeof を使う方がカンタンです。

const MemoizedComponent: typeof ComponentWithRef = React.memo(ComponentWithRef);

<MemoizedComponent prop1="foo" prop2={42} ref={ref} />; // きちんと型エラーになる

codesandbox

forwardRef と異なり React.memo でラップする前後で型は変わらないためうまく動きます。

Record & Tuple Proposal がすごい話

github.com

このプロポーザルの話です(2020年8月現在Stage2)

どういうプロポーザルか?

Record と Tuple は、簡単にいうとプリミティブ的なオブジェクトと配列です。どういうことかというと、次のようなことができるようになります。

[1, 2, 3] !== [1, 2, 3] // 配列の場合は等価じゃないけど
#[1, 2, 3] === #[1, 2, 3] // Tuple なら等価!!!

つまり、配列同士の比較を定数時間で行えるようになります

きちんと言うと、Record、Tupleは代数的データ型です。

オブジェクトと配列について

JavaScript では、文字列や数値などのプリミティブ値はインスタンスが異なるなんてことは起こりません。

1 === 1
"a" === "a"

しかし、配列やオブジェクトは異なります。

[1, 2, 3] !== [1, 2, 3]
{hoge: 42} !== {hoge: 42}

こうなっている理由の1つは、おそらくこれらがミュータブルであることです(確証はありませんが)。イミュータブルであれば、オブジェクトから予めハッシュを計算しておいてハッシュ同士を比較するという単純な等価比較ができますが、ミュータブルではそうもいきません。結局、内部ではプロパティの数だけ時間がかかってしまいます。

また、オブジェクトとして等しいかどうかを知りたいケースというのも実際にあります。深い比較は実装できますが、オブジェクトとして等しいかは実装できないので、JS側で提供する必要があります。

うれしいこと

では、オブジェクト同士の深い比較ができるようになったから、何が嬉しいのでしょうか?その答えの1つが React のメモ化です。

React ではメモを使い回すか判断するために、プロパティが変化したかどうか調べています。

// このようなことをしている
isEqual({prop1: 1, prop2: 2, prop3: 3}, {prop1: 100, prop2: 2, prop3: 3})

もちろんこの判定は定数時間でできません。そのため比較のコストとメモ化による恩恵のどちらが大きいか、検討する必要がありました。しかし、Tuple が導入されれば何も迷うことはありません。定数時間で判定できれば、今までのデメリットがなくなります。

また、hooks の依存関係のチェックでも同様の仕組みが使われています。こちらは必ず書く必要があるため、パフォーマンスが向上します。

制限について

Record と Tuple にはいくつか制限があります。

  • Immutable である
  • primitive値とRecord、Tupleのみで構成しなければならない

Immutableである

これはむしろ嬉しい制約です。オブジェクトを渡しても変更されることがないので、影響範囲が小さくなってデバッグが非常に楽になります。もしmutableだとオブジェクトがどこかで変更されていないかチェックする必要があり、非常に大変です。

// オブジェクトがミュータブルだと、調べる範囲が広くなりやすい
const obj1 = {};
func(obj1);
func2(obj1);
func3(obj1);

assert(obj1.hoge === 42); // これは意図してない変化なので、どこで変化したか調べる必要がある

primitive値とRecord、Tupleのみで構成しなければならない

これは2つがどちらも代数的データ型なので当然の結論です。といっても、実際のユースケースではそこまで問題にならないことがほとんどだと思います。そもそも、今のオブジェクトと配列が廃止になるわけではないので使い分けることになると思います。普段はほぼRecordとTupleを使い、できないケースで初めてオブジェクトと配列を使う形です。

// 以下は invalid な Record と Tuple インスタンス
const invalidTuple = #[new Map(), new MyClass(), new Date()]; // ダメ
const invalidRecord = #{
    map: new Map(),
    instance: new MyClass(),
    date: new Date(),
}; // ダメ

ちょうど今の const と let のような形に収まると思います。

めんどうくさがりのためのコンマ演算子を使ったデバッグ術

,演算子(コンマ演算子)を利用したデバッグ方法を紹介します。

返り値をすり替える

たとえば、ある関数の返り値を固定したくなる場面はよくあります。そういうとき、下のように書いてしまうとLinterに怒られがちです。

const isABTarget = () => {
  return true; // デバッグ用
  return targetList.includes(userId);
};

こんなときはコンマ演算子の出番です。

const isABTarget = () => {
  return targetList.includes(userId), true;
};

コンマ演算子は左辺の項を無視して右辺を返します。つまり、 targetList.includes(userId) , true === true です。タイプ数も少ない上、Linterに怒られません。

console.log を挟む

たとえばReactのFCのテストをしたいとき、いちいち波括弧を開いてconsole.logを差し込んでいませんか?

const Component = ({name}) => <h1>Hello {name}!</h1>;

// こうしがち
const Component = ({name}) => {
  console.log(name);
  return <h1>Hello {name}!</h1>;
};

しかし、タイプ数がとんでもなく多くてめんどうです。そこで、コンマ演算子を使います。

const Component = ({name}) => (console.log(name), <h1>Hello {name}!</h1>);

意味としては全く変わっておりません。

注意点としては、この方法が使えるのは式を挿入したい時のみということです。たとえば以下のようなことはできません。

let first = true;
const Component = ({name}) => (if (first) { console.log(name); first = false }, <h1>Hello {name}!</h1>);

この例くらいなら頑張って式に落とし込めなくはないですが、そこまでするなら素直に波括弧で開きましょう。

Docker でデプロイをする方法

いくつかあるのですが、「これだ!」みたいな方法が意外と見当たらなかったので書きます。

Docker を使ったデプロイにはいくつか方法があります。

  • リモートへログインして Docker Hub 経由でイメージを取得してデプロイ
  • ローカルからリモートの docker daemon へデータを送ってデプロイ

1. リモートへ SSH 接続してデプロイ

これが一番お手軽です。

  1. ローカルでイメージを作成
  2. 作成したイメージをリモートリポジトリ(Docker Hub など)にプッシュ
  3. リモートのマシンへログイン
  4. イメージをリポジトリから pull
  5. pull したイメージをもとにコンテナを作成

メリット

まず、とても簡単にできます。リモートへログインして直接コンテナを生成するので、迷う所がありません。

また、リモート側で docker-compose.yml を設定できます。このため、リモートマシン上にあるディレクトリを volumes へマウントできます。

デメリット

最大のデメリットは手動であることです。ローカルでイメージを更新したあと、いちいちリモートへログインして手動で pull する必要があります。Docker Hub と組み合わせて CI/CD をうまく構成できればやれなくはないとは思いますが、そもそも次に紹介する手法をとればもっと楽に実現できます。

また、この方法はリモートリポジトリを経由しなければなりません。そのため、プライベートリポジトリの数など制限があります。たとえば、Docker Hub の無料プランではプライベートリポジトリは 1 つしか作れません。もちろん、Docker Hub 以外にもリポジトリは色々あり、自前で立てることもできます。そのため、頑張ればプライベートリポジトリをいくつも作ることができます。しかし、そもそも次に紹介する方法ならリモートリポジトリを経由することなくデプロイできます。

2. Docker Context を使ってデプロイ

Docker には Context という機能があります。Docker Context を使うと、リモートの docker daemon にコマンドを実行することができます。

$ docker --context remote ps
# リモートマシン上のコンテナ情報が取得できる
$ docker --context remote run nginx
# リモートマシンで nginxイメージのコンテナを立ち上げる
$ docker --context remote build .
# リモートの docker daemon に ビルドコンテキストのファイルが送信されて、リモートでイメージが作成される

しかも、コンテキストは簡単に作成できます。

$ docker context create remote --docker host=ssh://example.com --default-stack-orchestrator swarm

host には ~/.ssh/config に書かれた設定も利用できます。

docker-compose の host オプションでも似たことができます。

$ docker-compose --context remote up
$ # あるいはこれでも
$ docker-compose --host ssh://example.com up

context で一括管理をするとホストの変更が容易です。また、名前も自由につけられるのでわかりやすいです。そのため、利用できるのであれば host オプションより context をおすすめします*1

version: "3"
services:
  web:
    image: nginx
    volumes:
      - /usr/share/nginx:/usr/share/nginx:ro

上の docker-compose.yml を context や host オプションを使って立ち上げます。すると、リモートマシン上にコンテナが立ち上がります。また、コンテナの/usr/share/nginxにリモートマシンの/usr/share/nginxがマウントされます。

複数コンテナを同時に立ち上げるとエラーが起こる

たとえば、以下のように複数コンテナをまとめて立ち上げようとすると、SSHコネクションエラーが発生します。

version: '3'
services:
  serviceA:
    image: nginx
  serviceB:
    image: nginx
  serviceC:
    image: nginx
  # ...
  serviceZ:
    image: nginx

どうやら、コンテナの分だけSSHコネクションが張られるようで、SSHコネクション数の制限に引っかかるみたいです。結局、僕は解決できず、独立したファイルに分けてデプロイすることにしました。

いい感じにデプロイしてみる

以下のような構成でリモートへデプロイしてみます。

./
├ service-a
│ ├ docker-compose.prod.yml
│ └ docker-compose.yml
├ service-b
│ ├ docker-compose.prod.yml
│ └ docker-compose.yml
├ service-c
│ ├ docker-compose.prod.yml
│ └ docker-compose.yml
├ reverse-proxy
│ ├ docker-compose.prod.yml
│ └ docker-compose.yml
└ deploy.sh
  • service-a、b、cはそれぞれ独立したサービス
  • reverse-proxyを介して各サービスへアクセスする
  • コンフィグは docker-compose.yml + docker-compose.prod.yml を使う

このとき、以下のようなスクリプトを書きます。

#!/bin/bash
SCRIPT_DIR=$(cd $(dirname $0); pwd)

for dir in $(ls -d */ | sed -e 's!/!!'); do
  cd $SCRIPT_DIR/$dir
  files=""
  if [ -f "$SCRIPT_DIR/$dir/docker-compose.yml" ]; then
    files+="-f $SCRIPT_DIR/$dir/docker-compose.yml "
  fi
  if [ -f "$SCRIPT_DIR/$dir/docker-compose.prod.yml" ]; then
    files+="-f $SCRIPT_DIR/$dir/docker-compose.prod.yml "
  fi
  if [ -n "$files" ]; then
    docker-compose --context remote $files build
    docker-compose --context remote $files down
    docker-compose --context remote $files up -d
  fi
done

各ディレクトリに入ってデプロイを行っています。context オプションを外せば、そのままローカルでテストができます(適宜docker-compose.dev.yml を追加するなど調整は必要です)。

まとめ

デプロイ時には Docker Context を使いましょう。

$ docker context create remote --docker host=ssh://example.com --default-stack-orchestrator swarm
$ docker-compose --context remote up -d

*1:context に対応したのが 2020 年 6 月リリースのバージョンなので、バージョンによっては host オプションしか使えない可能性があります