共计 10776 个字符,预计需要花费 27 分钟才能阅读完成。
第一篇文章中主要讲解了虚拟 DOM
基本实现,简单的回顾一下,虚拟 DOM
是使用 json
数据描述的一段虚拟 Node
节点树,通过 render
函数生成其真实 DOM
节点。并添加到其对应的元素容器中。在创建真实 DOM
节点的同时并为其注册事件并添加一些附属属性。
虚拟 Dom 详解 – (一)
在上篇文章中也曾经提到过,当状态变更的时候用修改后的新渲染的的 JavaScript
对象和旧的虚拟 DOM
的JavaScript
对象作对比,记录着两棵树的差异,把差别反映到真实的 DOM
结构上最后操作真正的 DOM
的时候只操作有差异的部分的更改。然而上篇文章中也只是简简单单的提到过一句却没有进行实质性的实现,这篇文章主要讲述一下虚拟 DOM
是如何做出更新的。那就开始吧 …O(∩_∩)O
在虚拟 DOM
中实现更新的话是使用 DIFF
算法进行更新的,我想大多数小伙伴都应该听说过这个词,DIFF
是整个虚拟 DOM
部分最核心的部分,因为当虚拟 DOM
节点状态发生改变以后不可能去替换整个 DOM
节点树,若是这样的话会出现打两个 DOM
操作,无非是对性能的极大影响,真的如此的话还不如直接操作 DOM
来的实际一些。
第一篇文章中是通过 render
对虚拟 DOM
节点树进行渲染的,但是在 render
函数中只做了一件事情,只是对虚拟 DOM
进行了新建也就是初始化工作,其实回过头来想一下,无论是新建操作还是修改操作,都应该通过 render
函数来做,在 react
中所有的 DOM
渲染都是通过其中的 render
函数完成的,那么也就得出了这个结论。
// 渲染虚拟 DOM
// 虚拟 DOM 节点树
// 承载 DOM 节点的容器,父元素
function render(vnode,container) {
// 首次渲染
mount(vnode,container);
};
既然更新和创建操作都是通过 render
函数来做的,在方法中又应该如何区分当前的操作到底是新建还是更新呢?毕竟在 react
我们并没有给出明确的标识来告诉其方法,当前是进行的哪个操作。在执行 render
函数的时候有两个参数,一个是传入的 vnode
节点树,还有一个就是承载真实 DOM
节点的容器,其实我们可以把其虚拟 DOM
节点树挂载在其容器中,若容器中存在其节点树则是更新操作,反之则是新建操作。
// 渲染虚拟 DOM
// 虚拟 DOM 节点树
// 承载 DOM 节点的容器,父元素
function render(vnode, container) {if (!container.vnode) {
// 首次渲染
mount(vnode, container);
} else {
// 旧的虚拟 DOM 节点
// 新的 DOM 节点
// 承载 DOM 节点的容器
patch(container.vnode, vnode, container);
}
container.vnode = vnode;
};
既然已经确定了现在的 render
函数所需要进行的操作了,那么接下来就应该进行下一步操作了,如果想要做更新的话必须要知道如下几个参数,原有的虚拟 DOM
节点是什么样的,新的虚拟 DOM
又是什么样的,上一步操作中我们已经把原有的虚拟 DOM
节点已经保存在了父容器中,直接使用即可。
// 更新函数
// 旧的虚拟 DOM 节点
// 新的 DOM 节点
// 承载 DOM 节点的容器
function patch(oldVNode, newVNode, container) {
// 新节点的 VNode 类型
let newVNodeFlag = newVNode.flag;
// 旧节点的 VNode 类型
let oldVNodeFlag = oldVNode.flag;
// 如果新节点与旧节点的类型不一致
// 如果不一致的情况下,相当于其节点发生了变化
// 直接进行替换操作即可
// 这里判断的是如果一个是 TEXT 一个是 Element
// 类型判断
if (newVNodeFlag !== oldVNodeFlag) {replaceVNode(oldVNode, newVNode, container);
}
// 由于在新建时创建 Element 和 Text 的时候使用的是两个函数进行操作的
// 在更新的时候也是同理的
// 也应该针对不同的修改进行不同的操作
// 如果新节点与旧节点的 HTML 相同
else if (newVNodeFlag == vnodeTypes.HTML) {
// 替换元素操作
patchMethos.patchElement(oldVNode, newVNode, container);
}
// 如果新节点与旧节点的 TEXT 相同
else if (newVNodeFlag == vnodeTypes.TEXT) {
// 替换文本操作
patchMethos.patchText(oldVNode, newVNode, container);
}
}
// 更新 VNode 方法集
const patchMethos = {
// 替换文本操作
// 旧的虚拟 DOM 节点
// 新的 DOM 节点
// 承载 DOM 节点的容器
patchText(oldVNode,newVNode,container){
// 获取到 el,并将 oldVNode 赋值给 newVNode
let el = (newVNode.el = oldVNode.el);
// 如果 newVNode.children 不等于 oldVNode.children
// 其他情况就是相等则没有任何操作,不需要更新
if(newVNode.children !== oldVNode.children){
// 直接进行替换操作
el.nodeValue = newVNode.children;
}
}
};
// 替换虚拟 DOM
function replaceVNode(oldVNode, newVNode, container) {
// 在原有节点中删除旧节点
container.removeChild(oldVNode.el);
// 重新渲染新节点
mount(newVNode, container);
}
上述方法简单的实现了对 Text
更新的一个替换操作,由于 Text
替换操作比较简单,所以这里就先实现,仅仅完成了对 Text
的更新是远远不够的,当 Element
进行操作的时也是需要更新的。相对来说 Text
的更新要比 Element
更新要简单很多的,Element
更新比较复杂所以放到了后面,因为比较重要嘛,哈哈~
首先想要进行 Element
替换之前要确定哪些 Data
数据进行了变更,然后才能对其进行替换操作,这样的话需要确定要更改的数据,然后替换掉原有数据,才能进行下一步更新操作。
// 更新 VNode 方法集
const patchMethos = {
// 替换元素操作
// 旧的虚拟 DOM 节点
// 新的 DOM 节点
// 承载 DOM 节点的容器
patchElement(oldVNode,newVNode,container){
// 如果 newVNode 的标签名称与 oldVNode 标签名称不一样
// 既然标签都不一样则直接替换就好了,不需要再进行其他多余的操作
if(newVNode.tag !== oldVNode.tag){replaceVNode(oldVNode,newVNode,container);
return;
}
// 更新 el
let el = (newVNode.el = oldVNode.el);
// 获取旧的 Data 数据
let oldData = oldVNode.data;
// 获取新的 Data 数据
let newData = newVNode.data;
// 如果新的 Data 数据存在
// 进行更新和新增
if(newData){for(let attr in newData){let oldVal = oldData[attr];
let newVal = newData[attr];
domAttributeMethod.patchData(el,attr,oldVal,newVal);
}
}
// 如果旧的 Data 存在
// 检测更新
if(oldData){for(let attr in oldData){let oldVal = oldData[attr];
let newVal = newData[attr];
// 如果旧数据存在,新数据中不存在
// 则表示已删除,需要进行更新操作
if(oldVal && !newVal.hasOwnProperty(attr)){
// 既然新数据中不存在,则新数据则传入 Null
domAttributeMethod.patchData(el,attr,oldVal,null);
}
}
}
}
};
// dom 添加属性方法
const domAttributeMethod = {
// 修改 Data 数据方法
patchData (el,key,prv,next){switch(key){
case "style":
this.setStyle(el,key,prv,next);
// 添加了这里,看我看我 (●'◡'●)
// 添加遍历循环
// 循环旧的 data
this.setOldVal(el,key,prv,next);
break;
case "class":
this.setClass(el,key,prv,next);
break;
default :
this.defaultAttr(el,key,prv,next);
break;
}
},
// 遍历旧数据
setOldVal(el,key,prv,next){
// 遍历旧数据
for(let attr in prv){
// 如果旧数据存在,新数据中不存在
if(!next.hasOwnProperty(attr)){
// 直接赋值为字符串
el.style[attr] = "";
}
}
},
// 修改事件注册方法
addEvent(el,key,prev,next){// 添加了这里,看我看我 (●'◡'●)
// prev 存在删除原有事件,重新绑定新的事件
if(prev){el.removeEventListener(key.slice(1),prev);
}
if(next){el.addEventListener(key.slice(1),next);
}
}
}
上面的操作其实只是替换 Data
部分,但是其子元素没有进行替换,所以还需要对子元素进行替换处理。替换子元素有共分为 6 种情况:
- 旧元素只有一个
- 旧元素为空
- 旧元素为多个
- 新元素只有一个
- 新元素为空
- 新元素为多个
// 更新 VNode 方法集
const patchMethos = {
// 替换元素操作
// 旧的虚拟 DOM 节点
// 新的 DOM 节点
// 承载 DOM 节点的容器
patchElement(oldVNode,newVNode,container){
// 如果 newVNode 的标签名称与 oldVNode 标签名称不一样
// 既然标签都不一样则直接替换就好了,不需要再进行其他多余的操作
if(newVNode.tag !== oldVNode.tag){replaceVNode(oldVNode,newVNode,container);
return;
}
// 更新 el
let el = (newVNode.el = oldVNode.el);
// 获取旧的 Data 数据
let oldData = oldVNode.data;
// 获取新的 Data 数据
let newData = newVNode.data;
// 如果新的 Data 数据存在
// 进行更新和新增
if(newData){for(let attr in newData){let oldVal = oldData[attr];
let newVal = newData[attr];
domAttributeMethod.patchData(el,attr,oldVal,newVal);
}
}
// 如果旧的 Data 存在
// 检测更新
if(oldData){for(let attr in oldData){let oldVal = oldData[attr];
let newVal = newData[attr];
// 如果旧数据存在,新数据中不存在
// 则表示已删除,需要进行更新操作
if(oldVal && !newVal.hasOwnProperty(attr)){
// 既然新数据中不存在,则新数据则传入 Null
domAttributeMethod.patchData(el,attr,oldVal,null);
}
}
}
// 添加了这里
// 更新子元素
// 旧子元素类型
// 新子元素类型
// 旧子元素的 children
// 新子元素的 children
// el 元素,容器
this.patchChildren(
oldVNode.childrenFlag,
newVNode.childrenFlag,
oldVNode.children,
newVNode.children,
el,
);
},
// 更新子元素
// 旧子元素类型
// 新子元素类型
// 旧子元素的 children
// 新子元素的 children
// el 元素,容器
patchChildren(...arg){let [oldChildrenFlag,newChildrenFlag,oldChildren,newChildren,container] = arg;
switch(oldChildrenFlag){
// 如果旧元素的子元素为一个
case childTeyps.SINGLE:
this.upChildSingle(...arg);
break;
// 如果旧元素的子元素为空
case childTeyps.EMPTY:
this.upChildEmpty(...arg);
break;
// 如果旧元素的子元素为多个
case childTeyps.MULTIPLE:
this.upChildMultiple(...arg);
break;
}
},
upChildSingle(...arg){let [oldChildrenFlag,newChildrenFlag,oldChildren,newChildren,container] = arg;
// 循环新的子元素
switch(newChildrenFlag){
// 如果新元素的子元素为一个
case childTeyps.SINGLE:
patch(oldChildren,newChildren,container);
break;
// 如果新元素的子元素为空
case childTeyps.EMPTY:
container.removeChild(oldChildren.el);
break;
// 如果新元素的子元素多个
case childTeyps.MULTIPLE:
container.removeChild(oldChildren.el);
for(let i = 0;i<newChildren.length;i++){mount(newChildren[i],container);
}
break;
}
},
upChildEmpty(...arg){let [oldChildrenFlag,newChildrenFlag,oldChildren,newChildren,container] = arg;
// 循环新的子元素
switch(newChildrenFlag){
// 如果新元素的子元素为一个
case childTeyps.SINGLE:
mount(newChildren,container);
break;
// 如果新元素的子元素为空
case childTeyps.EMPTY:
break;
// 如果新元素的子元素多个
case childTeyps.MULTIPLE:
container.removeChild(oldChildren.el);
for(let i = 0;i<newChildren.length;i++){mount(newChildren[i],container);
}
break;
}
},
upChildMultiple(...arg){let [oldChildrenFlag,newChildrenFlag,oldChildren,newChildren,container] = arg;
// 循环新的子元素
switch(newChildrenFlag){
// 如果新元素的子元素为一个
case childTeyps.SINGLE:
for(let i = 0;i<oldChildren.length;i++){container.removeChild(oldChildren[i].el);
}
mount(newChildren,container);
break;
// 如果新元素的子元素为空
case childTeyps.EMPTY:
for(let i = 0;i<oldChildren.length;i++){container.removeChild(oldChildren[i].el);
}
break;
// 如果新元素的子元素多个
case childTeyps.MULTIPLE:
// **
// 暂时搁置 这里是所有节点的对比
// **
break;
}
}
};
上面代码比较乱,因为嵌套了多层循环,大致逻辑就是使用上述六种情况一一对接配对并且使用其对应的解决方案。
上述六中情况,switch
匹配逻辑:
新数据 | 旧数据 |
---|---|
旧元素只有一个 | 新元素只有一个 |
旧元素只有一个 | 新元素为空 |
旧元素只有一个 | 新元素为多个 |
旧元素为空 | 新元素只有一个 |
旧元素为空 | 新元素为空 |
旧元素为空 | 新元素为多个 |
旧元素为多个 | 新元素只有一个 |
旧元素为多个 | 新元素为空 |
旧元素为多个 | 新元素为多个 |
最为复杂的就是最后一种情况,新旧元素各为多个,然而对于这一部分 react
和vue
的处理方式都是不一样的。以下借鉴的是 react
的diff
算法。
在进行虚拟 DOM
替换时,当元素之间的顺序没有发生变化则原有元素是不需要进行任何改动的,也就是说,若原有顺序是 123456
,新顺序为654321
则他们之间的顺序发生了变化这个时候需要对其进行变更处理,若其顺序出现了插入情况 192939495969
在每个数字后面添加了一个 9
,其实这个时候也是不需要进行更新操作的,其实他们之间的顺序还是和原来一致,只是添加了一些元素值而已,如果变成了213456
,这是时候只需要改变12
就好,其他的是不需要做任何改动的。接下来需要添加最关键的逻辑了。
// 更新 VNode 方法集
// 添加 oldMoreAndNewMore 方法
const patchMethos = {upChildMultiple(...arg) {let [oldChildrenFlag, newChildrenFlag, oldChildren, newChildren, container] = arg;
// 循环新的子元素
switch (newChildrenFlag) {
// 如果新元素的子元素为一个
case childTeyps.SINGLE:
for (let i = 0; i < oldChildren.length; i++) {
// 遍历删除旧元素
container.removeChild(oldChildren[i].el);
}
// 添加新元素
mount(newChildren, container);
break;
// 如果新元素的子元素为空
case childTeyps.EMPTY:
for (let i = 0; i < oldChildren.length; i++) {
// 删除所有子元素
container.removeChild(oldChildren[i].el);
}
break;
// 如果新元素的子元素多个
case childTeyps.MULTIPLE:
// 修改了这里 (●'◡'●)
this.oldMoreAndNewMore(...arg);
break;
},
oldMoreAndNewMore(...arg) {let [oldChildrenFlag, newChildrenFlag, oldChildren, newChildren, container] = arg;
let lastIndex = 0;
for (let i = 0; i < newChildren.length; i++) {let newVnode = newChildren[i];
let j = 0;
// 新的元素是否找到
let find = false;
for (; j < oldChildren.length; j++) {let oldVnode = oldChildren[j];
// key 相同为同一个元素
if (oldVnode.key === newVnode.key) {
find = true;
patch(oldVnode, newVnode, container);
if (j < lastIndex) {if(newChildren[i-1].el){
// 需要移动
let flagNode = newChildren[i-1].el.nextSibling;
container.insertBefore(oldVnode.el, flagNode);
}
break;
}
else {lastIndex = j;}
}
}
// 如果没有找到旧元素,需要新增
if (!find) {
// 需要插入的标志元素
let flagNode = i === 0 ? oldChildren[0].el : newChildren[i-1].el;
mount(newVnode, container, flagNode);
}
// 移除元素
for (let i = 0; i < oldChildren.length; i++) {
// 旧节点
const oldVNode = oldChildren[i];
// 新节点 key 是否在旧节点中存在
const has = newChildren.find(next => next.key === oldVNode.key);
if (!has) {
// 如果不存在删除
container.removeChild(oldVNode.el)
}
}
}
}
};
// 修改 mount 函数
// flagNode 标志 node 新元素需要插入到哪里
function mount(vnode, container, flagNode) {
// 所需渲染标签类型
let {flag} = vnode;
// 如果是节点
if (flag === vnodeTypes.HTML) {
// 调用创建节点方法
mountMethod.mountElement(vnode, container, flagNode);
} // 如果是文本
else if (flag === vnodeTypes.TEXT) {
// 调用创建文本方法
mountMethod.mountText(vnode, container);
};
};
// 修改 mountElement
const mountMethod = {
// 创建 HTML 元素方法
// 修改了这里 (●'◡'●) 添加 flagNode 参数
mountElement(vnode, container, flagNode) {
// 属性,标签名,子元素,子元素类型
let {data, tag, children, childrenFlag} = vnode;
// 创建的真实节点
let dom = document.createElement(tag);
// 添加属性
data && domAttributeMethod.addData(dom, data);
// 在 VNode 中保存真实 DOM 节点
vnode.el = dom;
// 如果不为空,表示有子元素存在
if (childrenFlag !== childTeyps.EMPTY) {
// 如果为单个元素
if (childrenFlag === childTeyps.SINGLE) {
// 把子元素传入,并把当前创建的 DOM 节点以父元素传入
// 其实就是要把 children 挂载到 当前创建的元素中
mount(children, dom);
} // 如果为多个元素
else if (childrenFlag === childTeyps.MULTIPLE) {
// 循环子节点,并创建
children.forEach((el) => mount(el, dom));
};
};
// 添加元素节点 修改了这里 (●'◡'●)
flagNode ? container.insertBefore(dom, flagNode) : container.appendChild(dom);
}
}
最终使用:
const VNODEData = [
"div",
{id:"test",key:789},
[
createElement("p",{
key:1,
style:{
color:"red",
background:"pink"
}
},"节点一"),
createElement("p",{
key:2,
"@click":() => console.log("click me!!!")
},"节点二"),
createElement("p",{
key:3,
class:"active"
},"节点三"),
createElement("p",{key:4},"节点四"),
createElement("p",{key:5},"节点五")
]
];
let VNODE = createElement(...VNODEData);
render(VNODE,document.getElementById("app"));
const VNODEData1 = [
"div",
{id:"test",key:789},
[
createElement("p",{key:6},"节点六"),
createElement("p",{
key:1,
style:{
color:"red",
background:"pink"
}
},"节点一"),
createElement("p",{key:5},"节点五"),
createElement("p",{key:2},"节点二"),
createElement("p",{key:4},"节点四"),
createElement("p",{
key:3,
class:"active"
},"节点三")
]
];
setTimeout(() => {let VNODE = createElement(...VNODEData1);
render(VNODE,document.getElementById("app"));
},1000)
上面代码用了大量的逻辑来处理其中使用大量计算,会比较两棵树之间的同级节点。这样就彻底的降低了复杂度,并且不会带来什么损失。因为在 web 应用中不太可能把一个组件在 DOM
树中跨层级地去移动。
在计算中会尽可能的引用之前的元素,进行位置替换,其实无论是 React
还是 Vue
在渲染列表的时候需要给其元素赋值一个 key
属性,因为在进行 diff
算法时,会优先使用其原有元素,进行位置调整,也是对性能优化的一大亮点。
结语
本文也只是对 diff
算法的简单实现,也许不能满足所有要求,React
的基本实现原理则是如此,希望这篇文章能对大家理解 diff
算法有所帮助。
非常感谢大家用这么长时间来阅读本文章,文章中代码篇幅过长,若有错误请在评论区指出,我会及时做出改正。