博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
snabbdom.js(二)
阅读量:6276 次
发布时间:2019-06-22

本文共 16068 字,大约阅读时间需要 53 分钟。

总共写了四篇文章(都是自己的一些拙见,仅供参考,请多多指教,我这边也会持续修正加更新)

这篇我将以自己的思路去解读一下源码(这里的源码我为了兼容IE8有作修改);

对虚拟dom的理解

通过js对象模拟出一个我们需要渲染到页面上的dom树的结构,实现了一个修改js对象即可修改页面dom的快捷途径,避免了我们‘手动’再去一次次操作dom-api的繁琐,而且其提供了算法可以使得用最少的dom操作进行修改。

从例子出发,寻找切入点

var snabbdom = SnabbdomModule;var patch = snabbdom.init([ //导入相应的模块    DatasetModule,    ClassModule,    AttributesModule,    PropsModule,    StyleModule,    EventlistenerModule]);var h = HModule.h;var app = document.getElementById('app');var newVnode = h('div#divId.red', {}, [h('p', {},'已改变')])var vnode = h('div#divId.red', {}, [h('p',{},'2S后改变')])vnode = patch(app, vnode);setTimeout(function() {    vnode=patch(vnode, newVnode);}, 2000)

从上面的例子不难看出,我们需要从三个重点函数 init patch h 切入,这三个函数分别的作用是:初始化模块,对比渲染,构建vnode;

而文章开头我说了实现虚拟dom的第一步就是 通过js对象模拟出一个我们需要渲染到页面上的dom树的结构,所以'首当其冲'就是需要先了解h函数,如何将js对象封装成vnode,vnode是我们定义的虚拟节点,然后就是利用patch函数进行渲染

构建vnode

h.js

var HModule = {};(function(HModule) {    var VNode = VNodeModule.VNode;    var is = isModule;    /**     *     * @param sel 选择器     * @param b    数据     * @param childNode    子节点     * @returns {
{sel, data, children, text, elm, key}} */ //调用vnode函数将数据封装成虚拟dom的数据结构并返回,在调用之前会对数据进行一个处理:是否含有数据,是否含有子节点,子节点类型的判断等 HModule.h = function(sel, b, childNode) { var data = {}, children, text, i; if (childNode !== undefined) { //如果childNode存在,则其为子节点 //则h的第二项b就是data data = b; if (is.array(childNode)) { //如果子节点是数组,则存在子element节点 children = childNode; } else if (is.primitive(childNode)) { //否则子节点为text节点 text = childNode; } } else if (b !== undefined) { //如果只有b存在,childNode不存在,则b有可能是子节点也有可能是数据 //数组代表子element节点 if (is.array(b)) { children = b; } else if (is.primitive(b)) { //代表子文本节点 text = b; } else { //代表数据 data = b; } } if (is.array(children)) { for (i = 0; i < children.length; ++i) { //如果子节点数组中,存在节点是原始类型,说明该节点是text节点,因此我们将它渲染为一个只包含text的VNode if (is.primitive(children[i])) children[i] = VNode(undefined, undefined, undefined, children[i]); } } //返回VNode return VNode(sel, data, children, text, undefined); }})(HModule)

h函数的主要工作就是把传入的参数封装为vnode

接下来看一下,vnode的结构

vnode.js

var VNodeModule = {};(function(VNodeModule) {    VNodeModule.VNode = function(sel, data, children, text, elm) {        var key = data === undefined ? undefined : data.key;        return {            sel: sel,            data: data,            children: children,            text: text,            elm: elm,            key: key        };    }})(VNodeModule)
sel 对应的是选择器,如'div','div#a','div#a.b.c'的形式data 对应的是vnode绑定的数据,可以有以下类型:attribute、props、eventlistner、class、dataset、hookchildren 子元素数组text 文本,代表该节点中的文本内容elm 里面存储着对应的真实dom element的引用key vnode标识符,主要是用在需要循环渲染的dom元素在进行diff运算时的优化算法,例如ul>li,tobody>tr>td等
text和children是不会同时存在的,存在text代表子节点仅为文本节点
如:h('p',123)
---> <p>123</p>;

存在children代表其子节点存在其他元素节点(也可以包含文本节点),需要将这些节点放入数组中 如:h('p',[h('h1',123),'222']) ---> <p><h1>123</h1>222</p>

打印一下例子中调用h函数后的结构:

vnode:
图片描述
newVnode:
图片描述

关于elm这个值后面再说

初始化模块和对比渲染

利用vnode生成我们的虚拟dom树后,就需要开始进行渲染了;只所以说是对比渲染,是因为它渲染的机制不是直接把我们的设置好的vnode全部渲染,而是会进行一次新旧vnode的对比,进行差异渲染;

snabbdom.js

init函数

function init(modules, api) {   ... }

它有两个参数,第一个是需要加载的模块数组,第二个是操作dom的api,一般我们只需要传入第一个参数即可

1.模块的初始化

先拿个模块举例:

var ClassModule = {};function updateClass(oldVnode, vnode){}ClassModule.create = updateClass;ClassModule.update = updateClass;
var hooks = ['create', 'update', 'remove', 'destroy', 'pre', 'post']; //全局钩子:modules自带的钩子函数function init(modules, api) {    var i, j, cbs = {};    ...    for (i = 0; i < hooks.length; ++i) {            cbs[hooks[i]] = [];            for (j = 0; j < modules.length; ++j) {                if (modules[j][hooks[i]] !== undefined) cbs[hooks[i]].push(modules[j][hooks[i]]);        }    }    ...}

上面就是模块初始化的核心,事先在模块中定义好钩子函数(即模块对于vnode的操作),然后在init函数中依次将这些模块的钩子函数加载进来,放在一个对象中保存,等待调用;

ps:init函数里面还会定义一些功能函数,等用到的时候再说,然后下一个需要分析的就是init被调用后会return一个函数---patch函数(这个函数是自己定义的一个变量名);

2.调用patch函数进行对比渲染

    在没看源码之前,我一直以为snabbdom的对比渲染是会把新旧vnode对比结果产生一个差异对象,然后在利用这个差异对象再进行渲染,后面看了后发现snabbdom这边是在对比的同时就直接利用dom的API在旧的dom上进行修改,而这些操作(渲染)就是定义在我们前面加载的模块中。

这里需要说一下snabbdom的对比策略是针对同层级的节点进行对比

图片描述
其实这里就有一个小知识点,bfs---广度优先遍历

广度优先遍历从某个顶点出发,首先访问这个顶点,然后找出这个结点的所有未被访问的邻接点,访问完后再访问这些结点中第一个邻接点的所有结点,重复此方法,直到所有结点都被访问完为止。

网上介绍的文章很多,我这边就不过多介绍了;

举个例子

var tree = {    val: 'div',    ch: [{        val: 'p',        ch: [{            val: 'text1'        }]    }, {        val: 'p',        ch: [{            val: 'span',            ch: [{                val: 'tetx2'            }]        }]    }]}function bfs(tree) {    var queue = [];    var res = []    if (!tree) return    queue.push(tree);    while (queue.length) {        var node = queue.shift();        if (node.ch) {            for (var i = 0; i < node.ch.length; i++) {                queue.push(node.ch[i]);            }        }        if (node.val) {            res.push(node.val);        }    }    return res;}console.log(bfs(tree)) //["div", "p", "p", "text1", "span", "tetx2"]

思路:先把根节点放入一个数组queue中,然后将其取出来,判断其是否有子节点,如果有,将其子节点依次放入queue数组中;然后依次再从这个数组中取值,重复上述步骤,直到这个数组queue没有数据;


这里snabbdom会比较每一个节点它的sel是否相似,如果相似对其子节点再进行比较,否则直接删除这个节点,添加新节点,其子节点也不会继续进行比较

patch函数

return function(oldVnode, vnode) {            var i, elm, parent;            //记录被插入的vnode队列,用于批量触发insert            var insertedVnodeQueue = [];            //调用全局pre钩子            for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();            //如果oldvnode是真实的dom节点,则转化为一个空vnode,一般这是初始化渲染的时候会用到            if (isUndef(oldVnode.sel)) {                oldVnode = emptyNodeAt(oldVnode);            }            //如果oldvnode与vnode相似,进行更新;相似是比较其key值与sel值            if (sameVnode(oldVnode, vnode)) {                patchVnode(oldVnode, vnode, insertedVnodeQueue);            } else {                //否则,将新的vnode插入,并将oldvnode从其父节点上直接删除                elm = oldVnode.elm;                parent = api.parentNode(elm);                createElm(vnode, insertedVnodeQueue);                if (parent !== null) {                    api.insertBefore(parent, vnode.elm, api.nextSibling(elm));                    removeVnodes(parent, [oldVnode], 0, 0);                }            }            //插入完后,调用被插入的vnode的insert钩子            for (i = 0; i < insertedVnodeQueue.length; ++i) {                insertedVnodeQueue[i].data.hook.insert(insertedVnodeQueue[i]);            }            //然后调用全局下的post钩子            for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();            //返回vnode用作下次patch的oldvnode            return vnode;        };

流程图:

图片描述

  • 当oldvnode的sel为空的时候,这里出现的场景基本上就是我们第一次调用patch去初始化渲染页面
  • 比较相似的方式为vnode的sel,key两个属性是否相等,不定义key值也没关系,因为不定义则为undefined,而undefined===undefined,只需要sel相等即可相似
  • 由于比较策略是同层级比较,所以当父节点不相相似时,子节点也不会再去比较
  • 最后会将vnode返回,也就是我们此刻需要渲染到页面上的vnode,它将会作为下一次渲染时的oldvnode

这基本上就是一个对比的大体过程,值得研究的东西还在后面,涉及到了其核心的diff算法,下篇文章再提。

再介绍一下上面用到的一些功能函数:

isUndef

为is.js中的函数,用来判断数据是否为undefined

emptyNodeAt

function emptyNodeAt(elm) {    var id = elm.id ? '#' + elm.id : '';    var c = elm.className ? '.' + elm.className.split(' ').join('.') : '';    return VNode(api.tagName(elm).toLowerCase() + id + c, {}, [], undefined, elm);}

用来将一个真实的无子节点的DOM节点转化成vnode形式,

如:<div id='a' class='b c'></div>
将转换为{sel:'div#a.b.c',data:{},children:[],text:undefined,elm:<div id='a' class='b c'>}

sameVnode

function sameVnode(vnode1, vnode2) {    return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel;}

用来比较两个vnode是否相似。

如果新旧vnode的key和sel都相同,说明两个vnode相似,我们就可以保留旧的vnode节点,再具体去比较其差异性,在旧的vnode上进行'打补丁',否则直接替换节点。这里需要说的是如果不定义key值,则这个值就为undefined,undefined===undefined //true,所以平时在用vue的时候,在没有用v-for渲染的组件的条件下,是不需要定义key值的,不会影响其比较。

createElm

创建vnode对应的真实dom,并将其赋值给vnode.elm,后续对于dom的修改都是在这个值上进行

//将vnode创建为真实dom    function createElm(vnode, insertedVnodeQueue) {        var i, data = vnode.data;        if (isDef(data)) {            //当节点上存在hook而且hook中有beforeCreate钩子时,先调用beforeCreate回调,对刚创建的vnode进行处理            if (isDef(i = data.hook) && isDef(i = i.beforeCreate)) {                i(vnode);                //获取beforeCreate钩子修改后的数据                data = vnode.data;            }        }        var elm, children = vnode.children,            sel = vnode.sel;        if (isDef(sel)) {            //解析sel参数,例如div#divId.divClass  ==>id="divId"  class="divClass"            var hashIdx = sel.indexOf('#');            //先id后class            var dotIdx = sel.indexOf('.', hashIdx);            var hash = hashIdx > 0 ? hashIdx : sel.length;            var dot = dotIdx > 0 ? dotIdx : sel.length;            var tag = hashIdx !== -1 || dotIdx !== -1 ? sel.slice(0, Math.min(hash, dot)) : sel;            //创建一个DOM节点引用,并对其属性实例化            elm = vnode.elm = isDef(data) && isDef(i = data.ns) ? api.createElementNS(i, tag) : api.createElement(tag);            //获取id名 #a --> a            if (hash < dot) elm.id = sel.slice(hash + 1, dot);            //获取类名,并格式化  .a.b --> a b            if (dotIdx > 0) elm.className = sel.slice(dot + 1).replace(/\./g, ' ');            //如果存在子元素Vnode节点,则递归将子元素节点插入到当前Vnode节点中,并将已插入的子元素节点在insertedVnodeQueue中作记录            if (is.array(children)) {                for (i = 0; i < children.length; ++i) {                    api.appendChild(elm, createElm(children[i], insertedVnodeQueue));                }            } else if (is.primitive(vnode.text)) { //如果存在子文本节点,则直接将其插入到当前Vnode节点                api.appendChild(elm, api.createTextNode(vnode.text));            }            //当创建完毕后,触发全局create钩子回调            for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode);            i = vnode.data.hook; // Reuse variable            if (isDef(i)) { //触发自身的create钩子回调                if (i.create) i.create(emptyNode, vnode);                //如果有insert钩子,则推进insertedVnodeQueue中作记录,从而实现批量插入触发insert回调                if (i.insert) insertedVnodeQueue.push(vnode);            }        }        //如果没声明选择器,则说明这个是一个text节点        else {            elm = vnode.elm = api.createTextNode(vnode.text);        }        return vnode.elm;    }

patchVnode

如果两个vnode相似,则会对具体的vnode进行‘打补丁’的操作

function patchVnode(oldVnode, vnode, insertedVnodeQueue) {        var i, hook;        //在patch之前,先调用vnode.data的beforePatch钩子        if (isDef(i = vnode.data) && isDef(hook = i.hook) && isDef(i = hook.beforePatch)) {            i(oldVnode, vnode);        }        var elm = vnode.elm = oldVnode.elm,            oldCh = oldVnode.children,            ch = vnode.children;        //如果oldnode和vnode的引用相同,说明没发生任何变化直接返回,避免性能浪费        if (oldVnode === vnode) return;        //如果oldvnode和vnode不同,说明vnode有更新        //如果vnode和oldvnode不相似则直接用vnode引用的DOM节点去替代oldvnode引用的旧节点        if (!sameVnode(oldVnode, vnode)) {            var parentElm = api.parentNode(oldVnode.elm);            elm = createElm(vnode, insertedVnodeQueue);            api.insertBefore(parentElm, elm, oldVnode.elm);            removeVnodes(parentElm, [oldVnode], 0, 0);            return;        }        //如果vnode和oldvnode相似,那么我们要对oldvnode本身进行更新        if (isDef(vnode.data)) {            //首先调用全局的update钩子,对vnode.elm本身属性进行更新            for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);            //然后调用vnode.data里面的update钩子,再次对vnode.elm更新            i = vnode.data.hook;            if (isDef(i) && isDef(i = i.update)) i(oldVnode, vnode);        }        /*         分情况讨论节点的更新: new代表新Vnode old代表旧Vnode         ps:如果自身存在文本节点,则不存在子节点 即:有text则不会存在ch,反之亦然         1 new不为文本节点             1.1 new不为文本节点,new还存在子节点                   1.1.1 new不为文本节点,new还存在子节点,old有子节点               1.1.2 new不为文本节点,new还存在子节点,old没有子节点                  1.1.2.1 new不为文本节点,new还存在子节点,old没有子节点,old为文本节点            1.2 new不为文本节点,new不存在子节点              1.2.1 new不为文本节点,new不存在子节点,old存在子节点              1.2.2 new不为文本节点,new不存在子节点,old为文本节点         2.new为文本节点             2.1 new为文本节点,并且old与new的文本节点不相等             ps:这里只需要讨论这一种情况,因为如果old存在子节点,那么文本节点text为undefined,则与new的text不相等             直接node.textContent即可清楚old存在的子节点。若old存在子节点,且相等则无需修改        */        //1        if (isUndef(vnode.text)) {            //1.1.1            if (isDef(oldCh) && isDef(ch)) {                //当Vnode和oldvnode的子节点不同时,调用updatechilren函数,diff子节点                if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);            }            //1.1.2            else if (isDef(ch)) {                //oldvnode是text节点,则将elm的text清除                //1.1.2.1                if (isDef(oldVnode.text)) api.setTextContent(elm, '');                //并添加vnode的children                addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);            }            //如果oldvnode有children,而vnode没children,则移除elm的children            //1.2.1            else if (isDef(oldCh)) {                removeVnodes(elm, oldCh, 0, oldCh.length - 1);            }            //1.2.2            //如果vnode和oldvnode都没chidlren,且vnode没text,则删除oldvnode的text            else if (isDef(oldVnode.text)) {                api.setTextContent(elm, '');            }        }        //如果oldvnode的text和vnode的text不同,则更新为vnode的text,        //2.1        else if (oldVnode.text !== vnode.text) {            api.setTextContent(elm, vnode.text);        }        //patch完,触发postpatch钩子        if (isDef(hook) && isDef(i = hook.postpatch)) {            i(oldVnode, vnode);        }    }

removeVnodes

/*        这个函数主要功能是批量删除DOM节点,需要配合invokeDestoryHook和createRmCb        主要步骤如下:        调用invokeDestoryHook以触发destory回调        调用createRmCb来开始对remove回调进行计数        删除DOM节点     *     *     * @param parentElm 父节点     * @param vnodes  删除节点数组     * @param startIdx  删除起始坐标     * @param endIdx  删除结束坐标     */    function removeVnodes(parentElm, vnodes, startIdx, endIdx) {        for (; startIdx <= endIdx; ++startIdx) {            var i, listeners, rm, ch = vnodes[startIdx]; //ch代表子节点            if (isDef(ch)) {                if (isDef(ch.sel)) {                    //调用destroy钩子                    invokeDestroyHook(ch);                    //对全局remove钩子进行计数                    listeners = cbs.remove.length + 1;                    rm = createRmCb(ch.elm, listeners);                    //调用全局remove回调函数,并每次减少一个remove钩子计数                    for (i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm);                    //调用内部vnode.data.hook中的remove钩子(只有一个)                    if (isDef(i = ch.data) && isDef(i = i.hook) && isDef(i = i.remove)) {                        i(ch, rm);                    } else {                        //如果没有内部remove钩子,需要调用rm,确保能够remove节点                        rm();                    }                } else { // Text node                    api.removeChild(parentElm, ch.elm);                }            }        }    }

invokeDestroyHook

/*    这个函数用于手动触发destory钩子回调,主要步骤如下:    先调用vnode上的destory    再调用全局下的destory    递归调用子vnode的destory    */    function invokeDestroyHook(vnode) {        var i, j, data = vnode.data;        if (isDef(data)) {            if (isDef(i = data.hook) && isDef(i = i.destroy)) i(vnode); //调用自身的destroy钩子            for (i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode); //调用全局destroy钩子            if (isDef(i = vnode.children)) {                for (j = 0; j < vnode.children.length; ++j) {                    invokeDestroyHook(vnode.children[j]);                }            }        }    }

addVnodes

//将vnode转换后的dom节点插入到dom树的指定位置中去function addVnodes(parentElm, before, vnodes, startIdx, endIdx, insertedVnodeQueue) {    for (; startIdx <= endIdx; ++startIdx) {        api.insertBefore(parentElm, createElm(vnodes[startIdx], insertedVnodeQueue), before);    }}

createRmCb

/*remove一个vnode时,会触发remove钩子作拦截器,只有在所有remove钩子回调函数都触发完才会将节点从父节点删除,而这个函数提供的就是对remove钩子回调操作的计数功能*/function createRmCb(childElm, listeners) {    return function() {        if (--listeners === 0) {            var parent = api.parentNode(childElm);            api.removeChild(parent, childElm);        }    };}

还有一个最核心的函数updateChildren,这个留到下篇文章再说;

我们这边简单的总结一下:

对比渲染的流程大体分为
1.通过sameVnode来判断两个vnode是否值得进行比较
2.如果不值得,直接删除旧的vnode,渲染新的vnode
3.如果值得,调用模块钩子函数,对其节点的属性进行替换,例如style,event等;再判断节点子节点是否为文本节点,如果为文本节点则进行更替,如果还存在其他子节点则调用updateChildren,对子节点进行更新,更新流程将会回到第一步,重复;

转载地址:http://qxgpa.baihongyu.com/

你可能感兴趣的文章
css面试题
查看>>
Vue组建通信
查看>>
用CSS画一个带阴影的三角形
查看>>
前端Vue:函数式组件
查看>>
程鑫峰:1.26特朗.普力挺美元力挽狂澜,伦敦金行情分析
查看>>
safari下video标签无法播放视频的问题
查看>>
浅析DNS解析过程
查看>>
使用prometheus + grafana + pushgateway搭建监控可视化系统
查看>>
计算机网络不完全整理(上)--春招实习
查看>>
01 iOS中UISearchBar 如何更改背景颜色,如何去掉两条黑线
查看>>
对象的继承及对象相关内容探究
查看>>
Spring: IOC容器的实现
查看>>
把你的devtools从webpack里删除
查看>>
Git 常用操作和流程
查看>>
Serverless五大优势,成本和规模不是最重要的,这点才是
查看>>
如何利用MongoDB实现高性能,高可用的双活应用架构?
查看>>
oc和swift混编项目,oc类和swift类互相访问
查看>>
Nginx 极简入门教程!
查看>>
iOS BLE 开发小记[4] 如何实现 CoreBluetooth 后台运行模式
查看>>
Item 23 不要在代码中使用新的原生态类型(raw type)
查看>>