共计 11286 个字符,预计需要花费 29 分钟才能阅读完成。
总共写了四篇文章(都是自己的一些拙见,仅供参考,请多多指教,我这边也会持续修正加更新)
介绍一下 snabbdom 基本用法
介绍一下 snabbdom 渲染原理
介绍一下 snabddom 的 diff 算法和对 key 值的认识
介绍一下对于兼容 IE8 的修改
这篇我将以自己的思路去解读一下源码 (这里的源码我为了兼容 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、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:
关于 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— 广度优先遍历
广度优先遍历从某个顶点出发,首先访问这个顶点,然后找出这个结点的所有未被访问的邻接点,访问完后再访问这些结点中第一个邻接点的所有结点,重复此方法,直到所有结点都被访问完为止。
这里 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,渲染新的 vnode3. 如果值得,调用模块钩子函数,对其节点的属性进行替换,例如 style,event 等;再判断节点子节点是否为文本节点,如果为文本节点则进行更替,如果还存在其他子节点则调用 updateChildren,对子节点进行更新,更新流程将会回到第一步,重复;