Virtual DOM的实现原理

  • 了解虚拟DOM,以及虚拟DOM的作用
  • Snabbdom的基本使用
  • Snabbdom的源码解析

虚拟DOM的作用和虚拟DOM库

h函数的用法h函数创建虚拟dom

  • 第一个参数:标签+选择器

  • 第二个参数:

    • 可以是一个数组,数组中的每一个元素也都是一个Vnode
    let vnode = h('div#container',[
        h('h1','Hello Snabbdom'),
        h('p','这是一个p')
    ])
    
    • 如果是字符串就是标签的文本内容
    let vnode = h('div#container.cls','Hello World')
    

patch函数对比dom的差异

  • patch 对比两个vnode把两个vnode的差异更新到真实dom上
  • 第一个参数:旧的vnode,可以是一个真实的Dom,会将真实dom转为vnode
  • 第二个参数:vnode
  • 返回新的VNODE,可以作为下一次调用时老的VNODE
let app = document.querySelector('#app')
let oldVnode = patch(app,vnode)

snabbdom模块的使用

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. 注册模块
const patch = init([
    styleModule,
    eventListenersModule
])

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

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

let app = document.querySelector('#app')

patch(app,vnode)

snabbdom源码解析

  1. 克隆源码

    git clone -b v2.1.0 --depth=1 https://github.com/snabbdom/snabbdom.git

  2. 安装依赖

  3. 编译

  4. 新版本的snabbdom的h函数的事件件绑定不在支持以数组的方式传递参数,改成用函数的方式调用传参

h('div.btn.rm-btn', { on: { click: [remove, movie] } }, 'x')
// 改为
h('div.btn.rm-btn', { on: { click: ()=>{
    remove(movie)
} } }, 'x')
// 其它同理

项目结构

  • src
    • package 源码目录
      • helpers
        • attachto.ts vnode中使用的attachData的数据结构
      • modules 模块
        • attributes.ts 绑定属性boolean类型的属性
        • class.ts 切换类名
        • dataset.ts 处理html5中提供的data-这样的自定义属性
        • eventlisteners.ts 事件的绑定
        • hero.ts hero示例里演示用的自定义模块
        • module.ts 定义模块中所有使用到的钩子函数
        • props.ts 文本属性
        • style.ts 内链样式
      • h.ts h函数
      • hooks.ts vnode中所有的声明周期函数
      • htmldomapi.ts 对domapi的包装创建元素,删除元素等
      • init.ts init函数,用来加载模块 domapi 返回patch函数
      • is.ts 辅助模块 判断数组和原始值
      • jsx-global.ts jsx类型声明文件
      • jsx.ts 处理jsx
      • thunk.ts dui 优化处理 对复杂视图不可变值的优化
      • tovnode.ts 将dom转换为vnode
      • ts-transform-js-extension.cjs 编译时的配置文件
      • tsconfig.json 编译时的配置文件
      • vnode.ts 定义vnode的结构
    • test 单元测试

h函数

只是简单的对函数的参数进行判定,然后调用vnode方法

// 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
    // 如果c是字符串或者数字
    } else if (is.primitive(c)) {
      text = c
    // 如果c是VNode
    } else if (c && c.sel) {
      children = [c]
    }
  } else if (b !== undefined && b !== null) {
    if (is.array(b)) {
      children = b
    } else if (is.primitive(b)) {
      // 如果 b 是字符串或者数字
      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接口用来约束VNode对象的属性

  • sel 选择器
  • data 模块中所需要的数据
  • children 跟text 属性互斥 描述子节点
  • elm 存储当前VNode对象转化之后的dom元素
  • text 与children互斥 记录 对应文本节点的文本内容
  • key 唯一标识当前的VNode对象

VNodeData接口

约束data属性的类型,属性后的问好表示当前的属性可以为空

vnode函数

接收5个参数,返回VNode对象,VNode对象有六个属性,key通过data(data.key)进行赋值

patch整体过程分析

patch源码在init.ts文件中,patch俗称打补丁

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

init

返回path函数

hooks数组,存储钩子函数的名称

init函数的参数

  • module:数组 存储一些钩子函数,存储在cbs中
  • domApi:用来把VNode对象转换成其它平台下对应的元素,默认为操作浏览器平台dom元素的api,

初始化 cbs对象 用来存储模块中的钩子函数 cbs --> {create:[fn1,fn2],update:[fn1,fn2],...}

init函数返回path函数将mdoules domApi缓存 ,init函数是一个高阶函数返回patch函数,调用path函数是只需要传递oldVnode,newVnode就可以了

patch

第一个参数可以是一个真实Dom

insertedVnodeQueu常量用来存储新插入对象的队列,用来触发Vnode的insert钩子函数

遍历cbs对象,触发pre函数

判断oldVnode是否是Vnode对象,判断是否有sel对象,如果没有就将其转换为一个Vnode对象,将dom元素的id和class转换成选择器的形式,调用vnode函数

判断是否是相同节点(判断vnode的key与sel是否相同),如果是调用patchVnode寻找两个节点的差异,跟新差异到dom上。如果不是创建newVnode对应的dom元素,插入新创建的dom元素,移除老元素,首先或去oldVnode的dom元素(ts语法!标识一个属性一定是有值的),然后获取dom元素的父元素,用于挂在新创建的元素,创建新的dom元素,判断parent是否为空插入新元素

触发对应的钩子函数,遍历insertedVnodeQueue这个队列里存储的是具有insert钩子函数的新的Vnode节点,队列中的元素实在createElement函数中添加的,insert钩子函数是在Vnode的data属性中获取的,即是用户传递过来的,即对应的节点添加的dom树上之后用户可以执行对应的操作

遍历cbs中的post钩子函数,触发模块中的post钩子函数

返回新的vnode

patch函数内部调用的createElm函数

作用把Vnode对象转换为对应的dom元素,把dom元素存储在Vnode的elm属性中,但是并不会把创建的dom元素挂在到对应的dom树上,

  function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
    // 第一个过程 执行用户设置的 init 钩子函数
    // init函数在创建dom之前让用户可以做一次修改
    let i: any
    let data = vnode.data
    if (data !== undefined) {
      const init = data.hook?.init
      if (isDef(init)) {
        init(vnode)
        data = vnode.data
      }
    }
    const children = vnode.children
    const sel = vnode.sel
    // 第二个过程 把vnode抓换成真实dom对象(没有渲染到页面)
    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
      // 上边的5行代码解析选择器
      const elm = vnode.elm = isDef(data) && isDef(i = data.ns)// 判断是否有值 ns命名空间 
        ? api.createElementNS(i, tag)// 如果有命名空间一般创建svg
        : api.createElement(tag)
      if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot))
      if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/\./g, ' '))// 设置id和class 
      // 执行模块的 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 // 此处的hook为h函数传入的钩子函数
      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
  }

patch函数中的removeVnodes函数和addVnodes函数

removeVnodes函数批量移除vnode对应的dom元素

addVnodes函数批量添加vnodes对应的dom元素到dom树上

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] // 遍历vnodes数组
      if (ch != null) {
        if (isDef(ch.sel)) {  // 判断是否有sel属性如果没有是文本节点
          invokeDestroyHook(ch) // 触发vnode的destroy函数
          listeners = cbs.remove.length + 1
          rm = createRmCb(ch.elm!, listeners) // listrners防止重复删除元素 从热爱teRm Cb高阶函数返回真正删除dom元素的函数
          for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm) // 调用remove钩子函数,调用rm函数删除dom元素
          const removeHook = ch?.data?.hook?.remove // 获取用户传递的钩子函数
          if (isDef(removeHook)) {// 如果用户传递了remove钩子函数,需要用户手动调用rm删除dom元素
            removeHook(ch, rm)// 都会调用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)// 执行用户传递过来的destroy函数
      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) // 递归用函数,删除子节点destroy是在删除dom之前执行的
          }
        }
      }
    }
  }

createRmCb函数

 function createRmCb (childElm: Node, listeners: number) {
    return function rmCb () {
      if (--listeners === 0) {// listeners记录的是remove函数的个数加一,当所有的remove函数都执行过时删除元素
        const parent = api.parentNode(childElm) as Node
        api.removeChild(parent, childElm)
      }
    }
  }

addVnodes函数

  function addVnodes (
    parentElm: Node,
    before: Node | null,
    vnodes: VNode[],
    startIdx: number,
    endIdx: number,
    insertedVnodeQueue: VNodeQueue
  ) {
    for (; startIdx <= endIdx; ++startIdx) {
      const ch = vnodes[startIdx]
      if (ch != null) {
        api.insertBefore(parentElm, createElm(ch, insertedVnodeQueue), before)
      }
    }
  }

patch函数中的patchVnode函数

patchVnode对比两个Vnode的差异,更新到dom上

首先触发prePatch和update钩子函数,都是对比之之前执行的

判断新旧节点中是否有text属性,text和children是互斥的,如果新节点有text而且不等于老节点的text属性,要把text赋值给对应的dom的textContent属性。赋值之前会判断老节点是否有children属性,如果有,执行removeVnodes移除子节点。

新老节点都有children,且不相等,调用updateChildren函数对比新旧节点的子节点,并更新子节点的差异,核心函数

只有新节点有children属性,老节点有text属性,清空老节点的文本内容,调用addVnodes函数添加子节点

老节点有children属性,新节点没有,移除说有老节点

只有老节点有text属性,清空对应dom元素的textContent

对比完成并更新之后会触发postpatch钩子函数

  function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
      // 过程一 触发prepatch和update钩子函数
    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)
    }
      // 过程二 对比新旧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!)
    }
      // 触发postpatch钩子函数
    hook?.postpatch?.(oldVnode, vnode)
  }

updateChildren函数

diff算法

虚拟Dom中为为什么要使用diff算法,渲染真实的dom的开销很大,dom操作会引起浏览器的重排和重绘,也就是重新渲染。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值