一. 虚拟DOM
-
什么是虚拟DOM
Virtual DOM 其实就是一棵以 JavaScript 对象( VNode 节点)作为基础的树,用对象属性来描述节点,实际上它只是一层对真实 DOM 的抽象。最终可以通过一系列操作使这棵树映射到真实环境上。
简单来说,可以把Virtual DOM 理解为一个简单的JS对象,并且最少包含标签名( tag)、属性(attrs)和子元素对象( children)三个属性。
- 虚拟dom渲染函数
class Element {
constructor (type, props, children) {
this.type = type
this.props = props
this.children = children
}
}
function createElement (type, props, children) {
return new Element (type, props, children)
}
createElement ('ul', { class: 'list' }, [
createElement ('li', { class: 'item' }, ['a']),
createElement ('li', { class: 'item' }, ['b']),
createElement ('li', { class: 'item' }, ['c']),
])
-
虚拟dom
-
真实dom
虚拟dom的作用/优点
在Web早期,页面的交互比较简单,不太需要频繁的操作DOM,随着时代的发展,页面上的功能越来越多,我们需要实现的需求也越来越复杂,DOM的操作也越来越频繁。通过js操作DOM的代价很高,因为会引起页面的重排重绘,增加浏览器的性能开销,降低页面渲染速度。
有了虚拟dom之后,我们可以在虚拟节点映射到视图的过程之前,将虚拟节点与上一次渲染视图所使用的虚拟节点(oldVnode)做对比,找出真正需要更新的节点来进行DOM操作,避免了不必要的DOM操作,从而节省了浏览器的性能开销使得页面的渲染速度得到提升。
二. diff算法
-
当数据发生变化时,vue是怎么更新节点的?
我们先根据真实DOM生成一颗virtual DOM树,当virtual DOM某个节点的发生改变后会生成一个新的Vnode,然后新老节点进行对比,对比的过程就是调用名为patch的函数,patch函数会生成一个补丁包,这个补丁包就是用来描述新老节点改变的内容,然后将这个补丁打到真实dom上更新dom。
在react进行patch时,是打包所有修改然后放入队列后集中处理,但是这样在早期浏览器上操作DOM时性能会有损失,因为 diff 过程中会遍历一次整棵树,patch 的时候又会遍历整棵树。而早期vue也是以这种形式对真实DOM进行patch,而现在vue中的patch是即时的,也就是 在diff的同时进行patch。 不过不管那种方式,现代浏览器对这样的DOM操作做了优化,二者已经并无太大差别。
-
diff的比较方式?
diff算法在比较新老节点的时候,比较只会在同层级进行, 不会跨层级比较。
层级相同的节点位置发生变化,diff时会复用这些节点而不是重新生成新的节点(通过节点的key来实现)
采用先序深度优先遍历
-
patch补丁包
1.patch函数接收两个参数oldVnode和Vnode分别代表新的节点和之前的旧节点,在比较新老节点生成patch补丁包之前会先判断这两个节点是否值得深入比较
-
patch补丁包
function patch (oldVnode, vnode) {
// some code
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode)
} else {
const oEl = oldVnode.el // 当前oldVnode对应的真实元素节点
let parentEle = api.parentNode(oEl) // 父元素
createEle(vnode) // 根据Vnode生成新元素
if (parentEle !== null) {
api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) // 将新元素添加进父元素
api.removeChild(parentEle, oldVnode.el) // 移除以前的旧元素节点
oldVnode = null
}
}
// some code
return vnode
}
sameVnode会根据新老节点的标签类型、key等确定这两个节点是否一致。如果两个节点都是一样的,那么就深入检查他们的子节点。如果两个节点不一样那就说明Vnode完全被改变了,就可以直接替换oldVnode。
function sameVnode (a, b) {
return (
a.key === b.key && // key值
a.tag === b.tag && // 标签名
a.isComment === b.isComment && // 是否为注释节点
// 是否都定义了data,data包含一些具体信息,例如onclick , style
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b) // 当标签是<input>的时候,type必须相同
)
}
当确定两个节点值得比较之后就会把两个节点作为参数传入到patchVnode方法中
patchVnode (oldVnode, vnode) {
const el = vnode.el = oldVnode.el // 找到对应的真实dom
let i, oldCh = oldVnode.children, ch = vnode.children
if (oldVnode === vnode) return //如果Vnode和oldVnode同一个对象,那么直接return
if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) { //
api.setTextContent(el, vnode.text) //如果他们都有文本节点并且不相等,那么将el的文本节点设置为Vnode的文本节点。
}else {
updateEle(el, vnode, oldVnode)
if (oldCh && ch && oldCh !== ch) { //如果两者都有子节点,则执行updateChildren函数比较子节点
updateChildren(el, oldCh, ch)
}else if (ch){ //如果oldVnode没有子节点而Vnode有,则将Vnode的子节点真实化之后添加到el
createEle(vnode) //create el's children dom
}else if (oldCh){ //如果oldVnode有子节点而Vnode没有,则删除el的子节点
api.removeChildren(el)
}
}
}
updateChildren源码,oldVnode和vnode的子节点进行对比
updateChildren (parentElm, oldCh, newCh) {
let oldStartIdx = 0, newStartIdx = 0
let oldEndIdx = oldCh.length - 1
let oldStartVnode = oldCh[0]
let oldEndVnode = oldCh[oldEndIdx]
let newEndIdx = newCh.length - 1
let newStartVnode = newCh[0]
let newEndVnode = newCh[newEndIdx]
let oldKeyToIdx
let idxInOld
let elmToMove
let before
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVnode == null) { // 对于vnode.key的比较,会把oldVnode = null
oldStartVnode = oldCh[++oldStartIdx]
}else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx]
}else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx]
}else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx]
}else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
}else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
}else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode)
api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
}else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode)
api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
}else {
// 使用key时的比较
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 有key生成index表
}
idxInOld = oldKeyToIdx[newStartVnode.key]
if (!idxInOld) {
api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
newStartVnode = newCh[++newStartIdx]
}
else {
elmToMove = oldCh[idxInOld]
if (elmToMove.sel !== newStartVnode.sel) {
api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
}else {
patchVnode(elmToMove, newStartVnode)
oldCh[idxInOld] = null
api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el)
}
newStartVnode = newCh[++newStartIdx]
}
}
}
if (oldStartIdx > oldEndIdx) {
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
}else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
}
首先,在新老两个VNode节点的左右头尾两侧都有一个变量标记
在遍历时,oldStartVnode、oldEndVnode与newStartVnode、newEndVnode会先进行两两比较,一共有四种比较方式,当其中两个能匹配上那么真实dom中的相应节点会移到Vnode相应的位置。即:
- oldStartVnode和newStartVnode匹配,位置不动,oldStartIdx,newStartIdx 指针后移。
- oldEndVnode和newEndVnode匹配,位置不动,oldEndIdx,newEndIdx 指针前移。
- oldStartVnode和newEndVnode匹配,oldStartVnode移动到newEndVnode所在位置,oldStartIdx指针前移,newEndIdx 指针后移。
- oldEndVnode和oldStartVnode匹配,oldEndVnode移动到newStartVnode所在位置,oldEndIdx指针后移,newStartIdx 指针前移。
此时已完成了新旧节点首位子节点的匹配,倘若以上4种方式都没能匹配上,如果设置了key,就会用key进行比较,遍历剩下的节点,如果在newVnode中找到一致key的旧的VNode节点,并且同时满足sameVnode,patchVnode,那么这个节点将得到复用。
key 的作用 主要是 :
1.决定节点是否可以复用
2.建立key-index的索引,主要是替代遍历,提升性能
小提示:循环数据时尽量不使用index作为key,除非你能保证index的唯一性。
最后 通过 oldStartIdx > oldEndIdx ,来判断 oldCh 和 newCh 哪一个先遍历完成
oldStartIdx > oldEndIdx表示oldCh先遍历完,那么就将多余的vCh根据index添加到dom中去。
StartIdx > EndIdx表示vCh先遍历完,那么就在真实dom中将区间的多余节点删掉