节点处理
约 1997 字大约 7 分钟
2026-04-09
子节点更新
在前面的实现中,渲染器已经可以处理元素属性和事件的更新,但对于子节点,还只是在 mountElement 中做了最基础的挂载
function mountElement(vnode, container) {
const el = vnode.el = createElement(vnode.type);
if (typeof vnode.children === 'string') {
setElementText(el, vnode.children);
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach((child) => {
patch(null, child, el);
});
}
}这里其实已经暴露出了一个问题:子节点在挂载时要区分类型,那么在更新时同样也要区分类型
对于一个元素来说,它的子节点大体可以分成三种情况:
- 没有子节点,也就是
vnode.children为null - 文本子节点,也就是
vnode.children为字符串 - 一组子节点,也就是
vnode.children为数组
当渲染器执行更新时,新旧子节点都可能分别落在这三种情况里,所以组合起来就会有多种更新路径。因此,子节点更新不能随便写在 patchElement 里,而是应该单独封装成一个 patchChildren 函数
patchChildren的基本结构处理子节点更新时,可以先以 "新子节点的类型" 为主线来拆分逻辑:
function patchChildren(oldVNode, newVNode, container) { if (typeof newVNode.children === 'string') { // 新子节点是文本节点 } else if (Array.isArray(newVNode.children)) { // 新子节点是一组子节点 } else { // 新子节点不存在 } }这样做的好处是:先确定最终结果应该是什么,再根据旧子节点的形态决定如何清理旧内容
新子节点是文本
如果新子节点是文本,那么最终容器里只应该保留这段文本
实现思路
- 如果旧子节点是一组子节点,需要先逐个卸载
- 如果旧子节点是文本或不存在,直接设置新的文本即可
function patchChildren(oldVNode, newVNode, container) { if (typeof newVNode.children === 'string') { if (Array.isArray(oldVNode.children)) { oldVNode.children.forEach((child) => { unmount(child); }); } setElementText(container, newVNode.children); } else if (Array.isArray(newVNode.children)) { // 新子节点是一组子节点 } else { // 新子节点不存在 } }新子节点是一组子节点
如果新子节点是数组,那么最终容器里应该挂载一组新的子节点。这时旧子节点依然有三种情况:
- 旧子节点也是数组
- 旧子节点是文本
- 旧子节点不存在
最简单的处理方式是:如果旧子节点也是数组,那就先全部卸载,再全部挂载新的子节点
重要
这种 "全量卸载 + 全量挂载" 的方式虽然能工作,但效率很低。Vue 真正的实现会在这里进入 Diff 算法,尽量复用已有节点,这部分会放到后面的 Diff 章节再展开
function patchChildren(oldVNode, newVNode, container) { if (typeof newVNode.children === 'string') { if (Array.isArray(oldVNode.children)) { oldVNode.children.forEach((child) => { unmount(child); }); } setElementText(container, newVNode.children); } else if (Array.isArray(newVNode.children)) { // 旧节点时数组,直接卸载旧节点,挂载新节点 if (Array.isArray(oldVNode.children)) { oldVNode.children.forEach((child) => unmount(child)); newVNode.children.forEach((child) => patch(null, child, container)); } else { setElementText(container, ''); newVNode.children.forEach((child) => patch(null, child, container)); } } else { // 新子节点不存在 } }新子节点不存在
如果新子节点不存在,那么最终容器里应该是空的
实现思路
- 如果旧子节点是一组子节点,逐个卸载
- 如果旧子节点是文本,直接清空文本
- 如果旧子节点也不存在,不需要处理
function patchChildren(oldVNode, newVNode, container) { if (typeof newVNode.children === 'string') { if (Array.isArray(oldVNode.children)) { oldVNode.children.forEach((child) => { unmount(child); }); } setElementText(container, newVNode.children); } else if (Array.isArray(newVNode.children)) { if (Array.isArray(oldVNode.children)) { oldVNode.children.forEach((child) => unmount(child)); newVNode.children.forEach((child) => patch(null, child, container)); } else { setElementText(container, ''); newVNode.children.forEach((child) => patch(null, child, container)); } } else { // 如果旧节点时数组,逐个卸载 if (Array.isArray(oldVNode.children)) { oldVNode.children.forEach((child) => { unmount(child); }); } else if (typeof oldVNode.children === 'string') { // 如果旧节点是文本,直接清空文本 setElementText(container, ''); } } }在
patchElement中接入子节点更新patchElement负责更新元素本身,因此它除了更新props,也应该继续调用patchChildren来处理子节点function patchElement(oldVNode, newVNode) { const el = newVNode.el = oldVNode.el; const oldProps = oldVNode.props; const newProps = newVNode.props; for (const key in newProps) { if (newProps[key] !== oldProps[key]) { patchProps(el, key, oldProps[key], newProps[key]); } } for (const key in oldProps) { if (!(key in newProps)) { patchProps(el, key, oldProps[key], null); } } patchChildren(oldVNode, newVNode, el); }
文本节点和注释节点
在
patch中识别文本和注释节点前面处理的虚拟节点,默认都是普通元素节点,也就是
type是一个标签字符串。但真实 DOM 中并不只有元素节点,还可能存在文本节点和注释节点我是文本节点 <!-- 我是注释节点 -->它们没有标签名称,所以不能再用字符串标签来描述。此时可以人为创建唯一标识,用来区分不同节点类型
patchconst Text = Symbol('Text'); const Comment = Symbol('Comment'); function patch(oldVNode, newVNode, container) { const { type } = newVNode; if (typeof type === 'string') { // 普通元素节点 } else if (type === Text) { // 文本节点 console.log('文本节点处理'); } else if (type === Comment) { // 注释节点 console.log('注释节点处理'); } else if (typeof type === 'object') { // 组件处理 console.log('组件处理'); } else { // 未知类型 console.log('未知类型'); } }vnodeconst textVNode = { type: Text, children: '我是文本', }; const commentVNode = { type: Comment, children: '我是注释', };平台能力抽离
直接在
patch中调用document.createTextNode或document.createComment,会让渲染器和浏览器平台耦合。因此,这些 DOM 操作也应该放到options中:const options = { createText(text) { return document.createTextNode(text); }, setText(el, text) { el.textContent = text; }, createComment(text) { return document.createComment(text); }, };处理文本节点
实现思路
- 旧节点不存在,创建文本节点并插入
- 旧节点存在,复用旧文本节点,只更新文本内容
if (type === Text) { if (!oldVNode) { const el = newVNode.el = createText(newVNode.children); insert(el, container); } else { const el = newVNode.el = oldVNode.el; if (newVNode.children !== oldVNode.children) { setText(el, newVNode.children); } } }处理注释节点
注释节点和文本节点的处理方式基本一致,只是创建节点时使用的是
createCommentif (type === Comment) { if (!oldVNode) { const el = newVNode.el = createComment(newVNode.children); insert(el, container); } else { const el = newVNode.el = oldVNode.el; if (newVNode.children !== oldVNode.children) { setText(el, newVNode.children); } } }完整的
patch代码const Text = Symbol('Text'); const Comment = Symbol('Comment'); function patch(oldVNode, newVNode, container) { if (oldVNode && oldVNode.type !== newVNode.type) { unmount(oldVNode); oldVNode = null; } const { type } = newVNode; if (typeof type === 'string') { if (!oldVNode) { mountElement(newVNode, container); } else { patchElement(oldVNode, newVNode); } } else if (type === Text) { if (!oldVNode) { const el = newVNode.el = createText(newVNode.children); insert(el, container); } else { const el = newVNode.el = oldVNode.el; if (newVNode.children !== oldVNode.children) { setText(el, newVNode.children); } } } else if (type === Comment) { if (!oldVNode) { const el = newVNode.el = createComment(newVNode.children); insert(el, container); } else { const el = newVNode.el = oldVNode.el; if (newVNode.children !== oldVNode.children) { setText(el, newVNode.children); } } } else if (typeof type === 'object') { console.log('组件处理'); } else { console.log('未知类型'); } }
Fragment 片段
FragmentVue3 中还有一种比较特殊的节点类型:
Fragment。它的作用是:自身不渲染真实 DOM,只渲染它的 children在 Vue2 中,一个组件模板必须只有一个根节点;但 Vue3 支持多根节点,本质上就是依赖
Fragment来描述这种结构<template> <li>1</li> <li>2</li> <li>3</li> <li>4</li> </template>与上面文本和注释节点一样,
Fragment也没有对应的标签名称,所以我们同样可以用一个Symbol来表示它const Fragment = Symbol('Fragment'); const vnode = { type: Fragment, children: [ { type: 'li', children: 'text 1', }, { type: 'li', children: 'text 2', } ], };在
patch中处理FragmentFragment自己不创建真实 DOM,所以首次挂载时,只需要把它的children逐个挂载到容器里实现思路
- 如果旧节点存在,则说明这是一次更新,可以直接复用前面封装好的
patchChildren - 如果旧节点不存在,则说明这是第一次挂载,需要逐个挂载
Fragment的子节点
function patch(oldVNode, newVNode, container) { const { type } = newVNode; if (typeof type === 'string') { // 省略普通元素处理 } else if (type === Text) { // 省略文本节点处理 } else if (type === Comment) { // 省略注释节点处理 } else if (type === Fragment) { if (!oldVNode) { newVNode.children.forEach((child) => patch(null, child, container)); } else { patchChildren(oldVNode, newVNode, container); } } else if (typeof type === 'object') { // 组件处理 } }- 如果旧节点存在,则说明这是一次更新,可以直接复用前面封装好的
卸载
Fragment普通元素卸载时,可以通过
vnode.el找到真实 DOM 并删除。但Fragment本身没有真实 DOM,所以卸载时不能直接找vnode.el,而是要递归卸载它的每一个子节点function unmount(vnode) { if (vnode.type === Fragment) { vnode.children.forEach((child) => unmount(child)); return; } const parent = vnode.el.parentNode; if (parent) { parent.removeChild(vnode.el); } }
