精读 Immer 源码(二)

1,513 阅读11分钟

前言

在上一篇文章 精读 Immer 源码(一),我详细分析了 core 部分的源码。其实 Immer 虽然是一个很简单的 JS 库,但是考虑到性能和体积,官方团队还是做了简单的插件机制。对于一些用户在特殊场景下才用到的功能,用户可以通过官方提供的 API 手动开启,也能让用户很清楚地知道使用了什么样的功能。 ​

目前 Immer 源码中,主要封装了三个插件:mapset、es5、patches,它们放在 src plugins 目录下,本文会通过详细的源码分析介绍每个插件的使用场景。 ​

通过看插件的源码,我们能知道 Immer 是怎么实现 Map、Set 这类数据的“Proxy”,如果要支持 es5 环境下的代理,也就是不能用 Proxy API,又要怎么 fallback。 ​

下面我们先从 mapset 插件开始,揭开每个插件的神秘面纱。 ​

mapset

精读 Immer 源码(一)一文中解读 core 源码的时候,在执行 createProxy 方法对数据进行 proxy 的时候,我们知道对于 Map、Set 等数据结构的代理走的是单独的 proxy 方法:

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)

而这些方法,实际上就是由 mapset 插件提供。在我们使用 Immer 的时候,默认是不支持 Map、Set 等数据结构的代理,我们必须调用 API 手动开启插件:

import { enableMapSet, produce } from 'immer'

enableMapSet()

const nextState = produce(new Map(), (draft) => {
  draft.set('immer', { title: 'Learn Immer', done: false })
})

那我们来看 mapset 具体是怎么实现的。 ​

先看下 proxyMap_ 的方法:

const DraftMap = (function (_super) {
  __extends(DraftMap, _super)
  // Create class manually, cause #502
  function DraftMap(this: any, target: AnyMap, parent?: ImmerState): any {
    this[DRAFT_STATE] = {
      type_: ProxyType.Map,
      parent_: parent,
      scope_: parent ? parent.scope_ : getCurrentScope()!,
      modified_: false,
      finalized_: false,
      copy_: undefined,
      assigned_: undefined,
      base_: target,
      draft_: this as any,
      isManual_: false,
      revoked_: false
    } as MapState
    return this
  }
  const p = DraftMap.prototype

  Object.defineProperty(p, 'size', {
    get: function () {
      return latest(this[DRAFT_STATE]).size
    }
    // enumerable: false,
    // configurable: true
  })

  p.has = function (key: any): boolean {
    return latest(this[DRAFT_STATE]).has(key)
  }

  p.set = function (key: any, value: any) {
    const state: MapState = this[DRAFT_STATE]
    assertUnrevoked(state)
    if (!latest(state).has(key) || latest(state).get(key) !== value) {
      prepareMapCopy(state)
      markChanged(state)
      state.assigned_!.set(key, true)
      state.copy_!.set(key, value)
      state.assigned_!.set(key, true)
    }
    return this
  }

  p.delete = function (key: any): boolean {
    if (!this.has(key)) {
      return false
    }

    const state: MapState = this[DRAFT_STATE]
    assertUnrevoked(state)
    prepareMapCopy(state)
    markChanged(state)
    state.assigned_!.set(key, false)
    state.copy_!.delete(key)
    return true
  }

  p.clear = function () {
    const state: MapState = this[DRAFT_STATE]
    assertUnrevoked(state)
    if (latest(state).size) {
      prepareMapCopy(state)
      markChanged(state)
      state.assigned_ = new Map()
      each(state.base_, (key) => {
        state.assigned_!.set(key, false)
      })
      state.copy_!.clear()
    }
  }

  p.forEach = function (
    cb: (value: any, key: any, self: any) => void,
    thisArg?: any
  ) {
    const state: MapState = this[DRAFT_STATE]
    latest(state).forEach((_value: any, key: any, _map: any) => {
      cb.call(thisArg, this.get(key), key, this)
    })
  }

  p.get = function (key: any): any {
    const state: MapState = this[DRAFT_STATE]
    assertUnrevoked(state)
    const value = latest(state).get(key)
    if (state.finalized_ || !isDraftable(value)) {
      return value
    }
    if (value !== state.base_.get(key)) {
      return value // either already drafted or reassigned
    }
    // despite what it looks, this creates a draft only once, see above condition
    const draft = createProxy(state.scope_.immer_, value, state)
    prepareMapCopy(state)
    state.copy_!.set(key, draft)
    return draft
  }

  p.keys = function (): IterableIterator<any> {
    return latest(this[DRAFT_STATE]).keys()
  }

  p.values = function (): IterableIterator<any> {
    const iterator = this.keys()
    return {
      [iteratorSymbol]: () => this.values(),
      next: () => {
        const r = iterator.next()
        /* istanbul ignore next */
        if (r.done) return r
        const value = this.get(r.value)
        return {
          done: false,
          value
        }
      }
    } as any
  }

  p.entries = function (): IterableIterator<[any, any]> {
    const iterator = this.keys()
    return {
      [iteratorSymbol]: () => this.entries(),
      next: () => {
        const r = iterator.next()
        /* istanbul ignore next */
        if (r.done) return r
        const value = this.get(r.value)
        return {
          done: false,
          value: [r.value, value]
        }
      }
    } as any
  }

  p[iteratorSymbol] = function () {
    return this.entries()
  }

  return DraftMap
})(Map)

function proxyMap_<T extends AnyMap>(target: T, parent?: ImmerState): T {
  // @ts-ignore
  return new DraftMap(target, parent)
}

初次看这里的代码,可能有些人有点蒙,为什么要自己构造一个 DraftMap,然后实现 Map 的所有增删改查的 API。 ​

如果对 Proxy 有过了解的同学就会知道,原生的 Proxy 是不支持代理 Map 和 Set 的一些增删改查操作的。我们做个实验:

const map = new Map([['immer'], [{ title: 'Lerna Immer', done: false }]])
const target = new Proxy(map, {
  get(obj, prop) {
    return obj[prop]
  },
  set(obj, prop, value) {
    obj.set(prop, value)
  }
})

console.log(target.get('immer'))
target.set('typescript', { title: 'Learn TS', done: false })

当我们去 get 数据的时候,devtool 报错如下: image.png 这跟 Map 的底层实现有关,Set 也是类似。Vue3 的响应式设计也使用了 Proxy,实际上也是有这个问题,所以解决方案也是构造一个新的对象去代理。 ​

搞清楚了这一点,我们继续看代码。 ​

其实 DraftMap 也会保存一个类似于 ProxyState 的数据,在这里叫 MapState,它存在一个叫 DRAFTSAFE 的属性上,跟 ProxyState 大多数属性类型,也会有 base 和 copy_ 属性,而所有的 get、set、has 方法都会映射到 copy__ 上,实际上这一块的设计 Immer 是做到了一致的,所以看懂了 core 中的对数组和 plain 对象的实现,看这里就比较轻松了。以 set 方法为例:

p.set = function (key: any, value: any) {
  const state: MapState = this[DRAFT_STATE]
  assertUnrevoked(state)
  if (!latest(state).has(key) || latest(state).get(key) !== value) {
    prepareMapCopy(state)
    markChanged(state)
    state.assigned_!.set(key, true)
    state.copy_!.set(key, value)
    state.assigned_!.set(key, true)
  }
  return this
}

当在外部调用 draft 对象的 set 方法,这里我们关注核心的逻辑。首先会判断最新的 state 有没有对应的 key,并且当前的 value 是不是跟更新的 value 一致,都不满足,才会走接下来的逻辑。 ​

首先要是初始化 copy** 属性,如果没有,从 base 拷贝一份。然后进行 Map 的 set 操作,也会将当前更新的 key 存在 assign** 属性里面,这里的逻辑还是比较简单的。 ​

其它的方法也是类似,Immer “Proxy” 的思路基本还是构造一个 state 对象,然后代理 state 对象的增删改查,然后再映射到 copy_ 属性中。对于 DraftSet 的实现思路跟 Map 是几乎一模一样的,只不过 Map 和 Set 的原型方法有些区别,这里就不重复解读了。 ​

es5

看完 mapset 插件,我们继续看 es5 插件的实现思路。 ​

我们知道 Proxy 是 es6 的 API,如果我们的功能需要支持 es5 的浏览器,那我们就要考虑使用别的方案就行数据代理。熟悉 Vue 的同学可能很容易就想到 Vue2 的响应式方案:Object.defineProperty,没错,Immer 也是使用该 API 实现对数据的劫持。 ​

同样在创建 draft 对象的时候,会根据当前的配置,如果浏览器不支持 Proxy,并且开启 es5 的插件,则使用 createES5Proxy_ 方法创建 draft 对象:

const draft: Drafted = getPlugin('ES5').createES5Proxy_(value, parent)

createES5Proxy_ 封装在 es5 插件中,看下具体实现:

function createES5Proxy_<T>(
  base: T,
  parent?: ImmerState
): Drafted<T, ES5ObjectState | ES5ArrayState> {
  const isArray = Array.isArray(base)
  const draft = createES5Draft(isArray, base)

  const state: ES5ObjectState | ES5ArrayState = {
    type_: isArray ? ProxyType.ES5Array : (ProxyType.ES5Object as any),
    scope_: parent ? parent.scope_ : getCurrentScope(),
    modified_: false,
    finalized_: false,
    assigned_: {},
    parent_: parent,
    // base is the object we are drafting
    base_: base,
    // draft is the draft object itself, that traps all reads and reads from either the base (if unmodified) or copy (if modified)
    draft_: draft,
    copy_: null,
    revoked_: false,
    isManual_: false
  }

  Object.defineProperty(draft, DRAFT_STATE, {
    value: state,
    // enumerable: false <- the default
    writable: true
  })
  return draft
}

对于构造 ProxyState 的处理,在这里对应的是 ES5ObjectState 和 ES5ArrayState, 前面已经提到了很多次,这里就不重复介绍。 ​

这个方法的关键还是在 createES5Draft 的调用吗,它创建了 draft 对象,看他的实现:

function createES5Draft(isArray: boolean, base: any) {
  if (isArray) {
    const draft = new Array(base.length)
    for (let i = 0; i < base.length; i++)
      Object.defineProperty(draft, '' + i, proxyProperty(i, true))
    return draft
  } else {
    const descriptors = getOwnPropertyDescriptors(base)
    delete descriptors[DRAFT_STATE as any]
    const keys = ownKeys(descriptors)
    for (let i = 0; i < keys.length; i++) {
      const key: any = keys[i]
      descriptors[key] = proxyProperty(
        key,
        isArray || !!descriptors[key].enumerable
      )
    }
    return Object.create(Object.getPrototypeOf(base), descriptors)
  }
}

对于数组,进行编辑后,通过 defineProperty 代理数组的每一项,最后调用了 proxyProperty 方法,去重写了属性的 set 和 get 方法:

function proxyProperty(
  prop: string | number,
  enumerable: boolean
): PropertyDescriptor {
  let desc = descriptors[prop]
  if (desc) {
    desc.enumerable = enumerable
  } else {
    descriptors[prop] = desc = {
      configurable: true,
      enumerable,
      get(this: any) {
        const state = this[DRAFT_STATE]
        if (__DEV__) assertUnrevoked(state)
        // @ts-ignore
        return objectTraps.get(state, prop)
      },
      set(this: any, value) {
        const state = this[DRAFT_STATE]
        if (__DEV__) assertUnrevoked(state)
        // @ts-ignore
        objectTraps.set(state, prop, value)
      }
    }
  }
  return desc
}

对于 plain 对象,则代理了对象所有的 ownKeys :

const descriptors = getOwnPropertyDescriptors(base)
delete descriptors[DRAFT_STATE as any]
const keys = ownKeys(descriptors)
for (let i = 0; i < keys.length; i++) {
  const key: any = keys[i]
  descriptors[key] = proxyProperty(
    key,
    isArray || !!descriptors[key].enumerable
  )
}
return Object.create(Object.getPrototypeOf(base), descriptors)

跟 Vue2.0 不一样的是,如果我们定义的数据对象和数组是嵌套的结构,Vue2.0 会在初始化的时候递归对属性进行劫持。Immer 在这里也是使用 lazy 的方式。 ​

如果看过 Immer 的同学,就知道,使用 Proxy 代理的方式,以对象为例,是在 objectTraps 里面对嵌套的属性进行 proxy。而在 es5 插件中,这里的 proxyProperty 方法引用的 objectTraps 实际上就是来自 core 源码中 proxy.ts 中。我们以 get traps 为例回顾下代码:

export const objectTraps: ProxyHandler<ProxyState> = {
  get(state, prop) {
    if (prop === DRAFT_STATE) return state

    const source = latest(state)
    if (!has(source, prop)) {
      // non-existing or non-own property...
      return readPropFromProto(state, source, prop)
    }
    const value = source[prop]
    if (state.finalized_ || !isDraftable(value)) {
      return value
    }
    // Check for existing draft in modified state.
    // Assigned values are never drafted. This catches any drafts we created, too.
    if (value === peek(state.base_, prop)) {
      prepareCopy(state)
      return (state.copy_![prop as any] = createProxy(
        state.scope_.immer_,
        value,
        state
      ))
    }
    return value
  }
  // 省略一些代码
}

在我们去访问嵌套属性的时候,就会再次调用 createProxy 方法,对嵌套的对象进行 proxy。 ​

使用 es5 插件的时候,跟使用 Proxy 还有个不同之处,在于最后一步,对于 processResult 的处理:

if (!scope.immer_.useProxies_)
  getPlugin('ES5').willFinalizeES5_(scope, result, isReplaced)

会多走一个特殊的 willFinalizeES5_ 逻辑,我们看下它的实现:

function willFinalizeES5_(scope: ImmerScope, result: any, isReplaced: boolean) {
  if (!isReplaced) {
    if (scope.patches_) {
      markChangesRecursively(scope.drafts_![0])
    }
    // This is faster when we don't care about which attributes changed.
    markChangesSweep(scope.drafts_)
  }
  // When a child draft is returned, look for changes.
  else if (
    isDraft(result) &&
    (result[DRAFT_STATE] as ES5State).scope_ === scope
  ) {
    markChangesSweep(scope.drafts_)
  }
}

我们先忽略 patch 相关逻辑,实际上就是做一个 markChangesSweep 逻辑:

// This looks expensive, but only proxies are visited, and only objects without known changes are scanned.
function markChangesSweep(drafts: Drafted<any, ImmerState>[]) {
  // The natural order of drafts in the `scope` array is based on when they
  // were accessed. By processing drafts in reverse natural order, we have a
  // better chance of processing leaf nodes first. When a leaf node is known to
  // have changed, we can avoid any traversal of its ancestor nodes.
  for (let i = drafts.length - 1; i >= 0; i--) {
    const state: ES5State = drafts[i][DRAFT_STATE]
    if (!state.modified_) {
      switch (state.type_) {
        case ProxyType.ES5Array:
          if (hasArrayChanges(state)) markChanged(state)
          break
        case ProxyType.ES5Object:
          if (hasObjectChanges(state)) markChanged(state)
          break
      }
    }
  }
}

其实就是对所有 draft 对象进行 markChanged 操作,markChanged 其实就是修改 state 的 modified_ 标识:

export function markChanged(state: ImmerState) {
  if (!state.modified_) {
    state.modified_ = true
    if (state.parent_) {
      markChanged(state.parent_)
    }
  }
}

为了优化性能, Immer 还做了数组或者对象是否有 change 的判断,这里不是特别核心的逻辑,就不再解读 hasArrayChanges 和 hasObjectChanges 方法了。 ​

总体来看,es5 插件逻辑略微有点绕,还有一些隐藏的逻辑需要理解 core 源码中 es6 Proxy 的实现才能知道。但其实只要明白了整个设计就是为了解决 es5 不支持 Proxy API,所以 fallback 到 defineProperty 方法来实现,整体的核心实现也不是很难理解。 ​

patches

要看懂这部分的源码,首先需要知道 patches 在 Immer 中是个什么功能,怎么用?打开官方文档,有个章节专门介绍 patches 相关的使用。 ​

首选需要知道,从 Immer v6.0 开始,patches 功能需要通过 API 开启:

Since version 6 support for Patches has to be enabled explicitly by calling enablePatches() once when starting your application.

开启了 patches 功能后,在我们调用 produce 期间,就会通过 patches 插件各 API 进行 patches 相关的数据存储。 ​

patches 功能主要有以下使用场景:

  • 与其它功能模块交换增量更新数据,例如通过 websockets;
  • 对于调试/追踪,可以准确地看到状态如何随时间更改,也就是追踪数据变化;
  • 作为撤销重做的基础功能,用来实现变化状态树的一种方式。

看下官网的使用例子:

import produce, { applyPatches } from 'immer'

// version 6
import { enablePatches } from 'immer'
enablePatches()

let state = {
  name: 'Micheal',
  age: 32
}

// Let's assume the user is in a wizard, and we don't know whether
// his changes should end up in the base state ultimately or not...
let fork = state
// all the changes the user made in the wizard
let changes = []
// the inverse of all the changes made in the wizard
let inverseChanges = []

fork = produce(
  fork,
  (draft) => {
    draft.age = 33
  },
  // The third argument to produce is a callback to which the patches will be fed
  (patches, inversePatches) => {
    changes.push(...patches)
    inverseChanges.push(...inversePatches)
  }
)

// In the meantime, our original state is replaced, as, for example,
// some changes were received from the server
state = produce(state, (draft) => {
  draft.name = 'Michel'
})

// When the wizard finishes (successfully) we can replay the changes that were in the fork onto the *new* state!
state = applyPatches(state, changes)

// state now contains the changes from both code paths!
expect(state).toEqual({
  name: 'Michel', // changed by the server
  age: 33 // changed by the wizard
})

// Finally, even after finishing the wizard, the user might change his mind and undo his changes...
state = applyPatches(state, inverseChanges)
expect(state).toEqual({
  name: 'Michel', // Not reverted
  age: 32 // Reverted
})

当我们开启了 patches 功能后,就可以给 produce 传第三个 patchListenner 参数,用于获取到每次修改 draft 后的 patches 和 inversePatches 数据,然后通过调用 applyPatches 拿到最新的 state 和上一次的 state。 ​

你可以直接调用 produceWithPatches 方法直接拿到每次修改 draft 后的 patches 和 inversePatches 数据,看一个例子:

import { produceWithPatches } from 'immer'

const [nextState, patches, inversePatches] = produceWithPatches(
  {
    age: 33
  },
  (draft) => {
    draft.age++
  }
)

说了这么多,那么 patch 是怎么来的了。在官网也提到了,其实 patch 的数据模型设计参考了:RFC 6902 JSON Patch 的规范。只是 Immer 中的 path 数据是使用数组表示:

;[
  {
    op: 'replace',
    path: ['profile'],
    value: { name: 'Veria', age: 5 }
  },
  { op: 'remove', path: ['tags', 3] }
]

而规范里面则是使用字符串,然后使用 / 分割多个值。 ​

在 patches 插件的实现中,主要暴露出来三个核心的 API:applyPatches*,generatePatches__, generateReplacementPatches*。_

我们先看下 _generatePatches 的实现:

function generatePatches_(
  state: ImmerState,
  basePath: PatchPath,
  patches: Patch[],
  inversePatches: Patch[]
): void {
  switch (state.type_) {
    case ProxyType.ProxyObject:
    case ProxyType.ES5Object:
    case ProxyType.Map:
      return generatePatchesFromAssigned(
        state,
        basePath,
        patches,
        inversePatches
      )
    case ProxyType.ES5Array:
    case ProxyType.ProxyArray:
      return generateArrayPatches(state, basePath, patches, inversePatches)
    case ProxyType.Set:
      return generateSetPatches(
        state as any as SetState,
        basePath,
        patches,
        inversePatches
      )
  }
}

这个方法比较简单,就是根据不同的数据类型,返回对应的 generatePatchsXXX 方法。对于可以 Proxy 的对象、es5 对象、Map 等数据结构走的是 generatePatchesFromAssigned 方法,数组一律走 generateArrayPatches 逻辑,Set 走独立的 generateSetPatches 方法。 ​

我们先看 generateArrayPatches 方法:

function generateArrayPatches(
  state: ES5ArrayState | ProxyArrayState,
  basePath: PatchPath,
  patches: Patch[],
  inversePatches: Patch[]
) {
  let { base_, assigned_ } = state
  let copy_ = state.copy_!

  // Reduce complexity by ensuring `base` is never longer.
  if (copy_.length < base_.length) {
    // @ts-ignore
    ;[base_, copy_] = [copy_, base_]
    ;[patches, inversePatches] = [inversePatches, patches]
  }

  // Process replaced indices.
  for (let i = 0; i < base_.length; i++) {
    if (assigned_[i] && copy_[i] !== base_[i]) {
      const path = basePath.concat([i])
      patches.push({
        op: REPLACE,
        path,
        // Need to maybe clone it, as it can in fact be the original value
        // due to the base/copy inversion at the start of this function
        value: clonePatchValueIfNeeded(copy_[i])
      })
      inversePatches.push({
        op: REPLACE,
        path,
        value: clonePatchValueIfNeeded(base_[i])
      })
    }
  }

  // Process added indices.
  for (let i = base_.length; i < copy_.length; i++) {
    const path = basePath.concat([i])
    patches.push({
      op: ADD,
      path,
      // Need to maybe clone it, as it can in fact be the original value
      // due to the base/copy inversion at the start of this function
      value: clonePatchValueIfNeeded(copy_[i])
    })
  }
  if (base_.length < copy_.length) {
    inversePatches.push({
      op: REPLACE,
      path: basePath.concat(['length']),
      value: base_.length
    })
  }
}

精读 Immer 源码(一)一文中分析 proxy 部分的源码时,每个 ProxyState 对象或者其它各代理的 MapState、ES5ObjectState 等对象,都有 base**、copy_、assigned** 属性,实际上在生成 patches 和 inversePatches 数据时,就是结合这三个属性存储的数据,经过一系列的算法计算,得到最后的 patches 数据。 ​

在方法的开始,有个非常 trick 的处理,如果发现 copy_ 的长度小于 base_ 的长度,会交换 base_ 和 copy_ 的值,inversePatches 和 patches 的值,为了减少后面处理的复杂度。因为下面处理的时候总是从数据最少的*开始,然后再到新增的部分数据。在 patche 的模型中,数据的增删改查被抽象为 repaceaddremove 等操作。 ​

看第一个 for 循环:

for (let i = 0; i < base_.length; i++) {
  if (assigned_[i] && copy_[i] !== base_[i]) {
    const path = basePath.concat([i])
    patches.push({
      op: REPLACE,
      path,
      // Need to maybe clone it, as it can in fact be the original value
      // due to the base/copy inversion at the start of this function
      value: clonePatchValueIfNeeded(copy_[i])
    })
    inversePatches.push({
      op: REPLACE,
      path,
      value: clonePatchValueIfNeeded(base_[i])
    })
  }
}

replace 操作指当前的数据项的 assign_ 标识为 true 且新的 copy_ 里面的数据不等于 base_ 里面的数据项,前面我们在看 ProxyState 源码时,assign_ 的属性注释是:

const state: ProxyState = {
  // 省略其它属性
  // Track which properties have been assigned (true) or deleted (false).
  assigned_: {}
  // 省略其它属性
}

所以看到这里的实现,我们就明白了这句注释的含义了。 ​

对于第二个 for 循环:

// Process added indices.
for (let i = base_.length; i < copy_.length; i++) {
  const path = basePath.concat([i])
  patches.push({
    op: ADD,
    path,
    // Need to maybe clone it, as it can in fact be the original value
    // due to the base/copy inversion at the start of this function
    value: clonePatchValueIfNeeded(copy_[i])
  })
}

这个就比较好理解了,对于多出来的数据项,那肯定就属于 add 操作了。 ​

最后还需要根据新老数据的长度变化,存一个 replace 操作:

if (base_.length < copy_.length) {
  inversePatches.push({
    op: REPLACE,
    path: basePath.concat(['length']),
    value: base_.length
  })
}

其它两个方法的实现,思路类似,这里就不重复介绍了。关键是要理解 patch 的设计思路,其实具体的算法实现就是要看使用场景了。 ​

最后我们还要看下 generatePatches_ 的调用时机:

function finalize(rootScope: ImmerScope, value: any, path?: PatchPath) {
    // 省略一些代码
    // first time finalizing, let's create those patches
    if (path && rootScope.patches_) {
        getPlugin("Patches").generatePatches_(
            state,
            path,
            rootScope.patches_,
            rootScope.inversePatches_!
            )
        }
    }
    return state.copy_
}

这个方法,相信大家看过前面的分析应该很熟悉了,在 finalize 章节详细分析过,只是当时跳过了 patch 相关的代码。也就是说生成 patchs 数据的阶段是 produce 方法最后的调用 processResult 方法的时候,这时候执行完了 recipe 函数,copy_ 数据也已经是最新的,所以这时候去计算 patches 数据是合理的。 ​

看完了 generatePatches_ 方法,我们继续看 applyPatches__ 方法:

function applyPatches_<T>(draft: T, patches: Patch[]): T {
  patches.forEach((patch) => {
    const { path, op } = patch

    let base: any = draft
    for (let i = 0; i < path.length - 1; i++) {
      const parentType = getArchtype(base)
      const p = '' + path[i]
      // See #738, avoid prototype pollution
      if (
        (parentType === Archtype.Object || parentType === Archtype.Array) &&
        (p === '__proto__' || p === 'constructor')
      )
        die(24)
      if (typeof base === 'function' && p === 'prototype') die(24)
      base = get(base, p)
      if (typeof base !== 'object') die(15, path.join('/'))
    }

    const type = getArchtype(base)
    const value = deepClonePatchValue(patch.value) // used to clone patch to ensure original patch is not modified, see #411
    const key = path[path.length - 1]
    switch (op) {
      case REPLACE:
        switch (type) {
          case Archtype.Map:
            return base.set(key, value)
          /* istanbul ignore next */
          case Archtype.Set:
            die(16)
          default:
            // if value is an object, then it's assigned by reference
            // in the following add or remove ops, the value field inside the patch will also be modifyed
            // so we use value from the cloned patch
            // @ts-ignore
            return (base[key] = value)
        }
      case ADD:
        switch (type) {
          case Archtype.Array:
            return key === '-'
              ? base.push(value)
              : base.splice(key as any, 0, value)
          case Archtype.Map:
            return base.set(key, value)
          case Archtype.Set:
            return base.add(value)
          default:
            return (base[key] = value)
        }
      case REMOVE:
        switch (type) {
          case Archtype.Array:
            return base.splice(key as any, 1)
          case Archtype.Map:
            return base.delete(key)
          case Archtype.Set:
            return base.delete(patch.value)
          default:
            return delete base[key]
        }
      default:
        die(17, op)
    }
  })

  return draft
}

这个方法的作用就是,通过 draft 和 patches 数据,将 draft 进行一系列的 patch 操作,能回到最初的数据 draft 或者算出最新的 draft。 ​

前面我们在分析 generatePatches 的时候,发现对于任何对象的增删改查,最后被抽象成一个个 pacth 对象,然后 applyPatches_ 就能将一个个 patch 对象作用到 draft 对象上。所以在刚开始的例子中,我们通过 applyPatches 方法和 patches 数据拿到最新的 state,也能通过最新的 draft 对象和 inversePatches 数据得到最初的 state。 ​

暴露给外面使用的 applyPatches 方法内部就是调用 applyPatches_ 方法:

applyPatches<T extends Objectish>(base: T, patches: Patch[]): T {
    // If a patch replaces the entire state, take that replacement as base
    // before applying patches
    let i: number
    for (i = patches.length - 1; i >= 0; i--) {
        const patch = patches[i]
	if (patch.path.length === 0 && patch.op === "replace") {
            base = patch.value
            break
        }
    }
    // If there was a patch that replaced the entire state, start from the
    // patch after that.
    if (i > -1) {
        patches = patches.slice(i + 1)
    }

    const applyPatchesImpl = getPlugin("Patches").applyPatches_
    if (isDraft(base)) {
        // N.B: never hits if some patch a replacement, patches are never drafts
        return applyPatchesImpl(base, patches)
    }
    // Otherwise, produce a copy of the base state.
    return this.produce(base, (draft: Drafted) =>
        applyPatchesImpl(draft, patches)
    )
}

总结

看完了三个插件,总体来说,Immer 的插件化设计还是很值得学习的。虽然 Immer 本身功能不复杂,引入插件化机制,也使得扩展功能相对来说比较容易;同时通过按需加载,也能减少整个包的体积。通过阅读插件源码,我们还知道了:

  • 如果要支持 Map 和 Set 等数据结构,需要通过 enableMapSet 开启插件,而且因为 Map 和 Set 等数据的特殊性,不能直接使用 Proxy 进行数据劫持,所以通过构造 DraftMap 和 DraftSet 等数据结构,实现两种数据的增删改查 API ,然后在代理到原始数据上;
  • 如果要支持 es5 的环境,则需要手动开启 es5 的插件,会将 Proxy 方案 fallback 到使用 Object.defineProperty API;
  • 通过 patches 插件,使得我们可以实现一些特定的业务功能,比如撤销和重做、记录每一次数据操作,方便追踪。