Skip to content

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('');