Virtual DOM과 유사한 역할을 하며, DocumentFragment를 사용한 Virtual DOM으로 Fragment DOM으로 명명했다.
Interface
ts
// Component 생성 시, 작성하는 함수
type render = (state) => FragmentDOM
type patch = (FragmentDOM, AutualDOM) => void
type isNodeChanged = (FragmentDOM, AutualDOM) => boolean
type isAttributeChanged = (FragmentDOM, AutualDOM) => boolean
patch 전략
Node
같은 레벨의 Node를 순회하며 비교한다. 비교 후 변경 사항이 있을 때, Replace를 하고 해당 Node의 순회를 종료한다.
Attribute
Attribute는 하나라도 변경되면 모두 변경한다.
Node와 Attribute 우선순위?
Attribute 변경은 신규 추가/삭제에 미발생한다. 기존 Node에만 Attribute를 변경한다.
코어
/core/render.js
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 = 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 {fragment, html} from './core/render.js';
const render = (list) => {
const options = {
attrs: {
style: 'font-size: 20px'
},
events: {
click: (event) => event.target.style.fontWeight = 'bold'
}
};
const children = list.map(text => html('li', text, options));
return fragment([
html('ul', children),
html('p', 'Text'),
html('input', [], {attrs: {type: 'text'}}),
html('input', [], {attrs: {type: 'checkbox'}}),
html('input', [], {attrs: {type: 'radio'}})
]);
};
const actualDOM = document.querySelector('#app');
const fragmentDOM = render(['Apple', 'Orange', 'Melon']);
actualDOM.appendChild(fragmentDOM);
/core/patch.js
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) => {
const TEXT_NODE_NAME = '#text';
if (fragmentDOM.nodeName === TEXT_NODE_NAME && actualDOM.nodeName === TEXT_NODE_NAME) {
return fragmentDOM.textContent !== actualDOM.textContent
} else {
return false;
}
};
const isAttributeChanged = (fragmentDOM, actualDOM) => {
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 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);
};
사용예
js
import {fragment, html} from './core/render.js';
import {patch} from './core/patch.js';
const render = (list) => {
const options = {
attrs: {
style: 'font-size: 20px'
},
events: {
click: (event) => event.target.style.fontWeight = 'bold'
}
};
const children = list.map(text => html('li', text, options));
return fragment([
html('ol', children),
html('p', 'Text Changed'),
html('input', [], {attrs: {type: 'text'}}),
html('input', [], {
attrs: {
type: 'checkbox',
class: 'checkbox-item'
}
}),
]);
};
const actualDOM = document.querySelector('#app');
const fragmentDOM = render(['Apple', 'Orange', 'Melon']);
patch(fragmentDOM, actualDOM);
사용케이스
상태 변경 후 패치
js
import {fragment, html} from './core/render.js';
import {patch} from './core/patch.js';
const render = (state) => {
return fragment([
html('input', [], {
attrs: {
type: 'text',
value: state.toUpperCase()
},
events: {input: onChange}
}),
html('input', [], {
attrs: {
type: 'checkbox',
class: 'checkbox-item'
}
}),
html('p', state),
html('textarea', state),
]);
};
const onChange = (event) => {
mount(event.target.value);
};
const mount = (state) => {
const actualDOM = document.querySelector('#app');
const fragmentDOM = render(state);
patch(fragmentDOM, actualDOM);
};
mount('');