最近想了解一下React和Vue框架分别在virtual dom部分的实现,以及他们的不一样之处。因而先翻开Vue的源码去找virtual dom 的实现,看到开头,它就提到了Vue的virtual dom更新算法是基于Snabbdom实现的。因而,又去克隆了Snabbdom的源码,发现它的源码并非很复杂而且星星🌟还不少,因此就仔细看了一遍了,这里就将详细学习一下它是如何实现virtual dom的。javascript
在Snabbdom的GitHub上就解释了,它是一个实现virtual dom的库,简单化,模块化,以及强大的特性和性能。html
A virtual DOM library with focus on simplicity, modularity, powerful features and performance.vue
这里是Snabbdom的仓库地址。java
Snabbdom的简单是基于它的模块化,它对virtual dom的设计很是巧妙,在核心逻辑中只会专一于vNode的更新算法计算,而把每一个节点具体要更新的部分,好比props
,class
,styles
,datalist
等放在独立的模块里,经过在不一样时机触发不一样module的钩子函数去完成。经过这样的方式解耦,不只可使代码组织结构更加清晰,更可使得每一部分都专一于实现特定的功能,在设计模式中,这个也叫作单一职责原则。在实际场景使用时,能够只引入须要用到的特定模块。好比咱们只会更新节点的类名和样式,而不关心属性以及事件,那么就只须要引用class和style的模块就能够了。例以下面这样,node
// 这里咱们只须要用到class和style模块,因此就能够只须要引用这2个模块
var patch = snabbdom.init([
require('snabbdom/modules/class').default,
require('snabbdom/modules/style').default,
]);
复制代码
它的核心方法就是这个init
,咱们先来简单看一下这个函数的实现,react
//这里是module中的钩子函数
const hooks = ['create', 'update', 'remove', 'destroy', 'pre', 'post'];
export function init(modules:Array<Partial<Module>>, domApi?:DOMAPI){
let i:number, j:number, cbs = ({} as ModuleHooks);
const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi;
//cbs存储了引入的modules中定义的钩子函数,
for(i = 0; i < hooks.length; ++i){
cbs[hooks[i]] = [];
for(j = 0; j < modules.length; ++j){
const hook = modules[j][hooks[i]];
if(hook !== undefined){
cbs[hooks[i]].push(hook);
}
}
}
//还定义了一些其余的内部方法,这些方法都是服务于patch
function emptyNodeAt(){/.../};
function createRmCb(){/.../};
function createElm(){/.../};
function addVnodes(){/.../};
function invokeDestroyHook(){/.../};
function removeVnodes(){/.../};
function updateChildren(){/.../};
function patchVnode(){/.../};
//init返回了一个patch函数,这个函数接受2个参数,第一个是将被更新的vNode或者真实dom节点,第二个是用来更新的新的vNode
return function patch(oldVnode: VNode | Element,vnode:VNode):VNode{
//...
}
}
复制代码
从init
函数总体来看,它接受一个modules数组,返回一个新的函数patch
。这不就是咱们熟悉的闭包函数吗?在init
中,它会将引入模块的钩子函数经过遍历存储在cbs
变量里,后面在执行更新算法时会相应的触发这些钩子函数。只须要初始化一次,后面virtual dom的更新都是经过patch
来完成的。git
流程图以下,github
最为复杂也最为耗时的部分就是如何实现virtual dom的更新,更新算法的好坏直接影响整个框架的性能,好比React中的react-reconciler模块,到vue中的vdom模块,都是最大可能优化这一部分。在Snabbdom中virtual dom的更新逻辑大体以下,算法
//这个patch就是init返回的
function patch(oldVnode,vnode){
//第一步:若是oldVnode是Element,则根据Element建立一个空的vnode,这个也是vnode tree的根节点
if(!isVnode(oldVnode)){
oldVnode = emptyAtNode(oldVnode);
}
//第二步:判断oldVnode是否与vnode相同的元素,若是是,则更新元素便可。这里判断它们是否相同,是对比了它们的key相同且tagName相同且ID属性相同且类相同
if(sameVnode(oldVnode,vnode)){
patchVnode(oldVnode,vnode);
}else{
//第三步:若是不相同,则直接用vnode建立新的element元素替换oldVnode,且删除掉oldVnode。
elm = oldVnode.elm;
parent = api.parentNode(elm);
createElm(vnode);
if(parent !== null){
api.insertBefore(parent,vnode.elm,api.nextSlibing(elm));
removeVnodes(parent,[oldVnode], 0, 0);
}
}
}
复制代码
patch
逻辑能够简化为下面:typescript
patchVnode
流程图以下,
在进行第3步时,当oldVnode与vnode不相同,是直接抛弃了旧的节点,建立新的节点来替换,在用新vnode来建立节点时会检查当前vnode有没有children,若是有,则也会遍历children建立出新的element。这意味oldVnode以及包含的全部子节点将被做为一个总体被新的vnode替换。示意图以下,
若是B与B'不相同,则B在被B'替换的过程当中,B的子节点D也就被B'的子节点D'和E'一块儿替换掉了。
咱们再来看看第2步,若是oldVnode与vnode相同,则会复用以前已经建立好的dom,只是更新这个dom上的差别点,好比text,class,datalist,style等。这个是在函数patchVnode
中实现的,下面为它的大体逻辑,
function patchVnode(oldVnode,vnode){
const elm = oldVnode.elm; //获取oldVnode的dom对象
vnode.elm = elm; //将vnode的elm直接指向elm,复用oldVnode的dom对象,由于它们类型相同
//若是oldVnode与vnode相等,则直接返回,根本不用更新了
if(oldVnode === vnode){
return;
}
//若是vnode是包含text,且不等于oldVnode.text,则直接更新elm的textContent为vnode.text
if(isDef(vnode.text) && vnode.text !== oldVnode.text){
return api.setTextContext(elm,vnode.text);
}
let oldCh = oldVnode.children; //获取oldVnode的子节点
let ch = vnode.children; //获取vnode的子节点
//若是oldVnode没有子节点,而vnode有子节点,则添加vnode的子节点
if(isUndef(oldCh) && isDef(ch)){
// 若是oldVnode有text值,则先将elm的textContent清空
if(idDef(oldVnode.text)){
api.setTextContext(elm,'');
}
addVnodes(elm,null,ch,0,ch.length-1);
}
//若是oldVnode有子节点,而vnode没有子节点,则删除oldVnode的子节点
else if(isUndef(ch) && isDef(oldCh)){
reoveVnodes(elm,oldCh,0,oldCh.length-1)
}
//若是它们都有子节点,而且子节点不相同,则更新它们的子节点
else if(ch !== oldCh){
updateChildren(elm,oldCh,ch);
}
//不然就是它们都有子节点,且子节点相同,若是oldVnode有text值,则将elm的textContent清空
else if(ifDef(oldVnode.text)){
api.setTextContext(elm,'');
}
}
复制代码
patchVnode
逻辑能够简化为下面:
流程图以下,
在patchVnode
更新时,vnode会先是经过触发定义在data数据上的钩子函数来更新本身节点上的信息,好比class或者styles等,而后再去更新children节点信息。
更新vnode.children信息是经过updateChildren
函数来完成的。只有当oldVnode上存在children,且vnode上也存在children时,而且oldVnode.children !== vnode.children
时,才会去调用updateChildren
。下面来梳理一下updateChildren
的大体逻辑,
function updateChildren(parentElm,oldCh,newCh){
// 旧的children
let oldStartIdx = 0;
let oldEndIdx = oldCh.length-1;
let oldStartVnode = oldCh[oldStartIdx];
let oldEndVnode = oldCh(oldEndIdx);
// 新的children
let newStartIdx = 0;
let newEndIdx = newCh.length-1;
let newStartVnode = newCh(newStartIdx);
let newEndVnode = newCh(newEndIdx);
let before = null;
// 循环比较
while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx){
if(oldStartVnode == null){
// 当前节点可能被移动了
oldStartVnode = oldCh[++oldStartIdx];
}else if(oldEndVnode == null){
oldEndVnode = oldCh[--oldEndIdx];
}else if(newStartVnode == null){
newStartVnode = newCh[++newStartIdx];
}else if(newEndVnode == null){
newEndVnode = newCh[--newEndIdx];
}else if(sameVnode(oldStartVnode,newStartVnode)){
patchVnode(oldStartVnode,newStartVnode); // 更新newStartVnode
oldStartVnode = oldCh[++oldStartIdx]; // oldStartIdx 向右移动
newStartVnode = newCh[++newStartIdx]; // newStartIdx 向右移动
}else if(sameVnode(oldEndVnode,newEndVnode)){
patchVnode(oldEndVnode,newEndVnode); // 更新newEndVnode
oldEndVnode = oldCh[--oldEndIdx]; // oldEndIdx 向左移动
newEndVnode = newCh[--newEndIdx]; // newEndIdx 向左移动
}else if(sameVnode(oldStartVnode,newEndVnode)){
patchVnode(oldStartVnode,newEndVnode); //更新newEndVnode
let oldAfterVnode = api.nextSibling(oldEndVnode);
// 将oldStartVnode移动到当前oldEndVnode后面
api.insertBefore(parentElm, oldStartVnode.elm,oldAfterVnode);
oldStartVnode = oldCh[++oldStartIdx]; // oldStartIdx 向右移动
newEndVnode = newCh[--newOldVnode]; // newEndIdx 向左移动
}else if(sameVnode(oldEndVnode,newStartVnode)){
patchVnode(oldEndVnode,newStartVnode); // 更新newStartVnode
//将oldEndVnode移动到oldStartVnode前面
api.insertBefore(parentElm,oldEndVnode.elm,oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx]; // oldEndVnode 向右移动
newStartVnode = newCh[++newStartIdx]; // newStartVnode 向左移动
}else{
//获取当前旧的children的节点的key与其index的对应值,
if(oldKeyIdx == undefined){
oldKeyIdx = createKeyToOldIdx(oldCh,oldStartIdx,oldEndIdx);
}
//获取当前newStartVnode的key是否存在旧的children数组里
idxInOld = oldKeyIdx[newStartVnode.key];
if(isUndef(idxInOld)){
//若是当前newStartVnode的key不存在旧的children数组里,那么这个newStartVnode就是新的,须要新建dom
let newDom = createElm(newStartVnode);
api.insertBefore(parentElm,newDom,oldStartVnode.elm);
newStartVnode = newCh[++newStartIdx];
}else{
//不然,当前newStartVnode的key存在旧的children里,说明它们以前是同一个Vnode,
elmToMove = oldCh[idxInOld];
if(elmToMove.sel !== newStartVnode.sel){
//节点类型变了,不是同一个类型的dom元素了,也是须要新建的
let newDom = createElm(newStartVnode);
api.insertBefore(parentElm,newDom,oldStartVnode.elm);
}else{
// 不然,它们是同一个Vnode且dom元素也相同,则不须要新建,只须要更新便可
patchVnode(elmToMove,newStartVnode);
oldCh[idxInOld] = undefined; // 标志旧的children当前位置的元素被移走了,
api.insertBefore(parentElm,elmToMove,oldStartVnode.elm);
}
newStartVnode = newCh[++newStartIdx];
}
}
}
// 若是循环以后,还有未处理的children,
if(oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx){
// 若是新的children还有部分未处理,则把多的部分增长进去
if(oldStartIdx > oldEndIdx){
before = newCh[newEndIdx+1] == null ? null : newCh[newEndIdx+1];
addVnodes(parentElm,before,newCh,newStartIdx,newEndIdx);
}else{
//若是旧的children还有未处理,则把多的部分删除掉
removeVnodes(parentElm,oldCh,oldStartIdx,oldEndIdx);
}
}
}
复制代码
updateChildren
函数逻辑能够简化为,
patchVnode
更新,当类型不一样时,则直接新建new vnode的dom 元素,并插入到合适的位置流程图以下,
在updateChildren
函数中,逐个更新children中节点时,当比较的两个节点类型相同时,又会反过来调用patchVnode
来更新节点,这样,实际上存在了间接的递归调用。
在使用React或者Vue时,你会发现它们都分别定义了组件的生命周期方法,虽然名称或触发时机不彻底相同,可是基本的顺序和目的是差很少的。Snabbdom也提供了相应的生命周期钩子函数,不一样的是它提供了2套,一套是针对virtual dom 的,好比一个Vnode的create
,update
,remove
等;一套是针对modules的,经过在不一样时机触发不一样module的钩子函数去完成当前Vnode的更新操做。
modules的上的钩子函数以下,
export interface Module {
pre: PreHook;
create: CreateHook;
update: UpdateHook;
destroy: DestroyHook;
remove: RemoveHook;
post: PostHook;
}
复制代码
它的触发时机图以下,
在触发modules的hooks函数时,不一样的函数会接受不一样的参数,下面为modukes中钩子函数接受参数状况,
Name | Triggered when | Arguments to callback |
---|---|---|
pre |
在patch 函数开始处 |
无 |
create |
在createElm 函数中建立一个element时 |
vnode |
update |
在pathVnode 函数中更新Vnode时, |
oldVnode ,newVnode |
destroy |
在removeVnodes 函数中移除Vnode时, |
vnode |
remove |
在removeVnodes 函数中移除Vnode时, |
vnode ,removeCallback |
post |
在patch 函数最后处, |
无 |
大部分module中都没有定义pre
函数和post
函数,主要是在create
,update
, destory
,remove
中对当前Vnode进行操做。好比,在class module中在create
函数内对Vnode上的操做以下,
// class modules 中在create钩子函数中对当前Vnode操做
function updateClass(oldVnode: VNode, vnode: VNode): void {
var cur: any, name: string, elm: Element = vnode.elm as Element,
oldClass = (oldVnode.data as VNodeData).class,// 旧的class
klass = (vnode.data as VNodeData).class; // 新的class
if (!oldClass && !klass) return; // 都不存在class,直接返回
if (oldClass === klass) return; // 相等,直接返回
oldClass = oldClass || {};
klass = klass || {};
// 删除那些存在oldVnode上而不存在vnode上的
for (name in oldClass) {
if (!klass[name]) {
elm.classList.remove(name);
}
}
// 遍历当前vnode上的class,
for (name in klass) {
cur = klass[name];
//若是不想等
if (cur !== oldClass[name]) {
// 若是值为true,则添加class,不然移除class
(elm.classList as any)[cur ? 'add' : 'remove'](name);
}
}
}
复制代码
其余module的其余hook函数也都会对当前vnode更新,这里就不一一列举了。
咱们再来看看对Vnode上的钩子函数以下,
export interface Hooks {
init?: InitHook;
create?: CreateHook;
insert?: InsertHook;
prepatch?: PrePatchHook;
update?: UpdateHook;
postpatch?: PostPatchHook;
destroy?: DestroyHook;
remove?: RemoveHook;
}
复制代码
它的触发时机以及接受参数状况以下,
Name | Triggered when | Arguments to callback |
---|---|---|
init |
在createElm 时会先触发init |
vnode |
create |
在createElm 时,已经建好了element,已经对应的children都建立完毕,以后在触发create |
emptyVnode ,vnode |
insert |
当vnode.elm 已经更新到dom文档上了,最后在patch 函数结尾处触发 |
vnode |
prepatch |
在patchVnode 开始处就触发了prepatch |
oldVnode ,vnode |
update |
在patchVnode 中,vnode.elm=oldVnode.elm 以后,更新children以前触发 |
oldVnode ,vnode |
postpatch |
在patchVnode 中结尾处,已经更新为children后触发, |
oldvnode ,vnode |
destroy |
在removeVnodes 中触发,此时尚未被移除 |
vnode |
remove |
在removeVnodes 中,destroy 以后触发,此时尚未真正被移除,需调用removeCallback 才真正将element移除 |
vnode ,removeCallback |
在Vnode
上的钩子函数就是咱们本身定义的了,定义在data.hooks
中,例如,
h('div.row', {
key: movie.rank,
hook: {
insert: (vnode) => { movie.elmHeight = vnode.elm.offsetHeight; }
}
});
复制代码
在看了源码以后,其实最为复杂的地方就是updateChildren
中更新子节点,这里为了不重复建立element,而作了不少的判断和比较,以达到最大化的复用以前已经建立好的element。与React和Vue相似,它在比较中也添加了key
来优化这一点。在更新Vnode对应的element时,它将不一样数据分解到不一样module中去更新,经过钩子函数来触发,这一点很是的优雅。