2026.01.18

Renderer 更新 -> 文本节点的处理与优化

在之前的章节中,我们一直通过虚拟 DOM 创建元素节点并渲染到页面中。然而,Vue 不仅支持元素节点,还支持文本节点的独立渲染。本章将深入探讨文本节点的实现原理,以及如何通过虚拟节点标准化来简化开发体验。

在之前的章节中,我们一直通过虚拟 DOM 创建元素节点并渲染到页面中。然而,Vue 不仅支持元素节点,还支持文本节点的独立渲染。本章将深入探讨文本节点的实现原理,以及如何通过虚拟节点标准化来简化开发体验。

从元素节点到文本节点

传统的元素节点渲染

在此前的实现中,我们通过虚拟 DOM 创建元素节点:

import { h, render, Text } from '../../../node_modules/vue/dist/vue.esm-browser.js'

const vNode = h('div', null, ['hello', 'world'])

render(vNode, app)

渲染结果:

独立的文本节点

但实际上,Vue 也支持将文本作为独立的虚拟节点进行渲染。观察 Vue 官方的 vnode 结构,可以看到 childrentype 是一个 Symbol(v-txt)

这意味着文本节点在 Vue 中有自己独立的节点类型标识,而不是简单地作为字符串存在。

实现文本节点支持

核心思路

要支持文本节点,我们需要:

  1. 定义文本节点类型:创建一个唯一的标识符来区分文本节点
  2. 处理文本节点挂载:在 patch 函数中添加文本节点的处理逻辑
  3. 处理文本节点更新:实现文本内容变化时的更新机制

第一步:创建文本节点标记

首先,我们需要定义一个唯一的标识符来表示文本节点:

// vnode.ts
export const Text = Symbol('v-txt')

为什么使用 Symbol?

特性说明
唯一性Symbol 保证了标识符的全局唯一性
类型安全避免与字符串类型混淆,类型检查更严格
语义化使用 Symbol('v-txt') 清晰表达意图

第二步:在渲染器中处理文本节点

文本节点的核心逻辑都在 render.ts 中实现。我们需要在 patch 函数中添加对文本节点的特殊处理:

基本使用

import { h, render, Text } from '../dist/vue.esm.js'

const vNode = h(Text, null, 'hello')

render(vNode, app)

初次挂载时,执行流程为:renderpatch

改造 patch 函数

patch 函数中使用 switch 语句来区分不同类型的节点:

function patch(n1, n2, container, anchor = null) {
  // 相同节点,直接返回
  if (n1 === n2)
    return

  // 节点类型不同,先卸载旧节点
  if (n1 && !isSameVNodeType(n1, n2)) {
    unmount(n1)
    n1 = null
  }

  const { shapeFlag, type } = n2

  switch (type) {
    case Text:
      // 处理文本节点
      processText(n1, n2, container, anchor)
      break

    default:
      if (shapeFlag & ShapeFlags.ELEMENT) {
        // 处理 DOM 元素 -> div span p h1
        processElement(n1, n2, container, anchor)
      }
      else if (shapeFlag & ShapeFlags.COMPONENT) {
        // TODO: 组件处理
      }
  }
}

设计要点:

  1. 类型优先判断:通过 switch (type) 优先处理特殊节点类型(如 Text
  2. 后备处理:在 default 分支中处理普通元素和组件
  3. 节点复用检查:在处理前先判断节点类型是否变化

第三步:实现 processText 函数

processText 函数负责文本节点的挂载和更新:

// 文本节点的更新与挂载
function processText(n1, n2, container, anchor) {
  if (n1 == null) {
    // 初次挂载:创建文本节点
    const el = hostCreateText(n2.children)
    n2.el = el
    hostInsert(el, container, anchor)
  }
  else {
    // 更新:复用 DOM 节点,仅更新文本内容
    n2.el = n1.el
    if (n1.children !== n2.children) {
      hostSetText(n2.el, n2.children)
    }
  }
}

逻辑分析

挂载阶段(n1 == null):

步骤操作说明
1hostCreateText(n2.children)创建真实的文本 DOM 节点
2n2.el = el将 DOM 节点引用保存到 vnode 中
3hostInsert(el, container, anchor)将节点插入到容器中

更新阶段(n1 != null):

步骤操作说明
1n2.el = n1.el复用旧节点的 DOM 引用
2比较文本内容判断 children 是否变化
3hostSetText(n2.el, n2.children)只在内容变化时更新,避免不必要的 DOM 操作

性能优化点:

  • 节点复用:更新时不创建新的 DOM 节点,直接复用旧节点
  • 按需更新:只在文本内容真正变化时才调用 hostSetText
  • 最小化操作:文本更新是最轻量的 DOM 操作之一

虚拟节点标准化:提升开发体验

问题场景

现在我们有了文本节点的支持,但在实际使用中会遇到这样的问题:

之前的写法:

const vnode = h('div', null, [
  h('span', null, 'hello'),
  h('span', null, 'world')
])

理想的写法:

const vnode = h('div', null, ['hello', 'world'])

第二种写法更简洁直观,但我们的系统目前无法识别字符串 'hello''world' 应该被当作文本节点处理。

解决方案:虚拟节点标准化

我们需要一个函数,将各种类型的 children 标准化为统一的虚拟节点格式:

// vnode.ts
// 虚拟节点标准化函数

/**
 * 将原始值转换为标准的虚拟节点
 * @param vnode - 可能是虚拟节点、字符串或数字
 * @returns 标准化后的虚拟节点
 */
export function normalizeVNode(vnode) {
  // 如果是字符串或数字,转换为文本节点
  if (isString(vnode) || isNumber(vnode)) {
    return createVNode(Text, null, String(vnode))
  }

  // 已经是虚拟节点,直接返回
  return vnode
}

处理的类型映射

输入类型处理方式输出
stringcreateVNode(Text, null, vnode)文本节点虚拟对象
numbercreateVNode(Text, null, String(vnode))文本节点虚拟对象(数字转字符串)
vnode直接返回原始虚拟节点

应用到 diff 算法

我们需要在所有处理子节点的地方应用标准化,确保系统能正确识别各种格式的 children

双端 diff 中的应用

function patchKeyedChildren(c1, c2, container) {
  let i = 0
  let e1 = c1.length - 1
  let e2 = c2.length - 1

  // 前序比对
  while (i <= e1 && i <= e2) {
    const n1 = c1[i]
    const n2 = (c2[i] = normalizeVNode(c2[i])) // 标准化
    if (isSameVNodeType(n1, n2)) {
      patch(n1, n2, container)
    }
    else {
      break
    }
    i++
  }

  // 后序比对
  while (i <= e1 && i <= e2) {
    const n1 = c1[e1]
    const n2 = (c2[e2] = normalizeVNode(c2[e2])) // 标准化
    if (isSameVNodeType(n1, n2)) {
      patch(n1, n2, container)
    }
    else {
      break
    }
    e1--
    e2--
  }

  // 新增节点处理
  if (i > e1) {
    const nextPos = e2 + 1
    const anchor = nextPos < c2.length ? c2[nextPos].el : null
    while (i <= e2) {
      patch(null, (c2[i] = normalizeVNode(c2[i])), container, anchor) // 标准化
      i++
    }
  }
  // ... 其他逻辑
}

乱序 diff 中的应用

else {
  // 乱序处理
  for (let j = s2; j <= e2; j++) {
    const n2 = (c2[j] = normalizeVNode(c2[j]))  // 标准化
    keyToNewIndexMap.set(n2.key, j)
  }
  // ...
}

标准化的好处

开发体验提升:

场景优化前优化后
纯文本列表[h(Text, null, 'a'), h(Text, null, 'b')]['a', 'b']
混合内容[h('div'), h(Text, null, 'text')][h('div'), 'text']
数字内容[h(Text, null, String(123))][123]

系统健壮性提升:

  1. 类型容错:自动处理多种输入类型
  2. 统一处理:内部统一使用虚拟节点格式
  3. 减少错误:避免直接传递字符串导致的类型错误

完整代码汇总

vnode.ts

// 文本节点类型标识
export const Text = Symbol('v-txt')

/**
 * 虚拟节点标准化函数
 * 将字符串、数字等原始类型转换为文本节点虚拟对象
 */
export function normalizeVNode(vnode) {
  if (isString(vnode) || isNumber(vnode)) {
    return createVNode(Text, null, String(vnode))
  }
  return vnode
}

render.ts

/**
 * patch 函数 - 节点对比和更新的核心
 */
function patch(n1, n2, container, anchor = null) {
  if (n1 === n2)
    return

  // 节点类型不同,卸载旧节点
  if (n1 && !isSameVNodeType(n1, n2)) {
    unmount(n1)
    n1 = null
  }

  const { shapeFlag, type } = n2

  switch (type) {
    case Text:
      processText(n1, n2, container, anchor)
      break

    default:
      if (shapeFlag & ShapeFlags.ELEMENT) {
        processElement(n1, n2, container, anchor)
      }
      else if (shapeFlag & ShapeFlags.COMPONENT) {
        // 组件处理
      }
  }
}

/**
 * 文本节点的挂载和更新
 */
function processText(n1, n2, container, anchor) {
  if (n1 == null) {
    // 挂载阶段
    const el = hostCreateText(n2.children)
    n2.el = el
    hostInsert(el, container, anchor)
  }
  else {
    // 更新阶段
    n2.el = n1.el
    if (n1.children !== n2.children) {
      hostSetText(n2.el, n2.children)
    }
  }
}

总结

通过本章的学习,我们完成了文本节点的完整支持和虚拟节点标准化优化。让我们回顾一下核心要点:

1. 文本节点实现

  • 类型标识:使用 Symbol('v-txt') 定义文本节点唯一标识
  • 挂载逻辑:通过 hostCreateText 创建真实文本 DOM 节点
  • 更新逻辑:复用节点,仅在内容变化时更新文本
  • 性能优化:最小化 DOM 操作,按需更新

2. 渲染器改造

  • 类型分发:在 patch 中使用 switch 优先处理特殊节点类型
  • 独立处理processText 专门处理文本节点的生命周期
  • 统一接口:遵循与元素节点相同的 n1n2 参数模式

3. 虚拟节点标准化

  • 类型转换:自动将字符串、数字转换为文本节点
  • 开发体验:简化 API 使用,支持更直观的语法
  • 系统健壮:统一内部数据格式,减少类型错误
  • 全面应用:在所有 children 处理位置应用标准化

4. 使用场景对比

标准化前:

h('div', null, [
  h(Text, null, 'hello'),
  h(Text, null, 'world'),
  h(Text, null, String(123))
])

标准化后:

h('div', null, ['hello', 'world', 123])

5. 关键技术点

技术点实现方式作用
节点标识Symbol('v-txt')唯一性标识,避免冲突
节点复用n2.el = n1.el减少 DOM 创建开销
按需更新if (n1.children !== n2.children)避免不必要的文本更新
类型标准化normalizeVNode(vnode)统一数据格式,提升开发体验

6. 性能优化效果

优化项优化前优化后提升
DOM 操作每次都重新创建复用节点减少创建开销
文本更新无判断直接更新按需更新减少不必要的 DOM 操作
API 简洁度冗长的虚拟节点创建直接使用字符串代码量减少 60%+

至此,我们的渲染器已经支持了文本节点的完整生命周期管理,并通过虚拟节点标准化大幅提升了开发体验。这为后续组件系统的实现奠定了坚实的基础。