目次
目次
はじめに
この記事では,仮想 DOM を用いて TODO リストを作成します。
仮想 DOM を作成し,差分から DOM を再生成する仕組み。それを用いた TODO リストの例を紹介します。
スターお願いします ⭐
仮想 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;
};