
코드만 바로 확인하고 싶으시다면 Soact 라이브러리 Github <- 해당 링크로 바로 이동해주시면 됩니다.
이전 글에 이어 이번 글에서는 비교 알고리즘을 수행하는 updateDOM 함수에 대해 자세히 설명해보려 한다.
이 글을 읽기 전에 얄팍한 코딩사전의 DOM은 뭐고 가상 DOM은 뭔가요?을 보고 오면 이 글을 이해하는데에 더 도움이 될 것이다.
영상에서도 설명하고 있지만 react는 가상 DOM이라는 설계도를 기준으로 변경할 부분을 찾아서 실제 DOM을 변경시킨다.
그렇다면 비교작업을 수행하면서 변경작업까지 동시에 수행하려면 어떤 알고리즘이 가장 적합할까?
우선 DOM은 트리 형태의 자료구조이니 트리를 순회하는 알고리즘을 사용해야할텐데 나는 여기서 BFS와 DFS가 떠올랐다.
결론부터 말하자면 나는 DFS를 사용했다.
그 이유는 비교작업을 수행하면서 동시에 변경작업까지 수행한다고 했을 때 만약 상위노드를 먼저 변경해버리면 하위노드를 변경할때 상위노드를 잘못 참조하는 일이 발생하기 때문이다.
만약 BFS를 사용하면 상위 노드부터 비교하고 변경할 것이다.
하지만 DFS를 사용하면 한 부모의 최하단까지 이동하고 다음 부모로 이동하기 때문에 최하위노드부터 변경이 가능하므로 DFS를 선택했다.
자세한 로직은 아래쪽에 작성할 createDOM과 updateDOM 코드를 보면 된다.
나는 우선 createDOM과 updateDOM 코드를 설명하기 전에 이 메서드는 무슨 역할을 하고 왜 필요한지부터 설명하려 한다.
앞서 작성한 게시글 vanila JS로 react 구현하기 (part 1)에서 내가 구현한 createElement메서드로 어떻게 virtualDOM을 구성하는지 설명했다.
재조명해보자면 아래와 같은 코드는
function Test() {
return (
<div>
<h1>테스트</h1>
<span>
{0}
{1}
</span>
</div>
);
}
아래처럼 생긴 VDOM을 생성한다.
const VDOM = {
el: 'div',
props: null,
children: [
{
el: 'h1',
props: null,
children: [{ value: '테스트', current: {} }],
current: {},
},
{
el: 'span',
props: null,
children: [
{ value: '0', current: {} },
{ value: '1', current: {} },
],
current: {},
},
],
current: {},
};
그럼 이제 VDOM은 준비되었으니 이 정보를 기반으로 실제 DOM을 생성할 일만 남았다.
여기서 실제 DOM을 생성하는 메서드가 createDOM이다.
createDOM이 수행하는 역할은 코드의 주석으로 설명하겠다.
const createDOM = (vDOM?: VDOM | TextVDOM) => {
if (isTextVDOM(vDOM)) {
// 전달된 VDOM이 TextVDOM이면 value를 기준으로 $textNode를 생성한다.
const $textNode = document.createTextNode(vDOM.value);
const { current } = vDOM;
// 혹시 전달된 VDOM에 current(Text | HTMLElement)가 있다면
// value가 같은지 비교 작업을 수행하고 같으면 current를 반환하고
// 같지 않다면 current를 교체한 후 새로 생성한 $textNode를 반환한다.
if (
typeof current?.data === 'string' &&
Object.is(current?.data, $textNode.data)
) {
return current;
} else {
vDOM.current = $textNode;
return $textNode;
}
} else if (vDOM) {
// 전달된 VDOM이 VDOM이면 `el`에 맞는 HTMLElement를 생성하고 current에 바인딩한다.
// 그리고 `props`를 기준으로 $el에 attributes를 세팅한다.
// 그리고 `children`을 순회하며 재귀적으로 `createDOM`을 수행한뒤 해당 배열에 담긴
// $childEl을 부모가 될 $el에 appendChild하고 $el을 반환한다.
const { el, props, children } = vDOM;
const $el = document.createElement(el);
vDOM.current = $el;
setAttrs(props, $el);
children?.map(createDOM).forEach(($childEl) => {
if ($childEl) {
$el.appendChild($childEl);
}
});
return $el;
}
};
주석으로는 이해하기 힘들 수 있으니 조금 나눠서 설명하자면 아래와 같다.
el을 기준으로 새로운 $el을 생성한다.props를 기준으로 $el의 attributes를 세팅한다.children을 순회하며 자식 노드를 생성하고 이들이 담겨있는 배열을 구성한다.children.map을 통해 구성된 자식 노드들을 부모가 될 $el에 appendChild한다.적다보니 재귀적인 요소에서 이해하기 힘든 부분들이 있겠다고 느꼈다.
이런 부분들은 차근차근 여러번 읽어보면 이해할 수 있을 것이라 생각한다.
화이팅(...👍)
그렇다면 이 createDOM메서드는 언제 사용될까?
바로 updateDOM을 수행하며 VDOM의 비교작업을 진행할 때 새로 생성해야할 노드가 있는 경우에 사용하면 된다.
드디어 대망의 updateDOM을 설명할 시간이다.
필자인 나도 이 메서드를 작성하면서 함수를 클린하게 작성했는지 로직이 논리적인지 많은 고민을 하면서 구현했지만 아직 고쳐야될 부분이 많다는 것을 느꼈다.
하지만 조금 비효율적인 부분이 있을지언정 비교작업 자체에는 문제가 없으니 우선 소개하고 추후에 차근차근 로직을 더 개선해나가려 한다.
아래는 전체 로직이지만 updateDOM과 updateElement를 구분해서 설명하려 한다.
// 이 메서드를 실행하고 나면 DOM이 업데이트되기 때문에 `updateDOM`이라고 명명했다.
const updateDOM = (
$parent: HTMLElement = getRoot(),
newVDOM: VDOM = getNewVDOM(),
initVDOM: VDOM = getVDOM()
) => {
// 이 메서드를 실행하고 나면 DOM tree의 Element 요소 하나씩 변경되기 때문에 `updateElement`라고 명명했다.
const updateElement = (
$parent: HTMLElement | Text,
newVDOM: TextVDOM | VDOM | undefined,
initVDOM?: TextVDOM | VDOM | undefined
) => {
const $current = initVDOM?.current;
if (!initVDOM || (isTextVDOM(initVDOM) && !initVDOM.value)) {
const $next = createDOM(newVDOM);
if ($next) {
$parent.appendChild($next);
}
} else if (!newVDOM || (isTextVDOM(newVDOM) && !newVDOM.value)) {
if ($current) {
$parent.removeChild($current);
}
} else if (isChanged(initVDOM, newVDOM)) {
const $next = createDOM(newVDOM);
if ($current && $next) {
$current.replaceWith($next);
newVDOM.current = $next;
}
} else if (!isTextVDOM(initVDOM) && !isTextVDOM(newVDOM)) {
const length = Math.max(
initVDOM.children?.length || 0,
newVDOM.children?.length || 0
);
newVDOM.current = $current;
for (let i = 0; i < length; i++) {
if ($current) {
updateElement(
$current,
newVDOM.children?.[i],
initVDOM.children?.[i]
);
}
}
} else {
newVDOM.current = $current;
}
if (!isTextVDOM(newVDOM) && newVDOM) {
setAttrs(newVDOM.props, $current);
}
};
resetStateId();
updateElement($parent, newVDOM, initVDOM);
setVDOM(newVDOM);
};
updateDOM 자체만을 놓고 보자면 로직은 간단하다.
const updateDOM = (
$parent: HTMLElement = getRoot(),
newVDOM: VDOM = getNewVDOM(),
initVDOM: VDOM = getVDOM()
) => {
resetStateId();
updateElement($parent, newVDOM, initVDOM);
setVDOM(newVDOM);
};
stateHook 구현하기에서 제대로 설명할 예정이다.append해야할지 remove해야할지 replace해야할지 알려야하기 때문에 $parent파라미터를 받는다.const updateElement = (
$parent: HTMLElement | Text,
newVDOM: TextVDOM | VDOM | undefined,
initVDOM?: TextVDOM | VDOM | undefined
) => {
const $current = initVDOM?.current;
if (!initVDOM || (isTextVDOM(initVDOM) && !initVDOM.value)) {
const $next = createDOM(newVDOM);
if ($next) {
$parent.appendChild($next);
}
} else if (!newVDOM || (isTextVDOM(newVDOM) && !newVDOM.value)) {
if ($current) {
$parent.removeChild($current);
}
} else if (isChanged(initVDOM, newVDOM)) {
const $next = createDOM(newVDOM);
if ($current && $next) {
$current.replaceWith($next);
newVDOM.current = $next;
}
} else if (!isTextVDOM(initVDOM) && !isTextVDOM(newVDOM)) {
const length = Math.max(
initVDOM.children?.length || 0,
newVDOM.children?.length || 0
);
newVDOM.current = $current;
for (let i = 0; i < length; i++) {
if ($current) {
updateElement(
$current,
newVDOM.children?.[i],
initVDOM.children?.[i]
);
}
}
} else {
newVDOM.current = $current;
}
if (!isTextVDOM(newVDOM) && newVDOM) {
setAttrs(newVDOM.props, $current);
}
};
비교작업을 수행하기 위해 알아야할 경우의 수는 총 4가지이다.
이 4가지 경우가 필요한 이유는 아래와 같다.
isChanged 함수를 통해 알아낸다.replace해준다.initVDOM과 newVDOM에 어떤 변경사항이 있었는지 알 수 없으니 두 VDOM의 children의 최대길이를 알아낸 후 그만큼 순회한다.VDOM | TextVDOM | undefined가 파라미터로 전달된다.
const isChanged = (
initVDOM: VDOM | TextVDOM | undefined,
newVDOM: VDOM | TextVDOM | undefined
) => {
const isTextInitVDOM = isTextVDOM(initVDOM);
const isTextNewVDOM = isTextVDOM(newVDOM);
// 두 타입이 다르거나
// 두 타입이 TextVDOM일 때 두 값이 다르거나
// 두 타입이 VDOM일 때 두 VDOM의 el이 다르면 VDOM에 변화가 생긴 것
return (
isTextInitVDOM !== isTextNewVDOM ||
(isTextInitVDOM &&
isTextNewVDOM &&
!Object.is(initVDOM.value, newVDOM.value)) ||
(!isTextInitVDOM && !isTextNewVDOM && !Object.is(initVDOM?.el, newVDOM?.el))
);
};
실제 변경사항을 알아내는건 훨씬 복잡한 로직이겠지만 나는 이정도로만 비교했다.
VDOM이나 TextVDOM이 전달되었을때 비교작업을 통해 boolean값을 반환한다.VDOM과 TextVDOM처럼 타입이 다른지TextVDOM이라면 value가 다른지VDOM이라면 el이 다른지이렇게 비교 알고리즘을 Node를 비교해가며 변경하는 형식으로 구현해보니 상태가 변경되었을때 필요한 부분만 변경이 발생한다는 것을 알 수 있었다.
처음에 구현할때는 text를 Node가 아닌 string으로 관리했는데 이렇게 되니 상태가 변경되었을때 text가 삭제되지 않고 계속 추가된다거나 이렇게 삭제되지 않고 추가된 text로 인해 변경작업에 오류가 생기는 등 많은 시행착오가 있었다.
이를 통해 모든 요소를 Node로 관리하면 변경이 필요한 부분만 변경할 수 있다는 것을 알게되어 값진 시간이었다.
다음 글에서는 useState를 구현하기 위해 어떤 생각을 가지고 어떻게 구현했는지 다뤄볼 생각이며 이번 글에서 잠깐 등장했던 resetStateId에 대해서도 설명해볼 것이다.