Vue 3 渲染器的核心秘密:从 VNode 创建到快速 Diff 算法

Vue 3 的渲染系统是整个框架的另一大核心。它负责将组件的状态转化为可交互的 DOM,并在状态变更时高效地更新视图。本文会从 h 函数创建 VNode 开始,一直深入到 renderer 的挂载与更新流程,以及那个被誉为"vue-next"精华的快速 Diff 算法实现。

一、VNode 的诞生:h 函数与 createVNode

当你使用 h('div', { id: 'app' }, 'Hello') 时,背后调用的是 createVNode 函数。这是一个精心设计的工厂函数,负责规范化各种参数,生成描述视图结构的 JavaScript 对象。

ts

typescript 复制代码
// packages/runtime-core/src/vnode.ts 简化
function createVNode(type, props, children) {
  // 规范化子节点,确保 children 始终是数组或字符串等标准形式
  if (isVNode(children)) {
    children = [children]
  }

  const vnode = {
    __v_isVNode: true,
    type,                // 'div' 或 组件对象
    props,
    children,
    el: null,            // 对应的真实 DOM,挂载后赋值
    key: props?.key ?? null,
    component: null,     // 组件实例
    shapeFlag: getShapeFlag(type),
    patchFlag: 0,        // 编译优化的靶向标记
    dynamicChildren: null // 动态子节点数组
  }

  // 根据 children 类型设置子节点标志位
  if (Array.isArray(children)) {
    vnode.shapeFlag |= ShapeFlags.ARRAY_CHILDREN
  } else if (typeof children === 'string') {
    vnode.shapeFlag |= ShapeFlags.TEXT_CHILDREN
  }

  // 对动态节点进行标记 (由编译器生成的代码中携带)
  if (currentBlock && vnode.patchFlag > 0) {
    currentBlock.push(vnode)
  }

  return vnode
}

这里的 shapeFlag 用一个数字的二进制位高效记录了 VNode 自身类型和子节点类型,后续 diff 时可以直接按位运算判断,避免多次 typeof 检查。例如判断是否含有数组子节点只需 vnode.shapeFlag & ShapeFlags.ARRAY_CHILDREN

二、渲染器的骨架:createRenderer

Vue 3 的渲染器通过 createRenderer 生成,它接收平台相关的节点操作函数(如 createElementinsert 等),返回 render 函数。这种跨平台设计使得 Vue 可以轻松适配 DOM、Canvas 甚至是原生小程序。

ts

scss 复制代码
function createRenderer(options) {
  const {
    createElement,
    setElementText,
    patchProp,
    insert,
    remove,
  } = options

  function render(vnode, container) {
    if (vnode === null) {
      // 卸载
      if (container._vnode) {
        unmount(container._vnode, null)
      }
    } else {
      // 初次挂载或更新
      patch(container._vnode || null, vnode, container)
    }
    container._vnode = vnode
  }

  function patch(n1, n2, container, anchor = null) {
    if (n1 === n2) return

    // 类型不同直接替换
    if (n1 && n1.type !== n2.type) {
      unmount(n1)
      n1 = null
    }

    const { type, shapeFlag } = n2
    switch (type) {
      case Text:
        processText(n1, n2, container)
        break
      case Fragment:
        processFragment(n1, n2, container)
        break
      default:
        if (shapeFlag & ShapeFlags.ELEMENT) {
          processElement(n1, n2, container, anchor)
        } else if (shapeFlag & ShapeFlags.COMPONENT) {
          processComponent(n1, n2, container, anchor)
        }
    }
  }

  return { render }
}

这个 patch 函数就是一切更新的入口。它通过 n1 是否为空判断挂载还是更新,并根据 VNode 类型分发到不同处理流程。processElement 则是处理普通 DOM 元素的核心。

三、元素挂载与更新:mountElement 与 patchElement

mountElement 负责将 VNode 转为真实 DOM 并插入页面,同时处理子节点的递归挂载。

ts

scss 复制代码
function mountElement(vnode, container, anchor) {
  const el = (vnode.el = createElement(vnode.type))
  const { props, children, shapeFlag } = vnode

  // 挂载属性
  if (props) {
    for (const key in props) {
      patchProp(el, key, null, props[key])
    }
  }

  // 挂载子节点
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    setElementText(el, children)
  } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    mountChildren(children, el)
  }

  insert(el, container, anchor)
}

function mountChildren(children, container) {
  children.forEach(child => {
    patch(null, child, container)
  })
}

更新元素节点时,patchElement 体现了大量编译优化。它会优先检查 dynamicChildren,实现靶向更新。

ts

scss 复制代码
function patchElement(n1, n2) {
  const el = (n2.el = n1.el)
  const oldProps = n1.props || {}
  const newProps = n2.props || {}

  // 1. 更新 props
  patchProps(el, n2, oldProps, newProps)

  // 2. 如果存在动态子节点,跳过静态内容,直接比对动态部分
  if (n2.dynamicChildren) {
    patchBlockChildren(n1.dynamicChildren, n2.dynamicChildren, el)
  } else {
    // 否则全量更新子节点
    patchChildren(n1, n2, el)
  }
}

patchBlockChildren 仅仅是遍历 dynamicChildren 数组,一一对应地调用 patch,因为编译器保证了动态节点数组结构稳定,无需进行复杂的移动比对。

四、Diff 算法的核心:快速 Diff 的实现

当新旧子节点都是数组且没有 dynamicChildren 优化时,会进入完整的 Diff 流程。Vue 3 采用了快速 Diff 算法,它从两端向中间收缩,尽力复用稳定区间的节点,仅对中间的乱序部分进行最小操作。

ts

ini 复制代码
function patchKeyedChildren(c1, c2, container) {
  let i = 0
  let e1 = c1.length - 1
  let e2 = c2.length - 1

  // 1. 从左端开始匹配相同节点
  while (i <= e1 && i <= e2 && c1[i].key === c2[i].key) {
    patch(c1[i], c2[i], container)
    i++
  }

  // 2. 从右端开始匹配相同节点
  while (i <= e1 && i <= e2 && c1[e1].key === c2[e2].key) {
    patch(c1[e1], c2[e2], container)
    e1--
    e2--
  }

  // 3. 仅有新增节点
  if (i > e1) {
    while (i <= e2) {
      patch(null, c2[i], container)
      i++
    }
  }
  // 4. 仅有卸载节点
  else if (i > e2) {
    while (i <= e1) {
      unmount(c1[i])
      i++
    }
  }
  // 5. 中间存在乱序区域
  else {
    const s1 = i
    const s2 = i
    const keyToNewIndexMap = new Map()
    // 构建新子节点中未处理部分的 key 到 index 的映射
    for (i = s2; i <= e2; i++) {
      if (c2[i].key !== null) {
        keyToNewIndexMap.set(c2[i].key, i)
      }
    }

    // 处理旧节点
    let patched = 0
    const toBePatched = e2 - s2 + 1
    const newIndexToOldIndexMap = new Array(toBePatched).fill(0)

    for (i = s1; i <= e1; i++) {
      const oldVNode = c1[i]
      if (patched >= toBePatched) {
        unmount(oldVNode)
        continue
      }
      const newIndex = keyToNewIndexMap.get(oldVNode.key)
      if (newIndex === undefined) {
        // 旧节点在新列表中不存在,卸载
        unmount(oldVNode)
      } else {
        // 记录下新位置对应的旧位置,用于求最长递增子序列
        newIndexToOldIndexMap[newIndex - s2] = i + 1
        patch(oldVNode, c2[newIndex], container)
        patched++
      }
    }

    // 移动和挂载
    const increasingNewIndexSequence = getSequence(newIndexToOldIndexMap)
    let j = increasingNewIndexSequence.length - 1
    for (i = toBePatched - 1; i >= 0; i--) {
      const newIndex = s2 + i
      const newVNode = c2[newIndex]
      const anchor = newIndex + 1 < c2.length ? c2[newIndex + 1].el : null
      if (newIndexToOldIndexMap[i] === 0) {
        // 全新节点,挂载
        patch(null, newVNode, container, anchor)
      } else if (i !== increasingNewIndexSequence[j]) {
        // 不在最长递增子序列中,需要移动
        container.insertBefore(newVNode.el, anchor)
      } else {
        j--
      }
    }
  }
}

这套算法的精妙之处在于仅对混乱区域动用移动逻辑,并利用最长递增子序列 最大程度保留不动的节点,将 DOM 移动次数降至最低。getSequence 返回的是可以保持不变的新索引数组下标序列,其余节点才需要移动。

五、组件渲染与调度

组件级别的更新最终也落在这套 Diff 上。Vue 3 会为每个组件创建一个渲染 effect,当组件内部响应式数据变化时,effect.scheduler 把组件的更新函数加入微任务队列。队列清空时,批量执行所有更新,调用组件 render 生成新的子 VNode 树,再作为 patch 的入参去比对。这就是整个视图更新链路闭环:响应式触发 ------ 组件更新入队 ------ 生成新 VNode ------ 快速 Diff 更新 DOM

六、总结

Vue 3 的渲染器通过分层设计、标志位加速判断、Block Tree 收集动态节点以及快速 Diff 算法,在保持虚拟 DOM 灵活性的同时,极大缩小了更新时的遍历范围。理解 createVNodepatchpatchKeyedChildren 这些底层函数,不仅有助于读懂框架源码,更能让我们在写模板时,有意识地利用 key 和静态结构帮助编译器生成更优的更新路径。

相关推荐
你驴我35 分钟前
WhatsApp 消息撤回与编辑的幂等性设计实践
java·服务器·前端·后端·python
新中地GIS开发老师1 小时前
零基础WebGIS开发入门 | GeoJSON数据持久化
前端·javascript·gis·webgis·三维gis开发
sunywz2 小时前
【c#】 Web Deploy一键发布,IIS部署全流程
开发语言·前端·c#
宁&沉沦2 小时前
Chrome 扩展 Manifest 字段版本支持一览(全量)
前端·后端·编辑器
乖孩子2 小时前
Service Worker + IndexedDB 实践
前端
浮生望2 小时前
BFF架构实战:用Express+SSE为Vue3搭建安全的流式对话中间层
前端
先吃饱再说2 小时前
React 组件通信:从 Props 单向传递到自定义事件
前端·react.js·前端框架
swipe3 小时前
05|(前端转后全栈)不手写一堆 SQL,后端怎么操作数据库?MyBatis-Plus 入门
前端·后端·全栈
swipe3 小时前
04|(前端转后全栈)前端状态为什么不够用?从页面数据到 MySQL 持久化
前端·后端·全栈
橘子星3 小时前
别光看教程!手把手拆解 React Todo 项目,一文吃透 5 个核心概念
前端·javascript