Skip to content

实现渲染器 - update 流程

本节对标 Vue 3 源码 @vue/runtime-core 中的 renderer.ts —— update / patch 部分

update 的触发时机

当响应式数据变化时,组件的 render effect 重新执行,生成新的 VNode 树。渲染器对比新旧 VNode 树,只更新变化的部分。

数据变化 → trigger → 组件 render effect → 新 VNode → patch(oldVNode, newVNode) → DOM 更新

patchElement —— 更新 DOM 元素

ts
// patchElement 负责更新已有的 DOM 元素节点
function patchElement(n1: VNode, n2: VNode) {
  // 复用旧 VNode 的真实 DOM 引用,同时赋给新 VNode
  // 这是 Diff 的核心思想:相同类型的节点复用 DOM,只更新差异部分
  const el = (n2.el = n1.el!)

  const oldProps = n1.props || {} // 旧属性,不存在时用空对象兜底
  const newProps = n2.props || {} // 新属性

  // 第 1 步:对比并更新属性(class、style、事件等)
  patchProps(el, oldProps, newProps)

  // 第 2 步:递归更新子节点(文本 / 数组 / 空的组合)
  patchChildren(n1, n2, el)
}

update 的核心就两步:更新属性 + 更新子节点。

patchProps —— 属性更新

ts
// patchProps 对比新旧属性,执行最小化的属性更新操作
function patchProps(
  el: any,
  oldProps: Record<string, any>,
  newProps: Record<string, any>,
) {
  // 引用相同说明属性完全没变,直接跳过
  if (oldProps === newProps) return

  // 遍历新属性:如果值与旧值不同,则更新(覆盖旧值或添加新属性)
  for (const key in newProps) {
    const prev = oldProps[key]
    const next = newProps[key]
    if (prev !== next) {
      patchProp(el, key, prev, next)
    }
  }

  // 遍历旧属性:如果某个 key 在新属性中不存在,则将其删除(传 null 表示删除)
  for (const key in oldProps) {
    if (!(key in newProps)) {
      patchProp(el, key, oldProps[key], null)
    }
  }
}

patchProp 的实现(runtime-dom)

ts
// patchProp 是 runtime-dom 提供的属性设置函数
// 根据属性名的特征分发到不同的处理逻辑
export function patchProp(
  el: Element,
  key: string,
  prevValue: any,
  nextValue: any,
) {
  if (key === 'class') {
    // class 属性走专门的优化路径
    patchClass(el, nextValue)
  } else if (key === 'style') {
    // style 属性需要逐个 CSS 属性对比
    patchStyle(el, prevValue, nextValue)
  } else if (isOn(key)) {
    // 以 on 开头的属性视为事件监听器(如 onClick、onInput)
    patchEvent(el, key, prevValue, nextValue)
  } else {
    // 其他普通 HTML 属性(如 id、href、disabled 等)
    patchAttr(el, key, nextValue)
  }
}

// 判断属性名是否为事件监听器:以 "on" 开头且第三个字符是大写字母
function isOn(key: string): boolean {
  return /^on[A-Z]/.test(key)
}

patchClass

ts
// patchClass 使用 className 直接赋值来设置 class
// 比 setAttribute('class', value) 性能更好(浏览器内部优化)
function patchClass(el: Element, value: string | null) {
  if (value == null) {
    el.removeAttribute('class') // null 或 undefined 时移除 class 属性
  } else {
    el.className = value // 直接赋值,性能最优
  }
}

使用 className 而非 setAttribute('class', value),性能更好。

patchStyle

ts
// patchStyle 对比新旧 style 对象,精确更新每个 CSS 属性
function patchStyle(
  el: HTMLElement,
  prev: Record<string, string> | null,
  next: Record<string, string> | null,
) {
  const style = el.style

  if (!next) {
    // 新样式为空 → 直接移除整个 style 属性
    el.removeAttribute('style')
    return
  }

  // 遍历新样式对象,逐一设置 CSS 属性
  for (const key in next) {
    style.setProperty(key, next[key])
  }

  // 清理:遍历旧样式,移除新样式中已不存在的 CSS 属性
  if (prev) {
    for (const key in prev) {
      if (!(key in next)) {
        style.removeProperty(key)
      }
    }
  }
}

patchEvent

ts
// patchEvent 使用 invoker 模式管理事件监听
// 核心优化:更新事件时只替换 invoker.value,不需要反复 removeEventListener + addEventListener
function patchEvent(
  el: Element & { _vei?: Record<string, any> }, // _vei = vue event invokers 的缩写
  rawName: string,    // 原始事件名,如 "onClick"
  prevValue: any,     // 旧的事件处理函数
  nextValue: any,     // 新的事件处理函数
) {
  // 获取或初始化元素上的 invoker 缓存对象
  const invokers = el._vei || (el._vei = {})
  const existingInvoker = invokers[rawName] // 查找此事件名是否已有 invoker
  const name = rawName.slice(2).toLowerCase() // "onClick" → "click"

  if (nextValue && existingInvoker) {
    // 情况 1:事件已存在且有新值 → 只需替换 invoker 内部的 value(高效更新)
    existingInvoker.value = nextValue
  } else if (nextValue) {
    // 情况 2:新增事件 → 创建 invoker 并注册到 DOM
    const invoker = (invokers[rawName] = createInvoker(nextValue))
    el.addEventListener(name, invoker)
  } else if (existingInvoker) {
    // 情况 3:删除事件 → 移除监听并清除 invoker 缓存
    el.removeEventListener(name, existingInvoker)
    invokers[rawName] = undefined
  }
}

// 创建 invoker 包装函数:真正的事件处理函数存储在 invoker.value 上
// 执行时调用 invoker.value(e),这样更新处理函数时只需替换 value 即可
function createInvoker(initialValue: any) {
  const invoker = (e: Event) => {
    invoker.value(e) // 通过 value 间接调用真正的处理函数
  }
  invoker.value = initialValue // 初始化 value 为传入的处理函数
  return invoker
}

关键设计:事件使用 invoker 模式。更新事件处理函数时,只需替换 invoker.value,不需要 removeEventListener + addEventListener,性能更优。

patchChildren —— 子节点更新

子节点更新是渲染器最复杂的部分。新旧子节点有 3 种类型(文本、数组、空),组合出 9 种情况:

ts
// patchChildren 处理子节点的更新
// 新旧子节点各有 3 种类型(文本、数组、空),组合出 9 种情况
function patchChildren(n1: VNode, n2: VNode, container: any) {
  const c1 = n1.children           // 旧子节点
  const c2 = n2.children           // 新子节点
  const prevShapeFlag = n1.shapeFlag // 旧 VNode 的类型标志
  const shapeFlag = n2.shapeFlag     // 新 VNode 的类型标志

  // ===== 情况一:新子节点是文本 =====
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      // 旧的是数组 → 需要先卸载所有旧子节点
      unmountChildren(c1 as VNode[])
    }
    // 文本内容不同时才更新(包括旧的是文本或空的情况)
    if (c1 !== c2) {
      setElementText(container, c2 as string)
    }
    return
  }

  // ===== 情况二:新子节点是数组 =====
  if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      // 新旧都是数组 → 这是最复杂的情况,需要 Diff 算法精确更新
      patchKeyedChildren(c1 as VNode[], c2 as VNode[], container)
    } else {
      // 旧的是文本或空 → 清空文本后挂载新的子节点数组
      if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
        setElementText(container, '')
      }
      mountChildren(c2 as VNode[], container)
    }
    return
  }

  // ===== 情况三:新子节点为空 =====
  if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    // 旧的是数组 → 卸载所有旧子节点
    unmountChildren(c1 as VNode[])
  } else if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
    // 旧的是文本 → 清空文本内容
    setElementText(container, '')
  }
  // 旧的也是空 → 不需要任何操作
}

9 种情况整理

旧 \ 新文本数组
文本替换文本清空文本 + 挂载清空文本
数组卸载 + 设置文本Diff卸载
设置文本挂载无操作

其中数组 → 数组是最复杂的情况,需要 Diff 算法处理。

unmountChildren

ts
// unmountChildren 批量卸载子节点数组
function unmountChildren(children: VNode[]) {
  for (let i = 0; i < children.length; i++) {
    unmount(children[i]) // 逐个调用 unmount 移除真实 DOM
  }
}

完整的更新流程

响应式数据变化


trigger → effect.scheduler → queueJob


nextTick(微任务)


组件 render() 生成新 VNode


patch(oldVNode, newVNode, container)

    ├── 类型不同? → unmount(old) + mount(new)

    └── 类型相同? → patchElement / patchComponent

            ├── patchProps(属性对比更新)

            └── patchChildren(子节点对比更新)

                    └── 都是数组? → Diff 算法

测试用例

ts
describe('renderer: update', () => {
  // 测试元素属性的更新和删除
  it('should update element props', () => {
    const root = document.createElement('div')

    // 首次渲染:id="foo" class="bar"
    render(h('div', { id: 'foo', class: 'bar' }), root)
    expect(root.innerHTML).toBe('<div id="foo" class="bar"></div>')

    // 更新渲染:id 变为 "baz",class 被删除(新 props 中没有 class)
    render(h('div', { id: 'baz' }), root)
    expect(root.innerHTML).toBe('<div id="baz"></div>')
  })

  // 测试文本子节点的更新
  it('should update text children', () => {
    const root = document.createElement('div')
    render(h('div', null, 'old'), root)
    expect(root.innerHTML).toBe('<div>old</div>')

    // 文本从 'old' 变为 'new',应该只更新文本内容
    render(h('div', null, 'new'), root)
    expect(root.innerHTML).toBe('<div>new</div>')
  })

  // 测试子节点类型从文本切换到数组
  it('should update from text to array children', () => {
    const root = document.createElement('div')
    render(h('div', null, 'text'), root) // 初始为文本子节点

    // 更新为数组子节点:先清空文本,再挂载子元素
    render(h('div', null, [h('span', null, 'child')]), root)
    expect(root.innerHTML).toBe('<div><span>child</span></div>')
  })

  // 测试 render(null) 时的卸载行为
  it('should unmount when render null', () => {
    const root = document.createElement('div')
    render(h('div', null, 'hello'), root)
    expect(root.innerHTML).toBe('<div>hello</div>')

    // 传入 null 应触发 unmount,清空容器内容
    render(null, root)
    expect(root.innerHTML).toBe('')
  })
})

本节小结

  1. patchElement — 更新属性 + 更新子节点
  2. patchProps — 遍历新旧属性,添加/更新/删除
  3. patchEvent — invoker 模式,避免频繁的 addEventListener/removeEventListener
  4. patchChildren — 9 种子节点组合情况,核心是数组 → 数组的 Diff
  5. 最小化 DOM 操作 — 只更新真正变化的部分

下一节实现 Diff 算法 —— 渲染器中最核心的优化策略。

用心学习,用代码说话 💻