2026.01.03

patchProp -> Vue 的属性更新机制

在前面的章节中,我们已经实现了基础的渲染器。现在让我们为虚拟节点添加属性支持:

patchProp 的作用

在前面的章节中,我们已经实现了基础的渲染器。现在让我们为虚拟节点添加属性支持:

import { createRenderer, h } from 'vue'

const vnode = h('div', { class: 'container' }, 'hello world')
const renderer = createRenderer(renderOptions)
renderer.render(vnode, app)

运行上面的代码会遇到错误:

错误提示我们缺少 patchProp 方法。这是因为 createRenderer 需要一个处理属性的方法。

实现 patchProp

nodeOps.ts 同级创建 patchProp.ts,并修改导出:

// runtime-dom/src/index.ts
import { nodeOps } from './nodeOps'
import { patchProp } from './patchProp'

export * from '@vue/runtime-core'

// 合并 DOM 操作方法和属性更新方法
const renderOptions = { patchProp, ...nodeOps }
export { renderOptions }

实现 patchClass

class 是最常用的属性之一,我们首先实现对它的支持:

// patchProp.ts
export function patchProp(el, key, prevValue, nextValue) {
  if (key === 'class') {
    patchClass(el, nextValue)
  }
}

function patchClass(el, value) {
  if (value) {
    el.className = value
  }
}

这样可以在初始化时设置 class,但如果要更新或移除 class 呢?

import { createRenderer, h } from 'vue'

const vnode = h('div', { class: 'container' }, 'hello world')
const renderer = createRenderer(renderOptions)
renderer.render(vnode, app)

// 1 秒后移除 class
const vnode2 = h('div', {}, 'hello world')
setTimeout(() => {
  renderer.render(vnode2, app)
}, 1000)

上面的代码中,vnode2 没有 class 属性,所以我们需要将其移除:

function patchClass(el, nextValue) {
  if (nextValue) {
    el.className = nextValue
  }
  else {
    // 如果新值为空,移除 class 属性
    el.removeAttribute('class')
  }
}

实现 patchStyle

样式处理比 class 更复杂,因为需要对比新旧样式对象:

function patchProp(el, key, prevValue, nextValue) {
  if (key === 'class') {
    return patchClass(el, nextValue)
  }
  if (key === 'style') {
    return patchStyle(el, prevValue, nextValue)
  }
}

function patchStyle(el, prevValue, nextValue) {
  const style = el.style

  if (nextValue) {
    // 设置新样式
    // 例如:h('div', { style: { color: 'red' } }, 'hello world')
    for (const key in nextValue) {
      style[key] = nextValue[key]
    }
  }

  if (prevValue) {
    // 移除旧样式中存在但新样式中不存在的属性
    // 例如:prevValue = { background: 'pink', color: 'yellow' }
    //       nextValue = { color: 'red' }
    //       需要移除 background
    for (const key in prevValue) {
      if (!(key in nextValue)) {
        style[key] = null
      }
    }
  }
}

实现 patchEvent

事件处理是 Vue 中非常重要的特性。让我们来实现事件的绑定和更新:

const renderer = createRenderer(renderOptions)
const vnode = h('div', {
  onClick() {
    console.log('click')
  }
}, 'hello world')
renderer.render(vnode, app)

此时传递的 keyonClick,我们需要判断是否以 on 开头来识别事件:

function patchProp(el, key, prevValue, nextValue) {
  if (key === 'class') {
    return patchClass(el, nextValue)
  }
  if (key === 'style') {
    return patchStyle(el, prevValue, nextValue)
  }
  if (/^on[A-Z]/.test(key)) {
    return patchEvent(el, key, prevValue, nextValue)
  }
}

基础实现

最简单的实现方式:

function patchEvent(el, rawName, prevValue, nextValue) {
  // 提取事件名:onClick -> click
  const name = rawName.slice(2).toLowerCase()
  el.addEventListener(name, nextValue)
}

这样点击事件可以执行了,但如果遇到更新的情况会有问题:

const renderer = createRenderer(renderOptions)

const vnode = h('div', {
  onClick() {
    console.log('click')
  }
}, 'hello world')

const vnode2 = h('div', {
  onClick() {
    console.log('click2')
  }
}, 'hello world')

renderer.render(vnode, app)

setTimeout(() => {
  renderer.render(vnode2, app)
}, 1000)

这样会导致点击时同时触发两个事件处理函数。我们需要改进:

改进:先移除再添加

function patchEvent(el, rawName, prevValue, nextValue) {
  const name = rawName.slice(2).toLowerCase()

  // 如果有旧的事件处理函数,先移除
  if (prevValue) {
    el.removeEventListener(name, prevValue)
  }

  // 添加新的事件处理函数
  el.addEventListener(name, nextValue)
}

这样虽然解决了问题,但每次更新都要先移除再添加,性能不够好。

优化:使用事件调用器(Invoker)

更优的方案是使用一个调用器函数,更新时只需要替换调用器内部的函数引用:

function createInvoker(value) {
  // 创建一个事件处理函数,内部调用 invoker.value
  // 当需要更新事件时,只需更新 invoker.value 即可
  const invoker = (e) => {
    invoker.value(e)
  }
  invoker.value = value
  return invoker
}

// 使用 Symbol 作为存储事件调用器的 key
const veiKey = Symbol('_vei')

export function patchEvent(el, rawName, nextValue) {
  const name = rawName.slice(2).toLowerCase()

  // 在元素上存储所有事件调用器:el._vei = {}
  const invokers = (el[veiKey] ??= {})

  // 获取之前绑定的 invoker
  const existingInvoker = invokers[rawName]

  if (nextValue) {
    if (existingInvoker) {
      // 如果已经存在 invoker,只需更新其 value 属性
      existingInvoker.value = nextValue
      return
    }

    // 创建新的 invoker 并绑定
    const invoker = createInvoker(nextValue)
    invokers[rawName] = invoker
    el.addEventListener(name, invoker)
  }
  else {
    // 如果没有新的事件处理函数,但旧的存在,则移除
    if (existingInvoker) {
      el.removeEventListener(name, existingInvoker)
      invokers[rawName] = undefined
    }
  }
}

这种方案的优势:

  1. 性能更好:更新事件时不需要移除和重新绑定监听器
  2. 内存友好:避免了频繁创建和销毁监听器
  3. 更符合 Vue 的设计理念:通过间接引用实现灵活的更新机制

实现 patchAttr

对于其他普通属性(如 iddata-* 等),我们需要一个通用的处理方法:

export function patchProp(el, key, prevValue, nextValue) {
  if (key === 'class') {
    return patchClass(el, nextValue)
  }
  if (key === 'style') {
    return patchStyle(el, prevValue, nextValue)
  }
  if (isOn(key)) {
    return patchEvent(el, key, nextValue)
  }

  patchAttr(el, key, nextValue)
}

function patchAttr(el, key, value) {
  if (value == null) {
    // 值为 null 或 undefined 时,移除属性
    el.removeAttribute(key)
  }
  else {
    // 设置属性
    el.setAttribute(key, value)
  }
}

代码结构优化

随着功能增多,我们需要将 patchProp 相关代码进行模块化拆分:

runtime-dom/
├── package.json
└── src
    ├── index.ts
    ├── modules
    │   ├── patchAttr.ts
    │   ├── patchClass.ts
    │   ├── patchEvent.ts
    │   └── patchStyle.ts
    ├── nodeOps.ts
    └── patchProp.ts

整合后的 patchProp.ts

import { isOn } from '@vue/shared'
import { patchAttr } from './modules/patchAttr'
import { patchClass } from './modules/patchClass'
import { patchEvent } from './modules/patchEvent'
import { patchStyle } from './modules/patchStyle'

export function patchProp(el, key, prevValue, nextValue) {
  if (key === 'class') {
    return patchClass(el, nextValue)
  }

  if (key === 'style') {
    return patchStyle(el, prevValue, nextValue)
  }

  if (isOn(key)) {
    return patchEvent(el, key, nextValue)
  }

  patchAttr(el, key, nextValue)
}

总结

通过实现 patchProp 及其相关方法,我们为渲染器添加了完整的属性更新能力:

  1. patchClass:高效处理 class 的添加、更新和移除
  2. patchStyle:支持样式对象的 diff 更新
  3. patchEvent:通过 invoker 模式优化事件绑定性能
  4. patchAttr:处理通用 HTML 属性

这些方法共同构成了 Vue 高效的属性更新机制,是虚拟 DOM diff 算法的重要组成部分。