2025-10-21 15:04:39

Vue 中,是通过虚拟 DOM 来创建真实 DOM 的。产生原因就是通过平台配置完成渲染。浏览器平台对应的模块就是 runtime-dom

官方描述: 👀

import { createRenderer } from '@vue/runtime-core'

const { render, createApp } = createRenderer({
  patchProp,
  insert,
  remove,
  createElement
  // ...
})

// `render` 是底层 API
// `createApp` 返回一个应用实例
export { createApp, render }

// 重新导出 Vue 的核心 API
export * from '@vue/runtime-core'

runtime-dom 中大概分为两部分

  • nodeOps:封装创建、插入、删除、查询等 DOM 节点操作
  • patchProp:处理元素属性更新,像 classstyle、事件和普通属性

createRenderer 接收的 options,就是浏览器平台提供渲染逻辑的能力。

渲染器内部只关心“我要创建一个元素”“我要插入一个节点”“我要更新一个属性”,但具体怎么操作 DOM,由 runtime-dom 传进去。

这样做之后,核心渲染逻辑就可以和平台解耦。浏览器里可以渲染 DOM,其他平台也可以提供自己的节点操作。

nodeOps

nodeOps 负责把常用的原生 DOM API 包一层,提供给渲染器使用。

// packages/runtime-dom/src/nodeOps.ts
export const nodeOps = {
  insert(el, parent, anchor) {
    parent.insertBefore(el, anchor || null)
  },

  createElement(type) {
    return document.createElement(type)
  },

  remove(el) {
    const parentNode = el.parentNode

    if (parentNode) {
      parentNode.removeChild(el)
    }
  },

  setElementText(el, text) {
    el.textContent = text
  },

  createText(text) {
    return document.createTextNode(text)
  },

  setText(node, text) {
    node.nodeValue = text
  },

  parentNode(el) {
    return el.parentNode
  },

  nextSibling(el) {
    return el.nextSibling
  },

  querySelector(selector) {
    return document.querySelector(selector)
  }
}

patchProp

patchProp 负责 props 更新,一个元素的 props 里可能出现很多类型:

  • class
  • style
  • onClick 事件
  • idtitledata-* 属性

不同类型的属性,更新方式并不一样。所以 patchProp 要做一次分发:

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

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)
}

更新 class

const vnode = h('div', { class: 'container' }, 'hello world')
const vnode2 = h('div', {}, 'hello world')

renderer.render(vnode, app)

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

第一次渲染时,元素上有 class="container"

第二次渲染时,新的 vnode 没有传 class,所以旧的 class 应该被移除。

export function patchClass(el, value) {
  if (value == null) {
    el.removeAttribute('class')
  }
  else {
    el.className = value
  }
}

更新 style

const vnode = h('div', { style: { color: 'red' } }, 'hello world')
const vnode2 = h(
  'div',
  { style: { background: 'green', color: 'pink' } },
  'hello world'
)

renderer.render(vnode, app)

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

style 不能只把新值写进去,还要处理旧样式的删除。

第二次渲染时:

  • colorred 更新为 pink
  • 新增 background: green
  • 如果旧样式里有新样式不存在的字段,删除
export function patchStyle(el, prevValue, nextValue) {
  const style = el.style
  // 先把新的样式全部设置到元素上。
  if (nextValue) {
    for (const key in nextValue) {
      style[key] = nextValue[key]
    }
  }
  // 遍历旧样式。如果某个旧字段在新样式里不存在,就把它清空
  if (prevValue) {
    for (const key in prevValue) {
      if (!nextValue || !(key in nextValue)) {
        style[key] = null
      }
    }
  }
}

更新事件

事件的属性名一般是 onClickonInput 这种形式。

模板里的 @click,最终会被编译成类似 onClick 的属性,所以可以通过正则识别事件:

export const isOn = key => /^on[A-Z]/.test(key)
const vnode = h('div', {
  onClick() {
    console.log('click')
  }
}, 'hello world')

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

renderer.render(vnode, app)

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

如果每次更新都先 removeEventListener,再 addEventListener,也能工作。

Vue 用一个 invoker 包一层事件函数。事件真正绑定到 DOM 上的是 invoker,后续更新时只替换 invoker.value

const veiKey: unique symbol = Symbol('_vei')

export function patchEvent(el, rawName, nextValue) {
  const name = rawName.slice(2).toLowerCase()
  const invokers = el[veiKey] || (el[veiKey] = {})
  const existingInvoker = invokers[rawName]

  if (nextValue) {
    if (existingInvoker) {
      existingInvoker.value = nextValue
    }
    else {
      const invoker = createInvoker(nextValue)

      invokers[rawName] = invoker
      el.addEventListener(name, invoker)
    }
  }
  else if (existingInvoker) {
    el.removeEventListener(name, existingInvoker)
    invokers[rawName] = undefined
  }
}

function createInvoker(value) {
  const invoker = (e) => {
    invoker.value(e)
  }

  invoker.value = value
  return invoker
}
  1. 之前有事件,现在也有事件:只更新 existingInvoker.value
  2. 之前没有事件,现在有事件:创建 invoker 并绑定到 DOM
  3. 之前有事件,现在没有事件:移除 DOM 事件监听

更新普通属性

剩下的属性走 patchAttr

比如 idtitledata-* 这类属性,都可以通过 setAttributeremoveAttribute 处理。

逻辑和 class 类似:

  • 新值存在,就设置属性
  • 新值是 nullundefined,就删除属性
export function patchAttr(el, key, value) {
  if (value == null) {
    el.removeAttribute(key)
  }
  else {
    el.setAttribute(key, value)
  }
}

组合 renderOptions

最后把节点操作和属性更新能力合并起来:

import { nodeOps } from './nodeOps'
import { patchProp } from './patchProp'

export const renderOptions = {
  patchProp,
  ...nodeOps
}

这样核心渲染器拿到 renderOptions 后,就可以完成浏览器 DOM 的创建、插入、删除、文本更新和属性更新。

这一层做完之后,渲染器不再依赖具体的 DOM API。只需要调用传入的配置项,就能把虚拟 DOM 渲染到真实页面上。