总共写了四篇文章(都是本身的一些拙见,仅供参考,请多多指教,我这边也会持续修正加更新)vue
这篇我将以本身的思路去解读一下源码(这里的源码我为了兼容IE8有做修改);
node
经过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函数进行渲染segmentfault
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函数的主要工做就是把传入的参数封装为vnodeapi
接下来看一下,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、hook children 子元素数组 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:app
关于elm这个值后面再说dom
利用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; };
流程图:
这基本上就是一个对比的大致过程,值得研究的东西还在后面,涉及到了其核心的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,渲染新的vnode3.若是值得,调用模块钩子函数,对其节点的属性进行替换,例如style,event等;再判断节点子节点是否为文本节点,若是为文本节点则进行更替,若是还存在其余子节点则调用updateChildren,对子节点进行更新,更新流程将会回到第一步,重复;