likes
comments
collection
share

不可变数据实现-Immer.js

作者站长头像
站长
· 阅读数 73

一、 Immer.js是什么?

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

二、 什么是不可变数据?

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

let objA = { name: '小明' };
let objB = objA;
objB.name = '小红';
console.log(objA.name); // objA 的name也变成了小红

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

那不可变数据是什么怩?

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

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

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

  • 数据拷贝处理中存在的问题
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)))

四、 常见的实现方法

4.1 深拷贝

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

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.js,Immutable.js 的不足:

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

五、 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。这就通过简单的修改数据同时保留不可变数据的优点。

不可变数据实现-Immer.js

源码解析
补充知识: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简单的流程图

不可变数据实现-Immer.js

总结 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
}

不可变数据实现-Immer.js

这里兼容了不支持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
    }
}

不可变数据实现-Immer.js

  • 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

六、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

useImmer 和 useState 非常像。它接收一个初始状态,返回一个数组。数组第一个值为当前状态,第二个值为状态更新函数。状态更新函数和 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;
    });
}

七、 总结

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

  • 优点:
    • 使用原生语法实现、上手快、学习成本低
    • 体积小约为4.3k、速度跟Immutable.js差不多
    • 结构共享,返回没有变化的部分且对变化的数据有冻结功能
  • 缺点
    • 兼容性:对于不支持proxy的浏览器使用defineProperty实现,在性能上为proxy的两倍

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

八、 附录

  • 官方文档
  • Introducing Immer: Immutability the easy way
  • Copy-on-write
转载自:https://juejin.cn/post/7047450607984541710
评论
请登录