不可变数据实现-Immer.js

242 阅读12分钟

1.1 Immer.js是什么?

Immer.jsmobx的作者写的一个 immutable(不可变数据) 库,同时在2019年获得JavaScript open source award 大奖。核心实现是利用ES6 的proxy,几乎以最小的成本实现了Javascript的不可变数据结构,简单易用、体量小巧、设计巧妙,满足了我们对JS不可变数据结构的需求。

1.2 什么是不可变数据?

首先需要先理解什么是可变数据:举个例子

let objA = { name: '小明' };

let objB = objA;

objB.name = '小红';

console.log(objA.name); // objA 的name也变成了小红

像这样我们明明只修改代码objBname,发现objA也发生了改变,这个就是可变数据。

那不可变数据是什么怩?

不可变数据概念来源于函数式编程。函数式编程中,对已初始化的“变量”是不可以更改的,每次更改都要创建一个新的“变量”。新的数据进行有副作用的操作都不会影响之前的数据。这也就是immutable的本质。

Javascript 在语言层没有实现不可变数据,需要借助第三方库来实现。Immer.js 就是其中一种实现(类似的还Immutable.js)。

1.3 为什么要追求不可变数据?

  • 数据拷贝处理中存在的问题
var testA = [{value:1}]

var testB = testA.map(item => item.value =2)

//问题:本意只是让testB的每个元素变为2、却无意改掉了testA每个元素的结果

//解决:当需要传递一个引用类型的变量进一个函数时,可以使用Object.assign或者...解构,断引用

var testB = testA.map(item =>({...item, item.value =2}))

//问题:Object.assign或者...只会断开一层引用,但如果对象嵌套超过一层

// 深层次的对象嵌套

var testA = [{

    value: 1,

    desc: { text: 'a' }

 }]

var testB = testA.map(item => ({ ...item, value: 2 }))

console.log(testA === testB)           // false

console.log(testA.desc === testB.desc) // true

// testA.desc和testB.desc指向同一个引用

//解决:深拷贝,递归去遍历

//只考虑对象的场景

function deepClone(obj) {

  const keys = Object.keys(obj)

  return keys.reduce((memo, current) => {

    const value = obj[current]

    if (typeof value === 'object') {

      return {

        ...memo,

        [current]: deepClone(value),

      }

    }

    return {

      ...memo,

      [current]: value,

    }

  }, {})

}

// deepClone可以满足简单的需求,但在实际开发中,需要考虑其他因素:如原型链上的处理,value出现循环引用的场景、value是个Symbol

// 所以一般会使用大型的工具函数:lodash.cloneDeep
  • 不可变数据在React中的重要性

为了加速了diff算法中reconcile(调和)的过程,React只需要检查object的索引有没有变即可确定数据有没有变

举个🌰

在React的生命周期中每次调用ComponentShouldUpdate()会将state现有的数据跟将要改变的数据进行比较(只会对state进行浅对比,也就是更新某个复杂类型数据时只要它的引用地址没变,那就不会重新渲染组件)。

const [todos, setTodos] = useState([{study:'open',,work:'down'}]); 

const onClick = () => { 

    todos[0].study = 'down'; 

    setTodos(todos);

}

// 不会触发渲染

// 正确的做法

const onClick = () => { 

    let list =[...todos]

    list[0].study='down'

    setTodos(list);

}

//引入immer

setState(produce((state) => (state.isShow = true)))

1.4 常见的实现方法

1.4.1 深拷贝

深拷贝的成本比较高,需要考虑其他如原型链、valuesymbol或者出现循环引用的处理且没有地址共享的数据,影响性能。

1.4.2 Immutable.js

Immutable.js源自Facebook,一个非常棒的不可变数据结构的库。使用另一套数据结构的API,将所有的原生数据类型转化成Immutable.js的内部对象,并且任何操作最终都会返回一个新的immutable

// 举个🌰

const { fromJS } = require('immutable')

const data = {

  val: 1,

  desc: {

    text: 'a',

  },

}

const a = fromJS(data)

const b = a.set('val', 2)



console.log(a.get('val')) // 1

console.log(b.get('val')) // 2



const pathToText = ['desc', 'text']

const c = a.setIn([...pathToText], 'c')



console.log(a.getIn([...pathToText])) // 'a'

console.log(c.getIn([...pathToText])) // 'c'



console.log(b.get('val') === a.get('val'))       // false

console.log(b.get('desc') === a.get('desc')) // true

const d = b.toJS()

const e = a.toJS()

console.log(e.desc === d.desc)       // false

console.log(e.val === d.val) // false

这个例子也可以看出:深层次的对象在没有修改的情况仍然能保证严格相等。这也是它另外一个特点:深层嵌套对象的结构共享

相比与Immer.jsImmutable.js的不足:

  • 自己维护一套数据结构、JavaScript的数据类型和Immutable.js需要相互转换,有入侵性
  • 他的操作结果需要通过toJS方法才能得到原生对象,这样导致在开发中需要时刻关注操作的是原生对象还是Immutable.js返回的结果
  • 库的体积大约在63KB、而Immer.js仅有12KB
  • API丰富、学习成本较高

1.5 Immer.js

基本概念
  • currentState:被操作对象的最初状态
  • draftState: 根据currentState生成的草稿、是currentState的代理、对draftState所有的修改都被记录并用于生成nextState。在此过程中,currentState不受影响
  • nextState: 根据draftState生成的最终状态
  • produce: 用于生成nextState或者producer的函数
  • Producer: 通过produce生成,用于生产nextState,每次执行相同的操作
  • recipe:用于操作draftState的函数
API简介
produce
  • 第一种用法:

produce(currentState, recipe: (draftState) => void | draftState, ?PatchListener): nextState

import produce from 'immer'; 

const baseState= [   {       title:'study javascript',       status:true   },   {       title:'study immer'.       status:false   }]

const nextState = produce(baseState, draftState=>{

    draftState.push({title:'study react'})

    draftState[1].status = true

})

// 新增的只会体现在在nextState上,baseState没被修改

expect(baseState.length).toBe(2)

expect(nextState.length).toBe(3)

// 

expect(baseState[1].status).toBe(false)

expect(nextState[1].status).toBe(true)

// 没有改变的数据共享

expect(nextState[0]).toBe(baseState[0])

// 改变的数据不再共享

expect(nextState[1]).not.toBe(baseState[1])

在上面的例子,对draftState的修改最终都会体现在nextState,但并不会修改baseState,需要注意的是nextStatebaseState共享未修改的部分。需要注意的是通过produce生成的nextState是被冻结的(使用Object.freeze实现,仅冻结nextStatecurrentState相比更改的部分),直接修改nextstate会报错

  • 第二种用法:柯里化

produce(recipe: (draftState) => void | draftState, ?PatchListener)(currentState): nextState

利用高阶函数特点,提前生成一个生产者 producer

recipe没有返回值,nextState是根据recipe函数中的draftState生成的;有返回值是根据返回值生成的。

const currentState = {

  p: {

    x: [5],

  },

};

let producer = produce((draft) => {

  draft.x = 2

});

let nextState = producer(currentState);
怎么工作的
produce(obj, draft => {

  draft.count++

})

通过以上的例子可以看出,obj是我们传入的简单对象,所以Immer.js的神奇一定在draft对象上。

核心实现是利用ES6的proxy实现Javascript的不可变结构。几乎以最小成本实现了不可变数据结构,简单易用、体量小巧。其基本思想在于所有的更改都应用在临时的draftState。一旦完成所有的变更,将草稿状态的变更生成nextState。这就通过简单的修改数据同时保留不可变数据的优点。

源码解析
补充知识:Proxy 对象

用于创建一个对象的代理,实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)

Proxy 对象接受两个参数,第一个参数是需要操作的对象,第二个参数是设置对应拦截的属性,这里的属性同样也支持getset等等,也就是劫持了对应元素的读和写,能够在其中进行一些操作,最终返回一个 Proxy 对象实例。

const handle = {

    get(target, key) {

      // 这里的 target 就是 Proxy 的第一个参数对象

      console.log('proxy get key', key)

      return '返回1'

    },

    set(target, key, value) {

      console.log('proxy set key', value)

    }

  }

  const target = {a:{b:1}}

  const p = new Proxy(target,handle)

  p.a = 2 // 所有设置操作都被转发到了 set 方法内部

  p.a.b= 1  // 触发的是get而非set

注意⚠️⚠️⚠️:如果一个对象的层级比较深,而且内部会有引用类型的属性值时。如果给当前对象生成代理并修改内层属性值时,如果修改的是最外层属性的值时,是会触发 set 方法,但是如果修改最外层某个属性值为对象的属性的值时,并不会触发 set 方法

这也就是为什么在Immer.js的实现里 需要递归给某个对象内部所有的属性(属性值为对象类型的属性)做代理的原因

Produce的实现

核心源码

produce: IProduce = (base: any, recipe?: any, patchListener?: any) => {

        // base判断 是否能生成draft

        if (typeof base === "function" && typeof recipe !== "function") {

            const defaultBase = recipe

            recipe = base

            const self = this

            return function curriedProduce(

                this: any,

                base = defaultBase,

                ...args: any[]

            ) {

                return self.produce(base, (draft: Drafted) => recipe.call(this, draft, ...args)) // prettier-ignore

            }

        }



        //  recipe、patchListener异常处理

        if (typeof recipe !== "function") die(6)

        if (patchListener !== undefined && typeof patchListener !== "function")

            die(7)

        let result

        // Only plain objects, arrays, and "immerable classes" are drafted.

        if (isDraftable(base)) {

            // 生成ImmerScope对象,和当前的produce绑定 主要是做复杂嵌套的追踪

            const scope = enterScope(this)

            // 创建 proxy(draft),并执行scope.drafts.push(proxy)将 proxy 保存到 scope 里

            const proxy = createProxy(this, base, undefined)

            let hasError = true

            try {

                // 执行用户的修改逻辑  也就是draftState

                result = recipe(proxy)

                hasError = false

            } finally {

                // finally instead of catch + rethrow better preserves original stack

                if (hasError) revokeScope(scope)

                else leaveScope(scope)

            }

            if (typeof Promise !== "undefined" && result instanceof Promise) {

                return result.then(

                    result => {

                        usePatchesInScope(scope, patchListener)

                        return processResult(result, scope)

                    },

                    error => {

                        revokeScope(scope)

                        throw error

                    }

                )

            }

            usePatchesInScope(scope, patchListener)

            return processResult(result, scope)

        } else if (!base || typeof base !== "object") {

            result = recipe(base)

            if (result === NOTHING) return undefined

            if (result === undefined) result = base

            if (this.autoFreeze_) freeze(result, true)

            return result

        } else die(21, base)

    }

Produce简单的流程图

image.png 总结produce

produce接受三个参数:base初始值、recipe用户执行修改逻辑、patchlistener用户接受patch数据做自定义操作

去除一些特殊的判断兼容处理代码,可以看出其主流程主要根据base创建draft对象、执行用户传入的recipe拦截读写操作,走到自定义的gettersetter最后再解析组装结果返回给用户。

step1、调用createProxy创建draftState

export function createProxy<T extends Objectish>(

    immer: Immer,

    value: T,

    parent?: ImmerState

): Drafted<T, ImmerState> {

    const draft: Drafted = isMap(value)

        ? getPlugin("MapSet").proxyMap_(value, parent)

        : isSet(value)

        ? getPlugin("MapSet").proxySet_(value, parent)

        : immer.useProxies_

        ? createProxyProxy(value, parent)

        : getPlugin("ES5").createES5Proxy_(value, parent)





    const scope = parent ? parent.scope_ : getCurrentScope()

    scope.drafts_.push(draft)

    return draft

}

export function createProxyProxy<T extends Objectish>(

    base: T,

    parent?: ImmerState

): Drafted<T, ProxyState> {

    const isArray = Array.isArray(base)

    const state: ProxyState = {

        type_: isArray ? ProxyType.ProxyArray : (ProxyType.ProxyObject as any),

        scope_: parent ? parent.scope_ : getCurrentScope()!,

        modified_: false,//是否被修改

        finalized_: false,//是否已经完成(所有的setter执行完、并已经生成了copy)

        assigned_: {},

        parent_: parent,//父级对象

        base_: base,//原始对象

        draft_: null as any, // set below

        copy_: null,//base的浅拷贝,使用 Object.assign(Object.create(null), obj) 实现

        revoke_: null as any,

        isManual_: false

    }

    let target: T = state as any

    let traps: ProxyHandler<object | Array<any>> = objectTraps

    if (isArray) {

        target = [state] as any

        traps = arrayTraps

    }

    // 创建一个可撤销的代理对象

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

    state.draft_ = proxy as any

    state.revoke_ = revoke

    return proxy as any

}

image.png 这里兼容了不支持proxy的ES5处理,其核心根据 base 构建一个 state 对象,如果base 为数组,是则基于 arrayTraps 创建state的Proxy,否则基于objectTraps创建stateProxy step2、拦截读写操作

export const objectTraps: ProxyHandler<ProxyState> = {

    get(state, prop) {

        if (prop === DRAFT_STATE) return state

        const source = latest(state)

        if (!has(source, prop)) {

            return readPropFromProto(state, source, prop)

        }

        const value = source[prop]

        if (state.finalized_ || !isDraftable(value)) {

            return value

        }

        if (value === peek(state.base_, prop)) {

            prepareCopy(state)

            return (state.copy_![prop as any] = createProxy(

                state.scope_.immer_,

                value,

                state

            ))

        }

        return value

    },

    set(

        state: ProxyObjectState,

        prop: string /* strictly not, but helps TS */,

        value

    ) {

        const desc = getDescriptorFromProto(latest(state), prop)

        if (desc?.set) {

            desc.set.call(state.draft_, value)

            return true

        }

        if (!state.modified_) {

   

            const current = peek(latest(state), prop)



            const currentState: ProxyObjectState = current?.[DRAFT_STATE]

            if (currentState && currentState.base_ === value) {

                state.copy_![prop] = value

                state.assigned_[prop] = false

                return true

            }

            if (is(value, current) && (value !== undefined || has(state.base_, prop)))

                return true

            prepareCopy(state)

            markChanged(state)

        }

        if (

            state.copy_![prop] === value &&

            // special case: NaN

            typeof value !== "number" &&

            // special case: handle new props with value 'undefined'

            (value !== undefined || prop in state.copy_)

        )

            return true

        state.copy_![prop] = value

        state.assigned_[prop] = true

        return true

    }

}

image.png

  • getter主要用来懒初始化代理对象,当代理对象的属性被访问的时候才会生成其代理对象

    • 举个例子:当访问draft.a时,通过自定义getter生成draft.a的代理对象darftA所用访问draft.a.x相当于darftA.x,同时如果draft.b没有访问,也不会浪费资源生成draftB
  • setter:当对draft对象发生修改,会对base进行浅拷贝保存到copy上,同时将modified属性设置为true,更新在copy对象上 step3、解析结果

export function processResult(result: any, scope: ImmerScope) {

    scope.unfinalizedDrafts_ = scope.drafts_.length

    const baseDraft = scope.drafts_![0]

    const isReplaced = result !== undefined && result !== baseDraft

    if (!scope.immer_.useProxies_)

        getPlugin("ES5").willFinalizeES5_(scope, result, isReplaced)

    if (isReplaced) {

        //虽然 Immer 的 Example 里都是建议用户在 recipe 里直接修改 draft,但用户也可以选择在 recipe 最后返回一个 result,不过得注意“修改 draft”和“返回新值”这个两个操作只能任选其一,同时做了的话processResult函数就会抛出错误

        if (baseDraft[DRAFT_STATE].modified_) {

            revokeScope(scope)

            die(4)

        }

        if (isDraftable(result)) {

            //核心处理

            result = finalize(scope, result)

            if (!scope.parent_) maybeFreeze(scope, result)

        }

        if (scope.patches_) {

            getPlugin("Patches").generateReplacementPatches_(

                baseDraft[DRAFT_STATE],

                result,

                scope.patches_,

                scope.inversePatches_!

            )

        }

    } else {

        // Finalize the base draft.

        result = finalize(scope, baseDraft, [])

    }

    revokeScope(scope)

    if (scope.patches_) {

        scope.patchListener_!(scope.patches_, scope.inversePatches_!)

    }

    return result !== NOTHING ? result : undefined

}

function finalize(rootScope: ImmerScope, value: any, path?: PatchPath) {

    // Don't recurse in tho recursive data structures

    if (isFrozen(value)) return value



    const state: ImmerState = value[DRAFT_STATE]

    // A plain object, might need freezing, might contain drafts

    if (!state) {

        each(

            value,

            (key, childValue) =>

                finalizeProperty(rootScope, state, value, key, childValue, path),

            true // See #590, don't recurse into non-enumarable of non drafted objects

        )

        return value

    }

    // Never finalize drafts owned by another scope.

    if (state.scope_ !== rootScope) return value

    // Unmodified draft, return the (frozen) original

    if (!state.modified_) {

        maybeFreeze(rootScope, state.base_, true)

        return state.base_

    }

    // Not finalized yet, let's do that now

    if (!state.finalized_) {

        state.finalized_ = true

        state.scope_.unfinalizedDrafts_--

        const result =

            // For ES5, create a good copy from the draft first, with added keys and without deleted keys.

            state.type_ === ProxyType.ES5Object || state.type_ === ProxyType.ES5Array

                ? (state.copy_ = shallowCopy(state.draft_))

                : state.copy_

        // Finalize all children of the copy

        // For sets we clone before iterating, otherwise we can get in endless loop due to modifying during iteration, see #628

        // Although the original test case doesn't seem valid anyway, so if this in the way we can turn the next line

        // back to each(result, ....)

        each(

            state.type_ === ProxyType.Set ? new Set(result) : result,

            (key, childValue) =>

                finalizeProperty(rootScope, state, result, key, childValue, path)

        )

        // everything inside is frozen, we can freeze here

        maybeFreeze(rootScope, result, false)

        // first time finalizing, let's create those patches

        if (path && rootScope.patches_) {

            getPlugin("Patches").generatePatches_(

                state,

                path,

                rootScope.patches_,

                rootScope.inversePatches_!

            )

        }

    }

    return state.copy_

}
  • 解析结果:其核心在于result = finalize(scope, baseDraft, [])。当produce执行完成,所有的用户修改也完成。

    • 如果state.modified_=false未被标记修改:说明没有更改该对象,直接返回原始base
    • 如果state.finalized_=false未被标记结束:递归basecopy的子属性执行finalizeProperty,如果相同则返回,否则递归整个过程。最后返回的对象是由base的一些没有修改的属性和copy修改的属性拼接而成,最终使用freeze冻结copy属性,将finalized设为true

1.6 Immer.js在React项目中的优势

setState中使用Immer.js
onClick = () => { 

    this.setState(prevState => ({ 

        user: { 

            ...personState.user, 

            age: personState.user.age + 1 

        } 

    })) 

}

// 使用immer

onClickImmer = () => { 

    this.setState(produce(draft=>{

        draft.user.age +=1    

    })) 

}
以hook的方式使用Immer.js

Immer.js还提供了一个React Hook库use-immer,可以在React的项目中以hook的形式使用immer

useImmeruseState 非常像。它接收一个初始状态,返回一个数组。数组第一个值为当前状态,第二个值为状态更新函数。状态更新函数和 produce 中的 recipe 一样。举个🌰:

import React from "react"

import { useImmer } from "use-immer"

const [person, updatePerson] = useImmer({name:'tom',age:25})

function updateName(name) {

    updatePerson(draft => {

      draft.name = name;

    });

}

1.7 总结

最后总结下immer相关的优缺点:

  • 优点:

    • 使用原生语法实现、上手快、学习成本低
    • 体积小约为4.3k、速度跟Immutable.js差不多
    • 结构共享,返回没有变化的部分且对变化的数据有冻结功能
  • 缺点

    • 兼容性:对于不支持proxy的浏览器使用defineProperty实现,在性能上为proxy的两倍

Immer.js本身没有性能屏障,在很多场景下基本都会优与现有的解决方案。如果有兼容性的要求可以考虑大型的工具库如lodash。在其余情况下,Immer.js不失为一个不可变数据实现的好选择。

1.8 附录