간단한 가상 DOM 구현 - ChoDragon9/posts GitHub Wiki

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Document</title>
</head>
<body>
<div id="main">
  <ul data-component="todos"></ul>
  <span data-component="counter">0 Item Left</span>
  <ul data-component="filters">
    <li>
      <a href="#">All</a>
    </li>
    <li>
      <a href="#">Active</a>
    </li>
    <li>
      <a href="#">Completed</a>
    </li>
  </ul>
</div>
<script type="module" src="index.js"></script>
</body>
</html>

index.js

렌더링 엔진은 requestAnimationFrame을 기반으로 한다. 모든 DOM 조작이나 애니메이션은 이 DOM API를 기반으로 해야 한다. 이 콜백 내에서 DOM 작업을 수행하면 더 효율적이 된다. 이 API는 메인 스레드를 차단하지 않으며 다음 Repaint가 이벤트 루프에서 스케줄링되기 직전에 실행된다.

import fetchMockTodos from './mockup/fetchMockTodos.js';
import createApp from './core/createApp.js'

const state = {
  todos: [],
  currentFilter: 'All'
};
const main = document.querySelector('#main');

const render = () => {
  window.requestAnimationFrame(() => {
    createApp(main, state);
  });
};

window.setInterval(() => {
  state.todos = fetchMockTodos();
  render();
}, 1000);

fetchMockTodos.js

import {from} from '../helper.js';

export default () => {
  return from({length: Math.max(Math.random() * 10, 5)})
    .map((value, index) => ({
      text: index,
      completed: index > 4
    }))
}

helper.js

export const clone = node => node.cloneNode(true);
export const from = iterable => Array.from(iterable);
export const assign = (...args) => Object.assign(...args);

createApp.js

import registry from './registry.js';
import applyDiff from './virtual-dom/applyDiff.js';
import registerComponents from './registerComponents.js';

const renderRoot = (rootElement, state) => {
  const newRootElement = registry.renderRoot(rootElement, state);
  const {parentNode} = rootElement;

  applyDiff(parentNode, rootElement, newRootElement);
};

export default (rootElement, state) => {
  registerComponents();
  renderRoot(rootElement, state);
}

registry.js

import {clone, from} from '../helper.js';

const COMPONENT_KEY = '[data-component]';

const registry = new Map();

const renderWrapper = componentFn => {
  return (targetElement, state) => {
    const element = componentFn(targetElement, state);
    const childComponents = element.querySelectorAll(COMPONENT_KEY);

    from(childComponents)
      .forEach(child => {
        const componentName = child.dataset.component;

        if (registry.has(componentName)) {
          const childComponentFn = registry.get(componentName);
          child.replaceWith(childComponentFn(child, state));
        }
      });

    return element;
  }
};

const add = (componentName, componentFn) => {
  registry.set(componentName, renderWrapper(componentFn));
};

const renderRoot = (root, state) => {
  return renderWrapper(clone)(root, state)
};

export default {
  add,
  renderRoot
}

applyDiff.js

import isNodeChanged from './isNodeChanged.js';
import {from} from '../../helper.js';

const applyDiff = (parentNode, realNode, virtualNode) => {
  if (realNode && !virtualNode) {
    realNode.remove();
    return
  }

  if (!realNode && virtualNode) {
    parentNode.appendChild(virtualNode);
    return
  }

  if (isNodeChanged(realNode, virtualNode)) {
    realNode.replaceWith(virtualNode);
    return
  }

  const realChildren = from(realNode.children);
  const virtualChildren = from(virtualNode.children);
  const length = Math.max(realChildren.length, virtualChildren.length);

  from({length})
    .map((v, i) => [
      realChildren[i],
      virtualChildren[i]
    ])
    .forEach(([realChild, virtualChild]) => {
      applyDiff(
        realNode,
        realChild,
        virtualChild
      )
    });
};


export default applyDiff;
isNodeChanged.js
import {from} from '../../helper.js';

const isDiffAttrLength = (node1, node2) => {
  const n1Attrs = node1.attributes;
  const n2Attrs = node2.attributes;

  return n1Attrs.length !== n2Attrs.length;
};

const isDiffAttrValue = (node1, node2) => {
  return from(node1.attributes)
    .find(attr => {
      const {name} = attr;
      const attr1 = node1.getAttribute(name);
      const attr2 = node2.getAttribute(name);

      return attr1 !== attr2
    });
};

const isDiffTextContent = (node1, node2) => {
  return node1.children.length === 0 &&
    node2.children.length === 0 &&
    node1.textContent !== node2.textContent;
};

export default (node1, node2) => {
  return isDiffAttrLength(node1, node2)
    || isDiffAttrValue(node1, node2)
    || isDiffTextContent(node1, node2);
};

registerComponents.js

import registry from './registry.js';
import todos from '../components/todos.js';
import counter from '../components/counter.js';
import filters from '../components/filters.js';

export default () => {
  registry.add('todos', todos);
  registry.add('counter', counter);
  registry.add('filters', filters);
};
todos.js
import {assign, clone} from '../helper.js';

const getTodoElement = todo => {
  const {
    text,
    completed
  } = todo;

  return `<li>
    <input 
      type="checkbox"
      ${completed ? 'checked' : ''}>
    <input value="${text}">
  </li>`
};

export default (targetElement, state) => {
  const newTodoList = clone(targetElement);
  const {todos} = state;
  const todosElements = todos.map(getTodoElement).join('');

  return assign(newTodoList, {
    innerHTML: todosElements
  });
}
counter.js
import {assign, clone} from '../helper.js';

const getTodoCount = todos => {
  const notCompleted = todos
    .filter(todo => !todo.completed);
  const { length } = notCompleted;

  return `${length} Items left`
};

export default (targetElement, state) => {
  return assign(clone(targetElement), {
    textContent: getTodoCount(state.todos)
  })
};
filters.js
import {assign, clone, from} from '../helper.js';

export default (targetElement, state) => {
  const newFilter = clone(targetElement);
  const {currentFilter} = state;

  from(newFilter.querySelectorAll('li a'))
    .forEach(a => {
      const fontWeight =
        a.textContent === currentFilter
          ? 'bold'
          : 'normal';
      assign(a.style, {fontWeight});
    });

  return newFilter;
};
⚠️ **GitHub.com Fallback** ⚠️