更新:现在多了一个状况库适配了 immer:jotai-immer
文章目录
Immer 现在已经成为了许多状况库的标配,包括 Redux, zustand,在去年的库运用频率的调研中,Immer 也是独占鳌头。
为了照顾没有 Immer 运用经历的同学,本文并不直接深化源码,而是包含三个方面:
- 基于 React,阐明原先的问题,探讨为什么需要 Immer
- Immer 的根底用法,为源码解读做衬托
- Immer 源码解读
Immer 官网
顾虑重重的深复制
1
在 JS 中,完结深复制一般来说有下面几种方式:
- 调用库函数,如 lodash 下的 cloneDeep。
- 运用
JSON.stringify
和JSON.parse
,可是这种没办法复制不属于 JSON 格局的值,例如 Date 目标、函数、Map、Set 等。 - 运用 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>
)
}
能够看到,当咱们点击按钮的时分,值的改动没有表现出来,一起也没有触发组件的从头渲染,也就是说,countRef
的索引一直没变,setCount 触发组件渲染失效了。
最朴素的处理的办法就是每次从头赋值 countRef 之前都深复制一份,上面咱们列的三个办法能够随意选用一个,在这儿咱们选择不太常见的第三个:
function handleClick() {
const newCountRef = structuredClone(countRef)
newCountRef.count += 1
setCount(newCountRef)
}
此刻再看:
这种处理方案能够说是简略粗暴,咱们在某些时分也是懒得想,直接运用。但它也有其局限性:它在数据量大的时分就会出现功能瓶颈。
假如咱们一个列表有非常多项,假如咱们只更改了列表中某一项的一个值,接着对一整个列表做一遍深复制的话,整个列表无关的子项也都会从头渲染一遍,这势必会形成非常大的功能损耗。
暂时救火的浅复制
深复制已然不可,咱们或许得采用浅复制了。
所以,最简略的办法或许形如下面这样:
list[1].title = 'new title'
setList([...list])
咱们运用浅复制把 list
的地址更改一下,这样也能触发更新。在 React 中,假如咱们的子组件没有运用 React.memo
包裹,父组件有了更新会触发下面每一个子组件从头渲染,除非子项是一个 Text Node 节点,就算是子组件没有接受任何 props,新旧 props 目标都是空目标 {}
,进行比较也不等,如下图所示:
优点是运用 React 的这个特性,不深复制目标,只改动列表目标的索引,就能触发列表的更新。坏处就是为了形成了许多子项的 Fiber Diff,为了处理这个问题,咱们许多时分都得包裹 React.memo
。而正是因为运用 React.memo
包裹,也引发了后面的问题。
咱们能够设计下面这个比如,你能够在线预览调试一下,推荐点击右上角【查看详情】去查看,当点击 【Change Title】按钮,没有任何反响,就是咱们上面说的问题了:
假如你看完了上面的比如,应该就能体会出什么问题了,怎么去处理这个问题呢?最朴素的办法则是在自界说 React.memo
的 compare
函数:
const Item = React.memo(({ subject }: { subject: any }) => {
return <div>{subject.title}</div>
}, (prev, cur) => {
if (prev.subject !== cur.subject) {
return false
}
return true
})
可是太遗憾了,仍是不可,因为咱们更改的 list 和新的是同一个索引地址,也就是说 prev
和 cur
都是同一个。你能够在上面的在线代码片段里修正试一下。
莫非这个问题就无解了吗?当然不是的,那就是不传递目标下来,而是把值解构出来传下来,这个办法是用到了 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
-
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,可是这样某些变量名会被转译,有点妨碍阅览,下面是一个很笨可是管用的办法:
- Fork 项目
- 在根目录新建一个 Vite 环境
- 引进进口文件导出的
produce
函数- 有报错,处理它
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_
目标上的值。
所以全体流程能够是:
- 收集更改
- 兼并更改
为了简略起见,本文只考虑目标的场景,数组的状况也不进行考虑,下面不再做特别阐明。
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
}
逻辑大致如下:
- 调用
createProxyProxy
绑定好署理联系,回来的是一个 Proxy 目标 -
scope
入当时 draft 目标,用处后面解析 - 回来 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
}
流程是:
- 界说好 Proxy 的
target
目标,即为state
,一切的重要信息都存在这个目标里 - 用
ojectTraps
去拦截对此目标的修正 - 回来此
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 目标,这不是多此一举吗?
原因有两个:
- 咱们
base_
的原始数据或许不是可写的(writeable),咱们得先把它复制到copy_
上,一起把它变成可写的。 - 咱们的值终究要写入一个目标中,它的值也承接了一个桥梁作用,并且咱们对一切特点都做了浅复制,可是只
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, 也就是调用完了 produce
的 recipe
函数,调用了 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"
})
无论怎么,关键的就两个函数:finalize
和 revokeScope
-
finalize
将依据base_
和copy_
兼并出终究的成果 -
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 去监听,终究再把二者值兼并出终究的目标。