Vue3里的vnode是什么?对比Vue2改了啥?能用来解决哪些实际开发问题?
Vue3用了一年多的朋友,可能每天都在写template,也隐约听过“vnode是虚拟DOM的节点”,但真正搞懂它的底层逻辑、和Vue2的核心差异,还能用它搞点小优化的人,可能不算多,今天咱们就用聊天的方式,把这些问题说透,从入门的“是啥”到进阶的“咋变的”,再到能直接用在项目里的“实用技巧”,争取让你看完就能摸到点门道。
先掰扯最基础的:vnode到底是个啥东西?
别被“虚拟DOM”“节点”这些词吓到,咱们先从最原始的网页渲染逻辑说起。
你写HTML的时候,是不是会写一堆嵌套的标签?比如<div class="app"><p>hello vue</p></div>,浏览器拿到这些标签后,会先解析成真实DOM树——也就是一堆带各种属性、父子关系的对象,然后再根据这棵树把页面画出来,每次你改了页面数据,比如把“hello vue”改成“bye vue”,浏览器都会重新解析、画树、渲染——这一套下来,要是改的地方多了,就容易卡,毕竟真实DOM的操作成本特别高,哪怕只是改一个字符,都可能触发重绘重排。
那vnode是怎么解决这个问题的呢?简单说,它就是用JavaScript对象模拟出来的“轻量级真实DOM替身”,还是刚才的例子,Vue3不会直接让你碰那个又贵又重的真实DOM,而是会先在内存里生成这么一个JS对象:
// 大概长这样,不是完全准确的源码结构,但核心意思没变
const vnode = {
type: 'div', // 标签名
props: { class: 'app' }, // 标签上的属性
children: [ // 子节点,这里也是个vnode数组
{
type: 'p',
props: {},
children: 'hello vue'
}
],
el: null, // 这里存的是对应的真实DOM,渲染完才会赋值
key: null // 优化用的标识,后面会讲
// 还有一堆其他Vue内部用的属性,比如patchFlag、shapeFlag这些
}
这个JS对象就是vnode,当你改数据的时候,Vue会先生成一棵新的vnode树,然后和旧的vnode树在内存里对比——这个过程叫“diff算法”——找出真正需要改的地方,最后只去修改真实DOM上的这一小部分,这样就大大减少了操作成本,页面也就流畅多了。
可能你会问:那我直接用template写,和vnode有啥关系?其实Vue3的template都会被编译器(比如Vue CLI里的@vue/compiler-sfc)先转成render函数,render函数执行完返回的就是一棵vnode树,比如刚才的<div class="app"><p>{{ msg }}</p></div>,转成render函数大概是这样的(用Vue3的h函数的话,写法更直观,h就是createVNode的缩写):
import { h } from 'vue'
export default {
data() {
return { msg: 'hello vue' }
},
render() {
return h('div', { class: 'app' }, [
h('p', null, this.msg)
])
}
}
哦对了,Vue3里不仅有代表普通DOM元素的vnode,还有代表组件的、代表文本的、代表注释的、代表插槽的……vnode的type属性不一样,处理逻辑也就不一样,不过核心都是JS对象模拟的轻量级结构。
再聊点进阶的:Vue3的vnode对比Vue2,改了啥要命的地方?
别以为只是换了个写法这么简单,Vue3的vnode底层做了好几项重构,每一项都是为了让框架更快、更小、更好用,咱们挑几个对开发者影响最大、或者说对性能优化最关键的讲。
第一个变化:shapeFlag和patchFlag的引入,让diff算法快了好几倍
Vue2的diff算法虽然也挺快,但它是“全量对比”——不管节点的属性、内容会不会变,每次都会遍历一遍所有属性,子节点也不管类型,直接双向遍历找差异,举个例子,如果你写了<p>固定文本</p>,Vue2每次更新都会检查一下这个p标签的class、style、内容有没有变,但你明明知道它永远不会变,这就浪费了不少性能。
Vue3的编译器就聪明多了,它会在编译template的时候,提前分析哪些地方是静态的、哪些地方是动态的、动态的是什么类型的,然后给生成的vnode加上两个标识:shapeFlag和patchFlag。
先说说shapeFlag,它是用来标识vnode的“类型组合”的——比如是普通DOM元素还是组件,子节点是文本还是数组还是插槽,因为是二进制位运算,所以一个shapeFlag就能存好几种类型,查找起来特别快,比如普通DOM元素是1,子节点是文本是8,那刚才的<p>固定文本</p>的shapeFlag就是1 | 8 = 9,这样Vue在patch(也就是新旧vnode对比更新)的时候,不用判断一堆if else,直接用位运算就能知道这个vnode是什么类型,该怎么处理。
再说说更厉害的patchFlag,它是用来标识“这个vnode哪些地方是动态的”的——比如是内容变了、class变了、style变了、还是绑定了事件变了,同样是二进制位运算,一个patchFlag就能存好几种动态类型,比如刚才的<p>{{ msg }}</p>,编译器会发现内容是动态的,就会给它的patchFlag加上TEXT(1);如果是<p :class="active ? 'red' : 'blue'">固定文本</p>,就会给patchFlag加上CLASS(2);如果两种都有,就是1 | 2 = 3。
有了patchFlag,Vue3的diff算法就变成了“靶向对比”——只对比标记过的动态属性和内容,静态的直接跳过,比如刚才的<p>固定文本</p>,编译器会给它的patchFlag设为0,patch的时候看一眼是0,直接就不管了,连子节点都不用看,这对静态内容多的页面(比如电商的商品详情页、企业官网的新闻页)性能提升特别明显,有测试数据说,静态内容占比越高,Vue3的渲染速度比Vue2快1.3倍到2倍都有可能。
第二个变化:vnode的属性结构简化了,核心代码量少了
Vue2的vnode属性特别多,比如有tag、data、children、text、elm、ns、context、key、componentOptions、componentInstance、parent、raw、isStatic、isRootInserted、isComment、isCloned、isOnce、asyncFactory、asyncMeta……大概有二十多个,很多属性是只有特定场景才会用的,比如isOnce是处理v-once的,asyncFactory是处理异步组件的,这就导致Vue2的核心库(runtime-only)大概有20KB左右(gzip压缩后)。
Vue3的团队做了一件事:把很多特定场景的属性拆成了单独的对象或者数组,或者合并到了shapeFlag/patchFlag里,比如把Vue2里的data对象拆成了更细粒度的,但其实更重要的是,把很多静态的优化、异步组件的状态、自定义指令的信息这些,都移到了专门的优化对象或者符号属性里,核心的vnode属性只剩下十几个了(比如type、props、children、el、key、patchFlag、shapeFlag、dynamicProps、dynamicChildren这些),再加上Tree Shaking的支持(就是你不用的代码,打包的时候会自动删掉),Vue3的runtime-only核心库压缩后只有10KB左右,比Vue2小了一半!
第三个变化:静态提升、预字符串化、缓存事件处理函数,这些编译器优化和vnode紧密相关
刚才说了shapeFlag和patchFlag是编译器给vnode加的标识,其实Vue3的编译器还给vnode做了三件更狠的优化,这三件事都是和性能直接挂钩的。
第一件是静态提升,比如你写了这样的template:
<div class="app">
<img src="logo.png" alt="logo">
<h1>{{ title }}</h1>
<p>固定介绍文本1</p>
<p>固定介绍文本2</p>
</div>
Vue2的编译器会把这整个template转成render函数,每次执行render函数(也就是每次数据更新),都会重新创建那两个固定p标签和img标签的vnode,虽然创建vnode的成本比真实DOM低,但每次都创建也没必要。
Vue3的编译器就不一样了,它会把完全静态的节点(比如img和那两个p标签)提升到render函数外面,变成一个静态的vnode数组,每次执行render函数的时候,直接引用这个数组就行,不用重新创建,而且如果有连续的多个静态文本节点或者纯静态的DOM节点(比如刚才的两个p标签),还会做预字符串化——把它们合并成一个字符串vnode,这样diff的时候连子节点都不用遍历了,直接对比字符串就行,更快!
第二件是缓存事件处理函数,比如你写了<button @click="handleClick">点我</button>,Vue2的编译器会转成h('button', { onClick: this.handleClick }),每次执行render函数的时候,都会重新给onClick赋值this.handleClick——虽然this.handleClick本身没变,但赋值这个操作本身也是个小开销,而且如果是动态绑定的函数(比如<button @click="() => handleClick(item)">点我</button>),每次执行render函数都会创建一个新的匿名函数,这会导致Vue3把这个按钮的patchFlag加上PROPS(16)和HYDRATE_EVENTS(64),甚至可能会导致子组件的props更新触发不必要的重新渲染(如果子组件没有做props的浅比较或者没有用shallowRef的话)。
Vue3的编译器解决了这个问题,它会把事件处理函数(包括匿名的)缓存到组件实例的_cache数组里,比如刚才的匿名函数例子,编译器会转成大概这样:
import { h, withModifiers } from 'vue'
export default {
render(ctx) {
return h('button', {
onClick: ctx._cache[0] || (ctx._cache[0] = ($event) => ctx.handleClick(ctx.item))
}, '点我')
}
}
第一次执行render函数的时候,会创建匿名函数并存到_cache[0]里,之后每次执行,直接用_cache[0]里的旧函数就行,不会重新创建,这样就减少了不必要的开销和重新渲染。
第三件优化暂时不说太多,主要是针对SSR(服务端渲染)的,和咱们平时的浏览器端开发关系不大,感兴趣的朋友可以自己去查。
最后说点实用的:vnode在实际开发中能用来解决哪些问题?
刚才讲了这么多底层逻辑,可能你觉得“这和我有啥关系?我还是继续写我的template就行了”,其实不然,掌握了vnode的逻辑,你就能做很多template做不到或者做起来特别麻烦的事,比如动态生成复杂的UI、封装通用组件、做一些小的性能优化,咱们举几个具体的例子。
例子1:根据后端返回的数据,动态生成不同类型的表单控件
比如你做了一个后台管理系统,有一个“动态表单”的功能,后端会返回一个数组,每个元素代表一个表单控件的配置:比如type是input,label是用户名,placeholder是请输入用户名;type是select,label是性别,options是男、女、保密;type是checkbox,label是兴趣爱好,options是编程、读书、运动……
如果用template写的话,你可能会写一堆v-if/v-else-if:
<template>
<div v-for="item in formConfig" :key="item.id">
<label>{{ item.label }}</label>
<input v-if="item.type === 'input'" :placeholder="item.placeholder" v-model="formData[item.id]">
<select v-else-if="item.type === 'select'" v-model="formData[item.id]">
<option v-for="opt in item.options" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
<div v-else-if="item.type === 'checkbox'">
<label v-for="opt in item.options" :key="opt.value">
<input type="checkbox" :value="opt.value" v-model="formData[item.id]">
{{ opt.label }}
</label>
</div>
<!-- 还有textarea、radio、datepicker……一堆v-else-if -->
</div>
</template>
这样写虽然能实现,但代码特别冗余,要是后面加个新的表单控件类型,就得再加个v-else-if,维护起来特别麻烦。
如果用h函数直接生成vnode的话,代码就会简洁很多:
import { h } from 'vue'
export default {
props: {
formConfig: { type: Array, required: true },
formData: { type: Object, required: true }
},
setup(props, { emit }) {
const handleInput = (id, value) => {
emit('update:formData', { ...props.formData, [id]: value })
}
// 渲染单个表单控件的函数
const renderFormItem = (item) => {
let controlVnode
switch (item.type) {
case 'input':
controlVnode = h('input', {
placeholder: item.placeholder,
value: props.formData[item.id],
onInput: (e) => handleInput(item.id, e.target.value)
})
break
case 'select':
controlVnode = h('select', {
value: props.formData[item.id],
onChange: (e) => handleInput(item.id, e.target.value)
}, item.options.map(opt => h('option', { value: opt.value }, opt.label)))
break
case 'checkbox':
controlVnode = h('div', {}, item.options.map(opt =>
h('label', {}, [
h('input', {
type: 'checkbox',
value: opt.value,
checked: props.formData[item.id]?.includes(opt.value),
onChange: (e) => {
const current = props.formData[item.id] || []
const newValue = e.target.checked
? [...current, opt.value]
: current.filter(v => v !== opt.value)
handleInput(item.id, newValue)
}
}),
opt.label
])
))
break
// 其他类型的控件同理
default:
controlVnode = h('p', {}, `不支持的控件类型:${item.type}`)
}
// 返回包含label和control的vnode
return h('div', { key: item.id }, [
h('label', {}, item.label),
controlVnode
])
}
return () => h('div', {}, props.formConfig.map(renderFormItem))
}
}
这样写的话,代码结构清晰,维护起来也方便——加新的控件类型,只需要在switch里加个case就行,而且这个组件是完全用render函数写的,没有依赖template,体积更小,性能也更好一点。
例子2:封装一个“点击空白处关闭”的通用组件
比如你做了一个弹窗、下拉菜单、tooltip之类的组件,都需要实现“点击空白处关闭”的功能,如果每个组件都单独写这个逻辑,代码会重复,而且容易出错。
如果用vnode的话,你可以封装一个通用的ClickOutside组件,用法大概是这样的:
<template>
<ClickOutside @click-outside="handleClose">
<div class="dropdown">
<button @click="show = !show">打开下拉菜单</button>
<ul v-if="show">
<li>选项1</li>
<li>选项2</li>
<li>选项3</li>
</ul>
</div>
</ClickOutside>
</template>
那这个ClickOutside组件怎么用vnode实现呢?核心思路是:ClickOutside组件接收一个默认插槽,渲染插槽的内容(也就是用户写的弹窗、下拉菜单),然后在document上监听click事件,判断点击的元素是不是在插槽内容的外面,如果是,就触发click-outside事件。
但这里有个问题:怎么获取插槽内容对应的真实DOM元素呢?如果用template写的话,你可以给插槽内容加个ref,但这样用户用的时候就必须加ref,不太方便,如果用vnode写的话,就可以直接修改插槽返回的vnode,给它加个ref属性,然后在组件里获取这个ref对应的真实DOM元素。
具体实现大概是这样的:
import { h, ref, onMounted, onUnmounted } from 'vue'
export default {
emits: ['click-outside'],
setup(props, { slots, emit }) {
const containerRef = ref(null)
const handleClick = (e) => {
if (containerRef.value && !containerRef.value.contains(e.target)) {
emit('click-outside')
}
}
onMounted(() => {
document.addEventListener('click', handleClick)
})
onUnmounted(() => {
document.removeEventListener('click', handleClick)
})
return () => {
// 获取默认插槽的vnode数组
const slotVnodes = slots.default?.() || []
// 注意:如果插槽返回的是多个根节点,这里可能需要处理一下,比如用div包起来
// 为了简单,这里假设插槽只有一个根节点
const firstSlotVnode = slotVnodes[0]
if (firstSlotVnode) {
// 给第一个根节点的vnode加ref属性
firstSlotVnode.props = firstSlotVnode.props || {}
firstSlotVnode.props.ref = containerRef
}
// 返回修改后的vnode数组
return slotVnodes
}
}
}
这样用户用的时候就不用加任何ref了,直接把内容放在ClickOutside的默认插槽里就行,是不是特别方便?
例子3:用动态的patchFlag做一点手动的性能优化
刚才说了Vue3的编译器会自动给vnode加patchFlag,但有时候编译器可能分析不出来你的需求,这时候你可以手动用createVNode函数(也就是h函数)加patchFlag,做一点手动的性能优化。
比如你写了一个组件,里面有一个列表,列表的每个item的内容都是动态的,但class永远是固定的,如果用template写的话:
<template>
<ul>
<li v-for="item in list" :key="item.id" class="list-item">{{ item.name }}</li>
</ul>
</template>
编译器会给li标签的patchFlag加上TEXT(1),但其实如果你确定item.id不会变(也就是列表不会重新排序、不会删除中间的元素、不会在中间插入元素),你还可以给vnode加一个KEYED_VNODES(128)的标识?不对,KEYED_VNODES是shapeFlag里的,用来标识子节点是带key的数组,哦对了,如果你确定某个动态属性不会变,但编译器标记了它是动态的,你可以手动修改patchFlag,把对应的位去掉,不过这种情况比较少,因为编译器一般都分析得挺准的。
还有一种情况,比如你写了一个无限滚动的列表,每次加载新数据的时候,都会在列表的前面插入新的item,这时候如果你给每个item的key设成item.id(是递增的数字,比如1、2、3……),Vue3的diff算法会把原来的item往后移,然后插入新的item——这样其实性能挺好的,但如果你给key设成了索引(比如0、1、2……),每次插入新的item,所有原来的item的key都会变,Vue3的diff算法就会销毁所有原来的真实DOM,然后重新创建——这样性能就特别差,所以这里的key虽然不是vnode的新属性,但它是vnode diff算法里最核心的优化手段之一,一定要用唯一的、稳定的标识,比如后端返回的id,而不是索引。
总结一下
今天咱们聊了三个问题:Vue3的vnode是啥、和Vue2比改了啥、能用来解决哪些实际问题。
简单回顾一下:
- vnode是用JS对象模拟的轻量级真实DOM替身,用来减少真实DOM的操作成本,提高页面性能;
- Vue3的vnode引入了shapeFlag和patchFlag,做了静态提升、预字符串化、缓存事件处理函数等优化,核心代码量也少了一半,性能比Vue2快了很多;
- vnode在实际开发中能用来动态生成复杂的UI、封装通用组件、做一点手动的性能优化。
可能你看完这篇文章,还是觉得vnode有点抽象,但没关系,平时你可以多试试用h函数写点小组件,慢慢就会熟悉它的逻辑了,而且了解了vnode的底层逻辑,对你理解Vue3的其他特性(比如Teleport、Suspense、自定义渲染器)也会有很大的帮助。
好了,今天的聊天就到这里,如果你有什么其他关于Vue3 vnode的问题,欢迎在评论区留言讨论。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网

