Immutable.jsとImmer、ちゃんと使い分けていますか?
昨今のフロントエンド開発では、データをイミュータブルなオブジェクトとして扱うのが主流です。すなはち、データが変わるときはオブジェクトを書き換えるのではなく、新しいデータを持った新しいオブジェクトを作ります。最近ではオブジェクトがデータとしてプログラムのあちこちで取り回されることが増えて、一度余所に渡されたデータの中身が後から変更されるのは混乱をきたし設計が困難になるというのが主な理由です。
データを変更するたびに新しいオブジェクトを作るのは、特にデータが複雑になったりネストしたりしていると面倒だしプログラムの見通しが悪くなります。そこで使われるのが、データをイミュータブルに扱うためのライブラリであるImmutable.jsとImmerです。
データをイミュータブルなものとして扱うという目的はどちらのライブラリでも達成することができますが、現在では Immer のほうが開発が活発であり、独自のデータ構造ではなく JavaScript のオブジェクトをそのまま扱える点からも Immer のほうが人気があるようです。
しかし、実はもともと両者は異なる特性を持つライブラリであり、適切な使い分けが必要です。普通のユースケースの場合は Immer で問題ありませんが、特殊な場合では Immutable.js のほうが適切な場合があるかもしれません。既存の日本語記事ではTypeScript の表現力で自由な JavaScript に立ち向かう 〜 Immutable.js 編 〜で次のように一瞬触れられています(強調は筆者)。この記事ではこれをもう少し深く解説します。
immer は Immutable.js とは異なり、JavaScript が提供する通常のデータ構造を freeze したり、proxy でラップしたりして提供しています。そのため理屈の上では「大きなコレクションを繰り返し変更するような場面」で不利になる可能性がありますが、おそらく実用上は問題ないでしょう。
一言でまとめ直せば、Immutable.js は永続データ構造を提供するライブラリであるが、Immer はそうではありません。永続データ構造の恩恵を受ける必要があれば Immutable.js を選択しましょう。ただ単に JavaScript のオブジェクトをイミュータブルに変更したいだけならば Immer で問題ありません。
Immutable.js と Immer の違いが分かる例
まず、Immutable.js と Immer がどのように異なるのか分かる例をお見せします。これはかなり Immutable.js 側に有利な例ですが、Immutable.js の特性を理解するには十分でしょう。次の例は、N
個の配列を作る処理を Immutable.js と Immer の両方で実装した例です。どちらも、例えばN === 5
ならば[[8],[8,1],[8,1,1],[8,1,1,4],[8,1,1,4,18]]
というデータを生成してくれます(Immutable.js の方は実際には JavaScript の配列ではなくList
ですが)。これらの配列は、毎回新規に作るのではなく「前の配列に 1 つ値を加える」という操作の繰り返しで生成されます。
function runImmutable(N: number) {
const result: List<number>[] = [];
let list = List<number>();
for (let i = 0; i < N; i++) {
list = list.push(rand());
result.push(list);
}
return result;
}
function runImmer(N: number) {
const result: number[][] = [];
let list: number[] = [];
for (let i = 0; i < N; i++) {
list = produce(list, (draft) => void draft.push(rand()));
result.push(list);
}
return result;
}
function rand() {
return Math.floor(Math.random() * 2 ** 5);
}
筆者の PC 上で、上のコードを用いて Immutable.js と Immer の速度を比較してみたところこのようになりました(自分で試してみたい方はこの CodeSandboxからどうぞ)。
N | Immutable.js | Immer |
---|---|---|
1000 | 2.6 ms | 47.6 ms |
2000 | 8.6 ms | 152.5 ms |
3000 | 10.6 ms | 331.4 ms |
4000 | 4.0 ms | 584.1 ms |
5000 | 1.9 ms | 914.2 ms |
このように、このタスクでは Immutable.js のほうが圧倒的に高速です。特に、実は Immutable.js の時間計算量はproduce
のたびに新しい配列を 1 から作っている一方、Immutable.js は複数の List の内部構造でオブジェクトを共有しているからです。
Immer はproduce
の結果として毎回別々の新しい配列オブジェクトを作り、その際元の配列オブジェクトから新しい配列オブジェクトへとデータがコピーされます。よって、一つの配列オブジェクトを作るのに
このような計算量・メモリ使用量的な恩恵を受けることが、永続データ構造を利用する主な目的の一つです。Immutable.js はこのような機能を提供することを主目的とするライブラリであり、Immer とは目的が異なるのです。
なお、配列たちの生成というタスクでは Immutable.js が圧倒的に高速でしたが、その配列たちを実際に使う際のパフォーマンスはまた異なるかもしれません。というのも、Immutable.js のList
に対するランダムアクセスは
フロントエンドとオブジェクトの再利用
以上のように、Immutable.js と Immer は異なる目的意識から成るライブラリです。それにも関わらず両者が比較されがちなのは、どちらもイミュータブルなオブジェクトでステートを表現するという共通の利用方法があるからです。この目的では Immer のほうが適しており、特にList
のような独自のデータ構造ではなく生のオブジェクトとしてデータを扱える点が優れています。
Immutable.js の開発が停滞しているのは、フロントエンドで永続データ構造の需要が乏しいからでしょう。このようなデータ構造自体は非常に重要な概念で、多くのプログラミング言語に存在します。我々フロントエンドエンジニアが依存するブラウザの内部でも、効率的なデータ処理のために多用されているはずです。しかし、フロントエンドエンジニアがイミュータブルに求めているのは処理速度ではなく設計の改善です。だからこそ、Immutable.js に代わって Immer が隆盛したのでしょう。
実際、例えば Immutable.js も Immer も使わなかったとして、オブジェクトのプロパティを 1 つ更新したい場合は次のように行えます。
const newObj = {
...obj,
foo: "bar",
};
この計算量は、obj
のサイズ(プロパティの数)を
言い方を変えれば、このような処理を行うとき我々はobj
を再利用することに興味がないとも言えます。obj.sub = { ... }
のようにネストされたオブジェクトはコピーされずに再利用されますが、せいぜいその程度であり、obj
そのものに入っていたデータは再利用ではなくコピーです。
そこまですることに興味が無いのであれば、Immutable.js は不要であり、より取り回しのいい Immer で十分なのです。もしそこまでする必要があるのであれば、Immer などではなく Immutable.js を使うことになります。
まとめ
この記事では Immutable.js と Immer の違いに着目して解説しました。これらのライブラリは同じ目的で使われることがある一方で、実は大きく異なる目的で作られています。基本的には Immer で大丈夫ですが、もしかしたら Immutable.js が必要な場面が来るかもしれません。この記事を読んでそんな場面に備えておきましょう。
-
コードを追ったわけではないので分かりませんが、償却計算量かもしれません。 ↩︎
Discussion