Immer 介绍 + 源码解读

date
Jun 8, 2021
slug
introduce-immer
status
Published
tags
summary
Immer 是 mobX 的作者编写的简化操作 immutable 数据的库。Immer 通过使用 Proxy 特性,使得我们能以一种更简单清晰的方式去处理 immutable 特性的数据。
type
Post

介绍

Immer 是 mobX 的作者编写的简化操作 immutable 数据的库。Immer 通过使用 Proxy 特性,使得我们能以一种更简单清晰的方式去处理 immutable 特性的数据。

例子

最直接的好处就是使得我们的代码变得清晰,比如在编写 redux reducer 时,我们需要保证返回新的 state 对象,如果使用深克隆在性能上会带来损耗,如果使用 … 运算符,在面对复杂结构时,则会使得代码变得复杂。
通过 immer 我们可以使用一种清晰易懂的方式编写类似的代码。
import produce from "immer"

const current = [
    {
        todo: "Learn typescript",
        done: true
    },
    {
        todo: "Try immer",
        done: false
    }
];

// 不使用 immer

const next = [
	...current.map((todo, index) => index === 1 ? {...todo, done: true} : todo),
  {todo: "Tweet about it"},
]


// 在使用了 immer 后

const next = produce(current, draft => {
    draft.push({ todo: "Tweet about it", done: false });
    draft[1].done = true;
});

reducer

实际上 immer 经常被配合 Redux 的 reducer 一起使用,可以大大简化我们编写 reducer 的代码逻辑
// Redux reducer
// Shortened, based on: https://github.com/reactjs/redux/blob/master/examples/shopping-cart/src/reducers/products.js
const reducer = (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

import produce from "immer"

const reducer = (state = {}, action) => produce(state, (draft) => {
    switch (action.type) {
        case RECEIVE_PRODUCTS:
            action.products.forEach(product => {
                draft[product.id] = product
            })
    }
}, {})
  • 使用
immer 只有一个核心函数 produce ,第一个参数是我们想要改变的 current 对象,第二个参数是一个函数,这个函数接受一个 draft 对象,而这个 draft 对象,其实就是 produce 内部使用 Proxy 包装后的 current 对象。
我们在函数中通过 draft 修改 current 上的数据,immer 通过 Proxy 拦截,收集我们作出的修改,最终根据这些修改来计算返回一个新的 state 对象。原来 state 对象上的所有数据则保持原样。
notion image

原理

我们传入的 current 会在 immer 内部使用 Proxy 进行包装,而 draft 就是包装后的 current 对象,我们修改 draft 上的属性时,immer 内部会拦截我们的修改操作,使得变动不会反应到原来的 current 对象上,最后返回一个产生变化的新的 next 对象。

结构共享

不同于深克隆的是,immer 返回的数据是结构共享的 。也就是说 current 和 next 共享未改变的部分,减少了不必要的克隆操作带来的额外性能问题。
notion image
由于蓝色部分并未发生变动,所以在结构上进行共享。注意,结构共享只是一种内部优化手段,从语义上我们可以看作返回了一个全新的对象。
const current = {
    name: 'state',
    a: {
        name: 'a',
        b: { name: 'b' },
        c: {
            name: 'c',
            d: { name: 'd' },
            e: { name: 'e' },
        }
    }
}

// 如果我们手动的写

const next = {
  ...current,
  a: {
  	...crrent.a,
    c: {
    	...current.c,
      name: 'new c',
    },
  },
};

// 使用 immer

const next = produce(current, draft => {
    draft.a.c.name = 'new c';
});

console.log(next === current); // => false
console.log(next.a === current.a); // => false
console.log(next.a.b === current.a.b); // => true
console.log(next.a.c === current.a.c); // => false
console.log(next.a.c.d === current.a.c.d); // => true

源码解读

随着发展,Immer 的版本号已经破 7 了,支持了 ES6 的 Map 和 Set 数据结构, 对异步修改也做了支持。但是为了是我们能更专注我们的目标,这里选取了 immer 1.0 的代码进行解读,现在看来依然令人赞叹不已,代码虽少五脏俱全。
阅读的过程中,为了保持代码的观赏性,删减了代码中错误处理的一些逻辑。immer 1.0 版本 ES5 相关的实现有部分缺陷,这里就不做说明了,不过这些缺陷在最新版都已经解决掉了。
├── src 源码目录
|  ├── common.js      一些通用方法
|  ├── es5.js         使用 Object.defineProperty 的实现
|  ├── immer.d.ts     ts 类型声明
|  ├── immer.js       入口文件
|  ├── immer.js.flow  flow 声明
|  └── proxy.js       使用 Proxy 的实现

src/immer.js

produce

produce 函数就是我们的入口函数,本身的逻辑很简单,就是根据 JS 环境特性执行不同的实现代码。
export {setAutoFreeze, setUseProxies} from "./common"

import {isProxyable, getUseProxies} from "./common"
import {produceProxy} from "./proxy"
import {produceEs5} from "./es5"

/**
 * @param {*} baseState 我们传入的想要修改的对象
 * @param {*} producer  我们传入的函数
 */
export default function produce(baseState, producer) {
    if (arguments.length === 1) {
      // 柯里化相关代码
      const producer = baseState
        if (typeof producer !== "function") throw new Error("if produce is called with 1 argument, the first argument should be a function")
        return function() {
            const args = arguments
            return produce(args[0], draft => {
                args[0] = draft // blegh!
                producer.apply(draft, args)
            })
        }
    }

    return getUseProxies() // 根据是否支持 Proxy 调用不同的入口方法
        ? produceProxy(baseState, producer)
        : produceEs5(baseState, producer)
}
我们先看下 getUseProxies 的逻辑,其实就是很简单的对当前环境是否支持 ES6 Proxy 特性的判断
// src/common.js

let useProxies = typeof Proxy !== "undefined"
export function getUseProxies() {
    return useProxies
}
接下来,我们来看 produceProxy 函数,这个函数就是所有魔法发生的地方。

src/proxy.js

produceProxy

export function produceProxy(baseState, producer) {
    const previousProxies = proxies // 存储先前的状态
    proxies = [] // 全局变量,本次执行 produce 函数过程中,所有 Proxy.revocable 的返回值
    try {
        // 创建 baseState 一个描述对象,并将其 Proxy 化后返回
        const rootClone = createProxy(undefined, baseState)
        // 执行我们传入的函数,收集修改
        verifyReturnValue(producer.call(rootClone, rootClone))
        // 根据修改计算最终的结果
        const res = finalize(rootClone)
        // 操作完成,撤销掉本次执行 produce 创建的所有 proxy
        each(proxies, (_, p) => p.revoke()) 
        return res
    } finally {
        proxies = previousProxies // 恢复之前的状态
    }
}
可以看到 immer 的核心函数逻辑非常的简单,开始首先保存先前执行的状态,这里主要是为了支持开发者在 producer 函数内再次调用 produce 函数,然后根据传入的 baseState 调用 createProxy 函数。

createProxy/createState

/**
 * @param {*} parentState base 对象所在的父对象,初始为 undefined
 * @param {*} base // 我们传入的 state 对象,或 state 的子对象
 */
function createProxy(parentState, base) {
    // 生成一个对 base 的描述对象
    const state = createState(parentState, base)
    // 根据类型,传入不同的拦截函数,生成 proxy
    const proxy = Array.isArray(base)
        ? Proxy.revocable([state], arrayTraps)
        : Proxy.revocable(state, objectTraps)

    // 在将对描述对象属性访问转发到 base 上
    // 将 Proxy.revocable 收集到 proxies 里,方便最后统一撤销
    proxies.push(proxy)
    return proxy.proxy // 返回 proxy 后对象
}

function createState(parent, base) {
    // 根据参数生成对 base 的描述对象
    return {
        modified: false, // 在执行 procuder 时或之后其属性发生了变化
        finalized: false, // 是否已经根据变化计算出最终对象
        parent, // 父 object
        base, // 存储着真正的属性,也就是我们传入的 state 对象
        copy: undefined, // base 的 shallowCopy,也是变化真正发生的地方
        proxies: {} // 如果自身某个 key 对应的 value 也就是 object 类型,同样将其 proxy 化,并且 proxy 对象挂到这里
    }
}

Proxy.revocable

这里值得注意的点有两个,immer 使用了 Proxy.revocable 而非 new Proxy 的形式来创建 proxy 对象,主要的区别在于,Proxy.revocable 的返回值是 {“proxy”: proxy, “revoke”: revoke} 其中,proxy 是我们期待的 proxy 对象,而 revoke 是一个撤销函数,一旦某个 proxy 对象被撤销,它将变得几乎完全不可调用,在它身上执行任何的可代理操作都会抛出 TypeError 异常。

createState 的返回值的 .proxies 属性

.proxies 里存着的是 base 对象上值为 object 的同样的被调用 createProxy 后返回的 proxy 对象。之所以开始的时候是一个空对象,是因为 immer 的策略是 lazy 的,只有你在 producer 函数里尝试访问 base 上的某个属性时,如果这个属性的值是 object 才会调用 createProxy 将其 Proxy 化,然后挂到这里。对于其他同样是 object 的属性,如果你没有在 producer 函数里访问,就不会触发 Proxy 化的步骤。

objectTraps 对象

immer 的核心逻辑都体现在创建 Proxy 时传入的第二个参数,也就是定义各种拦截器的地方,在这些拦截器中,immer 实现了拦截属性,变化监听,生成 base 的 copy 对象等操作。
const objectTraps = {
    get,
    has(target, prop) {
        return prop in source(target)
    },
    ownKeys(target) {
        return Reflect.ownKeys(source(target))
    },
    set,
    deleteProperty,
    getOwnPropertyDescriptor,
    defineProperty,
    setPrototypeOf() {
        throw new Error("Don't even try this...")
    }
}

function getOwnPropertyDescriptor(state, prop) {
    // 注意,能进到这个函数,说明开发者在 producer 函数对某个属性调用了 Object.getOwnPropertyDescriptor()
    // 这里我们需要根据开发者是否已经改变原数据来决定返回的数据,因为开发者作出改变后,期待拿到的是改变后的数据的描述符
    // 根据数据是否被修改来决定是返回原来的数据,还是新数据
    const owner = state.modified
        ? state.copy
        : has(state.proxies, prop) ? state.proxies : state.base
    const descriptor = Reflect.getOwnPropertyDescriptor(owner, prop)

    if (descriptor && !(Array.isArray(owner) && prop === "length"))
        descriptor.configurable = true
    return descriptor
}

function defineProperty() {
    throw new Error(
        "Immer does currently not support defining properties on draft objects"
    )
}

function defineProperty() {
    throw new Error(
        "Immer does currently not support defining properties on draft objects"
    )
}
我们将简单的拦截器罗列在了上面,接下来我们来专注解析三个核心拦截器 deleteProperty ,get ,set 。这三个拦截器有两个写操作,一个读操作。 我们先看下读操作 get
  • get
// src/common.js

export const PROXY_STATE =
    typeof Symbol !== "undefined"
        ? Symbol("immer-proxy-state")
        : "__$immer_state"

// src/proxy.js

/**
 * 
 * @param {*} state 描述对象
 * @param {*} prop 被访问的属性名
 */
function get(state, prop) {
    // 一个特殊内部的定义属性名,immer 用来判断一个对象是不是已经被调用过 createProxy 了
    if (prop === PROXY_STATE) return state
    if (state.modified) {
        // 如果被修改了,就使用修改后的对象进行返回
        const value = state.copy[prop]
        if (value === state.base[prop] && isProxyable(value))
            // 进到这个判断是因为,对象先进行 delete 或 set 操作后,同样也也会生成 copy 对象,在生成 copy 对象后
            // 此时如果取到的 value 值与原来相等,并且是个 object,说明还没进行 proxy 化,如果 proxy 化了,在生成 copy 的过程中
            // 会执行 Object.assign(state.copy, state.proxies) 操作
            // 同名属性的值会被对应 proxy 化对象给覆盖掉的,相等说明没有,所以这里进行 proxy 化,然后返回
            return (state.copy[prop] = createProxy(state, value))
        return value
    } else {
        // 如果没被修改,则看下这个值是否已经被 proxy 化,是的化就返回 proxy 后的对象
        if (has(state.proxies, prop)) return state.proxies[prop]
        const value = state.base[prop]
        // 否则就取出原对象 base 上的值,如果不是 proxy 且是可以被 proxy 化的
        // 则将其 proxy 并挂在到描述对象 state.proxies 上
        // 注意这里的 lazy 策略,只有一个属性被访问时才会被尝试 proxy 化,而非开始时就从头到底将所有 object 都 proxy 化
        // 并且 proxy化 过程只会出现在 get 操作里,set delete 都不会触发属性 proxy 化
        if (!isProxy(value) && isProxyable(value))
            return (state.proxies[prop] = createProxy(state, value))
        return value
    }
}

export function isProxy(value) {
    return !!value && !!value[PROXY_STATE]
}
  • deleteProperty/markChanged
修改的方法,我们先看比较简单的删除操作,顺便把 markChanged 函数的逻辑解释清楚。
function deleteProperty(state, prop) {
    // 标记对象已经被修改,生成 copy 对象
    markChanged(state)
    // 删除复制对象上的对应属性,注意我们的任何操作都不会发生在 base 上
    delete state.copy[prop]
    return true
}

function markChanged(state) {
    if (!state.modified) {
        state.modified = true
        // 浅克隆 base 对象
        state.copy = shallowCopy(state.base)
        // 将 base 上已经生成 proxy 的值复制到 copy 上,比如说在修改之前进行了几次读操作
        Object.assign(state.copy, state.proxies)
        // 递归的对所属父对象操作
        if (state.parent) markChanged(state.parent)
    }
}
首先我们要明确一点,无论是删除 还是 修改 都会触发 操作,所以 immer 将初始化 proxy 的逻辑放在了 set 里面,因为当你删除和修改时,首先会进行读操作,immer 拦截了读操作,将你读取的属性,如果是个 object 则进行 proxy 化,返回生成的 proxy 对象,进而拦截删除和修改操作。
其次是,文章开头说的,所有的修改操作都不会发生在原对象上,在你尝试修改时,immer 会立刻通过调用 markChanged 函数,生成 base 的一个 copy 对象,随后所有的操作都会发生在 copy 对象上,最终我们拿到的返回值也是 copy 对象。
最后的递归操作的也是必要的,试想当一个对象变化时,从逻辑上讲,包含他的祖先对象自然也是变化的了,但是他的兄弟节点和子节点是没有变化的,这也是为什么新旧对象可以是 结构共享 的,我们只需关注变化的部分,相同的部分复用即可,如果相同的部分被改变了,那么这部分则不复用,生成新的,没有改变的节点则继续复用。
notion image
  • set
function set(state, prop, value) {
    if (!state.modified) {
        // 如果还没有被改变
        if (
            (prop in state.base && is(state.base[prop], value)) || // 如果这个属性已经存在 base 对象上,且修改后的值和原来的相同
            (has(state.proxies, prop) && state.proxies[prop] === value) // 如果这个属性已经存在 state.proxies 上,且相同
        )   // 直接返回,因为前后一样,所有没必要修改,对应着这种情形 state.a = state.a
            return true
        // 否则,我们认为发生了变化,标记下,生成 copy 对象,对把修改设置到 copy 对象上
        markChanged(state)
    }
    // 改变了,已经生成 copy 对象,直接修改 copy 对象
    state.copy[prop] = value
    return true
}
这里值得一提的是,immer 使用了 Copy-on-write 优化策略,这里体现的就是对于仅仅被读取的属性,我们并没有创建新的对象,只有他被修改时,才会真正去创建一个 copy 对象,然后将所有的修改指向这个 copy 对象,防止污染了公共部分。这样做的结果就是,新旧对象对于未改变的部分,在结构上是共享的。
到底为止,我们介绍完了对于 object 类型拦截处理逻辑。实际上 Array 的处理逻辑和 object 基本上是一样的,这里我们快速过一遍。
// src/proxy.js

function createProxy(parentState, base) {
    // 生成一个对 base 的描述对象
    const state = createState(parentState, base)
    // 根据类型,传入不同的拦截函数,生成 proxy
    const proxy = Array.isArray(base)
        ? Proxy.revocable([state], arrayTraps)
        : Proxy.revocable(state, objectTraps)

    // 在将对描述对象属性访问转发到 base 上
    // 将 Proxy.revocable 收集到 proxies 里,方便最后统一解绑
    proxies.push(proxy)
    return proxy.proxy // 返回 proxy 后对象
}

const arrayTraps = {}
each(objectTraps, (key, fn) => {
    arrayTraps[key] = function() {
        arguments[0] = arguments[0][0]
        return fn.apply(this, arguments)
    }
})

// src/common.js

export function each(value, cb) {
    if (Array.isArray(value)) {
        for (let i = 0; i < value.length; i++) cb(i, value[i])
    } else {
        for (let key in value) cb(key, value[key])
    }
}
如果 base 是个 Array,则根据 base 生成的描述对象会被包裹成 Array 再创建相应 proxy 的对象。这一步是为了拦截 producer 内部尝试操作数组的操作,如 push 和 pop 等。在这里,我尝试直接复用 object 的拦截逻辑,发现代码一样是正常的。我猜测作者可能有些兼容性上的考虑。

verifyReturnValue

在完成 createProxy 的逻辑后,我们终于到了执行 producer 的时候,producer 执行的过程,就是 immer 通过 proxy 收集修改的过程。这里的 verifyReturnValue 逻辑很简单,就是检查下 producer 的返回值是不是 undefined ,不是就发出警告。
我们不需要返回 draft 来告知 immer 我们做的修改操作,整个修改过程已经通过拦截器收集完成了。
// src/proxy.js

export function produceProxy(baseState, producer) {
    const previousProxies = proxies // 存储先前的状态
    proxies = [] // 全局变量,本次执行 produce 函数过程中,所有 Proxy.revocable 的返回值
    try {
        // 创建 baseState 一个描述对象,并将其 Proxy 化后返回
        const rootClone = createProxy(undefined, baseState)
        // 执行我们传入的函数,收集修改
        verifyReturnValue(producer.call(rootClone, rootClone))
        // 根据修改计算最终的结果
        const res = finalize(rootClone)
        // 操作完成,撤销掉本次执行 produce 创建的所有 proxy
        each(proxies, (_, p) => p.revoke()) 
        return res
    } finally {
        proxies = previousProxies // 恢复之前的状态
    }
}

// src/common.js

export function verifyReturnValue(value) {
    // values either than undefined will trigger warning;
    if (value !== undefined)
        console.warn(
            `Immer callback expects no return value. However ${typeof value} was returned`
        )
}

finalize

最终,我们来到了 finalize 函数,顾名思义,这里也是我们解读的重点。
// given a base object, returns it if unmodified, or return the changed cloned if modified
export function finalize(base) {
    // 这个函数其实就是从根结点遍历所有到所有节点,将发生的修改进行收集整合,生成一个结构共享的新对象返回
    if (isProxy(base)) {
        // 判断是不是 proxy 对象
        // 是的话,就通过这个标志获取下自身,尽管 base 和 state 是同一个 proxy 对象 :(
        const state = base[PROXY_STATE]
        if (state.modified === true) {
            // 判断是否被修改过
            // 是否已经被固定了!这种情况出现在 s.a = {} s.b = s.a ,即两个属性指向同一个对象时
            // 已经固定的对象可以直接返回了,无需再次固定
            if (state.finalized === true) return state.copy
            // 设置已经固定了
            state.finalized = true
            // 然后进行固定操作,这里会根据是否支持 Proxy 特性,调用不同的固定操作
            return finalizeObject(
                useProxies ? state.copy : (state.copy = shallowCopy(base)),
                state
            )
        } else {
            // 如果没修改,说明被访问过,但是没有被修改过,直接复用原对象
            return state.base
        }
    }
    // 如果传进来的 base 不是 proxy 对象,一般是在这种情况
    /**
        const a = draft.a;
        draft.b = {
            a,
        };
     */
    // 一个原来对象上不存在的对象加进来,后续又没有访问它,不会触发 proxy 化操作,所以最终保持着非 proxy 的状态
    finalizeNonProxiedObject(base)
    return base
}

function finalizeNonProxiedObject(parent) {
    // 虽然是新加入的对象,但是可能带有 proxy 对象,同样递归调用 finalize
    if (!isProxyable(parent)) return
    if (Object.isFrozen(parent)) return
    each(parent, (i, child) => {
        if (isProxy(child)) {
            parent[i] = finalize(child)
        } else finalizeNonProxiedObject(child) // 同理
    })
    // always freeze completely new data
    freeze(parent)
}
循环递归调用后,我们最后得到最终结果 res 对象,随后执行清理逻辑,将本次 produce 过程中创建的 proxy 撤销掉,然后恢复先前的状态,最终返回结果 res 对象。我们就完成了整个逻辑链路!

问题

某些场景下的性能问题

const state = {
  todos: [
    {
      id: 9527,
      title: 'hello',
    },
    ...1000 items
  ],
};
尝试想象我们拥有这样一个 state 对象,现在我们的需求是,找到 id 为 3521 的 todo 对象,然后改变他的 .title 属性,很简单的需求,我们该怎么写呢?
import produce from 'immer';

const newState = produce(state, draft => {
  draft.todos.forEach(todo => {
  	if (todo.id === 3521) {
    	todo.title = 'finded';
    }
  });
});
我们可以轻而易举的写出这样的代码,但是!还记得,刚才讲的 immer 的 lazy 策略嘛。immer 初始化时会将 state 用 Proxy 包裹成 draft 对象,那么 state.todos 呢?
如果我们没有在 producer 函数里访问 draft.todos ,那么 draft.todos 这个数组是不会被 Proxy 包裹的。但是看看刚才我们写的代码,我们不仅访问 todos 数组,而且访问了内部的 todo 对象,并且遍历了一遍,当 immer 通过拦截 get 操作监听到我们的访问时,就会生成对应的 Proxy对象,如果我们找的 todo 对象恰好在最后一位,那么我们将生成大量的 Proxy 对象,仅仅是为了这么简单的需求。

解决

当然解决的方案也是有的,既然我们访问 draft 会被 immer 监听到,我们直接访问原来的 state 对象不就好了!
import produce from 'immer';

const newState = produce(state, draft => {
  state.todos.forEach((todo, index) => {
  	if (todo.id === 3521) {
    	draft.todos[index].title = 'finded';
    }
  });
});
这样我们就得到了正确的结果,并且保证了性能上的优势。Immer 的官方文档也表示说,当你需要大量访问对象来计算出一些值时,比较推荐的做法,是通过访问原对象完成计算,然后将计算后的值设置到 draft 上。

对比

流行库

Immutable.js

Immutable.js 最大的问题就是侵入性太强,无论是对于老项目接入,或者老项目迁出都有非常大的成本。并且其号称的性能优势,可以在后文的性能对比中看到,在算上序列化反序列化的成本后,并没有高出多少,甚至可能更慢。
其次,Immutable.js 另一个问题是 API 设计的巨难用,当然这是因为 js 语言本身限制的问题,但是客观来讲,使用 Immutable.js 后,基本就告别类型提示了。

seamless-immutable.js

对于这个库我只想说
notion image

性能对比

性能对比我们使用一个库时最先考虑的一个问题就是,性能上怎么样?
我们来看下官方提供的与市面上常见流行 immutable 库的性能比较图表,这是各个库配合使用 Redux reducer 的性能测试
notion image
鉴于互联网没人看表格,这里我们直接说重点

结论

  • 使用 Proxy 下的 immer,在最坏情况下,性能比起手写的 reducer 大概要慢到 2-3倍,在实际生产中,这点微小差异可以忽略。
  • 粗粗粗粗略的讲 immer 的性能基本上和 ImmutableJS 差不。但是在算上使用 immutableJS 需要 toJS 的成本后,immer 就比 ImmutableJS 快多了,这还没算通过接口拉取的数据转换成 Immutable 的成本。
  • ES5 方案下的 immer 性能稍微慢一点,但功能上是等价的。

最后

immer 最吸引我的地方,还是作者能想到通过 Proxy 简化操作 Immutable 数据的思维,实在令人叹为观止。

© 何云飞 2021 - 2024