虚拟DOM与Diff算法

  1. 虚拟DOM
  2. snabbdom
  3. Vue中的Diff算法

1 虚拟DOM

  1. 概述
  2. 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

  1. 认识snabbdom
  2. h函数
  3. patch函数
  4. patchVnode函数
  5. updateChildren函数
  6. 总结

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
})

diff.gif

这里手动创建新的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进行遍历比较,只比较同一层的同父节点的子节点。这种遍历的思想也称为广度优先遍历
difff.png
树的先序遍历或者说深度优先遍历会向着一个子节点的一个方向一直遍历下去,对于dom树的及时遍历及时对比及时更新来说,这种遍历思想并不合适。


怼怼
73 声望6 粉丝