从0到1实现一个虚拟DOM

Virtual DOM 是真实 DOM 的映射
当虚拟 DOM 树中的某些节点改变时,会得到一个新的虚拟树。算法对这两棵树(新树和旧树)进行比较,找出差异,然后只需要在真实的 DOM 上做出相应的改变。
用 JS 对象模拟 DOM 树
<ul class="”list”"><li>item 1li><li>item 2li>ul>
{ type: ‘ul’, props: { ‘class’: ‘list’ }, children: [{ type: ‘li’, props: {}, children: [‘item 1’] },{ type: ‘li’, props: {}, children: [‘item 2’] }] }
用如下对象表示 DOM 元素
{ type: ‘…’, props: { … }, children: [ … ] }
用普通 JS 字符串表示 DOM 文本节点
function h(type, props, …children) {return { type, props, children };}
h(‘ul’, { ‘class’: ‘list’ },h(‘li’, {}, ‘item 1’),h(‘li’, {}, ‘item 2’),);
<ul className="”list”"><li>item 1li><li>item 2li>ul>
React.createElement(‘ul’, { className: ‘list’ },React.createElement(‘li’, {}, ‘item 1’),React.createElement(‘li’, {}, ‘item 2’),);
h(...) 函数代替 React.createElement(…),那么我们也能使用 JSX 语法。其实,只需要在源文件头部加上这么一句注释:/** @jsx h */<ul className="”list”"><li>item 1li><li>item 2li>ul>
h(...) 函数代替 React.createElement(…),然后 Babel 就开始编译。’/** @jsx h */ const a = (<ul className="”list”"><li>item 1li><li>item 2li>ul>);
const a = (h(‘ul’, { className: ‘list’ },h(‘li’, {}, ‘item 1’),h(‘li’, {}, ‘item 2’),););
“h” 执行时,它将返回普通 JS 对象-即我们的虚拟 DOM:const a = ({ type: ‘ul’, props: { className: ‘list’ }, children: [{ type: ‘li’, props: {}, children: [‘item 1’] },{ type: ‘li’, props: {}, children: [‘item 2’] }] });
从 Virtual DOM 映射到真实 DOM
使用以’
$‘开头的变量表示真正的 DOM 节点(元素,文本节点),因此 \$parent 将会是一个真实的 DOM 元素虚拟 DOM 使用名为
node的变量表示
createElement(…),它将获取一个虚拟 DOM 节点并返回一个真实的 DOM 节点。这里先不考虑 props 和 children 属性:function createElement(node) {if (typeof node === ‘string’) {return document.createTextNode(node);}return document.createElement(node.type);}
{ type: ‘…’, props: { … }, children: [ … ] }
createElement 传入虚拟文本节点和虚拟元素节点——这是可行的。appendChild() 添加到我们的元素中:function createElement(node) {if (typeof node === ‘string’) {return document.createTextNode(node);}const $el = document.createElement(node.type);node.children.map(createElement).forEach($el.appendChild.bind($el));return $el;}
props 属性放到一边。待会再谈。我们不需要它们来理解虚拟 DOM 的基本概念,因为它们会增加复杂性。/** @jsx h */function h(type, props, ...children) {return { type, props, children };}function createElement(node) {if (typeof node === "string") {return document.createTextNode(node);}const $el = document.createElement(node.type);node.children.map(createElement).forEach($el.appendChild.bind($el));return $el;}const a = (<ul class="list"><li>item 1li><li>item 2li>ul>);const $root = document.getElementById("root");$root.appendChild(createElement(a));
比较两棵虚拟 DOM 树的差异
添加新节点,使用 appendChild(…) 方法添加节点

移除老节点,使用 removeChild(…) 方法移除老的节点

节点的替换,使用 replaceChild(…) 方法


$parent、newNode 和 oldNode,其中 \$parent 是虚拟节点的一个实际 DOM 元素的父元素。现在来看看如何处理上面描述的所有情况。添加新节点
function updateElement($parent, newNode, oldNode) {if (!oldNode) {$parent.appendChild(createElement(newNode));}}
移除老节点
$parent.removeChild(…) 方法把变化映射到真实的 DOM 上。但前提是我们得知道我们的节点在父元素上的索引,我们才能通过 \$parent.childNodes[index] 得到该节点的引用。function updateElement($parent, newNode, oldNode, index = 0) {if (!oldNode) {$parent.appendChild(createElement(newNode));} else if (!newNode) {$parent.removeChild($parent.childNodes[index]);}}
节点的替换
function changed(node1, node2) {return typeof node1 !== typeof node2 ||typeof node1 === ‘string’ && node1 !== node2 ||node1.type !== node2.type}
function updateElement($parent, newNode, oldNode, index = 0) {if (!oldNode) {$parent.appendChild(createElement(newNode));} else if (!newNode) {$parent.removeChild($parent.childNodes[index]);} else if (changed(newNode, oldNode)) {$parent.replaceChild(createElement(newNode), $parent.childNodes[index]);}}
比较子节点
当节点是 DOM 元素时我们才需要比较( 文本节点没有子节点 )
我们需要传递当前的节点的引用作为父节点
我们应该一个一个的比较所有的子节点,即使它是
undefined也没有关系,我们的函数也会正确处理它。最后是 index,它是子数组中子节点的 index
function updateElement($parent, newNode, oldNode, index = 0) {if (!oldNode) {$parent.appendChild(createElement(newNode));} else if (!newNode) {$parent.removeChild($parent.childNodes[index]);} else if (changed(newNode, oldNode)) {$parent.replaceChild(createElement(newNode), $parent.childNodes[index]);} else if (newNode.type) {const newLength = newNode.children.length;const oldLength = oldNode.children.length;for (let i = 0; i < newLength || i < oldLength; i++) {updateElement($parent.childNodes[index],newNode.children[i],oldNode.children[i],i);}}}
完整的代码
/*_ @jsx h_ /
function h(type, props, ...children) {return { type, props, children };}function createElement(node) {if (typeof node === "string") {return document.createTextNode(node);}const $el = document.createElement(node.type);node.children.map(createElement).forEach($el.appendChild.bind($el));return $el;}function changed(node1, node2) {return (typeof node1 !== typeof node2 ||(typeof node1 === "string" && node1 !== node2) ||node1.type !== node2.type);}function updateElement($parent, newNode, oldNode, index = 0) {if (!oldNode) {$parent.appendChild(createElement(newNode));} else if (!newNode) {$parent.removeChild($parent.childNodes[index]);} else if (changed(newNode, oldNode)) {$parent.replaceChild(createElement(newNode), $parent.childNodes[index]);} else if (newNode.type) {const newLength = newNode.children.length;const oldLength = oldNode.children.length;for (let i = 0; i < newLength || i < oldLength; i++) {updateElement($parent.childNodes[index],newNode.children[i],oldNode.children[i],i);}}}// ---------------------------------------------------------------------const a = (<ul><li>item 1li><li>item 2li>ul>);const b = (<ul><li>item 1li><li>hello!li>ul>);const $root = document.getElementById("root");const $reload = document.getElementById("reload");updateElement($root, a);$reload.addEventListener("click", () => {updateElement($root, b, a);});
<button id="reload">RELOADbutton><div id="root">div>
#root {border: 1px solid black;padding: 10px;margin: 30px 0 0 0;}

总结
设置元素属性(props)并进行 diffing/updating
处理事件——向元素中添加事件监听
让虚拟 DOM 与组件一起工作,比如 React
获取对实际 DOM 节点的引用
使用带有库的虚拟 DOM,这些库可以直接改变真实的 DOM,比如 jQuery 及其插件

评论
