Diff 알고리즘 구현
vDOM에서 무엇보다 중요한 것은 변경된 DOM 요소만을 업데이트 하는 Diff 알고리즘이다. 이번 글에서는 이전 가상돔 글에 이어, Diff 알고리즘을 완성해보도록 한다. Diff 알고리즘의 구현은 개발자 황준일님의 블로그를 참고해 내가 만든 웹 컴포넌트에 맞게 다시 작성했다.
updateElement
함수에서 prev
는 이전 노드, next
는 새로운 노드, $parent
는 prev
의 부모 엘리먼트, parentComponent
는 prev
의 부모 vDOM 컴포넌트, index
는 prev
와 next
가 부모의 children
중 몇 번째 자식인지를 의미한다.
여기서의 노드는 vDOM 요소인
Component
와Element
의 인스턴스, 그리고TextNode
를 의미한다. 또한 이후 설명에서Element
와 같이표기한 것은Element
클래스의 인스턴스, "엘리먼트"라고 한글로 표기한 것은 실제 DOM의 엘리먼트를 의미한다.
이제 updateElement()
함수의 코드를 살펴보자.
function updateElement(prev, next, $parent, parentComponent, index = 0) {
// 1. 이전 노드가 없던 자리에 새로운 노드가 추가된 경우.
if ((prev === null || prev === undefined) && next) {
if (typeof next === "object") {
next.mount($parent);
} else {
$parent.innerText = next;
}
parentComponent.children.push(next);
return;
}
// 2. 이전에 존재하던 노드가 제거된 경우
if (next === null || next === undefined) {
$parent.removeChild($parent.childNodes[index]);
parentComponent.children.splice(index, 1);
return;
}
// 3. 이전 노드, 새로운 노드가 모두 텍스트 노드(string | number)인 경우
if (
(typeof prev === "string" || typeof prev === "number") &&
(typeof next === "string" || typeof prev === "number")
) {
if (prev !== next) {
$parent.innerText = next;
parentComponent.children = [next];
}
return;
}
// 4. 이전 노드 또는 새로운 노드가 컴포넌트인 경우
if (typeof prev.tag === "function" || typeof next.tag === "function") {
if (typeof prev.tag === "function") {
next.children = next.template();
}
updateElement(prev.children, next.children, $parent, prev, index);
return;
}
// 5. 두 노드가 다른 태그 명을 가진 경우
if (typeof prev.tag !== typeof next.tag) {
return $parent.replaceChild(next.mount($parent), $parent.childNodes[index]);
}
// 6. 두 Element가 같은 태그 명을 가진 경우
for (const [attr, value] of Object.entries(prev.attrs)) {
if (prev[attr] !== next[attr]) {
if (!next[attr]) {
$parent.removeAttribute(attr, value);
} else {
$parent.setAttribute(attr, value);
}
}
}
const maxLength = Math.max(prev.children.length, next.children.length);
for (let i = 0; i < maxLength; i++) {
updateElement(
prev.children[i],
next.children[i],
$parent.childNodes[index],
prev,
i
);
}
}
updateElement()
함수는 크게 6가지 경우로 분기된다.
1. 이전 노드가 없던 자리에 새로운 노드가 추가된 경우.
// 1. 이전 노드가 없던 자리에 새로운 노드가 추가된 경우.
if ((prev === null || prev === undefined) && next) {
if (typeof next === "object") {
next.mount($parent);
} else {
$parent.innerText = next;
}
parentComponent.children.push(next);
return;
}
부모 컴포넌트에 next
를 추가하기만 하면 된다. 단, next
의 타입에 따라 그 과정이 조금 다르다. next
가 텍스트 노드인 경우 단순히 부모 DOM 엘리먼트의 값을 수정해주면 된다. next
가 텍스트 노드가 아닌 경우, 다시 말해 Element
또는 Component
인 경우 mount()
메서드를 호출해 부모 DOM 엘리먼트에 마운트 해주어야 한다. 마운트가 끝나면, 부모 컴포넌트의 자식으로 next
를 추가한다.
2. 이전에 존재하던 노드가 더이상 존재하지 않는 경우
// 2. 이전에 존재하던 노드가 제거된 경우
if (next === null || next === undefined) {
$parent.removeChild($parent.childNodes[index]);
parentComponent.children.splice(index, 1);
return;
}
이 경우는 훨씬 간단하다. 단순히 부모 엘리먼트인 $parent
에서 그 값을 제거하고, $parent
에 마운트 되어 있는 부모 컴포넌트에서도 동일 인덱스에 위치한 자식을 제거한다.
3. 이전 노드, 새로운 노드가 모두 텍스트 노드(string
또는 number
)인 경우
// 3. 이전 노드, 새로운 노드가 모두 텍스트 노드(string | number)인 경우
if (
(typeof prev === "string" || typeof prev === "number") &&
(typeof next === "string" || typeof prev === "number")
) {
if (prev !== next) {
$parent.innerText = next;
parentComponent.children = [next];
}
return;
}
prev
와 next
모두 텍스트 노드라면, 값이 변하지 않은 경우에만 새로운 값을 대입해준다.
4. 이전 노드 또는 새로운 노드가 컴포넌트인 경우
// 4. 이전 노드 또는 새로운 노드가 컴포넌트인 경우
if (typeof prev.tag === "function" || typeof next.tag === "function") {
if (typeof prev.tag === "function") {
next.children = next.template();
}
updateElement(prev.children, next.children, $parent, prev, index);
return;
}
4번의 경우엔 next
가 컴포넌트인지 여부에 따라 동작이 달라진다. next
가 컴포넌트라면 자식들을 업데이트 하기에 앞서, next
의 자식들, 부모 엘리먼트를 프로퍼티에 담아주는 작업이 필요하다. 컴포넌트가 아닌 Element
라면, 인스턴스가 이미 children
을 지니고 있으므로 상관하지 않아도 된다.
5. 두 노드가 다른 태그 명을 가진 Element
인 경우
// 5. 두 노드가 다른 태그 명을 가진 경우
if (typeof prev.tag !== typeof next.tag) {
return $parent.replaceChild(next.mount($parent), $parent.childNodes[index]);
}
서로 다른 태그 명을 가진 경우 볼 것도 없이 prev
를 next
로 대체한다.
6. 두 노드가 같은 태그 명을 가진 Element
인 경우
// 6. 두 Element가 같은 태그 명을 가진 경우
for (const [attr, value] of Object.entries(prev.attrs)) {
if (prev[attr] !== next[attr]) {
if (!next[attr]) {
$parent.removeAttribute(attr, value);
} else {
$parent.setAttribute(attr, value);
}
}
}
const maxLength = Math.max(prev.children.length, next.children.length);
for (let i = 0; i < maxLength; i++) {
updateElement(
prev.children[i],
next.children[i],
$parent.childNodes[index],
prev,
i
);
}
두 Element
가 같은 태그 명을 가진 경우라면, 먼저 엘리먼트의 속성을 옮겨주는 작업을 진행한다. 속성을 모두 옮겼다면, 자식들을 하나하나 순회하며 다시 updateElement()
함수를 호출한다.
위 함수를 사용해 카운터 예제를 만들어보았다.
createElement()로 추상화 하기
잘 동작하긴 하는데, template()
함수 내 코드가 너무 지저분하고 가독성이 떨어진다. 이를 조금이나마 가독성을 높이기 위해 Element
와 Component
의 인스턴스를 생성을 추상화 해보자.
createElement()
함수를 다음과 같이 작성한다.
/**
* @param {Object} tag - 엘리먼트의 태그 명 또는 컴포넌트를 상속받은 클래스
* @param {string} props - 일반 엘리먼트는 class 또는 id와 같은 속성, 컴포넌트는 초기 상태를 의미한다
* @param {string | number | Element[] | Component[]} children - 자식 엘리먼트를 배열로 전달
* @returns {Element | Component}
* @example createElement('div', {class: 'greet'}, 'hello')
* @example createElement(App, {id: 'app'}, [createElement(Header, {class: 'header'})])
*/
export function createElement(tag, props, children) {
if (typeof tag === "function") {
return new tag(props);
}
if (typeof tag === "string") {
return new Element(tag, props, children);
}
}
이게 끝이다. 자세한 설명은 코드의 주석을 확인하길 바란다.
마지막으로 방금 카운터 코드에 이를 적용해보면 template()
메서드는 다음과 같이 작성할 수 있다.
template() {
const count = this.getState("count");
return createElement("div", { id: "app" }, [
createElement("span", { class: "result" }, `count: ${count}`),
createElement("div", { class: "counter" }, [
createElement("button", { class: "inc" }, "+"),
createElement("button", { class: "dec" }, "-")
])
]);
}
여전히 가독성이 떨어지지만... 전보다는 아주 조금이나마 나아보인다. 이벤트 등록으로 인해 onMount()
메서드 역시 코드가 너무 거창하고 번거로워지는 것 같긴 한데, 이는 프로젝트가 모두 끝나면 다시 다루어보도록 하겠다.
끝!!!
여기까지가 웹 컴포넌트 구현의 끝이다. 다 적고 보니 코드가 몇 줄 안되지만 꽤나 힘든 과정이었다. 이 프로젝트의 원래 목표였던 RxJS로 게임 로직을 작성하는데는 약 2, 3일 정도 걸린 반면, 가상돔 구현은 계속해서 버그를 고치느라 어림잡아 2주 정도의 시간이 소요된것 같다. 배꼽이 배보다 10배 정도 크다😅
오랜 시간에 걸쳐 가상돔 구현을 마치고 나니 가상돔에 대한 이해도 조금 높아진것 같고, 나름대로 뿌듯함도 느껴진다. 하지만 가장 크게 느낀 점은... 다음 부터는 그냥 리액트 쓰자^^ 한 번으로 만족할 만한 좋은 경험이었다.
지금까지는 클래스를 기반으로 코드를 짰는데, 게임 로직은 대부분 RxJS를 사용해 작성했으니 반응형으로 작성한 코드를 보길 원한다면 다음 포스팅을 기대해도 좋다(물론 코드 품질이 좋다는 뜻은 아니다!)
참고자료
'Projects > RxJS로 산성비 만들기' 카테고리의 다른 글
RxJS로 산성비 만들기(4) - 게임 로직 만들기 (0) | 2023.02.20 |
---|---|
RxJS로 산성비 만들기(2) - 웹 컴포넌트(가상돔) 구현하기 (0) | 2023.02.06 |
RxJS로 산성비 만들기(1) - 프로젝트를 시작하며 (0) | 2023.02.01 |
댓글