Skip to content

仮想DOMをスクラッチで理解してTODOリストを作る

2020/12/21

TypeScript

目次

目次

  1. はじめに
  2. 仮想 DOM を理解する
    1. 仮想 DOM を生成する
    2. 仮想 DOM をレンダリングする
    3. 仮想 DOM をマウントする
    4. 差分だけ更新する
  3. TODO リストを作ろう
    1. TODO リストの動作 GIF
    2. ソースコード

はじめに

この記事では,仮想 DOM を用いて TODO リストを作成します。

仮想 DOM を作成し,差分から DOM を再生成する仕組み。それを用いた TODO リストの例を紹介します。

Github リポジトリ

GithubPages

スターお願いします ⭐

仮想 DOM を理解する

仮想 DOM について DOM 要素を取得する。

getElementById(‘app’)で,id プロパティが app の Element オブジェクトを取得できます。

<html>
  <head>
    <title>simple vdom framework example</title>
  </head>
  <body>
    <div id="app"></div>
    <script src="./dist/main.js"></script>
  </body>
</html>;
const $target = document.getElementById("app");

仮想 DOM を生成する

実現したいことは,以下のような html を生成することです。

<a id="element-1">text</a>

このhtmlは,tag(a), attribute({id: element-1}), children(text)の要素から成り立っています。これらの要素を含むオブジェクトを返す関数を作ります。

interface elementAttributeInterface {
  tagName: keyof HTMLElementTagNameMap;
  attrs: elementAttrsType;
  children: elementChildrenType;
}

type elementAttrsType = { [key: string]: string | EventListener };

type elementChildrenType = Array<elementAttribute | string>;

const createElement = (props: elementAttribute) => {
  return {
    tagName: props.tagName,
    attrs: props.attrs,
    children: props.children,
  };
};

この関数を用いて,先程の HTML 要素を表します。

const element = (state: any) => createElement({
  tagName: "p",
  attrs: {
    id: "element-1",
  },
  children: [
    "text",
  ],
}

仮想 DOM をレンダリングする

まずは,レンダリングするための関数を用意する必要があります。

attribute と child をすべて追加し,生成された Element オブジェクトを返しています。

const renderElem = (vNode: elementAttribute) => {
  const $el = document.createElement(vNode.tagName);

  for (const [k, v] of Object.entries(vNode.attrs)) {
    if (typeof v === "function") {
      /// EventListenerのための処理
      /// oninput を input に変換する必要がある。
      const eventName = k.slice(2);
      $el.addEventListener(eventName, v as EventListener);
    } else {
      $el.setAttribute(k, v);
    }
  }

  for (const child of vNode.children) {
    $el.appendChild(render(child));
  }

  return $el;
};

const firstRender = (vNode: elementAttribute) => {
  return renderElem(vNode);
};
const element = createElement({
  tagName: "p",
  attrs: {
    id: "element-1",
  },
  children: ["text"],
});
const $app = firstRender(element);

仮想 DOM をマウントする

レンダリングしたオブジェクトをマウントします。

interface mountInterface {
  $node: Element;
  $target: Element | null;
}

const mount = (props: mountInterface) => {
  if (props.$target !== null) {
    props.$target.replaceWith(props.$node);
    return props.$node;
  }
};

let vApp = element(state);
const $app = firstRender(vApp);
const $target = document.getElementById("app");
let $rootEl = mount({ $node: $app, $target: $target });

差分だけ更新する

仮想 DOM の特徴でもありますが,効率的にレンダリングするために,差分だけを更新してみます。

const replaceChildren = (
  oldVTree: elementAttribute,
  newVTree: elementAttribute,
  $rootEl: Element
) => {
  // childrenの分だけattributeがあるため,1つのFor分でattributeの差分も見ます。
  $rootEl = replaceAttrs(oldVTree.attrs, newVTree.attrs, $rootEl);

  /// いくつか場合分けをして考えます。
  /// 1.文字列のは置き換えます。
  /// 2.新たに加えたChildrenがある場合は追加します。
  /// 3.Childrenがない場合は,削除する (実装し忘れたー。あとでやります)

  newVTree.children.forEach((newVChild, i) => {
    if (oldVTree.children === undefined || newVTree.children === undefined) {
      return $rootEl;
    }

    const oldVChild = oldVTree.children[i];
    // string型の場合は 文字列のため,replaceWithメソッドで置き換えます。
    if (typeof newVChild === "string" || typeof oldVChild === "string") {
      if (newVChild !== oldVChild) {
        const $newNode = render(newVTree);
        $rootEl.replaceWith($newNode);
      }
      return $rootEl;
    }

    // 新たに加えたChildrenがある場合は,appendChildメソッドで追加します。
    if ($rootEl.children[i] === undefined) {
      $rootEl.appendChild(render(newVChild));
      return $rootEl;
    }
    replaceChildren(newVChild, oldVChild, $rootEl.children[i]);
  });
  return $rootEl;
};

const replaceAttrs = (
  oldAttrs: elementAttrs,
  newAttrs: elementAttrs,
  $rootEl: Element
) => {
  for (const [k, v] of Object.entries(newAttrs)) {
    if (typeof v === "function") {
      $rootEl.addEventListener(k, v as EventListener);
    } else {
      $rootEl.setAttribute(k, v);
    }
  }

  for (const k in oldAttrs) {
    if (!(k in newAttrs)) {
      $rootEl.removeAttribute(k);
    }
  }
  return $rootEl;
};

const diff = (
  oldVTree: elementAttribute,
  newVTree: elementAttribute,
  $rootEl: Element
) => {
  $rootEl = replaceChildren(oldVTree, newVTree, $rootEl);
  return $rootEl;
};

const reRender = () => {
  const vNewApp = view(state);
  if ($rootEl !== undefined) {
    const $newRootEl = diff(vApp, vNewApp, $rootEl);
    $rootEl = $newRootEl;
  }
  vApp = vNewApp;
};

TODO リストを作ろう

TODO リストの動作 GIF

vdom-diff

ソースコード

const view = (state: any) =>
  createElement({
    tagName: "div",
    attrs: { id: "app" },
    children: [
      createElement({
        tagName: "input",
        attrs: {
          type: "text",
          value: `${state.todoInput}`,
          oninput: (event: Event) => {
            const target = event.target as HTMLInputElement;
            setState({
              actionType: "todoInputTextChange",
              state: target.value,
            });
          },
        },
        children: [
          createElement({
            tagName: "p",
            attrs: {},
            children: [`${state.todos[0]}`],
          }),
        ],
      }),
      createElement({
        tagName: "button",
        attrs: {
          type: "button",
          onclick: () => {
            setState({ actionType: "addTodo", state: state.todoInput });
          },
        },
        children: [],
      }),
      ...todosMap(state.todos),
    ],
  });

const todosMap = (todos: Array<String>) => {
  return todos.map((todo, index) => {
    return createElement({
      tagName: "p",
      attrs: {
        key: `${index}`,
      },
      children: [`${todo}`],
    });
  });
};

let state: any = {
  todoInput: "input todo",
  todos: ["todo1", "todo2", "todo3"],
};

let vApp = view(state);
const $app = firstRender(view(state));
const $target = document.getElementById("app");
let $rootEl = mount({ $node: $app, $target: $target });

const setState = (actionState: setStateInterface) => {
  switch (actionState.actionType) {
    case "todoInputTextChange":
      state.todoInput = actionState.state;
      reRender();
      break;
    case "addTodo":
      state.todos.push(actionState.state);
      reRender();
      break;
  }
};

const reRender = () => {
  const vNewApp = view(state);
  if ($rootEl !== undefined) {
    const $newRootEl = diff(vApp, vNewApp, $rootEl);
    $rootEl = $newRootEl;
  }
  vApp = vNewApp;
};