kawa.dev

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

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

2020-12-21

Javascript

/

VirtualDom

はじめに

この記事では,仮想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 }