Skip to content

不可变数据结构与 immer 原理 #15

@moyui

Description

@moyui

概念

我们首先区分一下什么是可变/不可变数据结构

  1. 可变:一个变量创建之后进行修改,如果对应的内存地址为不发生变化,则这个数据类型为可变数据结构
  2. 不可变:一个变量创建之后,进行修改返回一个新的内存地址 且 原有的值存储在之前的内存空间中没有发生变化,则这个数据类型为不可变数据结构

js 里面,所有的基本数据类型都是不可变数据类型(number, string, boolean, undefined, null, symbol, bigint)
Array, Function, Object 等则都是可变数据类型

为什么要使用不可变数据

在函数式编程中,我们会提及纯函数这个概念。React 则遵循函数式编程范式,所以我们写的 React 组件越纯越好。

在该范式中,我们希望函数是一个不会修改外部数据的闭合功能单元,这样我们只需要关注函数的功能实现,不会影响到外部或者全局的数据修改。

于此,我们针对纯函数的入参和出参希望是一个不可变的数据结构。

那针对 React 本身来看,有以下优点

  1. 调试 console.log 不会被可变数据修改
  2. 优化 useMemo 等常见优化手段 都是通过浅比较实现的,这样检查就可以非常快
  3. 快照 可以展示撤销,重做和显示历史记录的值
  4. rerender 在渲染的时候状态可追踪

不可变 in React

我们通常在 React 里面写的代码通常是这样

setState({
  ...a,
  b: "更新",
});

这样整个对象使用不可变的数据结构进行更新,但是如果一旦遇到了复杂结构的话

setState({
    ...a,
    b: {
        ...c,
        d: {
            ...e,
            f: '更新在这里'
        }
    }
})

我们需要逐级将对应的数据元素给更新,层级比较深,写起来会比较难受
此外,如果我们需要使用splice, reverse等原地修改的方法,也会遇到同样的问题

a.splice(1, 'a');
setState([...a]);

a.reverse()
setState([...a]);

那么我们就介绍一个常见的库去减少这些操作,即immer

immer

官方: https://immerjs.github.io/immer/

immer.png

从图中可以看出,这个库会基于我们目前的状态生成一份草稿(draft), 在草稿上我们可以使用任意方法去修改,生成next。最后会比对修改结果去支持自动不可变数据的状态更新。

那么怎么使用:

const baseState = [
    {
        title: "Learn TypeScript",
        done: true
    },
    {
        title: "Try Immer",
        done: false
    }
]

const nextState = produce(baseState, draftState => {
    draftState.push({title: "Tweet about it"})
    draftState[1].done = true
})

官方甚至 单独针对React提供了一个hooks useImmmer

import { useImmer } from "use-immer";

  const [todos, setTodos] = useImmer([
    {
      id: "React",
      title: "Learn React",
      done: true
    },
    {
      id: "Immer",
      title: "Try Immer",
      done: false
    }
  ]);

  setTodos((draft) => {
      draft.push({
        id: "todo_" + Math.random(),
        title: "A new todo",
        done: false
      });
    });

首先我们看个手写immer

const state = {
  phone: "1-770-736-8031 x56442",
  website: "hildegard.org",
  company: {
    name: "Romaguera-Crona",
    catchPhrase: "Multi-layered client-server neural-net",
    bs: "harness real-time e-markets",
  },
};

function immer(state, thunk) {
  let copies = new Map(); // Map 的 key 可以是一个对象,非常适合用来缓存被修改的对象
  // 核心代码就在这里
  const handler = {
    get(target, prop) {
      // @ts-ignore
      return new Proxy(target[prop], handler);
    },
    set(target, prop, value) {
      const copy = { ...target }; // 浅拷贝
      copy[prop] = value; // 给拷贝对象赋值
      copies.set(target, copy);
    },
  };
  function finalize(state) {
    const result = { ...state };
    Object.keys(state).map((key) => {
      const copy = copies.get(state[key]);
      if (copy) {
        result[key] = copy;
      } else {
        result[key] = state[key];
      }
    });
    return result;
  }
  // @ts-ignore
  const proxy = new Proxy(state, handler);
  thunk(proxy);
  return finalize(copy);
}

const copy = immer(state, (draft) => {
  draft.website = "www.google.com";
});

那接下来我们直接看下immer里面是怎么操作的

github: https://github.com/immerjs/immer

// package.json

"source": "src/immer.ts",

那接下来我们去immer.ts中,这里有一堆方法,最核心的还是这个方法

const immer = new Immer();
export const produce: IProduce = immer.produce

代码只有200行不到,我们完整看下

export class Immer implements ProducersFns {
	autoFreeze_: boolean = true
	useStrictShallowCopy_: boolean = false

    //这里是一些参数控制
	constructor(config?: {autoFreeze?: boolean; useStrictShallowCopy?: boolean}) {
		if (typeof config?.autoFreeze === "boolean")
			this.setAutoFreeze(config!.autoFreeze)
		if (typeof config?.useStrictShallowCopy === "boolean")
			this.setUseStrictShallowCopy(config!.useStrictShallowCopy)
	}

//  * @param {any} base - 原始数据
//  * @param {Function} recipe - 修改数据操作的函数
//  * @param {Function} patchListener - optional 回调函数(patch用来记录修改的步骤,以达到精准更新的目的)
//  * @returns {any} 返回新的数据状态,如果没有修改,则返回原始数据 
	produce: IProduce = (base: any, recipe?: any, patchListener?: any) => {
		// curried invocation   // 科里化调用
		if (typeof base === "function" && typeof recipe !== "function") {
			const defaultBase = recipe
			recipe = base

			const self = this  // 保存当前的this指向
			return function curriedProduce(
				this: any,
				base = defaultBase,
				...args: any[]
			) {
                // 调用传入的参数,返回结果
				return self.produce(base, (draft: Drafted) => recipe.call(this, draft, ...args)) // prettier-ignore
			}
		}

        // 参数校验,可以忽略
		if (typeof recipe !== "function") die(6)
		if (patchListener !== undefined && typeof patchListener !== "function")
			die(7)

		let result

		// Only plain objects, arrays, and "immerable classes" are drafted.
        // 判断对象是不是可以草稿修改,即只有普通对象,数组和immerable类可以编辑
		if (isDraftable(base)) {
			const scope = enterScope(this)  // 使用scope 来区分不同的层级,以便处理嵌套数据中的层级关系
			const proxy = createProxy(base, undefined) // immer的核心就是利用Proxy来对数据进行拦截
			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)
			}
			usePatchesInScope(scope, patchListener)
			return processResult(result, scope)
		} else if (!base || typeof base !== "object") {
			result = recipe(base)
			if (result === undefined) result = base
			if (result === NOTHING) result = undefined
			if (this.autoFreeze_) freeze(result, true)
			if (patchListener) {
				const p: Patch[] = []
				const ip: Patch[] = []
				getPlugin("Patches").generateReplacementPatches_(base, result, p, ip)
				patchListener(p, ip)
			}
			return result
		} else die(1, base)
	}

	produceWithPatches: IProduceWithPatches = (base: any, recipe?: any): any => {
		// curried invocation
		if (typeof base === "function") {
			return (state: any, ...args: any[]) =>
				this.produceWithPatches(state, (draft: any) => base(draft, ...args))
		}

		let patches: Patch[], inversePatches: Patch[]
		const result = this.produce(base, recipe, (p: Patch[], ip: Patch[]) => {
			patches = p
			inversePatches = ip
		})
		return [result, patches!, inversePatches!]
	}

	createDraft<T extends Objectish>(base: T): Draft<T> {
		if (!isDraftable(base)) die(8)
		if (isDraft(base)) base = current(base)
		const scope = enterScope(this)
		const proxy = createProxy(base, undefined)
		proxy[DRAFT_STATE].isManual_ = true
		leaveScope(scope)
		return proxy as any
	}

	finishDraft<D extends Draft<any>>(
		draft: D,
		patchListener?: PatchListener
	): D extends Draft<infer T> ? T : never {
		const state: ImmerState = draft && (draft as any)[DRAFT_STATE]
		if (!state || !state.isManual_) die(9)
		const {scope_: scope} = state
		usePatchesInScope(scope, patchListener)
		return processResult(undefined, scope)
	}

	/**
	 * Pass true to automatically freeze all copies created by Immer.
	 *
	 * By default, auto-freezing is enabled.
	 */
	setAutoFreeze(value: boolean) {
		this.autoFreeze_ = value
	}

	/**
	 * Pass true to enable strict shallow copy.
	 *
	 * By default, immer does not copy the object descriptors such as getter, setter and non-enumrable properties.
	 */
	setUseStrictShallowCopy(value: boolean) {
		this.useStrictShallowCopy_ = value
	}

	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)
		)
	}
}
function createScope(
	parent_: ImmerScope | undefined,
	immer_: Immer
): ImmerScope {
	return {
		drafts_: [],
		parent_,
		immer_,
		// Whenever the modified draft contains a draft from another scope, we
		// need to prevent auto-freezing so the unowned draft can be finalized.
		canAutoFreeze_: true,
		unfinalizedDrafts_: 0
	}
}
export function enterScope(immer: Immer) {
	return (currentScope = createScope(currentScope, immer))
}
export function createProxyProxy<T extends Objectish>(
	base: T,
	parent?: ImmerState
): Drafted<T, ProxyState> {
	const isArray = Array.isArray(base)
	const state: ProxyState = {
		type_: isArray ? ArchType.Array : (ArchType.Object 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
		// The base copy with any updated values.
		copy_: null,
		// Called by the `produce` function.
		revoke_: null as any,
		isManual_: false
	}

	// the traps must target something, a bit like the 'real' base.
	// but also, we need to be able to determine from the target what the relevant state is
	// (to avoid creating traps per instance to capture the state in closure,
	// and to avoid creating weird hidden properties as well)
	// So the trick is to use 'state' as the actual 'target'! (and make sure we intercept everything)
	// Note that in the case of an array, we put the state in an array to have better Reflect defaults ootb
	let target: T = state as any
	let traps: ProxyHandler<object | Array<any>> = objectTraps
	if (isArray) {
		target = [state] as any
		traps = arrayTraps
	}

    // 这里用了revocable而不是直接new Proxy 是为了便于撤销这个代理对象,提高性能
	const {revoke, proxy} = Proxy.revocable(target, traps)
	state.draft_ = proxy as any
	state.revoke_ = revoke
	return proxy as any
}

这个代码就是代理object的各个方法

/**
 * Object drafts
 */
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(value, state))
		}
		return value
	},
	has(state, prop) {
		return prop in latest(state)
	},
	ownKeys(state) {
		return Reflect.ownKeys(latest(state))
	},
	set(
		state: ProxyObjectState,
		prop: string /* strictly not, but helps TS */,
		value
	) {
		const desc = getDescriptorFromProto(latest(state), prop)
		if (desc?.set) {
			// special case: if this write is captured by a setter, we have
			// to trigger it with the correct context
			desc.set.call(state.draft_, value)
			return true
		}
		if (!state.modified_) {
			// the last check is because we need to be able to distinguish setting a non-existing to undefined (which is a change)
			// from setting an existing property with value undefined to undefined (which is not a change)
			const current = peek(latest(state), prop)
			// special case, if we assigning the original value to a draft, we can ignore the assignment
			const currentState: ProxyObjectState = current?.[DRAFT_STATE]
			if (currentState && currentState.base_ === value) {
				state.copy_![prop] = value
				state.assigned_[prop] = false
				return true
			}
			if (is(value, current) && (value !== undefined || has(state.base_, prop)))
				return true
			prepareCopy(state)
			markChanged(state)
		}

		if (
			(state.copy_![prop] === value &&
				// special case: handle new props with value 'undefined'
				(value !== undefined || prop in state.copy_)) ||
			// special case: NaN
			(Number.isNaN(value) && Number.isNaN(state.copy_![prop]))
		)
			return true

		// @ts-ignore
		state.copy_![prop] = value
		state.assigned_[prop] = true
		return true
	},
	deleteProperty(state, prop: string) {
		// The `undefined` check is a fast path for pre-existing keys.
		if (peek(state.base_, prop) !== undefined || prop in state.base_) {
			state.assigned_[prop] = false
			prepareCopy(state)
			markChanged(state)
		} else {
			// if an originally not assigned property was deleted
			delete state.assigned_[prop]
		}
		if (state.copy_) {
			delete state.copy_[prop]
		}
		return true
	},
	// Note: We never coerce `desc.value` into an Immer draft, because we can't make
	// the same guarantee in ES5 mode.
	getOwnPropertyDescriptor(state, prop) {
		const owner = latest(state)
		const desc = Reflect.getOwnPropertyDescriptor(owner, prop)
		if (!desc) return desc
		return {
			writable: true,
			configurable: state.type_ !== ArchType.Array || prop !== "length",
			enumerable: desc.enumerable,
			value: owner[prop]
		}
	},
	defineProperty() {
		die(11)
	},
	getPrototypeOf(state) {
		return getPrototypeOf(state.base_)
	},
	setPrototypeOf() {
		die(12)
	}
}

通过上面的代码描述,我们可以知道:
immer通过Proxy对象进行数据劫持
通过patch来记录修改记录,以达到精准更新

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions