Snabbdom 源码解析与如何调试(一)

如何学习源码

  • 先宏观了解
  • 带着目标看源码
  • 看源码的过程要不求甚解
  • 调试
  • 参考资料

Snabbdom 的核心

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

Snabbdom 源码

  • 源码地址:

  • src 目录结构

    ├── package
    │   ├── helpers
    │   │   └── attachto.ts        定义了 vnode.ts 中 AttachData 的数据结构
    │   ├── modules
    │   │   ├── attributes.ts
    │   │   ├── class.ts
    │   │   ├── dataset.ts
    │   │   ├── eventlisteners.ts
    │   │   ├── hero.ts                example 中使用到的自定义钩子
    │   │   ├── module.ts            定义了模块中用到的钩子函数
    │   │   ├── props.ts
    │   │   └── style.ts
    │   ├── h.ts                            h() 函数,用来创建 VNode
    │   ├── hooks.ts                    所有钩子函数的定义
    │   ├── htmldomapi.ts            对 DOM API 的包装
    │   ├── init.ts                        加载 modules、DOMAPI,返回 patch 函数
    │   ├── is.ts                            判断数组和原始值的函数
    │   ├── jsx-global.ts            jsx 的类型声明文件
    │   ├── jsx.ts                        处理 jsx
    │   ├── thunk.ts                    优化处理,对复杂视图不可变值得优化
    │   ├── tovnode.ts                DOM 转换成 VNode
    │   ├── ts-transform-js-extension.cjs
    │   ├── tsconfig.json            ts 的编译配置文件
    │   └── vnode.ts                    虚拟节点定义

h 函数

  • h() 函数介绍

    • 在使用 Vue 的时候见过 h() 函数

      new Vue({
        router,
        store,
        render: h => h(App)
      }).$mount('#app')
    • h() 函数最早见于 hyperscript,使用 JavaScript 创建超文本

    • Snabbdom 中的 h() 函数不是用来创建超文本,而是创建 VNode

  • 函数重载

    • 概念

      • 参数个数或类型不同的函数
      • JavaScript 中没有重载的概念
      • TypeScript 中有重载,不过重载的实现还是通过代码调整参数
    • 重载的示意

      function add (a: number, b: number) {
        console.log(a + b)
      }
      function add (a: number, b: number, c: number) {
        console.log(a + b + c)
      }
      add(1, 2)
      add(1, 2, 3)
      function add (a: number, b: number) {
        console.log(a + b)
      }
      function add (a: number, b: string) {
        console.log(a + b)
      }
      add(1, 2)
      add(1, '2')
  • 源码位置:src/package/h.ts

    // h 函数的重载
    export function h (sel: string): VNode
    export function h (sel: string, data: VNodeData | null): VNode
    export function h (sel: string, children: VNodeChildren): VNode
    export function h (sel: string, data: VNodeData | null, children: VNodeChildren): VNode
    export function h (sel: any, b?: any, c?: any): VNode {
      var data: VNodeData = {}
      var children: any
      var text: any
      var i: number
      // 处理参数,实现重载的机制
      if (c !== undefined) {
        // 处理三个参数的情况
        // sel、data、children/text
        if (b !== null) {
          data = b
        }
        if (is.array(c)) {
          children = c
        } else if (is.primitive(c)) {
          text = c
        } else if (c && c.sel) {
          children = [c]
        }
      } else if (b !== undefined && b !== null) {
        if (is.array(b)) {
          children = b
        } else if (is.primitive(b)) {
          // 如果 c 是字符串或者数字
          text = b
        } else if (b && b.sel) {
          // 如果 b 是 VNode
          children = [b]
        } else { data = b }
      }
      if (children !== undefined) {
        // 处理 children 中的原始值(string/number)
        for (i = 0; i < children.length; ++i) {
          // 如果 child 是 string/number,创建文本节点
          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)
    };

VNode

  • 一个 VNode 就是一个虚拟节点用来描述一个 DOM 元素,如果这个 VNode 有 children 就是 Virtual DOM

  • 源码位置:src/package/vnode.ts

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 function vnode (sel: string | undefined,
                      data: any | undefined,
                      children: Array<VNode | string> | undefined,
                      text: string | undefined,
                      elm: Element | Text | undefined): VNode {
  const key = data === undefined ? undefined : data.key
  return { sel, data, children, text, elm, key }
}

snabbdom

  • patch(oldVnode, newVnode)
  • 打补丁,把新节点中变化的内容渲染到真实 DOM,最后返回新节点作为下一次处理的旧节点
  • 对比新旧 VNode 是否相同节点(节点的 key 和 sel 相同)
  • 如果不是相同节点,删除之前的内容,重新渲染
  • 如果是相同节点,再判断新的 VNode 是否有 text,如果有并且和 oldVnode 的 text 不同,直接更新文本内容
  • 如果新的 VNode 有 children,判断子节点是否有变化,判断子节点的过程使用的就是 diff 算法
  • diff 过程只进行同层级比较

image-20200102103653779

init

  • 功能:init(modules, domApi),返回 patch() 函数(高阶函数)

  • 为什么要使用高阶函数?

    • 因为 patch() 函数再外部会调用多次,每次调用依赖一些参数,比如:modules/domApi/cbs
    • 通过高阶函数让 init() 内部形成闭包,返回的 patch() 可以访问到 modules/domApi/cbs,而不需要重新创建
  • init() 在返回 patch() 之前,首先收集了所有模块中的钩子函数存储到 cbs 对象中

  • 源码位置:src/package/init.ts

    const hooks: Array<keyof Module> = ['create', 'update', 'remove', 'destroy', 'pre', 'post']
    export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {
      let i: number
      let j: number
      const cbs: ModuleHooks = {
        create: [],
        update: [],
        remove: [],
        destroy: [],
        pre: [],
        post: []
      }
      // 初始化 api
      const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi
      // 把传入的所有模块的钩子方法,统一存储到 cbs 对象中
      // 最终构建的 cbs 对象的形式 cbs = [ create: [fn1, fn2], update: [], ... ]
        for (i = 0; i < hooks.length; ++i) {
        // cbs['create'] = []
        cbs[hooks[i]] = []
        for (j = 0; j < modules.length; ++j) {
          // const hook = modules[0]['create']
          const hook = modules[j][hooks[i]]
          if (hook !== undefined) {
            (cbs[hooks[i]] as any[]).push(hook)
          }
        }
      }
      ……
      return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
        ……
      }
    }

patch

  • 功能:

    • 传入新旧 VNode,对比差异,把差异渲染到 DOM
    • 返回新的 VNode,作为下一次 patch() 的 oldVnode
  • 执行过程:

    • 首先执行模块中的钩子函数 pre
    • 如果 oldVnode 和 vnode 相同(key 和 sel 相同)
      • 调用 patchVnode(),找节点的差异并更新 DOM
    • 如果 oldVnode 是 DOM 元素
      • 把 DOM 元素转换成 oldVnode
      • 调用 createElm() 把 vnode 转换为真实 DOM,记录到 vnode.elm
      • 把刚创建的 DOM 元素插入到 parent 中
      • 移除老节点
      • 触发用户设置的 create 钩子函数
  • 源码位置:src/package/init.ts

    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)) {
        // 把 DOM 元素转换成空的 VNode
        oldVnode = emptyNodeAt(oldVnode)
      }
        // 如果新旧节点是相同节点(key 和 sel 相同)
      if (sameVnode(oldVnode, vnode)) {
        // 找节点的差异并更新 DOM
        patchVnode(oldVnode, vnode, insertedVnodeQueue)
      } else {
        // 如果新旧节点不同,vnode 创建对应的 DOM
        // 获取当前的 DOM 元素
        elm = oldVnode.elm!
        parent = api.parentNode(elm) as Node
            // 触发 init/create 钩子函数,创建 DOM
        createElm(vnode, insertedVnodeQueue)
    
        if (parent !== null) {
          // 如果父节点不为空,把 vnode 对应的 DOM 插入到文档中
          api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
          // 移除老节点
          removeVnodes(parent, [oldVnode], 0, 0)
        }
      }
        // 执行用户设置的 insert 钩子函数
      for (i = 0; i < insertedVnodeQueue.length; ++i) {
        insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i])
      }
      // 执行模块的 post 钩子函数
      for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()
      return vnode
    }

评论

暂无评论