虛擬DOM和比對算法講解
本篇文章是在近期的學習中整理出來的,內容是有關 Vue2.0
中 虛擬DOM 和比對算法的解釋。本篇依舊秉承着盡力通俗易懂的解釋。如若哪部分沒有解釋清楚,或者說寫的有錯誤的地方,還請各位 批評指正html
近期我還在整理 我的的Vue
的所學。從0開始再一次手寫Vue
。本篇內容將會在那篇文章中進行使用。前端
理論知識
爲何須要虛擬DOM
DOM
是很大的,裏面元素不少。操做起來比較浪費時間,浪費性能。因此咱們須要引入虛擬dom
的概念node
什麼是虛擬DOM
簡單來講,虛擬DOM
其實就是用js
中的對象來模擬真實DOM
,再經過方法的轉換,將它變成真實DOM
webpack
優勢
-
最終表如今 真實DOM
上 部分改變,保證了渲染的效率 -
性能提高 (對比操做真實DOM)
正式開始
思路
-
咱們須要獲取一個節點來掛載咱們的渲染結果 -
咱們須要把對象( 虛擬節點
),渲染成真實節點。插入到 獲取的節點中(固然這個中間會有不少繁瑣的過程。後面會一點點的說) -
在更新的過程當中,咱們須要比對 dom
元素的各個屬性,能複用複用。複用不了就更新
webpack
配置
// webpack.config.js
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
entry: './src/vdomLearn/index.js', // 入口文件
output: { // 輸出文件
filename: 'bundle.js',
path: path.resolve(__dirname,'dist'),
},
devtool: 'source-map', // 源碼映射
plugins: [ // 插件
new HtmlWebpackPlugin({
template: path.resolve(__dirname,'public/index.html'),
})
],
}
// package.json
"scripts": {
"start": "webpack-dev-server",
"build": "webpack"
},
獲取節點並初次渲染
首先先看一下咱們的 模板Html
,沒什麼重要內容,就是有一個id='#app'
的div(做爲掛載的節點)web
<!doctype html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Vue</title>
</head>
<body>
<div id="#app">
</div>
</body>
</html>
咱們建立一個名爲index.js
的文件,用來做爲 入口文件算法
// 獲取掛載節點
let app = document.getElementById('#app')
// 建立虛擬節點 咱們先用死數據模擬
let oldNode = h('div',{id:'container'},
h('span',{style:{color:'red'}},'hello')
'hello'
)
// 渲染函數 將 咱們建立的虛擬節點 掛載到對應節點上
render(oldNode,app)
爲何這麼叫名字呢? h是遵循Vue
裏面的叫法。剩下的基本都是英語翻譯json
目標明確
通過上面的index.js
文件,咱們明確了目標。數組
-
咱們須要一個 h
方法來把虛擬DOM
變成真實DOM -
還須要一個 render
方法,將咱們所建立的節點掛載到app
上
接下來咱們開始寫這兩個方法微信
爲了方便管理。咱們新建一個文件名爲vdom
的文件夾。裏面有一個index.js
文件,做爲 總導出app
// vdom/index.js
import h from './h'
import {render} from './patch';
export {
h,render
}
h方法
爲了方便管理,咱們建立一個名爲vNode.js
的文件。用來放與虛擬節點相關的內容
// vdom/vNode.js
// 主要放虛擬節點相關的
/**
* 建立虛擬dom
* @param tag 標籤
* @param props 屬性
* @param key only one 標識
* @param children 子節點
* @param text 文本內容
// 返回一個虛擬節點
* @returns {{children: *, tag: *, text: *, key: *, props: *}}
*/
export function vNode(tag,props,key,children,text='') {
return {
tag,
props,
key,
children,
text
}
}
// vdom/h.js
import {vNode} from './vNode';
// 主要放渲染相關的
/**
* h方法就是 CreateElement
* @param tag 標籤
* @param props 屬性
* @param children 孩子節點和文本
* @returns {{children: *, tag: *, text: *, key: *, props: *}} 返回一個虛擬dom
*/
export default function h(tag,props,...children){
// ... 是ES6語法
let key = props.key // 標識
delete props.key // 屬性中沒有key 屬性
// 遍歷子節點 若是子節點是對象,則證實他是一個節點。若是不是 則證實是一個文本
children = children.map(child=>{
if (typeof child === 'object'){
console.log(child)
return child
}else {
return vNode(undefined,undefined,undefined,undefined,child)
}
})
// key 做用 only one 標識 能夠對比兩個虛擬節點是不是同一個
// 返回一個虛擬節點
return vNode(tag,props,key,children)
}
render方法
render
方法的做用就是把 虛擬節點轉換成真實節點,並掛載到 app
節點上,咱們把它放到一個叫patch.js
的文件中
// vdom/patch.js
/**
* 渲染成 真實節點 並掛載
* @param vNode 虛擬DOM
* @param container 容器 即 須要向哪裏添加節點
*/
export function render(vNode, container) { // 把虛擬節點變成真實節點
let el = createElem(vNode)
container.appendChild(el) // 把 建立好的真實節點加入到 app 中
}
把 虛擬節點傳入後,咱們要根據虛擬節點來建立真實節點。因此咱們寫一個名爲createElem
的方法,用來 把虛擬節點變成真實節點
createElem
方法
// vdom/patch.js
// ...前面含有上述的render方法 我省略一下
/**
* 根據虛擬節點建立真實節點
* @param vNode 虛擬DOM
* @returns {any | Text} 返回真實節點
*/
function createElem(vNode) {
let { tag, props, children, text, key } = vNode
if (typeof tag === 'string') { // 即 div span 等
vNode.el = document.createElement(tag) // 建立節點 將建立出來的真實節點掛載到虛擬節點上
updateProperties(vNode) // 更新屬性方法
// 看是否有孩子 若是有孩子,則把這個孩子繼續渲染
children.forEach(child => {
return render(child, vNode.el)
})
} else { // 不存在 undefined Vnode.el 對應的是虛擬節點裏面的真實dom元素
vNode.el = document.createTextNode(text)
}
return vNode.el
}
難點解釋
我的以爲難以理解的一個部分應該是這個for
遍歷,children
是一個個虛擬子節點(用h方法建立的)。若是它有tag
屬性,則證實它是一個節點。裏面可能包含有其餘節點。因此咱們要遍歷children
。拿到每個虛擬子節點,繼續渲染,把 全部虛擬子節點上都掛載上真實的dom
。若是是文本,直接建立文本節點
就能夠了。而後把真實dom
返回。
updateProperties
方法
建立真實節點的過程當中,咱們爲了之後考慮。寫一個名爲updateProperties
用來更新或者初始化dom的屬性(props)
// vdom/patch.js
// ...前面含有上述的render,和createElem方法 我省略一下
/**
* 更新或者初始化DOM props
* @param vNode
* @param oldProps
*/
function updateProperties(vNode, oldProps = {}) {
let newProps = vNode.props || {}// 當前的老屬性 也可能沒有屬性 以防程序出錯,給了一個空對象
let el = vNode.el // 真實節點 取到咱們剛纔再虛擬節點上掛載的真實dom
let oldStyle = oldProps.style || {}
let newStyle = newProps.style || {}
// 處理 老樣式中 要更新的樣式 若是新樣式中不存在老樣式 就置爲空
for (let key in oldStyle) {
if (!newStyle[key]) {
el.style[key] = ''
}
}
// 刪除 更新過程當中 新屬性中不存在的 屬性
for (let key in oldProps) {
if (!newProps[key]) {
delete el[key]
}
}
// 考慮一下之前有沒有
for (let key in newProps) {
if (key === 'style') {
for (let styleName in newProps.style) {
// color red
el.style[styleName] = newProps.style[styleName]
}
} else if (key === 'class') { // 處理class屬性
el.className = newProps.class
} else {
el[key] = newProps[key] // key 是id 等屬性
}
}
}
**思路:**其實很簡單
-
把 老屬性中的樣式不存在於 新屬性的樣式置爲空 -
刪除 老屬性中不存在於 新屬性 的 屬性 -
新屬性 中 老屬性 沒有的,把它 添加/更新 上
總結和再串一次流程
這樣一來。咱們就完成了 從 虛擬dom
的建立再到 渲染
的過程
咱們再回顧一遍流程
-
先經過 h
方法,把傳入的各個屬性進行組合,變成虛擬dom
-
再經過 render
方法,把傳入的虛擬dom
進行渲染和掛載 -
在渲染的過程當中,咱們用了 createElem
方法,建立了真實節點, 並掛載到了虛擬節點的el屬性上,並返回真實節點 -
在執行 createElem
方法的過程當中,咱們還須要對 節點的屬性進行修改和更新。因此咱們建立了updateProperties
,用來更新節點屬性 -
方法都執行完成後,回到了 h
方法,把咱們建立好的真實節點掛載到了app
上
以上就是從獲取節點,再到 初次渲染的整個過程
結果展現
Dom的更新和比對算法
上述咱們敘述了 如何把虛擬dom
轉換成真實dom
的過程。接下來咱們 說一下 關於dom
的更新
先看 index
文件
import {h,render,patch} from './vdom'
// 獲取掛載節點
let app = document.getElementById('#app')
// 建立虛擬節點 咱們先用死數據模擬
let oldNode = h('div',{id:'container'},
h('span',{style:{color:'red'}},'hello')
'hello'
)
// 渲染函數 將 咱們建立的虛擬節點 掛載到對應節點上
render(oldNode,app)
// 咱們設置一個定時器, 用patch 方法來更新dom
// 把新的節點和老的節點作對比 更新真實dom 元素
setTimeout(()=>{
patch(oldNode,newNode)
},1000)
咱們用一個patch
方法來更新dom
在vdom/index
文件中導出這個方法
import h from './h'
import {render,patch} from './patch';
export {
h,render,patch
}
patch
文件
思路分析
咱們要作的是DOM的更新操做
,須要接收兩個參數(新老DOM),遵循着 能複用就複用的原則(複用比從新渲染效率高)。而後 更新屬性。結束後再對比 子節點。並作出響應的優化
patch dom
對比和更新
// vdom/patch.js
// ...省略上面的 了
/**
* 作dom 的對比更新操做
* @param oldNode
* @param newNode
*/
export function patch(oldNode, newNode) {
// 傳入的newNode是 一個對象 oldNode 是一個虛擬節點 上面el爲真實節點
// 1 先比對 父級標籤同樣不 不同直接幹掉 傳進來是虛擬節點
if (oldNode.tag !== newNode.tag) {
// 必須拿到父親才能夠替換兒子
// 老節點的 父級 替換 利用createElem建立真實節點 進行替換
oldNode.el.parentNode.replaceChild(createElem(newNode), oldNode.el)
}
// 對比文本 更改文本內容
if (!oldNode.tag) { // 證實其是文本節點
if (oldNode.el.textContent !== newNode.text) {
oldNode.el.textContent = newNode.text
}
}
// 標籤同樣 對比屬性
let el = newNode.el = oldNode.el // 新老標籤同樣 直接複用
updateProperties(newNode, oldNode.props) // 更新屬性
// 開始比對孩子 必需要有一個根節點
let oldNodeChildren = oldNode.children || []
let newNodeChildren = newNode.children || []
// 三種狀況 老有新有 老有新沒有 老沒有新有
if (oldNodeChildren.length > 0 && newNodeChildren.length > 0) {
// 新老都有 就更新
// el是什麼? 就是 兩個虛擬節點渲染後的真實節點
updateChildren(el, oldNodeChildren, newNodeChildren)
} else if (oldNodeChildren.length > 0) {
// 新沒有 老有
el.innerHTML = ''
} else if (newNodeChildren.length > 0) {
// 老沒有 新有
for (let i = 0; i < newNodeChildren.length; i++) {
let child = newNodeChildren[i]
el.appendChild(createElem(child)) // 將新兒子添加到 老的節點中
}
}
return el // 對比以後的返回真實節點
}
這段代碼的 較簡單都寫出來了。稍微難一點的在於 **比對孩子的過程當中,新老節點都有孩子。咱們就須要再來一個方法,用於新老孩子的更新 **
updateChildren
方法
**做用:**更新新老節點的子節點
/**
* 工具函數,用於比較這兩個節點是否相同
* @param oldVnode
* @param newVnode
* @returns {boolean|boolean}
*/
function isSameVnode(oldVnode, newVnode) {
// 當二者標籤 和 key 相同 能夠認爲是同一個虛擬節點 能夠複用
return (oldVnode.tag === newVnode.tag) && (oldVnode.key === newVnode.key)
}
// 虛擬Dom 核心代碼
/**
*
* @param parent 父節點的DOM元素
* @param oldChildren 老的虛擬dom
* @param newChildren 新得虛擬dom
*/
function updateChildren(parent, oldChildren, newChildren) {
// 怎麼對比? 一個一個對比,哪一個少了就把 多餘的拿出來 刪掉或者加倒後面
let oldStartIndex = 0 // 老節點索引
let oldStartVnode = oldChildren[0] // 老節點開始值
let oldEndIndex = oldChildren.length - 1 // 老節點 結束索引
let oldEndVnode = oldChildren[oldEndIndex] // 老節點結束值
let newStartIndex = 0 // 新節點索引
let newStartVnode = newChildren[0] // 新節點開始值
let newEndIndex = newChildren.length - 1 // 新節點 結束索引
let newEndVnode = newChildren[newEndIndex] // 新節點結束值
/**
* 把節點的key 創建起映射關係
* @param child 傳入節點
* @returns {{}} 返回映射關係
*/
function makeIndexByKey(child) {
let map = {}
child.forEach((item, index) => {
map[item.key] = index
})
return map // {a:0,b:1,c:2}
}
let map = makeIndexByKey(oldChildren)
// 開始比較
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
// 主要用來解決else 操做引發的 數組塌陷
if (!oldStartVnode) {
oldStartVnode = oldChildren[++oldStartIndex]
} else if (!oldEndVnode) {
oldEndVnode = oldChildren[--oldStartIndex]
// 上述先不用管 首先從這裏開始看
// 以上代碼在一個else中有用。跳過undefined 沒有比較意義
// 先從頭部開始比較 若是不同 再叢尾部比較
} else if (isSameVnode(oldStartVnode, newStartVnode)) { // 從頭開始遍歷 前面插入
patch(oldStartVnode, newStartVnode) // 用新屬性更新老屬性
// 移動 開始下一次比較
oldStartVnode = oldChildren[++oldStartIndex]
newStartVnode = newChildren[++newStartIndex]
} else if (isSameVnode(oldEndVnode, newEndVnode)) { // 從尾部開始遍歷 尾插法
patch(oldEndVnode, newEndVnode) // 用新屬性更新老屬性
oldEndVnode = oldChildren[--oldEndIndex]
newEndVnode = newChildren[--newEndIndex]
} else if (isSameVnode(oldStartVnode, newEndVnode)) {
// 倒序操做
// 正倒序 老的頭 新的尾部
patch(oldStartVnode, newEndVnode) // abc cba
// 這一步是關鍵 插入 把老 進行倒敘 nextSibling 某個元素以後緊跟的節點:
// parent 是一個父級的真實dom元素
parent.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling)
oldStartVnode = oldChildren[++oldStartIndex]
newEndVnode = newChildren[--newEndIndex]
} else if (isSameVnode(oldEndVnode, newStartVnode)) {
// 對比把尾部提到最前面
patch(oldEndVnode, newStartVnode)
// 要插入的元素 插入元素位置
parent.insertBefore(oldEndVnode.el, oldStartVnode.el)
oldEndVnode = oldChildren[--oldEndIndex]
newStartVnode = newChildren[++newStartIndex]
} else {
// 上述都不行了的話 則證實是亂序,先拿新節點的首項和老節點對比。若是不同,直接插在這個老節點的前面
// 若是找到了 則直接移動老節點(以防數組塌陷)
// 比對結束手可能老節點還有剩餘,指直接刪除
// 這裏用到了 map
let movedIndex = map[newStartVnode.key]
console.log(movedIndex)
if (movedIndex === undefined) { // 找不到的條件下
// Vnode.el 對應的是虛擬節點裏面的真實dom元素
parent.insertBefore(createElem(newStartVnode), oldStartVnode.el)
} else { // 找到的條件
// 移動這個元素
let moveVnode = oldChildren[movedIndex]
patch(moveVnode, newStartVnode)
oldChildren[movedIndex] = undefined
parent.insertBefore(moveVnode.el, oldStartVnode.el)
}
newStartVnode = newChildren[++newStartIndex]
}
}
// 若是比對結束後還有剩餘的新節點 直接把後面的新節點插入
if (newStartIndex <= newEndIndex) {
for (let i = newStartIndex; i <= newEndIndex; i++) {
// 獲取要插入的節點
let ele = newChildren[newEndIndex + 1] == null ? null : newChildren[newEndIndex + 1].el
// 可能前插 可能後插
parent.insertBefore(createElem(newChildren[i]), ele)
// parent.appendChild(createElem(newChildren[i]))
}
}
// 刪除排序以後多餘的老的
if (oldStartIndex<= oldEndIndex){
for (let i = oldStartIndex;i<=oldEndIndex;i++){
let child = oldChildren[i]
if (child !== undefined){
// 注意 刪除undefined 會報錯
parent.removeChild(child.el)
}
}
}
// 儘可能不要用索引來做爲key 可能會致使從新建立當前元素的全部子元素
// 他們的tag 同樣 key 同樣 須要把這兩個節點從新渲染
// 沒有重複利用的效率高
}
先說明一下isSameVnode
函數做用,當發現他們兩個 標籤同樣且key值同樣(標識),則證實他們兩個是同一個節點。
着重講述
從這個else if
開始看。也就是 判斷條件爲isSameVnode(oldStartVnode, newStartVnode)
開始。
核心就是 模擬鏈表增刪改 倒敘的操做。不過作了一部份優化
如下用這個else if
開始說到末尾的一個else if
。新節點。即要更新成的節點
-
else if
所做的事情。就是 從頭開始比對, 例如 老節點是 1 2 3 4 新節點 1 2 3 5.開始調用patch
進行更新判斷。它會先判斷是否是同一個節點。再更新文本 屬性 子節點。直到結束 把老節點內容更新成 1 2 3 5。 -
else if
所做的事情。就是 從尾部開始比對, 例如 老節點是 5 2 3 4 新節點 1 2 3 4。方法同上 -
else if
所做的事情。就是 優化了反序。例如 老節點是1 2 3 4 新節點 4 3 2 1。當不知足上述兩個條件的時候,會拿老節點的首項和新節點的末尾項相比。結束後插入到老節點的前面。利用了insertBefore
API。兩個參數, 一個,要插入的元素。**二個:**插入元素的位置 -
else if
所做的事情。就是 把末尾節點提到前面。老節點1 2 3 5. 新節點 5 1 2 3 。
以上就是四個else if
的做用。較爲容易理解。就是 模擬鏈表操做
接下來就是else
了
以上狀況都不知足的條件下,證實 新節點是亂序。這樣咱們本着 能複用就複用的原則,從頭開始比對,若是老節點中存在,就移動(注意數組塌陷)。不存在就建立。多餘的就刪除。
步驟
-
利用咱們建立好的 map
來找要比對的元素 -
若是沒有找到,就建立這個元素並插入。 -
找到了就先 patch
這個元素 移動這個元素,並把原來的位置設置爲undefined
。以防數組塌陷 -
移動 要被對比的元素 -
由於咱們設置了 undefined
,因此咱們要在開始的時候要進行判斷。 這就是咱們在前面的if else if 的緣由
while
進行完畢以後。下面兩個if
的做用就簡單的說了。由於while
的判斷條件。因此當一個節點比另外一個節點長的時候。會有一些沒有比較的,這些必定是新的或者老的多餘的。直接添加或者刪除就好了
補充,爲何不推薦用 索引作key值
舉個例子
節點A: a b c d B:b a d r
索引 0 1 2 3 B:0 1 2 3
判斷條件中,他們索引不同,致使以爲他們不是同一個節點。
這樣會 從新建立,渲染這個節點,效率不如直接重複利用的高。且在節點比較大(含有許多子節點)的時候異常明顯
總結
本篇文章,從0開始講述了虛擬節點的建立 渲染 diff
的過程。另外有一些配置沒有說。利用了webpack
進行打包,webpack-dev-server
等插件快速開發。
本文分享自微信公衆號 - 阿琛前端成長之路(lwkWyc)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。