虚构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树的及时遍历及时比照及时更新来说,这种遍历思维并不适合。
发表回复