Panda Noir

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

ルービックキューブ用ライブラリを作りました

github.com

概要

  • TypeScript で書かれています
  • d.tsファイルを同梱しています
  • テストカバレッジは99%です
  • ルービックキューブの回転をシミュレートできます
  • 回転記号の表記ゆれにも対応しています

インストール

npmで公開しています。

$ npm install @pandanoir/rubikscube

unpkgからダウンロードして使うこともできます。

<script src="https://unpkg.com/@pandanoir/rubikscube@0.1.0/dist/cube.js"></script>

TSで書かれています

すべてTSで書いてあります。そのため、型チェックによって快適に補完できます。たとえば、存在しない回転記号で回そうとすると型エラーが起こります。

const cube = new Cube();
cube.rotate('R'); // OK
cube.rotate('Mw'); // Type error

かなり厳格に型をつけたので、使いやすいと思います。

表記ゆれにも対応

回転記号には統一された規格がないので、表記ゆれがあります。

  • Rw2'
  • Rw'2
  • R2'
  • R'2

本ライブラリはすべての表記に対応しています。

対応回転記号

  • R, L, U, D, F, B
  • M, S, E
  • x, y, z
  • (r), (u), (f)

また、それぞれ180度回転、反転、ワイド回転にも対応しています。

回転記号を一括で入力可能

たとえば J-perm をこのように入力できます。

cube.rotate('R', 'U', `R'`, `F'`,'R', 'U', `R'`, `U'`, `R'`, 'F', 'R2', `U'`, `R'`, `U'`)

npm publishの練習をするならverdaccio + docker

みなさん、npm publish、怖くないですか???

npmでライブラリ公開したときに踏みつけた地雷7つ - Panda Noir

npmでアップデートするときに踏みがちな3つの落とし穴 - Panda Noir

僕はもうトラウマだらけです。怖いです。しかし、いつかは克服しなければいけません。では、どうすればいいのでしょうか?

失敗しても良い環境を作ればよいのです。

npm publish はなぜ怖いのか?

npm publish は npm にパッケージを公開するコマンドです。パッケージの更新も同じコマンドでできます。

このコマンドが恐ろしいのは「失敗してもやり直せない」点です。バージョンアップはできるのですが、過去のバージョンの内容を変更することはできません。同じバージョンのはずなのに内容が異なると利用者からすればとんでもないことですから、当然といえば当然です。

しかし、一度公開すると二度とやり直せないのは恐ろしいことです。実際、上の記事で書いたように僕は何度も失敗してしまいました。おかげで npm publish に恐怖しております。

じゃあ npm registry を作ろう

ならば、練習環境を整えれば良いのです。いきなり本番環境にあげるから失敗するのです。開発環境を作りましょう。

やりかたはとても簡単です。Verdaccio というレジストリを Docker 上に起動するだけです。

$ docker run -d -it --rm --name verdaccio -p 4873:4873 verdaccio/verdaccio
$ npm adduser --registry http://localhost:4873
$ npm publish --registry http://localhost:4873

これだけで使い捨てのnpm registryができます。失敗したらコンテナを破棄してもう一度立ち上げれば良いです。なんと素敵なのでしょうか。

あとはたくさん練習するだけですね!!!みなさんもいっぱい練習しましょう。

esbuild なら React の playground が5秒で出来る!!!!!

esbuild はプラグインなしで JSX・TSXをコンパイルできるから、React のプレイグラウンドがすぐ作れる!!

$ npm init --yes
$ npm i esbuild react react-dom
$ esbuild src/main.tsx --bundle '--define:process.env.NODE_ENV="development"' --outfile=out.js

型チェックが不要なら typescript のインストールすらいらない!!!!tsconfig.json なんて要らなかったんだ!!!

import React, {render} from 'preact/compat';

interface Props {
  name: string;
}
const App: React.FC<Props> = ({name}: Props) => <h1>Hello {name}</h1>;

render(<App/>, document.querySelector('#main'));

webpack も typescript もナシで TSX がコンパイルできる!!!!!!!すごいだろ。しかも早いんだぜ。すげえだろ。

ただし: 結局 Next.js が欲しくなる

まあ、作ってるうちに結局 ESLint も Prettier も欲しくなってくるし、 型チェックもほしくなる。watch すら簡単にできないので面倒。

はじめから Next.js で作るほうが結局は楽なので、playground と割り切るとき以外 esbuild は使わないほうが吉。

ただ、React の機能を試す playground がほしい時は本当にすぐ完成するので最高。

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

たとえば、以下のようなコンポーネントを考えます。

<Hoge callback={callback} argument={argument} ref={ref}/>

Hogeコンポーネントは、argumentの型とcallbackの引数の型が一致してほしいです。

ref がない場合の実装

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

type FunctionType = (...args: any) => any;
type Props<T extends FunctionType> = {
  callback: T;
  argument: Parameters<T>;
};
const Hoge = <T extends FunctionType>({ callback, argument }: Props<T>) => (
  <div>
    Component using generics.
    <button onClick={() => callback(argument)}>click me</button>
  </div>
);

<Hoge
  callback={(n: number) => {
    console.log(n);
  }}
  argument={[3]}
/>;

このコンポーネントは、仮引数と実引数の型が異なるとエラーを吐いてくれます。型もかなり素直に書けています。

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

ここまでは良いのですが、React.forwardRefが挟まると難しくなります。

const RefHoge = React.forwardRef(
  <T extends FunctionType>(
    { callback, argument }: Props<T>,
    ref: React.Ref<HTMLInputElement>
  ) => (
    <div>
      Component using generics.
      <button onClick={() => callback(argument)}>click me</button>
      <input ref={ref} />
    </div>
  )
);

<RefHoge
  callback={(n: number) => {
    console.log(n);
  }}
  argument={['11']}
/>;
// 仮引数と実引数の型が異なっているが型エラーが起きない

React.forwardRefでラップされているため、ジェネリクスがうまく働いていません。

React.FCを書きくだす

これを回避するために、React.FCを無理やり書き下します。もちろん、かなり邪道です。

type Component = (<T extends FunctionType>(
  props: Props<T>,
  ref: React.Ref<HTMLInputElement>
) => React.ReactElement | null) & { displayName?: string };

const RefHoge: Component = React.forwardRef(
  <T extends FunctionType>(
    { callback, argument }: Props<T>,
    ref: React.Ref<HTMLInputElement>
  ) => (
    <div>
      Component using generics.
      <button onClick={() => callback(argument)}>click me</button>
      <input ref={ref} />
    </div>
  )
);
<RefHoge
  callback={(n: number) => {
    console.log(n);
  }}
  argument={['11']}
/>;
// 型エラーが起きる

完全にはReact.FCと互換がとれておらず、問題が起きる可能性があります。使用する際は気をつけてください。

React.memoの場合

この手法はReact.memoでも使えます。しかし、React.memoの場合、もっと簡単な方法があります。

const MemoizedHoge = React.memo(RefHoge) as typeof RefHoge;

<MemoizedHoge
  callback={(n: number) => {
    console.log(n);
  }}
  argument={[false]}
/>;
// きちんと型エラーになる

基本的に型自体はReact.memoでラップする前後で変わらないので、asで注釈をつけるだけでうまく動きます。

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 のような形に収まると思います。