Example
import produce from "immer"
const baseState = [
{
todo: "Learn typescript",
done: true
},
{
todo: "Try immer",
done: false
}
]
// baseState 不变,nextState 是变更后的新对象
const nextState = produce(baseState, draftState => {
draftState.push({todo: "Tweet about it"})
draftState[1].done = true
})
Знакомство с Иммером
Слышу в первый разImmerПримерно несколько месяцев назад я написал библиотеку управления состоянием, которую хотел продвигать в компании.Одноклассники по группе прислали мне адрес Immer на GitHub, сказав, что существует библиотека управления состоянием на основе Proxy, которая утверждала, что имеет хорошая производительность. Можем ли мы его использовать? Я бросил несколько взглядов и ответил, что это не библиотека управления состоянием, а концептуально ближе к Immutable.js, который используется для облегчения манипулирования неизменяемыми данными. Он предоставляет пользователю draftState, и пользователь может изменить его по своему желанию, и, наконец, возвращает новые данные, исходные данные остаются неизменными. В то время я также немного рассмотрел его основной принцип. DraftState является прокси, и операции чтения и записи на нем будут выполняться внутри определенного геттера/сеттера. Короче говоря, когда вы получаете объект внутри draftState, он возвращает прокси, и когда вы его назначаете, он назначает объект копии исходного объекта. Наконец, верните объект копии.
Прокси фактически используется в моем проекте для упрощения операций, связанных с отправкой сообщений. Однако статус моего проекта изменчив (вначале он был неизменяем, а потом что-то изменилось для реализации определенных функций...), поэтому Immer для меня немного безвкусен, поэтому я не обращаю особого внимания на Это.
Анализ исходного кода
Однако после смены компании я часто слышал Immer от новых коллег и хотел использовать его в нашем проекте. Хотя я думаю, что он все же не подходит для нашей сцены, и я не планирую его использовать, но наслушавшись его много, я думаю, что лучше посмотреть исходный код полностью, может быть, я могу узнать что-то из углов ...
produce
product — это функция, которая доступна пользователям напрямую, это метод экземпляра класса Immer (вы можете прочитать мое объяснение ниже, не заглядывая сначала в код):
export class Immer {
constructor(config) {
assign(this, configDefaults, config)
this.setUseProxies(this.useProxies)
this.produce = this.produce.bind(this)
}
produce(base, recipe, patchListener) {
// curried invocation
if (typeof base === "function" && typeof recipe !== "function") {
const defaultBase = recipe
recipe = base
// prettier-ignore
return (base = defaultBase, ...args) =>
this.produce(base, draft => recipe.call(draft, draft, ...args))
}
// prettier-ignore
{
if (typeof recipe !== "function") throw new Error("if first argument is not a function, the second argument to produce should be a function")
if (patchListener !== undefined && typeof patchListener !== "function") throw new Error("the third argument of a producer should not be set or a function")
}
let result
// Only plain objects, arrays, and "immerable classes" are drafted.
if (isDraftable(base)) {
const scope = ImmerScope.enter()
const proxy = this.createProxy(base)
let hasError = true
try {
result = recipe.call(proxy, proxy)
hasError = false
} finally {
// finally instead of catch + rethrow better preserves original stack
if (hasError) scope.revoke()
else scope.leave()
}
if (result instanceof Promise) {
return result.then(
result => {
scope.usePatches(patchListener)
return this.processResult(result, scope)
},
error => {
scope.revoke()
throw error
}
)
}
scope.usePatches(patchListener)
return this.processResult(result, scope)
} else {
result = recipe(base)
if (result === undefined) return base
return result !== NOTHING ? result : undefined
}
}
product получает три параметра: обычно base — это исходные данные, recipe — это место, где пользователь выполняет логику модификации, а patchListener — это место, где пользователь получает данные исправления, а затем выполняет некоторые пользовательские операции.
Логика в начале product видит, что аннотация предназначена для каррирования (на самом деле она не является строго каррированием, но к содержанию этой статьи не имеет никакого отношения, поэтому я не буду об этом говорить), она определяет, будет ли следующая база - это функция, и если да, то присвоить базовое значение рецепту, а затем вернуть функцию, которая получает базу, что это значит? обычно дело в том, что вы похожиproduce(base, (draft) => { ... })
Это вызывает продукцию, но если в некоторых случаях вам нужно сначала получить функцию рецепта, а затем базу, вы можете сделать это так:produce((draft) => { ... })(base)
Таким образом, наиболее распространенным сценарием является взаимодействие с setState React:
// state = { user: { age: 18 } }
this.setState(
produce(draft => {
draft.user.age += 1
})
)
Конечно, вы также можете передать базу по умолчанию,const changeFn = produce(recipe, base)
Может напрямуюchangeFn()
также можетchangeFn(newBase)
, newBase перезапишет предыдущую базу.
Далееосновной процесс:
- Если база — это объект (включая массив), который может генерировать черновики, то:
- воплощать в жизнь
const scope = ImmerScope.enter()
, сгенерировать экземпляр области действия ImmerScope, области действия и текущей привязки вызова - воплощать в жизнь
this.createProxy(base)
Создать прокси (черновик) и выполнитьscope.drafts.push(proxy)
сохранить прокси в область видимости - Вызовите функцию рецепта, переданную пользователем с прокси-сервером в качестве параметра, и сохраните возвращаемое значение как результат.
- Вызывается, если при выполнении рецепта не возникло ошибок
scope.leave
, сбросить ImmerScope.current в начальное состояние (здесь null), и выполнить, если есть ошибкаscope.revoke()
, сбрасывает все состояния. - Определите, является ли результат обещанием, и верните его, если это так.
result.then(result => this.processResult(result, scope))
, иначе вернитесь напрямуюthis.processResult(result, scope)
(На самом деле это должно быть выполнено перед возвратомscope.usePatches(patchListener)
, патчные вещи не считаются основным процессом, тем более)
- воплощать в жизнь
- Если база не может создать тягу, то:
- воплощать в жизнь
result = recipe(base)
- Если результат не определен, верните базу напрямую, в противном случае оцените, является ли результат
NOTHING
(внутренний токен), undefined, если да, иначе результат
- воплощать в жизнь
Вся продукция в основном выполняет три функции:
- передача
createProxy
Создание черновиков для использования пользователями - Выполнить рецепт, переданный пользователем, перехватить операции чтения и записи и перейти к геттеру/сеттеру внутри прокси
- передача
processResult
Окончательный результат разбора и сборки возвращается пользователю
Далее мы шаг за шагом исследуем вовлеченную часть.
Создать черновик
Вы обнаружите, что в объявлении класса Immer нет метода экземпляра createProxy, но его можно выполнить в продукте.this.createProxy(base)
. Это магия? На самом деле createProxy существует в файлах proxy.js и es5.js. Содержимое es5.js является совместимым решением для сред, не поддерживающих прокси. Начало immer.js будет импортировать содержимое двух файлов:
import * as legacyProxy from "./es5"
import * as modernProxy from "./proxy"
Он будет выполнен в конструкторе Immerthis.setUseProxies(this.useProxies)
, useProxies используется, чтобы указать, поддерживает ли текущая среда Proxy, а useProxies будет оцениваться в setUseProxies:
- верно: assign(this, modernProxy)
- is false: assign(this, legacyProxy)
такcreateProxy
функция загружается вthis
Up, здесь мы подробно рассмотрим proxy.jscreateProxy
:
export function createProxy(base, parent) {
const scope = parent ? parent.scope : ImmerScope.current
const state = {
// Track which produce call this is associated with.
scope,
// 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,
// The base state.
base,
// The base proxy.
draft: null,
// Any property proxies.
drafts: {},
// The base copy with any updated values.
copy: null,
// Called by the `produce` function.
revoke: null
}
const {revoke, proxy} = Array.isArray(base)
? // [state] is used for arrays, to make sure the proxy is array-ish and not violate invariants,
// although state itself is an object
Proxy.revocable([state], arrayTraps)
: Proxy.revocable(state, objectTraps)
state.draft = proxy
state.revoke = revoke
scope.drafts.push(proxy)
return proxy
}
- Создайте объект состояния на основе базы, и свойства в нем будут детализированы, когда мы их используем
- Определите, является ли base массивом, если да, создайте его на основе arrayTraps
[state]
Прокси, иначе созданный на основе objectTrapsstate
Прокси
ArrayTraps в основном пересылает параметры в objectTraps, а ключевые элементы в objectTraps получаются и устанавливаются.Эти две функции перехватывают операции значения и присваивания прокси.
Операции перехвата значений
function get(state, prop) {
if (prop === DRAFT_STATE) return state
let {drafts} = state
// Check for existing draft in unmodified state.
if (!state.modified && has(drafts, prop)) {
return drafts[prop]
}
const value = source(state)[prop]
if (state.finalized || !isDraftable(value)) return value
// Check for existing draft in modified state.
if (state.modified) {
// Assigned values are never drafted. This catches any drafts we created, too.
if (value !== state.base[prop]) return value
// Store drafts on the copy (when one exists).
drafts = state.copy
}
return (drafts[prop] = createProxy(value, state))
}
get получает два параметра, первый — это состояние, то есть первый параметр (целевой объект), передаваемый при создании прокси, а второй параметр — это prop, то есть имя свойства, которое вы хотите получить. логика следующая:
- если реквизит
DRAFT_STATE
затем напрямую вернуть объект состояния (который будет использоваться при окончательной обработке результата) - Возьмите шашки в собственность государства. сохранено в черновиках
state.base
Прокси дочернего объекта, напримерbase = { key1: obj1, key2: obj2 }
,ноdrafts = { key1: proxyOfObj1, key2: proxyOfObj2 }
- Если состояние не было изменено и прокси, соответствующий реквизиту, существует в черновиках, возвращается прокси.
- подобно
state.copy
есть, возьмиstate.copy[prop]
, иначе взятьstate.base[prop]
, хранится в значении - Если состояние завершило вычисление или значение не может быть использовано для создания прокси, возвращайте значение напрямую.
- Если состояние было помечено для модификации
- подобно
value !== state.base[prop]
возвращаемое значение напрямую - иначе поставить
state.copy
Отнесите его в черновики (копия также содержит прокси подобъекта, который будет подробно описан в разделе набора)
- подобно
- Выполнить, если не вернулся раньше
createProxy(value, state)
Создайте прокси для дочернего состояния со значением в качестве базового и состоянием в качестве родительского, сохраните его в черновиках и верните.
После разговора о get,Мы обнаружили, что это прокси-сервер, используемый для создания подобъектов, кэширования прокси-сервера и последующего возврата прокси-сервера.Если прокси-сервер не может быть сгенерирован, возвращайте значение напрямую..
Перехват операций присваивания
function set(state, prop, value) {
if (!state.modified) {
// Optimize based on value's truthiness. Truthy values are guaranteed to
// never be undefined, so we can avoid the `in` operator. Lastly, truthy
// values may be drafts, but falsy values are never drafts.
const isUnchanged = value
? is(state.base[prop], value) || value === state.drafts[prop]
: is(state.base[prop], value) && prop in state.base
if (isUnchanged) return true
markChanged(state)
}
state.assigned[prop] = true
state.copy[prop] = value
return true
}
set принимает три параметра, первые два такие же, как и get, а третье значение — это новое значение, которое необходимо присвоить.Конкретная логика выглядит следующим образом:
-
Сначала определите, отмечено ли состояние для изменения, если нет, то:
- Определить, равны ли новое значение и старое значение, если они равны, вернуться напрямую, ничего не делать
- иначе выполнить
markChanged(state)
(подробнее позже)
-
Буду
state.assigned[prop]
Установите значение true, чтобы пометить свойство как присвоенное значение. -
присвоить значение
state.copy[prop]
Ядро всего набора на самом делеОтметьте модификацию и присвойте новое значение соответствующему свойству копируемого объекта., теперь смотрим на margeChanged:
function markChanged(state) {
if (!state.modified) {
state.modified = true
state.copy = assign(shallowCopy(state.base), state.drafts)
state.drafts = null
if (state.parent) markChanged(state.parent)
}
}
Состояние нужно отметить только один раз, как показано ниже:
- Пучок
state.modified
установить на истину - мелкая копия
state.base
, и положиstate.drafts
присвоить объекту копирования, присвоитьstate.copy
. то естьstate.copy
Прокси содержит подслуги, будут использоваться в Get In, мы сказали раньше - Пучок
state.drafts
установить на ноль - Если у состояния есть родитель, выполнить рекурсивно
markChanged(state.parent)
. Это легко понять, напримерdraft.person.name = 'Sheepy'
В этой операции мы должны не только изменить тег человека, но и изменить тег черновика.
результат синтаксического анализа возвращен
processResult(result, scope) {
const baseDraft = scope.drafts[0]
const isReplaced = result !== undefined && result !== baseDraft
this.willFinalize(scope, result, isReplaced)
if (isReplaced) {
if (baseDraft[DRAFT_STATE].modified) {
scope.revoke()
throw new Error("An immer producer returned a new value *and* modified its draft. Either return a new value *or* modify the draft.") // prettier-ignore
}
if (isDraftable(result)) {
// Finalize the result in case it contains (or is) a subset of the draft.
result = this.finalize(result, null, scope)
}
if (scope.patches) {
scope.patches.push({
op: "replace",
path: [],
value: result
})
scope.inversePatches.push({
op: "replace",
path: [],
value: baseDraft[DRAFT_STATE].base
})
}
} else {
// Finalize the base draft.
result = this.finalize(baseDraft, [], scope)
}
scope.revoke()
if (scope.patches) {
scope.patchListener(scope.patches, scope.inversePatches)
}
return result !== NOTHING ? result : undefined
}
Хотя пример Иммера рекомендует пользователям изменять черновик непосредственно в рецепте, пользователи также могут выбрать возврат результата в конце рецепта, но следует отметить, что две операции «изменить черновик» и «вернуть новое значение» могут только один, если это делается одновременноprocessResult
Функция выдает ошибку. Мы сосредоточимся на случае непосредственного манипулирования черновиками, и основная логика заключается в выполненииresult = this.finalize(baseDraft, [], scope)
, ситуация с возвратом результата аналогична, оба вызоваfinalize
, давайте посмотрим на эту функцию:
/**
* @internal
* Finalize a draft, returning either the unmodified base state or a modified
* copy of the base state.
*/
finalize(draft, path, scope) {
const state = draft[DRAFT_STATE]
if (!state) {
if (Object.isFrozen(draft)) return draft
return this.finalizeTree(draft, null, scope)
}
// Never finalize drafts owned by another scope.
if (state.scope !== scope) {
return draft
}
if (!state.modified) {
return state.base
}
if (!state.finalized) {
state.finalized = true
this.finalizeTree(state.draft, path, scope)
if (this.onDelete) {
// The `assigned` object is unreliable with ES5 drafts.
if (this.useProxies) {
const {assigned} = state
for (const prop in assigned) {
if (!assigned[prop]) this.onDelete(state, prop)
}
} else {
const {base, copy} = state
each(base, prop => {
if (!has(copy, prop)) this.onDelete(state, prop)
})
}
}
if (this.onCopy) {
this.onCopy(state)
}
// At this point, all descendants of `state.copy` have been finalized,
// so we can be sure that `scope.canAutoFreeze` is accurate.
if (this.autoFreeze && scope.canAutoFreeze) {
Object.freeze(state.copy)
}
if (path && scope.patches) {
generatePatches(
state,
path,
scope.patches,
scope.inversePatches
)
}
}
return state.copy
}
Мы пропускаем хуковую функциюonDelete
а такжеonCopy
, просто посмотрите на основной процесс:
- Получить состояние через черновик (объект состояния, сгенерированный в createProxy, включая такие свойства, как база, копия, черновики и т. д.)
- Если состояние не помечено для модификации, вернитесь напрямую
state.base
- Если состояние не помечено как конец, выполнить
this.finalizeTree(state.draft, path, scope
, и, наконец, вернутьсяstate.copy
Давайте посмотримfinalizeTree
:
finalizeTree(root, rootPath, scope) {
const state = root[DRAFT_STATE]
if (state) {
if (!this.useProxies) {
state.finalizing = true
state.copy = shallowCopy(state.draft, true)
state.finalizing = false
}
root = state.copy
}
const needPatches = !!rootPath && !!scope.patches
const finalizeProperty = (prop, value, parent) => {
if (value === parent) {
throw Error("Immer forbids circular references")
}
// In the `finalizeTree` method, only the `root` object may be a draft.
const isDraftProp = !!state && parent === root
if (isDraft(value)) {
const path =
isDraftProp && needPatches && !state.assigned[prop]
? rootPath.concat(prop)
: null
// Drafts owned by `scope` are finalized here.
value = this.finalize(value, path, scope)
// Drafts from another scope must prevent auto-freezing.
if (isDraft(value)) {
scope.canAutoFreeze = false
}
// Preserve non-enumerable properties.
if (Array.isArray(parent) || isEnumerable(parent, prop)) {
parent[prop] = value
} else {
Object.defineProperty(parent, prop, {value})
}
// Unchanged drafts are never passed to the `onAssign` hook.
if (isDraftProp && value === state.base[prop]) return
}
// Unchanged draft properties are ignored.
else if (isDraftProp && is(value, state.base[prop])) {
return
}
// Search new objects for unfinalized drafts. Frozen objects should never contain drafts.
else if (isDraftable(value) && !Object.isFrozen(value)) {
each(value, finalizeProperty)
}
if (isDraftProp && this.onAssign) {
this.onAssign(state, prop, value)
}
}
each(root, finalizeProperty)
return root
}
Функция начинается сstate.copy
назначить наroot
, и, наконец, выполнитьeach(root, finalizeProperty)
, то есть вызывается циклически с именем свойства (prop) и значением свойства (value) root в качестве параметровfinalizeProperty
,finalizeProperty
Хотя это выглядит как много кода, на самом деле этоЗамените значение свойства draft(proxy) в копии наdraft[DRAFT_STATE].copy
(Эти прокси назначаются при отметкеChanged, как мы говорили ранее), поэтому мы получаем реальную копию, который, наконец, может быть возвращен пользователю.
Суммировать
Из-за недостатка места я не буду вдаваться в детали содержания патчей, весь проект немного сложнее, чем я ожидал, но основная логика в основном выделена жирным шрифтом выше.
После долгого взгляда на него кажется, что особо и почерпнуть нечего...