본문으로 건너뛰기

Immer 를 1 개 new 하다

무료2019-10-19#JS#Immer 内部原理#JavaScript 不可变数据结构#不可变数据结构实现原理#Immer under the hood#dive into Immer

150 행의 코드로, 수제 Immer 를 생성

서문에

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 는 모든 프록시 조작을 서포트합니다

참고 자료

댓글

아직 댓글이 없습니다

댓글 작성