我的前端自学笔记 => Virtual DOM 的实现原理

485 阅读12分钟

导读

今天分享一下学习的内容: Virtual DOM 的实现原理

一、虚拟DOM

1、什么是虚拟DOM

Virtual DOM (虚拟DOM),是由普通的JS对象来描述DOM对象,因为不是真实的DOM对象,所以叫虚拟DOM

2、为什么使用虚拟DOM

手动操作 DOM 比较麻烦,还需要考虑浏览器兼容性问题,虽然有 jQuery 等库简化 DOM 操作,但是随着项目的复杂 DOM 操作复杂提升,为了简化 DOM 的复杂操作于是出现了各种 MVVM 框架,MVVM 框架解决了视图和状态的同步问题

为了简化视图的操作我们可以使用模板引擎,但是模板引擎没有解决跟踪状态变化的问题,例如数据发生变化时,无法获取上一次的状态,只能删除重新创建。于是Virtual DOM 出现了

Virtual DOM 的好处是当状态改变时不需要立即更新 DOM,只需要创建一个虚拟树来描述 DOM,

Virtual DOM 内部将弄清楚如何有效(diff)的更新 DOM

虚拟 DOM 可以维护程序的状态,跟踪上一次的状态

虚拟DOM的作用

  1. 维护视图和状态的关系:虚拟DOM可以记录上一次DOM的状态变化,只更新变化的位置

  2. 复杂视图情况下提升渲染性能:与传统DOM相比,在性能上虚拟DOM的性能更好

  3. 除了渲染DOM之外,还可以实现SSR服务端渲染(Nuxt.js/Next.js)、原生应用(Weex/React Native)、小程序(mpvue/uni-app)等

常用虚拟DOM库

常用的虚拟DOM库有Snabbdom、virtual-dom

Snabbdom

由于vue2版本的虚拟DOM是基于Snabbdom来实现的,所以下面我们来学习一下Snabbdom。首先定义一个html文件

// index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>snabbdom-demo</title>
</head>
<body>
    <div id="app"></div>
    <script src="./src/basicusage.js"></script>
</body>
</html>

导入Snabbdom

在官方文档中,Snabbdom是通过commonjs规范导入,这里我们通过import的方式去导入,注意:这里我们的snabbdom的版本是0.7.4,新版本通过import导入会出现Cannot resolve dependency 'snabbdom'这种问题,这里通过0.7.4版本进行演示和学习

// 导入
// import snabbdom from 'snabbdom'
import { h, thunk, init } from 'snabbdom'

可以看到,通过import导入snabbdom,不能直接通过第一种方式导入,这是因为在snabbdom源码中,没有通过export default的方式导出对象,所以只能通过大括号的形式导出。Snabbdom 的核心仅提供最基本的功能,只导出了三个函数 init()、h()、thunk()

init() 是一个高阶函数,返回 patch();

h() 返回虚拟节点 VNode,这种方式在vue中很常见,之前的响应式原理中也使用过h函数;

thunk() 是一种优化策略,可以在处理不可变数据时使用

snabbdom的使用

import { h, thunk, init } from 'snabbdom'

// 1. hello world
// 参数:数组,模块
// 返回值: patch函数,对比两个vnode的差异更新到真实DOM中
let patch = init([])
// 第一个参数:标签+选择器
// 第二个参数:如果是字符串就是标签中的内容
let vnode = h('div#container.cls', 'Hello World')

let app = document.querySelector('#app')
// 第一个参数:可以是DOM元素,内部会把DOM元素转换成vnode
// 第二个参数:vnode
// 返回值:VNode
let oldVnode = patch(app, vnode)

这里演示第一个案例,在这个案例,首先他们通过snabbdom中的init函数初始化了patch函数,随后通过h函数创建了一个id为container,class为cls的div元素,这个元素为创建的vnode,内容为Hello World,并且获取到了#app的dom元素,随后通过初始化的patch函数对两个vnode进行对比,patch中的第一个参数为Dom,patch函数会自动将DOM元素转换为vnode,并更新到视图上。通过这个案例,可以在index.html中发现#app的位置,内容已经更改为Hello World

这里我们假设一个情景,我们在某一时刻需要从服务器中获取数据,随后传入到页面中进行展示

// 假设的时刻

vnode = h('div', 'Hello Snabbdom')

patch(oldVnode, vnode)

这里我们对上一次patch函数处理过后的vnode进行了保存,保存到了oldVnode中,随后将原vnode的内容通过h函数进行修改,再通过patch函数进行比较,并将数据更新到视图上。

上述案例是创建了一个div节点,当我们想要创建的虚拟dom节点中有子节点,我们需要这么做

import { h, thunk, init } from 'snabbdom'

// 2. div中放置子元素 h1,p

let patch = init([])

let vnode = h('div#container', [
    h('h1', 'Hello Snabbdom'),
    h('p', 'Hello P')
])

let app = document.querySelector('#app')

let oldVnode = patch(app, vnode)

setTimeout(() => {
    // 清空页面元素
    // patch(oldVnode, null)
    patch(oldVnode, h('!'))
}, 2000)

这里主要区别在于,创建vnode时,h函数的第二个参数为一个数组,数组中为所有子节点及其内容,同时我们通过patch函数对其进行对比并渲染在页面中。当我们想要对页面中指定位置的内容进行清空,可以通过将patch函数中的第二个参数设置为h('!'),通过h函数设置注释节点,更改对应的内容。(官网中通过传null为第二个参数,这个做法是错误的,页面会报错)

snabbdom 模块

snabbdom中提供了6个常用的模块

当然我们可以自己拓展自己想要的模块

模块的使用

模块的使用步骤:

1.导入需要的模块

2.init()函数中注册模块

3.使用h函数创建VNode时,可以把第二个参数设置为对象,其余参数往后挪
import { h, thunk, init } from 'snabbdom'
// 导入模块
import style from 'snabbdom/modules/style'
import eventlisteners from 'snabbdom/modules/eventlisteners'
// 注册模块
let patch = init([
    style,
    eventlisteners
])
// 使用h函数的第二个参数传入模块需要的数据
let vnode = h('div', {
    style: {
        backgroundColor: 'red'
    },
    on: {
        click: eventHandler
    }
}, [
    h('h1', 'Hello Snabbdom'),
    h('p', 'Hello P')
])

let app = document.querySelector('#app')

patch(app, vnode)

function eventHandler() {
    console.log('点击我了!~')
}

这里我们通过import导入模块,模块的位置在snabbdom/modules中,随后通过init()函数,在数组中传入导入的模块,实现对模块的注册,随后在h函数中,将第二个参数传入模块需要的数据,这里传入了style和on,这两个数据都为对象,最后通过patch进行两个vnode的对比并渲染页面,页面效果如下

snabbdom 源码分析

snabbdom的核心是使用h函数创建VNode,通过init设置模块并创建patch,利用patch函数比较两个vnode,并将内容更新到dom中,所以我们主要分析一下这三个ts文件

h函数

最开始我们在vue中见过h函数,不过相对于snabbdom,vue对h函数实现了组件的机制

new Vue({
	router,
    store,
    render:h => h(App)
}).$mount('#app')

h函数最早见于hyperscript,使用javascript创建超文本

在snabbdom中,h函数不是用来创建超文本,而是创建VNode

函数重载

javascript是不支持函数的重载的,而typescript中有重载

我们来看一下源码,这里我将源码进行了注释,方便理解

import {vnode, VNode, VNodeData} from './vnode';
export type VNodes = Array<VNode>;
export type VNodeChildElement = VNode | string | number | undefined | null;
export type ArrayOrElement<T> = T | T[];
export type VNodeChildren = ArrayOrElement<VNodeChildElement>
import * as is from './is';

function addNS(data: any, children: VNodes | undefined, sel: string | undefined): void {
  data.ns = 'http://www.w3.org/2000/svg';
  if (sel !== 'foreignObject' && children !== undefined) {
    for (let i = 0; i < children.length; ++i) {
      let childData = children[i].data;
      if (childData !== undefined) {
        addNS(childData, (children[i] as VNode).children as VNodes, children[i].sel);
      }
    }
  }
}
// h 函数的重载
export function h(sel: string): VNode;
export function h(sel: string, data: VNodeData): VNode;
export function h(sel: string, children: VNodeChildren): VNode;
export function h(sel: string, data: VNodeData, children: VNodeChildren): VNode;
export function h(sel: any, b?: any, c?: any): VNode {
  var data: VNodeData = {}, children: any, text: any, i: number;
  // 三个参数的情况
  if (c !== undefined) {
    data = b;
    // array 说明有子元素(子节点)
    if (is.array(c)) { children = c; }
    // string or number
    else if (is.primitive(c)) { text = c; }
    // vnode
    else if (c && c.sel) { children = [c]; }
  } 
  // 两个参数
  else if (b !== undefined) {
    // array 说明有子元素(子节点)
    if (is.array(b)) { children = b; }
    // string or number
    else if (is.primitive(b)) { text = b; }
    // vnode
    else if (b && b.sel) { children = [b]; }
    else { data = b; }
  }
  if (children !== undefined) {
    // 处理children中的原始值(string/number)
    for (i = 0; i < children.length; ++i) {
      // 创建虚拟文本节点
      if (is.primitive(children[i])) children[i] = vnode(undefined, undefined, undefined, children[i], undefined);
    }
  }
  if (
    sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g' &&
    (sel.length === 3 || sel[3] === '.' || sel[3] === '#')
  ) {
    // svg 添加命名空间
    addNS(data, children, sel);
  }
  // 返回VNode
  return vnode(sel, data, children, text, undefined);
};
export default h;

源码中通过函数重载的方式,定义了参数不同的同名函数,在下面的函数实现中,首先对第三个参数进行判断,如果存在,说明传入了三个参数,将第二个参数b传入data中,随后对c进行判断,判断c的类型为数组、字符串、数字或者vnode,并根据判断结果将对应的children或者text进行赋值。若第三个参数c不存在,判断b是否存在,若存在,按照判断c的方式进行判断,在最后,判断children是否为空,并处理children中的原始值合兵创建虚拟文本节点,下面对svg进行判断,并添加命名空间,addNS方法在上面,通过循环调用addNS方法,实现对svg中的子节点添加命名空间,随后通过vnode函数返回

vnode函数

在h函数的最后调用了vnode方法,我们来看一下vnode方法

import {Hooks} from './hooks';
import {AttachData} from './helpers/attachto'
import {VNodeStyle} from './modules/style'
import {On} from './modules/eventlisteners'
import {Attrs} from './modules/attributes'
import {Classes} from './modules/class'
import {Props} from './modules/props'
import {Dataset} from './modules/dataset'
import {Hero} from './modules/hero'

export type Key = string | number;

export interface VNode {
  // 选择器
  sel: string | undefined;
  // 节点数据:属性/样式/事件
  data: VNodeData | undefined;
  // 子节点,与text互斥
  children: Array<VNode | string> | undefined;
  // 记录vnode对应的真实Dom
  elm: Node | undefined;
  // 节点内容,和children互斥
  text: string | undefined;
  // 优化
  key: Key | undefined;
}

export interface VNodeData {
  props?: Props;
  attrs?: Attrs;
  class?: Classes;
  style?: VNodeStyle;
  dataset?: Dataset;
  on?: On;
  hero?: Hero;
  attachData?: AttachData;
  hook?: Hooks;
  key?: Key;
  ns?: string; // for SVGs
  fn?: () => VNode; // for thunks
  args?: Array<any>; // for thunks
  [key: string]: any; // for any other 3rd party module
}

export function vnode(sel: string | undefined,
                      data: any | undefined,
                      children: Array<VNode | string> | undefined,
                      text: string | undefined,
                      elm: Element | Text | undefined): VNode {
  let key = data === undefined ? undefined : data.key;
  return {sel, data, children, text, elm, key};
}

export default vnode;

在这个方法中引用了对应的模块文件,随后,定义了两个接口,VNode和VNodeDataVNode是用来约束对象拥有相同的属性,包括选择器、节点数据、子节点、VNode对应的真实DOM、节点内容和优化的key,VNodeData是模块中需要的相关参数,在下面的vnode方法中,传入了5个参数,最后一个参数key通过data来声明,最后通过对象的方式返回这6个参数值

init函数

由于patch函数是通过init函数来创建的,我们首先看一下init函数

在init函数中,传入了两个参数,第二个参数可选,用于转换虚拟节点的api,首先对 module 中的 hook 进行收集,保存到 cbs 中,这里的cbs为模块中对应的钩子函数,随后在内部定义了一些辅助函数,在最后返回一个patch函数,两个参数为旧节点和新节点。

patch函数

由于我们学习的版本为0.7.4,所以patch函数定义在snabbdom.ts中,高版本的有可能在init.ts中

patch函数是用来对比两个vnode节点之间的诧异,对比流程如下:

对比新旧Vnode是否是相同节点(节点的key和sel相同)

如果不是相同节点,删除之前的内容重新渲染

如果是相同节点,再判断新的Vnode是否有text,如果有并且和oldVnode的text不同,则直接拿新Vnode的text替换掉原有的text

如果新的VNode有children,判断子节点否发生变化,判断子节点的过程使用的就是diff算法

diff算法只进行同级比较

下面我们来看一下patch函数

// init内部返回patch函数,把vnode渲染成真实dom,并返回vnode
  return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
    let i: number, elm: Node, parent: Node;
    // 保存新插入节点的队列,为了触发钩子函数
    const insertedVnodeQueue: VNodeQueue = [];
    // 执行pre钩子函数
    for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
    // oldVnode不是VNode,创建VNode,并设置elm
    if (!isVnode(oldVnode)) {
      oldVnode = emptyNodeAt(oldVnode);
    }
    // 如果新旧节点是相同节点(key 和 sel 相同)
    if (sameVnode(oldVnode, vnode)) {
      // 找节点的差异并更新DOM
      patchVnode(oldVnode, vnode, insertedVnodeQueue);
    } else {
      // 若新旧节点不同,创建对应的DOM
      // 获取当前的DOM元素
      elm = oldVnode.elm as Node;
      parent = api.parentNode(elm);
      // 创建 vnode 对应的DOM 元素,并触发 init/create 钩子函数
      createElm(vnode, insertedVnodeQueue);

      if (parent !== null) {
        // 父节点不为空,把 vnode 对应的dom插入到文档中
        api.insertBefore(parent, vnode.elm as Node, api.nextSibling(elm));
        // 移除旧节点
        removeVnodes(parent, [oldVnode], 0, 0);
      }
    }
    // 执行用户设置的insert钩子函数
    for (i = 0; i < insertedVnodeQueue.length; ++i) {
      (((insertedVnodeQueue[i].data as VNodeData).hook as Hooks).insert as any)(insertedVnodeQueue[i]);
    }
    // 执行模块的post钩子函数
    for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
    return vnode;
  };
  
  function emptyNodeAt(elm: Element) {
    const id = elm.id ? '#' + elm.id : '';
    const c = elm.className ? '.' + elm.className.split(' ').join('.') : '';
    return vnode(api.tagName(elm).toLowerCase() + id + c, {}, [], undefined, elm);
  }
  
  function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
    return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel;
  }

patch为init函数返回的,在patch函数中,首先会调用 modulepre hook,然后会判断传入的第一个参数是否为 vnode 类型,如果不是,会调用 emptyNodeAt 然后将其转换成一个 vnodeemptyNodeAt 的具体实现也很简单,注意这里只是保留了 class 和 style,这个和 toVnode 的实现有些区别,因为这里并不需要保存很多信息,比如 prop attribute 等。接着调用 sameVnode 来判断是否为相同的 vnode 节点,具体实现也很简单,这里只是判断了 key 和 sel 是否相同。如果相同,调用 patchVnode,如果不相同,会调用 createElm 来创建一个新的 dom 节点,然后如果存在父节点,便将其插入到 dom 上,然后移除旧的 dom 节点来完成更新。最后调用元素上的 insert hookmodule 上的 post hook。 这里的重点是 patchVnodecreateElm 函数,我们先看 createElm 函数,看看是如何来创建 dom 节点的。

createElm 函数

function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
    let i: any, data = vnode.data;
    if (data !== undefined) {
      // 执行用户设置的init钩子函数
      if (isDef(i = data.hook) && isDef(i = i.init)) {
        i(vnode);
        data = vnode.data;
      }
    }
    // 把 vnode 转换成真实DOM对象(没渲染到页面)
    let children = vnode.children, sel = vnode.sel;
    if (sel === '!') {
      // !创建注释节点
      if (isUndef(vnode.text)) {
        vnode.text = '';
      }
      vnode.elm = api.createComment(vnode.text as string);
    } else if (sel !== undefined) {
      // 选择器不为空
      // 解析选择器
      // Parse selector
      const hashIdx = sel.indexOf('#');
      const dotIdx = sel.indexOf('.', hashIdx);
      const hash = hashIdx > 0 ? hashIdx : sel.length;
      const dot = dotIdx > 0 ? dotIdx : sel.length;
      const tag = hashIdx !== -1 || dotIdx !== -1 ? sel.slice(0, Math.min(hash, dot)) : sel;
      const elm = vnode.elm = isDef(data) && isDef(i = (data as VNodeData).ns) ? api.createElementNS(i, tag)
                                                                               : api.createElement(tag);
      if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot));
      if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/\./g, ' '));
      // 执行模块的create 钩子函数
      for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode);
      // 如果vnode中有子节点,创建子vnode对应的dom元素并追加到dom树上
      if (is.array(children)) {
        for (i = 0; i < children.length; ++i) {
          const ch = children[i];
          if (ch != null) {
            api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue));
          }
        }
      } else if (is.primitive(vnode.text)) {
        // vnode的text为string/number,创建文本节点并追加到dom树上
        api.appendChild(elm, api.createTextNode(vnode.text));
      }
      i = (vnode.data as VNodeData).hook; // Reuse variable
      if (isDef(i)) {
        // 执行用户传入的钩子 create
        if (i.create) i.create(emptyNode, vnode);
        // 把vnode添加到队列中,为执行insert钩子做准备
        if (i.insert) insertedVnodeQueue.push(vnode);
      }
    } else {
      vnode.elm = api.createTextNode(vnode.text as string);
    }

    // 返回新创建的DOM
    return vnode.elm;
  }

根据思维导图和代码可以看出,首先会调用元素的 init hook,接着这里会存在三种情况:

如果当前元素是注释节点,会调用 createComment 来创建一个注释节点,然后挂载到 vnode.elm

如果不存在选择器,只是单纯的文本,调用 createTextNode 来创建文本,然后挂载到 vnode.elm

如果存在选择器,会对这个选择器做解析,得到 tag、id 和 class,然后调用 createElementcreateElementNS 来生成节点,并挂载到 vnode.elm。接着调用 module 上的 create hook,如果存在 children,遍历所有子节点并递归调用 createElm 创建 dom,通过 appendChild 挂载到当前的 elm 上,不存在 children 但存在 text,便使用 createTextNode 来创建文本。最后调用调用元素上的 create hook 和保存存在 insert hookvnode,因为 insert hook 需要等 dom 真正挂载到 document 上才会调用,这里用个数组来保存可以避免真正需要调用时需要对 vnode树做遍历。

addVnodes 和 removeVnodes

function addVnodes(parentElm: Node, before: Node | null, vnodes: Array<VNode>, startIdx: number, endIdx: number, insertedVnodeQueue: VNodeQueue) {
  for (; startIdx <= endIdx; ++startIdx) {
    const ch = vnodes[startIdx];
    if (ch != null) {
      api.insertBefore(parentElm, createElm(ch, insertedVnodeQueue), before);
    }
  }
}

function removeVnodes(parentElm: Node, vnodes: Array<VNode>, startIdx: number, endIdx: number): void {
  for (; startIdx <= endIdx; ++startIdx) {
    let i: any, listeners: number, rm: () => void, ch = vnodes[startIdx];
    if (ch != null) {
      if (isDef(ch.sel)) {
        // 调用 destory hook
        invokeDestroyHook(ch);
        // 计算需要调用 removecallback 的次数 只有全部调用了才会移除 dom
        listeners = cbs.remove.length + 1;
        rm = createRmCb(ch.elm as Node, listeners);
        // 调用 module 中是 remove hook
        for (i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm);
        // 调用 vnode 的 remove hook
        if (isDef(i = ch.data) && isDef(i = i.hook) && isDef(i = i.remove)) {
          i(ch, rm);
        } else {
          rm();
        }
      } else { // Text node
        api.removeChild(parentElm, ch.elm as Node);
      }
    }
  }
}

// 调用 destory hook
// 如果存在 children 递归调用
function invokeDestroyHook(vnode: VNode) {
  let i: any, j: number, data = vnode.data;
  if (data !== undefined) {
    if (isDef(i = data.hook) && isDef(i = i.destroy)) i(vnode);
    for (i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode);
    if (vnode.children !== undefined) {
      for (j = 0; j < vnode.children.length; ++j) {
        i = vnode.children[j];
        if (i != null && typeof i !== "string") {
          invokeDestroyHook(i);
        }
      }
    }
  }
}

// 只有当所有的 remove hook 都调用了 remove callback 才会移除 dom
function createRmCb(childElm: Node, listeners: number) {
  return function rmCb() {
    if (--listeners === 0) {
      const parent = api.parentNode(childElm);
      api.removeChild(parent, childElm);
    }
  };
}

这两个函数主要用来添加 vnode 和移除 vnode

patchVnode

这个函数做的事情是对传入的两个 vnode 做 diff,如果存在更新,将其反馈到 dom 上。

首先调用 vnode 上的 prepatch hook,如果当前的两个 vnode 完全相同,直接返回。接着调用 modulevnode 上的 update hook。然后会分为以下几种情况做处理:

均存在 children 且不相同,调用 updateChildrenvnode 存在 children,旧 vnode 不存在 children,如果旧 vnode 存在 text 先清空,然后调用 addVnodesvnode 不存在 children,旧 vnode 存在 children,调用 removeVnodes 移除 children 均不存在 children,新 vnode不存在text,移除旧 vnodetext均存在text,更新 text`

updateChildren

function updateChildren(parentElm: Node, oldCh: Array<VNode>, newCh: Array<VNode>, insertedVnodeQueue: VNodeQueue) {
  let oldStartIdx = 0,
    newStartIdx = 0;
  let oldEndIdx = oldCh.length - 1;
  let oldStartVnode = oldCh[0];
  let oldEndVnode = oldCh[oldEndIdx];
  let newEndIdx = newCh.length - 1;
  let newStartVnode = newCh[0];
  let newEndVnode = newCh[newEndIdx];
  let oldKeyToIdx: any;
  let idxInOld: number;
  let elmToMove: VNode;
  let before: any;

  // 遍历 oldCh newCh,对节点进行比较和更新
  // 每轮比较最多处理一个节点,算法复杂度 O(n)
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 如果进行比较的 4 个节点中存在空节点,为空的节点下标向中间推进,继续下个循环
    if (oldStartVnode == null) {
      oldStartVnode = oldCh[++oldStartIdx]; // Vnode might have been moved left
    } else if (oldEndVnode == null) {
      oldEndVnode = oldCh[--oldEndIdx];
    } else if (newStartVnode == null) {
      newStartVnode = newCh[++newStartIdx];
    } else if (newEndVnode == null) {
      newEndVnode = newCh[--newEndIdx];
      // 新旧开始节点相同,直接调用 patchVnode 进行更新,下标向中间推进
    } else if (sameVnode(oldStartVnode, newStartVnode)) {
      patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
      oldStartVnode = oldCh[++oldStartIdx];
      newStartVnode = newCh[++newStartIdx];
      // 新旧结束节点相同,逻辑同上
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
      oldEndVnode = oldCh[--oldEndIdx];
      newEndVnode = newCh[--newEndIdx];
      // 旧开始节点等于新的节点节点,说明节点向右移动了,调用 patchVnode 进行更新
    } else if (sameVnode(oldStartVnode, newEndVnode)) {
      // Vnode moved right
      patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
      // 旧开始节点等于新的结束节点,说明节点向右移动了
      // 具体移动到哪,因为新节点处于末尾,所以添加到旧结束节点(会随着 updateChildren 左移)的后面
      // 注意这里需要移动 dom,因为节点右移了,而为什么是插入 oldEndVnode 的后面呢?
      // 可以分为两个情况来理解:
      // 1. 当循环刚开始,下标都还没有移动,那移动到 oldEndVnode 的后面就相当于是最后面,是合理的
      // 2. 循环已经执行过一部分了,因为每次比较结束后,下标都会向中间靠拢,而且每次都会处理一个节点,
      // 这时下标左右两边已经处理完成,可以把下标开始到结束区域当成是并未开始循环的一个整体,
      // 所以插入到 oldEndVnode 后面是合理的(在当前循环来说,也相当于是最后面,同 1)
      api.insertBefore(parentElm, oldStartVnode.elm as Node, api.nextSibling(oldEndVnode.elm as Node));
      oldStartVnode = oldCh[++oldStartIdx];
      newEndVnode = newCh[--newEndIdx];
      // 旧的结束节点等于新的开始节点,说明节点是向左移动了,逻辑同上
    } else if (sameVnode(oldEndVnode, newStartVnode)) {
      // Vnode moved left
      patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
      api.insertBefore(parentElm, oldEndVnode.elm as Node, oldStartVnode.elm as Node);
      oldEndVnode = oldCh[--oldEndIdx];
      newStartVnode = newCh[++newStartIdx];
      // 如果以上 4 种情况都不匹配,可能存在下面 2 种情况
      // 1. 这个节点是新创建的
      // 2. 这个节点在原来的位置是处于中间的(oldStartIdx 和 endStartIdx之间)
    } else {
      // 如果 oldKeyToIdx 不存在,创建 key 到 index 的映射
      // 而且也存在各种细微的优化,只会创建一次,并且已经完成的部分不需要映射
      if (oldKeyToIdx === undefined) {
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
      }
      // 拿到在 oldCh 下对应的下标
      idxInOld = oldKeyToIdx[newStartVnode.key as string];
      // 如果下标不存在,说明这个节点是新创建的
      if (isUndef(idxInOld)) {
        // New element
        // 插入到 oldStartVnode 的前面(对于当前循环来说,相当于最前面)
        api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
        newStartVnode = newCh[++newStartIdx];
      } else {
        // 如果是已经存在的节点 找到需要移动位置的节点
        elmToMove = oldCh[idxInOld];
        // 虽然 key 相同了,但是 seletor 不相同,需要调用 createElm 来创建新的 dom 节点
        if (elmToMove.sel !== newStartVnode.sel) {
          api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
        } else {
          // 否则调用 patchVnode 对旧 vnode 做更新
          patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
          // 在 oldCh 中将当前已经处理的 vnode 置空,等下次循环到这个下标的时候直接跳过
          oldCh[idxInOld] = undefined as any;
          // 插入到 oldStartVnode 的前面(对于当前循环来说,相当于最前面)
          api.insertBefore(parentElm, elmToMove.elm as Node, oldStartVnode.elm as Node);
        }
        newStartVnode = newCh[++newStartIdx];
      }
    }
  }
  // 循环结束后,可能会存在两种情况
  // 1. oldCh 已经全部处理完成,而 newCh 还有新的节点,需要对剩下的每个项都创建新的 dom
  if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
    if (oldStartIdx > oldEndIdx) {
      before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
      addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
      // 2. newCh 已经全部处理完成,而 oldCh 还有旧的节点,需要将多余的节点移除
    } else {
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
    }
  }
}

在updateChildren中,实现了diff算法,这里我们主要来分析一下diff算法

由于在dom操作的时候我们很少会吧一个父节点移动或更新到某一个子节点中,所以我们只需要找同级别的节点进行比较,这样在时间复杂度上会优化很多

在进行同级别节点比较的时候,首先会对新老节点数组的开始和结尾节点设置索引,遍历过程中移动索引

在开始和结束节点比较时,会出现4中情况

  1. oldStartVnode / newStartVnode (旧开始节点/新开始节点)
  2. oldEndVnode / newEndVnode (旧结束节点/新结束节点)
  3. oldStartVnode / oldEndVnode (旧开始节点/新结束节点)
  4. oldEndVnode / newStartVnode (旧结束节点/新开始节点)

在 oldStartVnode 与 newStartVnode 进行比较时,会调用sameVnode 判断是否是相同节点,也就是比较keysel,相同的话,直接调用patchVnode对比内部的差异,更新到dom中,随即移动索引比较下一个节点;如果下一个节点不相同,会比较oldEndVnodenewEndVnode,同样调用sameVnode 判断是否是相同节点相同的话,调用patchVnode对比内部的差异,更新到dom中,随即向前移动索引比较上一个节点。

oldStartVnodeoldEndVnode进行比较时,同样调用sameVnode 判断是否是相同节点,相同的话,将旧开始节点移动到最后面的位置上,并将旧节点的索引向后移动,新节点的索引向前移动,继续对比

oldEndVnodenewStartVnode进行比较时,同样调用sameVnode 判断是否是相同节点,相同的话,将旧开始节点移动到最前面的位置上,并将旧节点的索引向前移动,新节点的索引向后移动,继续对比

若以上四种情况都不满足:

使用newStartVnode在旧节点中寻找相同key值的节点,若没有找到,说明是新的节点,创建对应DOM元素并插入到节点数组最前面,若找到了相同key的节点,需要判断是否是相同的sel选择器,若sel不相同,说明还是新的节点,创建对应DOM元素并插入到节点数组最前面,若找到了相同key的节点,并且sel也相同,那么直接将该节点移动到数组最前面

最后,判断一下旧节点和新节点的个数,如果新节点的个数 > 旧节点的个数,说明剩余未遍历的节点为新节点,插入到节点数组最后

若新节点的个数 < 旧节点的个数,说明剩余未遍历的节点为需要删除的节点,执行删除

以上就是diff算法的原理,同样也是updateChildren的实现原理。

结语

以上就是我对snabbdom的学习理解,如果有不对的地方欢迎大家指出,谢谢