虚拟DOM 之 Snabbdom 二、源码解析(h函数,虚拟DOM对比,Diff算法)

Snabbdom 源码解析

核心源码解析

如何学习源码

  • 先宏观了解:学习库的核心执行过程
  • 带着目标看源码,比如:
    • VNode是如何创建的
    • VNode是如何渲染成真实DOM的
  • 看源码的过程要不求甚解
    • 看源码的过程要围绕核心目标
    • 因为一个开源项目的功能会非常的多,代码分支逻辑会非常的多,分支会干扰看源码
    • 要先把主线逻辑走通,涉及分支的部分可以先不看
    • 这样可以提高源码的阅读速度
  • 调试
    • 一旦主线逻辑走通,可以写一个小Demo,对代码进行调试,加深理解
  • 参考资料
    • 在看源码之前,可以看别人写的文章,帮助更好理解,以提升源码阅读效率

vscode看源码快捷键

  • 右键-转到定义:快速跳转到定义 或 导入变量的位置
    • 当定义在当前文件中,会跳转到定义的位置
    • 当定义不在当前文件中,会弹出展示定义的小窗口
      • 窗口顶部是定义所在的文件,点击可以快速跳转
    • 当操作时鼠标/光标就是定义 或 导入的位置,同样会弹出定义的小窗口
  • Ctrl + 鼠标左键:效果同【转到定义】
  • F12:效果同【转到定义】
  • Alt + 左右方向键:在跳转历史中前进后退
    • 可用于查看完定义后,快速回到之前的位置

Snabbdom 的核心

  • 使用h()函数创建 Javascript 对象(VNode) 描述真实DOM
  • init() 设置模块,创建patch()
  • patch()根据diff算法比较新旧两个VNode
  • 把变化的内容更新到真实 DOM 树上

Diff 算法

百度百科:

虚拟DOM中采用的算法

把树形结构按照层级分解,只比较同级元素。不同层级的节点只有创建和删除操作。

把列表结构的每个单元添加唯一的key属性,方便比较。

虚拟DOM的目的是实现最小更新。

Diff算法是用于对比虚拟DOM树并更新视图的优化算法。

对比包括两个过程:

  1. 判断新旧节点是否是相同节点
  2. 根据对比结果,将差异更新到视图

完整对比一个树结构数据,时间复杂度是O(n^2)。

n是树的层级。

对于两个树结构的变化,若要达到最小更新,首先要对比每个节点是否相同,也就是:

for (var x = 0; x < n; x++ ) {
  // 遍历旧节点树的层级:n
  var oldVnode = oldTree[x] // 获取该层的某个节点
  for (var y = 0; y < n; y++) {
    // 遍历新节点树的层级:n^2
    var newVnode = newTree[y]
    // 判断oldVnode 和 newVnode 是否相同
    if (oldVnode == newVnode) {
      // ...
    } else {
      //...
    }
  }
}

找到差异后还要计算最小转换方式,将差异渲染到视图中,这个操作又会遍历一遍。(这个过程不知道如何表达)

所以使用完整对比方式的时间复杂度为O(n^3)。

Diff算法的背景是对比新旧DOM树,前端操作中一般不会出现节点跨层级移动或修改。

所以Diff算法只需要对比同层级节点即可。

Snabbdom中根据节点对象的属性 key 和 sel是否相同判断两个节点是否相同,根本不需要判断子节点是否相同。省略了n^2的遍历。

  1. 如果是相同节点,直接更新该节点
    1. 如果有子节点,将每个子节点作为新的树结构继续对比差异
  2. 如果不是相同节点,直接替换或删除

所以Diff算法的时间复杂度为O(n),效率高于 完整对比。

Prev                  Last
          A                     A  
         / \                   / \
        /   \                 /   \
       B     D     ====>     D     B
      /                             \
     C                               C

例如对比上面两个树结构:

  • 完整对比
    • n1 - 判断A是否相同,会先遍历Prev的所有节点
    • n2 - 用Prev的每个节点遍历对比Last的每个节点
    • n3 - 找到差异后,在寻找最小更新到视图的方式
  • Diff
    • n1 - 遍历Prev的每个节点
      • 以Snabbdom为例,判断Prev.node 和 Last.node 的 key 和 sel 是否相同
      • 根据结果直接执行相应处理

源码目录结构

当前查看Snabbdom@0.7.4版本,查看以安装的node_modules/snabbdom目录:

  • dist 存放编译后的结果
  • es 存放编译后的结果
  • helpers 存放编译后的结果
  • modules 存放编译后的结果
  • examples 官方示例
  • src 存放源码
  • 其他文件:
    • 配置文件
    • 打包后的文件(.ts,.js)

snabbdom使用gulp打包。

查看源码过程主要查看 examples 和 src 目录。

examples

Snabbdom官方总共提供4个示例,两个svg 和 两个列表。

  • hero:用于演示自定义模块(与核心流程无关,暂不关心)
  • reorder-animation:演示列表和动画效果
    • build.js:编译之后的结果
    • index.html:网页,在浏览器中打开查看演示效果
    • script.js:源码
src
  • helpers:
    • attachto.ts:定义了一些类型,这些类型在src/vnode.ts中使用(与核心流程无关,暂不关心)
  • modules:官方提供的模块
    • modules.ts:模块中要使用到的钩子函数
    • hero.ts:是 examples/hero 示例中使用的自定义模块
    • 其他官方提供的模块,用于设置属性、样式、事件等
  • h.ts:定义了h()函数
  • hooks.ts:定义了一些钩子函数,与Vue中的生命周期钩子函数类似
  • htmldomapi.ts:封装了一些DOM操作
  • is.ts:包含两个函数,判断是否是数组 和 判断是否是数字或字符串
  • snabbdom.bundle.ts:gulp 打包任务 [bundle:snabbdom] 的入口文件
    • 它默认导出包含两个函数的对象 snabbdomBundle:
      • patch:通过init()注册了attributes class props style eventlisteners 5个模块的补丁函数(没有注册dataset模块)
      • h:h函数
  • snabbdom.ts:gulp 打包任务 [bundle:snabbdom:init] 的入口文件,打包的结果(snabbdom.js)就是 snabbdom@0.7.x 版本使用 CommonJS 方式导入的默认文件。
    • 内部导出3个函数:
      • h
      • thunk
      • init
  • thunk.ts:导出thunk()函数,用于优化。(与核心流程无关,暂不关心)
  • tovnode.ts:导出一个tovnode()函数,用于把DOM元素转化成VNode
  • vnode.ts:定义了Virtual DOM的结构(ts接口),以及导出一个vnode()函数,用于创建vnode

分析源码主要查看 h.ts、 snabbdom.ts、 vnode.ts。

h() 函数 - h.ts

h() 函数介绍
  • h() 函数在使用Vue中的render选项中使用过。
    • vue中使用的 h() 函数改造自snabbdom的h()函数,在它大的基础上增加了组件的机制。
    • 所以vue中的h()函数的使用方式与snabbdom的 h() 函数一样。
  • h() 函数最早见于 hyperscript,它是使用 JavaScript 创建超文本(HyperText:也就是HTML字符串)
  • Snabbdom 中的 h() 函数不是用来创建超文本,而是创建 VNode。
    • Snabbdom 中的 h() 函数来源于 hyperscript 中的 h() 函数,并增强了它。
函数重载

源码中使用到了函数重载的概念

概念

函数重载一般指重载函数。

在Java中,重载函数是指多个同名的函数,它们形参的个数或类型不同,调用这个名称的函数时,编译器根据实参和形参的个数和类型,匹配对应的函数。

这就实现了调用同一个(同名)函数,完成不同的功能。

在JavaScript中没有 重载 的概念,但也可以通过判断 实参和形参的个数和类型,执行对应的功能,来实现 重载

TypeScript中有 重载 ,不过 重载 的实现还是通过代码调整参数。

总结来说,重载指的是对同一个名称的函数进行多次定义,每次定义的形参个数或类型不同,实现一个函数可以完成多个功能。

重载的示意

JavaScript 不支持重载,多次定义同名函数,会被覆盖。

// 定义两个参数个数不同的add函数
function add(a, b, c) {
  console.log(a + b + c)
}

// 这里将重新覆盖add函数
function add(a, b) {
  console.log(a + b)
}

add(1, 2) // 3
add(1, 2, 3) // 3

实现重载:

function add(a, b, c) {
  if (c !== undefined) {
    console.log(a + b + c)
  } else {
    console.log(a + b)
  }
}

add(1, 2) // 3
add(1, 2, 3) // 6

h.ts 源码

snabbdom源码使用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 {
  // 仅仅是对元素子元素的VNodeData添加ns
  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 函数的4种形式的重载
// 只定义了形式,未定义实现,仅仅用于智能提示
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;
// 真正定义了实现的 h 函数
export function h(sel: any, b?: any, c?: any): VNode {
  var data: VNodeData = {}, children: any, text: any, i: number;
  if (c !== undefined) {
	  // 判断传了3个参数的形式
    // 该形式下:
    // b是VNodeData,存储vnode数据的对象
    // c是VNodeChildren
    
    data = b;
    
    // 判断第三个参数
    // 数组:子元素列表
    // 原始类型(字符串/数字):文本节点
    // 包含sel属性的对象:VNode,转化成数组,便于处理
    if (is.array(c)) { children = c; }
    else if (is.primitive(c)) { text = c; }
    else if (c && c.sel) { children = [c]; }
  } else if (b !== undefined) {
    // 判断只传了2个参数的形式
    
    // 判断第二个参数
    // 数组:VNodeChildren
    // 原始类型:文本节点
    // 包含sel属性的对象:VNode
    // 其他情况:VNodeData
    if (is.array(b)) { children = b; }
    else if (is.primitive(b)) { text = b; }
    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(通过vnode函数创建的虚拟节点)
  return vnode(sel, data, children, text, undefined);
};
// 导出模块
export default h;

函数重载:

在真正定义 h 函数之前,定义了 h 函数4种形式的重载。

TypeScript支持重载,而JavaScript不支持重载,TypeScript最终会被编译成JavaScript,所以这里仅仅定义了4个重载的形式,并没有相应的实现

这4种重载相应的实现,都在第五次定义的 h 函数中。

定义4种重载的形式的目的是利用编辑器的智能提示。

导出方式:

源码中使用了两种导出 h 函数的方法 :

  • export function h() {}
  • export default h

这样的目的是为了使用时更方便(主要是snabbdom内部):

  • 既可以通过 import { h } from '...' 导入
  • 也可以通过 import h from '...' 导入

总结:

h() 函数的作用就是调用 vnode() 函数创建并返回一个虚拟节点。

vnode()函数 - vnode.ts

主要关注VNode接口,和 vnode() 函数如何实现。

接口(interface)是TypeScript的语法,它的作用是约束实现这个接口的对象都拥有相同的指定属性。

VNode 接口:

children和text是互斥的,二者只能存在一个,另一个为undefined

  • text:元素间的内容
    • 如果元素间只设置文本内容,可以使用text
  • children:子节点列表
    • 如果元素间想设置元素和文本,需使用children
// 导入依赖的模块
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'

// 定义并导出了3种类型:1个type 和 2个接口
export type Key = string | number;

// 接口:约束实现这个接口类型的对象,都拥有相同的属性
// 虚拟DOM类型
export interface VNode {
  // selector 选择器
  sel: string | undefined;
  // 节点数据:属性/样式/事件等,它的类型由VNodeData接口定义
  data: VNodeData | undefined;
  // 子节点,和 text 互斥
  children: Array<VNode | string> | undefined;
  // element:存储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
}

// 定义vnode函数,创建vnode,它是一个VNode对象
export function vnode(sel: string | undefined,
                      data: any | undefined,
                      children: Array<VNode | string> | undefined,
                      text: string | undefined,
                      elm: Element | Text | undefined): VNode {
  // VNode类型的key属性,由data传递
  let key = data === undefined ? undefined : data.key;
  // 返回与VNode类型匹配的对象,这个对象用于描述虚拟节点
  return {sel, data, children, text, elm, key};
}
// 导出vnode函数
export default vnode;

VNode 渲染真实 DOM

源码在 src/snabbdom.ts 文件中,核心函数是 patch(oldVnode, newVnode),它是整个Snabbdom的核心,俗称“打补丁”。

patch 把新节点中变化的内容渲染到真实DOM,最后返回新节点作为下一次处理的旧节点。

patch 的整体过程
  1. 首先对比新旧VNode是否是相同节点(判断节点的 key 和 sel 相同)
    1. key 是节点的唯一值
    2. sel 是节点的选择器
  2. 如果不是相同的节点,不用对比,直接替换之前的内容,重新渲染。
  3. 如果是相同的节点,再判断新的 VNode 是否有text属性
    1. 如果newVnode有text属性,并且与oldVnode的text不同,直接更新文本内容
    2. 如果newVnode有children属性,判断子节点是否有变化。
      1. 判断子节点的过程使用的是 diff 算法
  4. diff 过程只会进行同层级比较
    1. 因为DOM操作中,很少是跨层级的移动DOM元素,所以Virtual DOM 只会对同一层级的元素进行对比,这也是Snabbdom中对diff算法优化的一部分(React / Vue 同理)。
    2. 由于不需要跨层级比较,效率更高,时间复杂度(n是oldVnode节点数量):
      1. 跨层级完全比较:O(n^3)
        1. 2层对比差异,1层计算最小转换方式
      2. 同层级比较:O(n)

init() 函数 - snabbdom.ts

init() 函数内部返回了 patch() 函数,所以学习 patch() 函数之前,需要看看 init() 函数内部做了哪些工作。

在函数内部返回一个函数,被称为高阶函数。

Vue中旧使用了很多高阶函数

高阶函数的好处是为了让(返回的)函数使用更方便:

  1. 不需要每次调用都传入相同的参数或定义相同的配置
  2. 返回的函数内部使用了外部函数中的参数,形成了闭包,一方面不需要再次传递这些参数,另一方面内存中这些数据只需存储一份即可。

Snabbdom入口函数init生成patch函数,在init函数内部缓存了两个参数,即在返回的patch函数中可以通过闭包访问到init中初始化的模块和DOM操作的api。

// snabbdom.ts
/* global module, document, Node */
// 导入依赖的模块(接口类型 和 方法)
import {Module} from './modules/module';
import {Hooks} from './hooks';
import vnode, {VNode, VNodeData, Key} from './vnode';
import * as is from './is';
import htmlDomApi, {DOMAPI} from './htmldomapi';

// 定义了一些类型 和 辅助函数

function isUndef(s: any): boolean { return s === undefined; }
function isDef(s: any): boolean { return s !== undefined; }

type VNodeQueue = Array<VNode>;

const emptyNode = vnode('', {}, [], undefined, undefined);

function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
  return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel;
}

function isVnode(vnode: any): vnode is VNode {
  return vnode.sel !== undefined;
}

type KeyToIndexMap = {[key: string]: number};

type ArraysOf<T> = {
  [K in keyof T]: (T[K])[];
}

type ModuleHooks = ArraysOf<Module>;

function createKeyToOldIdx(children: Array<VNode>, beginIdx: number, endIdx: number): KeyToIndexMap {
  // 省略的代码
}

// 创建一个常量,存放了钩子函数的名字
const hooks: (keyof Module)[] = ['create', 'update', 'remove', 'destroy', 'pre', 'post'];

// 导出 h thunk init 3个方法
export {h} from './h';
export {thunk} from './thunk';

/**
 * 定义并导出 init 函数
 * @param modules 模块:处理样式、属性、事件等
 * @param domApi (可选)DOM操作的方法
 */
export function init(modules: Array<Partial<Module>>, domApi?: DOMAPI) {
  let i: number, j: number, cbs = ({} as ModuleHooks);

  // 初始化转换虚拟节点的 API

  // 默认是htmlDomApi对象,该对象封装了一些DOM操作的方法
  // 如将虚拟DOM转化为真实DOM,删除、添加、插入DOM等

  // 如果想把虚拟DOM转化为 HTML 字符串 或者 转化成其他类型的内容
  // 可以通过传递 domApi, 传入一些自定义操作,把虚拟DOM转化成具体想要的内容
  const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi;

  // 循环:
  // 把传入的所有模块的钩子函数,统一存储到 cbs(callbacks) 对象中
  // 将同一钩子的函数存储在一起,方便统一调用
  // cbs.create = [], cbs.update = []...
  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]] as Array<any>).push(hook);
      }
    }
  }

  function emptyNodeAt(elm: Element) {
    // 省略的代码
  }

  function createRmCb(childElm: Node, listeners: number) {
     // 省略的代码
  }

  function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
     // 省略的代码
  }

  function addVnodes(parentElm: Node,
                     before: Node | null,
                     vnodes: Array<VNode>,
                     startIdx: number,
                     endIdx: number,
                     insertedVnodeQueue: VNodeQueue) {
     // 省略的代码
  }

  function invokeDestroyHook(vnode: VNode) {
     // 省略的代码
  }

  function removeVnodes(parentElm: Node,
                        vnodes: Array<VNode>,
                        startIdx: number,
                        endIdx: number): void {
     // 省略的代码
  }

  function updateChildren(parentElm: Node,
                          oldCh: Array<VNode>,
                          newCh: Array<VNode>,
                          insertedVnodeQueue: VNodeQueue) {
     // 省略的代码
  }

  function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
     // 省略的代码
  }

  // init 内部返回 patch 函数
  return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
     // 省略的代码
    return vnode;
  };
}

patch() 函数 - snabbdom.ts

patch()函数是Snabbdom的核心,它的作用是对比两个vnode,把vnode的差异渲染到真实DOM,并返回新的vnode。

// init 内部返回 patch 函数
return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
  let i: number, elm: Node, parent: Node;
  // 定义一个常量:存储新插入节点的队列,为了方便触发这些节点上设置的钩子函数
  const insertedVnodeQueue: VNodeQueue = [];
  // 执行所有模块的 pre 钩子函数
  // pre:预处理,处理节点之前先执行的内容
  for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();

  // oldVnode 可以是两种类型: VNode | Element
  // 判断 oldVnode 不是 VNode(那就是Element)
  if (!isVnode(oldVnode)) {
    // 将其转化成 空的VNode
    oldVnode = emptyNodeAt(oldVnode);
  }

  // 判断是否是相同的节点(key和sel相同)
  if (sameVnode(oldVnode, vnode)) {
    // 相同节点
    // patchVnode 找节点的差异并更新 DOM
    patchVnode(oldVnode, vnode, insertedVnodeQueue);
  } else {
    // 不是相同节点:用新节点创建真实DOM,插入旧节点前面,然后删除就旧节点

    // 获取当前 DOM 元素
    elm = oldVnode.elm as Node;
    // 获取当前 DOM 元素的父节点
    parent = api.parentNode(elm);

    // 调用createElm:
    // 1. 将 vnode 转化成真实DOM 并存储到 vnode.elm 属性上
    // 2. 触发 init/create 钩子函数
    // 3. 将新插入的节点(vnode)添加到队列(insertedVnodeQueue)
    createElm(vnode, insertedVnodeQueue);

    if (parent !== null) {
      // 如果存在父节点,在旧节点位置之后插入新节点对应的 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;
};

调试 patch()

使用一开始的代码演示调试patch执行过程,调用patch的位置打断点。

调试中遇到一个小问题:

由于查看项目中的node_modules/snabbdom源码时,直接将注释写在了文件中。

打包调试时,第一次点击F11本应进入patch函数内部,但是缺进入了patchVnode,并且直到执行结束也没有进入patch函数。

而且在patch函数内部无法打断点(行号为灰色)。

在这里插入图片描述

在这里插入图片描述

在网上查找的解决方法说,左下角出现了{}图标,说明调试工具将这个文件认为是压缩过的,所以不能断点调试。

点击{}重新格式化代码即可打断点。

但是尝试后发现会新打开一个:formatted的文件,在里面确实可以打断点,但依然没有进入patch函数内部。

回想自己做的事情,只有添加了注释,于是将源码恢复最初的样子,再次运行,可以正常调试。

所以问题出在添加的注释上,而调试工具中查看源码是通过Source Map。

Source Map存储的是代码行列的映射。

所以问题的原因就是在源文件增加的注释影响了行的位置,但是map文件使用的是模块打包好的,所以在调试时定位位置错误。

于是尝试恢复注释,重新打包生成map文件,再次调试就正常了。

createElm() 函数

在 patch() 函数内部调用了3个复杂的函数:createElm()、patchVnode()、removeVnodes()。

createElm() 函数的作用:

  1. 执行用户设置的 init 钩子函数
  2. 把VNode转换成对应的DOM元素(但是并不负责渲染到视图,渲染视图是调用的api.insetBefore),并存储到VNode对象的elm属性上
    1. sel!时:创建注释节点
      1. 使用的document.createComment方法
    2. sel不为空时:
      1. 创建对应的 DOM 对象
      2. 触发模块的钩子函数 create
      3. createElm 创建所有子节点对应的 DOM 对象
      4. 触发用户的钩子函数 create
      5. 如果 vnode 有 insert 钩子函数,追加vnode到队列
    3. sel为空时:创建文本节点
  3. 返回创建好的DOM元素
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对象,并存储到vnode的elm属性上(没有渲染到页面)
  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) {
    // 选择器不为空

    // 解析选择器:
    // 解析的顺序默认把id选择器放在了class选择器前面
    // 所以sel传值的时候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)的标签
    const elm = vnode.elm = isDef(data) && isDef(i = (data as VNodeData).ns) ? api.createElementNS(i, tag)
    : api.createElement(tag);
    // 设置id
    if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot));
    // 设置class
    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);

    if (is.array(children)) {
      // 如果 vnode 中有子节点,创建子 vnode 对应的 DOM 元素,并追加到 DOM 树上
      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)) {
      // 如果是原始值(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);
      // 判断如果有 insert 钩子函数,就把 vnode 添加到队列中,为后续执行 insert 钩子做准备
      // patch函数在最后负责执行队列中 vnode 的insert钩子函数
      if (i.insert) insertedVnodeQueue.push(vnode);
    }
  } else {
    // 选择器为空,表示是文本节点
    vnode.elm = api.createTextNode(vnode.text as string);
  }
  // 返回创建好的DOM元素
  return vnode.elm;
}

调试 createElm

演示如何传入用户定义的钩子函数

let vnode = h(
  'div#container.cls',
  {
    hook: {
      init(vnode) {
        console.log(vnode.elm)
      },
      create(emptyVnode, vnode) {
        console.log(vnode.elm)
      }
    }
  },
  'hello world!'
)

addVnodes 和 removeVnodes

  • removeNodes():批量删除节点
  • addVnodes():批量添加节点
// 批量添加节点
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) {
      // 如果节点不为空,创建真实DOM,插入到参考节点前
      api.insertBefore(parentElm, createElm(ch, insertedVnodeQueue), before);
    }
  }
}

// 调用模块、节点和子节点的 destroy 钩子函数
function invokeDestroyHook(vnode: VNode) {
  let i: any, j: number, data = vnode.data;
  if (data !== undefined) {
    // 执行用户设置的 destroy 钩子函数
    if (isDef(i = data.hook) && isDef(i = i.destroy)) i(vnode);
    // 执行模块的 destroy 钩子函数
    for (i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode);
    // 如果有子节点并且不是原始值,递归调用invokeDestroyHook,执行 destroy 钩子函数
    if (vnode.children !== undefined) {
      for (j = 0; j < vnode.children.length; ++j) {
        i = vnode.children[j];
        if (i != null && typeof i !== "string") {
          invokeDestroyHook(i);
        }
      }
    }
  }
}

// 批量删除节点
function removeVnodes(parentElm: Node,
                       vnodes: Array<VNode>,
                       startIdx: number,
                       endIdx: number): void {
  // 循环vnodes数组中指定范围的每一个元素
  for (; startIdx <= endIdx; ++startIdx) {
    // ch获取当前遍历的元素
    let i: any, listeners: number, rm: () => void, ch = vnodes[startIdx];
    // 判断元素为null不做任何处理
    if (ch != null) {
      if (isDef(ch.sel)) {
        // 如果是元素节点

        // 执行 destroy 钩子函数
        // invokeDestroyHook 会执行当前节点及所有子节点的 destroy 钩子函数
        invokeDestroyHook(ch);

        // 获取模块中 remove 钩子函数的个数并+1
        // 它的作用是在模块的remove钩子函数全部执行完后执行删除元素,防止多次删除元素
        listeners = cbs.remove.length + 1;
        // createRmCb也是一个高阶函数,创建并返回一个用于删除元素的回调函数
        rm = createRmCb(ch.elm as Node, listeners);

        // 执行模块的 remove 钩子函数
        // 这里rm不会被调用
        for (i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm);

        // 这里rm最终会被调用
        if (isDef(i = ch.data) && isDef(i = i.hook) && isDef(i = i.remove)) {
          // 获取并调用 用户设置 的 remove 钩子函数
          i(ch, rm);
        } else {
          // 如果用户没有设置 remove 钩子函数,直接调用删除元素的回调函数
          rm();
        }
      } else { // Text node
        // 如果是文本节点,直接调用删除元素的方法
        api.removeChild(parentElm, ch.elm as Node);
      }
    }
  }
}

patchVnode() 函数

patchVnode(oldVnode, newVnode) 函数的作用是对比新旧两个节点,更新它们的差异。

整体执行过程:

  1. 触发 prepatch 钩子函数(在对比之前)
  2. 判断新旧节点是否相同
    1. 如果相同,直接返回,什么也不做
  3. 触发 update 钩子函数(在对比之前)
    1. prepatch 和 update 都是在对比之前执行,区别是
      1. update 在判断新旧节点不同时才会执行
      2. prepatch 无论新旧节点是否不同都会执行
  4. 对比节点,判断:
    1. newVnode 没有 text 属性,继续判断
      1. newVnode 和 oldVnode 都有 children
        1. 并且 children 不相等,调用updateChildren()函数对比差异,进行更新。
      2. newVnode 有children,oldVnode 没有
        1. 首先判断当前DOM是否有文本内容(oldVnode 有 text),有的话清空文本内容
        2. 然后调用 addVnodes() 批量添加newVnode的children
      3. newVnode 没有 children,oldVnode 有
        1. 说明新节点是个空节点,直接清空元素内容,由于有子节点,所以调用 removeVnodes() 移除所有子元素
      4. newVnode 和 oldVnode 都没有 childre,oldVnode 有 text
        1. 说明新节点是个空节点,当前DOM元素只有文本内容,使用 setTextContent 清空文本即可
    2. newVnode 有 text 属性
      1. 判断当前DOM是否有子元素,如果有先调用 removeVnodes() 移除全部
      2. 最后调用 setTextContent 设置新的文本内容
  5. 触发 postpatch 钩子函数(在对比之后)
// patch函数内部调用patchVnode之前会判断心就节点是否相同(key和sel相同)
// 如果相同才会调用patchVnode,对比二者的差异
function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
  let i: any, hook: any;
  // 首先执行用户设置的 prepatch 钩子函数
  if (isDef(i = vnode.data) && isDef(hook = i.hook) && isDef(i = hook.prepatch)) {
    i(oldVnode, vnode);
  }
  // 获取旧节点对应的DOM元素
  const elm = vnode.elm = (oldVnode.elm as Node);
  // 获取旧节点的子节点
  let oldCh = oldVnode.children;
  // 获取新节点的子节点
  let ch = vnode.children;
  // 如果新旧节点相同,什么也不做
  if (oldVnode === vnode) return;
  // 暂未发现 vnode.data === undefined 的场景
  if (vnode.data !== undefined) {
    // 执行模块的 update 函数
    for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
    // 执行用户设置的 update 函数
    i = vnode.data.hook;
    if (isDef(i) && isDef(i = i.update)) i(oldVnode, vnode);
  }

  if (isUndef(vnode.text)) {
    // 如果 新节点没有 text
    if (isDef(oldCh) && isDef(ch)) {
      // 新旧节点都有children
      // 但是二者的子节点不同,调用 updateChildren (使用diff算法)对比更新
      if (oldCh !== ch) updateChildren(elm, oldCh as Array<VNode>, ch as Array<VNode>, insertedVnodeQueue);
    } else if (isDef(ch)) {
      // 新节点有children,旧节点没有
      // 先清空DOM元素的内容(只有文本)
      if (isDef(oldVnode.text)) api.setTextContent(elm, '');
      // 批量添加新节点的children
      addVnodes(elm, null, ch as Array<VNode>, 0, (ch as Array<VNode>).length - 1, insertedVnodeQueue);
    } else if (isDef(oldCh)) {
      // 旧节点有children,新节点没有
      // 说明新节点是个空元素,直接移除所有子元素
      removeVnodes(elm, oldCh as Array<VNode>, 0, (oldCh as Array<VNode>).length - 1);
    } else if (isDef(oldVnode.text)) {
      // 旧节点有 text
      // 仅清空文本内容即可
      api.setTextContent(elm, '');
    }
  } else if (oldVnode.text !== vnode.text) {
    // 如果 新节点有 text, 但是与旧节点的text不同
    // 只需更新节点中的内容 为 新的文本内容
    if (isDef(oldCh)) {
      // 如果旧节点有children
      // 移除所有子节点
      removeVnodes(elm, oldCh as Array<VNode>, 0, (oldCh as Array<VNode>).length - 1);
    }
    // 更新文本内容
    api.setTextContent(elm, vnode.text as string);
  }
  // 最后执行用户设置的 postpatch 钩子函数
  if (isDef(hook) && isDef(i = hook.postpatch)) {
    i(oldVnode, vnode);
  }
}

updateChildren() 整体分析

updateChildren() 是整个 Virtual DOM 的核心,内部使用 diff 算法,对比新旧节点的 children,更新DOM 。

同级别对比:

在这里插入图片描述

  • 完全对比两棵树的差异,可以取第一棵树的每个节点依次和第二棵树的每个节点比较,但是这样的时间复杂度为 O(n^3)。
  • 在DOM操作的时候,很少会把一个父节点 移动/更新 到某个子节点。
  • 因此只需要找 同级别 的子节点 依次比较,然后再找下一级别的节点比较,这样算法的时间复杂度为 O(n)。Snabbdomde的diff算法就是这样进行比较的。
执行过程:
  1. 对比环节:遍历节点对比,根据对比节点的结果,进行以下处理
    1. 更新节点
    2. 移动节点
    3. 插入新节点
  2. 批量处理环节:遍历在新旧节点的索引相遇后结束,所以对比处理完,仍然有未遍历到的节点,对这些节点执行批量增删处理,并渲染到视图:
    1. 批量添加 newVnode 中未遍历的节点(表示是新节点)
      1. 比如只在尾部添加节点的情况
    2. 批量移除 oldVnode 中未遍历的节点(表示是要删除的节点)
对比场景:

在这里插入图片描述

  • 在进行同级别比较的时候,首先会对新旧节点数组的开始和结尾节点设置标记索引(startIdx、endIdx),遍历的过程中移动索引。
    • 索引指向的节点分别称为 开始节点结束节点
    • 遍历条件是:新旧节点的索引都未交叉
      • oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx
  • 首先判断节点是否为空,因为在场景Scene5处理节点时,可能会把节点设置为undefined。
    • 如果节点为空,直接更新索引
  • 开始和结束节点 总共有五种比较的场景:
    • Scene1:oldStartVnode / newStartVnode(旧开始节点 / 新开始节点)
    • Scene2:oldEndVnode / newEndVnode(旧结束节点 / 新结束节点)
    • Scene3:oldStartVnode / newEndVnode(旧开始节点 / 新结束节点)
    • Scene4:oldEndVnode / newStartVnode(旧结束节点 / 新开始节点)
    • Scene5:key和sel不能全部匹配,则匹配key,按此规则依次遍历oldStartVnode - oldEndVnode,对比newStartVnode
对比顺序:

5种场景按顺序依次比较节点是否相同(key 和 sel 相同)

  • 如果相同则进行 移动/更新,并更新索引
  • 如果不相同,则比较下一个场景
  • Scence 5 是开始结束节点无法匹配时执行的判断规则
更新移动:

对节点的 更新 / 移动 指的是对 DOM 的操作。

获取新旧节点对比后的差异,直接通过 setTextContent 和 insertBefore 更新视图。

oldVnode只用于对比,并未被改动,所以patch方法最后返回的是 newVnode

根据与 oldVnode 对比的结果进行处理:

  • 对相同的节点(key 和 sel相同),进行数据更新、位置移动
  • 对不同的节点,进行移除、新增
insertBefore方法:

Snabbdom中实现移动节点 和 插入新节点,使用的是 DOM APIs (insertBefore)方法。

insertBefore 被 父节点 调用,接收两个参数:

  • 参数1:要插入的dom元素
    • 要插入的元素如果是页面中已有的元素,而不是新建的或已有元素的副本,则会移动这个已有元素
    • 也就是会更新 parentNode.children的顺序
    • patchVnode 使用 Vnode中的 elm 作为insertBefore的参数,它只是DOM元素的指向,所以VNode中的children并没有被影响,idx 可以继续遍历。
  • 参数2:插入到哪个元素之前
    • snabbdom中使用endVnode.nextSibling获取末尾(而不是最后一个节点),实现向父节点末尾插入元素

以下介绍的对节点的操作都是指的 oldVnode 对象。

对比场景单独介绍:

Scene1Scene2 是开始节点的新旧对比 和 结束节点的新旧对比,两种情况类似:

在这里插入图片描述

Scene1

  • 首先对比 oldStartVnode 和 newStartVnode 是 sameVnode(相同节点:key 和 sel相同)
    • 如果相同就调用 patchVnode() 对比和更新节点
      • 将索引后移:oldStartIdx++ / newStartIdx++
      • 继续对比下一组
    • 如果不相同,则判断 Scene2 场景

Scene2Scene1 类似,只不过方向是从后往前:

  • 对比 oldEndVnode 和 newEndVnode 是 sameVnode
    • 如果相同,就调用 patcheVnode 对比和更新节点
      • 将索引前移:oldStartIdx-- / newStartIdx–
      • 继续对比下一组
    • 如果不同,则判断 Scene3 场景

在这里插入图片描述

Scene3

  • 对比 oldStartVnode 和 newEndVnode 是 sameVnode
    • 如果相同,从节点对象中获取对应的DOM元素,将其右移:
      • 先调用 patchVnode 对比和更新节点
      • 然后调用 domApi.insertBefore 将 oldStartVnode 表示的DOM元素(elm) 移动到 oldEndVnode 表示的DOM元素(elm) 后面
        • insertBefore移动已有元素时,自己仍然占位,所以获取新位置时应该是 oldEndIdx+1 的位置,即oldEndVnode 后面。
        • 注意由于索引会更新,所以此时的startVnode和endVnode不一定就是真正的首尾节点。而应该是start/end索引指向的节点。
      • 更新索引:oldStartIdx++ / newEndIdx–
    • 如果不相同,则判断 Scene4 场景

在这里插入图片描述

Scene4

  • 对比 oldEndVnode 和 newStartVnode 是 sameVnode
    • 如果相同,将DOM元素左移:
      • 先调用 patchVnode 对比和更新节点
      • 然后调用 domApi.insertBefore 将 oldStartVnode.elm 移动到 oldStartVnode.elm 前面
        • 位置的原理参考Scene3
      • 更新索引:oldEndIdx-- / newStartIdx++
    • 如果不同免责判断 Scene5 场景

在这里插入图片描述

Scene5

  • 获取oldStartVnode - oldEndVnode 所有包含key属性的节点的key和索引位置,存储在oldKeyToIdx对象中。
    • 使用 newStartVnode 的 key 在 oldKeyToIdx 中查找相同的 key
      • 如果没找到,说明 newStartVnode 是新节点
        • 调用 createElm 创建新节点对应的真实DOM
        • 调用 domApi.insertBefore,将新建的DOM,插入到 oldStartVnode.elm 前面
        • 更新索引:newStartIdx++
      • 如果找到了,获取这个节点并存储到 elmToMove
        • 判断 elmToMove 和 newStartVnode 的 sel 是否相同
          • 如果不相同,说明节点被修改了
            • 调用 createElm 重新创建新节点对应的真实DOM
            • 调用 domApi.insertBefore,将新建的DOM,插入到 oldStartVnode.elm 前面
          • 如果相同,表示是sameVnode,同 Scene4 同理
            • 先调用 patchVnode 对比和更新节点
            • 将原位置的节点设为undefined(避免被遍历到时再参与对比,设置为undefined而不是移除它,是为了占位,保证索引不会被影响)
            • 然后调用 domApi.insertBefore 将 elmToMove 插入到 oldStartVnode.elm 前面
        • 更新索引:newStartIdx++
  • 执行完进入批量处理环节
批量处理环节:

遍历对比完 oldVnode,最后进行批量判断处理环境。

在这里插入图片描述

在这里插入图片描述

  • 判断停止遍历的场景:
    • oldVnode 遍历完:oldStartIdx > oldEndIdex
      • 说明 newStartVnode - newEndVnode 是未参与遍历的新节点
      • 调用 addVnodes 将它们插入 索引位置 newEndIdx +1 的dom元素前
    • newVnode 遍历完:else(newStartIdx > newEndIdx)
      • 说明 oldStartVnode - oldEndVnode 是未参与遍历的要被删除的节点
      • 调用 removeNodes 将它们从视图中移除
function updateChildren(parentElm: Node,
                          oldCh: Array<VNode>,
                          newCh: Array<VNode>,
                          insertedVnodeQueue: VNodeQueue) {
  // 创建索引 和 索引指向的vnode
  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;

  // 循环遍历
  // 结束条件:旧节点数组先遍历完成 或者 新节点数组先遍历完成
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 索引变化后,可能会把节点设置为空(实际设置为undefined,所以这里是双等号)
    // 设置为空的场景参考文档 Scene5

    // 节点为空移动索引
    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];

      // 场景 Scene1 - Scene4
    } 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];
    } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
      patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
      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];

      // 场景 Scene5
      // 新旧开始节点和结束节点都不相同
    } else {
      // 获取旧节点开始结束之间的所有节点的key和位置,组成键值对对象
      // 使用 newStartVnode 的 key 在旧节点键值对中寻找相同 key 的节点
      if (oldKeyToIdx === undefined) {
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
      }

      // 寻找与newStartVnode相同key的节点
      idxInOld = oldKeyToIdx[newStartVnode.key as string];

      if (isUndef(idxInOld)) { // New element
        // 找不到,说明新节点是新增节点
        api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
        newStartVnode = newCh[++newStartIdx];
      } else {
        // 找到后再判断 sel
        elmToMove = oldCh[idxInOld];
        // sel 不同,说明是不同的节点,按照新增节点处理
        // sel 相同,说明是相同节点,按照 sameVnode 处理,类似Scene3和Scene4
        if (elmToMove.sel !== newStartVnode.sel) {
          api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
        } else {
          patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
          oldCh[idxInOld] = undefined as any;
          api.insertBefore(parentElm, (elmToMove.elm as Node), oldStartVnode.elm as Node);
        }
        newStartVnode = newCh[++newStartIdx];
      }
    }
  }

  // 批量进行添加未遍历的新节点和删除未遍历的旧节点表示的DOM元素
  if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
    if (oldStartIdx > oldEndIdx) {
      // 如果就节点数组先遍历完,说明有未遍历的新节点
      // 把剩余的新节点表示的DOM元素插入
      before = newCh[newEndIdx+1] == null ? null : newCh[newEndIdx+1].elm;
      addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
    } else {
      // 如果新节点数组先遍历完,说明有未遍历的旧节点
      // 把剩余的旧节点表示的DOM元素移除
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
    }
  }
}
key

从源码可知,给VNode设置key之后,当在对元素列表排序,或者给列表插入新项的时候会重用上一次对应的DOM对象,减少渲染次数,因此会提高性能。

也就是Scene5中判断sameVnode的场景。

Modules 源码

  • Snabbdom的核心就是patch()方法,内部调用:patch() -> patchVnode() -> updateChildren()
  • Snabbdom 为了保证核心库的精简,把处理元素的 属性 / 事件 / 样式等工作,放置到模块中。
  • 模块可以按需引入,官方文档
  • 模块实现的核心时基于 Hooks

hooks.ts

Hooks 钩子是 挂载到 DOM 节点生命周期的一种方法。

模块使用钩子来扩展Snabbdom,并在常规代码中使用钩子在虚拟节点声明中的期望点执行任意代码。

源码:src/hooks.ts 文件中定义了Snabbdom中预定义的所有钩子函数。

常用钩子:

  • create - 在把VNode转化成真实 DOM 之前触发
    • 可以用于给DOM元素添加属性 事件等。
  • update - 在patchVnode对比节点之前调用
    • 可以在对比前先去决定两个VNode对应的DOM的属性是否需要变化

使用限制:

  • 模块中允许使用的钩子只有:
    • pre,create,update,destroy,remove,post
  • h函数中定义VNode的数据的hook属性,可以使用:
    • init,create,insert,prepatch,update,postpatch,destroy,remove
import {VNode} from './vnode';

// 定义每个钩子的类型
export type PreHook = () => any;
export type InitHook = (vNode: VNode) => any;
export type CreateHook = (emptyVNode: VNode, vNode: VNode) => any;
export type InsertHook = (vNode: VNode) => any;
export type PrePatchHook = (oldVNode: VNode, vNode: VNode) => any;
export type UpdateHook = (oldVNode: VNode, vNode: VNode) => any;
export type PostPatchHook = (oldVNode: VNode, vNode: VNode) => any;
export type DestroyHook = (vNode: VNode) => any;
export type RemoveHook = (vNode: VNode, removeCallback: () => void) => any;
export type PostHook = () => any;

export interface Hooks {
  // patch 函数开始执行的时候触发
  pre?: PreHook;
  // createElm 函数开始之前的时候触发
  // 在把 VNode 转换成真实 DOM 之前触发
  init?: InitHook;
  // createElm 函数末尾调用
  // 创建完真实 DOM 后触发
  create?: CreateHook;
  // patch 函数末尾执行
  // 真实 DOM 添加到 DOM 树种触发
  insert?: InsertHook;
  // patchVnode 函数开头调用
  // 开始对比两个 VNode 的差异之前触发
  prepatch?: PrePatchHook;
  // patchVnode 函数开头调用
  // 两个 VNode 对比过程中触发,比 prepatch 稍晚
  update?: UpdateHook;
  // patchVnode 的最末尾调用
  // 两个 VNode 对比结束执行
  postpatch?: PostPatchHook;
  // removeVnodes -> invokeDestroyHook 中调用
  // 在删除元素之前触发,子节点的 destroy 也会被触发
  destroy?: DestroyHook;
  // removeVnodes 中调用
  // 元素被删除的时候触发
  remove?: RemoveHook;
  // patch 函数的最后调用
  // patch 全部执行完毕触发
  post?: PostHook;
}

src/modules

  • module.ts - 定义了模块可以使用的钩子函数,并不是所有的钩子函数。
  • hero.ts - 官方示例中自定义的模块。
  • 其他 - Snabbdom预定义的模块(解析其中一个即可)。
// src/modules/module.ts
import {PreHook, CreateHook, UpdateHook, DestroyHook, RemoveHook, PostHook} from '../hooks';

export interface Module {
  pre: PreHook;
  create: CreateHook;
  update: UpdateHook;
  destroy: DestroyHook;
  remove: RemoveHook;
  post: PostHook;
}
attributes.ts
import {VNode, VNodeData} from '../vnode';
import {Module} from './module';

// 定义一些类型
// because those in TypeScript are too restrictive: https://github.com/Microsoft/TSJS-lib-generator/pull/237
declare global {
  interface Element {
    setAttribute(name: string, value: string | number | boolean): void;
    setAttributeNS(namespaceURI: string, qualifiedName: string, value: string | number | boolean): void;
  }
}

export type Attrs = Record<string, string | number | boolean>

// 定义一些常量
const xlinkNS = 'http://www.w3.org/1999/xlink';
const xmlNS = 'http://www.w3.org/XML/1998/namespace';
const colonChar = 58;
const xChar = 120;

// 定义模块核心函数
function updateAttrs(oldVnode: VNode, vnode: VNode): void {
  var key: string, elm: Element = vnode.elm as Element,
      oldAttrs = (oldVnode.data as VNodeData).attrs,
      attrs = (vnode.data as VNodeData).attrs;

  // 如果新旧节点都没有 attrs 属性,直接返回
  if (!oldAttrs && !attrs) return;
  // 如果新旧节点的 attes 属性相同,直接返回
  if (oldAttrs === attrs) return;
  oldAttrs = oldAttrs || {};
  attrs = attrs || {};

  // update modified attributes, add new attributes
  // 遍历新节点所有属性,与旧节点同key的属性对比,更新修改过的属性,添加新增的属性
  for (key in attrs) {
    const cur = attrs[key];
    const old = oldAttrs[key];
    // 如果属性相同就不需做任何操作
    if (old !== cur) {
      // 如果新节点属性值是 布尔类型时:selected checked
      if (cur === true) {
        elm.setAttribute(key, "");
      } else if (cur === false) {
        elm.removeAttribute(key);
      // 新节点属性值不是 布尔类型时
      } else {
        // 判断是否是命名空间的属性:
        // xml xmlns 属性可以在文档中定义命名空间
        // xmlns:<svg xmlns="http://www.w3.org/2000/svg">
        // xml:<html lang="en" xml:lang="en">
        // 具有前缀的命名空间:
        // xmlns:<svg xmlns:xlink="http://www.w3.org/1999/svg">

        // 判断第一个字母是否是‘x’,x的charCode=120
        if (key.charCodeAt(0) !== xChar) {
          // 如果不是,正常设置属性的值
          elm.setAttribute(key, cur);
        } else if (key.charCodeAt(3) === colonChar) {
          // 根据冒号位置判断xml命名空间
          elm.setAttributeNS(xmlNS, key, cur);
        } else if (key.charCodeAt(5) === colonChar) {
          // 根据冒号位置判断xlink命名空间
          elm.setAttributeNS(xlinkNS, key, cur);
        } else {
          // 没有前缀的 xmlns 用正常方式设置
          elm.setAttribute(key, cur);
        }
      }
    }
  }
  // remove removed attributes
  // use `in` operator since the previous `for` iteration uses it (.i.e. add even attributes with undefined value)
  // the other option is to remove all attributes with value == undefined
  // 移除旧的节点中没有在新节点中保留的属性
  // 英文提示这里使用 in 运算符判断,也可以根据 value==undfined 判断
  for (key in oldAttrs) {
    if (!(key in attrs)) {
      elm.removeAttribute(key);
    }
  }
}

// 导出一个包含两个钩子的对象
// 通过两种方式导出,是为了方便使用
export const attributesModule = {create: updateAttrs, update: updateAttrs} as Module;
export default attributesModule;

总结

模块中的工作其实就是定义一些钩子函数,在DOM元素创建好后,对DOM元素做一些额外的操作,例如属性、样式、事件 或 自定义的操作。

使用模块的时机是init()函数的开始位置,遍历传入的所有模块并整理到cbs对象中。

在patch()函数执行到特定时机时,执行cbs中所有的钩子函数。