咱們常說的虛擬DOM是經過JS對象模擬出來的DOM
節點,domDiff是經過特定算法計算出來一次操做所帶來的DOM
變化。
react和vue中都使用了虛擬DOM,vue我只停留在使用層面就很少說了,react瞭解多一些,就藉着react聊聊虛擬DOM。
react中涉及到虛擬DOM的代碼主要分爲如下三部分,其中核心是第二步的domDiff算法:css
幹前端的都知道DOM
操做是性能殺手,由於操做DOM
會引發頁面的迴流或者重繪。相比起來,經過多一些預先計算來減小DOM
的操做要划算的多。
可是,「使用虛擬DOM會更快」這句話並不必定適用於全部場景。例如:一個頁面就有一個按鈕,點擊一下,數字加一,那確定是直接操做DOM
更快。使用虛擬DOM無非白白增長了計算量和代碼量。即便是複雜狀況,瀏覽器也會對咱們的DOM
操做進行優化,大部分瀏覽器會根據咱們操做的時間和次數進行批量處理,因此直接操做DOM
也未必很慢。
那麼爲何如今的框架都使用虛擬DOM呢?由於使用虛擬DOM能夠提升代碼的性能下限,並極大的優化大量操做DOM時產生的性能損耗。同時這些框架也保證了,即便在少數虛擬DOM不太給力的場景下,性能也在咱們接受的範圍內。
並且,咱們之因此喜歡react、vue等使用了虛擬DOM框架,不光是由於他們快,還有不少其餘更重要的緣由。例如react對函數式編程的友好,vue優秀的開發體驗等,目前社區也有好多比較這兩個框架並打口水戰的,我覺着仍是在兩個都懂的狀況下多探究一下原理更有意義一些。前端
實現domDiff分爲如下四步:vue
設計師的老本行不能忘,看我畫張圖:react
解釋一下這張圖:
首先看第一個紅色色塊,這裏說的是把真實DOM
映射爲虛擬DOM
,其實在react中沒有這個過程,咱們直接寫的就是虛擬DOM(JSX),只是這個虛擬DOM
表明着真實DOM
。
當虛擬DOM變化時,例如上圖,它的第三個p
和第二個p
中的son2
被刪除了。這個時候咱們會根據先後的變化計算出一個差別對象patches
。
這個差別對象的key值就是老的DOM
節點遍歷時的索引,用這個索引咱們能夠找到那個節點。屬性值是記錄的變化,這裏是remove
,表明刪除。
最後,根據patches
中每一項的索引去對應的位置修改老的DOM
節點。es6
下面這段代碼是入口文件,咱們模擬了一個虛擬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 } 複製代碼
如今這段代碼已經能夠跑起來了,執行之後的結果以下圖:瀏覽器
//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算法的超級簡化版本:bash
其實walk中應該有大量的邏輯,我只處理了一種狀況,就是元素被刪除。其實還應該有添加、替換等各類狀況,同時涉及到大量的邊界檢查。真正的domDiff算法很複雜,它的複雜度應該是O(n3),react爲了把複雜度下降到線性而作了一系列的妥協。
我這裏只是選取一種狀況作了演示,有興趣的能夠看看源碼或者搜索一些相關的文章。這篇文章畢竟叫「淺入淺出」,很是淺……babel
好,那咱們執行這個算法看看效果:
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中的核心思路簡單實現了一下,只是供你們瞭解一下domDiff算法的思路,如我個人描述讓你對domDiff產生了一點興趣或者對你有一點幫助,我很高興。