🔎
2020-12-21

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

Javascript

VirtualDom

はじめに
この記事では,仮想DOMを用いてTODOリストを作成します
仮想DOMを作成し,差分からDOMを再生成する仕組み.それを用いたTODOリストの例を紹介します
Githubリポジトリ: https://github.com/kawa1214/virtual-dom-simple-framework
GithubPages: https://kawa1214.github.io/virtual-dom-simple-framework/
スターお願いします⭐

仮想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

ソースコード

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
}