从0手写自己的虚拟DOM
所有的源码github地址:https://github.com/Benzic/Simple-VDOM-example/blob/master/index.html
喜欢请star一下哦,也请指正
做了很久的React、Vue项目,对于虚拟DOM都是耳熟能详,但是真实的虚拟DOM到底是怎么实现的呢,就想写一篇文章来记录一下自己对于虚拟DOM的理解。
虚拟DOM并不是真正的DOM,只是一个包含DOM信息的对象,例如这样:
var element = {
tagName: 'ul', // 节点标签名
props: { // DOM的属性,用一个对象存储键值对
id: 'list'
},
children: [ // 该节点的子节点
{tagName: 'li', props: {class: 'item'}, children: ["Item 1"]},
{tagName: 'li', props: {class: 'item'}, children: ["Item 2"]},
{tagName: 'li', props: {class: 'item'}, children: ["Item 3"]},
]
}
对应的DOM节点:
<ul id='list'>
<li class='item'>Item 1</li>
<li class='item'>Item 2</li>
<li class='item'>Item 3</li>
</ul>
虚拟DOM
先创建一个createElement方法,用于创建虚拟DOM方法:
function createElement(type, props, children) {
return new Element(type, props, children)
}
在创建Element构造函数
class Element {
constructor(type, props, children) {
this.type = type;
this.props = props;
this.children = children
}
}
生成虚拟DOM对象:
let VDOM = createElement("ul", {
class: "ul-box"
}, [createElement("li", {
class: "li-item"
}, [1]), createElement("li", {
class: "li-item"
}, [2]), createElement("li", {
class: "li-item"
}, [3])])
VDOM
有了虚拟DOM对象那么就要生成真实的节点并添加到页面当中,这个时候就需要先创建一个真是节点createNode:
function createNode(node) {
let ele = document.createElement(node.type);
for (let key in node.props) {
if (key === 'value') {
if (node.type.toUpperCase() === 'INPUT' || node.type.toUpperCase() === 'TEXTAREA') { //input和textarea特殊处理,
el.value = node.props[key]
}
} else {
el.setAttribute(el, node.props[key])
}
}
return ele
}
createNode仅仅是将单个dom节点创建出来,虚拟DOM树嘛,肯定是很多dom节点,所以还需要将所有单个节点连接起来形成参天大树,这个时候就新建一个方法createDom。
function createDom(vDom) {
let rootNode = createNode(vDom);
if (vDom.children && vDom.children.length) {
vDom.children.map((item) => {
if (item instanceof Element) { //如果children是节点则继续生成节点,如果不是就按文本处理
rootNode.appendChild(createDom(item))
} else {
rootNode.appendChild(document.createTextNode(item))
}
})
}
return rootNode
}
节点
现在确实获取到了真实的节点,但是页面还差一步,因为还没有把生成的节点加载到页面中去
function renderDom(VDOM, root) {
root.appendChild(createDom(VDOM))
}
renderDom(VDOM, document.querySelector('#app'))
加载到页面中
添加input甚至更复杂的内容试一下
let VDOM = createElement("ul", {
class: "ul-box"
}, [createElement("li", {
class: "li-item"
}, [1]), createElement("li", {
class: "li-item",
}, [createElement("input", {
type: "radio",
value: "radio内容",
}, []),
createElement("input", {
type: "text",
value: "文本内容",
placeholder: "请输入文本"
}, [])
]), createElement("li", {
class: "li-item div-box",
}, [createElement("div", {
class: "div-left bg-blue",
}, ["文本内容"]), createElement("div", {
class: "div-right bg-red",
}, [createElement("div", {
class: "div-right-item",
}, ["文本内容"]), createElement("div", {
class: "div-right-item",
}, [createElement("a", {
class: "a",
href: "www.baidu.com"
}, [
createElement("span", {
class: "span"
}, ["这是一条文本"])
]),
createElement("img", {
class: "img",
src: "https://ss3.bdstatic.com/70cFv8Sh_Q1YnxGkpoWK1HF6hhy/it/u=2534506313,1688529724&fm=26&gp=0.jpg",
alt: "图片",
title: "虚拟DOM"
}, [])
])])])])
更复杂的例子
这就算是一个简单的虚拟DOM例子,当然能够生成DOM树是远远不够的,虚拟DOM的diff算法才是虚拟DOM相较于真实DOM的优势所在。
Diff算法的简单实现
我们为什么还需要diff算法?
因为如果我们有一个很庞大的DOM Tree,我们要对它进行更新操作,如果我们只是更新了它很小的一部分,我们就需要更新整个DOM Tree。这也是很浪费性能和资源的。所以Diff算法的作用就是来剔除无用更新,只更新需要更新的部分。
编写的策略:
- 同一层级的一组节点,他们可以通过唯一的id进行区分
- diff只是找到差异,找到了我们需要补齐差异
- 拥有相同类的两个组件将会生成相似的树形结构,拥有不同类的两个组件将会生成不同的树形结构
实现过程:
先创建一个补丁对象和一个全局index索引:
let patches = {};
let index = 0;
虚拟DOM之间最主要的变化包括:
-文本变化
-属性变化
-删除节点
-替换节点
所以根据这些变化创建4个标识符:
const TEXT = 0; //文本
const ATTR = 1; //属性
const REMOVE = 2; //删除
const REPLACE = 3; //替换
同级比较,相同颜色之间比较:
两个虚拟DOM同级比较
新旧之间的区别示例:
新旧之间的区别示例
创建diff方法比较两者差异
function diff(oldTree, newTree) {
walk(oldTree, newTree, index) //遍历两个虚拟DOM树
}
文本的比较:
function walk(oldTree, newTree, index) {
let patch = [];
if (typeof oldTree === 'string' && typeof newTree === 'string') {
if (oldTree !== newTree) {
patch.push({
type: TEXT,
text: newTree
})
}
}
}
属性比较
...
//必须是相同节点类型
if (oldTree.type === newTree.type) {
let attr = diffAttr(oldTree.props, newTree.props);
if (JSON.stringify(attr) !== "{}") {
patch.push({
type: ATTR,
attr: attr
})
}
diffChildren(oldTree.children, newTree.children)
}
...
function diffAttr(oldProps, newProps) {
let attr = {};//遍历找出两者之间的属性差异
for (let key in oldProps) {
if (oldProps[key] !== newProps[key]) {
attr[key] = newProps[key]
}
}
for (let key in newProps) {
if (!oldProps.hasOwnProperty(key)) {
attr[key] = newProps[key]
}
}
return attr
}
function diffChildren(oldChildren, newChildren) {
//遍历children找出区别
oldChildren.forEach(function (child, i) {
walk(child, newChildren[i], ++index)
})
}
删除节点,没有newTree就视为删除
...
if (!newTree) {
patch.push({
type: REMOVE,
index
})
}
...
替换节点,除了以上的所有情况都视为替换
...
patch.push({
type: REPLACE,
newTree
})
...
walk的完整代码:
function diff(oldTree, newTree) {
let patches = {};
let index = 0;
const TEXT = 0; //文本
const ATTR = 1; //属性
const REMOVE = 2; //删除
const REPLACE = 3; //替换
walk(oldTree, newTree, index)
function diffAttr(oldProps, newProps) {
let attr = {};
for (let key in oldProps) {
if (oldProps[key] !== newProps[key]) {
attr[key] = newProps[key]
}
}
for (let key in newProps) {
if (!oldProps.hasOwnProperty(key)) {
attr[key] = newProps[key]
}
}
console.log(attr)
return attr
}
function diffChildren(oldChildren, newChildren) {
oldChildren.forEach(function (child, i) {
walk(child, newChildren[i], ++index)
})
}
function walk(oldTree, newTree, index) {
console.log(oldTree, newTree, index)
let patch = [];
if (!newTree) {
patch.push({
type: REMOVE,
index
})
} else if (typeof oldTree === 'string' && typeof newTree === 'string') {
if (oldTree !== newTree) {
patch.push({
type: TEXT,
text: newTree
})
}
} else if (oldTree.type === newTree.type) {
console.log(oldTree.props, newTree.props)
let attr = diffAttr(oldTree.props, newTree.props);
if (JSON.stringify(attr) !== "{}") {
patch.push({
type: ATTR,
attr: attr
})
}
diffChildren(oldTree.children, newTree.children)
} else {
patch.push({
type: REPLACE,
newTree
})
}
if (patch.length > 0) {
patches[index] = patch;
console.log(patches)
}
}
return patches
}
现在我们就获得了diff比较之后的结构差异补丁对象patches,接下来要做的就是根据补丁对象,更改真实的DOM对象。
function patch(DOM, patches) {
let patchIndex = 0; //暂存当前处理的patchIndex
walkPath(DOM, patches)
}
获取节点和子节点的,分别根据patch补丁对象比较原有DOM节点
function walkPath(DOM, patches) {
//获取节点的补丁
let patch = patches[patchIndex++];
//获取当前DOM的子节点
let children = DOM.childNodes;
console.log(children, patch)
//遍历子节点,打补丁
children && children.forEach((child) => walkPath(child, patches))
if (patch) {
doPath(DOM, patch)
}
}
根据patch类型分别处理文本、属性、删除、和新增节点的对应处理
function doPath(node, patch) {
patch.forEach((item) => {
switch (item.type) {
case TEXT:
node.textContent = item.text //替换节点文本
break;
case ATTR:
for (let key in item.attr) { //遍历补丁对象根据属性替换属性值
let value = item.attr[key]
if (value) {
if (key === 'value') {
if (node.type.toUpperCase() === 'INPUT' || node.type.toUpperCase() ===
'TEXTAREA') {
node.value = value
}
} else {
node.setAttribute(key, value)
}
} else {
node.removeAttribute(key);
}
}
break;
case REMOVE: //删除对应节点
node.parentNode.removeChild(node);
break;
case REPLACE: //替换对应节点
let newTree = patch.newTree
newTree = (newTree instanceof Element) ? createDom(newTree) : document.createTextNode(
newTree);
newTree.parentNode.replaceChild(newTree)
break;
default:
break;
}
})
}
完整的patch方法
function patch(DOM, patches) {
let patchIndex = 0;
const TEXT = 0; //文本
const ATTR = 1; //属性
const REMOVE = 2; //删除
const REPLACE = 3; //替换
walkPath(DOM, patches)
function walkPath(DOM, patches) {
//获取第一个节点的补丁
let patch = patches[patchIndex++];
//获取当前DOM的子节点
let children = DOM.childNodes;
console.log(children, patch)
//遍历子节点,打补丁
children && children.forEach((child) => walkPath(child, patches))
if (patch) {
doPath(DOM, patch)
}
}
function doPath(node, patch) {
patch.forEach((item) => {
switch (item.type) {
case TEXT:
node.textContent = item.text
break;
case ATTR:
for (let key in item.attr) {
let value = item.attr[key]
if (value) {
if (key === 'value') {
if (node.type.toUpperCase() === 'INPUT' || node.type.toUpperCase() ===
'TEXTAREA') {
node.value = value
}
} else {
node.setAttribute(key, value)
}
} else {
node.removeAttribute(key);
}
}
break;
case REMOVE:
node.parentNode.removeChild(node);
break;
case REPLACE:
let newTree = patch.newTree
newTree = (newTree instanceof Element) ? createDom(newTree) : document.createTextNode(
newTree);
newTree.parentNode.replaceChild(newTree)
break;
default:
break;
}
})
}
}
试试效果:
let vDom1 = createElement("div", {
class: "div"
}, [
createElement("div", {
class: "div1"
}, ["diff之前文本"])
]);
let vDom2 = createElement("div", {
class: "div3"
}, [
createElement("div", {
class: "div4"
}, ["diff之后文本"])
]);
let DOODM = createDom(vDom1)
function renderDom(VDOM, root) {
root.appendChild(VDOM)
}
renderDom(DOODM, document.querySelector('#app'))
let patchs = diff(vDom1, vDom2)
patch(DOODM, patchs)
执行结果
执行结果
以上就是我总结的自己实现虚拟DOM的全过程,过程较为简单,还有很多地方没有考虑到,但是也能帮助自己对于虚拟DOM的了解。