Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

snabbdom源码学习 #65

Open
kekobin opened this issue Nov 8, 2019 · 0 comments
Open

snabbdom源码学习 #65

kekobin opened this issue Nov 8, 2019 · 0 comments

Comments

@kekobin
Copy link
Owner

kekobin commented Nov 8, 2019

简介

读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 的内部工作原理

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant