虚拟DOM的实现原理【1-4】

前言

当浏览器获取了一个 HTML 文档后,浏览器内核 webkit 的 WebCore 层中的 HTML 引擎会将该文档解析为 DOM 树,解析出的 DOM 树会交给同处在 WebCore 层的 DOM 模块负责管理,这里的 DOM 树就是真实 DOM。 

我们在前端的刀耕火种的时代,都是直接使用 JavaScript 直接操作 DOM,但 Dom 树实际是由 DOM 模块负责管理的,浏览器内核中单独有一块内存来存储 DOM 树,但 JavaScript 引擎和这块内存并无关系,也就是它并不能直接的操作真实的 DOM 树。

为了给予 JavaScript 操作 DOM 的能力,浏览器在全局对象 window 上为 JavaScript 封装了一个 document 对象,在这个对象上提供了大部分 DOM 操作 API(接口由 C++ 实现)。

虚拟DOM的实现原理【1-4】_第1张图片

1: 什么是虚拟DOM

      虚拟DOM:是由普通 JS 对象 来描述 DOM 对象 ,因为不是真实的 DOM,所以叫做虚拟 DOM。

     创建/操作真实 DOM ,资源开销是特别大的,而仅仅使用普通对象来模拟虚拟 DOM,能够有效减少对真实 DOM 的操作,从而提升网页性能。

虚拟DOM的实现原理【1-4】_第2张图片

2: 为什么要虚拟DOM?

  • 手动操作 DOM 比较麻烦,还需要考虑浏览器兼容性问题,虽然有 JQuery 等库简化 DOM 操作 ,但是随着项目的复杂,DOM 操作复杂提升,比较前端开发刀耕火种的年代
  • 为了简化 DOM 的复杂操作,于是出现了各种 MVVM 框架,MVVM 框架解决了 视图 和 状态同步问题
  • 为了简化 视图 的操作,可以使用模版引擎,但是模版引擎没有解决跟踪状态变化的问题,于是虚拟 DOM 出现了
  • 虚拟 DOM 的好处是当状态改变时,不需要立即更新 DOM ,只需要创建一个虚拟树来描述 DOM ,虚拟 DOM 内部将弄清楚如何有效( diff )的更新 DOM
  •  虚拟 DOM 可以维护程序的状态,跟踪上一次的状态,通过比较前后两次状态的差异更新真实 DOM
  • 参考 github 上 virtual-dom 的描述

当我们通过这些接口操作 DOM 时, JavaScript 并没有直接和 DOM 模块交互,它调用浏览器的 DOM API后,是由浏览器来操作 DOM 模块,再把操作结果返回给 JavaScript 引擎的,而正是由于 JavaScript 需要借助 DOM 接口才能操作真实 DOM,所以操作真实 DOM 的代价是巨大的:

  1. JavaScript 直接调用的是 C++ 实现的接口;
  2. 真实 DOM 树的节点较多,体积较为庞大,真实 DOM 操作过程资源开销成本很大;
  3. 修改真实 DOM 经常导致页面重绘,操作 DOM 越多,网页性能越差;

3:  虚拟 DOM 的作用

      创建/操作真实 DOM ,资源开销是特别大的,因此 

  •  我们可以在状态发生变化需要更新视图时,使用虚拟 DOM 在内存中完成修改,减少对真实 DOM 的直接操作次数, 维护 视图状态 的关系
  •  我们还可以通过 diff 算法比较修改后的虚拟 DOM 和修改前的真实 DOM 的差异,只需去更新真实 DOM 中发生变化的位置,较少重绘次数,提升渲染性能
  •  其它:虚拟 DOM 还作用于  服务端渲染 SSR、原生应用 React Native、小程序 uni-app 等其它方面;

               虚拟DOM的实现原理【1-4】_第3张图片

问题思考:使用 虚拟 DOM 一定 比不使用 虚拟DOM 性能好吗??

1: 使用虚拟DOM,在首次渲染的时候会影响性能,因为要创建额外的对象来描述真实DOM

2: 在更新少量标签的时候,也不会有性能上的提升,在DOM 结构复杂的时候更新 DOM,才会有明显的性能提升,因为仅仅将 前后两次 DOM 树 的差异 更新到真实DOM

4: Virtual DOM【虚拟DOM】库

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

Snabbdom

  •   Vue 2.x 内部使用的 虚拟 DOM 就是改造的 Snabbdom
  •   通过snabbdom的模块可扩展处理属性/样式/事件功能,
  •   源码使用 TypeScript 开发
  •   最快的 Virtual DOM 之一,大约 200 SLOC(single line of code)

virtual-dom

       案例演示     jQuery-demo    和    snabbdom-demo 

5: Snabbdom 

    项目代码链接 

    1: 创建项目,并安装 parcel (打包工具)   

  # 创建项目目录
  md snabbdom-demo
  # 进入项目目录
  cd snabbdom-demo
  # 创建 package.json
  npm init -y
  # 本地安装 parcel
  npm install parcel-bundler -D

    2: 配置 package.json scripts

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

    3: 创建目录结构  

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

Snabbdom 的基本使用 

1: Snabbdom 文档

  文档地址

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

2: 安装  Snabbdom  

npm install snabbdom@2.1.0

3: 导入 Snabbdom 

 1: Snabbdom 2个核心函数 init 和 h()

  • init() 是一个高阶函数(函数返回函数),返回 patch
  • h() 返回虚拟节点 VNode,我们在 Vue.js 中有见到过
  • 回顾 Vue 中的 render 函数
    new Vue({
      router,
      store,
      render: h => h(App)
    }).$mount('#app')

 2: 导入方式  

  • 文档中导入的方式
import { init } from 'snabbdom/init'
import { h } from 'snabbdom/h'
const patch = init([])

 注意:此时运行的话会告诉我们找不到 init / h 模块,因为模块路径并不是 snabbdom/init,             这个路径是在 package.json 中的 exports 字段设置的,而我们使用的打包工具不支持             exports 这个字段,webpack 4 也不支持,webpack 5 支持该字段。该字段在导入                 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"
  }
  • 实际导入的方式 (parcel / webpack 4 不支持 package.json 中的 exports)

  如果使用不支持 package.jsonexports 字段的打包工具,我们应该把模块的路径写全

  查看安装的 snabbdom 的目录结构

import { h } from 'snabbdom/build/package/h'
import { init } from 'snabbdom/build/package/init'
import { classModule } from 'snabbdom/build/package/modules/class'

案例1:

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

const patch = init([])

// 第一个参数:标签+选择器
// 第二个参数: 如果是字符串就是文本中的内容
let vnode = h('div#container.cls', {
    hook: {
      init (vnode) {
        console.log(vnode.elm)
      },
      create (emptyNode, vnode) {
        console.log(vnode.elm)
      }
    }
  }, 'hello world')
 
let app = document.querySelector("#app")

// 第一个参数:旧的VNode 或者 DOM元素
// 第二个参数: 新的VNode 
// 对比新旧2个VNode,将差异部分更新到视图中,将新的VNode返回,在下一次调用patch时当作旧的VNode
let oldVNode = patch(app, vnode)

vnode = h('div#container.cls', 'hello i am new')

patch(oldVNode, vnode)
【index.html】

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

案例2:

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', '我是一个p')
])
let app = document.querySelector('#app')
let oldVNode = patch(app, vnode)


// 2s 后更新
setTimeout(() => {
    vnode = h('div#container', [
        h('h1', 'Hello World'),
        h('p', 'Hello p')
    ])
    patch(oldVNode, vnode)
}, 2000)

// 4s 清空div中的内容
setTimeout(() => {
    patch(oldVNode, h('!'))
}, 4000)

【index.html】

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

4:  Snabbdom 中的模块

   1: 模块的作用:

        a:Snabbdom 中的核心库并不能处理 DOM 元素的属性/样式/事件等,可以通过注册 Snabbdom默认提供的 模块 来实现
        b:模块可以用来 扩展 Snabbdom的功能
        c:  模块的实现是通过 注册全局的钩子函数 来实现的
        d:  我们可以自己添加模块

    

官方提供了 6 个模块

  attributes
    设置 DOM 元素的属性,使用  setAttribute()
    处理布尔类型的属性
  props
    和 attributes 模块相似,设置 DOM 元素的属性 element[attr] = value
    不处理布尔类型的属性
  class
    切换类样式
    注意:给元素设置类样式是通过  sel  选择器
  dataset
    设置  data-*  的自定义属性
  eventlisteners
    注册和移除事件
  style
    设置行内样式,支持动画
    delayed/ remove/ destroy

   2: 模块的的使用步骤            

  • 导入需要的模块
  • init () 注册模块
  • h() 的第二个参数 使用模块 

   3: 案例

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

// 导入模块
import { styleModule } from 'snabbdom/build/package/modules/style'
import { eventListenersModule } from 'snabbdom/build/package/modules/eventlisteners'

// 注册模块
let patch = init([
    styleModule,
    eventListenersModule
])

// 使用模块:使用h()的第二个参数传入模块中使用的数据(对象)
let vnode = h('div', [
    h('h1', { style: { backgroundColor: 'red' } }, 'hello module'),
    h('p', { on: { click: eventHandler } }, 'Hello P')
])

function eventHandler () {
    console.log('别点我,我疼')
}

let app = document.querySelector("#app")
patch(app, vnode)

5:  Snabbdom 源码解析

   Snabbdom 的核心

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

 Snabbdom 源码          

  •   源码地址:https://github.com/snabbdom/snabbdom   (版本:v2.1.0)
  •   克隆代码:git clone -b v2.1.0 --depatch=1 https://github.com/snabbdom/snabbdomhttps://github.com/snabbdom/snabbdom     

   src目录结构

  ├── package
  │   ├── helpers
  │   │   └── attachto.ts		定义了 vnode.tsAttachData 的数据结构
  │   ├── 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.tsDOM API 的包装
  │   ├── init.ts						加载 modulesDOMAPI,返回 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					虚拟节点定义

 1: h() 函数

   h() 函数介绍:

  • 作用:h() 函数用来 创建 VNode对象 ,并返回VNode 对象
  • Vue  中的 h()  函数
new Vue({
  router,
  store,
  render: h => h(App) // h() 创建虚拟 DOM
}).$mount('#app')
  • h() 函数最早见于 https://github.com/snabbdom/snabbdom,使用 JavaScript  创建 超文本(html)

   函数重载

  • 概念

        参数 个数/类型 不同的函数
        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
// sel: any表示可以是任何类型     b?: any 表示可以是任何值,也可以不传
export function h (sel: any, b?: any, c?: any): VNode {
  var data: VNodeData = {}
  var children: any
  var text: any
  var i: number
  // 处理参数,实现重载的机制,c不是undefined,证明有三个参数
  if (c !== undefined) {
    // 处理三个参数的情况
    // sel、data、children/text
    if (b !== null) {
      data = b
    }
    // c是数组,表示有子元素
    if (is.array(c)) {
      children = c
    } else if (is.primitive(c)) { // c是字符串或者数字
      text = c
    } else if (c && c.sel) { // c是VNode
      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:  h函数通过调用vnode方法,创建vnode对象,并返回vnode对象
  return vnode(sel, data, children, text, undefined)
};

2: VNode       

  • 概念一个VNode 就是一个虚拟节点,用来描述一个 DOM 元素, 如果这个VNode有 children 就是 Virtual DOM
  • 源码位置src/package/vnode.ts
export interface VNode {
  // 选择器
  sel: string | undefined;
  // 节点数据:属性/样式/事件等
  data: VNodeData | undefined;
  // 子节点,和 text 只能互斥
  children: Array | undefined;
  // 记录 vnode 对应的真实 DOM
  elm: Node | undefined;
  // 节点中的内容,和 children 只能互斥
  text: string | undefined;
  // 优化用
  key: Key | undefined;
}

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

 3: init (module, domApi)

  • 功能:init (module, domApi)  返回 patch() 函数(高阶函数)  
let patch = init([])
  • 为什么要高阶函数?

patch() 函数在外部会被多次调用,每次调用依赖一些参数,如:module/domApi/cbs;

因为高阶函数让 init() 内部形成闭包,返回的 patch() 可以访问 modules/domApi/cbs,而不需要重新创建

init() 在返回 patch() 之前,首先收集了所有模块中的钩子函数存储到 cbs 对象中  

  • 源码位置src/package/init.ts 
const hooks: Array = ['create', 'update', 'remove', 'destroy', 'pre', 'post']
// modules: 模块数组; domApi:把vnode转化为其他平台API,如果不传,默认是转化为浏览器dom API 
export function init (modules: Array>, 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 {
    ……
  }
}

4: patch (oldVNode, newVNode) 

  • 作用传入新旧 VNode ,对比差异,将差异渲染到 DOM,返回新的 VNode,作为下一次执行 patch() 的 oldVNode
  • 执行过程分析 
  •  对比新旧 VNode 节点是否相同( 节点的 key 和 sel 相同),调用patchVnode() 找节点的差异,并更新 DOM
  •  如果不是相同节点,删除之前的内容,重新渲染
  •  如果是相同节点,判断新的 VNode  中是否有 text,如果有且和 oldVNode 中 text不同,则直接更新文本内容 
  • 如果 oldVnode 是DOM元素, a: 把 DOM 元素转化为 oldVnode;     b: 调用createElm()把 vnode 转化为真实DOM,记录到vnode.elm;    c: 把刚创建的 DOM 元素插入到 parent 中;   d:移除老节点
  •  如果新的 VNode 有 children,判断子节点是否有变化,判断子节点的过程就是diff算法,diff 过程只进行同层级比较

    总结:Snabbdom 中的 patch 函数是通过 Snabbdom 的入口函数 init 生成的,init 中初始化 模块DOM 操作的 api,最终返回 patch, 这里的 init 是一个高阶函数,在 init 内部缓存了2个参数, 在返回的 patch 中可以通过 闭包 访问到 init 中 初始化的 模块 和 DOM 操作的 api

  • 源码位置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
}

 5: createElm

  • 功能:createElm(vnode,  insertedVnodeQueue) ,创建 vnode 对应 的 DOM 对象,并返回创建的 DOM 对象
  • 注意:没有吧新创建的DOM,挂载到 DOM 树,而是先存储到当前 VNode 对象的 elm 属性中
  • 执行过程

       如果选择器是!,创建评论节点

       如果选择器是空,创建文本节点

       如果选择器不为空

               解析选择器,设置标签的 id 和 class 属性                    

6: patchVnode 

  • 功能:patchVnode(oldVnode,  vnode,  insertedVnodeQueue)  ,对比 oldVnode 和 vnode 的差异,把差异渲染到 DOM
  • 注意:对比新旧节点,如果节点本身都没有 text 属性,再去 对比他们的的子节点,如果有 text 属性 ,并且新旧 VNodetext 属性不同,就把新节点的 text 属性更新到 DOM

7: updateChildren

  • 功能: diff 算法的核心,对比新旧 VNode 节点的 子节点children,更新DOM
  • 要点: 在对比过程中因为DOM 操作的特殊性,同时也为了优化操作,所有只对比 两棵树中的 同一层级的子节点 

diff算法

理解:

        1.先同级比较,再比较子节点

        2.先判断一方有儿子一方没儿子的情况

        3.比较都有儿子的情况

        4.递归比较子节点


 总结:

     VDOM原理:因为js的执行速度是非常快的,所以VDOM就是用JS模拟DOM结构,计算出最小的变更(这个对比算法就是DIFF),操作DOM;

     DOM结构可以用JSON模拟出来,类似XML;下图需要能写出来  

虚拟DOM的实现原理【1-4】_第4张图片

学习VDOM利用 snabbdom

  1、DIFF算法例如 v-for 的key为什么必须要;就讲讲DIFF算法

DIFF比较算法

  1、只比较同一层级,不跨级比较

  2、tag不相同,则这接删掉重建,不再深度比较

  3、tag 和 key,两者都相同,则认为是相同节点,不再深度比较

DIFF源码的核心

  1、pathVnode(对比vnode 和 oldVnode,把差异渲染到dom)

  2、addVnodes , removeVnodes

  3、updateChildren(key的重要性)

虚拟DOM的实现原理【1-4】_第5张图片

虚拟DOM的实现原理【1-4】_第6张图片

你可能感兴趣的:(vue,html,css,html5,vue,vue.js)