「Immer」で簡単!イミュータブル!
はじめに
株式会社Another worksでインターンをしている、nonoyamalfoyと申します。
普段はReactNativeでモバイルアプリの開発をしています。
先日LTで登壇した内容を記事にしました。気軽に見ていただけると幸いです。
背景
以前、オブジェクトの参照とコピーについて記事を投稿しました。
簡単に説明すると
スプレッド構文や、Object.assignはシャローコピーなので、深いネストを持つオブジェクトのコピーを行う際は注意する必要がある。といった内容でした。
reactではイミュータブルを意識して実装する必要があり、stateの更新前にオブジェクトのコピーを行うことが多々あるかと思います。
しかし、深いネストを持つオブジェクトをイミュータブルを上記のコピーではネストを気にしないと行けないので面倒ですし、ミスが発生するかもしれません。
そこで今回は、イミュータブルを意識した実装を容易にしてくれる「immer」についてご紹介したいと思います。
イミュータブルについて
イミュータブルとは「不変性」を意味します。つまり、値が変更されないことを指します。
イミュータブルを意識することで意図しない値の変更を防ぐことができます。
どこかで意図せず変更があるかもしれないと恐怖ですよね。
また、reactの公式ドキュメントでは以下のように記されています。
ミュータブル (mutable) なオブジェクトは中身が直接書き換えられるため、変更があったかどうかの検出が困難です。ミュータブルなオブジェクト変更の検出のためには、以前のコピーと比較してオブジェクトツリーの全体を走査する必要があります。
イミュータブルなオブジェクトでの変更の検出はとても簡単です。参照しているイミュータブルなオブジェクトが前と別のものであれば、変更があったということです。
このように、Reactで実装していく上ではイミュータブルを意識することは重要になってきます。
immerについて
immerの公式ドキュメントには以下のように記されています。
内容からもReactでの実装を意識して作成されていることがわかります。
Immerは、不変の状態をより便利な方法で操作できる小さなパッケージです。Reactステート、ReactやReduxのリデューサー、構成管理などと組み合わせて使うことができます。イミュータブルなデータ構造は、(効率的な)変更検出を可能にします。オブジェクトへの参照が変更されていなければ、オブジェクト自体も変更されていないことになります。さらに、クローンを比較的安価に作成することができる。データツリーの変更されていない部分はコピーする必要がなく、同じ状態の古いバージョンとメモリ上で共有される。
後ほど触れますが、最後の部分が特徴的ですね。
クローンを比較的安価に作成することができる。データツリーの変更されていない部分はコピーする必要がなく、同じ状態の古いバージョンとメモリ上で共有される。
実はReactの公式ドキュメントでも深くネストされたオブジェクトでイミュータブルを守る際の選択肢として推奨されています。
実際に使ってみる
復習も兼ねてまずはスプレッド構文の例を見ていきます。
深くネストされたオブジェクトをスプレッド構文でコピーし、値を変更します。
const obj1: Obj = {
p1: "p1",
nest1: {
p2: "p2",
nest2: {
p3: "p3"
}
}
}
// この場合、shallowcopyになりコピー元が変更されてしまう
const copyObj1 = { ...obj1 }
copyObj1.nest1.nest2.p3 = "spread test!"
console.log(obj1.nest1.nest2.p3)
// spread test!
// 元の値も変更されてしまう。
console.log(copyObj1.nest1.nest2.p3)
// spread test!
実際はスプレッド構文でもネストされている部分までコピーすればイミュータブルを実現することができますが、ネストが何重にもなるとかなり面倒です。
続いてimmerの例を見ていきます。
immerではproduceという関数が提供されており、お手軽にイミュータブルを実現できます。
produceは第1引数に変更したい値を受け取り、第2引数に状態を変更する関数を受け取ります。
そして変更された値が返却されます。変更の際に値がコピーされているので、元の値に影響はありません。
import produce from "immer";
const obj1: Obj = {
p1: "p1",
nest1: {
p2: "p2",
nest2: {
p3: "p3"
}
}
}
const copyObj2 = produce(obj1, draft => {
draft.nest1.nest2.p3 = "immer test!"
})
console.log(obj1.nest1.nest2.p3)
// p3(元の値は変更されていない)
console.log(copyObj2.nest1.nest2.p3)
// immer test!
また、produceの第一引数に関数を渡すことで汎用的な関数を作成できます。
いわゆるカリー化というものです。何度も変更がある場合に便利ですね!
const changeP3 = produce((draft: Obj, value)=> {
draft.nest1.nest2.p3 = value
})
const copyObj2 = changeP3(obj1, "immer test!")
lodash cloneDeepとの比較
今までネストが深いオブジェクトをコピーする際はcloneDeepで対応してしまっていたので、どのような違いがあるのか比較してみました。
挙動の違い
cloneDeepはすべてのオブジェクトツリーをコピーする一方で、冒頭で述べたようにimmerは変更される部分のみコピー(メモリを確保)してくれます。
先程のオブジェクトにnest3を加えて検証して行きます。
import produce from "immer";
import {cloneDeep} from "lodash"
const obj1: Obj = {
p1: "p1",
nest1: {
p2: "p2",
nest2: {
p3: "p3"
},
nest3: {
p4: "p4"
}
}
}
// immer
const changeP3 = produce((draft, value) => {
draft.nest1.nest2.p3 = value
})
const copyObj2 = changeP3(obj1, "immer test!")
// cloneDeep
const copyObj3 = cloneDeep(obj1)
copyObj3.nest1.nest2.p3 = "cloneDeep test!"
// 結果
console.log(copyObj2.nest1.nest2.p3)
// → immer test!
console.log(copyObj3.nest1.nest2.p3)
// → cloneDeep test!
// メモリ空間が同じかどうか比較
console.log(obj1.nest1.nest3 === copyObj2.nest1.nest3)
// → true
// immerは必要のないコピーを行わないので、p3とオブジェクトツリー上で関係のないnest3はコピーされない。
console.log(obj1.nest1.nest3 === copyObj3.nest1.nest3)
// → false
// cloneDeepを使用すると、不要なコピーが生じてしまう。
パフォーマンス
簡易的にベンチマークを測定。
要素数が少なければ大差はないが、10000件程度の配列になると倍くらい変わってくる。
const obj1: Obj = {
p1: "p1",
nest1: {
p2: "p2",
nest2: {
p3: "p3"
},
nest3: {
p4: "p4"
}
}
}
const arr1: Obj[] = []
for (let i = 0; i < 10000; i++) {
arr1.push(obj1)
}
// immer test
const immerStartTime = performance.now();
const changeP3 = produce((draft: Obj[], value: string) => {
draft[0].nest1.nest2.p3 = value
})
const copyObj2 = changeP3(arr1, "immer test!")
const immerEndTime = performance.now();
// cloneDeep test
const cloneDeepStartTime = performance.now();
const copyArr2= cloneDeep(arr1)
copyArr2[0].nest1.nest2.p3 = "cloneDeep test!"
const cloneDeepEndTime = performance.now();
console.log(immerEndTime - immerStartTime);
// → 4.699999988079071(ms)
console.log(cloneDeepEndTime - cloneDeepStartTime);
// → 9.5(ms)
パッケージのサイズ比較
immer | lodash | |
---|---|---|
bundle size | 5.6kb | 24.5kb |
download time | 6ms | 28ms |
イミュータブルのための導入であればimmerがよいかと!
use-immer
同時に提供されているuseImmerというライブラリを利用して、stateをを更新する際に簡単にイミュータブルを実現できます。
produceでも実現できますが、より簡略化できます。
使い方としてはuseStateと非常に似ており、set関数で値をすると、内部でコピーを行ってくれます。
import { useImmer } from 'use-immer';
// ...
const [obj, setObj] = useImmer<Obj>({
p1: "p1",
nest1: {
p2: "p2",
nest2: {
p3: "p3"
},
}
});
const changeP3 = (value: string) => {
// useStateを使用している場合はここにコピーの処理を書く必要がある。
setObj((draft) => {
draft.nest1.nest2.p3 = value
})
}
useReducer用のuseImmerReducerも提供されています。
reduxでの活用
produceの第一引数に渡し、switch文で変更したいstateを変更するだけです。
reduxあるあるの多量のスプレッド構文が消え、スタイリッシュに書くことができます。
import produce from "immer"
const initialState = {
todos: []
}
const todosReducer = produce((draft = initialState, action) => {
switch (action.type) {
case "toggle":
const todo = draft.todos.find(todo => todo.id === action.payload)
todo.done = !todo.done
break
case "add":
draft.todos.push({
id: action.payload,
title: "new todo",
done: false
})
break
default:
break
}
})
おまけ
reduxについては正規化が推奨されており、なるべくネストは避けるべきとのこと。(優先度B)
ネストの多いオブジェクトのイミュータブルを意識することも大切だが、ネストを減らした実装を意識することも必要かも。
多くのアプリケーションは、複雑なデータをストアにキャッシュする必要があります。そのデータはしばしばAPIからネストされた形で受け取られたり、データ内の異なるエンティティ間の関係を持っています(例えば、ユーザー、投稿、コメントを含むブログなど)。
そのようなデータは、ストアに「正規化」された形で保存することをお勧めします。これにより、IDに基づいたアイテムの検索や、ストア内の単一のアイテムの更新が容易になり、最終的にはパフォーマンス・パターンの改善につながります。
最後に
immerを使用することで、イミュータブルな実装が容易になりましたね。
イミュータブルを実現するためのライブラリとして、「immutable.js」、「Ramda」、「immutability-helper」などが存在します。
今回は、React公式が推奨している「immer」をピックアップさせていただきましたが、機会があればそれらのライブラリについても調査してみたいと思います。
▼複業でスキルを活かしてみませんか?複業クラウドの登録はこちら!
参考
Discussion