虛擬DOM/domDiff
我們常說的虛擬DOM是通過JS物件模擬出來的DOM
節點,domDiff是通過特定演算法計算出來一次操作所帶來的DOM
變化。
react和vue中都使用了虛擬DOM,vue我只停留在使用層面就不多說了,react瞭解多一些,就藉著react聊聊虛擬DOM。
react中涉及到虛擬DOM的程式碼主要分為以下三部分,其中核心是第二步的domDiff演算法:
- 把render中的JSX(或者createElement這個API)轉化成虛擬DOM
- 狀態或屬性改變後重新計算虛擬DOM並生成一個補丁物件(domDiff)
- 通過這個補丁物件更新檢視中的DOM節點
虛擬DOM不一定更快
幹前端的都知道DOM
操作是效能殺手,因為操作DOM
會引起頁面的迴流或者重繪。相比起來,通過多一些預先計算來減少DOM
的操作要划算的多。
但是,“使用虛擬DOM會更快”這句話並不一定適用於所有場景。例如:一個頁面就有一個按鈕,點選一下,數字加一,那肯定是直接操作DOM
更快。使用虛擬DOM無非白白增加了計算量和程式碼量。即使是複雜情況,瀏覽器也會對我們的DOM
操作進行優化,大部分瀏覽器會根據我們操作的時間和次數進行批量處理,所以直接操作DOM
也未必很慢。
那麼為什麼現在的框架都使用虛擬DOM呢?因為使用虛擬DOM可以提高程式碼的效能下限,並極大的優化大量操作DOM時產生的效能損耗。同時這些框架也保證了,即使在少數虛擬DOM不太給力的場景下,效能也在我們接受的範圍內。
而且,我們之所以喜歡react、vue等使用了虛擬DOM框架,不光是因為他們快,還有很多其他更重要的原因。例如react對函數語言程式設計的友好,vue優秀的開發體驗等,目前社群也有好多比較這兩個框架並打口水戰的,我覺著還是在兩個都懂的情況下多探究一下原理更有意義一些。
實現domDiff的思路
實現domDiff分為以下四步:
- 用JS模擬真實DOM節點
- 把虛擬DOM轉換成真實DOM插入頁面中
- 發生變化時,比較兩棵樹的差異,生成差異物件
- 根據差異物件更新真實DOM
設計師的老本行不能忘,看我畫張圖:
解釋一下這張圖:
首先看第一個紅色色塊,這裡說的是把真實DOM
對映為虛擬DOM
,其實在react中沒有這個過程,我們直接寫的就是虛擬DOM(JSX),只是這個虛擬DOM
代表著真實DOM
。
當虛擬DOM變化時,例如上圖,它的第三個p
和第二個p
中的son2
被刪除了。這個時候我們會根據前後的變化計算出一個差異物件patches
。
這個差異物件的key值就是老的DOM
節點遍歷時的索引,用這個索引我們可以找到那個節點。屬性值是記錄的變化,這裡是remove
,代表刪除。
最後,根據patches
中每一項的索引去對應的位置修改老的DOM
節點。
程式碼如何實現呢?
通過虛擬DOM建立真實DOM
下面這段程式碼是入口檔案,我們模擬了一個虛擬DOM叫oldEle
,我們這裡是寫死的。而在react中,是通過babel解析JSX語法得到一個抽象語法樹(AST),進而生成虛擬DOM。如果對babel轉換感興趣,可以看看另一篇文章入門babel--實現一個es6的class轉換器。
import { createElement } from './createElement'
let oldEle = createElement('div', { class: 'father' }, [
createElement('h1', { style:'color:red' }, ['son1']),
createElement('h2', { style:'color:blue' }, ['son2']),
createElement('h3', { style:'color:red' }, ['son3'])
])
document.body.appendChild(oldEle.render())
複製程式碼
下面這個檔案匯出了createElement
方法。它其實就是new
了一個Element
類,呼叫這個類的render
方法可以把虛擬DOM
轉換為真實DOM
。
class Element {
constructor(tagName, attrs, childs) {
this.tagName = tagName
this.attrs = attrs
this.childs = childs
}
render() {
let element = document.createElement(this.tagName)
let attrs = this.attrs
let childs = this.childs
//設定屬性
for (let attr in attrs) {
setAttr(element, attr, attrs[attr])
}
//先序深度優先遍歷子建立並插入子節點
for (let i = 0; i < childs.length; i++) {
let child = childs[i]
console.log(111, child instanceof Element)
let childElement = child instanceof Element ? child.render() : document.createTextNode(child)
element.appendChild(childElement)
}
return element
}
}
function setAttr(ele, attr, value) {
switch (attr) {
case 'style':
ele.style.cssText = value
break;
case 'value':
let tageName = ele.tagName.toLowerCase()
if (tagName == 'input' || tagName == 'textarea') {
ele.value = value
} else {
ele.setAttribute(attr, value)
}
break;
default:
ele.setAttribute(attr, value)
break;
}
}
function createElement(tagName, props, child) {
return new Element(tagName, props, child)
}
module.exports = { createElement }
複製程式碼
現在這段程式碼已經可以跑起來了,執行以後的結果如下圖:
繼續看domDIff演算法
//keyIndex記錄遍歷順序
let keyIndex = 0
function diff(oldEle, newEle) {
let patches = {}
keyIndex = 0
walk(patches, 0, oldEle, newEle)
return patches
}
//分析變化
function walk(patches, index, oldEle, newEle) {
let currentPatches = []
//這裡應該有很多的判斷型別,這裡只處理了刪除的情況...
if (!newEle) {
currentPatches.push({ type: 'remove' })
}
else if (oldEle.tagName == newEle.tagName) {
//比較兒子們
walkChild(patches, currentPatches, oldEle.childs, newEle.childs)
}
//判斷當前節點是否有改變,有的話把補丁放入補丁集合中
if (currentPatches.length) {
patches[index] = currentPatches
}
}
function walkChild(patches, currentPatches, oldChilds, newChilds) {
if (oldChilds) {
for (let i = 0; i < oldChilds.length; i++) {
let oldChild = oldChilds[i]
let newChild = newChilds[i]
walk(patches, ++keyIndex, oldChild, newChild)
}
}
}
module.exports = { diff }
複製程式碼
上面這段程式碼就是domDiff演算法的超級簡化版本:
- 首先宣告一個變數記錄遍歷的順序
- 執行walk方法分析變化,如果兩個元素tagName相同,遞迴遍歷子節點
其實walk中應該有大量的邏輯,我只處理了一種情況,就是元素被刪除。其實還應該有新增、替換等各種情況,同時涉及到大量的邊界檢查。真正的domDiff演算法很複雜,它的複雜度應該是O(n3),react為了把複雜度降低到線性而做了一系列的妥協。
我這裡只是選取一種情況做了演示,有興趣的可以看看原始碼或者搜尋一些相關的文章。這篇文章畢竟叫“淺入淺出”,非常淺……
好,那我們執行這個演算法看看效果:
import { createElement } from './createElement'
import { diff } from './diff'
let oldEle = createElement('div', { class: 'father' }, [
createElement('h1', { style: 'color:red' }, ['son1']),
createElement('h2', { style: 'color:blue' }, ['son2']),
createElement('h3', { style: 'color:red' }, ['son3'])
])
let newEle = createElement('div', { class: 'father' }, [
createElement('h1', { style: 'color:red' }, ['son1']),
createElement('h2', { style: 'color:blue' }, [])
])
console.log(diff(oldEle, newEle))
複製程式碼
我在入口檔案中新建立了一個元素,用來代表被更改之後的虛擬DOM,它有兩個元素被刪除了,一個h3
、一個文字節點son2
,理論上應該有兩條記錄,執行程式碼我們看下:
我們看到,輸出的patches
物件裡有兩個屬性,屬性名是這個元素的遍歷序號、屬性值是記錄的資訊,我們就是通過序號去遍歷找到老的DOM
節點,通過屬性值裡的資訊來做相應的更新。
更新檢視
下面我們看如何通過得到的patches
物件更新檢視:
let index = 0;
let allPatches;
function patch(root, patches) {
allPatches = patches
walk(root)
}
function walk(root) {
let currentPatches = allPatches[index]
index++
(root.childNodes || []) && root.childNodes.forEach(child => {
walk(child)
})
if (currentPatches) {
doPatch(root, currentPatches)
}
}
function doPatch(ele, currentPatches) {
currentPatches.forEach(currentPatch => {
if (currentPatch.type == 'remove') {
ele.parentNode.removeChild(ele)
}
})
}
module.exports = { patch }
複製程式碼
檔案匯出的patch
方法有兩個引數,root
是真實的DOM
節點,patches
是補丁物件,我們用和遍歷虛擬DOM
同樣的手段(先序深度優先)去遍歷真實的節點,這很重要,因為我們是通過patches
物件的key
屬性記錄哪個節點發生了變化,相同的遍歷手段可以保證我們的對應關係是正確的。
doPatch
方法很簡單,判斷如果type
是“remove”,直接刪掉該DOM
節點。其實這個方法也不應該這麼簡單,它也應該處理很多事情,比如說刪除、互換等,其實還應該判斷屬性的變化並做相應的處理。
淺入淺出嘛,所以這些都沒處理,我當然不會說我根本寫不出來……
現在我們應用一下這個patch
方法:
import { createElement } from './createElement'
import { diff } from './diff'
import { patch } from './patch'
let oldEle = createElement('div', { class: 'father' }, [
createElement('h1', { style: 'color:red' }, ['son1']),
createElement('h2', { style: 'color:blue' }, ['son2']),
createElement('h3', { style: 'color:green' }, ['son3'])
])
let newEle = createElement('div', { class: 'father' }, [
createElement('h1', { style: 'color:red' }, ['son1']),
createElement('h2', { style: 'color:green' }, [])
])
//這裡應用了patch方法,給原始的root節點打了補丁,更新成了新的節點
let root = oldEle.render()
let patches = diff(oldEle, newEle)
patch(root, patches)
document.body.appendChild(root)
複製程式碼
好,我們執行程式碼,看一下檢視的變化:
我們看到,h3標籤不見了,h2標籤還在,但是裡面的文字節點son2不見了,這跟我們的預期是一樣的。
到這裡,這個演算法就已經寫完了,上面貼出來的程式碼都是按模組貼出來的,並且是完整可以執行的。
未處理的問題
這個演算法還有很多沒有處理的問題,例如:
- 沒有處理屬性變化
- 只處理了刪除的情況,新增和替換都沒有處理
- 如果你刪除了第一個元素,那麼因為索引錯位,後面的元素都會被認為是不同的而被替換掉,react中使用了key屬性解決了這個問題,同時為了效能也做了妥協。
- 當然還有很多很多優化
最後
上面的程式碼只是把react中的核心思路簡單實現了一下,只是供大家瞭解一下domDiff演算法的思路,如我我的描述讓你對domDiff產生了一點興趣或者對你有一點幫助,我很高興。