第一篇文章中主要講解了虛擬DOM
基本實現,簡單的回顧一下,虛擬DOM
是使用json
數據描述的一段虛擬Node
節點樹,通過render
函數生成其真實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
算法有所幫助。
非常感謝大家用這麼長時間來閱讀本文章,文章中代碼篇幅過長,若有錯誤請在評論區指出,我會及時做出改正。