在上一章咱们学习了,modules,vnode,h,htmldomapi,is等模块,在这一篇咱们将会学习到
snabbdom的核心功能——patchVnode和updateChildren功能。javascript
首先咱们先从简单的部分开始,好比一些工具函数,我将逐个来说解他们的用处html
这个函数主要用于比较oldvnode与vnode同层次节点的比较,若是同层次节点的key和sel都相同
咱们就能够保留这个节点,不然直接替换节点java
function sameVnode(vnode1, vnode2) { return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel; }
这个函数的功能十分简单,就是将oldvnode数组中位置对oldvnode.key的映射转换为oldvnode.key
对位置的映射node
function createKeyToOldIdx(children, beginIdx, endIdx) { var i, map = {}, key; for (i = beginIdx; i <= endIdx; ++i) { key = children[i].key; if (isDef(key)) map[key] = i; } return map; }
snabbdom在全局下有6种类型的钩子,触发这些钩子时,会调用对应的函数对节点的状态进行更改
首先咱们来看看有哪些钩子:算法
Name | Triggered when | Arguments to callback |
---|---|---|
pre |
the patch process begins (patch开始时触发) | none |
init |
a vnode has been added (vnode被建立时触发) | vnode |
create |
a DOM element has been created based on a vnode (vnode转换为真实DOM节点时触发 | emptyVnode, vnode |
insert |
an element has been inserted into the DOM (插入到DOM树时触发) | vnode |
prepatch |
an element is about to be patched (元素准备patch前触发) | oldVnode, vnode |
update |
an element is being updated (元素更新时触发) | oldVnode, vnode |
postpatch |
an element has been patched (元素patch完触发) | oldVnode, vnode |
destroy |
an element is directly or indirectly being removed (元素被删除时触发) | vnode |
remove |
an element is directly being removed from the DOM (元素从父节点删除时触发,和destory略有不一样,remove只影响到被移除节点中最顶层的节点) | vnode, removeCallback |
post |
the patch process is done (patch完成后触发) | none |
而后,下面列出钩子对应的状态更新函数:api
create => style,class,dataset,eventlistener,props,hero数组
update => style,class,dataset,eventlistener,props,heroapp
remove => styledom
destory => eventlistener,style,hero函数
pre => hero
post => hero
好了,简单的都看完了,接下来咱们开始打大boss了,第一关就是init函数了
init函数有两个参数modules和api,其中modules是init依赖的模块,如attribute、props
、eventlistener这些模块,api则是对封装真实DOM操做的工具函数库,若是咱们没有传入,则默认
使用snabbdom提供的htmldomapi。init还包含了许多vnode和真实DOM之间的操做和注册全局钩子,
还有patchVnode和updateChildren这两个重要功能,而后返回一个patch函数
//注册钩子的回调,在发生状态变动时,触发对应属性变动 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]]); } }
这个函数主要的功能是将一个真实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'>}
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); }
咱们知道当咱们须要remove一个vnode时,会触发remove钩子做拦截器,只有在全部remove钩子
回调函数都触发完才会将节点从父节点删除,而这个函数提供的就是对remove钩子回调操做的计数功能
function createRmCb(childElm, listeners) { return function() { if (--listeners === 0) { var parent = api.parentNode(childElm); api.removeChild(parent, childElm); } }; }
这个函数用于手动触发destory钩子回调,主要步骤以下:
先调用vnode上的destory
再调用全局下的destory
递归调用子vnode的destory
function invokeDestroyHook(vnode) { var i, j, data = vnode.data; if (isDef(data)) { //先触发该节点上的destory回调 if (isDef(i = data.hook) && isDef(i = i.destroy)) i(vnode); //在触发全局下的destory回调 for (i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode); //递归触发子节点的destory回调 if (isDef(i = vnode.children)) { for (j = 0; j < vnode.children.length; ++j) { invokeDestroyHook(vnode.children[j]); } } } }
这个函数主要功能是批量删除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]; 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); } } } }
就如太极有阴就有阳同样,既然咱们有remove操做,确定也有createelm的操做,这个函数主要功能
以下:
初始化vnode,调用init钩子
建立对应tagname的DOM element节点,并将vnode.sel中的id名和class名挂载上去
若是有子vnode,递归建立DOM element节点,并添加到父vnode对应的element节点上去,
不然若是有text属性,则建立text节点,并添加到父vnode对应的element节点上去
vnode转换成dom节点操做完成后,调用create钩子
若是vnode上有insert钩子,那么就将这个vnode放入insertedVnodeQueue中做记录,到时
再在全局批量调用insert钩子回调
function createElm(vnode, insertedVnodeQueue) { var i, data = vnode.data; if (isDef(data)) { //当节点上存在hook并且hook中有init钩子时,先调用init回调,对刚建立的vnode进行处理 if (isDef(i = data.hook) && isDef(i = i.init)) { i(vnode); //获取init钩子修改后的数据 data = vnode.data; } } var elm, children = vnode.children, sel = vnode.sel; if (isDef(sel)) { // Parse selector 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)); } //若是存在子文本节点,则直接将其插入到当前Vnode节点 } else if (is.primitive(vnode.text)) { 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)) { 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; }
这个函数十分简单,就是将vnode转换后的dom节点插入到dom树的指定位置中去
function addVnodes(parentElm, before, vnodes, startIdx, endIdx, insertedVnodeQueue) { for (; startIdx <= endIdx; ++startIdx) { api.insertBefore(parentElm, createElm(vnodes[startIdx], insertedVnodeQueue), before); } }
说完上面的节点工具函数以后,咱们就开始看如何进行patch操做了,首先咱们从patch,也就是init
返回的函数开始
首先咱们须要明确的一个是,若是按照传统的diff算法,那么为了找到最小变化,须要逐层逐层的去
搜索比较,这样时间复杂度将会达到 O(n^3)的级别,代价十分高,考虑到节点变化不多是跨层次的,
vdom采起的是一种简化的思路,只比较同层节点,若是不一样,那么即便该节点的子节点没变化,咱们
也不复用,直接将从父节点开始的子树所有删除,而后再从新建立节点添加到新的位置。若是父节点
没变化,咱们就比较全部同层的子节点,对这些子节点进行删除、建立、移位操做。有了这个思想,
理解patch也十分简单了。patch只须要对两个vnode进行判断是否类似,若是类似,则对他们进行
patchVnode操做,不然直接用vnode替换oldvnode。
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节点,转化为oldvnode if (isUndef(oldVnode.sel)) { oldVnode = emptyNodeAt(oldVnode); } //若是oldvnode与vnode类似,进行更新 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; };
真正对vnode内部patch的仍是得靠patchVnode。让咱们看看他到底作了什么?
function patchVnode(oldVnode, vnode, insertedVnodeQueue) { var i, hook; //在patch以前,先调用vnode.data的prepatch钩子 if (isDef(i = vnode.data) && isDef(hook = i.hook) && isDef(i = hook.prepatch)) { i(oldVnode, vnode); } var elm = vnode.elm = oldVnode.elm, oldCh = oldVnode.children, ch = vnode.children; //若是oldvnode和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); } //若是vnode不是text节点 if (isUndef(vnode.text)) { //若是vnode和oldVnode都有子节点 if (isDef(oldCh) && isDef(ch)) { //当Vnode和oldvnode的子节点不一样时,调用updatechilren函数,diff子节点 if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue); } //若是vnode有子节点,oldvnode没子节点 else if (isDef(ch)) { //oldvnode是text节点,则将elm的text清除 if (isDef(oldVnode.text)) api.setTextContent(elm, ''); //并添加vnode的children addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue); } //若是oldvnode有children,而vnode没children,则移除elm的children else if (isDef(oldCh)) { removeVnodes(elm, oldCh, 0, oldCh.length - 1); } //若是vnode和oldvnode都没chidlren,且vnode没text,则删除oldvnode的text else if (isDef(oldVnode.text)) { api.setTextContent(elm, ''); } } //若是oldvnode的text和vnode的text不一样,则更新为vnode的text else if (oldVnode.text !== vnode.text) { api.setTextContent(elm, vnode.text); } //patch完,触发postpatch钩子 if (isDef(hook) && isDef(i = hook.postpatch)) { i(oldVnode, vnode); } }
对于同层的子节点,snabbdom主要有删除、建立的操做,同时经过移位的方法,达到最大复用存在
节点的目的,其中须要维护四个索引,分别是:
oldStartIdx => 旧头索引
oldEndIdx => 旧尾索引
newStartIdx => 新头索引
newEndIdx => 新尾索引
而后开始将旧子节点组和新子节点组进行逐一比对,直到遍历完任一子节点组,比对策略有5种:
oldStartVnode和newStartVnode进行比对,若是类似,则进行patch,而后新旧头索引都后移
oldEndVnode和newEndVnode进行比对,若是类似,则进行patch,而后新旧尾索引前移
oldStartVnode和newEndVnode进行比对,若是类似,则进行patch,将旧节点移位到最后
而后旧头索引后移,尾索引前移,为何要这样作呢?咱们思考一种状况,如旧节点为【5,1,2,3,4】 ,新节点为【1,2,3,4,5】,若是缺少这种判断,意味着须要先将5->1,1->2,2->3,3->4,4->5五 次删除插入操做,即便是有了key-index来复用,也会出现也会出现【5,1,2,3,4】-> 【1,5,2,3,4】->【1,2,5,3,4】->【1,2,3,5,4】->【1,2,3,4,5】共4次操做,若是 有了这种判断,咱们只须要将5插入到旧尾索引后面便可,从而实现右移
oldEndVnode和newStartVnode进行比对,处理和上面相似,只不过改成左移
若是以上状况都失败了,咱们就只能复用key相同的节点了。首先咱们要经过createKeyToOldIdx
建立key-index的映射,若是新节点在旧节点中不存在,咱们将它插入到旧头索引节点前, 而后新头索引向后;若是新节点在就旧节点组中存在,先找到对应的旧节点,而后patch,并将 旧节点组中对应节点设置为undefined,表明已经遍历过了,再也不遍历,不然可能存在重复 插入的问题,最后将节点移位到旧头索引节点以前,新头索引向后
遍历完以后,将剩余的新Vnode添加到最后一个新节点的位置后或者删除多余的旧节点
/** * * @param parentElm 父节点 * @param oldCh 旧节点数组 * @param newCh 新节点数组 * @param insertedVnodeQueue */ 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, idxInOld, elmToMove, before; while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { if (isUndef(oldStartVnode)) { oldStartVnode = oldCh[++oldStartIdx]; // Vnode has been moved left } else if (isUndef(oldEndVnode)) { oldEndVnode = oldCh[--oldEndIdx]; } //若是旧头索引节点和新头索引节点相同, else if (sameVnode(oldStartVnode, newStartVnode)) { //对旧头索引节点和新头索引节点进行diff更新, 从而达到复用节点效果 patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue); //旧头索引向后 oldStartVnode = oldCh[++oldStartIdx]; //新头索引向后 newStartVnode = newCh[++newStartIdx]; } //若是旧尾索引节点和新尾索引节点类似,能够复用 else if (sameVnode(oldEndVnode, newEndVnode)) { //旧尾索引节点和新尾索引节点进行更新 patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue); //旧尾索引向前 oldEndVnode = oldCh[--oldEndIdx]; //新尾索引向前 newEndVnode = newCh[--newEndIdx]; } //若是旧头索引节点和新头索引节点类似,能够经过移动来复用 //如旧节点为【5,1,2,3,4】,新节点为【1,2,3,4,5】,若是缺少这种判断,意味着 //那样须要先将5->1,1->2,2->3,3->4,4->5五次删除插入操做,即便是有了key-index来复用, // 也会出现【5,1,2,3,4】->【1,5,2,3,4】->【1,2,5,3,4】->【1,2,3,5,4】->【1,2,3,4,5】 // 共4次操做,若是有了这种判断,咱们只须要将5插入到最后一次操做便可 else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue); api.insertBefore(parentElm, oldStartVnode.elm, api.nextSibling(oldEndVnode.elm)); oldStartVnode = oldCh[++oldStartIdx]; newEndVnode = newCh[--newEndIdx]; } //原理与上面相同 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]; } //若是上面的判断都不经过,咱们就须要key-index表来达到最大程度复用了 else { //若是不存在旧节点的key-index表,则建立 if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx); //找到新节点在旧节点组中对应节点的位置 idxInOld = oldKeyToIdx[newStartVnode.key]; //若是新节点在旧节点中不存在,咱们将它插入到旧头索引节点前,而后新头索引向后 if (isUndef(idxInOld)) { // New element api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm); newStartVnode = newCh[++newStartIdx]; } else { //若是新节点在就旧节点组中存在,先找到对应的旧节点 elmToMove = oldCh[idxInOld]; //先将新节点和对应旧节点做更新 patchVnode(elmToMove, newStartVnode, insertedVnodeQueue); //而后将旧节点组中对应节点设置为undefined,表明已经遍历过了,不在遍历,不然可能存在重复插入的问题 oldCh[idxInOld] = undefined; //插入到旧头索引节点以前 api.insertBefore(parentElm, elmToMove.elm, oldStartVnode.elm); //新头索引向后 newStartVnode = newCh[++newStartIdx]; } } } //当旧头索引大于旧尾索引时,表明旧节点组已经遍历完,将剩余的新Vnode添加到最后一个新节点的位置后 if (oldStartIdx > oldEndIdx) { before = isUndef(newCh[newEndIdx+1]) ? null : newCh[newEndIdx+1].elm; addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue); } //若是新节点组先遍历完,那么表明旧节点组中剩余节点都不须要,因此直接删除 else if (newStartIdx > newEndIdx) { removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx); } }
至此,snabbdom的主要功能就分析完了