Virtual DOM 的实现原理


Virtual DOM的实现原理

一、Virtual DOM

1.课程目标

  • 了解什么是虚拟DOM,以及虚拟DOM的作用

  • Snabbdom的基本使用

    Vue内部的虚拟DOM改造了一个开源库Snabbdom

  • Snabbdom的源码解析

2.什么是Virtual DOM

  • Virtual DOM(虚拟DOM),是由普通的JS对象来描述DOM对象

  • 真实DOM成员

    let element = document.querySelector('#app')
    let s = ''
    for (var key in element) {
      s += key + ','
    }
    console.log(s)
    // 打印DIV的所有成员
    align,title,lang,translate,dir,hidden,accessKey,draggable,spellcheck,autocapitalize,contentEditable,isContentEditable,inputMode,offsetParent,offsetTop,offsetLeft,offsetWidth,offsetHeight,style,innerText,outerText,oncopy,oncut,onpaste,onabort,onblur,oncancel,oncanplay,oncanplaythrough,onchange,onclick,onclose,oncontextmenu,oncuechange,ondblclick,ondrag,ondragend,ondragenter,ondragleave,ondragover,ondragstart,ondrop,ondurationchange,onemptied,onended,onerror,onfocus,oninput,oninvalid,onkeydown,onkeypress,onkeyup,onload,onloadeddata,onloadedmetadata,onloadstart,onmousedown,onmouseenter,onmouseleave,onmousemove,onmouseout,onmouseover,onmouseup,onmousewheel,onpause,onplay,onplaying,onprogress,onratechange,onreset,onresize,onscroll,onseeked,onseeking,onselect,onstalled,onsubmit,onsuspend,ontimeupdate,ontoggle,onvolumechange,onwaiting,onwheel,onauxclick,ongotpointercapture,onlostpointercapture,onpointerdown,onpointermove,onpointerup,onpointercancel,onpointerover,onpointerout,onpointerenter,onpointerleave,onselectstart,onselectionchange,onanimationend,onanimationiteration,onanimationstart,ontransitionend,dataset,nonce,autofocus,tabIndex,click,focus,blur,enterKeyHint,onformdata,onpointerrawupdate,attachInternals,namespaceURI,prefix,localName,tagName,id,className,classList,slot,part,attributes,shadowRoot,assignedSlot,innerHTML,outerHTML,scrollTop,scrollLeft,scrollWidth,scrollHeight,clientTop,clientLeft,clientWidth,clientHeight,attributeStyleMap,onbeforecopy,onbeforecut,onbeforepaste,onsearch,elementTiming,previousElementSibling,nextElementSibling,children,firstElementChild,lastElementChild,childElementCount,onfullscreenchange,onfullscreenerror,onwebkitfullscreenchange,onwebkitfullscreenerror,setPointerCapture,releasePointerCapture,hasPointerCapture,hasAttributes,getAttributeNames,getAttribute,getAttributeNS,setAttribute,setAttributeNS,removeAttribute,removeAttributeNS,hasAttribute,hasAttributeNS,toggleAttribute,getAttributeNode,getAttributeNodeNS,setAttributeNode,setAttributeNodeNS,removeAttributeNode,closest,matches,webkitMatchesSelector,attachShadow,getElementsByTagName,getElementsByTagNameNS,getElementsByClassName,insertAdjacentElement,insertAdjacentText,insertAdjacentHTML,requestPointerLock,getClientRects,getBoundingClientRect,scrollIntoView,scroll,scrollTo,scrollBy,scrollIntoViewIfNeeded,animate,computedStyleMap,before,after,replaceWith,remove,prepend,append,querySelector,querySelectorAll,requestFullscreen,webkitRequestFullScreen,webkitRequestFullscreen,createShadowRoot,getDestinationInsertionPoints,ELEMENT_NODE,ATTRIBUTE_NODE,TEXT_NODE,CDATA_SECTION_NODE,ENTITY_REFERENCE_NODE,ENTITY_NODE,PROCESSING_INSTRUCTION_NODE,COMMENT_NODE,DOCUMENT_NODE,DOCUMENT_TYPE_NODE,DOCUMENT_FRAGMENT_NODE,NOTATION_NODE,DOCUMENT_POSITION_DISCONNECTED,DOCUMENT_POSITION_PRECEDING,DOCUMENT_POSITION_FOLLOWING,DOCUMENT_POSITION_CONTAINS,DOCUMENT_POSITION_CONTAINED_BY,DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC,nodeType,nodeName,baseURI,isConnected,ownerDocument,parentNode,parentElement,childNodes,firstChild,lastChild,previousSibling,nextSibling,nodeValue,textContent,hasChildNodes,getRootNode,normalize,cloneNode,isEqualNode,isSameNode,compareDocumentPosition,contains,lookupPrefix,lookupNamespaceURI,isDefaultNamespace,insertBefore,appendChild,replaceChild,removeChild,addEventListener,removeEventListener,dispatchEvent

    可以看到有很多成员,也就是创建一个dom对象的成本非常高。

  • 使用Virtual DOM来描述真实DOM

    创建Virtual DOM的成员很少,创建一个Virtual DOM的开销要比真实的DOM小很多

    // 普通的JavaScript对象
    {
      sel: "div",  // 选择器
      data: {},
      children: undefined,
      text: "Hello Virtual DOM",  // dom文本
      elm: undefined,
      key: undefined
    }

3.为什么使用Virtual DOM

  • 手动操作DOM比较麻烦,还需要考虑浏览器兼容性问题,虽然jQuery等库简化DOM操作,但随着项目的复杂DOM操作负责提升

    jQuery开发的列表demo,列表有三种功能:增加、排序、删除,三种功能增加渐变效果,在2s之内把透明度从0变为1。通过点击增加、排序、删除按钮,发现页面每次都需要闪烁,其内部是重新渲染所有列表,性能消耗较高

  • 为了简化DOM的负责操作,于是出现了各种的MVVM框架,MVVM矿建解决了视图和状态的同步问题

  • 为了简化视图的操作,可以使用模板引擎,但是模板引擎没有解决跟踪状态变化的问题,于是Virtual DOM出现了

  • Virtual DOM的好处是当状态改变时不需要立即更新DOM,只需要创建一个虚拟树来描述DOM,Virtual DOM内部将弄清楚如何有效(diff算法)的更新DOM

    Virtual DOM列表案例演示,可以看到当点击增加时,只有列表第一条在变化,整个页面并没有闪烁。通过Virtual DOM可有效减少DOM操作,Virtual记录上一次状态。

  • 参考GitHub上virtual-dom的描述

    • 虚拟DOM可以维护程序的状态,跟踪上一次的状态
    • 通过比较前后两次状态的差异来更新真实的DOM

4.虚拟DOM的作用

  • 维护视图和状态的关系
  • 负责视图情况下提升渲染性能
  • 跨平台
    • 浏览器平台渲染DOM
    • 服务端渲染SSR(Nuxt.js/Next.js)
    • 原生应用(Weex/React Native)
    • 小程序(mpvue/uni-app)等

5.Virtual DOM库

  • Snabbdom
    • Vue2.x内部使用的Virtual DOM就是改造的Snabbdom
    • 大约200SLOC(Single Line Of Code)
    • 通过模块可扩展
    • 源码使用TypeScript开发
    • 最快的Virtual DOM之一
  • virtual-dom

6.案例演示

二、Snabbdom基本使用

1.创建项目

  • 为了使用简单,使用了 parcel

  • 创建项目

    # windows环境,创建项目目录
    md snabbdom-demo
    # 进入项目目录
    cd snabbdom-demo
    # 创建package.json
    yarn init -y
    # 本地安装parcel
    yarn add parcel-bundler
  • 配置package.json的scripts

    "scripts": {
        "dev": "parcel index.html --open",
        "build": "parcel bundle index.html"
    }
  • 创建项目目录结构

    │  index.html
    │  package.json
    └─src
         01-basicusage.js

2.导入Snabbdom

Snabbdom文档

  • 看文档的意义

    • 学习任何一个库都要 先看文档
    • 通过文档了解库的作用
    • 看文档中提供的实例,自己快速实现一个demo
    • 通过文档查看API的使用
  • 文档地址

    • https://github.com/snabbdom/snabbdom

    • 当前版本v2.1.0

      # --depth 表示克隆深度, 1 表示只克隆最新的版本. 因为如果项目迭代的版本很多, 克隆会很慢
      git clone -b v2.1.0 --depth=1 https://github.com/snabbdom/snabbdom.git

安装Snabbdom

  • 安装Snabbdom

    npm install snabbdom@2.1.0
  • Snabbdom的两个核心函数是inith()

    • init()是一个高阶函数,返回patch()函数
    • h()函数返回虚拟节点VNode,这个函数在Vue.js的时候见过
    import { init } from 'snabbdom/init'
    import { h } from 'snabbdom/h'
    const patch = init([])  // init()函数接受一个参数为数组

    注意:此时运行的话,项目会报错,提示找不到init/h模块,因为模块路径并不是snabbdom/init,这个路径是在package.json中的exports字段设置的,而我们使用的打包工具不支持exports这个字段,webpack4也不支持,webpack5支持该字段。

    该字段在导入snabbdom/init的时候回补全路径成snabbdom/build/package/init.js

    "exports": {
        "./init": "./build/package/init.js",
        "./h": "./build/package/h.js",
        "./helpers/attachto": "./build/package/helpers/attachto.js",
        "./hooks": "./build/package/hooks.js",
        "./htmldomapi": "./build/package/htmldomapi.js",
        "./is": "./build/package/is.js",
        "./jsx": "./build/package/jsx.js",
        "./modules/attributes": "./build/package/modules/attributes.js",
        "./modules/class": "./build/package/modules/class.js",
        "./modules/dataset": "./build/package/modules/dataset.js",
        "./modules/eventlisteners": "./build/package/modules/eventlisteners.js",
        "./modules/hero": "./build/package/modules/hero.js",
        "./modules/module": "./build/package/modules/module.js",
        "./modules/props": "./build/package/modules/props.js",
        "./modules/style": "./build/package/modules/style.js",
        "./thunk": "./build/package/thunk.js",
        "./tovnode": "./build/package/tovnode.js",
        "./vnode": "./build/package/vnode.js"
      }
  • 如果使用不支持package.json的exports字段的打包工具,我们应该把模块路径写全

    • 查看安装的snabbdom的目录结构

      import { h } from 'snabbdom/build/package/h'
      import { init } from 'snabbdom/build/package/init'
      import { classModule } from 'snabbdom/build/package/modules/class'
  • 回顾Vue中的render函数

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

3.代码演示

案例一:使用snabbdom写一个Hello World,使用虚拟DOM在div中放置纯文本内容“Hello World”。

import {init} from 'snabbdom/build/package/init'
import {h} from 'snabbdom/build/package/h'

// init内部返回patch函数,把虚拟DOM渲染成真实DOM并挂载到DOM树上
const patch = init([])

/*
* h函数创建虚拟DOM,这里创建的是VNode虚拟节点,VNode的作用是用来描述真实DOM
* h函数参数:
* 参数一:标签+选择器,字符串形式
* 参数二:如果是字符串时,代表标签中的文本内容
* 此处创建的vnode div将要替换掉index.html中用来占位的#app的div
* */
let vnode = h('div#container.cls', 'Hello World')
let app = document.querySelector('#app')

/*
* patch函数的作用是对比两个VNode,把两个VNode的差异更新到真实DOM上
* 参数一:旧的 VNode或旧的真实DOM,patch函数内部会将真实DOM转换为VNode
* 参数二:新的 Vnode
* 返回值:返回一个新的Vnode,返回的值回作为下次调用patch函数时的第一个参数,老的VNode
* */
let oldVnode = patch(app, vnode)

命令行运行yarn dev,查看结果

再加一个小功能:假设页面上有一个按钮,当点击按钮时,把idcontainerdiv改成Hello Snabbdom,并且更改类样式。

结果演示:

案例二:在div中创建两个子元素:hp标签。

import {init} from 'snabbdom/build/package/init'
import {h} from 'snabbdom/build/package/h'
const 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)

拓展1:

两秒中后更新h标签和p标签中的文本内容

import {init} from 'snabbdom/build/package/init'
import {h} from 'snabbdom/build/package/h'
const 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)

// 两秒中后更新h标签和p标签中的文本内容
setTimeout(() => {
  vnode = h('div.cls', [
    h('h2', 'Hello World'),
    h('p', '这是段落')
  ])
  // 把老的视图更新到新的状态
  patch(oldVNode, vnode)
}, 2000)

拓展2:

两秒后清空div标签的内容

import {init} from 'snabbdom/build/package/init'
import {h} from 'snabbdom/build/package/h'
const 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)

// 两秒后清空div标签的内容
setTimeout(() => {
  // h('!')是创建注释
  vnode = h('!')
  patch(oldVNode, vnode)
}, 2000)

4.模块

Snabbdom 的核心库并不能处理DOM元素的属性/样式/事件等,如果需要处理的话,可以使用模块

常用模块

  • 官方提供了6个模块
    • attributes
      • 设置VNode对应的DOM元素的属性,内部使用DOM的标准方法setAttribute()
      • 内部会对DOM对象的布尔类型的属性做判断,例如:selected/checked等
    • props
      • attributes模块相似,设置DOM元素的属性,内部使用element[attr] = value
      • 内部不能处理布尔类型的属性
    • dataset
      • 设置HTML5中提供的data-*的自定义属性
    • class
      • 不是用来设置类样式的,而是用来切换类样式。如果要设置类样式,可以通过h()的第一个参数来设置h('#div.red', vnode)
      • 注意:给元素设置类样式是通过sel选择器
    • eventListeners
      • 注册和移除事件
    • style
      • 设置行内样式,使用该模块可以很容易设置动画
      • 内部注册了transitionEnd事件
      • delayed/remove/destroy

模块使用

模块的使用步骤:

  • 导入需要的模块
  • init()函数中注册模块
  • 使用h()函数创建VNode的时候,可以把第二个参数设置为对象,其他参数依次后移

代码演示

import {init} from 'snabbdom/build/package/init'
import { h } from 'snabbdom/build/package/h'

// 1.导入所需的模块
import { styleModule } from 'snabbdom/build/package/modules/style'
import { eventListenersModule } from 'snabbdom/build/package/modules/eventlisteners'
// 2.init函数中注册所需的模块
// init()的参数是数组,可以用来传入模块,处理属性、样式、事件等
let patch = init([
  // 注册模块
  styleModule,
  eventListenersModule
])
// 3.使用h()函数创建Vnode
let vnode = h('div.cls', [
  h('h1', {style: {backgroundColor: 'red'}}, '这里是h1标签'),
  h('p', {on: {click: eventHandler}}, '这里是p标签')
])

function eventHandler() {
  console.log('别点我,疼')
}
let app = document.querySelector('#app')
patch(app, vnode)

三、Snabbdom源码解析

接下来学习Snabbdom源码,因为Vue中的虚拟DOM是通过改造Snabbdom实现的。所以看完Snabbdom源码之后,就掌握了Vue中虚拟DOM的实现原理。通过查看Snabbdom源码,可以掌握VNode到底是什么,h()、init()、patch()到底是如何工作的。

1.概述

如何学习源码

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

Snabbdom的核心

  • 使用h()函数创建JavaScript对象(VNode)描述真实DOM
  • init()设置模块,创建patch()
  • patch()比较新旧两个VNode,如果patch函数的第一个参数是真实DOM,首先将真实DOM转换成虚拟DOM,再进行对比
  • 把变化的内容更新到真实DOM树上

Snabbdom源码

  • 源码地址:

  • 克隆代码

    • ```bash
      git clone -b v2.1.0 –depth=1 https://github.com/snabbdom/snabbdom.git
      
      - src目录结构
      
        ```txt
        ├── 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					虚拟节点定义

2.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函数
      add(1, 2, 3)  // 调用第二个add函数
      // 参数类型不同的函数重载
      function add (a: number, b: number) {
        console.log(a + b)
      }
      function add (a: number, b: string) {
        console.log(a + b)
      }
      add(1, 2)  // 调用第一个add函数
      add(1, '2')  // 调用第二个add函数
    • 源码位置: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
        // 处理参数,实现重载的机制
        // 当c参数不为undefined时,正面函数参数个数为3
        if (c !== undefined) {
          // 处理三个参数的情况
          // sel、data、children/text
          if (b !== null) {
            data = b
          }
          // 如果 c 是数组
          if (is.array(c)) {
            children = c
          // 如果 c 是字符串或者数字,是给节点标签中用来显示的内容
          } else if (is.primitive(c)) {
            text = c
          // 如果 c 是VNode
          } else if (c && c.sel) {
            children = [c]
          }
        } else if (b !== undefined && b !== null) {
          // 处理两个参数的情况
          // 如果b是数组
          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)
      };

3.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 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?: 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 {
      const key = data === undefined ? undefined : data.key
      return { sel, data, children, text, elm, key }
    }

4.Snabbdom

patch 整体过程分析

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

init

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

  • 为什么使用高阶函数?

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

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

    // 定义了一些hooks钩子函数的名称,这些钩子函数在init时会被初始化,在特定的时机会被执行
    const hooks: Array<keyof Module> = ['create', 'update', 'remove', 'destroy', 'pre', 'post']
    
    // 参数一:modules,模块数组
    // 参数二:domAPI,用来把VNode对象转换成其他平台下的对应的元素,没有传递时默认设置成DOMAPI(浏览器环境下的dom对象,htmlDomApi)
    export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {
      let i: number
      let j: number
      // callbacks回调函数,存储模块中的钩子函数
      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

      for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()
    • 如果 oldVnodevnode 相同(keysel相同)

      sameVnode

      function sameVnode (vnode1: VNode, vnode2: VNode): boolean {
        // key和sel都相同
        return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel
      }
      • 调用patchVnode(),找节点的差异并更新DOM(后面详细讲解)

        function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
          const hook = vnode.data?.hook
          hook?.prepatch?.(oldVnode, vnode)
          const elm = vnode.elm = oldVnode.elm!
          const oldCh = oldVnode.children as VNode[]
          const ch = vnode.children as VNode[]
          if (oldVnode === vnode) return
          if (vnode.data !== undefined) {
            for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
            vnode.data.hook?.update?.(oldVnode, vnode)
          }
          if (isUndef(vnode.text)) {
            if (isDef(oldCh) && isDef(ch)) {
              if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
            } else if (isDef(ch)) {
              if (isDef(oldVnode.text)) api.setTextContent(elm, '')
              addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
            } else if (isDef(oldCh)) {
              removeVnodes(elm, oldCh, 0, oldCh.length - 1)
            } else if (isDef(oldVnode.text)) {
              api.setTextContent(elm, '')
            }
          } else if (oldVnode.text !== vnode.text) {
            if (isDef(oldCh)) {
              removeVnodes(elm, oldCh, 0, oldCh.length - 1)
            }
            api.setTextContent(elm, vnode.text!)
          }
          hook?.postpatch?.(oldVnode, vnode)
        }
    • 如果oldVnodeDOM元素

      • DOM元素转换成oldVnode

        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)
        }
      • 调用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)) {
        // 通过 emptyNodeAt 把 DOM 元素转换成空的 VNode
        oldVnode = emptyNodeAt(oldVnode)
      }
      // 如果新旧节点是相同节点(key 和 sel 相同)
      if (sameVnode(oldVnode, vnode)) {
        // 找节点的差异并更新 DOM
        patchVnode(oldVnode, vnode, insertedVnodeQueue)
      } else {
        // 如果新旧节点不同,vnode 创建对应的 DOM
        // 获取当前的 DOM 元素,!是TypeScript语法标识这个属性一定有值
        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
    }

createElm

  • 功能

    • createEle(Vnode, insertedVnodeQueue), 返回创建的DOM元素
    • 创建vnode对应的DOM元素,把DOM元素存储在vnodeelm属性中,但是并没有挂载在DOM树上
  • 执行过程

    主要分为三个过程:

    • 执行用户设置的init钩子函数
    • 把VNode转换成真实DOM,并存储在vnode的elm属性上,此时并没有挂载到DOM树上
    • 返回新创建的DOM
    • 首先触发用户设置的init钩子函数
    • 如果选择器是!,创建注释节点
    • 如果选择器为空,创建文本节点
    • 如果选择器不为空
      • 解析选择器,设置标签的idclass属性
      • 执行模块的create钩子函数
      • 如果vnodechildren,创建子vnode对应的DOM,追加到DOM
      • 如果vnodetext值是string/number,创建文本节点并追加到DOM
      • 执行用户设置的create钩子函数
      • 如果有用户设置的insert钩子函数,把vnode添加到队列中
  • 源码位置:src/package/init.ts

    function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
      let i: any
      let data = vnode.data
      
      if (data !== undefined) {
        // 执行用户设置的 init 钩子函数
        const init = data.hook?.init
        if (isDef(init)) {
          init(vnode)
          data = vnode.data
        }
      }
      const children = vnode.children
      const sel = vnode.sel
      if (sel === '!') {
        // 如果选择器是!,创建注释节点
        if (isUndef(vnode.text)) {
          vnode.text = ''
        }
        vnode.elm = api.createComment(vnode.text!)
      } 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.ns)
          ? api.createElementNS(i, tag)  // 一般是SVG
          : api.createElement(tag)  // 创建DOM元素
        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))
        }
        const hook = vnode.data!.hook
        if (isDef(hook)) {
          // 执行用户传入的钩子 create
          hook.create?.(emptyNode, vnode)
          if (hook.insert) {
            // 把 vnode 添加到队列中,为后续执行 insert 钩子做准备
            insertedVnodeQueue.push(vnode)
          }
        }
      } else {
        // 如果选择器为空,创建文本节点
        vnode.elm = api.createTextNode(vnode.text!)
      }
      // 返回新创建的 DOM                                
      return vnode.elm
    }

removeVnodes

function removeVnodes (parentElm: Node,
  vnodes: VNode[],
  startIdx: number,
  endIdx: number): void {
  // 参数一:要删除节点的父节点
  // 参数二:要删除的节点
  // 参数三:开始索引
  // 参数四:结束索引
  for (; startIdx <= endIdx; ++startIdx) {
    //
    let listeners: number
    let rm: () => void
    const ch = vnodes[startIdx]
    if (ch != null) {
      if (isDef(ch.sel)) {
        // 内部触发了vnode的destroy钩子函数
        invokeDestroyHook(ch)
        // 防止重复删除DOM元素
        listeners = cbs.remove.length + 1
        // createRmCb高阶函数,内部返回真正删除dom元素的函数
        rm = createRmCb(ch.elm!, listeners)
        // remove钩子函数内部会真正调用rm(删除DOM元素)
        for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm)
        // 用户是否传入remove钩子函数
        const removeHook = ch?.data?.hook?.remove
        if (isDef(removeHook)) {
          removeHook(ch, rm)
        } else {
          rm()
        }
      } else { // Text node
        api.removeChild(parentElm, ch.elm!)
      }
    }
  }
}

invokeDestroyHook

function invokeDestroyHook (vnode: VNode) {
  const data = vnode.data
  if (data !== undefined) {
    data?.hook?.destroy?.(vnode)
    for (let i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode)
    if (vnode.children !== undefined) {
      for (let j = 0; j < vnode.children.length; ++j) {
        const child = vnode.children[j]
        if (child != null && typeof child !== 'string') {
          invokeDestroyHook(child)
        }
      }
    }
  }
}

createRmCb

function createRmCb (childElm: Node, listeners: number) {
  return function rmCb () {
    if (--listeners === 0) {
      const parent = api.parentNode(childElm) as Node
      api.removeChild(parent, childElm)
    }
  }
}

addVnodes

function addVnodes (
  parentElm: Node,  // 父元素
  before: Node | null,  // 参考节点,vnode定义的节点插入到before之前
  vnodes: VNode[],  // 添加的节点
  startIdx: number,  // 开始索引
  endIdx: number,  // 结束索引
  insertedVnodeQueue: VNodeQueue  // 存储刚刚插入的具有insert钩子函数的vnode节点
) {
  for (; startIdx <= endIdx; ++startIdx) {
    const ch = vnodes[startIdx]
    if (ch != null) {
      api.insertBefore(parentElm, createElm(ch, insertedVnodeQueue), before)
    }
  }
}

patchVnode

  • 功能
    • patchVnode(oldVnode, vnode, insertedVnodeQueue)
    • 对比oldVnodevnode的差异,把差异渲染到DOM
  • 执行过程
    • 首先执行用户设置的prepatch钩子函数
    • 执行create钩子函数
      • 首先执行模块create钩子函数
      • 然后执行用户设置的create钩子函数
    • 如果vnode.text未定义
      • 如果oldVnode.childrenvnode.children都有值
        • 调用updateChildren()
        • 使用diff算法对比子节点,更新子节点
      • 如果vnode.children有值,oldVnode.children无值
        • 清空DOM元素
        • 调用addVnodes(),批量添加子节点
      • 如果oldVnode.children有值,vnode.children无值
        • 调用removeVnode(),批量移除子节点
      • 如果oldVnode.text有值
        • 清空DOM元素的内容
    • 如果设置了vnode.text并且和oldVnode.text不等
      • 如果老节点有子节点,全部移除
      • 设置DOM元素的textContentvnode.text
    • 最后执行用户设置的postpatch钩子函数

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

function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
    const hook = vnode.data?.hook
    // 首先执行用户设置的 prepatch 钩子函数
    hook?.prepatch?.(oldVnode, vnode)
    const elm = vnode.elm = oldVnode.elm!
    const oldCh = oldVnode.children as VNode[]
    const ch = vnode.children as VNode[]
  	// 如果新老 vnode 相同返回
    if (oldVnode === vnode) return
    if (vnode.data !== undefined) {
      // 执行模块的 update 钩子函数
      for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
      // 执行用户设置的 update 钩子函数
      vnode.data.hook?.update?.(oldVnode, vnode)
    }
  	// 如果 vnode.text 未定义
    if (isUndef(vnode.text)) {
      // 如果新老节点都有 children
      if (isDef(oldCh) && isDef(ch)) {
        // 调用 updateChildren 对比子节点,更新子节点
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
      } else if (isDef(ch)) {
        // 如果新节点有 children,老节点没有 children
      	// 如果老节点有text,清空dom 元素的内容
        if (isDef(oldVnode.text)) api.setTextContent(elm, '')
        // 批量添加子节点
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
      } else if (isDef(oldCh)) {
        // 如果老节点有children,新节点没有children
      	// 批量移除子节点
        removeVnodes(elm, oldCh, 0, oldCh.length - 1)
      } else if (isDef(oldVnode.text)) {
        // 如果老节点有 text,清空 DOM 元素
        api.setTextContent(elm, '')
      }
    } else if (oldVnode.text !== vnode.text) {
      // 如果没有设置 vnode.text
      if (isDef(oldCh)) {
        // 如果老节点有 children,移除
        removeVnodes(elm, oldCh, 0, oldCh.length - 1)
      }
      // 设置 DOM 元素的 textContent 为 vnode.text
      api.setTextContent(elm, vnode.text!)
    }
    // 最后执行用户设置的 postpatch 钩子函数
    hook?.postpatch?.(oldVnode, vnode)
  }

updateChildren

  • 功能

    • diff算法的核心,对比新旧节点的children,更新DOM
  • 执行过程:

    • 要对比两棵树的差异,我们可以取第一棵树的没一个节点一次和第二棵树的每一个节点比较,但是这样的时间复杂度为O(n^3)

    • 在DOM操作的时候我们很少很少会把一个父节点移动/更新到某一个子节点

    • 因此只需要找同级别子节点一次比较,然后再找下一级别的节点比较,这样算法的时间复杂度为O(n)

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

    • 在对开始和结束节点比较的时候,总共会有四种情况:

      • oldStartVnode / newStartVnode(旧开始节点 / 新开始节点)

      • oldEndVnode / newEndVnode(旧结束节点 / 新结束节点)

      • oldStartVnode / newEndVnode(旧开始节点 / 新结束节点)

      • oldEndVnode / newStartVnode(旧结束节点 / 新开始节点)

    • 开始节点和结束节点比较,这两种情况类似

      • oldStartVnode / newStartVnode(旧开始节点 / 新开始节点)
      • oldEndVnode / newEndVnode(旧结束节点 / 新结束节点)
    • 如果oldStartVnodenewStartVnodesameVnode(key和sel相同)

      • 调用patchVnode对比和更新节点

      • 把旧开始和新开始索引往后移动oldStartIdx++ / oldEndIdx++

    • oldStartVnode / newEndVnode(旧开始节点 / 新结束节点)相同

      • 调用patchVnode()对比和更新节点

      • oldStartVnode对应的DOM元素,移动到右边

      • 更新索引

    • oldEndVnode / newStartVnode(旧结束节点 / 新开始节点)相同

      • 调用patchVnode()对比和更新节点

      • oldEndVnode对应的DOM元素,移动到左边

      • 更新索引

    • 如果不是以上四种情况

      • 遍历新节点,使用newStartNodekey在老节点数组中找相同节点

      • 如果没有找到,说明newStartNode是新节点

        • 创建新节点对应的DOM元素,插入到DOM树中
      • 如果找到了

        • 判断新节点和找到的老节点的sel选择器是否相同

          • 如果不同,说明节点被修改了
            • 重新创建对应的DOM元素,插入到DOM树中
          • 如果相同,把elmToMove对应的DOM元素,移动到左边

    • 循环结束

      • 当老节点的所有子节点先遍历完(oldStartIdx > oldEndIdx),循环结束
      • 新节点的所有子节点先遍历完(newStartIdx < newEndIdx),循环结束
    • 如果老节点的数组先遍历完(oldStartIdx > oldEndIdx),说明新节点有剩余,把剩余节点批量插入到右边

    • 如果新节点的数组先遍历完(newStartIdx > newEndIdx),说明老节点有剩余,把剩余节点批量删除

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

    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) {
          // 节点为空移动索引
          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]
        // 比较开始和结束节点的四种情况
        } else if (sameVnode(oldStartVnode, newStartVnode)) {
          // 1. 比较老开始节点和新的开始节点
          patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
          oldStartVnode = oldCh[++oldStartIdx]
          newStartVnode = newCh[++newStartIdx]
        } else if (sameVnode(oldEndVnode, newEndVnode)) {
          // 2. 比较老结束节点和新的结束节点
          patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
          oldEndVnode = oldCh[--oldEndIdx]
          newEndVnode = newCh[--newEndIdx]
        } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
          // 3. 比较老开始节点和新的结束节点
          patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
          api.insertBefore(parentElm, oldStartVnode.elm!, api.nextSibling(oldEndVnode.elm!))
          oldStartVnode = oldCh[++oldStartIdx]
          newEndVnode = newCh[--newEndIdx]
        } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
          // 4. 比较老结束节点和新的开始节点
          patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
          api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!)
          oldEndVnode = oldCh[--oldEndIdx]
          newStartVnode = newCh[++newStartIdx]
        } else {
          // 开始节点和结束节点都不相同
          // 使用 newStartNode 的 key 再老节点数组中找相同节点
          // 先设置记录 key 和 index 的对象
          if (oldKeyToIdx === undefined) {
            oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
          }
          // 遍历 newStartVnode, 从老的节点中找相同 key 的 oldVnode 的索引
          idxInOld = oldKeyToIdx[newStartVnode.key as string]
          // 如果是新的vnode
          if (isUndef(idxInOld)) { // New element
            // 如果没找到,newStartNode 是新节点
            // 创建元素插入 DOM 树
            api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
          } else {
            // 如果找到相同 key 相同的老节点,记录到 elmToMove 遍历
            elmToMove = oldCh[idxInOld]
            if (elmToMove.sel !== newStartVnode.sel) {
              // 如果新旧节点的选择器不同
              // 创建新开始节点对应的 DOM 元素,插入到 DOM 树中
              api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
            } else {
              // 如果相同,patchVnode()
              // 把 elmToMove 对应的 DOM 元素,移动到左边
              patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
              oldCh[idxInOld] = undefined as any
              api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!)
            }
          }
          // 重新给 newStartVnode 赋值,指向下一个新节点
          newStartVnode = newCh[++newStartIdx]
        }
      }
      // 循环结束,老节点数组先遍历完成或者新节点数组先遍历完成
      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)
        } else {
          // 如果新节点数组先遍历完成,说明老节点有剩余
          // 批量删除老节点
          removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
        }
      }
    }

调试updateChildren

<ul>
  <li>首页</li>
  <li>微博</li>
  <li>视频</li>
</ul>

<ul>
  <li>首页</li>
  <li>视频</li>
  <li>微博</li>
</ul>

文章作者: 5coder
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 5coder !
  目录