STEP +α - memo, useMemo 구현
지금까지는 문서에 나와있는 내용을 차근차근 따라 하며 구현해 봤다.
한 번에 이해가 가지 않는 부분들이 있어서 문서보고 따라치며 github README에 정리 -> 정리한 내용을 바탕으로 다시 처음부터 코드를 짜보며 블로그에 다시 정리 -> 마지막으로 코드 다시 작성해 보면서 총 3번을 해보니까 이제야 흐름이 눈에 보이기 시작했다!
문서 내용에 그치지 않고, 더 나아가 memo, useMemo를 구현해야 한다고 하면 어떻게 작성해야 할지 추가 스텝을 진행해보려고 한다!
우선 이 memo, useMemo를 구현하기 전에 memo와 useMemo가 뭔지 알아야 구현을 시작할 수 있으니 개념 정리를 다시금 진행했다. 개념 정리는 노션에 정리해 놔서 이 글 바로 다음에 이어서 작성하려고 한다 :)
Didact.memo 구현하기
우선 React.memo와 같은 동작을 하는 Didact.memo를 구현해 보기 위해 큰 틀부터 생각해 보자.
- React.memo는 메모이제이션 된 컴포넌트를 반환한다.
- 부모 컴포넌트로 전달받은 props를 Object.is로 비교해 동일하면 재렌더링을 하지 않는다.
즉, 우리가 구현하고자 하는 Didact.memo도 아래와 같은 조건을 만족하도록 구현되어야 한다.
- 동일한 props로 다시 호출될 때 이전 렌더링된 결과를 캐싱해 재사용할 수 있어야 한다.
- Object.is로 props를 비교하고, 다르면 재렌더링 같으면 재렌더링을 건너뛰어야 한다.
1. 우선 props 비교는 Object.is로 처리하면 된다는 사실을 이미 알고 있다.
이전 props와 다음 props를 비교하는 함수를 만든다.
function propsAreEqual(prevProps, nextProps) {
return Object.keys(nextProps).every((key) =>
Object.is(prevProps[key], nextProps[key])
);
}
2. 캐싱 처리는 클로저를 통해 처리한다.
function memo(component) {
let prevProps = null;
return function MemoizedComponent(nextProps) {
if (prevProps && propsAreEqual(prevProps, nextProps)) {
return null;
} else {
prevProps = nextProps;
return component(nextProps);
}
};
}
그렇지만, 위 코드에서는 props가 동일한 마지막에 렌더링 된 경우 재렌더링 처리를 막는 건 아니고 null을 반환하고 있다.
2-1. 마지막 반환 결과도 저장하고 null이 아닌 마지막 반환 결과를 반환하는 코드로 수정했다.
function memo(component, propsAreEqual) {
let prevProps = null;
let lastRenderedOutput = null;
return function MemoizedComponent(nextProps) {
if (prevProps && propsAreEqual(prevProps, nextProps)) {
return lastRenderedOutput;
} else {
prevProps = nextProps;
lastRenderedOutput = component(nextProps);
return lastRenderedOutput;
}
};
}
2-2. 마지막으로 React.memo의 명세를 보면 propsAreEqual을 사용자가 직접 함수를 넘겨줄 수 있게끔 되어있다. (권장하진 않음) 그 부분도 처리될 수 있도록 수정했다.
function memo(component, areEqual = propsAreEqual) {
let prevProps = null;
let lastRenderedOutput = null;
return function MemoizedComponent(nextProps) {
if (prevProps && areEqual(prevProps, nextProps)) {
return lastRenderedOutput;
} else {
prevProps = nextProps;
lastRenderedOutput = component(nextProps);
return lastRenderedOutput;
}
};
}
+. 작동을 확인하고 싶어 JSX 코드는 아래와 같이 변경했다.
const Didact = {
createElement,
render,
useState,
memo,
};
/** @jsx Didact.createElement */
function Parent() {
const [count, setCount] = Didact.useState(1);
return (
<div style={{ display: "flex" }}>
<button type="button" onClick={() => setCount((c) => c - 1)}>
-
</button>
<Child count={count} />
<button type="button" onClick={() => setCount((c) => c + 1)}>
+
</button>
</div>
);
}
const Child = Didact.memo(function Child({ count }) {
return <h1>Count: {count}</h1>;
});
const container = document.getElementById("root");
const element = <Parent />;
Didact.render(element, container);
Didact.memo는 구현 완료!
memo까지 구현한 현재까지의 총 코드는 다음과 같다.
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map((child) =>
typeof child === "object" ? child : createTextElement(child)
),
},
};
}
function createTextElement(text) {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: [],
},
};
}
function createDom(fiber) {
const dom =
fiber.type === "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(fiber.type);
updateDom(dom, {}, fiber.props);
return dom;
}
const isEvent = (key) => key.startsWith("on");
const isProperty = (key) => key !== "children" && !isEvent(key);
const isNew = (prev, next) => (key) => prev[key] !== next[key];
const isGone = (prev, next) => (key) => !(key in next);
function updateDom(dom, prevProps, nextProps) {
// Remove old or changed event listeners
Object.keys(prevProps)
.filter(isEvent)
.filter((key) => !(key in nextProps) || isNew(prevProps, nextProps)(key))
.forEach((name) => {
const eventType = name.toLowerCase().substring(2);
dom.removeEventListener(eventType, prevProps[name]);
});
// Remove old properties
Object.keys(prevProps)
.filter(isProperty)
.filter(isGone(prevProps, nextProps))
.forEach((name) => {
dom[name] = "";
});
// Set new or changed properties
Object.keys(nextProps)
.filter(isProperty)
.filter(isNew(prevProps, nextProps))
.forEach((name) => {
dom[name] = nextProps[name];
});
// Add event listeners
Object.keys(nextProps)
.filter(isEvent)
.filter(isNew(prevProps, nextProps))
.forEach((name) => {
const eventType = name.toLowerCase().substring(2);
dom.addEventListener(eventType, nextProps[name]);
});
}
function commitRoot() {
deletions.forEach(commitWork);
// add nodes to dom
commitWork(wipRoot.child);
currentRoot = wipRoot;
wipRoot = null;
}
function commitWork(fiber) {
if (!fiber) {
return;
}
let domParentFiber = fiber.parent;
while (!domParentFiber.dom) {
domParentFiber = domParentFiber.parent;
}
const domParent = domParentFiber.dom;
if (fiber.effectTag === "PLACEMENT" && fiber.dom != null) {
domParent.appendChild(fiber.dom);
} else if (fiber.effectTag === "UPDATE" && fiber.dom != null) {
updateDom(fiber.dom, fiber.alternate.props, fiber.props);
} else if (fiber.effectTag === "DELETION") {
commitDeletion(fiber, domParent);
}
commitWork(fiber.child);
commitWork(fiber.sibling);
}
function commitDeletion(fiber, domParent) {
if (fiber.dom) {
domParent.removeChild(fiber.dom);
} else {
commitDeletion(fiber.child, domParent);
}
}
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
alternate: currentRoot,
};
deletions = [];
nextUntilOfWork = wipRoot;
}
let nextUntilOfWork = null;
let currentRoot = null;
let wipRoot = null;
let deletions = null;
function workLoop(deadline) {
let shouldYield = false;
while (nextUntilOfWork && !shouldYield) {
nextUntilOfWork = performUnitOfWork(nextUntilOfWork);
shouldYield = deadline.timeRemaining() < 1;
}
if (!nextUntilOfWork && wipRoot) {
commitRoot();
}
requestIdleCallback(workLoop);
}
requestIdleCallback(workLoop);
function performUnitOfWork(fiber) {
const isFunctionComponent = fiber.type instanceof Function;
if (isFunctionComponent) {
updateFunctionComponent(fiber);
} else {
updateHostComponent(fiber);
}
if (fiber.child) {
return fiber.child;
}
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = nextFiber.parent;
}
}
let wipFiber = null;
let hookIndex = null;
function updateFunctionComponent(fiber) {
wipFiber = fiber;
hookIndex = 0;
wipFiber.hooks = [];
const children = [fiber.type(fiber.props)];
reconcileChildren(fiber, children);
}
function useState(initial) {
const oldHook =
wipFiber.alternate &&
wipFiber.alternate.hooks &&
wipFiber.alternate.hooks[hookIndex];
const hook = {
state: oldHook ? oldHook.state : initial,
queue: [],
};
const actions = oldHook ? oldHook.queue : [];
actions.forEach((action) => {
hook.state = action(hook.state);
});
const setState = (action) => {
hook.queue.push(action);
wipRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
};
nextUntilOfWork = wipRoot;
deletions = [];
};
wipFiber.hooks.push(hook);
hookIndex++;
return [hook.state, setState];
}
function updateHostComponent(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
reconcileChildren(fiber, fiber.props.children);
}
function reconcileChildren(wipFiber, elements) {
let index = 0;
let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
let prevSibling = null;
while (index < elements.length || oldFiber != null) {
const element = elements[index];
let newFiber = null;
// compare oldFiber to element
const sameType = oldFiber && element && element.type == oldFiber.type;
if (sameType) {
// update the node
newFiber = {
type: oldFiber.type,
props: element.props,
dom: oldFiber.dom,
parent: wipFiber,
alternate: oldFiber,
effectTag: "UPDATE",
};
}
if (element && !sameType) {
// add this node
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: wipFiber,
alternate: null,
effectTag: "PLACEMENT",
};
}
if (oldFiber && !sameType) {
// delete the oldFiber's node
oldFiber.effectTag = "DELETION";
deletions.push(oldFiber);
}
if (oldFiber) {
oldFiber = oldFiber.sibling;
}
if (index === 0) {
wipFiber.child = newFiber;
} else if (element) {
prevSibling.sibling = newFiber;
}
prevSibling = newFiber;
index++;
}
}
function propsAreEqual(prevProps, nextProps) {
return Object.keys(nextProps).every((key) =>
Object.is(prevProps[key], nextProps[key])
);
}
function memo(component, areEqual = propsAreEqual) {
let prevProps = null;
let lastRenderedOutput = null;
return function MemoizedComponent(nextProps) {
if (prevProps && areEqual(prevProps, nextProps)) {
return lastRenderedOutput;
} else {
prevProps = nextProps;
lastRenderedOutput = component(nextProps);
return lastRenderedOutput;
}
};
}
const Didact = {
createElement,
render,
useState,
memo,
};
/** @jsx Didact.createElement */
function Parent() {
const [count, setCount] = Didact.useState(1);
return (
<div style={{ display: "flex" }}>
<button type="button" onClick={() => setCount((c) => c - 1)}>
-
</button>
<Child count={count} />
<button type="button" onClick={() => setCount((c) => c + 1)}>
+
</button>
</div>
);
}
const Child = Didact.memo(function Child({ count }) {
return <h1>Count: {count}</h1>;
});
const container = document.getElementById("root");
const element = <Parent />;
Didact.render(element, container);
useMemo 구현하기
이번에도 useMemo를 구현해 보기 위해 큰 틀부터 생각해 보자.
- useMemo는 값을 메모이제이션 한다.
- 연산할 함수와 의존성 배열을 받는다.
- 의존성 배열을 받아 해당 의존성이 변경된 경우에만 다시 연산을 실행한다.
memo와 다른 점은 useMemo는 컴포넌트 내부에서 호출되는 함수여야 한다는 점이다.
이전에 문서를 보며 useState 구현했던 부분을 참고했다.
1. dependencies들이 동일한지 확인할 수 있는 함수를 만든다.
function dependenciesAreEqual(prevDependencies, nextDependencies) {
return (
prevDependencies.length === nextDependencies.length &&
prevDependencies.every((dep, i) => Object.is(dep, nextDependencies[i]))
);
}
2. useMemo로 계산할 함수, 의존성 배열을 받는다.
이전에 저장된 hook이 있는지 확인 후 있다면 그때의 값을, 없다면 지금 계산된 값을 return 한다.
function useMemo(calculateValue, dependencies) {
const oldHook =
wipFiber.alternate &&
wipFiber.alternate.hooks &&
wipFiber.alternate.hooks[hookIndex];
const hook = {
dependencies,
value: oldHook ? oldHook.value : calculateValue(),
};
if (oldHook && dependenciesAreEqual(oldHook.dependencies, dependencies)) {
hook.value = oldHook.value;
} else {
hook.value = calculateValue();
}
wipFiber.hooks.push(hook);
hookIndex++;
return hook.value;
}
+. 작동이 잘 되는지 확인하고 싶어 JSX 코드를 아래와 같이 변경해 보았다.
const Didact = {
createElement,
render,
useState,
memo,
useMemo,
};
/** @jsx Didact.createElement */
function Parent() {
const [count, setCount] = Didact.useState(1);
const [count2, setCount2] = Didact.useState(1);
const memoizedValue = Didact.useMemo(() => {
console.log("expensive calculation start");
const startTime = Date.now();
while (Date.now() - startTime < 2000) {}
console.log("expensive calculation end");
return count;
}, [count]);
return (
<div>
<Child count={memoizedValue} />
<button type="button" onClick={() => setCount((c) => c + 1)}>
+ count
</button>
<h1>Count2: {count2}</h1>
<button type="button" onClick={() => setCount2((c) => c + 1)}>
+ count2
</button>
</div>
);
}
const Child = Didact.memo(function Child({ count }) {
return <h1>Count: {count}</h1>;
});
const container = document.getElementById("root");
const element = <Parent />;
Didact.render(element, container);
Didact.useMemo도 구현 완료!
useMemo까지 구현한 총 코드는 다음과 같다.
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map((child) =>
typeof child === "object" ? child : createTextElement(child)
),
},
};
}
function createTextElement(text) {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: [],
},
};
}
function createDom(fiber) {
const dom =
fiber.type === "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(fiber.type);
updateDom(dom, {}, fiber.props);
return dom;
}
const isEvent = (key) => key.startsWith("on");
const isProperty = (key) => key !== "children" && !isEvent(key);
const isNew = (prev, next) => (key) => prev[key] !== next[key];
const isGone = (prev, next) => (key) => !(key in next);
function updateDom(dom, prevProps, nextProps) {
// Remove old or changed event listeners
Object.keys(prevProps)
.filter(isEvent)
.filter((key) => !(key in nextProps) || isNew(prevProps, nextProps)(key))
.forEach((name) => {
const eventType = name.toLowerCase().substring(2);
dom.removeEventListener(eventType, prevProps[name]);
});
// Remove old properties
Object.keys(prevProps)
.filter(isProperty)
.filter(isGone(prevProps, nextProps))
.forEach((name) => {
dom[name] = "";
});
// Set new or changed properties
Object.keys(nextProps)
.filter(isProperty)
.filter(isNew(prevProps, nextProps))
.forEach((name) => {
dom[name] = nextProps[name];
});
// Add event listeners
Object.keys(nextProps)
.filter(isEvent)
.filter(isNew(prevProps, nextProps))
.forEach((name) => {
const eventType = name.toLowerCase().substring(2);
dom.addEventListener(eventType, nextProps[name]);
});
}
function commitRoot() {
deletions.forEach(commitWork);
// add nodes to dom
commitWork(wipRoot.child);
currentRoot = wipRoot;
wipRoot = null;
}
function commitWork(fiber) {
if (!fiber) {
return;
}
let domParentFiber = fiber.parent;
while (!domParentFiber.dom) {
domParentFiber = domParentFiber.parent;
}
const domParent = domParentFiber.dom;
if (fiber.effectTag === "PLACEMENT" && fiber.dom != null) {
domParent.appendChild(fiber.dom);
} else if (fiber.effectTag === "UPDATE" && fiber.dom != null) {
updateDom(fiber.dom, fiber.alternate.props, fiber.props);
} else if (fiber.effectTag === "DELETION") {
commitDeletion(fiber, domParent);
}
commitWork(fiber.child);
commitWork(fiber.sibling);
}
function commitDeletion(fiber, domParent) {
if (fiber.dom) {
domParent.removeChild(fiber.dom);
} else {
commitDeletion(fiber.child, domParent);
}
}
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
alternate: currentRoot,
};
deletions = [];
nextUntilOfWork = wipRoot;
}
let nextUntilOfWork = null;
let currentRoot = null;
let wipRoot = null;
let deletions = null;
function workLoop(deadline) {
let shouldYield = false;
while (nextUntilOfWork && !shouldYield) {
nextUntilOfWork = performUnitOfWork(nextUntilOfWork);
shouldYield = deadline.timeRemaining() < 1;
}
if (!nextUntilOfWork && wipRoot) {
commitRoot();
}
requestIdleCallback(workLoop);
}
requestIdleCallback(workLoop);
function performUnitOfWork(fiber) {
const isFunctionComponent = fiber.type instanceof Function;
if (isFunctionComponent) {
updateFunctionComponent(fiber);
} else {
updateHostComponent(fiber);
}
if (fiber.child) {
return fiber.child;
}
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = nextFiber.parent;
}
}
let wipFiber = null;
let hookIndex = null;
function updateFunctionComponent(fiber) {
wipFiber = fiber;
hookIndex = 0;
wipFiber.hooks = [];
const children = [fiber.type(fiber.props)];
reconcileChildren(fiber, children);
}
function useState(initial) {
const oldHook =
wipFiber.alternate &&
wipFiber.alternate.hooks &&
wipFiber.alternate.hooks[hookIndex];
const hook = {
state: oldHook ? oldHook.state : initial,
queue: [],
};
const actions = oldHook ? oldHook.queue : [];
actions.forEach((action) => {
hook.state = action(hook.state);
});
const setState = (action) => {
hook.queue.push(action);
wipRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
};
nextUntilOfWork = wipRoot;
deletions = [];
};
wipFiber.hooks.push(hook);
hookIndex++;
return [hook.state, setState];
}
function dependenciesAreEqual(prevDependencies, nextDependencies) {
return (
prevDependencies.length === nextDependencies.length &&
prevDependencies.every((dep, i) => Object.is(dep, nextDependencies[i]))
);
}
function useMemo(calculateValue, dependencies) {
const oldHook =
wipFiber.alternate &&
wipFiber.alternate.hooks &&
wipFiber.alternate.hooks[hookIndex];
const hook = {
dependencies,
value: oldHook ? oldHook.value : calculateValue(),
};
if (oldHook && dependenciesAreEqual(oldHook.dependencies, dependencies)) {
hook.value = oldHook.value;
} else {
hook.value = calculateValue();
}
wipFiber.hooks.push(hook);
hookIndex++;
return hook.value;
}
function updateHostComponent(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
reconcileChildren(fiber, fiber.props.children);
}
function reconcileChildren(wipFiber, elements) {
let index = 0;
let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
let prevSibling = null;
while (index < elements.length || oldFiber != null) {
const element = elements[index];
let newFiber = null;
// compare oldFiber to element
const sameType = oldFiber && element && element.type == oldFiber.type;
if (sameType) {
// update the node
newFiber = {
type: oldFiber.type,
props: element.props,
dom: oldFiber.dom,
parent: wipFiber,
alternate: oldFiber,
effectTag: "UPDATE",
};
}
if (element && !sameType) {
// add this node
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: wipFiber,
alternate: null,
effectTag: "PLACEMENT",
};
}
if (oldFiber && !sameType) {
// delete the oldFiber's node
oldFiber.effectTag = "DELETION";
deletions.push(oldFiber);
}
if (oldFiber) {
oldFiber = oldFiber.sibling;
}
if (index === 0) {
wipFiber.child = newFiber;
} else if (element) {
prevSibling.sibling = newFiber;
}
prevSibling = newFiber;
index++;
}
}
function propsAreEqual(prevProps, nextProps) {
return Object.keys(nextProps).every((key) =>
Object.is(prevProps[key], nextProps[key])
);
}
function memo(component, areEqual = propsAreEqual) {
let prevProps = null;
let lastRenderedOutput = null;
return function MemoizedComponent(nextProps) {
if (prevProps && areEqual(prevProps, nextProps)) {
return lastRenderedOutput;
} else {
prevProps = nextProps;
lastRenderedOutput = component(nextProps);
return lastRenderedOutput;
}
};
}
const Didact = {
createElement,
render,
useState,
memo,
useMemo,
};
/** @jsx Didact.createElement */
function Parent() {
const [count, setCount] = Didact.useState(1);
const [count2, setCount2] = Didact.useState(1);
const memoizedValue = Didact.useMemo(() => {
console.log("expensive calculation start");
const startTime = Date.now();
while (Date.now() - startTime < 2000) {}
console.log("expensive calculation end");
return count;
}, [count]);
return (
<div>
<Child count={memoizedValue} />
<button type="button" onClick={() => setCount((c) => c + 1)}>
+ count
</button>
<h1>Count2: {count2}</h1>
<button type="button" onClick={() => setCount2((c) => c + 1)}>
+ count2
</button>
</div>
);
}
const Child = Didact.memo(function Child({ count }) {
return <h1>Count: {count}</h1>;
});
const container = document.getElementById("root");
const element = <Parent />;
Didact.render(element, container);
여기까지 STEP +a 구현 완료! 😊
추가로 useCallback은 useMemo를 활용해 함수를 저장하는 구조라 아래와 같다.
function useCallback(callback, dependencies) {
return useMemo(() => callback, dependencies);
}
이번 나만의 리액트 만들기를 하면서 생각보다도 더 많은 시간을 쏟았는데,
이번 기회에 react의 fiber 동작 원리와 더 나아가 useState, memo, useMemo까지 동작원리를 알 수 있었던 아주 좋은 시간이었다! 🧚🏻
'FRONT-END > React' 카테고리의 다른 글
Suspense 이해하기 (0) | 2024.10.08 |
---|---|
memo, useMemo 이해하기 (1) | 2024.09.11 |
나만의 React 만들기(Build your own react) - STEP 8 (0) | 2024.09.09 |
나만의 React 만들기(Build your own react) - STEP 7 (0) | 2024.09.07 |
나만의 React 만들기(Build your own react) - STEP 6 (1) | 2024.09.06 |
댓글