개요
HTML 매핑은 추상 문서 모델과 DOM 사이의 다리입니다. 양방향 변환이 필요합니다: 렌더링을 위해 모델을 HTML로, 붙여넣은 콘텐츠를 파싱하거나 저장된 문서를 로드하기 위해 HTML을 모델로.
문제는 HTML이 지저분하고 일관성이 없는 반면, 모델은 깨끗하고 검증된다는 것입니다. 정규화와 신중한 파싱이 필수적입니다.
직렬화 (모델 → HTML)
렌더링을 위해 모델을 HTML로 변환합니다:
노드 직렬화
function serializeNode(node) {
switch (node.type) {
case 'document':
return serializeChildren(node.children);
case 'paragraph':
return '<p>' + serializeChildren(node.children) + '</p>';
case 'heading':
const level = node.attrs?.level || 1;
return '<h' + level + '>' + serializeChildren(node.children) + '</h' + level + '>';
case 'text':
return serializeText(node);
case 'link':
const href = escapeHtml(node.attrs?.href || '');
return '<a href="' + href + '">' + serializeChildren(node.children) + '</a>';
case 'image':
const src = escapeHtml(node.attrs?.src || '');
const alt = escapeHtml(node.attrs?.alt || '');
return '<img src="' + src + '" alt="' + alt + '">';
default:
return serializeChildren(node.children);
}
}
function serializeChildren(children) {
return children
.map(child => serializeNode(child))
.join('');
}마크 직렬화
텍스트 노드에 마크를 적용합니다:
function serializeText(node) {
let html = escapeHtml(node.text);
// 순서대로 마크 적용
if (node.marks && node.marks.length > 0) {
// 필요시 우선순위로 마크 정렬
const sortedMarks = sortMarks(node.marks);
sortedMarks.forEach(mark => {
html = wrapWithMark(html, mark);
});
}
return html;
}
function wrapWithMark(html, mark) {
const tagMap = {
bold: 'strong',
italic: 'em',
underline: 'u',
strikethrough: 's',
code: 'code'
};
const tag = tagMap[mark.type];
if (!tag) return html;
const attrs = mark.attrs ? serializeAttrs(mark.attrs) : '';
return '<' + tag + attrs + '>' + html + '</' + tag + '>';
}
// 예제: 여러 마크가 있는 텍스트
// 입력: { type: 'text', text: '볼드 이탤릭', marks: [{ type: 'bold' }, { type: 'italic' }] }
// 출력: '<strong><em>볼드 이탤릭</em></strong>'
// 또는: '<em><strong>볼드 이탤릭</strong></em>' (일부 마크의 경우 순서가 중요함)속성 직렬화
function serializeAttrs(attrs) {
const parts = [];
for (const [key, value] of Object.entries(attrs)) {
if (value !== null && value !== undefined) {
const escaped = escapeHtml(String(value));
parts.push(key + '="' + escaped + '"');
}
}
return parts.length > 0 ? ' ' + parts.join(' ') : '';
}
// 예제
serializeAttrs({ href: 'https://example.com', title: '예제' })
// 반환: ' href="https://example.com" title="예제"'
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}역직렬화 (HTML → 모델)
HTML을 모델로 파싱합니다:
HTML 파싱
function parseHTML(html) {
// HTML 문자열을 DOM으로 파싱
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// DOM을 모델로 변환
return {
type: 'document',
children: Array.from(doc.body.childNodes)
.map(node => parseNode(node))
.filter(Boolean)
};
}
function parseNode(domNode) {
if (domNode.nodeType === Node.TEXT_NODE) {
// 텍스트 노드
return {
type: 'text',
text: domNode.textContent,
marks: extractMarks(domNode)
};
}
if (domNode.nodeType === Node.ELEMENT_NODE) {
// 요소 노드
const nodeType = getNodeType(domNode.tagName);
if (!nodeType) {
// 알 수 없는 요소, 언래핑하고 자식 파싱
return parseChildren(domNode.childNodes);
}
return {
type: nodeType,
attrs: extractAttributes(domNode, nodeType),
children: parseChildren(domNode.childNodes)
};
}
// 다른 노드 타입 무시
return null;
}
function parseChildren(domNodes) {
return Array.from(domNodes)
.map(node => parseNode(node))
.filter(Boolean);
}노드 파싱
function getNodeType(tagName) {
const tagMap = {
'P': 'paragraph',
'H1': 'heading',
'H2': 'heading',
'H3': 'heading',
'H4': 'heading',
'H5': 'heading',
'H6': 'heading',
'A': 'link',
'IMG': 'image',
'STRONG': null, // 마크로 처리
'B': null, // 마크로 처리
'EM': null, // 마크로 처리
'I': null, // 마크로 처리
'U': null, // 마크로 처리
'CODE': null, // 마크로 처리
};
return tagMap[tagName.toUpperCase()] || null;
}
function extractAttributes(domElement, nodeType) {
const attrs = {};
if (nodeType === 'heading') {
const level = parseInt(domElement.tagName[1]) || 1;
attrs.level = level;
}
if (nodeType === 'link') {
attrs.href = domElement.getAttribute('href') || '';
attrs.title = domElement.getAttribute('title') || '';
}
if (nodeType === 'image') {
attrs.src = domElement.getAttribute('src') || '';
attrs.alt = domElement.getAttribute('alt') || '';
}
return attrs;
}마크 추출
function extractMarks(textNode) {
const marks = [];
let current = textNode.parentElement;
// 포맷팅 요소를 찾기 위해 DOM 트리를 따라 올라가기
while (current && current !== editor) {
const mark = getMarkFromElement(current);
if (mark) {
marks.push(mark);
}
current = current.parentElement;
}
return marks;
}
function getMarkFromElement(element) {
const markMap = {
'STRONG': { type: 'bold' },
'B': { type: 'bold' },
'EM': { type: 'italic' },
'I': { type: 'italic' },
'U': { type: 'underline' },
'S': { type: 'strikethrough' },
'CODE': { type: 'code' },
'A': {
type: 'link',
attrs: {
href: element.getAttribute('href') || '',
title: element.getAttribute('title') || ''
}
}
};
const tagName = element.tagName.toUpperCase();
return markMap[tagName] || null;
}
// 예제: <strong><em>텍스트</em></strong>
// 텍스트 노드의 마크: [{ type: 'bold' }, { type: 'italic' }]HTML 정규화
일관성 없는 HTML을 스키마에 맞게 정규화합니다:
요소 정규화
function normalizeHTML(html) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// 요소 정규화
normalizeElements(doc.body);
// 구조 수정
fixStructure(doc.body);
return doc.body.innerHTML;
}
function normalizeElements(element) {
// b를 strong으로 변환
element.querySelectorAll('b').forEach(b => {
const strong = document.createElement('strong');
strong.innerHTML = b.innerHTML;
b.parentNode.replaceChild(strong, b);
});
// i를 em으로 변환
element.querySelectorAll('i').forEach(i => {
const em = document.createElement('em');
em.innerHTML = i.innerHTML;
i.parentNode.replaceChild(em, i);
});
// div를 p로 변환 (적절한 경우)
element.querySelectorAll('div').forEach(div => {
if (!div.querySelector('p, ul, ol, h1, h2, h3, h4, h5, h6, table')) {
const p = document.createElement('p');
p.innerHTML = div.innerHTML;
div.parentNode.replaceChild(p, div);
}
});
// style과 class 속성 제거
element.querySelectorAll('[style]').forEach(el => {
el.removeAttribute('style');
});
element.querySelectorAll('[class]').forEach(el => {
el.removeAttribute('class');
});
}구조 정규화
function fixStructure(element) {
// 블록이 body/document의 직접 자식인지 확인
const blocks = ['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'UL', 'OL', 'BLOCKQUOTE'];
// 다른 블록 내부에 중첩된 블록 언래핑
element.querySelectorAll(blocks.join(',')).forEach(block => {
const parent = block.parentElement;
if (parent && blocks.includes(parent.tagName)) {
// 블록 내부의 블록, 언래핑
const grandparent = parent.parentElement;
if (grandparent) {
grandparent.insertBefore(block, parent);
if (!parent.hasChildNodes()) {
parent.remove();
}
}
}
});
// 인접한 텍스트 노드 병합
mergeTextNodes(element);
// 빈 노드 제거 (br 제외)
removeEmptyNodes(element);
}
function mergeTextNodes(element) {
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT,
null
);
let prevNode = null;
let node;
while (node = walker.nextNode()) {
if (prevNode && prevNode.parentNode === node.parentNode) {
prevNode.textContent += node.textContent;
node.remove();
} else {
prevNode = node;
}
}
}
function removeEmptyNodes(element) {
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_ELEMENT,
null
);
const toRemove = [];
let node;
while (node = walker.nextNode()) {
if (node.tagName === 'BR') continue;
if (!node.hasChildNodes() ||
(node.textContent.trim() === '' && !node.querySelector('br, img'))) {
toRemove.push(node);
}
}
toRemove.forEach(node => node.remove());
}증분 업데이트
전체 문서를 다시 렌더링하는 대신, 변경된 부분만 업데이트합니다:
Diff 알고리즘
function updateDOM(oldModel, newModel, domRoot) {
// 모델을 비교하고 차이점 찾기
const diff = diffModels(oldModel, newModel);
// DOM에 변경사항 적용
diff.forEach(change => {
applyChange(change, domRoot);
});
}
function diffModels(oldModel, newModel) {
const changes = [];
// 자식 비교
const oldChildren = oldModel.children || [];
const newChildren = newModel.children || [];
// 간단한 diff: 추가, 제거, 수정된 노드 찾기
const maxLen = Math.max(oldChildren.length, newChildren.length);
for (let i = 0; i < maxLen; i++) {
const oldChild = oldChildren[i];
const newChild = newChildren[i];
if (!oldChild && newChild) {
// 추가됨
changes.push({
type: 'insert',
index: i,
node: newChild
});
} else if (oldChild && !newChild) {
// 제거됨
changes.push({
type: 'remove',
index: i
});
} else if (oldChild && newChild) {
// 수정되었는지 확인
if (!nodesEqual(oldChild, newChild)) {
changes.push({
type: 'update',
index: i,
oldNode: oldChild,
newNode: newChild
});
}
}
}
return changes;
}
function nodesEqual(node1, node2) {
if (node1.type !== node2.type) return false;
if (node1.type === 'text') {
return node1.text === node2.text &&
marksEqual(node1.marks, node2.marks);
}
// 다른 속성 비교...
return true;
}DOM 패칭
function applyChange(change, domRoot) {
const domNode = findDOMNode(change.index, domRoot);
switch (change.type) {
case 'insert':
const newElement = renderNode(change.node);
if (domNode) {
domNode.parentNode.insertBefore(newElement, domNode);
} else {
domRoot.appendChild(newElement);
}
break;
case 'remove':
if (domNode) {
domNode.remove();
}
break;
case 'update':
if (domNode) {
// 제자리에서 업데이트
updateDOMNode(domNode, change.oldNode, change.newNode);
}
break;
}
}
function updateDOMNode(domNode, oldNode, newNode) {
if (newNode.type === 'text') {
// 텍스트 콘텐츠 업데이트
if (domNode.nodeType === Node.TEXT_NODE) {
domNode.textContent = newNode.text;
} else {
// 텍스트 노드로 요소 교체
const textNode = document.createTextNode(newNode.text);
domNode.parentNode.replaceChild(textNode, domNode);
}
// 마크 업데이트
updateMarks(domNode, oldNode.marks, newNode.marks);
} else {
// 요소 업데이트
updateElement(domNode, oldNode, newNode);
}
}엣지 케이스
중첩된 마크
HTML은 중첩된 포맷팅을 가질 수 있습니다: <strong><em>텍스트</em></strong>
// HTML: <strong><em>볼드 이탤릭</em></strong>
// 모델: 두 마크를 모두 가진 단일 텍스트 노드
{
type: 'text',
text: '볼드 이탤릭',
marks: [
{ type: 'bold' },
{ type: 'italic' }
]
}
// 파싱할 때, 부모 체인에서 모든 마크 수집
function extractMarks(textNode) {
const marks = [];
let current = textNode.parentElement;
while (current && current !== editor) {
const mark = getMarkFromElement(current);
if (mark) {
marks.push(mark);
}
current = current.parentElement;
}
return marks;
}
// 직렬화할 때, 순서대로 마크 적용
function serializeText(node) {
let html = escapeHtml(node.text);
node.marks.forEach(mark => {
html = wrapWithMark(html, mark);
});
return html;
}빈 노드
빈 단락, 빈 목록 등을 처리합니다:
// 빈 단락
{
type: 'paragraph',
children: []
}
// 직렬화: <p><br></p> 또는 <p> </p>
function serializeNode(node) {
if (node.type === 'paragraph' && node.children.length === 0) {
return '<p><br></p>';
}
// ...
}
// 빈 단락 파싱
function parseNode(domNode) {
if (domNode.tagName === 'P' && domNode.textContent.trim() === '') {
return {
type: 'paragraph',
children: []
};
}
// ...
}공백 처리
HTML은 공백을 축소하지만, 보존하고 싶을 수 있습니다:
// 코드 블록에서 공백 보존
{
type: 'codeBlock',
children: [
{ type: 'text', text: ' const x = 1;
const y = 2;' }
]
}
// <pre><code>로 직렬화
function serializeNode(node) {
if (node.type === 'codeBlock') {
return '<pre><code>' + escapeHtml(node.children[0].text) + '</code></pre>';
}
}
// 공백을 보존하며 파싱
function parseNode(domNode) {
if (domNode.tagName === 'PRE' || domNode.tagName === 'CODE') {
return {
type: 'text',
text: domNode.textContent, // 공백 보존
marks: domNode.tagName === 'CODE' ? [{ type: 'code' }] : []
};
}
}