🍃このブログは移転しました。
3秒後、自動的に移動します・・・。

immerのコードを読んだ

GitHub - mweststrate/immer: Create the next immutable state by mutating the current one

(MobXの作者による)イミュータブルに状態を操作するユーティリティライブラリ。

ちなみに、この世で最初にスターしたのは俺ですw

immerとは

読んだのは`v0.8.2`。

ImmutableJSみたく独特なAPIを覚えなくてもいい、今まで通り配列やオブジェクトを変更すればいい。
それなのにイミュータブルにデータを扱える!素敵!というやつ。

// いわゆるreducerが
const byId = (state, action) => {
  switch (action.type) {
    case RECEIVE_PRODUCTS:
      return {
        ...state,
        ...action.products.reduce((obj, product) => {
          obj[product.id] = product
          return obj
        }, {})
      }
    default:
      return state
  }
}

// immerならこう書ける
const byId = (state, action) =>
  produce(state, draft => {
    switch (action.type) {
      case RECEIVE_PRODUCTS:
        action.products.forEach(product => {
          draft[product.id] = product
        })
    }
  })

ちなみに作者による概説記事もあるので、TL;DR的な意味でリンクを。

Introducing Immer: Immutability the easy way – Hacker Noon

一言でいうならば、Proxyを通してコピーオンライトでデータ構造をイミュータブルにするライブラリ。

コードの構造

├── common.js いわゆるUtil
├── es5.js    Proxyに対応してない環境向けの実装
├── index.js  es5かproxyかの振り分け
└── proxy.js  Proxyに対応してる環境向けの実装

というわけで、今回主に見るのは`proxy.js`に書かれてる内容。

そもそもProxy

7割くらいの人は「なんやっけそれ?」ってなる気がするw

const obj = { a: 1 };

const handler = {
  get(target, name) {
    return name in target ? target[name] : 0;
  },
  set(target, name, val) {
    if (typeof val !== 'number') {
      target[name] = 0;
    } else {
      target[name] = val;
    }
    return target[name];
  },
};

const p = new Proxy(obj, handler);

// Get 0 for undefined props
console.log(p.a); // 1
console.log(p.b); // 0

// Set 0 if value is not a number
p.c = 10;
console.log(p.c); // 10
p.d = 'Not a number';
console.log(p.d); // 0

という感じで、`get`とか`set`とか内部的に呼ばれる操作にトラップを仕掛けて、その名の通り挙動をプロキシできるやつ。

トラップを仕掛けられる内部的な操作は以下の通り。

Internal Method Handler Method
GetPrototypeOf `getPrototypeOf`
SetPrototypeOf `setPrototypeOf`
IsExtensible `isExtensible`
PreventExtensions `preventExtensions`
GetOwnProperty `getOwnPropertyDescriptor`
DefineOwnProperty `defineProperty`
HasProperty `has`
Get `get`
Set `set`
Delete `deleteProperty`
OwnPropertyKeys `ownKeys`
Call `apply`
Construct `construct`

ECMAScript® 2018 Language Specification

いわゆるES6で追加されてたけど、メタプロ用というニッチさからまったく話題になってないAPIさん。

で、このProxyは生成の仕方が2パターンあって、上述のコンストラクタでやるパターンともう1つ、後から`revoke()`できるやつがある。

const { proxy, revoke } = Proxy.revocable(target, handler);
revoke();

`immer`で使ってるのはこっち。
というか、使い捨てないコードならこっちしか使わんような気もする。

produceProxy(baseState, producer)が全て

正確には2つの関数が`export`されてるけど、まあコレが本丸。

export function produceProxy(baseState, producer) {
    const previousProxies = revocableProxies
    revocableProxies = []
    try {
        // create proxy for root
        const rootClone = createProxy(undefined, baseState)
        // execute the thunk
        const maybeVoidReturn = producer(rootClone)
        //values either than undefined will trigger warning;
        !Object.is(maybeVoidReturn, undefined) &&
            console.warn(
                `Immer callback expects no return value. However ${typeof maybeVoidReturn} was returned`
            )
        // and finalize the modified proxy
        const res = finalize(rootClone)
        // revoke all proxies
        revocableProxies.forEach(p => p.revoke())
        return res
    } finally {
        revocableProxies = previousProxies
    }
}

`try`の中でやってる以下がキモ。

// Proxyを仕込む
const rootClone = createProxy(undefined, baseState)
// そこに対してWriteな変更があれば、それが記録される
producer(rootClone)
// 記録された変更と、元の状態をあわせて新たな状態を返す
const res = finalize(rootClone)
// Proxyは使い終わったのでrevoke
revocableProxies.forEach(p => p.revoke())
return res

同じ人やし当たり前なんやけど、MobXでやってたのと雰囲気は近い。

次期MobXもProxy使うとかどうとか。

createProxy()

配列かオブジェクトかを判別して、それぞれのトラップを仕込むだけ。

この時にあわせて、内部で扱う用に独自のプロパティを持つオブジェクトにコンバートしてる。

function createState(parent, base) {
    return {
        modified: false,
        finalized: false,
        parent,
        base,
        copy: undefined,
        proxies: {}
    }
}

このProxyのSetにトラップしてる関数が、そのツリーに対して変更(Write)があったかを見てる。

function set(state, prop, value) {
    if (!state.modified) {
        if (
            (prop in state.base && Object.is(state.base[prop], value)) ||
            (prop in state.proxies && state.proxies[prop] === value)
        )
            return true
        markChanged(state)
    }
    state.copy[prop] = value
    return true
}

これで後でどれが変更されたのかが追える。

finalize()

オリジナルとコピーを検証して返す。
変更されてれば(`modified`なフラグが立ってれば)コピーを返すし、何も変わってないならオリジナルな参照を返す。

ちなみに、`Object.freeze()`して返してくれるのもココでやってる。

発想の勝利

よねー、すごい。

(Proxyさえ知ってれば)さくっとコードは読めるくらいに薄いのに、割とインパクトのある内容な気がする。