当前位置: 首页 > article >正文

从认识 VNode VDOM 到实现 mini-vue

前言

现有框架几乎都引入了虚拟 DOM 来对真实 DOM 进行抽象,也就是现在大家所熟知的 VNode 和 VDOM,那么为什么需要引入虚拟 DOM 呢?下面就一起来了解下吧!!!

VNode & VDOM

VNode 和 VDOM 是什么?

直接看 vue3 中关于 VNode 部分的源码,文件位置:packages\runtime-core\src\vnode.ts

通过源码部分,可以很明显的看到 VNode 本身就是一个 JavaScript 对象,只不过它是通过不同的属性去描述一个真实 dom.

VDOM 其实就是多个 VNode 组成的树结构,这就好比 HTML 元素和 DOM 树之间的关系:多个 HTML 元素能够组成树形结构就称之为 DOM 树.

function _createVNode(
  type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT,
  props: (Data & VNodeProps) | null = null,
  children: unknown = null,
  patchFlag: number = 0,
  dynamicProps: string[] | null = null,
  isBlockNode = false
): VNode {
  ...
  return createBaseVNode(
    type,
    props,
    children,
    patchFlag,
    dynamicProps,
    shapeFlag,
    isBlockNode,
    true
  )
}

function createBaseVNode(
  type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT,
  props: (Data & VNodeProps) | null = null,
  children: unknown = null,
  patchFlag = 0,
  dynamicProps: string[] | null = null,
  shapeFlag = type === Fragment ? 0 : ShapeFlags.ELEMENT,
  isBlockNode = false,
  needFullChildrenNormalization = false
) {
  const vnode = {
    __v_isVNode: true,
    __v_skip: true,
    type,
    props,
    key: props && normalizeKey(props),
    ref: props && normalizeRef(props),
    scopeId: currentScopeId,
    slotScopeIds: null,
    children,
    component: null,
    suspense: null,
    ssContent: null,
    ssFallback: null,
    dirs: null,
    transition: null,
    el: null,
    anchor: null,
    target: null,
    targetAnchor: null,
    staticCount: 0,
    shapeFlag,
    patchFlag,
    dynamicProps,
    dynamicChildren: null,
    appContext: null
  } as VNode
  ...
  return vnode
}

为什么要使用 VDOM ?

既然要使用肯定是因为 虚拟 DOM 拥有一些 真实 DOM 没有的优势:

  • 对真实元素节点抽象成 VNode,减少直接操作 dom 时的性能问题
    • 直接操作 dom 是有限制的,比如:diff、clone 等操作,一个真实元素上有许多的内容,如果直接对其进行 diff 操作,会去额外 diff 一些没有必要的内容;同样的,如果需要进行 clone 那么需要将其全部内容进行复制,这也是没必要的。但是,如果将这些操作转移到 JavaScript 对象上,那么就会变得简单了。
    • 直接操作 dom 容易引起页面的重绘和回流,但是通过 VNode 进行中间处理,可以避免一些不要的重绘和回流
  • 方便实现跨平台
    • 同一 VNode 节点可以渲染成不同平台上的对应的内容,比如:渲染在浏览器是 dom 元素节点,渲染在 Native( iOS、Android) 变为对应的控件、可以实现 SSR 、渲染到 WebGL 中等等
    • 而且 Vue 允许开发者基于 VNode 实现自定义渲染器(renderer),以便于针对不同平台上的渲染

虚拟 DOM 的渲染过程

在这里插入图片描述

Vue 三大核心系统

Vue 中的三大核心系统如下:

  • Compiler 模块:涉及 AST 抽象语法树的内容,再通过 generate 将 AST 生成渲染函数,这里暂不实现
  • Runtime 模块:也可称为 Renderer 模块,将虚拟 dom 生成真实 dom 元素,并渲染到浏览器上
  • Reactivity 模块:响应式系统

三大系统的关系

在这里插入图片描述

实现 Runtime 模块

下面的实现部分只实现最简单、最核心的内容,不涉及各种复杂的边界条件.

createVNode & h

VNode 主要作用就是将外部传入的各种参数组合成一个 JavaScript 对象.

其中 createVNode 就是用于创建 VNode ,而 h 函数(render function)负责将创建好的 VNode 进行返回.

function createVNode(type, props, children) {
  // vnode ——> js 对象
  return {
    type,
    props,
    children
  }
}

function h(type, props, children) {
  return createVNode(type, props, children)
}

mount

得到 VNode 之后,接下来就需要将 VNode变成真实的 dom元素,并渲染到浏览器上.

  • 通过 document.createElement方法将 VNode变成 dom元素

  • 处理传入的 props对象

    • on 开头的默认为事件,通过 addEventListenerdom元素注册事件
    • 其他属性默认为 dom上的属性,通过 setAttributedom元素设置属性
  • 处理 children,只考虑 childrenStringArray的情况

    • childrenString 默认为是文本节点,通过 textContent属性进行设置
    • childrenArray 默认为是多个 VNode集合,通过递归调用 mount方法进行挂载
function mount(vnode, container) {
  // 1. 获取容器 element
  if (container.nodeType !== 1) {
    container = document.querySelector(container)
  }

  // 2. vnode ——> element
  const { type, props, children } = vnode
  const el = document.createElement(type)
  vnode.el = el

  // 3. 处理 props
  if (props) {
    for (const key in props) {
      // 事件
      if (key.startsWith('on')) {
        el.addEventListener(key.slice(2).toLowerCase(), props[key]);
      } else {
        // 属性
        el.setAttribute(key, props[key])
      }
    }
  }

  // 4. 处理 children
  if (typeof children === 'string') {
    el.textContent = children
  } else {
    children.forEach(v => {
      mount(v, el)
    });
  }

  // 5. 挂载到容器中
  container.appendChild(el)
}

patch

实现了能够将 VNode 渲染为真实 DOM 之后,就需要考虑更新时 VNode 间的 diff 比较了,这就属于 patch 的过程.

  • 新旧 VNode 类型不一致,先删除旧节点,用新的替换旧的

  • 新旧 VNode 类型一致

    • 更新 props:更新 dom 属性 & 更新 dom 事件

      • 新旧属性或事件存在且不一致,直接更新
      • 新属性存在 & 旧属性不存在,直接添加
      • 新属性都存在 & 新旧值不一致,直接删除
    • 更新 children

      • 新 children 是字符串,只要和旧的 children 不相等,直接使用 innerHTML 替换旧的内容
      • 新 children 是数组 & 旧 children 是字符串,先清空旧节点的内容,循环调用 mount 新增元素
      • 新旧 children 都是数组,取新旧 children 中最小长度,用于减少循环 patch 次数,若 oldLength < newLength 需要通过 mount 新增元素, 若 oldLength > newLength 需要通过 el.removeChild 删除多余旧元素
/**
* 
* @param {oldVnode} n1 
* @param {newVnode} n2 
*/
function patch(n1, n2) {
 // 1. 类型不一致
 if (n1.type !== n2.type) {
   const parent = n1.el.parentElement
   // 删除 oldVnode.el
   parent.removeChild(n1.el)
   // 渲染 newVnode.el
   mount(n2, parent)
 } else {
 // 2. 类型一致

   // 2.1 统一 el 对象,因为最终修改的是 oldVnode.el,因此,使用 n1.el 作为最终值
   const el = n2.el = n1.el

   // 2.2 处理 props
   const oldProps = n1.props
   const newProps = n2.props

   // 处理 props 不一致
   for (const key in newProps) {
     const newValue = newProps[key]
     const oldValue = oldProps[key]

     // 旧的有值,新的没值,移除该属性
     if (newValue !== oldValue) {
       // 事件不一致
       if (key.startsWith('on')) {
         el.removeEventListener(key.slice(2).toLowerCase(), oldProps[key])
         el.addEventListener(key.slice(2).toLowerCase(), newProps[key])
       } else {
         // props 值不一致
         el.setAttribute(key, newValue)
       }
     }
   }

   // 删除旧的 props
   for (const key in oldProps) {
     if (!(key in newProps)) {
       // 旧事件不存在 newProps 中
       if (key.startsWith('on')) {
         el.removeEventListener(key.slice(2).toLowerCase(), oldProps[key])
       } else {
         // oldProps 中的属性不存在 newProps 中
         el.removeAttribute(key)
       }
     }
   }

   // 2.3 处理 children
   const oldChildren = n1.children
   const newChildren = n2.children

   // 新的子节点是字符串
   if (typeof newChildren === 'string') {
     // 新旧子节点不一致,直接使用新节点进行替换旧节点
     if (newChildren !== oldChildren) el.innerHTML = newChildren
   } else {
     // 新的子节点为数组

     // 旧的子节点为字符串
     if (typeof oldChildren === 'string') {
       el.innerHTML = ''
       newChildren.forEach(v => {
         mount(v, el)
       })
     } else {
       // 旧的子节点也为数组

       // 取最小的长度进行最少的循环
       let commonLength = Math.min(newChildren.length, oldChildren.length)

       for (let i = 0; i < commonLength; i++) {
         // 递归调用 patch 新老节点
         patch(oldChildren[i], newChildren[i])
       }

       // 循环结束:oldLength < newLength || oldLength > newLength
       
       // oldLength < newLength,需要添加新节点
       if(oldChildren.length < newChildren.length){
         newChildren.slice(oldChildren.length).forEach(v => {
           mount(v, el)
         })
       }

       // oldLength > newLength,需要删除旧节点
       if(oldChildren.length > newChildren.length){
         oldChildren.slice(newChildren.length).forEach(v => {
           el.removeChild(v.el)
         })
       }
     }
   }
 }
}

实现 Reactivity 模块

在这里插入图片描述

基于 Object.defineProperty 实现响应式

Object.defineProperty 的优点:

  • 兼容性好,可以兼容到 IE9
    Object.defineProperty 的不足:
  • 不能劫持对象 property添加移除
  • 不能劫持数组变化
    • 通过数组下标修改数组项
    • 修改数组长度
class Dep {
 constructor() {
   this.subscribers = new Set()
 }

 depend() {
   if (activeEffect) {
     this.subscribers.add(activeEffect)
   }
 }

 notify() {
   this.subscribers.forEach(effect => {
     effect()
   })
 }
}

// 当前正在执行 effect 函数
let activeEffect = null

function watchEffect(effect) {
 activeEffect = effect
 effect() // 目的:初始化调用 + 依赖收集
 activeEffect = null
}

// 存储依赖副作用
const targetMap = new WeakMap()

// 获取当前的对象的依赖 dep 对象
function getDep(target, key) {
 // 1. 根据传入的 target 获取对应的 Map 对象
 let depsMap = targetMap.get(target)

 // 2. 若 depsMap 不存在,则初始化一个 Map 对象
 if (!depsMap) {
   depsMap = new Map()
   targetMap.set(target, depsMap)
 }

 // 3. 获取具体的 dep 对象
 let dep = depsMap.get(key)

 // 4. 若 dep 不存在,则实例化一个 Dep 对象
 if (!dep) {
   dep = new Dep()
   depsMap.set(key, dep)
 }

 // 5. 返回 dep 实例
 return dep
}

// 数据劫持
function reactive(raw) {
 Object.keys(raw).forEach(key => {
   const dep = getDep(raw, key)
   let value = raw[key]
   Object.defineProperty(raw, key, {
     enumerable: true,
     configurable: true,
     get() {
       // 依赖收集
       dep.depend()

       return value
     },
     set(newValue) {
       if (value !== newValue) {
         value = newValue

         // 通知依赖更新
         dep.notify()
       }

       return true
     }
   })
 })

 return raw
}

基于 Proxy 实现响应式

Proxy 的优点:

  • Proxy 能监测的类型比 Object.defineProperty 更丰富的类型
    • 能监测对象和数组的变化
    • hasin 操作符的捕获器
    • deletePropertydelete 操作符的捕获器
  • Proxy 作为新标准,将受到浏览器厂商重点持续性的优化

Proxy 的缺点:

  • 不兼容 IE,没有对应的 polyfill
class Dep {
  constructor() {
    this.subscribers = new Set()
  }

  depend() {
    if (activeEffect) {
      this.subscribers.add(activeEffect)
    }
  }

  notify() {
    this.subscribers.forEach(effect => {
      effect()
    })
  }
}

// 当前正在执行 effect 函数
let activeEffect = null

function watchEffect(effect) {
  activeEffect = effect
  effect() // 目的:初始化调用 + 依赖收集
  activeEffect = null
}

// 存储依赖副作用
const targetMap = new WeakMap()

// 获取当前的对象的依赖 dep 对象
function getDep(target, key) {
  // 1. 根据传入的 target 获取对应的 Map 对象
  let depsMap = targetMap.get(target)

  // 2. 若 depsMap 不存在,则初始化一个 Map 对象
  if (!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }

  // 3. 获取具体的 dep 对象
  let dep = depsMap.get(key)

  // 4. 若 dep 不存在,则实例化一个 Dep 对象
  if (!dep) {
    dep = new Dep()
    depsMap.set(key, dep)
  }

  // 5. 返回 dep 实例
  return dep
}

// 数据劫持
function reactive(raw) {
  return new Proxy(raw, {
    get(target, key) {
      const dep = getDep(target, key)
      dep.depend()

      return Reflect.get(target, key)
    },
    set(target, key, newValue) {
      const dep = getDep(target, key)
      const result = Reflect.set(target, key, newValue)
      dep.notify()

      return result
    },
  })
}

createApp() —— Runtime 模块 + Reactivity 模块

如果你对 Vue3 中 createApp 的使用比较熟练或者阅读过相关源码,其实不难发现 createApp 其实会返回一个带有 mount 方法的 JavaScript 对象.

在下面的 mount 方法中针对 VNode 的 mount(挂载) 和 patch(更新) 进行了判断,以便于在响应式数据发生变更时渲染不同的内容.

function createApp(rootComponent) {
  return {
    mount(selector) {
      let isMounted = false
      let oldVnode = null
      let newVnode = null

      watchEffect(() => {
        if (!isMounted) {
          isMounted = true
          oldVnode = rootComponent.render()
          mount(oldVnode, selector)
        } else {
          newVnode = rootComponent.render()
          patch(oldVnode, newVnode)
          oldVnode = newVnode
        }
      })
    }
  }
}

下面是一个简单的计数器案例的实现:

测试代码如下

  <div id="app"></div>

  <script src="./js/renderer.js"></script>
  <script src="./js/reactive.js"></script>
  <script src="./js/createApp.js"></script>

  <script>
    const App = {
      data: reactive({
        count: 0
      }),
      render() {
        return h('div', null, [
          h('h1', null, `当前计数:${this.data.count}`),
          h('button', {
            onClick: () => this.data.count++
          }, '+1')
        ])
      }
    }

    const app = createApp(App)
    app.mount('#app')
  </script>

最后

通过对 VNode 、VDOM 以及 Vue 三大核心系统的认识和实现,最终又通过 createApp 将这些内容串联在一起,可以说是实现了一个小版本的 vue,当然很多场景还是没有进行一一处理,现在只能实现最简单的测试案例.


http://www.kler.cn/a/387108.html

相关文章:

  • MyBatis CRUD快速入门
  • SpringBoot后端解决跨域问题
  • 外星人入侵
  • Android 进入浏览器下载应用,下载的是bin文件无法安装,应为apk文件
  • 即插即用篇 | YOLOv8 引入 代理注意力 AgentAttention
  • 【RabbitMQ】08-延迟消息
  • 【数据结构与算法】第9课—数据结构之二叉树(链式结构)
  • es数据同步(仅供自己参考)
  • 机器学习中的分类:决策树、随机森林及其应用
  • 鸿道Intewell高实时架构:鸿道Intewell-Hyper II 构型
  • c语言宏定义的优缺点及举例说明
  • AscendC从入门到精通系列(二)基于Kernel直调开发AscendC算子
  • Vue禁止打开控制台/前端禁止打开控制台方法/禁用F12/禁用右键
  • 如何设置docker的定时关闭和启动
  • MCU的OTA升级(未完-持续更新)
  • 19. 异常处理
  • 2.4_SSRF服务端请求伪造
  • Docker lmdeploy 快速部署Qwen2.5模型openai接口
  • PHP静默活体识别API接口应用场景与集成方案
  • 常用的c++新特性-->day03
  • 持续集成(Continuous Integration, CI)和持续部署(Continuous Deployment, CD)
  • C++高级编程(8)
  • unity3d————屏幕坐标,GUI坐标,世界坐标的基础注意点
  • PHP API的数据交互类型设计
  • 短视频矩阵系统的源码, OEM贴牌源码
  • LSM树 (Log-Structured Merge Tree)、Cuckoo Hashing详细解读