更新:现在多了一个状况库适配了 immer:jotai-immer

文章目录

Immer 现在已经成为了许多状况库的标配,包括 Redux, zustand,在去年的库运用频率的调研中,Immer 也是独占鳌头。

不可变数据流的完美处理方案——Immer 源码解读

为了照顾没有 Immer 运用经历的同学,本文并不直接深化源码,而是包含三个方面:

  1. 基于 React,阐明原先的问题,探讨为什么需要 Immer
  2. Immer 的根底用法,为源码解读做衬托
  3. Immer 源码解读

Immer 官网

顾虑重重的深复制

1

在 JS 中,完结深复制一般来说有下面几种方式:

  1. 调用库函数,如 lodash 下的 cloneDeep。
  2. 运用 JSON.stringifyJSON.parse ,可是这种没办法复制不属于 JSON 格局的值,例如 Date 目标、函数、Map、Set 等。
  3. 运用 structuredClone,比第 2 种办法要好,它支撑像 Map、Set 这些 JS 内置的目标,可是它不复制原型链上的,也不能复制函数。

事实上,在实际的场景中咱们一般仍是运用第 1 种办法。

2

在 React 中,有一个或许能够称之为比较反直觉的现象,能够先看代码,再看下方的 GIF 图:

function App() {
  const [countRef, setCount] = useState({ count: 1 })
  console.log('rerendered')
  function handleClick() {
    countRef.count += 1
    setCount(countRef)
  }
  return (
    <button onClick={handleClick}>
      count is {countRef.count}
    </button>
  )
}

不可变数据流的完美处理方案——Immer 源码解读

能够看到,当咱们点击按钮的时分,值的改动没有表现出来,一起也没有触发组件的从头渲染,也就是说,countRef 的索引一直没变,setCount 触发组件渲染失效了。

最朴素的处理的办法就是每次从头赋值 countRef 之前都深复制一份,上面咱们列的三个办法能够随意选用一个,在这儿咱们选择不太常见的第三个:

 function handleClick() {
    const newCountRef = structuredClone(countRef)
    newCountRef.count += 1
    setCount(newCountRef)
  }

此刻再看:

不可变数据流的完美处理方案——Immer 源码解读

这种处理方案能够说是简略粗暴,咱们在某些时分也是懒得想,直接运用。但它也有其局限性:它在数据量大的时分就会出现功能瓶颈。

假如咱们一个列表有非常多项,假如咱们只更改了列表中某一项的一个值,接着对一整个列表做一遍深复制的话,整个列表无关的子项也都会从头渲染一遍,这势必会形成非常大的功能损耗。

暂时救火的浅复制

深复制已然不可,咱们或许得采用浅复制了。

所以,最简略的办法或许形如下面这样:

list[1].title = 'new title'
setList([...list])

咱们运用浅复制把 list 的地址更改一下,这样也能触发更新。在 React 中,假如咱们的子组件没有运用 React.memo 包裹,父组件有了更新会触发下面每一个子组件从头渲染,除非子项是一个 Text Node 节点,就算是子组件没有接受任何 props,新旧 props 目标都是空目标 {},进行比较也不等,如下图所示:

不可变数据流的完美处理方案——Immer 源码解读

优点是运用 React 的这个特性,不深复制目标,只改动列表目标的索引,就能触发列表的更新。坏处就是为了形成了许多子项的 Fiber Diff,为了处理这个问题,咱们许多时分都得包裹 React.memo。而正是因为运用 React.memo 包裹,也引发了后面的问题。

咱们能够设计下面这个比如,你能够在线预览调试一下,推荐点击右上角【查看详情】去查看,当点击 【Change Title】按钮,没有任何反响,就是咱们上面说的问题了:

假如你看完了上面的比如,应该就能体会出什么问题了,怎么去处理这个问题呢?最朴素的办法则是在自界说 React.memocompare 函数:

const Item = React.memo(({ subject }: { subject: any }) => {
  return <div>{subject.title}</div>
}, (prev, cur) => {
  if (prev.subject !== cur.subject) {
    return false
  }
  return true
})

可是太遗憾了,仍是不可,因为咱们更改的 list 和新的是同一个索引地址,也就是说 prevcur 都是同一个。你能够在上面的在线代码片段里修正试一下。

莫非这个问题就无解了吗?当然不是的,那就是不传递目标下来,而是把值解构出来传下来,这个办法是用到了 memo 函数默认的比较函数是比较 props 每一项的特点:

const Item = React.memo(({ title }: { title: string }) => {
  return <div>{title}</div>
})

又或者改值的时分:

  function handleChange() {
    setList([
      { title: 'Javascript changed', name: 'foo' },
      list[1],
      list[2]  
    ])
  }

信任,运用 React 的许多同学都会采用过上面两种写法,乃至还有更费事的:

setObject({
    ...level1,
    appendLevel1: {
        ...appendLevel1,
        appendLevel2: {
            ...
        }
    }
})

上述代码在以前的 Redux 中可谓遍地都是,笔者之前的团队曾在小程序中引进了 Redux,可是没有运用 Immer。写起来费事是一个方面,功能也不见得好到哪里去,因为小程序功能有限,在列表到达 200 项的时分,一些低端机器就开端闪退了。

Immer 怎么处理

Immer 的出现处理了上面的问题,并且完美的兼容 React.memo,上面的比如只需改形成:

import produce from "immer"
// ...
function handleChange() {
    const nextList = produce(list, (draft) => {
      draft[0].title = 'Javascript Changed'
    })
    setList(nextList)
}

在 Hook 中的运用也非常方便:

import React, { useCallback } from "react";
import { useImmer } from "use-immer";
function App() {
    const [todos, setTodos] = useImmer([
        {
            id: "React",
            title: "Learn React",
            done: true
        },
        {
            id: "Immer",
            title: "Try Immer",
            done: false
        }
    ]);
    const handleToggle = useCallback((id) => {
        setTodos((draft) => {
            const todo = draft.find((todo) => todo.id === id);
            todo.done = !todo.done;
        });
    }, []);
    // 其余部分省略...
}

假如对完好示例感兴趣,请参阅 官网。

Redux Toolkit 也是内置承继了 Immer,主要的改动在 Reducer ,改为了下面这样的格局书写:

export const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment: (state) => {
      state.value += 1
    },
    decrement: (state) => {
      state.value -= 1
    },
  },
})

提到这儿,不敢信任谁能抵御得住,想去了解这个库源码的引诱。话不多说,现在就为咱们带来它的源码解读。

本文只解析 produce 函数的逻辑。

Immer 源码解读

前置知识

Proxy

  1. Proxy.revocable 阅览材料:咱们能够调用 new Proxy() 去创立一个署理,也能够运用 Proxy.revocable,可是后者能够销毁掉 proxy 目标。
var revocable = Proxy.revocable({}, {
  get(target, name) {
    return "[[" + name + "]]";
  }
});
var proxy = revocable.proxy;
proxy.foo;              // "[[foo]]"
revocable.revoke(); // proxy 不起作用了,再取值会报错

树的深度优先遍历

目标 的整个署理进程就是一个深度优先遍历,伪代码如下:

function traverse (obj) {
    for (let key of Object.keys(obj)) {
        doSomethingIn(obj[key])
        traverse(obj[key])
    }
}

接下来,咱们把流程拆解为 7 步。

Step 1

预备工作,先克隆下代码,安装依靠:

git clone https://github.com/immerjs/immer.git
pnpm i

这个 是进口函数的地址,假如你想和我一块,能够找到这个方位。

假如你想调试代码,能够直接起一个安装了 Immer 的项目进行 Debugger,可是这样某些变量名会被转译,有点妨碍阅览,下面是一个很笨可是管用的办法:

  1. Fork 项目
  2. 在根目录新建一个 Vite 环境
  3. 引进进口文件导出的 produce 函数
  4. 有报错,处理它

Step 2

咱们先给一个最小的完结,让咱们快速抓住脉络。咱们先来说一下它的完结思路。

简略的说,Immer 把原始目标的每一个目标里的值扩充为:

var obj = {
    a: a的值
}
// 转化为
var proxyTarget = {
    a: {
        base_: a的值
        copy_: a的深复制
    }
}

也就是说,原本的值存为 base_, 保存一份对原始目标的深复制(并不准确,只是为了咱们了解),记作 copy_。一起会保护一个 proxy 目标,这个 proxy 目标就是 recipe 函数的 draft 目标。

当咱们对目标进行修正,Immer 会把这个修正署理到 copy_ 上面。终究再把 copy_ 回来(并不准确,只是为了咱们了解)。

有一点值得注意,在回来终究成果的时分并不是简略的回来 copy_ 目标,假如当时值没有被修正,那就回来 base_ 对应的值。

咱们能够把目标幻想成一棵树,当某一个孩子节点被修正了,它及其它的 parent 节点都会被符号为被修正,此刻回来 copy_ 目标上的值;而除去这个链路上的节点(它的 sibling 节点,它 parent 的 sibling 节点)都直接回来 base_ 目标上的值。

所以全体流程能够是:

  1. 收集更改
  2. 兼并更改

为了简略起见,本文只考虑目标的场景,数组的状况也不进行考虑,下面不再做特别阐明。

Step 3

首先,咱们给出 produce 函数的主体逻辑和注释,

produce = (base: any, recipe: any) => {
    let result;
    // 判别传入的值是不是合法的目标
    if (isDraftable(base)) {
        // 创立一个上下文,后面依据上下文能够复原出值
        const scope = enterScope(this)
        // 对 base 目标创立署理,以便绑架一切的修正
        const proxy = createProxy(this, base, undefined)
        // 调用修正函数,咱们 recipe 或许只操作 proxy,不回来值
        result = recipe(proxy)
        // 兼并出终究的成果,回来
        return processResult(result, scope)
    }
}

接下来咱们便逐个办法进行解析。

Step 4

if (isDraftable(base)) {
    ...
}

假如是目标,咱们才能够继续,代码能够简化为:

function isDraftable(value: any) {
    if (!value || typeof value !== "object") return false
    return true
}

Step 5

const scope = enterScope(this)

目前咱们还没有用到它,咱们看一下完结即可,能够略过,等用到再回来看,完结如下:

let currentScope: ImmerScope | undefined
export function enterScope(immer: Immer) {
    return (currentScope = createScope(currentScope, immer))
}
function createScope(parent_, immer_): ImmerScope {
    return {
        drafts_: [],
        parent_,
        immer_,
    }
}

Step 6

const proxy = createProxy(this, base, undefined)

代码如下:

export function createProxy<T extends Objectish>(
    immer: Immer,
    value: T,
    parent?: ImmerState
): Drafted<T, ImmerState> {
    const draft: Drafted = createProxyProxy(value, parent)
    const scope = parent ? parent.scope_ : getCurrentScope()
    scope.drafts_.push(draft)
    return draft
}

逻辑大致如下:

  1. 调用 createProxyProxy 绑定好署理联系,回来的是一个 Proxy 目标
  2. scope 入当时 draft 目标,用处后面解析
  3. 回来 Proxy 目标

中心是 createProxyProxy,它是收集更改进程中灵魂的函数。

export function createProxyProxy<T extends Objectish>(
	base: T,
	parent?: ImmerState
): Drafted<T, ProxyState> {
    const state: ProxyState = {
        scope_: parent ? parent.scope_ : getCurrentScope()!,
        modified_: false,
        finalized_: false,
        assigned_: {},
        parent_: parent,
        base_: base,
        draft_: null as any, // set below
        copy_: null,
        revoke_: null as any,
        isManual_: false
    }
    let target: T = state as any
    let traps: ProxyHandler<object | Array<any>> = objectTraps
    const {revoke, proxy} = Proxy.revocable(target, traps)
    state.draft_ = proxy as any
    state.revoke_ = revoke
    return proxy as any
}

流程是:

  1. 界说好 Proxy 的 target 目标,即为 state,一切的重要信息都存在这个目标里
  2. ojectTraps 去拦截对此目标的修正
  3. 回来此 proxy

state 的每一个特点穿插后续流程,咱们给出值的详细意义:

变量名 意义
modified 符号是否被修正,假如修正,回来数据的时分从 copy_ 字段取
finalized 符号是否已经完结修正,已完结修正对 proxy 的操作将不被接受
parent 它的上层 proxy 目标
base_ 原始数据
draft_ 原始数据对应的 proxy 目标
copy_ 原始数据的一份复制,开始是浅复制
revoke_ 销毁 proxy 目标用

接下来是 objectTraps 的完结,咱们主要看它的 get 办法和 set 办法,关键点是 按需绑定 proxy,只要在读到那个值的时分才绑定。

几个重要的东西函数:

// 总是取最新的值,有 copy_ 就不取 base_
// 这样能取到最新修正的值
export function latest(state: ImmerState): any {
    return state.copy_ || state.base_
}
// DRAFT_STATE 是一个内置的 Symbol 目标
// 假如 draft 是原始目标,肯定没有 DRAFT_STATE,回来本身的值
// 假如 state 有值,阐明 draft 是 proxy,那调用 latest(state)[prop] 取值
// DRAFT_STATE 其实就是在 proxy 的 get 里署理了的,回来它本身,详情见 get 函数第一行
function peek(draft: Drafted, prop: PropertyKey) {
    const state = draft[DRAFT_STATE]
    const source = state ? latest(state) : draft
    return source[prop]
}
// 把 `base_` 上的每一个自有特点移动到 `copy_` 上
// 做的浅复制,也就是说,在此刻:
// state.copy_ !== state.base_ 
// 可是 state.copy_[someKey] ===  state.base_[someKey]
export function prepareCopy(state: {base_: any; copy_: any}) {
    if (!state.copy_) {
            state.copy_ = shallowCopy(state.base_)
    }
}

必定要注意,上面三个函数绝对要了解用处,它是了解后面 set、get 的中心。不懂的话能够多读几遍。

在上面的根底上,咱们再来看 get 函数:

// https://github.com/mysteryven/immer/blob/d91a6597e92570086b329ba5b197c18d211077db/src/core/proxy.ts#L101
get(state, prop) {
    if (prop === DRAFT_STATE) return state
    // 取最新的值,此刻 value 或许是 proxy 目标,也或许是原始目标
    const source = latest(state)
    const value = source[prop]
    // 修正完毕或者不是目标结构,就直接回来
    // state.finalized_ 在后面兼并依靠时被设置为 false
    // 也是调用完 recipe 函数之后
    if (state.finalized_ || !isDraftable(value)) {
        return value
    }
    // 看看二者是否持平,右边值必定是原始值,持平阐明还没有被署理
    if (value === peek(state.base_, prop)) {
        // 浅复制 `base_` 的特点 到 `copy_`
        prepareCopy(state)
        // `copy_` 的 prop 这一项 从头赋值为 proxy 目标
        return (state.copy_![prop as any] = createProxy(
                state.scope_.immer_,
                value,
                state
        ))
    }
    return value
},

或许有同学看到了这儿会疑问,为什么在 prepareCopy 浅复制到了 copy_, 又接着把 copy_ 设置为 Proxy 目标,这不是多此一举吗?

原因有两个:

  1. 咱们 base_ 的原始数据或许不是可写的(writeable),咱们得先把它复制到 copy_ 上,一起把它变成可写的。
  2. 咱们的值终究要写入一个目标中,它的值也承接了一个桥梁作用,并且咱们对一切特点都做了浅复制,可是只 prop 这一项是做了 Proxy 署理

接着看 set 函数,了解此函数最关键的一步就是要知道 copy_ 的数据格局,它是拥有和 base_ 相同 key 值,可是 value 或许是原始值,也或许是 proxy 目标。

咱们来看一下它的函数主体:

set(
    state: ProxyObjectState,
    prop: string
    value
) {
    // 没有被符号为修正,阐明第一次修正
    // 或许需要进行复制、符号修正的操作
    if (!state.modified_) {
        // 拿到最新的值
        const current = peek(latest(state), prop)
        //假如是 proxy,拿它本身的 state 目标
        const currentState: ProxyObjectState = current?.[DRAFT_STATE];
        // 假如 value 和 base_ 持平,那没必要赋值
        if (currentState && currentState.base_ === value) {
                state.copy_![prop] = value
                state.assigned_[prop] = false
                return true
        }
        // 浅复制 state 到 `copy_` 中
        // 如 a.b.c.d.e = {x: 'hello world'}
        // d.e 算是赋值了
        // get 那里赋值 `copy_` 只到 d 这一层
        // e 这一层仍是没有被复制。
        prepareCopy(state)
        // 符号 state 和 state 的 parent 为 modifed_
        markChanged(state)
    }
    // 直接赋值 copy_ 的值为 value
    // 此刻不再是 proxy 目标了
    state.copy_![prop] = value
    state.assigned_[prop] = true
    return true
},
export function markChanged(state: ImmerState) {
    if (!state.modified_) {
        state.modified_ = true
        if (state.parent_) {
                markChanged(state.parent_)
        }
    }
}

Step 7

经过了 Step 6, 也就是调用完了 producerecipe 函数,调用了 get 或者 set,终究会进入兼并阶段,即 Step 3 的 processResult 函数。

这儿主要是依据 recipe 函数回来值做区别:

export function processResult(result: any, scope: ImmerScope) {
    const baseDraft = scope.drafts_![0]
    const isReplaced = result !== undefined && result !== baseDraft
    // 有回来值做替换
    if (isReplaced) {
        if (baseDraft[DRAFT_STATE].modified_) {
            revokeScope(scope)
        }
        if (isDraftable(result)) {
            result = finalize(scope, result)
            if (!scope.parent_) maybeFreeze(scope, result)
        }
    } else {
        result = finalize(scope, baseDraft, [])
    }
    revokeScope(scope)
    return result
}

特别阐明,没有回来值的状况,也就是 recipe 函数这样调用:

state = produce(state, draft => {
    draft.name = "Michel"
})

无论怎么,关键的就两个函数:finalizerevokeScope

  1. finalize 将依据 base_copy_ 兼并出终究的成果
  2. revokeScope 注销掉一个个 proxy 目标

了解 finalize 函数的关键仍是了解 copy_base_ 值联系:

function finalize(rootScope: ImmerScope, value: any) {
    const state: ImmerState = value[DRAFT_STATE]
    // 阐明是原始目标,直接遍历它的每一个 child
    if (!state) {
        each(
            value,
            (key, childValue) =>
                // 作用是为每一个特点也调用 finalize
                finalizeProperty(rootScope, state, value, key, childValue),
        )
        return value
    }
    // 没有修正过,直接回来
    if (!state.modified_) {
        return state.base_
    }
    // 是 state 目标格局,并且被修正过了
    if (!state.finalized_) {
        // 先符号为修正完结,这时分再进行 set 就不生效了
        state.finalized_ = true
        // copy_ 的值或许是 proxy 目标,或许是原始目标,或许是原生值
        // 终究要把 copy_ 的值为 proxy 目标的变为原始目标,其他的坚持不变。
        const result = state.copy_
        each(
            result,
            (key, childValue) =>
                finalizeProperty(rootScope, state, result, key, childValue, path)
        )
    }
    return state.copy_
}
export function isDraft(value: any): boolean {
    return !!value && !!value[DRAFT_STATE]
}
function finalizeProperty(
    rootScope: ImmerScope,
    parentState: undefined | ImmerState,
    targetObject: any,
    prop: string | number,
    childValue: any,
) {
    // 是不是 Proxy 目标
    if (isDraft(childValue)) {
        // Drafts owned by `scope` are finalized here.
        // 递归的判别内部的逻辑,终究会回来原始目标
        const res = finalize(rootScope, childValue)
        // proxy 目标重写成原始目标
        targetObject[prop] = res
    } else return
}

了解的关键是假如把根级别的 copy_ 看做树,它的特点的值或许是 proxy 目标,或许是原始值(或许是新的或者和 base_ 相同),可是树的叶子节点肯定是原始值。乃至,叶子节点往上也或许是原始值而不是 proxy 目标,这就确保了递归进程有终止。而遍历方式是先遍历孩子(const res = finalize(rootScope, childValue))再赋值本身( targetObject[prop] = res),就确保了赋值的时分,值必定是原始值了,终究拿到了成果 copy_ 就是正确的。

revokeScope 和主流程联系不大,可是为了确保完好性,仍是给出一切的函数:

export function revokeScope(scope: ImmerScope) {
    leaveScope(scope)
    // 销毁每一个 drafts 里的 proxy 目标
    scope.drafts_.forEach(revokeDraft)
    scope.drafts_ = null
}
export function leaveScope(scope: ImmerScope) {
    if (scope === currentScope) {
            currentScope = scope.parent_
    }
}
function revokeDraft(draft: Drafted) {
    const state: ImmerState = draft[DRAFT_STATE]
    if (
        state.type_ === ProxyType.ProxyObject ||
        state.type_ === ProxyType.ProxyArray
    )
        state.revoke_()
    else state.revoked_ = true
}

到这儿,咱们的 Immer 源码解读就完结了。

总结

Immer 凭借 Proxy 的才能,对咱们读值、取值进行署理,并且巧妙的在 base_copy_ 进行中转,整个进程都是按需修正,假如咱们有了更改,才会按需的增加 Proxy 去监听,终究再把二者值兼并出终究的目标。