虚拟DOM与Diff算法
- 虚拟DOM
- snabbdom
- Vue中的Diff算法
1 虚拟DOM
- 概述
- VNode
1.1 概述
原生DOM为我们提供了一些获取DOM元素以及操作DOM元素的API,可以对DOM元素进行增删改查。
复杂的页面状态
维护需要提前写好大量的DOM操作,会造成状态很难维护,代码的逻辑也很混乱。
所以我们会使用数据驱动
的方式进行视图更新 - 数据与视图的绑定,更新数据时进行视图更新。
渲染
我们通常以状态
作为输入
,并生成DOM输出到页面上显示 - 渲染
问题
当状态发生变化时,需要重新渲染,也就是重新输入与输出,那么应该如何确认状态发生了什么变化,又该如何更新DOM?
显然把DOM全部删除再重新生成是不可取的。
解决方案
Angular采用脏检查
的方式:设置一些触发条件,执行一个检测来遍历所有的数据,对比更改的位置执行更新的操作,性能低
React与Vue采用虚拟DOM
:根据状态生成一个虚拟节点树
,使用虚拟节点树进行渲染。数据变化时,创建一个新
的虚拟节点树,对两棵树进行对比只渲染不同的部分。
Vue虚拟节点树是由组件树建立起来的整个虚拟节点
(Virtual Node - vnode) 树
React与Vue对比
首先两者的虚拟DOM是一致的,diff算法只有细微区别,但是更新策略
大不相同
React:函数式组件思想 当状态变化会遍历diff
当前组件所有子节点组件,react 16
引入fiber
,可以使用API进行中断,避免不必要的子组件重新渲染
Vue:组件响应式思想 代理模式将data中的值用setter/getter绑定起来,并设置响应式watcher,当状态变化时会通知到组件
,在组件中diff
Vue中的虚拟DOM
使用模板
描述状态与DOM之间的映射关系
。
通过编译
将模板转换成渲染函数
,渲染函数会返回一个虚拟节点树
,然后patch
到视图
在patch的过程中会对新旧vnode进行对比,根据对比结果进行DOM操作来更新视图
1.2 VNode
VNode类可以实例化出不同类型的vnode实例,代表不同类型的DOM元素
1.2.1 VNode类
var VNode = function VNode (
tag,
data,
children,
text,
elm,
context,
componentOptions,
asyncFactory
) {
this.tag = tag;
this.data = data;
this.children = children;
this.text = text;
this.elm = elm;
this.ns = undefined;
this.context = context;
this.fnContext = undefined;
this.fnOptions = undefined;
this.fnScopeId = undefined;
this.key = data && data.key;
this.componentOptions = componentOptions;
this.componentInstance = undefined;
this.parent = undefined;
this.raw = false;
this.isStatic = false;
this.isRootInsert = true;
this.isComment = false;
this.isCloned = false;
this.isOnce = false;
this.asyncFactory = asyncFactory;
this.asyncMeta = undefined;
this.isAsyncPlaceholder = false;
};
实例化后产生的vnode可以理解为节点描述对象,它描述了应该如何创建真实DOM节点。通过vnode创建DOM,再插入到视图中这是渲染视图的基本过程。
<!-- 真实页面 -->
<div id="app" class="container">
<h1>虚拟DOM</h1>
<ul style="color:teal">
<li>项目一</li>
<li>项目二</li>
</ul>
</div>
//对应的vnode描述对象
{
tag: 'div',
props: { id: 'app', className: 'container' },
children: [
{ tag: 'h1', children: '虚拟DOM' },
{
tag: 'ul',
props: { style: 'color: teal' },
children: [
{ tag: 'li', children: '项目一' },
{ tag: 'li', children: '项目二' },
]
}
]
}
vnode会被缓存起来,方便前后对比灵活的修改真实DOM
1.2.2 VNode的类型
vnode的类型有以下几种
- 注释节点
- 文本节点
- 元素节点
- 组件节点
- 函数式组件
- 克隆节点
注释节点
//创建注释节点
function createEmptyVode(text) {
const node = new VNode()
node.text = text
node.isComment = true
return node
}
文本节点
//创建文本节点
function createTextVNode(val){
return new VNode(undefined,undefined,undefined,String(val))
}
克隆节点
function cloneVNode(vnode,deep){
const cloned = new VNode(vnode.tag,vnode.data,...)
cloned.ns = vnode.ns
...
cloned.isCloned = true
if(deep && vnode.children){
cloned.children = cloneVNode(vnode.children)
}
return cloned
}
唯一区别isCloed
元素节点
- tag:标签名
- data:节点数据,attrs、class、id、style...
- children:子节点数组
- context:当前组件实例vm
组件节点
与元素节点类似,还有两个独有的属性
- componentOptions:组件节点的选项参数,propsData、tag、children...
- componentInstance:组件实例
//一个组件节点
<child></child>
//vnode
{
...
tag:"vue-component-1-child"
}
函数式组件
与组件节点类似,还有两个独有的属性
- functionalContext:函数式上下文
- functionalOptions:函数式选项
2 snabbdom
- 认识snabbdom
- h函数
- patch函数
- patchVnode函数
- updateChildren函数
- 总结
2.1 认识snabbdom
snabbdom是著名的虚拟DOM库,是diff算法的鼻祖,Vue源码借鉴
了snabbdom。
仓库地址
snabbdom库是使用TypeScript写的,github上只有TypeScript的版本
可以在npm上安装编译成JavaScript的库文件,同时也包含了原版文件
npm init
npm install -S snabbdom
可以在node_modules/snabbdom的build文件夹下查看js版本,在src文件夹下查看ts版本
容器与引入
//容器与按钮
<div id="container"></div>
<button id="btn">修改数据</button>
//引入snabbdom
<script src="https://lib.baomitu.com/snabbdom/0.7.4/h.js"></script>
<script src="https://lib.baomitu.com/snabbdom/0.7.4/snabbdom-class.js"></script>
<script src="https://lib.baomitu.com/snabbdom/0.7.4/snabbdom-eventlisteners.js"></script>
<script src="https://lib.baomitu.com/snabbdom/0.7.4/snabbdom-props.js"></script>
<script src="https://lib.baomitu.com/snabbdom/0.7.4/snabbdom-style.js"></script>
<script src="https://lib.baomitu.com/snabbdom/0.7.4/snabbdom.js"></script>
<script src="index.js"></script>
初始化与生成虚拟节点
/**
* 初始化
*/
// 引入snabbdom库
const snabbdom = window.snabbdom
// 初始化h函数 - 创建虚拟节点
const h = snabbdom.h
// 初始化patch函数 - 将虚拟节点插入指定容器中
const patch = snabbdom.init([
snabbdom_class,
snabbdom_props,
snabbdom_style,
snabbdom_eventlisteners
])
/**
* 生成虚拟节点并插入
*/
//获取容器
const container = document.getElementById('container')
//创建vnode
const vnode = h('ul#list', {}, [
h('li.item', {}, '第一项'),
h('li.item', {}, '第二项'),
])
//插入vnode
patch(container, vnode)
使用h函数生成vnode
patch(oldVnode,newVnode)对新旧虚拟节点树进行比较,得到需要修改的信息,最终去操作DOM更新视图
patch操作
const btn = document.getElementById('btn')
btn.addEventListener('click', () => {
const newVnode = h('ul#list', {}, [
h('li.item', {}, '项目一'),
h('li.item', {}, '项目2'),
h('li.item', {}, '项目3'),
])
patch(vnode, newVnode)
vnode = newVnode
})
这里手动创建新的vnode,然后使用patch进行对比计算
在Vue中会有数据侦测,数据变化会识别组件然后自动调用渲染函数,以及后续操作
可以看到数据更新与添加都会触发节点的重新渲染
2.2 h函数
生成vnode
下面代码描述都是精简之后的,详细访问源码仓库
核心逻辑
//核心逻辑
function h(sel:string,data:VNodeData|null,children:VNodeChildren):VNode;
function h(sel:any,b?:any,c?:any):VNode {
//设置判断条件确定 data children text
...
return vnode(sel,data,children,text,undefined)
}
//vnode函数
function vnode(
sel: string | undefined,
data: any | undefined,
children: Array<VNode | string> | undefined,
text: string | undefined,
elm: Element | Text | undefined
): VNode {
const key = data === undefined ? undefined : data.key;
//返回vnode节点
return {sel, data, children, text, elm, key}
}
elm 对应的真实DOM
每个组件都可以接收key,在v-for中必须提供key
children与text是互斥
的,newVNode两者有其一,就需要删除对应的或者更新自身
2.3 patch函数
将vnode渲染成真实DOM
2.3.1 init
//init 返回patch函数
function init(moudles: Array<Partial<Module>>,domApi?:DOMAPI){
function emptyNodeAt(elm:Element)
function createRmCb(childElm: Node,listeners: number)
function createElm(vnode: VNode, insertedVnodeQueue: VNode): Node{...}
function addVnodes(
parentElem:Node,
before:Node|null,
vnodes:VNode[],
startIdx:number,
endIdx:number,
insertedVnodeQueue:VNodeQueue
)
function invokeDestoryHook(vnode: VNode)
function removeVnodes(
parentElem: Node,
vnodes: VNode[],
startIdx: number,
endIdx: number
): void {...}
function updateChildren(...){...}
function patchVnode(...){...}
return function patch(...): VNode{... return vnode}
}
init接收两个数据,返回一个patch函数
2.3.2 patch函数
基本结构
function patch(oldVnode: VNode|Element,vnode: VNode):VNode{
//判断oldVnode类型,决定是否创建空的vnode - 首次渲染
oldVnode是Element -> oldVnode emptyNodeAt(oldVode)
/**
* 对比oldVnode与vnode的sel和key是否相等
* sameVnode(oldVnode,vnode)
*/
//相等时 - 同一个节点 更新
pathchVnode(oldVnode,vnode,insertedVnodeQueue)
//不相等时 - 不是同一个节点 - 创建并插入
//获取需要更新的DOM
elm = oldVnode.elm!
parent = api.parentNode(elm) as Node
//根据vnode创建真实DOM节点
createElm(vnode,insertedVnodeQueue)
//插入至真实DOM
if (parent !== null) {
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm));
//删除原DOM节点
removeVnodes(parent, [oldVnode], 0, 0);
}
}
emptyNodeAt
function emptyNodeAt(elm:Element){
const id = elm.id ? "#" + elm.id : ""
cosnt c = elm.className ? "." + elm.className.split("").join(".") : ""
return vnode(api.tagName(elm).toLowerCase() + id + c,{},[],undefined,elm)
}
返回一个vnode,参数1是一个选择器(标签名#id.class) 指定真实DOM最后一个参数elm
2.4 patchVnode函数
针对新旧相同节点进行更新 - 更新vnode以及视图
function patchVnode(
oldVndoe: VNode,
vnode: VNode,
insetedVnodeQueue:VNodeQueue
){
...
//设置vnode关联的DOM元素
const elm = (vnode.elm = oldVnode.elm)!
//获取children 新旧相等则不更新
cosnt oldCh = oldVnode.children as VNode[]
cosnt ch = vnode.children as VNode[]
if(oldVnode === vnode) return
...
/**
* if 新无text
*/
//if 新有children 老有children
updateChildren(...)
//else if 新有children -> 老无children/有text或无text
api.setTextContent(elm,'') //清空text
addVodes(elm,null,ch,0,ch.length-1,insetedVnodeQueue)//添加children
//else if 老有children -> 新无children text
removeVnodes(elm,oldCh,0,oldCh.length-1) //移除children 新的什么都没有
//else if 老有text -> 新无children text
api.setTextContent(elm,'')//清空text
/**
* else if 新老text不等 -> 相等结束判断
*/
// if 老有children -> 新有text
removeVnodes(elm,oldCh,0,oldCh.length-1)//移除children
api.setTextContent(elm,vnode.text)//设置text
}
patchNode在两节点指代同一个DOM是,对其text与children进行判断比较
不管是oldVnode还是newVnode,text与children是互斥
的,只能有其一
根据这种关系理论上有9中组合,除去新旧两者都无,剩余8种,上述的判断条件以及处理程序完全涵盖了所有操作。
- 主要关注
新vnode
的状态,比对后决定了应该进行怎么的DOM操作 - 添加children,移除children,设置text,清空text
2.5 updateChildren函数
patchVnode中两者都都children时进行的处理函数
function updateChildren(
parentElm: Node,
oldCh: VNode[],
newCh: VNide[],
insertedVnodeQueue: VNodeQueue
){
//索引 - 首尾共4个
let oldStartIdx = 0 , oldEndIdx = oldCh.length - 1;//标记为o1 o2号指针
let newStartIdx = 0 , newEndIdx = newCh.length - 1;//标记为n1 n2号指针
//节点 - 首尾共4个
let oldStartVnode = oldCh[0] , oldEndVnode = oldCh[oldEndIdx]//标记为oA oB
let newStartVnode = newCh[0] , newEndVnode = oldCh[newEndIdx]//标记为nA nB
//其他
let oldKeyToIdx: KeyToIndexMap | undefined
/**
* 双指针循环遍历,当首尾指针前后顺序发生变化时终止
*/
while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx){
//if oA == null o1 oA 右移
...其余判空操作
//4中比较
//else if sameVnode(oA,nA) - 两首节点使用同一个DOM
patchVnoe(oA,nA) //同时将两首指针首节点右移
...尾节点同理
//else if sameVnode(oB,nA) - 新首老尾使用同一个DOM
patchVnode(oB,oA) //同时将两首指针首节点右移
api.insetBefore(parentElm,oA.elm!,api.nextSibling(oB.elm!))//需要移动
//else 4种比较均未命中
idxInOld = oldKeyToIdx[nA.key as string]//拿到新首节点的key
//if 老children无相同key - 全新的vnode
api.insertBefore(parentElm,createElm(nA,...),oA.elm!)//创建新节点
//else 有相同的key
elmToMove = oldCh[idxInOld]//获取
//if tag不相等 - 全新的vnode
...同上创建新节点并插入
//else tag相等 - 等价于sameVnode()
patchVnode(oldV,oA,insetedVnodeQueue)
oldCh[idxInOld] = undefined as any
api.insertBefore(parentElm,elmToMove.elm!,oB.elm!)//重新插入
//新首节点右移
}
//收尾工作
if(o1 <= o2 || n1 <= n2 ){// 有一个遍历结束
if(o1 > o2){//老children先结束 -> 新节点未完全遍历,有新增
before = newCh[n2 + 1] == null ? null : newCh[n2 + 1].elm
addVnodes(parentElm,before,newCh,n1,n2,insertedVnodeQueue)
} else{//新children未完全遍历 - 有删除
removeVnodes(parentElm,oldCh,o1,o2)
}
}
}
流程
- 创建新旧children的首尾索引与对应节点
- 双指针循环,当两个中有一个的首尾指针顺序变化循环结束
- 进行首首,尾尾,首尾比较,key与sel相同,进行patchVnoe并移动两个指针与节点
- 未命中,从新children的首节点开始,去匹配旧children的key,再检测tag,以此判断是新增还是移动,移动首指针与节点
- 继续循环,四种比较与单独的key、sel移动一个指针节点
- 循环结束,如果有一方未完全遍历完成,判断是删除了节点还是添加了节点,并对DOM进行修改
2.6 总结
从整个流程来看,snabbdom对于整个虚拟节点树进行了层序遍历
的diff
从根节点开始对children进行遍历比较,只比较同一层的同父节点的子节点。这种遍历的思想也称为广度优先遍历
树的先序遍历
或者说深度优先遍历
会向着一个子节点的一个方向一直遍历下去,对于dom树的及时遍历及时对比及时更新来说,这种遍历思想并不合适。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。