현실적으로 서비스를 운영할 때, 마크업은 탬플릿 형태로 반영된다. 탬플릿 형태를 사용하는 형태로 심플한 패치 기능 프로젝트다.
Interface
ts
interface FragmentAST {
tagName: string
children: FragmentAST[] | string
options: {
attrs: object
events: object
}
}
type parse = (template) => FragmentAST[]
type generate = (FragmentAST[], events) => FragmentDOM
type patch = (FragmentDOM, AutualDOM) => void
Core
Fragment DOM에서 영감을 얻은 코어.
parse
js
// interface FragmentAST {
// tagName: string
// children?: FragmentAST[]
// options: {
// attrs: object
// events: object
// }
// }
import {EVENT_PREFIX, TEXT_NODE} from './constants.js';
export const parse = (template) => {
const childNodes = parseHTML(template);
return parseChildren(childNodes)
};
const parseHTML = (template) => {
const div = document.createElement('div');
div.innerHTML = template;
return div.childNodes;
};
const parseChildren = (childNodes) => {
return Array
.from(childNodes)
.map(toFragmentAST);
};
const toFragmentAST = (node) => {
return {
tagName: toTagName(node),
children: toChildren(node),
options: toOptions(node)
}
};
const toTagName = (node) => node.nodeName.toLowerCase();
const toChildren = (node) => {
const {textContent, childNodes} = node;
const tagName = toTagName(node);
return tagName === TEXT_NODE
? textContent
: parseChildren(childNodes);
};
const toOptions = (node) => {
const extractedAttributes = extractAttributes(node);
const events = extractedAttributes
.filter(({nodeName}) => nodeName.startsWith(EVENT_PREFIX));
const attrs = extractedAttributes
.filter(({nodeName}) => !nodeName.startsWith(EVENT_PREFIX));
return {
attrs: fromEntries(attrs),
events: fromEntries(events),
};
};
const extractAttributes = ({attributes = []}) => {
return Array
.from({length: attributes.length})
.map((_, index) => attributes[index])
.map(({nodeName, nodeValue}) => ({nodeName, nodeValue}));
};
const fromEntries = (entries) => {
return entries
.reduce((acc, {nodeName, nodeValue}) => {
return {...acc, [nodeName]: nodeValue}
}, {})
};
generate
js
import {fragment, html} from './fragment-dom-20200725/render.js';
import {EVENT_PREFIX} from './constants.js';
export const generate = (fragmentAST, fns) => {
return fragment(toHTML({fragmentAST, fns}));
};
const toHTML = ({fragmentAST, fns}) => {
return fragmentAST
.map((node) => {
const {tagName, children, options} = node;
return html(
tagName,
Array.isArray(children)
? toHTML({fragmentAST: children, fns})
: children,
toHTMLOptions({options, fns})
);
});
};
const toHTMLOptions = ({options, fns}) => {
const {attrs, events} = options;
return {
attrs: attrs,
events: toHTMLEvents(events, fns)
}
};
const toHTMLEvents = (events, fns) => {
return Object
.entries(events)
.map(([name, fn]) => {
if (name.startsWith(EVENT_PREFIX)) {
return [
name.replace(EVENT_PREFIX, ''),
fns[fn]
]
} else {
return [name, fn]
}
})
.reduce((acc, [key, value]) => {
return {...acc, [key]: value}
}, {});
}
fragment-dom
patch
js
export const patch = (fragmentDOM, actualDOM) => {
// 1. 초기일 때
if (isBeforeMount(actualDOM)) {
appendChild(fragmentDOM, actualDOM);
return;
}
// 2. 업데이트 일 때
patchAfterMount(fragmentDOM, actualDOM);
};
const patchAfterMount = (fragmentDOM, actualDOM) => {
const fragmentDOMChildren = toChildren(fragmentDOM);
const actualDOMChildren = toChildren(actualDOM);
const childrenLength = Math.max(fragmentDOMChildren.length, actualDOMChildren.length);
range(childrenLength)
.forEach((index) => {
const fragment = fragmentDOMChildren[index];
const actual = actualDOMChildren[index];
if(isNodeRemoved(fragment)) {
actualDOM.removeChild(actual);
} else if (isNodeChanged(fragment, actual)) {
actualDOM.replaceChild(fragment, actual);
} else if (isTextChanged(fragment, actual)) {
actualDOM.replaceChild(fragment, actual);
} else {
if (isAttributeChanged(fragment, actual)) {
patchAttributes(fragment, actual);
}
patchAfterMount(fragment, actual)
}
});
};
const isNodeChanged = (fragmentDOM, actualDOM) => {
const fragmentDOMChildren = toChildren(fragmentDOM);
const actualDOMChildren = toChildren(actualDOM);
return fragmentDOM.nodeName !== actualDOM.nodeName ||
fragmentDOMChildren.length !== actualDOMChildren.length
};
const isNodeRemoved = (fragmentDOM) => fragmentDOM === undefined;
const isTextChanged = (fragmentDOM, actualDOM) => {
if (isTextNode(fragmentDOM, actualDOM)) {
return fragmentDOM.textContent !== actualDOM.textContent
} else {
return false;
}
};
const isAttributeChanged = (fragmentDOM, actualDOM) => {
if (isTextNode(fragmentDOM, actualDOM)) {
return false;
}
const {attributes: fragAttrs} = fragmentDOM;
const {attributes: actualAttrs} = actualDOM;
if (fragAttrs.length !== actualAttrs.length) {
return true;
}
return from(fragAttrs).some((fragAttr, index) => {
const actualAttr = actualAttrs[index];
return fragAttr.nodeName !== actualAttr.nodeName ||
fragAttr.textContent !== actualAttr.textContent
})
};
const patchAttributes = (fragmentDOM, actualDOM) => {
from(actualDOM.attributes)
.forEach((attr) => {
actualDOM.removeAttributeNode(attr);
});
from(fragmentDOM.attributes)
.forEach((attr) => {
if (attr.nodeName === 'value') {
actualDOM.value = attr.textContent;
} else {
actualDOM.setAttributeNode(attr.cloneNode());
}
})
};
const isBeforeMount = (actualDOM) => toChildren(actualDOM).length === 0;
const isTextNode = (fragmentDOM, actualDOM) => {
const TEXT_NODE_NAME = '#text';
return fragmentDOM.nodeName === TEXT_NODE_NAME
&& actualDOM.nodeName === TEXT_NODE_NAME
};
const toChildren = node => from(node.childNodes);
const appendChild = (fragmentDOM, actualDOM) => actualDOM.appendChild(fragmentDOM);
const from = (...args) => Array.from(...args);
const range = (length) => from({length}, (_, index) => index);
const group = (name, fn) => {
console.group(name);
fn();
console.groupEnd(name);
};
render
js
export const fragment = (nodes) => {
const fragment = new DocumentFragment();
nodes.forEach(node => {
fragment.appendChild(node);
});
return fragment;
};
export const html = (tagName, children, options) => {
const elem = tagName === '#text'
? document.createTextNode(tagName)
: document.createElement(tagName);
appendChild(elem, children);
appendOptions(elem, options);
return elem;
};
const appendChild = (elem, children) => {
if (Array.isArray(children)) {
children.forEach((child) => {
typeof child === 'function' ?
child(elem) :
elem.appendChild(child)
})
} else {
elem.textContent = children
}
};
const appendOptions = (elem, options) => {
if (options) {
const {attrs = {}, events = {}} = options;
forEach(attrs, ([key, value]) => {
elem.setAttribute(key, value)
});
forEach(events, ([key, fn]) => {
elem.addEventListener(key, fn)
});
}
};
const forEach = (obj, fn) => {
Object
.entries(obj)
.forEach(fn)
};
사용예
js
import {parse} from './core/parse.js';
import {generate} from './core/generate.js';
import {patch} from './core/fragment-dom-20200725/patch.js';
const template = (state) => {
return `<div>
<h1>Todo</h1>
<input
type="text"
@keyup="onKeyUp"
@input="onInput"
value="${state.text}"
>
<button type="button" @click="onClick">추가</button>
<p>작성중: ${state.text}</p>
<h2>Todo List</h2>
<ol>
${state.todo.map(todo => `<li>${todo}</li>`).join('')}
</ol>
</div>`
};
const fns = {
addTodo: () => {
if (!state.text) {
return;
}
state.setTodo(state.text);
state.setText('');
},
onKeyUp: (event) => {
if (event.key.toLowerCase() === 'enter') {
fns.addTodo(state.text);
}
},
onClick: event => fns.addTodo(),
onInput: event => state.setText(event.target.value)
};
const actualDOM = document.querySelector('#app');
const update = (state) => {
const fragmentAST = parse(template(state));
const fragmentDOM = generate(fragmentAST, fns);
patch(fragmentDOM, actualDOM);
};
const state = {
text: '',
todo: [],
setText: (text) => {
state.text = text;
update(state);
},
setTodo: (todo) => {
state.todo = [...state.todo, todo];
update(state);
},
};
update(state);