摘要:本文所實現(xiàn)的完整代碼存放在。這就是所謂的算法。兩個樹的完全的算法是一個時間復(fù)雜度為的問題。如果有差異的話就記錄到一個對象里面。如和的不同,會被所替代。這牽涉到兩個列表的對比算法,需要另外起一個小節(jié)來討論。
作者:戴嘉華
轉(zhuǎn)載請注明出處并保留原文鏈接( https://github.com/livoras/blog/issues/13 )和作者信息。
目錄:1 前言
2 對前端應(yīng)用狀態(tài)管理思考
3 Virtual DOM 算法
4 算法實現(xiàn)
4.1 步驟一:用JS對象模擬DOM樹
4.2 步驟二:比較兩棵虛擬DOM樹的差異
4.3 步驟三:把差異應(yīng)用到真正的DOM樹上
5 結(jié)語
6 References
1 前言本文會在教你怎么用 300~400 行代碼實現(xiàn)一個基本的 Virtual DOM 算法,并且嘗試盡量把 Virtual DOM 的算法思路闡述清楚。希望在閱讀本文后,能讓你深入理解 Virtual DOM 算法,給你現(xiàn)有前端的編程提供一些新的思考。
本文所實現(xiàn)的完整代碼存放在 Github。
2 對前端應(yīng)用狀態(tài)管理的思考假如現(xiàn)在你需要寫一個像下面一樣的表格的應(yīng)用程序,這個表格可以根據(jù)不同的字段進行升序或者降序的展示。
這個應(yīng)用程序看起來很簡單,你可以想出好幾種不同的方式來寫。最容易想到的可能是,在你的 JavaScript 代碼里面存儲這樣的數(shù)據(jù):
var sortKey = "new" // 排序的字段,新增(new)、取消(cancel)、凈關(guān)注(gain)、累積(cumulate)人數(shù) var sortType = 1 // 升序還是逆序 var data = [{...}, {...}, {..}, ..] // 表格數(shù)據(jù)
用三個字段分別存儲當前排序的字段、排序方向、還有表格數(shù)據(jù);然后給表格頭部加點擊事件:當用戶點擊特定的字段的時候,根據(jù)上面幾個字段存儲的內(nèi)容來對內(nèi)容進行排序,然后用 JS 或者 jQuery 操作 DOM,更新頁面的排序狀態(tài)(表頭的那幾個箭頭表示當前排序狀態(tài),也需要更新)和表格內(nèi)容。
這樣做會導(dǎo)致的后果就是,隨著應(yīng)用程序越來越復(fù)雜,需要在JS里面維護的字段也越來越多,需要監(jiān)聽事件和在事件回調(diào)用更新頁面的DOM操作也越來越多,應(yīng)用程序會變得非常難維護。后來人們使用了 MVC、MVP 的架構(gòu)模式,希望能從代碼組織方式來降低維護這種復(fù)雜應(yīng)用程序的難度。但是 MVC 架構(gòu)沒辦法減少你所維護的狀態(tài),也沒有降低狀態(tài)更新你需要對頁面的更新操作(前端來說就是DOM操作),你需要操作的DOM還是需要操作,只是換了個地方。
既然狀態(tài)改變了要操作相應(yīng)的DOM元素,為什么不做一個東西可以讓視圖和狀態(tài)進行綁定,狀態(tài)變更了視圖自動變更,就不用手動更新頁面了。這就是后來人們想出了 MVVM 模式,只要在模版中聲明視圖組件是和什么狀態(tài)進行綁定的,雙向綁定引擎就會在狀態(tài)更新的時候自動更新視圖(關(guān)于MV*模式的內(nèi)容,可以看這篇介紹)。
MVVM 可以很好的降低我們維護狀態(tài) -> 視圖的復(fù)雜程度(大大減少代碼中的視圖更新邏輯)。但是這不是唯一的辦法,還有一個非常直觀的方法,可以大大降低視圖更新的操作:一旦狀態(tài)發(fā)生了變化,就用模版引擎重新渲染整個視圖,然后用新的視圖更換掉舊的視圖。就像上面的表格,當用戶點擊的時候,還是在JS里面更新狀態(tài),但是頁面更新就不用手動操作 DOM 了,直接把整個表格用模版引擎重新渲染一遍,然后設(shè)置一下innerHTML就完事了。
聽到這樣的做法,經(jīng)驗豐富的你一定第一時間意識這樣的做法會導(dǎo)致很多的問題。最大的問題就是這樣做會很慢,因為即使一個小小的狀態(tài)變更都要重新構(gòu)造整棵 DOM,性價比太低;而且這樣做的話,input和textarea的會失去原有的焦點。最后的結(jié)論會是:對于局部的小視圖的更新,沒有問題(Backbone就是這么干的);但是對于大型視圖,如全局應(yīng)用狀態(tài)變更的時候,需要更新頁面較多局部視圖的時候,這樣的做法不可取。
但是這里要明白和記住這種做法,因為后面你會發(fā)現(xiàn),其實 Virtual DOM 就是這么做的,只是加了一些特別的步驟來避免了整棵 DOM 樹變更。
另外一點需要注意的就是,上面提供的幾種方法,其實都在解決同一個問題:維護狀態(tài),更新視圖。在一般的應(yīng)用當中,如果能夠很好方案來應(yīng)對這個問題,那么就幾乎降低了大部分復(fù)雜性。
3 Virtual DOM算法DOM是很慢的。如果我們把一個簡單的div元素的屬性都打印出來,你會看到:
而這僅僅是第一層。真正的 DOM 元素非常龐大,這是因為標準就是這么設(shè)計的。而且操作它們的時候你要小心翼翼,輕微的觸碰可能就會導(dǎo)致頁面重排,這可是殺死性能的罪魁禍首。
相對于 DOM 對象,原生的 JavaScript 對象處理起來更快,而且更簡單。DOM 樹上的結(jié)構(gòu)、屬性信息我們都可以很容易地用 JavaScript 對象表示出來:
var element = { tagName: "ul", // 節(jié)點標簽名 props: { // DOM的屬性,用一個對象存儲鍵值對 id: "list" }, children: [ // 該節(jié)點的子節(jié)點 {tagName: "li", props: {class: "item"}, children: ["Item 1"]}, {tagName: "li", props: {class: "item"}, children: ["Item 2"]}, {tagName: "li", props: {class: "item"}, children: ["Item 3"]}, ] }
上面對應(yīng)的HTML寫法是:
既然原來 DOM 樹的信息都可以用 JavaScript 對象來表示,反過來,你就可以根據(jù)這個用 JavaScript 對象表示的樹結(jié)構(gòu)來構(gòu)建一棵真正的DOM樹。
之前的章節(jié)所說的,狀態(tài)變更->重新渲染整個視圖的方式可以稍微修改一下:用 JavaScript 對象表示 DOM 信息和結(jié)構(gòu),當狀態(tài)變更的時候,重新渲染這個 JavaScript 的對象結(jié)構(gòu)。當然這樣做其實沒什么卵用,因為真正的頁面其實沒有改變。
但是可以用新渲染的對象樹去和舊的樹進行對比,記錄這兩棵樹差異。記錄下來的不同就是我們需要對頁面真正的 DOM 操作,然后把它們應(yīng)用在真正的 DOM 樹上,頁面就變更了。這樣就可以做到:視圖的結(jié)構(gòu)確實是整個全新渲染了,但是最后操作DOM的時候確實只變更有不同的地方。
這就是所謂的 Virtual DOM 算法。包括幾個步驟:
用 JavaScript 對象結(jié)構(gòu)表示 DOM 樹的結(jié)構(gòu);然后用這個樹構(gòu)建一個真正的 DOM 樹,插到文檔當中
當狀態(tài)變更的時候,重新構(gòu)造一棵新的對象樹。然后用新的樹和舊的樹進行比較,記錄兩棵樹差異
把2所記錄的差異應(yīng)用到步驟1所構(gòu)建的真正的DOM樹上,視圖就更新了
Virtual DOM 本質(zhì)上就是在 JS 和 DOM 之間做了一個緩存。可以類比 CPU 和硬盤,既然硬盤這么慢,我們就在它們之間加個緩存:既然 DOM 這么慢,我們就在它們 JS 和 DOM 之間加個緩存。CPU(JS)只操作內(nèi)存(Virtual DOM),最后的時候再把變更寫入硬盤(DOM)。
4 算法實現(xiàn) 4.1 步驟一:用JS對象模擬DOM樹用 JavaScript 來表示一個 DOM 節(jié)點是很簡單的事情,你只需要記錄它的節(jié)點類型、屬性,還有子節(jié)點:
element.js
function Element (tagName, props, children) { this.tagName = tagName this.props = props this.children = children } module.exports = function (tagName, props, children) { return new Element(tagName, props, children) }
例如上面的 DOM 結(jié)構(gòu)就可以簡單的表示:
var el = require("./element") var ul = el("ul", {id: "list"}, [ el("li", {class: "item"}, ["Item 1"]), el("li", {class: "item"}, ["Item 2"]), el("li", {class: "item"}, ["Item 3"]) ])
現(xiàn)在ul只是一個 JavaScript 對象表示的 DOM 結(jié)構(gòu),頁面上并沒有這個結(jié)構(gòu)。我們可以根據(jù)這個ul構(gòu)建真正的:
Element.prototype.render = function () { var el = document.createElement(this.tagName) // 根據(jù)tagName構(gòu)建 var props = this.props for (var propName in props) { // 設(shè)置節(jié)點的DOM屬性 var propValue = props[propName] el.setAttribute(propName, propValue) } var children = this.children || [] children.forEach(function (child) { var childEl = (child instanceof Element) ? child.render() // 如果子節(jié)點也是虛擬DOM,遞歸構(gòu)建DOM節(jié)點 : document.createTextNode(child) // 如果字符串,只構(gòu)建文本節(jié)點 el.appendChild(childEl) }) return el }
render方法會根據(jù)tagName構(gòu)建一個真正的DOM節(jié)點,然后設(shè)置這個節(jié)點的屬性,最后遞歸地把自己的子節(jié)點也構(gòu)建起來。所以只需要:
var ulRoot = ul.render() document.body.appendChild(ulRoot)
上面的ulRoot是真正的DOM節(jié)點,把它塞入文檔中,這樣body里面就有了真正的的DOM結(jié)構(gòu):
完整代碼可見 element.js。
4.2 步驟二:比較兩棵虛擬DOM樹的差異正如你所預(yù)料的,比較兩棵DOM樹的差異是 Virtual DOM 算法最核心的部分,這也是所謂的 Virtual DOM 的 diff 算法。兩個樹的完全的 diff 算法是一個時間復(fù)雜度為 O(n^3) 的問題。但是在前端當中,你很少會跨越層級地移動DOM元素。所以 Virtual DOM 只會對同一個層級的元素進行對比:
上面的div只會和同一層級的div對比,第二層級的只會跟第二層級對比。這樣算法復(fù)雜度就可以達到 O(n)。
4.2.1 深度優(yōu)先遍歷,記錄差異在實際的代碼中,會對新舊兩棵樹進行一個深度優(yōu)先的遍歷,這樣每個節(jié)點都會有一個唯一的標記:
在深度優(yōu)先遍歷的時候,每遍歷到一個節(jié)點就把該節(jié)點和新的的樹進行對比。如果有差異的話就記錄到一個對象里面。
// diff 函數(shù),對比兩棵樹 function diff (oldTree, newTree) { var index = 0 // 當前節(jié)點的標志 var patches = {} // 用來記錄每個節(jié)點差異的對象 dfsWalk(oldTree, newTree, index, patches) return patches } // 對兩棵樹進行深度優(yōu)先遍歷 function dfsWalk (oldNode, newNode, index, patches) { // 對比oldNode和newNode的不同,記錄下來 patches[index] = [...] diffChildren(oldNode.children, newNode.children, index, patches) } // 遍歷子節(jié)點 function diffChildren (oldChildren, newChildren, index, patches) { var leftNode = null var currentNodeIndex = index oldChildren.forEach(function (child, i) { var newChild = newChildren[i] currentNodeIndex = (leftNode && leftNode.count) // 計算節(jié)點的標識 ? currentNodeIndex + leftNode.count + 1 : currentNodeIndex + 1 dfsWalk(child, newChild, currentNodeIndex, patches) // 深度遍歷子節(jié)點 leftNode = child }) }
例如,上面的div和新的div有差異,當前的標記是0,那么:
patches[0] = [{difference}, {difference}, ...] // 用數(shù)組存儲新舊節(jié)點的不同
同理p是patches[1],ul是patches[3],類推。
4.2.2 差異類型上面說的節(jié)點的差異指的是什么呢?對 DOM 操作可能會:
替換掉原來的節(jié)點,例如把上面的div換成了section
移動、刪除、新增子節(jié)點,例如上面div的子節(jié)點,把p和ul順序互換
修改了節(jié)點的屬性
對于文本節(jié)點,文本內(nèi)容可能會改變。例如修改上面的文本節(jié)點2內(nèi)容為Virtual DOM 2。
所以我們定義了幾種差異類型:
var REPLACE = 0 var REORDER = 1 var PROPS = 2 var TEXT = 3
對于節(jié)點替換,很簡單。判斷新舊節(jié)點的tagName和是不是一樣的,如果不一樣的說明需要替換掉。如div換成section,就記錄下:
patches[0] = [{ type: REPALCE, node: newNode // el("section", props, children) }]
如果給div新增了屬性id為container,就記錄下:
patches[0] = [{ type: REPALCE, node: newNode // el("section", props, children) }, { type: PROPS, props: { id: "container" } }]
如果是文本節(jié)點,如上面的文本節(jié)點2,就記錄下:
patches[2] = [{ type: TEXT, content: "Virtual DOM2" }]
那如果把我div的子節(jié)點重新排序呢?例如p, ul, div的順序換成了div, p, ul。這個該怎么對比?如果按照同層級進行順序?qū)Ρ鹊脑挘鼈兌紩惶鎿Q掉。如p和div的tagName不同,p會被div所替代。最終,三個節(jié)點都會被替換,這樣DOM開銷就非常大。而實際上是不需要替換節(jié)點,而只需要經(jīng)過節(jié)點移動就可以達到,我們只需知道怎么進行移動。
這牽涉到兩個列表的對比算法,需要另外起一個小節(jié)來討論。
4.2.3 列表對比算法假設(shè)現(xiàn)在可以英文字母唯一地標識每一個子節(jié)點:
舊的節(jié)點順序:
a b c d e f g h i
現(xiàn)在對節(jié)點進行了刪除、插入、移動的操作。新增j節(jié)點,刪除e節(jié)點,移動h節(jié)點:
新的節(jié)點順序:
a b c h d f g h i j
現(xiàn)在知道了新舊的順序,求最小的插入、刪除操作(移動可以看成是刪除和插入操作的結(jié)合)。這個問題抽象出來其實是字符串的最小編輯距離問題(Edition Distance),最常見的解決算法是 Levenshtein Distance,通過動態(tài)規(guī)劃求解,時間復(fù)雜度為 O(M * N)。但是我們并不需要真的達到最小的操作,我們只需要優(yōu)化一些比較常見的移動情況,犧牲一定DOM操作,讓算法時間復(fù)雜度達到線性的(O(max(M, N))。具體算法細節(jié)比較多,這里不累述,有興趣可以參考代碼。
我們能夠獲取到某個父節(jié)點的子節(jié)點的操作,就可以記錄下來:
patches[0] = [{ type: REORDER, moves: [{remove or insert}, {remove or insert}, ...] }]
但是要注意的是,因為tagName是可重復(fù)的,不能用這個來進行對比。所以需要給子節(jié)點加上唯一標識key,列表對比的時候,使用key進行對比,這樣才能復(fù)用老的 DOM 樹上的節(jié)點。
這樣,我們就可以通過深度優(yōu)先遍歷兩棵樹,每層的節(jié)點進行對比,記錄下每個節(jié)點的差異了。完整 diff 算法代碼可見 diff.js。
4.3 步驟三:把差異應(yīng)用到真正的DOM樹上因為步驟一所構(gòu)建的 JavaScript 對象樹和render出來真正的DOM樹的信息、結(jié)構(gòu)是一樣的。所以我們可以對那棵DOM樹也進行深度優(yōu)先的遍歷,遍歷的時候從步驟二生成的patches對象中找出當前遍歷的節(jié)點差異,然后進行 DOM 操作。
function patch (node, patches) { var walker = {index: 0} dfsWalk(node, walker, patches) } function dfsWalk (node, walker, patches) { var currentPatches = patches[walker.index] // 從patches拿出當前節(jié)點的差異 var len = node.childNodes ? node.childNodes.length : 0 for (var i = 0; i < len; i++) { // 深度遍歷子節(jié)點 var child = node.childNodes[i] walker.index++ dfsWalk(child, walker, patches) } if (currentPatches) { applyPatches(node, currentPatches) // 對當前節(jié)點進行DOM操作 } }
applyPatches,根據(jù)不同類型的差異對當前節(jié)點進行 DOM 操作:
function applyPatches (node, currentPatches) { currentPatches.forEach(function (currentPatch) { switch (currentPatch.type) { case REPLACE: node.parentNode.replaceChild(currentPatch.node.render(), node) break case REORDER: reorderChildren(node, currentPatch.moves) break case PROPS: setProps(node, currentPatch.props) break case TEXT: node.textContent = currentPatch.content break default: throw new Error("Unknown patch type " + currentPatch.type) } }) }
完整代碼可見 patch.js。
5 結(jié)語Virtual DOM 算法主要是實現(xiàn)上面步驟的三個函數(shù):element,diff,patch。然后就可以實際的進行使用:
// 1. 構(gòu)建虛擬DOM var tree = el("div", {"id": "container"}, [ el("h1", {style: "color: blue"}, ["simple virtal dom"]), el("p", ["Hello, virtual-dom"]), el("ul", [el("li")]) ]) // 2. 通過虛擬DOM構(gòu)建真正的DOM var root = tree.render() document.body.appendChild(root) // 3. 生成新的虛擬DOM var newTree = el("div", {"id": "container"}, [ el("h1", {style: "color: red"}, ["simple virtal dom"]), el("p", ["Hello, virtual-dom"]), el("ul", [el("li"), el("li")]) ]) // 4. 比較兩棵虛擬DOM樹的不同 var patches = diff(tree, newTree) // 5. 在真正的DOM元素上應(yīng)用變更 patch(root, patches)
當然這是非常粗糙的實踐,實際中還需要處理事件監(jiān)聽等;生成虛擬 DOM 的時候也可以加入 JSX 語法。這些事情都做了的話,就可以構(gòu)造一個簡單的ReactJS了。
本文所實現(xiàn)的完整代碼存放在 Github,僅供學(xué)習(xí)。
6 Referenceshttps://github.com/Matt-Esch/virtual-dom/blob/master/vtree/diff.js
文章版權(quán)歸作者所有,未經(jīng)允許請勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉(zhuǎn)載請注明本文地址:http://m.specialneedsforspecialkids.com/yun/86258.html
摘要:二原理每個都有兩個,一個是新的,一個是原來的。三實現(xiàn)過程四算法的理解與實現(xiàn)本質(zhì)上就是在和之間做了一個緩存。將差異的應(yīng)用到真正的樹上對真實上的樹進行深度優(yōu)先遍歷,在所有的差異列表中找出當前遍歷的節(jié)點差異,然后根據(jù)不同進行操作。 React Virtual DOM 一、概念 在react中,對于每個DOM對象都有一個相應(yīng)的虛擬DOM對象,相當于DOM對象的輕量級副本 由于是Virtual...
摘要:所以對于組件更新階段的組件生命周期,我們簡單提及并且提供一些資料給大家。這里為了知識的完整,補充關(guān)于更新階段的組件生命周期你可以通過這個方法控制組件是否重新渲染。大家對這更新階段的生命周期比較感興趣的話可以查看官網(wǎng)文檔。 React.js 小書 Lesson20 - 更新階段的組件生命周期 本文作者:胡子大哈本文原文:http://huziketang.com/books/react...
摘要:所以只針對同層級節(jié)點做比較,將復(fù)雜度的問題轉(zhuǎn)換成復(fù)雜度的問題。 React系列 React系列 --- 簡單模擬語法(一)React系列 --- Jsx, 合成事件與Refs(二)React系列 --- virtualdom diff算法實現(xiàn)分析(三)React系列 --- 從Mixin到HOC再到HOOKS(四)React系列 --- createElement, ReactElem...
摘要:目前,前端領(lǐng)域中勢頭正盛,使用者眾多卻少有能夠深入剖析內(nèi)部實現(xiàn)機制和原理。當發(fā)現(xiàn)節(jié)點已經(jīng)不存在,則該節(jié)點及其子節(jié)點會被完全刪除掉,不會用于進一步的比較。 目前,前端領(lǐng)域中 React 勢頭正盛,使用者眾多卻少有能夠深入剖析內(nèi)部實現(xiàn)機制和原理。本系列文章希望通過剖析 React 源碼,理解其內(nèi)部的實現(xiàn)原理,知其然更要知其所以然。 React diff 作為 Virtual DOM 的加速...
摘要:前端日報精選免費的計算機編程類中文書籍英文技術(shù)文檔看不懂看印記中文就夠了的內(nèi)部工作原理美團點評點餐前后端分離實踐讓你的動畫坐上時光機中文譯有多棒簡書譯別再使用圖片輪播了掘金譯如何在中使用掘金個讓增長成億美元公司的獨特方法眾成翻 2017-08-23 前端日報 精選 FPB 2.0:免費的計算機編程類中文書籍 2.0英文技術(shù)文檔看不懂?看印記中文就夠了!Virtual DOM 的內(nèi)部工作...
閱讀 917·2023-04-25 18:51
閱讀 1870·2021-09-09 11:39
閱讀 3283·2019-08-30 15:53
閱讀 2098·2019-08-30 13:03
閱讀 1310·2019-08-29 16:17
閱讀 582·2019-08-29 11:33
閱讀 1884·2019-08-26 14:00
閱讀 2124·2019-08-26 13:41