LoginSignup
1
0

Immer で組み込みクラスや独自クラスなどのインスタンスは扱えるのか

Last updated at Posted at 2023-08-16

概要

Immer で何が扱えて何が扱えないのか、特に独自クラスのインスタンスは扱えるのかについて書きます。

これが公式の説明だけど、この文章を読んでいると結構JSの基礎力が試される感じがあったので、細かく解説します。

注釈がない限り以降の引用は上記のページからのものとします。
Immer のバージョンは 10.0.2 です。

先に結論

  • 実用上、基本的に何らかのクラスのインスタンスであっても問題なく使えると思って良い(若干の含みがある理由は以降で解説)。

  • カスタムクラスの場合、immerableシンボルをキーとするプロパティに true を入れる必要はあるが、それ以外は特別な対応は不要。

  • ただし、DOMノードやBuffer、Dateなど環境依存なネイティブオブジェクトは扱えない。

何もせずにImmerで扱えるもの

Plain objects (objects without a prototype), arrays, Maps and Sets are always drafted by Immer.

  • プレーンオブジェクト
  • 配列

これらは何もせずにImmerで扱ってOK。

準備が必要なもの

  • Map
  • Set

v6 以降では Map と Set を扱う場合は enableMapSet() を事前に実行する必要があります。

"Plain object(objects without a prototype)"は厳密には間違ってる

例えば以下のようにリテラルで作成する空オブジェクトであっても、プロトタイプは存在する。

const obj = {};
console.log(obj.__proto__); // { constructor: f, __defineGetter__: f, ...
console.log(obj.__proto__ === Object.prototype) // true

これはいわゆる「プレーンオブジェクト」もObjectをコンストラクタに持つオブジェクトだからであり、本当の意味で without a prototype なオブジェクトっていうのはJSの中では Object.prototype のみのはず。

FYI:

その他のクラスのインスタンス

Every other object must use the immerable symbol to mark itself as compatible with Immer. When one of these objects is mutated within a producer, its prototype is preserved between copies.

その他のオブジェクトに関しては、immerableというシンボルをキーとするプロパティにtrueが入っている場合はImmerで通常通り扱うことができます。

// https://immerjs.github.io/immer/complex-objects/ より引用

import {immerable} from "immer"

class Foo {
    [immerable] = true // Option 1

    constructor() {
        this[immerable] = true // Option 2
    }
}

Foo[immerable] = true // Option 3

Immerを通して新たに作成されたオブジェクトも、ちゃんと元のオブジェクトと同じプロトタイプを持ちます。
その証拠に、instanceoftrueを返します(instanceofは同じプロトタイプを持つかどうかを確認する)。
またconstructorプロパティも同じ値が入ります。
ちゃんとそのクラスのインスタンスとして扱えるオブジェクトですね。

// https://immerjs.github.io/immer/complex-objects/ より引用したコードに一部加筆

import {immerable, produce} from "immer"

class Clock {
    [immerable] = true

    hour: number;
    minute: number;

    constructor(hour, minute) {
        this.hour = hour
        this.minute = minute
    }

    get time() {
        return `${this.hour}:${this.minute}`
    }

    tick() {
        return produce(this, draft => {
            draft.minute++
        })
    }
}

const clock1 = new Clock(12, 10)
const clock2 = clock1.tick() // ここで Immer により新たなオブジェクトが返される
console.log(clock1.time) // 12:10
console.log(clock2.time) // 12:11
console.debug(clock1.constructor === clock2.constructor); // 同じコンストラクタを持つ
console.debug(clock1.__proto__ === clock2.__proto__); // 同じプロトタイプを持つ
console.log(clock2 instanceof Clock) // prototype が同じなので Clock のインスタンスと判定

Own Properties を全てコピーする

When creating a draft, Immer will copy all own properties from the base to the draft.This includes non-enumerable and symbolic properties.

ここで使われている"own"という言葉はJSでは特別(?)な意味を持っていて、"own properties"というのは 「(プロトタイプチェーンを辿るのではなく)実際にそのオブジェクトが持っているプロパティ」 という意味になります。

実際に、Object.getOwnPropertyNames()という関数にOwnPropertyという言葉が使われていますね。この関数はown propertiesの名前の配列を返す関数です。

例えば、上の例で言うとclock1clock2の own properties というのはhourminuteだけです。

console.debug("clock1", Object.getOwnPropertyNames(clock1));
// -> clock1 [ 'hour', 'minute' ]
console.debug("clock2", Object.getOwnPropertyNames(clock2));
// -> clock2 [ 'hour', 'minute' ]

つまり、hourminuteをコピーします。

では"Own getters"とは?

Own getters will be invoked during the copy process, just like Object.assign would.

-> Own getters は Object.assign と同じようにコピー時に実行される。

この辺り結構ややこしいんですが、ここで言及されているのは "own properties"としてgetter/setterを持っている場合の話です。

"Own getters" を持っているというのはつまり以下のようなオブジェクトです。

// クラスの定義ではなくオブジェクトに直接 getter がある
const obj = {
  get hoge() {
    console.log("hoge!!!");
    return 123
  }
}
Object.getOwnPropertyNames(obj) // ['hoge']

このオブジェクトはプロトタイプ(クラス)ではなく"own properties"として getter の hoge を持っています。
ドキュメントに書いてあるように、このオブジェクトを Object.assign に通すと、 getter は継承されず、通常の値のプロパティとしてコピーされます。

obj.hoge
// hoge!!! (プロパティの参照のように見えるが getter なので関数が実行され、 console.log が実行されている)

const newObj = Object.assign({}, obj);
// hoge!!! (assign 時に getter が実行されるので console.log が呼ばれる)

newObj.hoge
// 123 (assign を通して hoge は getter ではなく通常の number 型のプロパティになっているので、参照しても console.log が呼ばれない)

Immer でも getter のコピー時にはこれと同じことが起きるということですね。

クラスの getter の扱い

Inherited getters and methods will remain as is and be inherited by the draft.

継承された getter とメソッドはそのまま新しいオブジェクトに継承されます。
プロトタイプが変わらないことを考えれば当然ですね。

Only getters that have a setter as well will be writable in the draft, as otherwise the value can't be copied back.

setter を持っている getter だけが draft で書き込み可能です。

これも当然っちゃ当然で、そもそも言語仕様レベルで setter がないプロパティに値を代入しようとするとエラーになります。これは Immer を通しても当然起きることです。
例えば上の例の Clock クラスで tick 関数を以下のように変更して実行するとエラーになります。

  tick() {
    return produce(this, (draft) => {
      draft.minute++;
      draft.time = "99:99"; // setter がないけど代入してみる
    });
  }
state.copy_[prop] = value;
                      ^
TypeError: Cannot set property time of #<Clock> which has only a getter

逆に、setter を用意すると draft の変更が可能になります。

class Clock2 {
  [immerable] = true;

  constructor(hour, minute) {
    this.hour = hour;
    this.minute = minute;
  }
  get time() {
    return this._time;
  }
  // setter を用意する
  set time(value) {
    this._time = value;
  }

  tick() {
    return produce(this, (draft) => {
      draft.minute++;
      draft.time = "99:99"; // 値を代入
    });
  }
}

const clock1 = new Clock2(12, 10);
const clock2 = clock1.tick();
console.debug(clock1.time); // undefined (値を代入していないので)
console.debug(clock2.time); // 99:99 (Immer を通して stter による代入が成功している)
console.debug(Object.getOwnPropertyNames(clock2)); // [ 'hour', 'minute', '_time' ]

getter についてまとめ

クラス内で定義した getter は、そのインスタンスから見て "own getters" ではありません。

つまり、 getter を持っているクラスのインスタンスも問題なく Immer で扱えます。

オブジェクトに直接 getter を持たせることはアプリケーションのコードを書く上でほとんど無いんじゃないでしょうか。
となると、基本的には実用上、アプリケーションのコードで Immer で扱えないオブジェクトというのはかなり限られてきそうです。

Because Immer will dereference own getters of objects into normal properties, it is possible to use objects that use getter/setter traps on their fields, like MobX and Vue do.

MobX や Vue などでは直接 getter を持つオブジェクトを扱いますが、getter も値が欠落するわけではなく通常のプロパティとしてコピーされるので問題ないとのこと。
すみません、両方全く使わないのでここはノーコメントです。

コンストラクタは呼ばれない

ダイヤモンドは砕けない。

Immer will not invoke constructor functions.

Immerは新しいオブジェクトのコピー時にコンストラクタを呼びません。
コンストラクタで特殊な処理をしている場合は注意かもしれません。
(コンストラクタで特殊な処理をするのが妥当なのかという話は別であります)

明確に扱えないオブジェクトたち

Immer does not support exotic / engine native objects such as DOM Nodes or Buffers, nor is subclassing Map, Set or arrays supported and the immerable symbol can't be used on them.

「DOMノードやBufferのようなエキゾチックオブジェクト/ネイティブオブジェクトはサポートされていません。」と書かれているものの...
配列はエキゾチックオブジェクトだけど、普通に Immer で扱える🤔
あんまり厳密な記述ではないということっぽい。

参考:

実際には扱えないオブジェクトを渡すとエラーを吐くので対応はできますね。

const date = new Date();
const newDate = produce(date, (draft) => {
  draft.hoge = 123;
});

// Error: [Immer] produce can only be called on things that are draftable: plain objects, arrays, Map, Set or classes that are marked with '[immerable]: true'.

Map, Set, Array のサブクラスは...

また Map, Set, Array のサブクラスのインスタンスもNGと書いてあるんですが、実際に実行してみると

const { immerable, produce, enableMapSet } = require("immer");

enableMapSet();

class NewMap extends Map {
  [immerable] = true;
}
const map = new NewMap();
const newMap = produce(map, (draft) => {
  draft.hoge = 123;
});
console.log(newMap);

動きました。
動くんか。

結論

  • 実用上、基本的に何らかのクラスのインスタンスであっても問題なく使えると思って良い。

  • カスタムクラスの場合、immerableシンボルをキーとするプロパティに true を入れる必要はあるが、それ以外は特別な対応は不要。

  • ただし、DOMノードやBuffer、Dateなど環境依存なネイティブオブジェクトは扱えない。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0