2020-12-21
Javascript
/
VirtualDom
この記事では,仮想DOMを用いてTODOリストを作成します
仮想DOMを作成し,差分からDOMを再生成する仕組み.それを用いたTODOリストの例を紹介します
スターお願いします⭐
仮想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')
実現したいことは,以下のような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", ], }
まずは,レンダリングするための関数を用意する必要があります.
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)
レンダリングしたオブジェクトをマウントします.
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 }
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 }