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

相关推荐
圣殿骑士-Khtangc12 小时前
Go字符串高效拼接性能对比与底层原理分析
服务器·前端·golang
BigTopOne12 小时前
【ijkplayer】 硬解码流程
前端
kyriewen13 小时前
DeepSeek Harness开源第一天我就上手了——和Claude Code的差距比想象中大
前端·ai编程·deepseek
捡田螺的小男孩13 小时前
什么是 Skill?手把手带你写一个简单有用的 Skill!
前端·后端·程序员
IT_陈寒13 小时前
Redis集群这个坑,差点让我通宵
前端·人工智能·后端
90后的晨仔14 小时前
uni-app 路由跳转与页面导航 —— 终极详解(对标 iOS / Android / 鸿蒙三端原生)
前端
Heo14 小时前
大厂前端调试不能只会debugger
前端·javascript·面试
用户21816970493014 小时前
Flutter (十七) 网络请求
前端
cidy_9814 小时前
React 19 + Vite 企业级前端项目:从零搭建到规范交付
前端
用户9210802628614 小时前
如何把一张图片做成自定义复杂 UI 图标
前端