likes
comments
collection
share

Virtual DOM的实现原理

作者站长头像
站长
· 阅读数 13

@[TOC]

一、简介

虚拟DOM就是使用普通的js对象模拟的DOM元素,只保留DOM元素中比较关键的几个属性。在数据驱动视图的应用场景下,数据变化时,使用虚拟DOM不会立即更新视图,而是会对比旧的虚拟DOM和新生成的虚拟DOM之间有没有差异,只更新有修改的部分。Vue.js中虚拟DOM使用的是改造后的Snabbdom库,Snabbdom库代码量不大,但是功能很强大,可以通过模块扩展的方式扩展功能。所以本篇文章主要对Snabbdom库的使用和源码进行探究。

二、Snabbdom库的基本使用

使用的Snabbdom库的版本:3.5.1

(一)项目初始化

1、初始化项目安装打包工具parcel

Virtual DOM的实现原理

2、更改package.json的scripts配置

  "scripts": {
    "dev": "parcel index.html --open",
    "build": "parcel build index.html"
  },

3、初始化文件目录

Virtual DOM的实现原理 并在index.html中引入js文件

(二)Snabbdom基本用法

Snabbdom官方文档地址

1、下载Snabbdom

npm install snabbdom

2、Hello World案例

案例目标: 创建虚拟DOM,进行差异对比并替换旧的DOM API简介: ① init():接受一个模块组成的数组作为参数,返回一个patch()函数 ② patch():对比差异,并且更新DOM。接收两个参数, 参数一:虚拟DOM或者真实的元素,如果传递真实的元素,内部会转化出一个虚拟DOM 参数二:虚拟DOM 返回值:新的虚拟DOM,即参数二,用来作为下一次差异对比的旧值 ③ h():创建虚拟DOM。h()函数接收参数的方式有很多种,这里使用: 参数一:标签和选择器 参数二:一段字符串,适用于虚拟DOM里面只有纯文本的情况 案例代码:

import {
    init,
    h,
} from "snabbdom";
// 使用init函数创建patch函数用来对比差异
const patch = init([])
// 使用h函数创建虚拟节点:
// tagName=`div`, id=`container`, class=`cls`, innerHTML=`Hello World`
const vnode = h('div#container.cls', 'Hello World')
// 使用patch函数对比差异,更新到真实DOM
// 使用oldVnode保存上一次的虚拟节点,用于下一次的对比
const oldValue = patch(document.getElementById('app'), vnode)

案例效果: Virtual DOM的实现原理 Virtual DOM的实现原理

第二次差异对比: 创建变量接收patch()函数的返回值,和新的虚拟DOM进行二次对比,并将新的虚拟DOM展示到视图上。 代码:

const oldValue = patch(document.getElementById('app'), vnode)
const newValue = h('div#container.xxx', 'Hello Snabbdom')
patch(oldValue, newValue)

效果: Virtual DOM的实现原理 Virtual DOM的实现原理

3、创建一个包含子元素的DOM

案例实现的功能: ① 创建一个包含子元素的虚拟DOM并替换原始html中的div#app ② 2s之后修改子节点中的内容 ③ 再过2s之后清除虚拟DOM 知识点: ① h()函数创建有子节点的虚拟DOM时,参数列表: 参数一:选择器 参数二:使用h()创建的虚拟DOM数组 ② h()函数创建空的注释节点:h('!') 代码:

import {
    init,
    h,
} from "snabbdom";
// 使用init函数创建patch函数用来对比差异
const patch = init([])
// 使用h函数创建一个有子节点的虚拟DOM
const vnode = h('div#container.cls', [
    h('h1', 'Hello Snabbdom'),
    h('p', '这是一个p标签')
])
// 使用patch函数对比差异
const app = document.querySelector('#app')
const oldVnode = patch(app, vnode)
// 2s后再次对比差异
setTimeout(() => {
    const newVnode = h('div#container.cls', [
        h('h1', 'Hello World'),
        h('p', 'Hello P')
    ])
    patch(oldVnode, newVnode)
}, 2000)
// 4s之后清空页面
setTimeout(() => {
    patch(oldVnode, h('!'))
}, 4000)

(三)模块基本使用

Snabbdom库中提供的模块都只能操作虚拟DOM,不能操作真实的DOMSnabbdom 中的模块可以用来扩展 Snabbdom的功能。 引入:

import {
    init,
    h,
    styleModule,
    eventListenersModule
} from "snabbdom";

init()中注册:

const patch = init([styleModule,eventListenersModule])

h()中使用: h()函数的第二个参数传递模块所需要的配置参数

const vnode = h("div",[
    h("h1",{
        style:{color:'pink'},// styleModeule需要配置style属性
        on:{
            click:()=>{alert('Hello Snabbdom')},
        } // eventListenersModule需要配置on属性
    }, "Hello Snabbdom")
])

三、Snabbdom源码解析

看源码tips: 先大致了解库的核心实现过程,然后围绕一个核心的目标,顺着一条功能线去逐步看源码是怎么实现的,可以灵活使用调试工具查看函数执行的流程。 Snabbdom库核心执行过程:

init函数引入所需要的模块并创建patch函数
h函数创建虚拟DOM
patch函数进行差异对比
patch函数将新的DOM渲染到页面上

(一)运行官方示例

github地址 将源码下载下来之后,运行npm i安装依赖;npm run build构建项目;npm run examples运行示例代码就可以在页面中看到官方提供的示例效果: Virtual DOM的实现原理

(二)h()函数

h()函数是用来创建虚拟DOM的,通过阅读h()函数的源码,我们需要回答一个问题: h()函数是如何创建虚拟DOM的? h()函数的定义在src/h.tsVirtual DOM的实现原理 h()函数在执行的时候有多种接收参数的方式,其中关键部分在于h()函数的重载,定义了四种接收不同种类和数量的参数的h()函数,真正的代码执行在第五个h()函数的定义中执行 Virtual DOM的实现原理 主要是判断传进来的第三个参数和第二个参数有没有值,以及值的类型,对不同类型的值进行处理,将子元素处理成数组等等处理,最终返回vnode()函数的调用结果。

  // 返回vnode函数处理后的结果
  // sel: string, 选择器
  // data: VNodeData | null, 模块配置数据
  // children: VNodeChildren, 子元素组成的数组
  // text: string | undefined, 文本
  // elm: Element | Text | undefined
  return vnode(sel, data, children, text, undefined);

接下来看vnode()函数的内部实现 Virtual DOM的实现原理 其内部定义了两个接口,规定两个对象的数据类型,vnode()方法把参数组装成一个js对象返回。 Virtual DOM的实现原理 所以,h()函数是如何创建虚拟DOM的? h()函数存在重载机制,它的参数有四种组合方式,h()函数会对传进来的参数进行判断和处理,最终组装成一个vNode类型的js对象。

(三)patch()函数

patch()函数的作用是对比两个vNode的差异,并且更新视图。patch()函数由一个高阶函数init()返回。回顾一下init()函数的使用:

const patch = init([styleModule,eventListenersModule])

我们在使用的时候传入了一个模块组成的数组。 init()函数的定义: Virtual DOM的实现原理 ① 参数一是用来接收模块数组的。init()中初始化了一个钩子函数对象,然后会遍历模块,将模块中的钩子函数添加到钩子函数对象中,在将来对应的时机执行。

	// 钩子函数,会在对应的时机执行。初始化为数组,因为未来可能会有多个钩子函数
  const cbs: ModuleHooks = {create: [], update: [], remove: [], destroy: [], pre: [], post: [],};
  for (const hook of hooks) {
	  // 遍历模块,把模块中的钩子函数添加到cbs中
	  for (const module of modules) {
	    const currentHook = module[hook];
	    if (currentHook !== undefined) {
	      // 遍历结果:cbs = {create: [fn1,fn2],...}
	      (cbs[hook] as any[]).push(currentHook);
	    }
	  }
	}

② 参数二domApi如果用户不指定,就会使用snabbdom自己定义的htmlDOMApi,内部就是对原生的document方法的封装

// 初始化DOMApi。如果用户没有指定,就使用默认的htmlDomApi
// htmlDomApi就是一个对象,里面有一些方法,比如createElement、appendChild等,
// 这些方法都是对原生DOM的封装
const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi;

下面是htmlDomApi的接口定义: Virtual DOM的实现原理 createElement()方法就是直接返回了原生的createElement()的执行结果

function createElement(
  tagName: any,
  options?: ElementCreationOptions
): HTMLElement {
  return document.createElement(tagName, options);
}

③ 参数三options的接口定义如下:

// TODO Should `domApi` be put into this in the next major version bump?
export type Options = {
  experimental?: {
    fragments?: boolean;
  };
};

是一个实验性的功能,支持使用DocumentFragment对象替换真实的DOM节点。如果patch()函数的第二个参数(新的vNode),没有sel选择器属性(没有指定真实DOM),但是有children属性(有子节点),并且将参数三设置为

{
	fragments:true
}

就会创建一个DocumentFragment对象,并且增加指定的子节点,挂载到新的vNode.elm属性上,后续会被渲染到DOM中。 init()函数最终返回patch()函数。patch()函数内部的执行步骤如下: Virtual DOM的实现原理 patch()

// 两个参数:新旧vNode
// 首次渲染时,oldVnode是一个真实的dom节点,相当于占位的作用
// 新创建的虚拟DOM vNode,会替换掉oldVnode
return function patch(oldVnode: VNode | Element | DocumentFragment, vnode: VNode): VNode {
  let i: number, elm: Node, parent: Node;
  // 存储新插入节点的队列
  // 为了触发节点上insert钩子
  const insertedVnodeQueue: VNodeQueue = [];
  // 遍历执行钩子函数中的pre()
  // pre:处理vnode之前的预处理钩子函数
  for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();

  if (isElement(api, oldVnode)) {
    // 如果oldVnode是一个真实的dom节点,将其转换为vNode
    oldVnode = emptyNodeAt(oldVnode);
  } else if (isDocumentFragment(api, oldVnode)) {
    oldVnode = emptyDocumentFragmentAt(oldVnode);
  }

  // 判断是否是相同节点
  if (sameVnode(oldVnode, vnode)) {
    // 如果是相同节点,执行patchVnode,对比两个节点的差异
    patchVnode(oldVnode, vnode, insertedVnodeQueue);
  } else {
    // 如果不是相同节点,直接替换
    // !表示这个属性一定有值
    elm = oldVnode.elm!;
    // 获取父节点
    parent = api.parentNode(elm) as Node;

    // 创建vNode对应的真实dom节点
    // 将新插入的节点的队列传入进去,会触发相应的钩子函数
    createElm(vnode, insertedVnodeQueue);

    if (parent !== null) {
      // 将创建出来的DOM插入到父节点中
      // elm:老节点对应的dom
      api.insertBefore(parent, vnode.elm!, api.nextSibling(elm));
      // 把老节点对应的DOM元素移除
      removeVnodes(parent, [oldVnode], 0, 0);
    }
  }

  for (i = 0; i < insertedVnodeQueue.length; ++i) {
    // 触发insert钩子函数。从data中获取,由用户自定义
    // !如果没有值就不往下执行
    insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i]);
  }
  // 遍历执行钩子函数中的post()
  // post是模块中定义的钩子函数,用于处理vnode之后的钩子函数
  for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
  return vnode;
};

其中有三个比较复杂的函数,patchVnode()createElm()removeVnodes()。接下来依次看这三个函数的执行:

1、createElm()函数

createElm()函数功能: ① 执行用户传入的init()钩子函数 ② 判断vnode.sel属性,创建对应的DOM元素,并且添加到vnode.elm属性中。这一步比较复杂,sel分为四种情况: 🦋 !:注释节点 🦋 有值:创建真实DOM。如果有子节点,递归调用createElm() 🦋 undefined,需要创建文档片段:创建DocumentFragment,递归处理children 🦋 undefined,不需要创建文档片段:根据text属性创建文本节点 ③ 返回vnode.elm属性

Virtual DOM的实现原理 createElm()

function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
  let i: any;
  let data = vnode.data;
  if (data !== undefined) {
    // init是用户传入的钩子函数
    // init使用户可以在创建元素之前执行一些操作
    // 如果hook没有值,直接返回undefined
    const init = data.hook?.init;
    // init是否有定义
    if (isDef(init)) {
      init(vnode);
      data = vnode.data;
    }
  }
  // 获取子节点,后面会递归创建vnode的子节点
  const children = vnode.children;
  // 获取vnode选择器
  const sel = vnode.sel;
  if (sel === "!") {
    // 创建注释节点
    if (isUndef(vnode.text)) {
      // 如果vnode.text没有值,就把vnode.text设置为空字符串
      vnode.text = "";
    }
    // 创建注释节点,并把注释节点赋值给vnode.elm,
    // 内部调用的是document.createComment
    vnode.elm = api.createComment(vnode.text!);
  } else if (sel !== undefined) {
    // 转换selector,解析出标签名、id、class
    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;
    // 创建元素节点
    // ns:命名空间,一般用于svg元素
    const elm = (vnode.elm =
      isDef(data) && isDef((i = data.ns))
        ? api.createElementNS(i, tag, data)
        : api.createElement(tag, data));
    // 处理id和class
    if (hash < dot) elm.setAttribute("id", sel.slice(hash + 1, dot));
    if (dotIdx > 0)
      elm.setAttribute("class", sel.slice(dot + 1).replace(/\./g, " "));
    // dom元素创建完毕,执行create钩子函数
    for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode);
    // 如果children是数组,就递归创建子节点
    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是否是字符串或者数字
      // 如果是字符串或者数字,就创建文本节点,并把文本节点添加到elm中
      api.appendChild(elm, api.createTextNode(vnode.text));
    }
    // 获取用户传入的钩子函数
    const hook = vnode.data!.hook;
    if (isDef(hook)) {
      // 如果用户传入的钩子函数中有create钩子函数,就执行create钩子函数
      hook.create?.(emptyNode, vnode);
      if (hook.insert) {
        // 如果用户传入的钩子函数中有insert钩子函数,就把vnode添加到insertedVnodeQueue中
        // DOM元素插入到DOM树中后,执行insert钩子函数
        insertedVnodeQueue.push(vnode);
      }
    }
  } else if (options?.experimental?.fragments && vnode.children) {
    // sel == undefined并且vnode.children有值
    // 创建DocumentFragment
    vnode.elm = (
      api.createDocumentFragment ?? documentFragmentIsNotSupported
    )();
    // 执行create钩子函数
    for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode);
    for (i = 0; i < vnode.children.length; ++i) {
      const ch = vnode.children[i];
      if (ch != null) {
        api.appendChild(
          vnode.elm,
          createElm(ch as VNode, insertedVnodeQueue)
        );
      }
    }
  } else {
    // sel为空,创建文本节点
    vnode.elm = api.createTextNode(vnode.text!);
  }
  return vnode.elm;
}

2、removeVnodes()函数

removeVnodes()函数用户删除老节点对应的DOM元素。

// 获取父节点
parent = api.parentNode(elm) as Node;
removeVnodes(parent, [oldVnode], 0, 0);

参数列表: 🦋 parent:要删除的DOM元素的父节点 🦋 [oldVnode]:要删除的DOM元素对应的vnode数组 🦋 startIdx:要删除的DOM元素在[oldVnode]中的起始索引 🦋 endIdx:要删除的DOM元素在[oldVnode]中的结束索引 由于要删除的节点只有一个,所以要删除的节点起始索引和结束索引都是0,startIdxendIdx都是0。removeVnodes()函数的执行流程如下: Virtual DOM的实现原理 removeVnodes()

function removeVnodes(
  parentElm: Node,
  vnodes: VNode[],
  startIdx: number,
  endIdx: number
): void {
  // 循环删除vnodes中的节点
  for (; startIdx <= endIdx; ++startIdx) {
    let listeners: number;
    let rm: () => void;
    const ch = vnodes[startIdx];
    if (ch != null) {
      if (isDef(ch.sel)) {
        // 元素节点
        // 执行destroy钩子函数
        invokeDestroyHook(ch);
        // remove钩子函数个数
        listeners = cbs.remove.length + 1;
        // 删除DOM元素的方法
        rm = createRmCb(ch.elm!, listeners);
        // 执行模块中的remove钩子函数
        // remove钩子函数参数一:vnode
        // remove钩子函数参数二:钩子函数内部会执行的函数
        // 每执行一次remove钩子函数,listeners减1
        for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm);
        // 执行用户传入的钩子函数
        const removeHook = ch?.data?.hook?.remove;
        if (isDef(removeHook)) {
          // 如果用户传入了remove钩子函数,就执行remove钩子函数
          // 需要用户手动调用rm函数
          removeHook(ch, rm);
        } else {
          // 如果用户没有传入remove钩子函数,就直接调用rm函数
          // 所有remove钩子函数执行完毕后,才会调用rm函数
          rm();
        }
      } else if (ch.children) {
        // DocumentFragment节点
        invokeDestroyHook(ch);
        removeVnodes(
          parentElm,
          ch.children as VNode[],
          0,
          ch.children.length - 1
        );
      } else {
        // 文本节点
        api.removeChild(parentElm, ch.elm!);
      }
    }
  }
}

删除元素的操作需要在所有的模块的remove钩子函数执行完毕之后进行。这里使用到了高阶函数来返回真正删除DOM元素的函数。

// remove钩子函数个数
listeners = cbs.remove.length + 1;
// 删除DOM元素的方法
rm = createRmCb(ch.elm!, listeners);
// 执行模块中的remove钩子函数
// remove钩子函数参数一:vnode
// remove钩子函数参数二:钩子函数内部会执行的函数
// 每执行一次remove钩子函数,listeners减1
for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm);
// 执行用户传入的钩子函数
const removeHook = ch?.data?.hook?.remove;
if (isDef(removeHook)) {
  // 如果用户传入了remove钩子函数,就执行remove钩子函数
  // 需要用户手动调用rm函数
  removeHook(ch, rm);
} else {
  // 如果用户没有传入remove钩子函数,就直接调用rm函数
  // 所有remove钩子函数执行完毕后,才会调用rm函数
  rm();
}

createRmCb()方法

function createRmCb(childElm: Node, listeners: number) {
  // 高阶函数
  // 使用高阶函数的目的是对传进来的两个参数进行缓存
  return function rmCb() {
    // 先让listeners减1
    // 当所有的remove钩子函数都执行完毕才会执行删除节点的操作
    if (--listeners === 0) {
      const parent = api.parentNode(childElm) as Node;
      api.removeChild(parent, childElm);
    }
  };
}

这个高阶函数的定义和用法比较难理解,通过查看styleModule模块源码,发现这个模块支持用户给元素设定一个删除时的过渡效果,等所有元素的过渡效果结束之后再调用rm()方法,相当于是一个异步调用。

(elm as Element).addEventListener(
  "transitionend",
  function (ev: TransitionEvent) {
    if (ev.target === elm) --amount;
    if (amount === 0) rm();
  }
);

并且只有styleModule模块是支持用户自己定义remove属性的:

export const styleModule: Module = {
  pre: forceReflow,
  create: updateStyle,
  update: updateStyle,
  destroy: applyDestroyStyle,
  remove: applyRemoveStyle,
};

applyRemoveStyle()方法内部,用户如果没有配置remove属性,就会直接执行rm(),如果配置了remove属性,就会给元素添加transitionend事件监听,异步调用rm()。 也就是说cbs.remove里面最多只有一个函数,就是applyRemoveStyle,执行模块的remove钩子函数就是执行applyRemoveStyle()方法。 回到rm(),假设给元素设置了删除时的过渡效果需要耗时3s,执行过程如下: Virtual DOM的实现原理

3、patchVnode(oldVnode,vnode,insertedVnodeQueue)函数

oldVnodevnode是同一节点的时候,需要调用patchVnode()函数,对比两个节点的差异,进行节点替换。patchVnode()函数内部大致分为三个阶段: 🦋 阶段一:执行prepatch钩子函数和update钩子函数 🦋 阶段二:对比差异,更新DOM 🦋 阶段三:执行postpatch钩子函数。此时DOM已经更新完毕,postpatch钩子函数类似于vue中的updated钩子函数。 其中阶段二是核心的对比差异、更新DOM的操作。我们先看一下vnode的接口定义:

export interface VNode {
  sel: string | undefined;  // 选择器
  data: VNodeData | undefined;  // 配置对象,定义钩子函数等
  children: Array<VNode | string> | undefined;  // 子元素
  elm: Node | undefined;  // 对应的真实DOM
  text: string | undefined;  // 文本节点
  key: Key | undefined;  // 唯一标识
}

其中children属性和text属性是互斥的,在对比差异时,首先要知道新旧vnode中存的是文本节点还是子节点,其内部存在很多的if条件判断,大致执行流程如下: Virtual DOM的实现原理 其中,比较复杂的操作是当newVnodeoldVnode都存在子节点时,执行的updateChildren()函数。还用到了一个addVnodes()函数,给一个DOM元素添加子节点。 在上面的思维导图中,当oldVnodenewVnode都没有文本并且都没有子节点的时候就不处理了,那思考一下,如果新旧节点都是img标签,修改的仅仅是src属性,会如何进行处理? 答案是:给img设置标签需要用到attributesModule,经过断点调试发现,attributesModule这个模块的update钩子函数中会更新元素的属性,所以只更新src属性,在update钩子函数中就完成了,并且不会创建新元素。

const vnode = h("img", {
    attrs: {
        src: "https://is1-ssl.mzstatic.com/image/thumb/Purple126/v4/62/59/89/62598941-0360-2d04-7c79-327e3f611cfd/icon.png/460x0w.webp"
    }
})
// 使用patch函数对比差异
const img = document.querySelector('img')
patch(img, vnode)

patchVnode()

function patchVnode(
  oldVnode: VNode,
  vnode: VNode,
  insertedVnodeQueue: VNodeQueue
) {
  // 第一个过程:触发prepacth钩子函数、update钩子函数
  const hook = vnode.data?.hook;
  // 执行用户传入的钩子函数,不管有没有差异都会执行
  hook?.prepatch?.(oldVnode, vnode);
  const elm = (vnode.elm = oldVnode.elm)!;
  // 如果两个节点相同直接返回
  if (oldVnode === vnode) return;
  if (
    vnode.data !== undefined ||
    (isDef(vnode.text) && vnode.text !== oldVnode.text)
  ) {
    // 如果vnode.data属性不存在,则初始化一个空对象
    vnode.data ??= {};
    // 如果oldVnode.data属性不存在,则初始化一个空对象
    oldVnode.data ??= {};
    // 先执行模块的update钩子函数,再执行用户传入的update钩子函数
    // 用户的修改会覆盖模块的修改
    for (let i = 0; i < cbs.update.length; ++i)
      cbs.update[i](oldVnode, vnode);
    vnode.data?.hook?.update?.(oldVnode, vnode);
  }
  // 第二个过程:真正对比新旧vnode差异的地方
  const oldCh = oldVnode.children as VNode[];
  const ch = vnode.children as VNode[];

  if (isUndef(vnode.text)) {
    // 如果新节点的text属性不存在,就说明新节点是一个标签节点
    if (isDef(oldCh) && isDef(ch)) {
      // 如果老节点的children属性和新节点的children属性都存在
      // 并且不相同,则调用updateChildren函数对比子节点的差异
      if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
    } else if (isDef(ch)) {
      // 如果新节点的children属性存在,老节点的text属性存在
      // 则清空老节点的text属性,将新节点的children添加到DOM中
      if (isDef(oldVnode.text)) api.setTextContent(elm, "");
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
    } else if (isDef(oldCh)) {
      // 如果新节点的children属性不存在,老节点的children属性存在
      // 则移除DOM中的老节点的children
      removeVnodes(elm, oldCh, 0, oldCh.length - 1);
    } else if (isDef(oldVnode.text)) {
      // 如果老节点有text属性,清空老节点的text属性
      api.setTextContent(elm, "");
    }

  } else if (oldVnode.text !== vnode.text) {
    // 如果新节点的text属性存在,并且和旧节点的text属性不同
    if (isDef(oldCh)) {
      // 如果老节点有子节点,先删除子节点
      removeVnodes(elm, oldCh, 0, oldCh.length - 1);
    }
    // 设置vnode对应的DOM元素的文本内容
    // DOM元素没有重新创建,仅仅更新了DOM元素的文本内容
    api.setTextContent(elm, vnode.text!);
  }
  // 第三个过程:触发postpatch钩子函数
  // DOM已经更新完毕,类似于vue中的updated钩子函数
  hook?.postpatch?.(oldVnode, vnode);
}
① updateChildren()函数

在新旧vnode的子节点都存在并且新旧vnode的子节点不相同的时候会调用updateChildren()方法对比所有子节点的差异并更新DOM。在页面中如果存在大量的DOM操作会耗费很多的性能,diff算法就是不会在数据变化的同时立马更新DOM,而是创建虚拟DOM进行差异对比,对比出新旧节点中的不同之处,只修改发生了改变的地方,从而减少反复销毁和创建DOM元素。

🐳 diff算法

diff算法是一种用于比较两个文本或数据集的差异的算法。它可以找出在两个文本中哪些部分发生了变化,类似于排序。 SnabbDOM中的diff算法,是对两个新旧DOM树的每一层进行对比,由于DOM操作很少会跨级别操作,比如把子节点移动为兄弟节点,所以SnabbDOM中的diff算法只进行同一层级的DOM节点的对比,在现存的DOM列表中查找有没有和新节点selkey属性都相同的节点,如果能找到就复用已经存在的DOM节点,改变该节点的内容、属性、位置等,不需要重新销毁或创建DOMVirtual DOM的实现原理 diff算法负责对两个节点数组进行差异对比,其中应用到了“首尾指针法”的思想。 扩展 首尾指针法是一种常用的算法技巧,通常用于在数组或链表中查找满足某种条件的连续子序列。 该方法使用两个指针,一个指向序列的起始位置,另一个指向序列的结束位置。通过不断调整这两个指针的位置,可以实现对序列的遍历和搜索。 diff算法一开始会将指针分别指向新旧节点数组的首尾节点上,不断移动指针位置,依次进行节点之间的对比。对比的情况有以下四种: 🌼 oldStartVnode / newStartVnode(旧开始节点 / 新开始节点) 🌼 oldEndVnode / newEndVnode (旧结束节点 /新结束节点) 🌼 oldStartVnode / newEndVnode (旧开始节点/新结束节点) 🌼 oldEndVnode / newStartVnode (日结束节点 /新开始节点) 对比的过程如下: ① 如下图所示,刚开始进行比较的时候,会进行旧开始节点和新开始节点之间的比较,如果新旧开始节点是 sameVnode (keysel 相同) 🎤 调用patchVnode() 对比和更新节点 🎤 把旧开始和新开始索引往后移动 oldstartldxt+ / newstartldx++,以2号节点为新旧起始节点进行对比Virtual DOM的实现原理 ② 如果起始新旧节点不是同一节点,则会进行旧结束节点 /新结束节点的对比,类似于第一种情况。当旧结束节点 /新结束节点也不是同一个节点的时候,会进行第三个阶段的对比 ③ 对比旧开始节点/新结束节点 在这个阶段,会首先进行old1号和new6号的对比,如果这两个节点是同一节点: 🎤 会调用patchVnode()更新old1号节点,并且将old1号节点对应的DOM元素移动到oldVnode列表的最后,相当于把old1号元素移动位置并且内容可能更新,此时不需要重新创建DOM。 🎤 将oldStartIdx++newEndIdx--,下一轮对比会首先进行old2号和new1号的对比 如果旧开始节点/新结束节点不是sameVnode,会进行第四阶段的比较 Virtual DOM的实现原理旧结束节点 /新开始节点 进行old6号和new1号的比较,如果这两个节点是sameVnode: 🎤 调用 patchVnode() 对比和更新节点 🎤 把oldEndVnode 对应的 DOM 元素,移动到oldStartIdx对应的DOM元素的前边,更新索引,oldEndIdx--newStartIdx++;下一轮对比会首先进行old1号和new2号的对比 Virtual DOM的实现原理 ⑤ 如果上述四种情况都不满足,会在oldVnode数组中找和new1key值相同的旧节点: 🎤 假设找到old3号和new1号的key值相同,并且经过判断它们的sel属性也相同,说明old3号和new1号是相同的节点,此时会将old3号赋值给emlToMove这个变量,并且将old3号节点对应的DOM元素移动到oldStartIdx对应的DOM元素的前边。 🎤 如果没有找到和new1key值和sel值都相同的就节点,那么要根据new1号虚拟节点调用createElm()方法创建一个新的DOM元素,并插入到DOM树中oldStartVnode对应的DOM元素的前边。 🎤 newStartIdx++ Virtual DOM的实现原理 ⑥ 循环结束的情况 判断循环执行的条件是oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx。首先解释一下这个循环结束的条件。假设新旧节点列表数量和元素都相同,对比差异的循环会依次向后进行,当进行到最末尾元素的对比时,oldStartIdx == oldEndIdx && newStartIdx == newEndIdx,下一次循环就需要跳出来。 Virtual DOM的实现原理 如果新旧节点列表数量不同,结束循环后就会出现以下两种情况: 🎤 newVnode有剩余节点,即newStartIdx<=newEndIdx。此时就需要对于剩下的new4号和new5号创建新DOM节点,并且插入到真实DOM元素列表的末尾。 Virtual DOM的实现原理 如果剩余的不是结尾的节点,会找到索引为newEndIdx+1的新节点对应的旧节点(即old5号),把new3号和new4号插入到目标节点的前边。代码中最后会调用原生的insertBefore(parentElm,child)方法,只不过如果剩下的是new4号和new5号,目标节点会是null,查看mdn发现insertBefore这个方法的第二个参数为null时,会把要插入的元素插入到parentElm的结尾,正好符合需求。 Virtual DOM的实现原理 🎤 oldVnode有剩余节点,即oldStartIdx <= oldEndIdx,需要从父节点中删除

removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);

Virtual DOM的实现原理 updateChildren()

  // parentElm: 父节点
  // oldCh: 旧的子节点
  // newCh: 新的子节点
  // insertedVnodeQueue: 存储插入的具有insert钩子函数的vnode
  function updateChildren(
    parentElm: Node,
    oldCh: VNode[],
    newCh: VNode[],
    insertedVnodeQueue: VNodeQueue
  ) {
    // 定义所有变量
    let oldStartIdx = 0;
    let 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: KeyToIndexMap | undefined;
    let idxInOld: number;
    let elmToMove: VNode;
    let before: any;

    // 同级别的节点比较
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (oldStartVnode == null) {
        // 如果旧的开始节点是null,oldStartVnode就指向下一个节点
        oldStartVnode = oldCh[++oldStartIdx]; // Vnode might have been moved left
      } else if (oldEndVnode == null) {
        // 如果旧的结束节点是null,oldEndVnode就指向上一个节点
        oldEndVnode = oldCh[--oldEndIdx];
      } else if (newStartVnode == null) {
        // 如果新的开始节点是null,newStartVnode就指向下一个节点
        newStartVnode = newCh[++newStartIdx];
      } else if (newEndVnode == null) {
        // 如果新的结束节点是null,newEndVnode就指向上一个节点
        newEndVnode = newCh[--newEndIdx];
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        // 如果旧的开始节点和新的开始节点是同一个节点
        // 就对旧的开始节点和新的开始节点进行patchVnode,不会重新创建DOM元素,会重用旧的开始节点dom元素
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
        // 旧的开始节点和新的开始节点都指向下一个节点
        oldStartVnode = oldCh[++oldStartIdx];
        newStartVnode = newCh[++newStartIdx];
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        // 如果旧的结束节点和新的结束节点是同一个节点
        // 就对旧的结束节点和新的结束节点进行patchVnode,不会重新创建DOM元素
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
        // 旧的结束节点和新的结束节点都指向上一个节点
        oldEndVnode = oldCh[--oldEndIdx];
        newEndVnode = newCh[--newEndIdx];
      } else if (sameVnode(oldStartVnode, newEndVnode)) {
        // 如果旧的开始节点和新的结束节点是同一个节点
        // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
        // 将oldStartVnode对应的dom元素向后移动,移动到oldEndVnode对应的dom元素的后边,位置和newVnode数组保持一致
        api.insertBefore(
          parentElm,
          oldStartVnode.elm!,
          api.nextSibling(oldEndVnode.elm!)
        );
        oldStartVnode = oldCh[++oldStartIdx];
        newEndVnode = newCh[--newEndIdx];
      } else if (sameVnode(oldEndVnode, newStartVnode)) {
        // 如果旧的结束节点和新的开始节点是同一个节点
        // Vnode moved left
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
        // 将oldEndVnode对应的dom元素向前移动到oldEndVnode对应的dom元素前面,位置与newVnode数组保持一致
        api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!);
        oldEndVnode = oldCh[--oldEndIdx];
        newStartVnode = newCh[++newStartIdx];
      } else {
        // oldKeyToIdx:存储旧的子节点的key和索引的映射
        if (oldKeyToIdx === undefined) {
          oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
        }
        // 使用新节点的key属性,找到旧节点中与之相同的节点的索引
        // 如果是新插入的节点,可能找不到对应的旧节点,此时idxInOld为undefined
        idxInOld = oldKeyToIdx[newStartVnode.key as string];
        if (isUndef(idxInOld)) {
          // New element
          // 如果idxInOld为undefined,说明是新插入的节点
          // 就创建新的节点,并插入到旧的开始节点之前
          api.insertBefore(
            parentElm,
            createElm(newStartVnode, insertedVnodeQueue),
            oldStartVnode.elm!
          );
        } else {
          // 找到了对应的旧节点
          elmToMove = oldCh[idxInOld];
          if (elmToMove.sel !== newStartVnode.sel) {
            // 如果找到的旧节点的选择器和新的开始节点的选择器不同
            // 说明是不同类型的节点,就创建新的节点,并插入到旧的开始节点之前
            api.insertBefore(
              parentElm,
              createElm(newStartVnode, insertedVnodeQueue),
              oldStartVnode.elm!
            );
          } else {
            // 如果找到的旧节点的选择器和新的开始节点的选择器相同
            // 就对旧的开始节点和新的开始节点进行patchVnode,不会重新创建DOM元素
            patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
            // 把旧的开始节点设置为undefined,表示已经处理过了
            oldCh[idxInOld] = undefined as any;
            // 把找到的旧节点插入到旧的开始节点之前
            api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!);
          }
        }
        newStartVnode = newCh[++newStartIdx];
      }
    }

    // 循环结束后,有可能是oldCh或者newCh还有剩余的节点没有处理
    if (newStartIdx <= newEndIdx) {
      // 如果是newCh还有剩余的节点没有处理
      before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
      addVnodes(
        parentElm,
        before,
        newCh,
        newStartIdx,
        newEndIdx,
        insertedVnodeQueue
      );
    }
    if (oldStartIdx <= oldEndIdx) {
      // 如果是oldCh还有剩余的节点没有处理
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
    }
  }
② addVnodes()函数

addVnodes()函数用于将节点放到旧节点的后面。

// 参数:
// parentElm: 父节点
// before: 插入到哪个节点之前
// vnodes: 要插入的节点
// startIdx: 开始索引
// endIdx: 结束索引 startIdx和endIdx之间的节点都会被插入到DOM树中
// insertedVnodeQueue: 存储插入的具有insert钩子函数的vnode
function addVnodes(
  parentElm: Node,
  before: Node | null,
  vnodes: 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);
    }
  }
}

(四)key值的意义

1、设置key值的效果

首先我们先复习一下sameVnode()函数的代码:

function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
  // key:节点的唯一标识
  const isSameKey = vnode1.key === vnode2.key;
  const isSameIs = vnode1.data?.is === vnode2.data?.is;
  const isSameSel = vnode1.sel === vnode2.sel;
  const isSameTextOrFragment =
    !vnode1.sel && vnode1.sel === vnode2.sel
      ? typeof vnode1.text === typeof vnode2.text
      : true;

  return isSameSel && isSameKey && isSameIs && isSameTextOrFragment;
}

这里不考虑太过复杂的情况,只考虑selkey属性。如果不给虚拟DOM设置key属性,那么只要是选择器相同就会被认为是同一个DOM元素,设置了key属性之后只有selkey都相同才会被认为是同一个DOM元素。下面我们通过调试,观察一下虚拟DOM不设置key和设置key在进行差异对比的时候会有什么区别。 第一段代码不设置key:

const vnode1 = h('ul', [
    h('li','苹果'),
    h('li', '香蕉'),
])
const vnode2 = h('ul', [
    h('li', '香蕉'),
    h('li','苹果'),
])
patch(document.querySelector('#app'), vnode1)
setTimeout(() => {
    patch(vnode1, vnode2)
}, 1000)

vnode1vnode2中的第一个子节点由于sel属性相同,并且key属性都是undefined,也是相同的,所以会被认为是同一个节点,此时是旧开始节点/新开始节点,如下图所示,oldStartVnodevnode1中的h('li','苹果')newStartVnodevnode2中的h('li','香蕉')Virtual DOM的实现原理 并且这两个节点都是文本节点,所以会更改该节点里面的文本: Virtual DOM的实现原理 第二段代码设置key:

const vnode1 = h('ul', [
    h('li',{key:'apple'},'苹果'),
    h('li',{key:'banana'}, '香蕉'),
])
const vnode2 = h('ul', [
    h('li',{key:'banana'}, '香蕉'),
    h('li',{key:'apple'},'苹果'),
])
patch(document.querySelector('#app'), vnode1)
setTimeout(() => {
    patch(vnode1, vnode2)
}, 1000)

vnode1h('li',{key:'apple'},'苹果')vnode2h('li',{key:'apple'},'苹果')是同一节点,此时是旧开始节点/新结束节点的情形,此时会将旧开始节点对应的DOM元素移动到旧结束节点对应的DOM元素的后面,也就是会把h('li',{key:'apple'},'苹果')移动到h('li',{key:'banana'}, '香蕉')的后面 Virtual DOM的实现原理

2、key值的必要性

不使用key值的话,可以最大程度的复用已经存在的元素,但是会有一些隐患,看下面一段代码,

const vnode1 = h('ul', [
    h('li',[
        h('input',{attrs:{type:'checkbox',value:'香蕉'}}),
        '香蕉'
    ])
    ,
    h('li',[
        h('input',{attrs:{type:'checkbox',value:'苹果'}}),
        '苹果'
    ])
])
const vnode2 = h('ul', [
    h('li',[
        h('input',{attrs:{type:'checkbox',value:'苹果'}}),
        '苹果'
    ])
    ,
    h('li',[
        h('input',{attrs:{type:'checkbox',value:'香蕉'}}),
        '香蕉'
    ])
])
patch(document.querySelector('#app'), vnode1)
setTimeout(() => {
    patch(vnode1, vnode2)
}, 3000)

子元素li里面还有两个子节点,一个是checkbox,一个是文本节点。由于没有给li设置key,old1linew1li就会被认为是同一个节点,然后会修改li1>input中的value属性和文本节点中的文本,但是如果当前checkbox被选中,就会导致选中状态出现混乱,例如我们在patch(vnode1,vnode2)之前选中香蕉: Virtual DOM的实现原理 等待patch()函数进行完毕就会变成 Virtual DOM的实现原理 但是如果我们给li1设置key:

const vnode1 = h('ul', [
    h('li',{key:'banana'},[
        h('input',{attrs:{type:'checkbox',value:'香蕉'}}),
        '香蕉'
    ])
    ,
    h('li',[
        h('input',{attrs:{type:'checkbox',value:'苹果'}}),
        '苹果'
    ])
])
const vnode2 = h('ul', [
    h('li',[
        h('input',{attrs:{type:'checkbox',value:'苹果'}}),
        '苹果'
    ])
    ,
    h('li',{key:'banana'},[
        h('input',{attrs:{type:'checkbox',value:'香蕉'}}),
        '香蕉'
    ])
])

就会将香蕉所在的li移动位置,而不是只改变文本内容,选中状态就会保持正常。 Virtual DOM的实现原理

四、总结

Snabbdom是一个实现虚拟DOM的库,阅读Snabbdom的源码是为了为后续学习vue的源码做铺垫。Snabbdom中主要的函数有下面几个: 🌸 ① h()函数:用于创建虚拟DOM,其内部实现了重载,拥有四种传递参数的方法,h()函数内部会进行参数判断,对参数进行处理,然后把参数列表传递给vnode()函数,并且返回vnode()函数的处理结果; 🌸 ② vnode()函数:用于返回一个虚拟DOM对象,虚拟DOM对象的数据类型参考VNode接口定义; 🌸 ③ init()函数:参数列表有三个,一般只传递第一个参数,即模块列表,init()函数会将模块中的钩子函数整合成一个对象数组,在合适的时机调用,最终返回一个patch()函数,init()函数是一个高阶函数,其内部定义的变量都可以在patch()函数中使用,并且可以得到缓存,可以减少很多变量的重新定义; 🌸 ④ patch()函数:用于差异对比以及更新DOM,接收两个虚拟DOM节点作为参数,如果参数一是真实DOM也可以进行转换为虚拟DOM。然后会判断两个节点是不是同一节点,如果不是同一节点直接删除重新添加,如果是同一节点就需要调用patchVnode()函数进行节点的差异对比; 🌸 ⑤ patchVnode()函数:用于同一节点差异对比以及更新DOM,会判断新旧节点的text属性和children属性,根据这两个属性有没有值以及值是否相同进行不同的操作。其中最复杂的情况是新旧节点都有children属性,并且children属性不相同,此时就会调用updateChildren()函数 🌸 ⑥ updateChildren()函数:这个函数是Snabbdom库中最复杂的函数,用于对新旧节点序列进行差异对比,查找相同的节点,其中使用到diff算法,进行两个序列的差异对比。此处的diff算法只进行同一层级节点的对比,找到新旧相同的节点时,调用patchVnode()函数进行差异对比以及更新DOM,如果是新增的节点还需要创建新的DOM元素插入到DOM树上,如果旧节点还有剩余节点也需要从DOM树上删除。 大致的流程就是这个样子,其中还需要注意生命周期钩子函数的调用时机。还运用到了很多高阶函数的思想,在实际的编程中也很值得借鉴。以及patchVnode()函数和updateChildren()函数的相互调用来实现对于深层DOM树的遍历思想也很interesting。另外还有使用ts实现重载,支持多种参数类型,对参数进行处理再组装成一个标准形式进行后续的使用,以及diff算法等等,在工作中都可以借鉴这些变成思想。