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 和静态结构帮助编译器生成更优的更新路径。

相关推荐
光影少年1 小时前
react navite 跨端核心原理
前端·react native·react.js
奇奇怪怪的1 小时前
从开发到生产——生成优化、监控、安全与成本
前端
10share1 小时前
100行代码 模拟实现Vue 响应式系统
前端·vue.js
Heo1 小时前
Vite进阶用法详解
前端·javascript·面试
狂炫冰美式2 小时前
人均配了AI, 为什么公司还是没变快? 🤔 本质还是分布式系统问题
前端·后端·架构
乘风gg3 小时前
多 Agent 不是万能的!搞懂这 5 个原则,少走 1 年弯路!
前端·agent·ai编程
猩猩程序员3 小时前
Vercel 推出 Agent 框架 Eve:让 AI Agent 像写 Web 应用一样简单
前端
爱读源码的大都督4 小时前
Claude Code源码分析(三):为什么系统提示词中需要有tools呢?
前端·人工智能·后端
爱勇宝4 小时前
Claude Code 被曝暗藏“隐形检测”代码:封代理不是最可怕的,可怕的是你根本不知道它在干什么
前端·后端·程序员