5

Immutable的后浪——Immer.js

 3 years ago
source link: https://zhuanlan.zhihu.com/p/143056270
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Immutable的后浪——Immer.js

简介

ReactReconciliation解决了虚拟DOM更新的问题,但没有对在什么场景下优化更新给出解决方案,它不像Reactive那样能自动跟踪状态变化,而是把这个难题通过shouldComponentUpdate这个口子留给了开发者。于是开发者需要针对前后状态的变化进行对比,以决定要不要继续渲染更新DOM。如果状态对象的层次很深很复杂,那么这个对比必然会很痛苦。

但是话说回来,如果比较得很痛苦,那很有可能状态对象设计的有问题

于是Immutable诞生了,简单的可以把Immutable看做是一个黑盒,输入是对象A,输出是一个对象A‘。

用公式表示就是

假如输入对象A1A2完全一样(但仍然不是同一个实例),那么输出的A1'A2’能方便地比较出是否相等(提供了API直接调用来比较),同时也能从A1'A2'里拿出A1A2的原始数据(但A1'A2‘仍然是两个不同的实例)。后续更新的操作也都是要通过A'的API来进行了,也就是说原始对象已经离你远去。

名气最大的Immutable解决方案就是当年的Immutable.js,本文介绍的Immer.js属于Immutable界的后浪。Immer这个词来自于德语的Always,它的作者同时也是Mobx的作者。

先上图,看样子仍然是个黑盒不变。

如果真没任何变化,那也称不上是后浪了。它基于copy-on-write机制——在当前的状态数据上复制出一份临时的草稿,然后对这份草稿涂涂改改,最后生成新的状态数据。借力于ES6Proxy,只需要直接修改对象即可,不需要调用复杂的API(几乎只用到一个produce函数!!!),跟响应式的自动跟踪是一样的。

官网文档将其比喻成你的小秘(不是小蜜),这个小秘复制一份你的信给你涂改,然后根据涂改能帮你打印出一份新的修改后的信件。

 import produce from "immer"
 ​
 const baseState = [
     {
         todo: "Learn typescript",
         done: true
     },
     {
         todo: "Try immer",
         done: false
     }
 ]
 ​
 const nextState = produce(baseState, draftState => {
     draftState.push({todo: "Tweet about it"})
     draftState[1].done = true
 })

如果了解响应式的话,会发现

React里,setState就变成了

 this.setState(
     produce(draft => {
         draft.user.age += 1
     })
 )

Redux里,Reducer就变成了

 const byId = produce((draft, action) => {
     switch (action.type) {
         case RECEIVE_PRODUCTS:
             action.products.forEach(product => {
                 draft[product.id] = product
             })
     }
 })

default分支也可以省略,因为produce方法默认返回原始对象实例。

也有个例外,就是如果要返回undefined还不能直接干,得要返回nothing

 import produce, {nothing} from "immer"
 const state = {
     hello: "world"
 }
 ​
 produce(state, draft => {})
 produce(state, draft => undefined)
 // Both return the original state: { hello: "world"}
 ​
 produce(state, draft => nothing)
 // Produces a new state, 'undefined'

源码

produce

以普通对象为例看一下源码部分,先不看数组之类的分支,produce方法的核心部分如下

 // Only plain objects, arrays, and "immerable classes" are drafted.
 produce(base: any, recipe?: any, patchListener?: any) {
     //...
     if (isDraftable(base)) {
         const scope = enterScope(this)
         const proxy = createProxy(this, base, undefined)
         let hasError = true
         try {
             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 {
         //...
     }
 }

跟图里描述的一模一样,先用createProxy(this, base, undefined)生成了draftState,然后调用第二个参数也就是修改方法,传入这个draftState,最后把结果用processResult解出来返回。

createProxy里辗转来到proxy.ts里创建Proxy对象,先拿传入的对象封装一下,这里的注释很重要,特意保留

 const state: ProxyState = {
     type_: isArray ? ProxyTypeProxyArray : (ProxyTypeProxyObject as any),
     // Track which produce call this is associated with.
     scope_: parent ? parent.scope_ : getCurrentScope()!,
     // True for both shallow and deep changes.
     modified_: false,
     // Used during finalization.
     finalized_: false,
     // Track which properties have been assigned (true) or deleted (false).
     assigned_: {},
     // The parent draft state.
     parent_: parent,
     // The base state.
     base_: base,
     // The base proxy.
     draft_: null as any, // set below
     // Any property proxies.
     drafts_: {},
     // The base copy with any updated values.
     copy_: null,
     // Called by the `produce` function.
     revoke_: null as any,
     isManual_: false
 }

然后你要的Proxy对象终于来了

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

traps就是一个ProxyHandler类型的对象,像Proxy里用到的get、set方法都在这里面定义

 interface ProxyHandler<T extends object> {
     getPrototypeOf? (target: T): object | null;
     setPrototypeOf? (target: T, v: any): boolean;
     isExtensible? (target: T): boolean;
     preventExtensions? (target: T): boolean;
     getOwnPropertyDescriptor? (target: T, p: PropertyKey): PropertyDescriptor | undefined;
     has? (target: T, p: PropertyKey): boolean;
     get? (target: T, p: PropertyKey, receiver: any): any;
     set? (target: T, p: PropertyKey, value: any, receiver: any): boolean;
     deleteProperty? (target: T, p: PropertyKey): boolean;
     defineProperty? (target: T, p: PropertyKey, attributes: PropertyDescriptor): boolean;
     enumerate? (target: T): PropertyKey[];
     ownKeys? (target: T): PropertyKey[];
     apply? (target: T, thisArg: any, argArray?: any): any;
     construct? (target: T, argArray: any, newTarget?: any): object;
 }

get

跟踪数据变化主要是看gettersetter两个方法。

刚才在produce里已经把根部的draftState建立好了,后续的代理对象就根据getter被调用的对象路径再懒初始化建出来。比如draftState.user.info.name那么就会按次序调用draftState.userdraftState.user.infodraftState.user.info.namegetter方法。

代理对象只能监听一层,比如draftState建出来后,只能针对draftState.xXx属性的变化监听,对于draftState.xXx.xXx就监听不到,所以才需要把整个路径的代理对象都建立出来。好在Proxy本身的机制就会依次进入getter,不需要我们手工去解析路径。

 get(state, prop) {
     if (prop === DRAFT_STATE) return state
     let {drafts_: drafts} = state
 ​
     //... 省略代码
 ​
     return (drafts![prop as any] = createProxy(
         state.scope_.immer_,
         value,
         state
     ))
 }

set

再看set方法,通过modified_做了一层缓存标记,如果还没有改变标记,就比较一下前后状态是否相同,一旦发生了变化,就继续,详细过程稍后有分析

 set(state, prop: string /* strictly not, but helps TS */, value) {
     if (!state.modified_) {
         const baseValue = peek(state.base_, prop)
         const isUnchanged = value
             ? is(baseValue, value) || value === state.drafts_![prop]
             : is(baseValue, value) && prop in state.base_
         if (isUnchanged) return true
         prepareCopy(state)
         markChangedProxy(state)
     }
     state.assigned_[prop] = true
     // @ts-ignore
     state.copy_![prop] = value
     return true
 }

prepareCopy的代码如下

 function prepareCopy(state: ProxyState) {
     if (!state.copy_) {
         state.copy_ = shallowCopy(state.base_)
     }
 }

markChangedProxy的代码如下

 export function markChangedProxy(state: ImmerState) {
     if (!state.modified_) {
         state.modified_ = true
         if (
             state.type_ === ProxyTypeProxyObject ||
             state.type_ === ProxyTypeProxyArray
         ) {
             const copy = (state.copy_ = shallowCopy(state.base_))
             each(state.drafts_!, (key, value) => {
                 // @ts-ignore
                 copy[key] = value
             })
             state.drafts_ = undefined
         }
 ​
         if (state.parent_) {
             markChangedProxy(state.parent_)
         }
     }
 }

注意这里是向上递归。

实例

下面以一个例子走一遍流程

 const baseState = {
     user: {
         info: {
             name: 'A'
         }
     }
 }
 ​
 const nextState = immer.produce(baseState, draftState => {
     draftState.user.info.name = 'B'
 })

draftState.user.info.name = 'B'这一句赋值经历了getset两个过程,如果打上断点可以看到先后三次进入了getter方法,生成三个不同的代理对象,分别对应draftState.userdraftState.user.infodraftState.user.info.name

等到赋值为B的时候,setter的第一个参数是

第二个参数prop"name",第三个参数value为"B"

  1. 此时state.modified_false,于是进入代码块
  2. 首先取出原来的值,也即是baseValue拿出来为A
  3. 然后比较一下,注意这里还区分了+0-0以及NaN等几个特殊的值,发现还是有变化的,于是继续
  4. 进入prepareCopy,浅复制state.base_也即是{name: "A"}state.copy_
  5. 进入markChangedProxy
    1. state.modified_true,下一次再修改就不用重复这些了
    2. 浅复制state.base_state.copy_
    3. 发现state.parent_不为空,于是向上递归置标记并浅复制,使得整个一条链都换新了

setter结束后还没完,别忘了到目前为止还仅仅是操作草稿数据,最终还要打印出最后正式的信件。于是回到produce方法里,此时的复制件上面都是涂涂改改的痕迹(许多的draft属性),于是跑到finalize方法里,根据刚才打上的modified_标记,要么直接返回原来的对象,要么继续遍历属性并调用finalizeProperty方法。

 function finalizeProperty(
     rootScope: ImmerScope,
     parentState: undefined | ImmerState,
     targetObject: any,
     prop: string | number,
     childValue: any,
     rootPath?: PatchPath
 ) {
     if (__DEV__ && childValue === targetObject) die(5)
     if (isDraft(childValue)) {
         const path =
             rootPath &&
             parentState &&
             parentState!.type_ !== ProxyTypeSet && 
             !has((parentState as Exclude<ImmerState, SetState>).assigned_!, prop) 
                 ? rootPath!.concat(prop)
                 : undefined
 ​
         const res = finalize(rootScope, childValue, path)
         set(targetObject, prop, res)
 ​
         if (isDraft(res)) {
             rootScope.canAutoFreeze_ = false
         } else return
     }
 ​
     if (parentState && is(childValue, get(parentState!.base_, prop))) {
         return
     }
 ​
     if (isDraftable(childValue)) {
         if (!rootScope.immer_.autoFreeze_ && rootScope.unfinalizedDrafts_ < 1) {
             return
         }
         finalize(rootScope, childValue)
 ​
         if (!parentState || !parentState.scope_.parent_)
             maybeFreeze(rootScope, childValue)
     }
 }

这个方法会递归调用finalize,这里直接读代码真是很累,建议用实际场景debug跟踪一下。

immer.js也有基于ES5的插件,背后底层自然是使用了defineProperty,既然是MobX的作者亲手实操,当然是轻车熟路的了。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK