[Vanilla JS 문서편집기 설명 및 개선] 3. 사이드바 렌더링 최적화 및 성능 비용 절감 글을 작성했지만 이는 가독성이 매우 매우 좋지 않고 다른 부분의 렌더링을 최적화하려면 코드를 새로 작성해야 한다는 문제가 있습니다. 기능을 추가할 때마다 새로운 문제에 직면하는 사이드 이펙트도 있어 정말 딱 그 부분만을 위한 최적화였던 것입니다.
저는 렌더링 최적화 방식을 개선해야 할 필요성을 느꼈고 일관성 있도록 최적화하고 싶었습니다. 따라서, 리액트에서 렌더링 하는 방식인 VirtualDOM처럼 기존 DOM과 새로 만들어질 DOM을 비교해 변경된 부분만 업데이트해 주는 방식을 적용해보려 합니다.
VirtualDOM이란?
실제 DOM을 조작하는 방식이 아닌, 실제 DOM을 모방한 가상의 DOM을 만들어 실제 DOM과 달라진 부분만 리렌더링 하는 방식으로 동작합니다. 이를 이용하면 깜빡임 없이 부드러운 UX를 사용자에게 제공할 수 있습니다.
직접 DOM 노드를 생성하거나 접근해 변경하는 것이 아닌, 실제 렌더링된 UI를 JS 객체로 따로 관리해 이를 이용해 빠르게 변경할 수 있습니다. (DOM 노드를 조작하는 것보다 JS객체를 조작하는 속도가 훨씬 빠름)
1. state 변경을 감지하면 UI를 Virtual DOM으로 렌더링
2. 현재의 Virtual DOM과 이전에 저장해 둔 Virtual DOM을 비교(diffing)
3. 변경된 부분을 실제 DOM에 반영
적용하기
사용하기 전에 이전에는 Component 템플릿을 사용하지 않았는데 이를 도입했기 때문에, 먼저 적용을 해봤습니다.
템플릿을 적용했기 때문에 기존의 코드와 달리 SideBarPagesDetails는 불필요해져 SideBarPages에 포함이 되었고, 전체 코드는 다음과 같이 변경되었습니다.
import Data from "../../data.js";
import { push } from "../../router.js";
import { setItem, getItem } from "@stores";
import { Component } from "@core";
import { findTargetById, updateSideBarPages } from "@utils";
export default class SideBarPages extends Component {
setup() {
this.state = {
pages: this.props,
openedDetail: new Set(getItem("openedDetail", [])),
};
this.data = new Data();
}
template() {
return `${printPage(this.state.pages, this.state.openedDetail)}`;
}
mounted() {}
setEvent() {
...
}
}
const printPage = (pageList, openedDetail, depth = 0) => {
return `
<ul class="sidebar__pages--container">
${pageList
?.map(
(list) =>
`<li class="sidebar__pages--detail sidebar__pages--detail-click" data-id=${list.id} data-action="select" style="--depth:${depth}">
<div class = "sidebar__pages--detail-contents">
<button class = "sidebar__pages--detail-button sidebar__pages--detail-click" data-action='toggle'>
<span class = "material-symbols-rounded"> description </span>
</button>
<span class = 'sidebar__pages--detail-title'>
${list.id || "제목 없음"}
</span>
</div>
<div class = "sidebar__pages--detail-toolkit">
<button class = "sidebar__pages--detail-button sidebar__pages--detail-click" data-action='remove'>
<span class = "material-symbols-rounded"> remove </span>
</button>
<button class = "sidebar__pages--detail-button sidebar__pages--detail-click" data-action='add'>
<span class = "material-symbols-rounded"> add </span>
</button>
</div>
</li>
${
openedDetail.has(String(list.id))
? list.documents.length
? printPage(list.documents, openedDetail, depth + 1)
: `<div class="sidebar__pages--empty" style="--depth:${
depth + 1
}">하위 페이지 없음</div>`
: ""
}`
)
.join("")
.trim()}
</ul>
`;
};
Component 템플릿 수정
만들어진 노드를 기존의 DOM과 Component의 render()에서 비교를 해준 뒤 적용하는 방식으로 구현했습니다.
export default class Component {
...
render() {
const { $target } = this;
const virtualNode = $target.cloneNode(true);
virtualNode.innerHTML = this.template();
const realChildNodes = [...$target.childNodes];
const virtualChildNodes = [...virtualNode.childNodes];
const max = Math.max(realChildNodes.length, virtualChildNodes.length);
for (let i = 0; i < max; i++) {
updateNode($target, realChildNodes[i], virtualChildNodes[i]);
}
this.mounted();
}
...
}
const updateNode = (parent, realNode, virtualNode) => {
if (realNode && !virtualNode) {
return realNode.remove();
}
if (!realNode && virtualNode) {
return parent.appendChild(virtualNode);
}
if (realNode instanceof Text && virtualNode instanceof Text) {
if (realNode.nodeValue === virtualNode.nodeValue) return;
return (realNode.nodeValue = virtualNode.nodeValue);
}
if (realNode.nodeName !== virtualNode.nodeName) {
const index = [...parent.childNodes].indexOf(realNode);
return realNode.remove(), parent.appendChild(virtualNode, index);
}
updateAttributes(realNode, virtualNode);
const virtualChildren = [...virtualNode.childNodes];
const realChildren = [...realNode.childNodes];
const max = Math.max(realChildren.length, virtualChildren.length);
for (let i = 0; i < max; i++) {
updateNode(realNode, realChildren[i], virtualChildren[i]);
}
};
const updateAttributes = (realNode, virtualNode) => {
for (const { name, value } of [...virtualNode.attributes]) {
if (value === realNode.getAttribute(name)) continue;
realNode.setAttribute(name, value);
}
for (const { name } of [...realNode.attributes]) {
if (virtualNode.getAttribute(name) !== null) continue;
realNode.removeAttribute(name);
}
};
이해를 위해 설명하면, 기본적으로 실제 DOM의 객체를 real~~, Virtual DOM의 객체를 virtual~~이라 명명했습니다.
const { $target } = this;
const virtualNode = $target.cloneNode(true);
virtualNode.innerHTML = this.template();
const realChildNodes = [...$target.childNodes];
const virtualChildNodes = [...virtualNode.childNodes];
const max = Math.max(realChildNodes.length, virtualChildNodes.length);
for (let i = 0; i < max; i++) {
updateNode($target, realChildNodes[i], virtualChildNodes[i]);
}
virtualNode엔 업데이트될 DOM의 구조가 들어있고, $target엔 현재 DOM의 구조가 들어있습니다.
각각의 자식 노드들을 가져와 차례대로 비교해 주는 작업을 합니다.
updateNode에서 노드들을 비교하는데, 코드의 순서대로 설명하겠습니다.
if (realNode && !virtualNode) {
return realNode.remove();
}
realNode가 존재하고 virtualNode가 존재하지 않는다면 삭제된 노드이기 때문에 제거해 줍니다.
if (!realNode && virtualNode) {
return parent.appendChild(virtualNode);
}
realNode가 존재하지 않고 virtualNode가 존재한다면 새로 생성된 노드이기 때문에 appendChild를 해줍니다. (새로 생성된 노드는 항상 마지막에 존재하기 때문에 appendChild 사용)
if (realNode instanceof Text && virtualNode instanceof Text) {
if (realNode.nodeValue === virtualNode.nodeValue) return;
return (realNode.nodeValue = virtualNode.nodeValue);
}
realNode와 virtualNode가 Text의 인스턴스인지 확인하고 둘 다 해당한다면 실행하는데, 값이 다르다면 realNode를 업데이트해 줍니다. (Text는 HTML 요소 내의 텍스트 콘텐츠를 나타내는 노드입니다. 예를 들면 <p>I'm text</p>에서 "I'm text"가 Text에 해당합니다.)
if (realNode.nodeName !== virtualNode.nodeName) {
const index = [...parent.childNodes].indexOf(realNode);
return realNode.remove(), parent.appendChild(virtualNode, index);
}
nodeName을 가져와 해당 이름이 다르다면 realNode를 제거하고 해당 위치에 virtualNode를 삽입합니다.
(nodeName은 말 그대로 node의 이름인데 <li>I'm li</li>의 경우 nodeName은 li입니다.)
앞의 모든 경우에 해당하지 않는다면 attribute가 다른지 확인을 해주는데,
updateAttributes(realNode, virtualNode);
const updateAttributes = (realNode, virtualNode) => {
for (const { name, value } of [...virtualNode.attributes]) {
if (value === realNode.getAttribute(name)) continue;
realNode.setAttribute(name, value);
}
for (const { name } of [...realNode.attributes]) {
if (virtualNode.getAttribute(name) !== null) continue;
realNode.removeAttribute(name);
}
};
virtualNode의 attribute 값이 realNode에 존재하지 않거나, 다르다면 값을 입력합니다.
또, 만약 realNode에 virtualNode에 존재하지 않는 attribute가 존재한다면 해당 attribute를 제거합니다.
const virtualChildren = [...virtualNode.childNodes];
const realChildren = [...realNode.childNodes];
const max = Math.max(realChildren.length, virtualChildren.length);
for (let i = 0; i < max; i++) {
updateNode(realNode, realChildren[i], virtualChildren[i]);
}
이후 virtualNode와 realNode의 childNode를 가져와 하위 항목들도 전부 비교해 줍니다.
React의 VitrualDOM과 완벽하게 같은 방식은 아니지만, 실제 DOM과 VirtualDOM 역할을 하는 객체를 만들어 이용해 봤습니다!
문제 발생
렌더링이 될 때 DOM객체를 출력해 보면 정상이었던 것 같은데 노드 위치가 계속 변경됩니다. 도대체 어떤 게 문제일지 찾아봤지만 아무리 봐도 모르겠었습니다.
크롬 관리자도구로 디버깅을 해본 결과
.childNodes 결과를 출력해 봤을 때 의문의 text가 끼워져 있는 것을 볼 수 있습니다. 이 text의 정체를 알지 못했기 때문에 DOM요소 중 일부를 의미하는 줄 알고 비중을 두지 않았는데 text가 바로 문제의 원인이었습니다.
동일한 위치에 정확한 노드가 존재하고, 변경을 해야 하는데 해당 노드가 아닌 text에 변경이 적용이 되어 문제가 발생했던 것입니다. 이 text의 정체는 템플릿 리터럴을 사용할 때 공백과 줄 바꿈이 그대로 HTML로 렌더링 되기 때문에 발생하는 문제였고 .innerHTML을 출력해 본 결과 아래와 같이 줄 바꿈이 포함되어 있음을 알 수 있었습니다.
<ul class="sidebar__pages--container">
<li class="sidebar__pages--detail sidebar__pages--detail-click" data-id="137174" data-action="select" style="--depth:0">
...
</li>
<ul class="sidebar__pages--container">
<li class="sidebar__pages--detail sidebar__pages--detail-click" data-id="137186" data-action="select" style="--depth:1">
...
</li>
<ul class="sidebar__pages--container">
<li class="sidebar__pages--detail sidebar__pages--detail-click" data-id="137187" data-action="select" style="--depth:2">
...
</li>
<div class="sidebar__pages--empty" style="--depth:3">하위 페이지 없음</div>
</ul>
</ul>
<li class="sidebar__pages--detail sidebar__pages--detail-click" data-id="137175" data-action="select" style="--depth:0">
...
</li>
</ul>
문제의 원인은 Prettier 설정 때문에 저장 시 자동으로 SideBarPages에서 printPage 함수에서 템플릿 리터럴을 사용할 때 줄 바꿈이 만들어지고, 제가 작성한 부분에서도 의도와 다르게 공백이나 줄 바꿈이 추가되었다는 것이었습니다.
그래서 이 문제를 해결하기 위해 두 방법을 사용했습니다.
1. prettier 설정 무시2. 템플릿 리터럴 내에서 공백, 줄 바꿈 제어
1. prettier 설정 무시
먼저 코드에 //prettier-ignore을 입력해 설정을 무시할 수 있습니다.
//prettier-ignore
const printPage = (pageList, openedDetail, depth = 0) => {
return `
<ul class="sidebar__pages--container">
...
</ul>
`;
};
이 변경만으로는 오류를 완벽하게 해결할 수 없었기 때문에 추가적인 작업을 했습니다.
2. 템플릿 리터럴 내에서 공백, 줄 바꿈 제어
템플릿 리터럴 내에서 공백과 줄바꿈이 그대로 입력되기 때문에 처음부터 이를 없애기 위해 노력했습니다. 그렇기 때문에 다음과 같이 코드를 변경했습니다.(+ 가독성 때문에 3항 연산자를 이중으로 쓰던 코드를 if문으로 변경했습니다.)
// prettier-ignore
const printPage = (pageList, openedDetail, depth = 0) => {
let content = '<ul class="sidebar__pages--container">';
pageList.forEach((list, index) => {
content += `
<li class="sidebar__pages--detail sidebar__pages--detail-click" data-id="${list.id}" data-action="select" style="--depth:${depth}">
<div class="sidebar__pages--detail-contents">
<button class="sidebar__pages--detail-button sidebar__pages--detail-click" data-action="toggle">
<span class="material-symbols-rounded"> description </span>
</button>
<span class="sidebar__pages--detail-title">
${list.title || "제목 없음"}
</span>
</div>
<div class="sidebar__pages--detail-toolkit">
<button class="sidebar__pages--detail-button sidebar__pages--detail-click" data-action="remove">
<span class="material-symbols-rounded"> remove </span>
</button>
<button class="sidebar__pages--detail-button sidebar__pages--detail-click" data-action="add">
<span class="material-symbols-rounded"> add </span>
</button>
</div>
</li>`;
if (openedDetail.has(String(list.id))) {
if (list.documents.length) {
content += printPage(list.documents, openedDetail, depth + 1);
} else {
content += `<div class="sidebar__pages--empty" style="--depth:${depth + 1}">하위 페이지 없음</div>`;
}
}
});
content += '</ul>';
return content;
};
위의 방법으로 문제를 해결한 줄 알았지만 실제 문제는 따로 있었습니다.
if (realNode.nodeName !== virtualNode.nodeName) {
const index = [...parent.childNodes].indexOf(realNode);
return realNode.remove(), parent.appendChild(virtualNode, index);
}
실제로 해당 코드에서 잘못된 문법을 사용하고 있어 렌더링이 잘못되고 있었습니다.
appendChild에는 index를 지정해 노드를 추가하는 기능이 존재하지 않았지만 이를 사용했기 때문에 appendChild로 변경된 부분이 제일 마지막에 추가됨으로써 문제가 발생했던 것 입니다.
따라서 해당 기능을 정상적으로 동작하게 하기 위해 insertBefore를 대신 사용해 문제를 해결했습니다.
if (realNode.nodeName !== virtualNode.nodeName) {
const index = [...parent.childNodes].indexOf(realNode);
return (
realNode.remove(),
parent.insertBefore(virtualNode, parent.children[index] || null)
);
}
'프로젝트 > Vanilla JS 문서편집기' 카테고리의 다른 글
[Vanilla JS 문서편집기 설명 및 개선] 7. Rich한 에디터 만들기(1) (0) | 2024.09.06 |
---|---|
[Vanilla JS 문서편집기 설명 및 개선] 6. 중앙 집중식 상태 관리 적용하기 (1) | 2024.09.04 |
[Vanilla JS 문서편집기 설명 및 개선] 4. 컴포넌트 템플릿 만들기 (0) | 2024.08.29 |
[Vanilla JS 문서편집기 설명 및 개선] 3. 사이드바 렌더링 최적화 및 성능 비용 절감 (0) | 2024.08.21 |
[Vanilla JS 문서편집기 설명 및 개선] 2. 서버 요청 로직 (0) | 2024.08.09 |