前端入门到弃坑:虚拟dom的实现原理

虚拟 DOM 的实现原理主要包括以下 3 部分:

  • 用 JavaScript 对象模拟真实 DOM 树,对真实 DOM 进行抽象;
  • diff 算法 — 比较两棵虚拟 DOM 树的差异;
  • pach 算法 — 将两个虚拟 DOM 对象的差异应用到真正的 DOM 树。

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
 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.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'

案例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】
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Snabbdom-demo</title>
    </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】
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Snabbdom-demo</title>
    </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)

  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.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                    虚拟节点定义

 1: h() 函数
   h() 函数介绍:

    作用:h() 函数用来 创建 VNode对象 ,并返回VNode 对象
    Vue  中的 h()  函数

    new Vue({
      router,
      store,
      render: h => h(App) // h() 创建虚拟 DOM
    }).$mount('#app')

    h() 函数最早见于  hypeScript ,使用 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<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 }
    }

 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<keyof Module> = ['create', 'update', 'remove', 'destroy', 'pre', 'post']
    // modules: 模块数组; domApi:把vnode转化为其他平台API,如果不传,默认是转化为浏览器dom API
    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 {
        ……
      }
    }

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 属性 ,并且新旧 VNode 的 text 属性不同,就把新节点的 text 属性更新到 DOM 上

7: updateChildren

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

diff算法

    理解:

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

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

            3.比较都有儿子的情况

            4.递归比较子节点

 总结:

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

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

    学习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的重要性)

 响应式过程:


DIFF算法

 


原文链接:https://blog.csdn.net/qq_37833745/article/details/120440897

原文链接  https://www.cnblogs.com/Rivend/p/12630569.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值