Immutable II

576 阅读8分钟

前言

Immutable I中,已经讲到为什么要实现并利用immutable state,在当前前端各个视图框架或库中,data->view是核心思想,在render过程利用immutable state来尽可能减少re-rendering,将可以大幅度提升性能。同时也提到了由Facebook推出的嫡系工具库immutable.js,是react社区的一柄利器。正如Chapter I 中说到的,immutable.js利用的是Persistent Vectors机制,是典型的利用空间换时间的做法。那么还有没有比immutable.js更好的实现方式呢?答案是肯定的,请出本文的主角immerimmer才是更适合你的immutable state实现方案,它已经俨然成为react社区的新宠儿。

immer

简介

immer提供了一种更加便捷的方式来实现immutable state,是基于一种“复写”机制来实现的。意思是说,在对current state进行修改时,会生成一个temporary draft state作为代理proxy state,最后修改完成时,会基于被修改的draft state产生next state而保持current state不变。意味着在数据修改过程中,只需要对proxy state进行简单赋值操作就能享有所有immutable state带来的好处。下图引自immer官方文档,从图上我们也能清晰的看出来immer是如何实现immutable state

immer.png

immer内部将要被编辑修改的state称作一份draft(草稿),同时也规定了只有object、array、Map、Set及实现Symbol("immer-draftable")才能作为符合要求的draft。

简单produce

先来一个简单的例子来比较直观的了解下immer的Immutable实现方式,也就是展示一下immer produce方法的魅力。

顾名思义:produce即是生产。但这里更恰当的理解是“复制”,只是这个“复制”的过程中会生成代理。根据输入“复制”得到一个全新的输出,同时保留输入不发生任何变化。

import produce from "immer"

const baseState = [
  {
    todo: "Learn typescript",
    done: true
  }
];

const nextState = produce(baseState, draft => {
  draftState.push({todo: "Tweet about it"}) // 简单的对象操作
  draftState[0].done = false // 简单的属性复制
});

借助上面这个例子,下面我们就来分析一下immer的工作原理。

工作原理

结合上面提到的immer“复写”机制的示意图,我们来看下immer的工作原理。下面是原理流程图,

immer1.png

从上图中我们可以比较清晰的了解到immer是如何实现这样一种简单优雅的Immutable机制,核心思想是current -> proxy -> next

事实上从图中可以得出最核心的步骤是,

  1. 生成proxy,proxy = createProxy(current),根据current生成proxy
  2. proxy如何处理数据gettersetter来维护数据副本copy,result = recipe(proxy),传递proxy完成数据变更获取最新数据

immer内部默认情况下是基于ES6Proxy来实现生成proxy,在不支持ES6的环境下也可以通过内部插件来开启实现。

我们来着重看下produce函数内部做了什么:

  1. 生成scope:一次produce有且只会生成一个root scope,用于createProxy时关联proxy target,同时会沿着proxy target传递下去进行递归proxy creation,这样递归链上生成所有的proxy都会关联到root scope进行缓存,当有嵌套produce时还会关联父级scope,从而形成scope chain用于最终的finalize
  2. 生成proxy:作为在数据修改过程中实际被操作的代理,过程中每个proxy都会同时保留原始base、副本copy,最终会利用副本来生成最新的数据。proxy作为一个ES6Proxy实例,其target就是对base state包装的proxy state
  1. proxy handler getter拦截里会根据修改的key取到对应的最近修改的值,如果未被修改且发现对应value未生成proxy,会进行递归的proxy creation,此时会传递当前immer实例及当前proxy state
  1. 执行recipe:调用recipe生成最终的数据,此间会把生成好的proxy传递进去,此时,大家就能明白(draft) => next句柄中的draft是什么了,事实上就是这个新生成的proxy,所有对draft的修改操作都会被proxy拦截代理。
  2. 最后finalize:根据proxy statecopy来生成最终全新的next state

从上面简单produce例子代码中,可以发现在immer produce中,接收baseStaterecipe handler,同时在recipe handler中对生成的proxy进行修改,而且你会发现对其进行修改时不需要额外复杂的api,只需要最平常的对象操作属性赋值,这种数据修改方式给予我们很大的便利,也降低了在已有应用中加入Immutable机制的改造成本。生成的这个proxy很好“保护”了baseStatebaseState就好像从来没被碰过一样,返回的nextState就是基于proxy修改之后的全新state。

produce方法的源码中我们可以发现上面说的这个过程,这个时候你可能非常关心这个proxy又做了什么,但如果你只是纯粹使用immerproduce来实现Immutable的话,你只要明白proxy即是一个ES6 Proxy实例,它的target是对baseState的包装,proxygetter拦截读取baseState里的对应值,再用setter拦截设置自己的副本,最终返回该副本。

但是你如果不满足于理解immer如何使用,而想看看它的内部魔法,那我们一起往下看。

const proxy = createProxy(this, base, undefined)
try {
  result = recipe(proxy) // 将proxy传入recipe handler中,也就是上面例子中的draft
}

Curried produce

上面说到简单produce的用法对于保持Immutable是非常实用的,但不是最吸引人的,因为通过这种方式得到一个Immutable state难免看着有点“低效”,是否有一种更高效且更贴合react习惯的recipe handlerimmer不愧是react社区的新贵,当然不会忘记满足这样的诉求,提供了另一种更强大的produce方式(curried produce)。看下面的例子

// mapper will be of signature (state, index) => state
const mapper = produce((draft, index) => {
    draft.index = index
})
// example usage
console.dir([{}, {}, {}].map(mapper))
//[{index: 0}, {index: 1}, {index: 2}])

produce接收一个函数时,会得到像(draft,other) => next这样的curried produce,想必大家对前面这样的函数签名非常熟悉,一定能联想到react setState里的functional state以及redux reducer,这也是immer作为更适合的Immutable实现方式的另一个原因,更贴合react技术栈。同样我们看看produce内部做了什么,可以得到这样的一个curried produce

if (typeof base === "function" && typeof recipe !== "function") {
  const defaultBase = recipe
  recipe = base
  const self = this
  return function curriedProduce(
    this: any, // ts写法
    base = defaultBase, // 这里会传入base state, 例如react component state或者是reducer state之类的
    ...args: any[] // 可以传入像reducer action之类的
  ) {
    // 返回一个全新的state, 这里和简单produce的处理一样
    return self.produce(base, (draft: Drafted) => recipe.call(this, draft, ...args))
  }
}

从上面源码可以看出来,就是对produce做了一次curry,将收到的第一个参数作为recipe handler,第二个参数作为默认的baseState,这也完美契合了redux reducer默认state的做法。得到的curriedProduce第一个参数会接收一个baseState,例如react component state或者redux reducer state,第二个参数可以接收像reducer action之类的。

Redux reducer
  • 不用immer的reducer长这样,里面存在各种对当前state的merge处理,生怕修改影响到其他属性,往往会使得代码看着有点点“冗余”和“混乱”
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之后,完全不需要考虑当前的state,尽情修改就是,不仅代码简洁明了,往往更贴近代码逻辑语义。
import produce from "immer"

const byId = produce((draft, action) => {
  switch (action.type) {
    case RECEIVE_PRODUCTS:
      action.products.forEach(product => {
        draft[product.id] = product
      })
  }
})
React setState
  • 不用immer的setState,与reducer遇到同样的问题
onClick = () => {
  this.setState(prevState => ({
    user: {
      ...prevState.user,
      age: prevState.user.age + 1
    }
  }))
}
  • 使用immer之后,curried produce刚好可以作为functional stateprevState即是当前的baseState
onClick = () => {
  this.setState(
    produce(draft => {
      draft.user.age += 1
    })
  )
}

异步处理

对于state的修改,不只停留在同步简单修改,也会有需要根据异步获取到的数据来进行修改的场景,immer同样不会忽略这一点,这也可以作为immer成为更合适的Immutable实现方式的一个原因。produce同样具备实现异步修改的能力。

if (typeof Promise !== "undefined" && result instanceof Promise) {
  return result.then(
    result => {
      usePatchesInScope(scope, patchListener)
      return processResult(result, scope) // 进入异步
    },
    error => {
      revokeScope(scope)
      throw error
    }
  )
}

从上面源码可以看出,只需要recipe handler简单的返回一个Promise就可以实现异步修改。如下面的例子,在recipe handler里可以将异步转同步,本质上还是可以像简单produce那样修改state,等到对于所有想要修改的fields都做好修改,就自然而然拿到了最终的全新state。

import produce from "immer"

const user = {
  name: "michel",
  todos: []
}

const loadedUser = await produce(user, async function(draft) {
  draft.todos = await doSomeAsyncWork(); // 此处实现异步修改
})

👆上面这样的异步Immutable方式,我觉得这是一种非常舒服的编码方式,有太多的可操作空间,得到的修改后的数据是可以马上用于像setState这样的触发re-rendering,所有的代码逻辑语义都是非常顺畅的,想修改什么field,修改它,不用考虑其他不希望修改的fields,然后更新视图,真正的data驱动view,不再有不必要的re-rendering。

总结

immer最为一种更便捷的Immutable机制,当我们使用它时,几乎都感觉不到它的存在而又无处不体现它的原理的精妙。用一种最贴合data->view思想的直观数据变更方式而不影响我们的编码习惯,真的是编码效率和应用性能的双丰收,大家可以多尝试一些immer在react技术栈中的玩法,打造更加健壮的应用。