渲染器

很多人学 Vue 时,更熟悉的是模板:

<div>{{ count }}</div>

但 Vue 真正把页面变出来,靠的不是模板本身,而是渲染器。

你可以把渲染器理解成这样一层:

给我一棵虚拟节点树,我来决定怎么把它挂到真实页面上,以及数据变化后怎么高效更新。

这张图可以帮助你快速区分渲染器最重要的两类工作:首次挂载,以及后续更新。

一、渲染器到底在做什么

渲染器主要做三件事:

  • 把虚拟节点挂载成真实节点
  • 在新旧节点之间做对比
  • 用尽量少的 DOM 操作完成更新

简单说,它负责把“声明式 UI”落地成“命令式 DOM 操作”。

二、为什么需要虚拟节点

因为 Vue 不希望你每次更新都自己手动写:

const el = document.createElement('div')
el.textContent = String(count)
container.appendChild(el)

它希望你只描述“页面应该长什么样”:

h('div', null, String(count))

这个描述结果就是虚拟节点,也就是常说的 VNode

三、VNode 可以简单理解成什么

你可以把它理解成“页面结构的 JavaScript 对象表示”。

例如:

const vnode = {
	type: 'div',
	props: { class: 'box' },
	children: 'hello',
}

这不是 Vue 真实源码里的完整结构,但足够帮助理解:

  • type:节点类型
  • props:属性
  • children:子节点

四、渲染器的核心问题:第一次渲染和后续更新

渲染器最重要的判断其实很简单:

  • 如果旧节点不存在,就是挂载
  • 如果旧节点存在,就是更新

所以很多渲染逻辑,最终都会汇总到一个核心函数里:

patch(n1, n2, container)

这里通常约定:

  • n1:旧节点
  • n2:新节点

五、patch 为什么是渲染器的中心

因为无论是元素、组件、文本节点、Fragment,本质上都要回答同一个问题:

旧的是什么?新的是什么?我该怎么最小代价地把旧的变成新的?

一个极简版本可以理解成:

function patch(n1, n2, container) {
	if (!n1) {
		mountElement(n2, container)
	} else {
		patchElement(n1, n2)
	}
}

真实源码里当然会区分:

  • 普通元素
  • 组件
  • 文本
  • 注释
  • Fragment

但主线不变。

六、挂载阶段发生了什么

假设第一次渲染的是:

h('div', { class: 'title' }, 'hello')

渲染器大致会做:

  1. 创建真实 DOM 节点
  2. 处理属性
  3. 处理子节点
  4. 插入到容器中

伪代码:

function mountElement(vnode, container) {
	const el = document.createElement(vnode.type)

	for (const key in vnode.props) {
		el.setAttribute(key, vnode.props[key])
	}

	if (typeof vnode.children === 'string') {
		el.textContent = vnode.children
	}

	container.appendChild(el)
}

七、更新阶段为什么更难

因为更新不能每次都把整棵 DOM 删掉重建。

否则虽然功能上也能跑,但性能会很差,用户体验也不好。

因此更新阶段真正要做的是:

  • 比较新旧节点是否是同一种类型
  • 如果不是,直接替换
  • 如果是,尽量复用已有 DOM
  • 只更新真正变化的部分

八、元素更新通常分成哪几步

1. 比较类型

如果旧节点是 div,新节点变成了 span,那通常就没必要继续细比了,直接替换更干脆。

2. 更新属性

例如:

  • 新的有、旧的没有 -> 新增
  • 新旧值不同 -> 更新
  • 旧的有、新的没有 -> 删除

3. 更新子节点

这部分通常最复杂,因为子节点可能是:

  • 文本
  • 数组

并且数组和数组之间还会涉及列表 diff。

九、列表 diff 为什么是渲染器最难的一块

假设旧列表是:

[a, b, c]

新列表变成:

[b, a, d, c]

渲染器要解决的问题不是“我能不能更新”,而是:

怎样用尽量少的移动、插入、删除操作完成更新。

这也是为什么 Vue 的渲染器源码中,子节点 diff 会占据很重要的位置。

十、为什么 key 很重要

因为 key 帮渲染器识别“这个节点到底是不是同一个东西”。

没有 keykey 不稳定时,渲染器会更难判断:

  • 哪些节点该复用
  • 哪些节点该移动
  • 哪些状态应该保留

所以 key 不是为了“消除警告”,而是为了帮助 diff 更准确。

十一、从源码角度,渲染器最值得看哪些东西

如果你要读 Vue 3 渲染器源码,最值得先建立下面几条主线:

1. createRenderer

这是渲染器工厂函数。

它很重要,因为 Vue 并没有把渲染器硬编码成“只能操作浏览器 DOM”。

相反,Vue 的设计是:

  • 提供一套通用渲染流程
  • 具体如何创建元素、插入元素、设置文本,交给宿主环境实现

这也是为什么 Vue 不只能渲染到浏览器,还能扩展到其他平台。

2. 宿主操作 host*

在源码里你会看到一组很重要的宿主方法,例如:

  • hostCreateElement
  • hostInsert
  • hostSetElementText
  • hostPatchProp
  • hostRemove

这组方法把“通用渲染逻辑”和“具体平台 API”分开了。

3. patch

这是整个渲染流程的中枢。

无论是初次渲染还是更新,最终都会汇总到这里。

4. processElementprocessComponent

源码里通常会按节点类型拆分处理逻辑:

  • 元素走元素逻辑
  • 组件走组件逻辑

这让渲染器结构更清晰。

十二、一个简化版源码脉络

可以把渲染器主流程压缩成这样:

function createRenderer(options) {
	const { createElement, insert, patchProp, setElementText } = options

	function mountElement(vnode, container) {
		const el = vnode.el = createElement(vnode.type)
		if (typeof vnode.children === 'string') {
			setElementText(el, vnode.children)
		}
		if (vnode.props) {
			for (const key in vnode.props) {
				patchProp(el, key, null, vnode.props[key])
			}
		}
		insert(el, container)
	}

	function patch(n1, n2, container) {
		if (!n1) {
			mountElement(n2, container)
		} else {
			patchElement(n1, n2)
		}
	}

	return {
		render(vnode, container) {
			patch(null, vnode, container)
		},
	}
}

真实渲染器会复杂很多,但这段已经足够帮助你抓主线:

  • 创建
  • 挂载
  • 对比
  • 更新

十三、渲染器和响应式是什么关系

它们不是同一个东西,但配合得非常紧。

  • 响应式负责发现“数据变了”
  • 组件更新 effect 负责重新执行 render
  • 渲染器负责把新旧 VNode 做对比并更新 DOM

也就是说:

响应式解决“什么时候要更新”,渲染器解决“怎么更新”。

十四、读渲染器源码时最容易卡住的点

1. 一开始就钻列表 diff 细节

这会很容易把自己看晕。

更好的顺序是:

  1. 先看 createRenderer
  2. 再看 patch
  3. 再看元素挂载
  4. 再看元素更新
  5. 最后再看 keyed diff

2. 把组件逻辑和元素逻辑混在一起

组件是组件,元素是元素。先把元素渲染主线看清楚,再去看组件挂载会更轻松。

总结

渲染器最核心的价值是:

  • 把声明式 UI 变成真实视图
  • 在更新时尽可能复用已有 DOM
  • 通过 diff 减少不必要操作

你真正要抓住的不是每个细节分支,而是这条主线:

  • 先有 VNode
  • 再由 patch 决定挂载还是更新
  • 最后通过宿主 API 操作真实平台
上次更新:
贡献者: Joe, joe