kekobin / blog

blog

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

snabbdom源码学习

kekobin opened this issue · comments

简介

读vue源码的时候,发现它的虚拟dom**是参考的snabbdom,故先研读snabbdom还是比较的有价值。因为snabbdom就是一个纯实现虚拟dom的库,代码非常的优雅和纯粹,对于研究虚拟dom**再适合不过了。

代码目录

snabbdom官方代码是使用的typescript+flow写的,个人更喜欢es6的写法,故将其转成了snabbdom-es6,并使用rollup整合打包用于解读。

目录如下(跟官方基本一致):

│  h.js               	创建vnode的函数         
│  hooks.js		钩子函数定义           
│  htmldomapi.js	基本的dom api操作         
│  index.js		初始化和对外暴露的入口              
│  is.js	        判断的工具函数             
│  snabbdom.js	        核心patch和diff             
│  thunk.js		分块优化             
│  tovnode.js		dom元素转vnode             
│  vnode.js		虚拟节点             
│  
├─helpers
│   attachto.js
│      
└─modules		钩子功能模块             
    attributes.js		
    class.js
    dataset.js
    eventlisteners.js
    hero.js
    module.js
    props.js
    style.js

Virtual Dom实现思路

一般而言,实现一个Virtual Dom基本是如下的三个步骤,具体可以参考实现一个virtual dom

  • 使用javascript描述元素节点(即创建元素节点)
  • 实现diff算法进行新旧节点树的比对,获取差异对象数组
  • 将差异应用到实际的dom节点上

那么snabbdom是否也是一样遵循这个思路呢?
先透露下答案:大体一致,不过后面两个步骤是合在了一起,即找到新旧节点差异后,立马应用到实际的dom节点上了。

接下来就一步步分析其具体实现。

初始化和对外暴露的入口 index.js

接下来我们就从入口开始解读:

import { init } from './snabbdom';
import { attributesModule } from './modules/attributes'; // for setting attributes on DOM elements
import { classModule } from './modules/class'; // makes it easy to toggle classes
import { propsModule } from './modules/props'; // for setting properties on DOM elements
import { styleModule } from './modules/style'; // handles styling on elements with support for animations
import { eventListenersModule } from './modules/eventlisteners'; // attaches event listeners
import { h } from './h'; // helper function for creating vnodes
var patch = init([
    attributesModule,
    classModule,
    propsModule,
    styleModule,
    eventListenersModule
]);
export default { h, patch };

可以看到,入口仅仅是初始化得到patch,并且对外暴露出h和patch。看到这里疑问来了:

  1. h是干嘛用的?
  2. init传入模块的目的?得到的patch是干嘛用的?

好,让我们抽丝剥茧,一个个看。

1. h是干嘛用的?

从src/h.js可以看到:

export function h(sel, b, c) {
    //......
 // 上面仅仅是对h参数进行优化处理,得到vnode需要的入参,最终返回vnode的调用结果,即生成js虚拟node
    return vnode(sel, data, children, text, undefined);
}

可以很明显看到,h只是对vnode的再封装,目的在于将传参进一步处理提供给vnode使用。最后返回vnode,即创建js虚拟节点。

而vnode也很简单:

export function vnode(sel, data, children, text, elm) {
    var key = data === undefined ? undefined : data.key;
    // 仅仅返回了一个描述dom节点的对象,即 虚拟dom节点
    return { sel: sel, data: data, children: children, text: text, elm: elm, key: key };
}
export default vnode;

仅仅是使用一个对象,用于描述DOM节点的结构。正常来说,描述一个节点只需要 tagName、props、children就够了。那这里其他的参数是干嘛用的呢?

  • sel包含了tagName,可能还有id、class,例如:div#container.content;
  • data也是一个符合属性,包含了props、hook,还有其他相关数据;
  • text的增加是为了和其他的children区分来处理,因为text和children的处理方式差异比较大,故独立出来处理;
  • elm表示该节点对应的真实dom;
  • key是一个标识,用于后续diff算法时的比对。

2. init传入模块的目的?得到的patch是干嘛用的?

snabbdom.js整体结构

var hooks = ['create', 'update', 'remove', 'destroy', 'pre', 'post'];
export function init(modules, domApi) {
    var i, j, cbs = {};
    var api = domApi !== undefined ? domApi : htmlDomApi;
   // 使用策略模式初始化预先设置的hooks表,得到
    // cbs['create'] = [func1, func2...]、cbs['update'] = [func1, func2...] ...
    for (i = 0; i < hooks.length; ++i) {
        cbs[hooks[i]] = [];
        for (j = 0; j < modules.length; ++j) {
            var hook = modules[j][hooks[i]];
            if (hook !== undefined) {
                cbs[hooks[i]].push(hook);
            }
        }
    }
    function emptyNodeAt(elm) {
        //...
    }
    function createRmCb(childElm, listeners) {
        //...
    }
    function createElm(vnode, insertedVnodeQueue) {
        //...
    }
    function addVnodes(parentElm, before, vnodes, startIdx, endIdx, insertedVnodeQueue) {
        //...
    }
    function invokeDestroyHook(vnode) {
        //...
    }
    function removeVnodes(parentElm, vnodes, startIdx, endIdx) {
        //...
    }
    function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue) {
        //...
    }
    function patchVnode(oldVnode, vnode, insertedVnodeQueue) {
        //...
    }
    return function patch(oldVnode, vnode) {
        //...
    };
}

可以看到,snabbdom.js返回了一个init函数,init函数初始化了cbs和一些函数,直接返回了patch函数。那么为什么不直接返回patch函数,然后再它里面初始化cbs呢?
答案是为了更加灵活的处理,即cbs的处理可能根据需要传入的modules进行初始化,然后patch相当于一个闭包,引用了闭包环境中的cbs和相关函数。

hooks初始化

这里在init时初始化钩子函数到cbs对象中,后续就可以在不同阶段执行响应的hook钩子了。

所谓的钩子函数,就相当于是预先设置了一张表,如上面的 hooks,然后初始化这张表,比如cbs.create = [func1, func2...],cbs.update = [func1, func2...]。。。然后就可以用这张表去适配多种情况,即直接可以通过cbs[vnode.data.hook]调用执行对应的hook,而不用通过if else一个个去判断。这也是策略模式的一种应用。

实际上,当你看完整个snabbdom代码时,你会发现并没有显示的代码来对dom节点的class、style、attribute等进行处理。其奥秘就在于shabbdom在各个主要的环节提供了钩子,通过它执行扩展模块,attribute、props、eventlistener等都是通过扩展模块实现的。
如上面讲各个扩展模块的属性预存到了cbs上,在不同的环节上执行对应的钩子,就完成了对这些模块的扩展。

如src/modules/class.js的classModule:

function updateClass(oldVnode, vnode) {
    var cur, name, elm = vnode.elm, oldClass = oldVnode.data.class, klass = vnode.data.class;
    if (!oldClass && !klass)
        return;
    if (oldClass === klass)
        return;
    oldClass = oldClass || {};
    klass = klass || {};
    for (name in oldClass) {
        if (!klass[name]) {
            elm.classList.remove(name);
        }
    }
    for (name in klass) {
        cur = klass[name];
        if (cur !== oldClass[name]) {
            elm.classList[cur ? 'add' : 'remove'](name);
        }
    }
}
export var classModule = { create: updateClass, update: updateClass };
export default classModule;

扩展了class的crete和update方法,在patchNode中有这么一段:

function patchVnode(oldVnode, vnode, insertedVnodeQueue) {
    //...
    if (vnode.data !== undefined) {
        // 执行所有modules的update钩子函数(例如,oldVnode和vnode的props变化了,则这里就会将变化更新到对应的elm上)
        // 这样的好处时,预存的钩子中,只要这里新旧节点对应的模块改变了就会更新,不用额外去处理,也不用通过if else判断哪个模块更新了
        for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
        i = vnode.data.hook;
        if (isDef(i) && isDef(i = i.update)) i(oldVnode, vnode);
    }
    //...
}

即当新节点中data设置了数据(这时候可能就是attribute、class、eventlisteners等变化了)时,直接执行了cbs的所有update钩子,只要有变化,直接就应用了变化做更新处理。

patch函数

function patch(oldVnode, vnode) {
    var i, elm, parent;
    var insertedVnodeQueue = [];
    // 执行pre钩子
    for (i = 0; i < cbs.pre.length; ++i)
        cbs.pre[i]();
    // 如果旧节点不是Vnode,则将其转为Vnode,因为后面的比对都针对统一的Vnode虚拟节点来的
    if (!isVnode(oldVnode)) {
        oldVnode = emptyNodeAt(oldVnode);
    }
    // 相同的vnode节点,则开始节点比对
    if (sameVnode(oldVnode, vnode)) {
        patchVnode(oldVnode, vnode, insertedVnodeQueue);
    }
    // 不相同的,说明是新的节点
    else {
        elm = oldVnode.elm;
        parent = api.parentNode(elm);
        // 创建该节点实际的Dom
        createElm(vnode, insertedVnodeQueue);
        if (parent !== null) {
            // 插入到旧节点元素后面,然后删除掉旧元素
            api.insertBefore(parent, vnode.elm, api.nextSibling(elm));
            // 之所以要删除旧节点,是因为这里相当于是根节点都不一样,那么整棵树都要替换
            removeVnodes(parent, [oldVnode], 0, 0);
        }
    }
    for (i = 0; i < insertedVnodeQueue.length; ++i) {
        insertedVnodeQueue[i].data.hook.insert(insertedVnodeQueue[i]);
    }
    // 执行cbs.post钩子
    for (i = 0; i < cbs.post.length; ++i)
        cbs.post[i]();
    // 返回新节点
    return vnode;
}

逻辑比较简单,主要是判断新旧两棵树是否是同一颗,是的话则进行比对异同patchVnode;不是的话则创建新的树来替换旧树。

patchVnode函数

重点看看patchVnode函数的处理。

function patchVnode(oldVnode, vnode, insertedVnodeQueue) {
        var i, hook;
        // vnode.data.hook.prepatch存在,则执行propatch钩子
        if (isDef(i = vnode.data) && isDef(hook = i.hook) && isDef(i = hook.prepatch)) {
            i(oldVnode, vnode);
        }
        var elm = vnode.elm = oldVnode.elm;
        var oldCh = oldVnode.children;
        var ch = vnode.children;
        if (oldVnode === vnode) return;
        if (vnode.data !== undefined) {
            for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
            i = vnode.data.hook;
            if (isDef(i) && isDef(i = i.update)) i(oldVnode, vnode);
        }
        // 根据vnode.text是否存在分两种情况比对
        // 1. vnode.text不存在
        if (isUndef(vnode.text)) {
            // 新旧节点孩纸都存在,如果还不同,则比对更新孩纸
            if (isDef(oldCh) && isDef(ch)) {
                if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
            }
            // 如果旧孩纸不存在,新孩纸存在,说明是新增孩纸,需要把旧节点的text去掉,并添加新孩纸
            else if (isDef(ch)) {
                if (isDef(oldVnode.text)) api.setTextContent(elm, '');
                addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
            }
            // 如果新孩纸不存在,旧孩纸存在,说明是删除了孩纸,需要把就孩纸干掉
            else if (isDef(oldCh)) {
                removeVnodes(elm, oldCh, 0, oldCh.length - 1);
            }
            // 新旧孩纸都不存在,那就是text的比对了,需要把旧节点的text干掉
            else if (isDef(oldVnode.text)) {
                api.setTextContent(elm, '');
            }
        }
        // 2. vnode.text存在,说明新节点只设置了text
        else if (oldVnode.text !== vnode.text) {
            // 此时,如果就孩纸存在,则需要一个个干掉
            if (isDef(oldCh)) {
                removeVnodes(elm, oldCh, 0, oldCh.length - 1);
            }
            // 然后换上vnode.text
            api.setTextContent(elm, vnode.text);
        }
        // postpatch钩子执行
        if (isDef(hook) && isDef(i = hook.postpatch)) {
            i(oldVnode, vnode);
        }
    }

这里也不多说了,注释比较清楚了。强调一点:这里可以看出vnode声明时把children和text进行区分的好处了,而且一个Vnode中children和text是二选一的。因为对于dom来说,text也是children,而在虚拟dom里区分开,就可以直接根据它是否存在就行比对了。

上面比对新旧孩纸的逻辑updateChildren是我们需要最最关注,以及最难点的了。下面最核心解读下它。

updateChildren

function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue) {
    var oldStartIdx = 0, newStartIdx = 0;
    var oldEndIdx = oldCh.length - 1;
    var oldStartVnode = oldCh[0];
    var oldEndVnode = oldCh[oldEndIdx];
    var newEndIdx = newCh.length - 1;
    var newStartVnode = newCh[0];
    var newEndVnode = newCh[newEndIdx];
    var oldKeyToIdx;
    var idxInOld;
    var elmToMove;
    var before;

    // 使用了四个指针oldStartIdx、oldEndIdx、newStartIdx、newEndIdx,在新旧children开始和结束往中间一步步遍历对比
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        // 首先判断几种边界情况

        // condition 1
        // oldStartVnode为空,则oldStartIdx加一位
        if (oldStartVnode == null) {
            oldStartVnode = oldCh[++oldStartIdx]; // Vnode might have been moved left
        }
        // condition 2
        // oldEndVnode为空,则oldEndIdx减一位
        else if (oldEndVnode == null) {
            oldEndVnode = oldCh[--oldEndIdx];
        }
        // condition 3
        // newStartVnode为空,则newStartIdx加一位
        else if (newStartVnode == null) {
            newStartVnode = newCh[++newStartIdx];
        }
        // condition 4
        // newEndVnode为空,则newEndIdx减一位
        else if (newEndVnode == null) {
            newEndVnode = newCh[--newEndIdx];
        }
        // condition 5
        // oldStartVnode, newStartVnode节点相同,则直接比对它们的差异
        // 注意,这种情况 oldStartIdx 和 newStartIdx是相同的,即它们是相同位置节点,没有发生移动
        else if (sameVnode(oldStartVnode, newStartVnode)) {
            patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
            // 对应的索引均加一位
            oldStartVnode = oldCh[++oldStartIdx];
            newStartVnode = newCh[++newStartIdx];
        }
        // condition 6
        // 同上
        else if (sameVnode(oldEndVnode, newEndVnode)) {
            patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
            // 对应的索引均减一位
            oldEndVnode = oldCh[--oldEndIdx];
            newEndVnode = newCh[--newEndIdx];
        }
        // condition 7
        // oldStartVnode, newEndVnode节点相同,说明位置发生了移动,不仅要比对变化,还需要移动位置
        else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
            patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
            // 这里讲oldStartVnode.elm移动到了oldEndVnode.elm后面
            api.insertBefore(parentElm, oldStartVnode.elm, api.nextSibling(oldEndVnode.elm));
            // 这里比较容易引起疑惑,dom移动后,后面的会自动填充到前面移走的位置,也就是说自动的重新排好了序,那这里干嘛还要自增自减
            // 因为这里是dom的快照,是虚拟node节点的索引,它们是需要手动维护的
            oldStartVnode = oldCh[++oldStartIdx];
            newEndVnode = newCh[--newEndIdx];
        }
        // condition 8
        // 同上
        else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
            patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
            api.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
            oldEndVnode = oldCh[--oldEndIdx];
            newStartVnode = newCh[++newStartIdx];
        }
        else {
            // 排除上面的几种边界情况后,那就是需要判断在孩纸去头去尾的中间还是否有相同的新旧节点
            // 这里利用的是节点身上的key属性,即新旧节点,只要key相同,那么大概率就是相同的节点

            // 所以,首先获取oldCh的所有含有key属性的 key-index 映射对象 oldKeyToIdx
            if (oldKeyToIdx === undefined) {
                oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
            }
            // 然后,尝试判断 新节点的key 是否存在与oldKeyToIdx中
            idxInOld = oldKeyToIdx[newStartVnode.key];

            // condition 9
            // 不存在,则肯定是新节点,需要把它插入到 oldStartVnode.elm 前面
            if (isUndef(idxInOld)) { // New element
                api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm);
                newStartVnode = newCh[++newStartIdx];
            }
            
            else {
                // 若存在,说明是相同节点,则获取到索引 idxInOld 对应的旧节点
                elmToMove = oldCh[idxInOld];

                // condition 10
                // 然后还需要判断其sel,即元素标签等是否相同
                // 不同,说明还是新节点,同上插入
                if (elmToMove.sel !== newStartVnode.sel) {
                    api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm);
                }
                // condition 11
                else {
                    // 相同,说明是同一个节点,比对差异
                    patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
                    // 然后需要在虚拟节点队列中 把 旧节点置灰,并移动到当前索引的节点oldStartVnode前面
                    // 这里要把 oldCh[idxInOld] 置灰,是因为它已经被移动了,在后面的遍历中,不需要再进行遍历处理
                    oldCh[idxInOld] = undefined;
                    api.insertBefore(parentElm, elmToMove.elm, oldStartVnode.elm);
                }
                newStartVnode = newCh[++newStartIdx];
            }
        }
    }
    // 这里是另一种边界处理:旧节点先遍历完,或者新节点先遍历完。因为可能新旧节点数是不一样的(这是很大可能存在的)
    if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
        // condition 12
        // 旧节点先遍历完,说明还有新节点没遍历到,即要添加
        if (oldStartIdx > oldEndIdx) {
            before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
            // 添加剩下的所有未遍历到的新节点
            addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
        }
        // condition 13
        else {
            // 新节点先遍历完,说明还有旧节点没遍历到,即要删除
            removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
        }
    }
}

代码注释已经比较清楚了,不过还是不够清晰的说明整个孩纸列表的比对过程。
下面通过一个比对的例子和图示来进一步说明。

updateChildren示例

以下面两颗新旧节点树为示例进行说明:

image

整合成children对比状态:
image

1.round-1: 边界情况都不满足,调用createKeyToOldIdx创建key-index映射对象,判断newStartVnode是否在oldCh中间。很明显存在,对应上面的 condition 11,实际的dom需要把B移动到A前面,旧虚拟Dom里需要把B置为undefined:
image

注意,此时newStartIdx加了一位,但oldStartIdx还是没变的,因为它指向的node并没有任何变动。

2.round-2: 很明显,当前的oldStartVnode和newStartVnode相同,对应上面的condition 5。直接比对差异,oldStartIdx和newStartIdx各加1:
image

3.round-3: oldStartVnode为null,对应condition 1,oldStartIdx加一位:
image

4.round-4: 和2一样,得到结果:
image

5.rond-5: 同4,得到结果:
image

6.很明显,旧节点先遍历完了,对应上面的 condition 12,添加剩下的所有未遍历到的新节点。

至此,代码注释加上图例,应该很清楚的说明了整个updateChildren的过程了。

参考

list-diff
深度剖析:如何实现一个 Virtual DOM 算法
simple-virtual-dom
深入剖析:Vue核心之虚拟DOM
snabbdom源码分析
解析 snabbdom 源码,教你实现精简的 Virtual DOM 库
Virtual DOM 的内部工作原理