Vue3之渲染器

前言

渲染器就是用来执行渲染任务的,前面我们学习了响应式系统,渲染器(Renderer)是连接响应式系统DOM 更新的核心引擎,接下来我们学习渲染器的工作,所谓的虚拟DOM和Diff算法又有何妙处。

声明式描述UI(模版与虚拟DOM)

Vue使用一种基于HTML的模版语法,能够声明式地将其组件实例的数据绑定到呈现的DOM上。当然如果你对虚拟DOM熟悉,也可以结合JSX直接手写渲染函数。无论是手写虚拟 DOM(渲染函数)还是使用模板,都属于声明式 地描述 UI,并且 Vue.js 同时支持这两种描述 UI 的方式。

模板通过编译器编译为渲染函数,之后也需要学习编译器,这里先简单知道有这个东西,把模板编译为渲染函数。编译技术是一门庞大的学科,到时候也只是了解一下其中的工作流程和关键点。

在我们静态的描述"UI应该是什么样子"之后,Vue3框架式如何进行DOM的更新的呢? 显然,Vue框架肯定不是使用innerHTML或者全量替换虚拟DOM来更新页面的,这样全量销毁-重建的方式非常消耗性能。这里开始就不得不提到虚拟DOM了。

虚拟DOM

虚拟DOM(Virtual DOM,简称VDOM)是一种编程概念,是用 JavaScript 对象轻量描述真实 DOM 结构的树形表示,意为将目标所需UI通过数据结构"虚拟"地表示出来,保存在内存中,然后将真实DOM与之保持同步。即用 JavaScript对象(VNode)描述UI结构(树形结构),在内存中对比新老VNode差异(Diff),再最小化操作真实DOM。

ts 复制代码
// 虚拟DOM结构(简化版)
interface VNode {
	type: string | Component; // 标签名或组件对象
	props: Record<string, any> | null;
	children: VNode[] | string | null;
	key: string | number | null;
	el: Element | null; // 挂载后的真实DOM
}

注:虚拟DOM是跨平台的,因为他只是一个JS对象

渲染器

渲染器是用来执行渲染任务的,核心职责如下

  • 挂载(mount):虚拟DOM ---> 真实DOM
  • 更新(patch) :旧VNode + 新 VNode---> 最小化DOM操作
  • 卸载(unmount):移除DOM并清理副作用(如事件监听、组件销毁)

createRenderer

前面提到虚拟DOM是跨平台的, 对应的渲染器也是平台无关的,只要提供不同平台的节点操作方法(如:createElementinsertremove等),就可以实现跨平台渲染(Web、Weex、小程序等)

createRenderer根据平台提供的节点操作方法(Host Config),生成一套完整的、可复用的渲染器API(如 rendercreateApp等)

ts 复制代码
function createRenderer<HostNode, HostElemnt>(options: RendererOptions<HostNode, HostElemnt>): { 
	render: (vnode: VNode | null, container: HostElement) => void;
	createApp: (...args: any[])	=> App
}
scss 复制代码
createRenderer(options)
├─ render(vnode, container)          // 入口
├─ patch(n1, n2, container, anchor)  // 核心差分
├─ mountElement / mountComponent     // 首次挂载
├─ patchElement / patchComponent     // 更新打补丁
└─ unmount                           // 卸载回收

这里简单介绍一下返回值

render(vnode, container)

用于将VNode渲染到指定容器中,首次调用为挂载(mount)、后续调用为更新(patch) 或 卸载(unmount)。

createApp().mount() 的区别仅在于:它不关联任何应用上下文(app context) ,因此无法访问全局注册的资源(如全局组件、指令、app.provide() 注入的数据等)

如果你直接用 render(h(MyComponent), el),MyComponet会被当做一个普通组件渲染但没有应用上下文(app context),无法使用全局注册的组件/指令/插件,无法注入来自 app 的 provide。

同一个容器可以被多次调用 render,Vue 会自动进行 patch(diff)更新 。容器上会挂载 _vnode 属性缓存上一次的 VNode,用于对比。当 vnodenull 时,render(null, container) 会卸载容器内所有内容,并触发组件的 onUnmounted 钩子。

ts 复制代码
import { render, h } from 'vue';
import Modal from './Modal.vue';

function showTemporaryModal(props) {
  const container = document.createElement('div');
  document.body.appendChild(container);
    render(h(Modal, props), container);
  
  // 关闭时卸载
  // render(null, container);
  // document.body.removeChild(container);
}

在有需要时候使用render,能让Vue应用更灵活、更强大,比如动态渲染 微前端、测试、生命周期控制等场景可能能用上。

createApp(...args)

返回一个App实例(应用根实例),支持插件、全局组件、指令等配置;内部会调用render进行挂载。每个createApp()调用都会生成一个全新的应用上下文 。与Vue2中的new Vue()构造函数的单例模式不同。Vue 2 是 "一个 Vue,多个实例" ;Vue 3 是 "多个 app,彼此独立"

常用的方法有 .mount(domTarget),use(plugin).component(name, component)`等

挂载与更新

HTML Attributes 和 DOM Properties

我们在写关于代码的时候,属性是可以写在HTML标签上,也可以通过JS对象设置的。那Vue3在将虚拟DOM装换成真实DOM的时候是如何处理属性的呢?到底是用setAttribute还是elem.prop = xxxx呢?

DOM Properties 与 HTML Attributes 的名字不总是一模一样的,如classel.className。如果DOM Properties 与 HTML Attributes具有相同的名称属性(如id),我们将其看做直接映射关系。

html 复制代码
<input value="foo" />
<!-- 这是一个具有value属性的input标签,如果用户修改了文本框的内容,会出现一些的修改现象 -->
console.log(el.getAttribute('value')) // 仍然是 'foo'
console.log(el.value) // 'bar'

这是因为HTML Attributes的作用是设置与之对应的DOM Properties 的初始值(但如果设置的是不合法的值,浏览器内部会进行矫正),我们也可以通过 el.defaultValue来访问初始值。这说明一个 HTML Attributes 可能关联多个 DOM Properties。例如与el.valueel.defaultValue都有关联。以上例子中HTML Attributes 和 DOM Properties的工作,是浏览器自动分析完成的,但用户写的Vue文件组件模板是不会被浏览器解析的,这些工作都需要Vue框架来完成。

html 复制代码
<button disabled> Button </button>

对应的vnode

js 复制代码
const button = {
	type: 'button',
	props: {
		disabled:'' // 注意,这里是空字符串
	}
} 

这时候通过 el.setAttribute('disabled', '')是可以设置为禁用的。如果是

html 复制代码
<button :disabled="false"> Button </button>

对应的vnode

js 复制代码
const button = {
	type: 'button',
	props: {
		disabled:'false' // 注意,这里是字符串
	}
} 

这时候通过 el.setAttribute('disabled', 'false')也是设置为禁用的,因为false是字符串(setAttribute函数设置的值总会被字符串化),而disabled要求布尔类型的值。所以我们发现,渲染器不能总是使用setAttributevnode.props对象中的属性设置到元素上。即只使用setAttribute是行不通的。

上面的例子如果使用el.disabled = false是可以正确工作的,但是空字符的时候el.disabled = ''则会相当于el.disabled = false

这样看来,无论是使用 setAttribute 函数,还是直接设置元素 的 DOM Properties,都存在缺陷。要彻底解决这个问题,我们只能做特殊处理,即优先设置元素的 DOM Properties,但当值为空字符串 时,要手动将值矫正为 true。只有这样,才能保证代码的行为符合预期。

Vue3在处理属性时,会根据属性类型决定是使用HTML Attribute还是DOM Property,即

  1. DOM Properties → 走 el.prop = value
  2. HTML Attributes → 走 setAttribute / removeAttribute
  3. Events → 走 addEventListener

class特殊处理

写Vue组件文件的时候,我们都知道class有好几种绑定方式且可以同时使用,但最终是如何正确绑定到class属性上的呢?

normalizeClass
  1. 静态 template 字符串
  2. :class 对象/数组/字符串
  3. 组件根元素继承的 $attrs.class(透传)
  4. v-bind="{class:...}"这种动态对象里写的class(相当于:class="...")
vue 复制代码
  <div
    class="static"
    :claas="[
      { 'from-object': flage, inactive: !flage },
      'from-array',
      flage ? 'from-computed' : ''
    ]"
  >
    <button @click="flag = !flag">toggle flag</button>
  </div>

上面的例子首先会把模板里面的class全部转成数组形式['static', {...}, Array(2), 'from-parent'],然后递归把数组/对象/字符串拍平成一串空格分割的字符串 normalizeClass, static inactive from-array from-parent 一次性赋值给className。 注:style 属性也需要做类似的处理。

为什么选 className 而不是 setAttribute('class',...)

按前面的说法,class的属性对应的DOM Properties为class,el.class是不存在的,所以应该会使用setAttribute来设置的,但在实际操作中是使用el.className来设置的,因为className的性能覆盖全部类名更快,少一次字符串解析,而 setAttribute 必须走「属性 → 字符串 → 词法扫描 → 令牌列表」全流程。模板任然使用class是为了保留书写习惯所致。

事件的处理

事件被当作一种特殊的 prop,key 以 on 开头(如 @click → onClick),凡是以字符串 on 开头的属性都视作事件。

在绑定事件时,我们可以绑定一个伪造的事件处理函数 invoker,然后把真正的事件处理函数设置为 invoker.value 属性的值。这样当更新事件的时候,我们将不再需要调用 removeEventListener 函数来移除上一次绑定的事件,只需要更新 invoker.value 的值即可。

js 复制代码
const vnode = {
  type: "p",
  props: {
    onClick: [
      // 第一个事件处理函数
      () => {
        alert("clicked 1");
      },
      // 第二个事件处理函数
      () => {
        alert("clicked 2");
      },
    ],
  },
  children: "text",
};
js 复制代码
// 代码演示
patchProps(el, key, prevValue, nextValue) {
  if (/^on/.test(key)) {
    const invokers = el._vei || (el._vei = {})
    let invoker = invokers[key]
    const name = key.slice(2).toLowerCase()
    if (nextValue) {
      if (!invoker) {
        invoker = el._vei[key] = (e) => {
          // e.timeStamp 是事件发生的时间
          // 如果事件发生的时间早于事件处理函数绑定的时间,则不执行事件处理函数
          if (e.timeStamp < invoker.attached) return
          if (Array.isArray(invoker.value)) {
            invoker.value.forEach(fn => fn(e))
          } else {
            invoker.value(e)
          }
        }
        invoker.value = nextValue
        // 添加 invoker.attached 属性,存储事件处理函数被绑定的时间
        invoker.attached = performance.now()
        el.addEventListener(name, invoker)
      } else {
        invoker.value = nextValue
      }
    } else if (invoker) {
      el.removeEventListener(name, invoker)
    }
  } else if (key === 'class') {
    // 省略部分代码
  } else if (shouldSetAsProps(el, key, nextValue)) {
    // 省略部分代码
  } else {
    // 省略部分代码
  }
}

问题:事件修饰符变了,渲染器怎么处理?不需要removeEventListener再addEventListener吗?

模板编译器在生成代码时就把 .stop / .prevent 等修饰符内联进 handler wrapper ,渲染器侧完全不感知修饰符存在;因此运行时依旧只有"一个 invoker"。

卸载

卸载的时候也不能简单的使用innerHTML清空容器,它并不会移除绑定DOM元素上的事件处理函数,正确的卸载方式是,根据 vnode 对象获取与其相关联的真实 DOM 元素,然后使用原生 DOM 操作方法将该 DOM 元素移除。

js 复制代码
function unmount(vnode, parent, doRemove) {
  const { type, shapeFlag, el, props } = vnode

  // 1. 触发 beforeUnmount 钩子
  if (shapeFlag & ShapeFlags.COMPONENT) {
    invokeArrayFns(bu)
  }

  // 2. 卸载指令
  if (dirs) {
    invokeDirectiveHook(dirs, vnode, null, 'beforeUnmount')
  }

  // 3. 真正移除 DOM 前的"事件壳"清理
  if (props) {
    for (const key in props) {
      if (key[0] === 'o' && key[1] === 'n') {
        // 仅把 invoker.value 置空,壳仍留在 el._vei
        const invoker = el._vei[key.slice(2).toLowerCase()]
        if (invoker) invoker.value = null
      }
    }
  }

  // 4. 递归子组件
  if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    unmountChildren(children)
  }

  // 5. 移除 DOM 节点(hostRemove 是平台 API)
  if (doRemove) {
    hostRemove(el!)
  }

  // 6. 生命周期 unmounted(队列)
  queuePostRenderEffect(um)
}

Diff算法

Diff 算法是渲染器的核心,操作DOM的性能开销通常是比较大的,为了以最小的性能开销完成更新操作,Vue3是如何做的呢,首先来介绍一下几种Diff算法。

简单Diff算法

核心在于:"按序对比" +"就地复用"。只用 新列表顺序做基准,在旧列表里找可复用节点,找不到旧新建,找到旧patch+移动

less 复制代码
新 [A B C]          // 从头扫描
旧 [A C B]

Step1: A 相同 → patch
Step2: 新 B ≠ 旧 C → 在旧列表里按 key 找到 B → 把真实 DOM 移动到第 2 位
Step3: 新 C ≠ 旧 C(已被跳过)→ 在旧列表里按 key 找到 C → 把真实 DOM 移动到第 3 位

缺点

  • 每找一个新节点都要遍历整个旧列表(O(n²) 最坏)
  • 移动次数可能不是最优-上例中只需要移动一次即可

双端Diff算法

核心在于:"四指针" + "四部预判"。建立旧头旧尾,新头新尾四个索引,按头头、尾尾、头尾、尾头顺序对比,提前命中省去乱序查找。

ini 复制代码
初始
old  [A B C D]
new  [D A B C]

第1轮
① 头头 A≠D;② 尾尾 D≠C;③ 头尾 A≠C;④ 尾头 D=D ✅ → 把真实 D 移动到最前
→ oldE 前移;newS 后移

old  [A B C]   D(已处理)
new      [A B C]   D(已处理)

第2轮
① 头头 A=A ✅ → 无移动,双 S 后移
② 头头 B=B ✅ → 无移动
③ 头头 C=C ✅ → 无移动
结束

头头/尾尾,DOM无需移动,指针向中间走;头尾/尾头,进行一次DOM移动,指正继续向中间走。如果四步都没有命中,则拿新的一组子节点中的头部节点去旧的一组子节点中寻找,如果找到可复用节点则移动DOM并标记处理过了,找不到则插入新的节点。然后头部指针往中间走,继续双端Diff。

快速Diff算法

核心在于:"预处理相同前缀/后缀" + "最长递增子序列", 把移动次数做到理论最小。 (这也是Vue3框架设计的取舍之处,宁愿增加内存的计算,也要减少操作DOM的性能开销)

主要有三步

  1. 头尾预处理 从头、尾同时扫描,key相同的直接patch并剪掉这部分,剩下真正乱序的部分
  2. 构建新->旧索引映射 把新列表的key做成Map -> 旧列表出现的下标数组
  3. 求出最长递增子序列(LIS) 得到最多可以原地不动的节点,其余节点进行移动
css 复制代码
旧  [A B C D E F]
新  [A D E C B F]

Step1 预处理
头 A 相同 → 剪掉
尾 F 相同 → 剪掉
剩余
旧  [B C D E]
新  [D E C B]

Step2 建立映射
新 key → 旧下标
D→3  E→4  C→2  B→0
得到 newIndexToOldIndexMap = [3,4,2,0]

Step3 求 LIS
最长递增 = [2,4] 对应旧索引 → C,E
即 C,E 可以不动
只需移动 D,B 两次即可

与Vue2对比的渲染性能提升点

静态提升

Vue2每次渲染都会重新创建所有的VNode,而Vue3将不依赖于响应式数据的静态节点提升到渲染函数外部,只创建一次,后续复用。

js 复制代码
<template>
  <div>
    <h1>欢迎使用 Vue3</h1> <!-- 静态节点 -->
    <p>当前计数:{{ count }}</p> <!-- 动态节点 -->
  </div>
</template>

<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
js 复制代码
// 静态节点被提升到函数外部(只创建一次)
const _hoisted_1 = createVNode("h1", null, "欢迎使用 Vue3")

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return createVNode("div", null, [
    _hoisted_1, // 复用
    createVNode("p", null, "当前计数:" + _ctx.count, PatchFlags.TEXT) // 动态
  ])
}

预字符串处理

对连续的大块静态内容,直接编译为HTML字符串,渲染时直接通过innerHtml插入,避免逐节点创建

vue 复制代码
<template>
  <div>
    <header>
      <h1>网站标题</h1>
      <nav>
        <a href="/">首页</a>
        <a href="/about">关于</a>
      </nav>
    </header>
    <main>
      <p>内容区域:{{ message }}</p>
    </main>
  </div>
</template>
js 复制代码
const _hoisted_1 = /*#__PURE__*/createStaticVNode('<header><h1>网站标题</h1><nav><a href="/">首页</a><a href="/about">关于</a></nav></header>', 3)

export function render(_ctx, _cache, ...) {
  return createVNode("div", null, [
    _hoisted_1, // 直接插入 HTML 字符串
    createVNode("main", null, [
      createVNode("p", null, "内容区域:" + _ctx.message, PatchFlags.TEXT)
    ])
  ])
}

PatchFlag(动态节点标记)

编译器为每个VNode打上patchFlag,标明器动态类型(如 TEXT、PROPS、CLASS等),渲染器在进行Diff时只检查标记的动态部分,跳过静态属性对比

vue 复制代码
<template>
  <div :id="dynamicId" class="static-class" :class="{ active: isActive }">
    {{ title }}
  </div>
</template>
js 复制代码
createVNode(
  "div",
  {
    id: _ctx.dynamicId,           // 动态属性
    class: normalizeClass(["static-class", { active: _ctx.isActive }]) // 动态 class
  },
  _ctx.title,                     // 动态文本
  PatchFlags.TEXT | PatchFlags.CLASS | PatchFlags.PROPS, // 标记类型
  ["id"] // props 白名单(仅 id 需要追踪)
)

更新时,渲染器知道只需检查 textclassid,跳过其他静态属性(如 style、事件等)比对

Block Tree (块级树)

将组件VNode树划分为多个"块"(Block),每个块只包含动态节点及其最近的静态祖先,更新的时候,只遍历这些小块,而不是整棵树,大幅缩小Diff的范围

vue 复制代码
<template>
  <div>
    <header>静态头部</header>
    <main>
      <p>静态段落</p>
      <span>{{ user.name }}</span> <!-- 动态 -->
      <footer>静态页脚</footer>
    </main>
    <aside>{{ sidebarContent }}</aside> <!-- 动态 -->
  </div>
</template>

编译策略:

  • 整个 <div> 是根块。
  • <span>{{ user.name }}</span><aside>{{ sidebarContent }}</aside> 是两个动态节点。
  • 它们各自所在的子树(包括最近的静态父节点)被组织为独立的"块"

运行时行为:user.name 变化时:

  • 渲染器只进入包含 <span> 的块进行 Diff。
  • <aside> 块和 <header> 等完全跳过

虚拟 DOM Diff 算法优化

Vue3 重写了 Diff 算法, 在处理子节点移动时,使用 LIS 算法最小化 DOM 移动操作。相比 Vue2 的简单双端 Diff,能更智能地复用节点,减少重排重绘。

事件处理函数缓存

内联事件函数(如 @click="() => doX(id)")会被自动缓存,避免因函数引用变化导致子组件不必要的更新。

vue 复制代码
<template>
  <ChildComponent @click="() => handleClick(item.id)" />
</template>

在Vue2中,每次父组件渲染,() => handleClick(item.id) 都是一个新函数 ,导致 ChildComponent 被强制更新(即使 props 未变)。vue3编译器自动使用 _cache 缓存内联函数

js 复制代码
// _cache[0] 会缓存该函数,只要 item.id 不变,函数引用不变
createVNode(ChildComponent, {
  onClick: _cache[0] || (_cache[0] = ($event) => _ctx.handleClick(_ctx.item.id))
})

结语

渲染器是 Vue 3 的核心模块,负责将虚拟 DOM(VNode)转换为真实 DOM,并处理更新、卸载、组件生命周期等逻辑。它是平台无关 的,通过配置不同的节点操作 API 可适配 Web、Native、Canvas 等环境。其核心是虚拟 DOM 算法响应式系统的深度结合,通过编译时优化和运行时调度,将 DOM 操作降至最低,同时保持出色的开发体验。

相关推荐
刘永胜是我3 小时前
解决Volta环境下npm全局包卸载失败:一次深入排查之旅
前端·node.js
白水清风3 小时前
Vue3之组件化
前端·vue.js·面试
边洛洛3 小时前
解决[PM2][ERROR] Script not found: D:\projects\xxx\start
前端·javascript
白水清风3 小时前
Vue3之响应式系统
vue.js·面试·前端工程化
农夫山泉的小黑3 小时前
【DeepSeek帮我准备前端面试100问】(十八)Reflect在vue3的使用
前端·面试
Achieve前端实验室4 小时前
【每日一面】手写防抖函数
前端·面试·node.js
三十_4 小时前
TypeORM 多对多关联篇:中间表、JoinTable 与复杂关系的建模
前端·后端
用户6883362059704 小时前
移动端 Web 性能调优:viewport、dvh 与触控优化解析
前端
console.log('npc')4 小时前
使用 Vue3 和 Element Plus 实现选择新增用户集下拉选项框,切换类型,有物业,网格,电子围栏,行政区划管理
javascript·vue.js·elementui