서문에
Immer 는 Copy-on-write 메커니즘과 ES6 Proxy 특성을 결합하여, 비상히 간결한 불변 데이터 조작 방식을 제공합니다:
const myStructure = {
a: [1, 2, 3],
b: 0
};
const copy = produce(myStructure, () => {
// nothings to do
});
const modified = produce(myStructure, myStructure => {
myStructure.a.push(4);
myStructure.b++;
});
copy === myStructure // true
modified !== myStructure // true
JSON.stringify(modified) === JSON.stringify({ a: [1, 2, 3, 4], b: 1 }) // true
JSON.stringify(myStructure) === JSON.stringify({ a: [1, 2, 3], b: 0 }) // true
이것은 도대체 어떻게 실현된 것일까요?
一.목표
Immer 에는 1 개의 코어 API 만 있습니다:
produce(currentState, producer: (draftState) => void): nextState
따라서, 등가인 produce 함수를 수동으로 구현할 수 있으면, Immer 의 비밀을 해명할 수 있습니다
二.思路
produce 의 용법을 주의 깊게 관찰하면, 5 개의 특징을 발견하는 것은 어렵지 않습니다 (주석 참조):
const myStructure = {
a: [1, 2, 3],
b: 0
};
const copy = produce(myStructure, () => {});
const modified = produce(myStructure, myStructure => {
// 1.producer 함수 중에서 draftState 에 액세스하는 것은, 원값 currentState 에 액세스하는 것과 같음
myStructure.a.push(4);
myStructure.b++;
});
// 2.producer 중에서 draftState 를 수정하지 않으면, 인용은 불변으로, 모두 원값을 가리킴
copy === myStructure // true
// 3.draftState 를 고친 경우, 인용이 변화하고, produce() 는 신값을 반환
modified !== myStructure // true
// 4.producer 함수 중에서 draftState 에 대한 조작은 모두 신값에 적용됨
JSON.stringify(modified) === JSON.stringify({ a: [1, 2, 3, 4], b: 1 }) // true
// 5.producer 함수 중에서 draftState 에 대한 조작은 원값에 영향하지 않음
JSON.stringify(myStructure) === JSON.stringify({ a: [1, 2, 3], b: 0 }) // true
즉:
-
쓰기 시에만 복사 (주석 2, 주석 3 참조)
-
읽기 조작은 원값에 프록시됨 (주석 1 참조)
-
쓰기 조작은 복사값에 프록시됨 (주석 4, 주석 5 참조)
그렇다면, 심플한 골격이 수면에 떠올랐습니다:
function produce(currentState, producer) {
const copy = null;
const draftState = new Proxy(currentState, {
get(target, key, receiver) {
// todo 읽기 조작을 원값에 프록시
},
set() {
if (!mutated) {
mutated = true;
// todo 복사값을 생성
}
// todo 쓰기 조작을 복사값에 프록시
}
});
producer(draftState);
return copy || currentState;
}
더욱이, Proxy 는 현재 층의 속성 액세스만 감시할 수 있으므로, 프록시 관계도 필요에 따라 생성해야 합니다:

루트 노드는 사전에 Proxy 를 생성하고, 오브젝트 트리 상에서 액세스된 모든 중간 노드 (또는 신규 추가 서브트리의 루트 노드) 도 대응하는 Proxy 를 생성해야 합니다
각 Proxy 는 쓰기 조작 (직접 대입, 네이티브 데이터 조작 API 등) 을 감시한 때만 복사값을 생성하고 (소위 Copy-on-write), 그 후의 쓰기 조작을 모두 복사값에 프록시합니다
마지막으로, 이러한 복사값과 원값을 통합하여, 데이터 조작 결과를 취득합니다
따라서, Immer = Copy-on-write + Proxy
三.구체적 구현
상기의 분석에 따라, 구현 상은 주로 3 개의 부분으로 나뉩니다:
-
프록시: 필요에 따라 생성, 읽기 쓰기 조작을 프록시
-
복사: 필요에 따라 복사 (Copy-on-write)
-
통합: 복사값과 원값의 관련을建立, 원값과 복사값을 깊이 merge
프록시
원값을 취득한 후, 먼저 루트 노드에 Proxy 를 생성하고, producer 가 조작하는 draftState 를 취득:
function produce(original, producer) {
const draft = proxy(original);
//...
}
가장 중요한 것은 원값의 get, set 조작의 프록시입니다:
function proxy(original, onWrite) {
// 프록시 관계 및 복사값을 격납
let draftState = {
originalValue: original,
draftValue: Array.isArray(original) ? [] : Object.create(Object.getPrototypeOf(original)),
mutated: false,
onWrite
};
// 루트 노드 프록시를 생성
const draft = new Proxy(original, {
// 읽기 조작 (속성 액세스를 프록시)
get(target, key, receiver) {
if (typeof original[key] === 'object' && original[key] !== null) {
// 기본값 타입이 아닌 기존 속성, 다음 층의 프록시를 생성
return proxyProp(original[key], key, draftState, onWrite);
}
else {
// 고친 경우는 직접 draft 에서 최신 상태를 취득
if (draftState.mutated) {
return draftValue[key];
}
// 존재하지 않거나, 또는 값이 기본값의 기존 속성, 원값에 프록시
return Reflect.get(target, key, receiver);
}
},
// 쓰기 조작 (데이터 수정을 프록시)
set(target, key, value) {
// 신값이 기본값 타입이 아닌 경우, 다음 층의 프록시를 생성
if (typeof value === 'object') {
proxyProp(value, key, draftState, onWrite);
}
// 첫 번째 쓰기 시에 복사
copyOnWrite(draftState);
// 복사済み, 직접 쓰기
draftValue[key] = value;
return true;
}
});
return draft;
}
P.S. 더욱이, 다른 많은 읽기 쓰기 메서드도 프록시해야 합니다. 예를 들어 has, ownKeys, deleteProperty 등. 처리 방식은 유사하며, 여기서는 자세히 서술하지 않습니다
복사
상기에 나타난 copyOnWrite 함수입니다:
function copyOnWrite(draftState) {
const { originalValue, draftValue, mutated, onWrite } = draftState;
if (!mutated) {
draftState.mutated = true;
// 다음 층에 수정이 있을 때만 부 레벨 draftValue 에 걸기
if (onWrite) {
onWrite(draftValue);
}
// 첫 번째 쓰기 시에 복사
copyProps(draftValue, originalValue);
}
}
첫 번째 쓰기 시 (!mutated) 만 원값 상의 다른 속성을 draftValue 에 복사합니다
特殊的, 浅 복사 시에는 속성 기술자, [Symbol](/articles/symbol-es6 노트 7/) 속성 등의 상세에 주의해야 합니다:
// target 身上에 이미 존재하는 속성을 스킵
function copyProps(target, source) {
if (Array.isArray(target)) {
for (let i = 0; i < source.length; i++) {
// 보다 깊은 층에서 이미 고쳐진 속성을 스킵
if (!(i in target)) {
target[i] = source[i];
}
}
}
else {
Reflect.ownKeys(source).forEach(key => {
const desc = Object.getOwnPropertyDescriptor(source, key);
// 기존 속성을 스킵
if (!(key in target)) {
Object.defineProperty(target, key, desc);
}
});
}
}
P.S.Reflect.ownKeys 는 오브젝트의 모든 속성명 (Symbol 속성명과 문자열 속성명을 포함) 을 반환할 수 있습니다
통합
복사값과 원값을 통합하려면, 먼저 2 개의 관계를建立해야 합니다:
-
프록시와 원값, 복사값의 관련: 루트 노드의 프록시는 결과를 가져내야 함
-
하층 복사값과 조상 복사값의 관련: 복사값은 결과 트리에 쉽게 대응할 수 있어야 함
첫 번째 문제에 대해서는, 프록시 오브젝트에 대응하는 draftState 를 노출시키기만 하면 됩니다:
const INTERNAL_STATE_KEY = Symbol('state');
function proxy(original, onWrite) {
let draftState = {
originalValue: original,
draftValue,
mutated: false,
onWrite
};
const draft = new Proxy(original, {
get(target, key, receiver) {
// proxy 에서 draft 값으로의 관련을建立
if (key === INTERNAL_STATE_KEY) {
return draftState;
}
//...
}
}
}
두 번째 문제에 대해서는, onWrite 후크를 통해 하층 복사값과 조상 복사값의 관련을建立할 수 있습니다:
// 다음 층의 프록시를 생성
function proxyProp(propValue, propKey, hostDraftState) {
const { originalValue, draftValue, onWrite } = hostDraftState;
// 다음 층 속성이 쓰기 조작을 발생했을 때
const onPropWrite = (value) => {
// 필요에 따라 부 레벨 복사값을 생성
if (!draftValue.mutated) {
hostDraftState.mutated = true;
// host 의 모든 속성을 복사
copyProps(draftValue, originalValue);
}
// 자 레벨 복사값을 걸기 (복사값의 부자 관계를建立)
draftValue[propKey] = value;
// 조상에 통보, 위에 완전한 복사값 트리를建立
if (onWrite) {
onWrite(draftValue);
}
};
return proxy(propValue, onPropWrite);
}
즉, 심층 속성이 처음으로 쓰기 조작을 발생했을 때, 위에 필요에 따라 복사하고, 복사값 트리를 구축
至此, 大功告成:
function produce(original, producer) {
const draft = proxy(original);
// draft 를 수정
producer(draft);
// draft 내부 상태를 꺼내
const { originalValue, draftValue, mutated } = draft[INTERNAL_STATE_KEY];
// 고친 신값을 patch 하다
const next = mutated ? draftValue : originalValue;
return next;
}
四.온라인 Demo
수제의 버전은原版보다 조금 심플하므로, m 을 1 개 적게 하여, imer 라고 부릅니다:
五.Immer 와 비교
正版과 비교하여, 구현方案上에 2 개의 차이가 있습니다:
-
프록시를 생성하는 방식이 다름:imer 는
new Proxy를 사용하고, immer 는Proxy.revocable()를 채용 -
통합方案이 다름:imer 는 복사값 트리를反向 구축하고, immer 는 프록시 오브젝트 트리를正向 주사
Proxy.revocable() 로 생성한 Proxy 는 프록시 관계를 해제할 수 있어, 더 안전합니다
Immer 가 프록시 오브젝트 트리를正向 주사하는 것도매우 현명한 방법입니다:
When the producer finally ends, it will just walk through the proxy tree, and, if a proxy is modified, take the copy; or, if not modified, simply return the original node. This process results in a tree that is structurally shared with the previous state. And that is basically all there is to it.
onWrite 가 복사값 트리를反向 구축하는 것보다 직관적이고, 借鉴할 가치가 있습니다
P.S. 더욱이, Immer 는 Object.defineProperty(), Object.setPrototypeOf() 조작을 서포트하지 않지만, 수제의 imer 는 모든 프록시 조작을 서포트합니다
아직 댓글이 없습니다