snabbdom源码学习
kekobin opened this issue · comments
简介
读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。看到这里疑问来了:
- h是干嘛用的?
- 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示例
以下面两颗新旧节点树为示例进行说明:
1.round-1: 边界情况都不满足,调用createKeyToOldIdx创建key-index映射对象,判断newStartVnode是否在oldCh中间。很明显存在,对应上面的 condition 11,实际的dom需要把B移动到A前面,旧虚拟Dom里需要把B置为undefined:
注意,此时newStartIdx加了一位,但oldStartIdx还是没变的,因为它指向的node并没有任何变动。
2.round-2: 很明显,当前的oldStartVnode和newStartVnode相同,对应上面的condition 5。直接比对差异,oldStartIdx和newStartIdx各加1:
3.round-3: oldStartVnode为null,对应condition 1,oldStartIdx加一位:
6.很明显,旧节点先遍历完了,对应上面的 condition 12,添加剩下的所有未遍历到的新节点。
至此,代码注释加上图例,应该很清楚的说明了整个updateChildren的过程了。
参考
list-diff
深度剖析:如何实现一个 Virtual DOM 算法
simple-virtual-dom
深入剖析:Vue核心之虚拟DOM
snabbdom源码分析
解析 snabbdom 源码,教你实现精简的 Virtual DOM 库
Virtual DOM 的内部工作原理