Skip to content

현실적으로 서비스를 운영할 때, 마크업은 탬플릿 형태로 반영된다. 탬플릿 형태를 사용하는 형태로 심플한 패치 기능 프로젝트다.

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