Snabbdom 底层Diff算法详解

Vue2.0Virtual DOM 是基于 Snabbdom 改造的,针对 ChildrenDiff 过程,做了一下详细的过程分析以及演示。

  • 前言

   浏览器在渲染 DOM 元素的时候,是非常‘昂贵的’,在进行 DOM 更新的时候,针对复杂 DOM 的局部更新,为了节省浏览器的开支,避免不必要的更新,这个时候就需要通过Diff 比较来决定更新哪些 DOM 元素,当然,并不是所有的情况都能使 Virtual DOM 比原生 DOM 操作要快,这里我就不过多阐述了,好了,进入主题

1.0 Snabbdom Diff 方式

   Snabbdom 在进行 Diff 比较的时候只针对同层级进行比较,而不会进行跨层级比较,也就是说,我不会管你当前的 Children 是否一致,这里我只关心你和他。Snabbdom 更新DOM的方式是 先移 后添加或删除Diff 比较是双向由外向里进行比较。

Diff方式

为了方便演示 SnabbdomDiff 的过程,这里我以一个 DOM 更新的案例为大家讲解

  假设当前 DOM parentElm 有9个 DOM元素,
  这里我用数组模拟oldCh = [null, "A", "B", "C", "D", "E", "F", "G", null]表示,
  其中数组的每一个元素代表一个子节点DOM,其中两个null,表示已经移除了,这里加入null 的目的是为了后续分析源码流程所使用。

parentElm DOM

  更新后DOM parentElm有11个DOM元素,
  这里我用数组模拟newCh = [null, "A", "F", "E", "M", "O", "I", "E", "B", "G", null]表示,
  其中数组的每一个元素代表一个子节点DOM,其中两个null,表示已经移除了,这里加入null 的目的是为了后续分析源码流程所使用。

2.0 Snabbdom Diff 过程

  首先,我用一张图来解释Snabbdom 在找出差异之前是如何进行Diff 比较的

找出差异前

  Snabbdom 在找出差异之前,每一轮的比较是需要经过8项条件比较的,具体比较【条件】如下:

  【1】.判断 oldCh 第一个节点元素是不是空的,如果是空的表示 DOM 已经移除,接着进行下一轮比较
  【2】.判断 oldCh 最后一个节点元素是不是空的,如果是空的表示 DOM 已经移除,接着进行下一轮比较
  【3】.判断 newCh 第一个节点元素是不是空的,如果是空的表示 DOM 已经移除,接着进行下一轮比较
  【4】.判断 newCh 最后一个节点元素是不是空的,如果是空的表示 DOM 已经移除,接着进行下一轮比较。
  【5】.判断 oldChnewCh 第一个节点元素是不是相同,如果相同我们接着进行下一轮比较
  【6】.判断 oldChnewCh 最后一个节点元素是不是相同,如果相同我们接着进行下一轮比较
  【7】.判断 oldCh 第一个节点元素与 newCh 的最后一个节点元素是否相同,如果相同,就将oldCh 第一个节点元素进行移动,具体如何移动,后面我会详细阐述说明。
  【8】.判断 oldCh 最后一个节点元素与 newCh 的第一个节点元素是否相同,如果相同,就将oldCh 最后一个节点元素进行移动,具体如何移动,后面我会详细阐述说明。

  由于 Diff 比较过程是双向由外向里进行比较,所以为了比较过程这里我们设置几个记录值

  为了记录比较过程这里我们设置几个记录值
  oldSIdx :初始值为0,表示当前oldCh 比较队列的 初始的节点位置
  newSIdx :初始值为0,表示当前newCh 比较队列的 初始的节点位置
  oldEIdx : 初始值为oldCh.length-1,表示当前oldCh 比较队列的末尾的节点位置
  newEIdx : 初始值为newCh.length-1,表示当前newCh 比较队列的末尾的节点位置
  oldSnode :初始值为oldCh[0],表示当前oldCh 比较队列的初始节点
  oldEnode :初始值为oldCh[oldEIdx],表示当前oldCh 比较队列的末尾节点
  newSnode :初始值为newCh[0],表示当前newCh 比较队列的初始节点
  newEnode :初始值为newCh[newEIdx],表示当前newCh 比较队列的末尾节点

  每一轮的Diff 执行条件:oldSIdx <= oldEIdx && newSIdx <= newEIdx,也就是说,当两边的比较有一方的元素已经全部比较完毕,那么Diff中止

  初始状态 :

初始状态

   第1轮Diff 比较:满足条件【1】 oldCh 第一个节点元素是空,执行以下步骤, 根据结果接着进行下一轮比较

  执行: oldSIdx位置加1,oldSnode指向下一个节点> oldSnode=[++oldSIdx]

第1轮执行过程

  第1轮Diff 比较结束后,oldCh 初始记录点oldSIdx索引+1,由0变为1oldSnode指向下一个节点,由null变为A

第1轮比较结束后

   第2轮Diff 比较:满足条件【2】 oldCh 最一个节点元素是空,,执行以下步骤, 根据结果接着进行下一轮比较

  执行: oldEIdx位置减1,oldEnode指向上一个节点> oldEnode=[--oldEIdx]

第2轮执行过程

  第2轮Diff 比较结束后,oldCh 末尾记录点oldEIdx索引-1,由8变为7oldEnode指向下一个节点,由null变为G

第2轮比较结束后

   第3Diff 轮比较:满足条件【3】 newCh 第一个节点元素是空,执行以下步骤, 根据结果接着进行下一轮比较

  执行: newSIdx位置加1,newSnode指向下一个节点> newSnode=[++newSIdx]

第3轮执行过程

  第3轮Diff 比较结束后,newCh 初始记录点newSIdx索引+1,由0变为1newSnode指向下一个节点,由null变为A

第3轮比较结束后

   第4轮Diff 比较:满足条件【4】 newCh 最一个节点元素是空,执行以下步骤, 根据结果接着进行下一轮比较

  执行: newEIdx位置减1,newEnode指向上一个节点> newEnode=[--newEIdx]

第4轮执行过程

  第4轮Diff 比较结束后,newCh 末尾记录点newEIdx索引-1,由10变为9newEnode指向下一个节点,由null变为G

第4轮比较结束后

   第5轮Diff 比较:满足条件【5】 oldChnewCh 第一个节点元素相同,执行以下步骤, 根据结果接着进行下一轮比较

  执行:
  oldSIdx位置加1,oldSnode指向下一个节点> oldSnode=[++oldSIdx]
  newSIdx位置加1,newSnode指向下一个节点> newSnode=[++newSIdx]

第5轮执行过程

  第5轮Diff 比较结束后:
  oldCh 初始记录点oldSIdx索引+1,由1变为2oldSnode指向下一个节点,由A变为B
  newCh 初始记录点newSIdx索引+1,由1变为2newSnode指向下一个节点,由A变为F

第5轮比较结束后

   第6轮Diff 比较:满足条件【6】 oldChnewCh 最后一个节点元素相同,执行以下步骤, 根据结果接着进行下一轮比较

  执行:
  oldEIdx位置减1,oldEnode指向上一个节点> oldEnode=[--oldEIdx]
  newEIdx位置减1,newEnode指向上一个节点> newEnode=[--newEIdx]

第6轮执行过程

  第6轮Diff 比较结束后:
  oldCh 末尾记录点oldEIdx索引-1,由7变为6oldEnode指向上一个节点,由G变为F
  newCh 末尾记录点newEIdx索引-1,由9变为8newEnode指向上一个节点,由G变为B

第6轮比较结束后

   看到这里,不知道各位有没有发现,每一轮的比较,在满足Diff算法的前 6 种判断条件下,父节点parentElm 是没有发生任何变化的,那么,接下来就是见证奇迹的时刻!

   第7轮Diff 比较:满足条件【7】 oldCh 第一个节点元素与 newCh 最后一个节点元素相同,执行以下步骤, 根据结果接着进行下一轮比较

  执行:
  我们首先将当前的oldSnode(B)元素移动插入到当前oldEnode(F)元素的后面
  oldSIdx位置加1,oldSnode指向下一个节点> oldSnode=[++oldSIdx]
  newEIdx位置减1,newEnode指向上一个节点> newEnode=[--newEIdx]

第7轮执行过程

  第7轮Diff 比较结束后:
  parentElm更新DOM,将当前的oldSnode(B)元素移动插入到当前oldEnode(F)元素的后面
  oldCh 初始记录点oldSIdx索引+1,由2变为3oldSnode指向下一个节点,由B变为C
  newCh 末尾记录点newEIdx索引-1,由8变为7newEnode指向上一个节点,由B变为E

第7轮比较结束后

   第8轮Diff 比较:满足条件【8】 oldCh 最后一个节点元素与 newCh 第一个节点元素相同,执行以下步骤, 根据结果接着进行下一轮比较

  执行:
  我们首先将当前的oldEnode(F)元素插入到当前oldSnode(C)元素的前面
  oldEIdx位置减1,oldEnode指向上一个节点> oldEnode=[--oldEIdx]
  newSIdx位置加1,newSnode指向下一个节点> newSnode=[++newSIdx]

第8轮执行过程

  第8轮Diff 比较结束后:
  parentElm更新DOM,将当前的oldEnode(F)元素插入到当前oldSnode(C)元素的前面
  oldCh 末尾记录点oldEIdx索引-1,由6变为5oldEnode指向上一个节点,由F变为E
  newCh 初始记录点newSIdx索引+1,由2变为3newSnode指向下一个节点,由F变为E

第8轮比较结束后

   第9轮Diff 比较:满足条件【6】 oldChnewCh 最后一个节点元素相同,执行以下步骤, 根据结果接着进行下一轮比较

  执行:
  oldEIdx位置减1,oldEnode指向上一个节点> oldEnode=[--oldEIdx]
  newEIdx位置减1,newEnode指向上一个节点> newEnode=[--newEIdx]

第9轮执行过程

  第9轮Diff 比较结束后:
  oldCh 末尾记录点oldEIdx索引-1,由5变为4oldEnode指向上一个节点,由E变为D
  newCh 末尾记录点newEIdx索引-1,由7变为6newEnode指向上一个节点,由E变为I

第9轮比较结束后

   看到这里,经过以上每一轮的比较,基本上前 8 种Diff条件都已经差不多全部执行了,接下来的就是我们找出差异之后的DOM更新了。

   第10轮Diff 比较:前 8 种Diff条件都不满足,则需要新增节点
   执行以下步骤, 根据结果接着进行下一轮比较

  执行:
  parentElm更新DOM,将当前的oldEnode(E)元素插入到当前oldSnode(C)元素的前面
  newSIdx位置+1,newSnode指向下一个节点> newSnode=[++newSIdx]

第10轮执行过程

  第10轮Diff 比较结束后:
  parentElm更新DOM,将当前的oldEnode(E)元素插入到当前oldSnode(C)元素的前面
  newCh 初始记录点newSIdx索引+1,由3变为4newSnode指向下一个节点,由E变为M

第10轮比较结束后

   第11轮Diff 比较:前 8 种Diff条件都不满足,则需要新增节点
   执行以下步骤,根据结果接着进行下一轮比较

  执行:
  parentElm更新DOM,将当前的oldEnode(M)元素插入到当前oldSnode(C)元素的前面
  newSIdx位置+1,newSnode指向下一个节点> newSnode=[++newSIdx]

第11轮执行过程

  第11轮Diff 比较结束后:
  parentElm更新DOM,将当前的oldEnode(M)元素插入到当前oldSnode(C)元素的前面
  newCh 初始记录点newSIdx索引+1,由4变为5newSnode指向下一个节点,由M变为O

第11轮比较结束后

;

   第12轮Diff 比较:前 8 种Diff条件都不满足,则需要新增节点
   执行以下步骤, 根据结果接着进行下一轮比较

  执行:
  parentElm更新DOM,将当前的oldEnode(O)元素插入到当前oldSnode(C)元素的前面
  newSIdx位置+1,newSnode指向下一个节点> newSnode=[++newSIdx]

第12轮执行过程

  第12轮Diff 比较结束后:
  parentElm更新DOM,将当前的oldEnode(O)元素插入到当前oldSnode(C)元素的前面
  newCh 初始记录点newSIdx索引+1,由5变为6newSnode指向下一个节点,由M变为O

第12轮比较结束后

   第13轮Diff 比较:前 8 种Diff条件都不满足,则需要新增节点
   执行以下步骤, 根据结果接着进行下一轮比较

  执行:
  parentElm更新DOM,将当前的oldEnode(I)元素插入到当前oldSnode(C)元素的前面
  newSIdx位置+1,newSnode指向下一个节点> newSnode=[++newSIdx]

第13轮执行过程

  第13轮Diff 比较结束后:
  parentElm更新DOM,将当前的oldEnode(I)元素插入到当前oldSnode(C)元素的前面
  newCh 初始记录点newSIdx索引+1,由6变为7newSnode指向下一个节点,由I变为E
  此时 newSIdx > newEIdx,已经不能满足下一轮Diff 比较的条件,Diff 比较比较结束

第13轮最后一次比较结束

  经过13轮的新旧子节点Diff比较,parentElm通过将旧节点移动,新节点增加,进行了DOM的更新,接下来就是根据最终oldChnewCh剩余情况进行parentElm的删除或者新增。
  如果当前newCh[newSIdx,newEIdx]有剩余,说明还有需要新增的元素,那么我们根据剩余的节点区间,依次插入到最终比较的newEnode后面。
  如果当前oldCh[oldSIdx,oldEIdx]有剩余,说明这些元素是需要删除的,那么我们根据剩余的节点区间,依次删除。

old节点删除过程

最终DOM局部更新完成

3.0 代码模拟过程

基于Snabbdom,通过数组来表示DOM节点元素,这里我稍微改造了一下,基于数组操作来模拟DOM的更新


/**
 * author:Echonessy
 * des:
 * date:2020.07.05
 * target: Diff 算法模拟
 * */


let oldCh = [null,"A","B","C","D","E","F","G",null];
let newCh = [null,"A","F","E","M","O","I","E","B","G",null];
// let oldCh = [null,"A","B","C","D","E","F","G",null];
// let newCh = [null,"A","F","C","D","E","M","O","I","E","B","G",null];
let parentElm = oldCh.slice();
diff(parentElm,oldCh,newCh);
// 判断节点是否相同
export function same(vnode1, vnode2) {
    return vnode1 === vnode2
}

// 模拟DOM节点插入与移动
function insertBerfore(parent,newNode,referenceNode,order) {
    switch (order) {
        case 'left':parent.splice(parent.indexOf(referenceNode),0,newNode);parent.splice(parent.indexOf(newNode.split('-')[1]),1);break;
        case 'right':parent.splice(parent.indexOf(referenceNode),0,newNode);parent.splice(parent.lastIndexOf(newNode.split('-')[1]),1);break;
        case 'diff':parent.splice(parent.indexOf(referenceNode.indexOf('-')!=-1?referenceNode.split('-')[1]:referenceNode),0,newNode);break;
        case 'add':parent.splice(parent.lastIndexOf(referenceNode),0,newNode);break;
    }
}

// 移除vNode
export function removeVnodes(parentElm,vnodes,startIdx,endIdx){
    // 循环vnodes
    for (; startIdx <= endIdx; ++startIdx) {
        let ch = vnodes[startIdx];
        if (ch != null) {
            var index = parentElm.indexOf(ch);
            parentElm.splice(index, 1)
        }
    }
}
// 添加vNode
function addVnodes(parentElm,before,vnodes,startIdx,endIdx) {
    for (; startIdx <= endIdx; ++startIdx) {
        const ch = vnodes[startIdx];
        if (ch != null) {
            insertBerfore(parentElm,'new-'+ch,before,'add')
        }
    }
}
// diff 过程
function diff(parentElm,oldCh,newCh) {
    console.log('初始')
    console.log(oldCh)
    console.log(newCh)
    console.log(parentElm)
    let oldSIdx = 0, newSIdx = 0;
    let oldEIdx = oldCh.length - 1;
    let oldSnode = oldCh[0];
    let oldEnode = oldCh[oldEIdx];
    let newEIdx = newCh.length - 1;
    let newStnode = newCh[0];
    let newEnode = newCh[newEIdx];
    let conunt = 0; // 计数器,记录循环次数
    // 两数组比较结束之前,循环调用Diff
    while (oldSIdx <= oldEIdx && newSIdx <= newEIdx){
        if (oldSnode == null) {
            oldSnode = oldCh[++oldSIdx];
        } else if (oldEnode == null) {
            oldEnode = oldCh[--oldEIdx];
        } else if (newStnode == null) {
            newStnode = newCh[++newSIdx];
        } else if (newEnode == null) {
            newEnode = newCh[--newEIdx];
        } else if (same(oldSnode, newStnode)){
            oldSnode = oldCh[++oldSIdx];
            newStnode = newCh[++newSIdx];
        } else if (same(oldEnode, newEnode)) {
            oldEnode = oldCh[--oldEIdx];
            newEnode = newCh[--newEIdx];
        }else if (same(oldSnode, newEnode)) {
            insertBerfore(parentElm,'old-'+oldSnode,oldCh[oldEIdx+1],'left');
            oldSnode = oldCh[++oldSIdx];
            newEnode = newCh[--newEIdx];
        }else if (same(oldEnode, newStnode)) {
            insertBerfore(parentElm,'old-'+oldEnode,oldCh[oldSIdx],'right');
            oldEnode = oldCh[--oldEIdx];
            newStnode = newCh[++newSIdx];
        } else {
            insertBerfore(parentElm,'new-'+newStnode,oldCh[oldSIdx],'diff');
            newStnode = newCh[++newSIdx];
        }
        console.log('------------------------------------------------------------------')
        console.log('第'+ (++conunt) + '次对比,oldSIdx:'+oldSIdx + ' oldEIdx:' +oldEIdx + ' newSIdx:'+newSIdx+ ' newEIdx:'+newEIdx )
        console.log(oldCh.slice(oldSIdx,oldEIdx+1))
        console.log(newCh.slice(newSIdx,newEIdx+1))
        console.log(parentElm);
    }
    // 最后比较完成之后
    if (oldSIdx <= oldEIdx || newSIdx <= newEIdx) {
        console.log('Diff 对比结束')
        console.log(oldCh)
        console.log(newCh)
        if (oldSIdx > oldEIdx) {
            // 'old-'用来模拟区分新旧节点
            console.log('新增节点')
            let before =newCh[newEIdx+1] == null ? null : 'old-'+ newCh[newEIdx+1];
            addVnodes(parentElm, before, newCh, newSIdx, newEIdx);
        } else {
            console.log('移除节点')
            removeVnodes(parentElm, oldCh, oldSIdx, oldEIdx);
        }
        console.log('模拟DOM更新完成')
        console.log(parentElm);
    }
}

4.0 附件

Snabbdom 源代码地址:https://github.com/snabbdom/snabbdom

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 196,165评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,503评论 2 373
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 143,295评论 0 325
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,589评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,439评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,342评论 1 273
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,749评论 3 387
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,397评论 0 255
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,700评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,740评论 2 313
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,523评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,364评论 3 314
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,755评论 3 300
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,024评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,297评论 1 251
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,721评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,918评论 2 336