Vue3 的“模块语法革命”如果要用一句话概括,最直观的就是“告别多根节点包裹,拥抱 Fragments”。熟悉 Vue2 的朋友应该都有过这种经历:写一个组件时,只要模板里出现了两个平级的元素,编译器立刻在控制台抛一个警告,要求你“The template root requires exactly one element”,然后你不得不额外包一层<div>。这一层 div 看似无所谓,但真正做复杂业务的时候,它会把布局层级硬生生地拉深一层,会破坏 flex/grid 的子项关系,还会让语义化标签变得不伦不类。Vue3 引入了 Fragments,多根组件从“违规”变成“合法”,这不仅仅是语法上少包一个标签,而是对运行时渲染模型的一次调整。
这篇文章我不打算只停留在“怎么用”的层面,更多会聊聊 Vue3 为什么要这么设计、底层 VNode 和 patchFlag 发生了哪些变化,以及从 Vue2 项目迁移时到底该怎么处理多根组件。无论你是刚从 Vue2 转到 Vue3,还是正在封装基础组件库,里面应该都有一些可以直接抄走的经验。
1. 告别包裹层的真实价值:不止是少写一个 div
1.1 Vue2 时代那根被迫存在的“毒 div”
在 Vue2 中,每个组件的 template 最终都会编译成一个 render 函数,这个 render 函数必须返回一个虚拟节点(VNode),并且只能返回一个。Vue 实例挂载之后,会把这个 VNode 渲染成真实的 DOM 节点,作为组件的根元素绑定到组件实例上。所以只要你写了两个平级节点,Vue 就不知道到底该把哪个当作根。
解决办法只有一个:在外面再套一层。这个包裹层在大多数场景里只是个无意义的空壳,但副作用却很实在。最常见的是列表和表格场景。比如你要封装一组<li>,在 Vue2 里必须写成<div><li>...</li><li>...</li></div>,但这样的结构放在<ul>里明显是无效 HTML。又比如要给一个表格加多个<tr>或<td>,外层包裹 div 会导致浏览器解析时自动把它挪出来,页面直接错位。为了规避这种情况,很多组件库会选择使用render函数 + 函数式组件来绕过单根限制,但写法非常别扭。
1.2 多根节点对布局与样式的解放
Vue3 放开多根节点限制之后,受益最大的其实是 CSS 布局。想象一下,你要做一行 flex 布局,里面由两个组件组成:一个文字块、一个按钮。在 Vue2 中,你必须给这两个组件分别套一个不会影响 flex 的包裹层,如果包裹层是 div,它就成了 flex 容器里的一个子项,导致你原来的两个子项不得不包在一个 div 里,再去设置display: contents或者调整选择器。很多人写 Vue2 组件库时,为了处理这种问题,会特意让根节点继承外层的 class,或者使用$attrs透传,非常麻烦。
有了 Fragments,组件的多个子元素可以直接作为父级 flex/grid 容器的子项,样式关系和原生写法完全一致。这在开发抽屉、弹窗、表单行、导航菜单这类组件时尤其舒服,不需要再额外包一层无意义的元素。另一个容易被忽视的好处是语义化与无障碍。多根组件可以直接输出<dt> + <dd>组合,可以输出<thead> + <tbody>,也可以输出一组<option>,不再强行插入一个 div 去破坏文档结构,对屏幕阅读器也更友好。
1.3 Fragments 并不仅是“模板层面去壳”
Fragments 并不是简单地把模板里的多个根节点“拼接在一起”然后渲染时去掉包裹层。它是在虚拟 DOM 层面定义了一种特殊类型的节点,叫 Fragment 节点。Fragment 节点本身不产生任何真实 DOM 元素,它只是一个容器,用来存放一组子节点。渲染器在看到 Fragment 节点时,会直接遍历它的 children 并把它们分别挂载到父级容器里,相当于一个透明的占位。
这种设计的意义在于:组件仍然是一个完整的组件,有自己的生命周期、依赖注入和更新边界,只是它的“根”不再是一个元素,而是一个碎片集合。因此渲染器必须能处理这个碎片集合。Vue3 的源码里为这个碎片集合专门设计了 patch 逻辑,和普通元素节点、文本节点、组件节点区别开。这可不是在编译器里偷偷加一个数组那么简单。
1.4 什么时候该用多根,什么时候不该用
多根虽好,但不能无脑用。我的经验是:如果组件输出的是单一段落、单张卡片、单个按钮,老老实实保持单根更利于复用。因为单根组件天然支持外部传入 class、style、事件监听器,$attrs会自动继承到根元素上。而多根组件的class和style无法自动处理,因为渲染器不知道该把它们放到哪个根上,需要你自己在组件内部手动绑定。
适合用多根的场景大概是这几类:
- 需要返回同一层级的多个列表项、单元格、行元素;
- 不想因为额外包裹层破坏 flex/grid/绝对定位布局;
- 需要保证 HTML 语义正确,比如
<dl>中的一组<dt>/<dd>; - 想要把一个模块拆成多个并列片段,但不想增加真实 DOM 层级。
而像按钮、输入框、卡片这种“独立组件”,单根依然是默认选择。Fragments 是工具,不是银弹,凡是把多根组件当普通单根组件去外部套 class 的,很容易在样式透传上踩坑。
2. 从 VNode 到 Fragment:理解 Vue3 的底层编译与渲染
2.1 单根限制的源头:VNode 的“一个萝卜一个坑”
为什么 Vue2 如此执着于单根?因为它的渲染和更新模型是围绕“组件等于一个 VNode 树”设计的。每个组件实例在挂载时,需要知道自己的宿主元素在哪里。更新时,diff 算法从组件根 VNode 开始,和旧 VNode 进行比对,然后递归更新整棵树。如果根 VNode 下面还有多个兄弟节点,这个树结构就变成了一个森林,组件的“唯一根”概念就站不住脚。
Vue3 则把“组件挂载”和“组件根的渲染”分离了。组件实例在挂载时不需要直接对应一个 DOM 元素,而是对应一个 VNode。这个 VNode 的类型可以是元素、组件、文本、注释,也可以是 Fragment。于是碎片森林也能作为一个整体被组件承接,组件树从“单根树”变成了“可以是多根树”。从数据结构上看,单根约束更像是一个历史包袱,Vue3 只是把这个包袱从运行时拿掉了。
2.2 多根模板编译后的 render 函数长什么样
如果你把下面的 Vue3 组件模板:
<template> <h1>标题</h1> <p>描述</p> </template>交给编译器处理,大概会得到类似这样的 render 函数:
import { createElementBlock, openBlock } from 'vue' export function render(_ctx, _cache) { return ( openBlock(), createElementBlock( Fragment, // 特殊类型标记 null, [ createElementVNode('h1', null, '标题', 1 /* TEXT */), createElementVNode('p', null, '描述', 1 /* TEXT */) ], 64 /* STABLE_FRAGMENT */ ) ) }关键就在createElementBlock(Fragment, null, [...], 64)。这里的createElementBlock是在构建优化后的编译器输出中经常出现的函数,它本质上会调用createVNode,但额外会开启一个块(Block)。Fragment并不是一个真实的 HTML 标签,而是一个特殊的 VNode type 值,在 Vue3 内部通常是一个 Symbol。因为 Symbol 不可能和 HTML tag 重名,所以渲染器可以安全地识别出这是一个 Fragment 节点,而不是真实的元素节点。
这里有个小细节:如果你用vue/compiler-sfc的compileTemplate去手动编译一个多根模板,你会发现返回的 AST 根节点实际上是一个名为Fragment的模拟根节点,所有顶层真实节点都作为它的 children。也就是说,单根限制在编译器层面被打破了,但开发者仍然不会直接接触 Fragment。
2.3 Fragment 的三种 patchFlag:STABLE / KEYED / UNKEYED
Vue3 在 diff 一个 Fragment 节点时,会根据子节点的稳定性选择不同的更新策略,这部分体现在 patchFlag 上。我查阅官方源码时发现常用的有三位:
STABLE_FRAGMENT (64):表示 Fragment 的子节点是静态稳定的,没有使用v-for,也不存在需要按 key 重排的情况。更新时 Vue 会尽量复用旧节点,不做无谓的移动。KEYED_FRAGMENT (128):表示子节点是通过v-for生成,并且提供了 key。diff 时会按照 key 进行快速匹配和移动,这是最高效的列表更新方式。UNKEYED_FRAGMENT (256):表示子节点是动态生成的,但没有提供 key。diff 时只能按索引暴力对比,性能较差,而且可能出现状态错位。
模板编译器会自动生成正确的 patchFlag。你写一个v-for列表,如果项目里写了:key,编译结果就是KEYED_FRAGMENT;如果忘记写 key,就会是UNKEYED_FRAGMENT。这个标记对多根 Fragment 同样适用。你可以在模板里用v-for直接循环输出很多根节点,编译后的 Fragment 会保存列表更新所必需的动态结构。
理解这三种 patchFlag 的价值在于,你不需要刻意优化,但要知道为什么多根组件有时比单根包裹反而更快。单根组件列表更新时,diff 器要先经过那一层 div,再进入列表;多根组件的 Fragment 直接让列表项成为兄弟节点,少了一层递归比较。加上KEYED_FRAGMENT的存在,Vue 甚至可以直接通过 key 定位需要更新的节点,而不用每次都从头比对子树。
2.4 为什么说“多根优化”是对 DOM diff 的一次成本减负
讨论 Vue3 性能时,很多人只盯着虚拟 DOM 的对比算法,却忽略了减少真实 DOM 节点数量本身就是最快的优化。多根组件没有包裹层,所以整个组件渲染后的 DOM 树层级更浅、节点更少。diff 过程中需要遍历的子树更小,浏览器在创建、插入、删除节点时操作也更少。
举个例子,一个卡片组件如果 Vue2 里面包了一层 div,底层传给它的插槽内容会全部成为这个 div 的子树,哪怕插槽内容完全没变,diff 也要从那个 div 开始逐层检查。而 Vue3 的多根组件可以做到“插槽内容就是组件的兄弟节点”,从结构上就省掉了一层不必要的容器。这不是微优化,在大量列表渲染、频繁增删操作的场景下,能明显感受到帧率的提升。
当然,有人会说多根组件会让组件树变得混乱,因为组件根不是一个节点,使用 devtools 时树结构看起来不如单根直观。实际上,Vue DevTools 已经针对 Fragment 做了展示,组件树中会显示“Fragment”这个虚拟节点,查看起来并不会丢失信息。只要不是在transition或某些特殊 API 的场景里强行使用多根,收益通常是正向的。
3. 实战上手:从模板到 render 函数全面拥抱多根
3.1 最简单的多根组件模板写法
在 Vue3 的单文件组件里,直接写平级标签就是多根组件。不需要任何配置,也不需要额外 import 什么。
<!-- 多根Demo.vue --> <template> <h2 class="title">标题区域</h2> <p class="desc">一段描述文字</p> </template>编译后,这个组件会被自动视为一个 Fragment 渲染。如果你在父组件里引用它:
<template> <section> <MultiRootComponent class="parent-class" /> </section> </template>注意:class="parent-class"不会被自动作用到任何子元素上。因为在多根组件中,外部传入的 class、style、listeners 没有明确的单一目标。这一点和单根组件完全相反。如果确实要让外部 class 作用到某个节点上,你可以通过$attrs手动绑定:
<template> <h2 v-bind="$attrs">标题</h2> <p>描述</p> </template>这样父组件传入的非 prop 属性会落到 h2 上。我个人推荐的做法是:多根组件尽量避免依赖外部 class 透传,业务所需的样式要么自己写死,要么通过 prop 传递,否则很容易出现“类名没生效”的困扰。
3.2 给多根节点加上 key,避免 diff 阶段的坑
多根组件如果只是静态模板,基本不需要关心 key。但一旦根节点带有v-if、v-for或者使用动态组件切换,就应该谨慎处理 key 的问题。尤其是同一级有多个根节点,并且它们会随着条件变化而互相替换场景。
比如这个模板:
<template> <div v-if="loading">加载中</div> <div v-else-if="error">加载失败</div> <div v-else>内容区域</div> </template>上面三个 div 实际上构成一个 Fragment 下的多个条件分支,但同一时刻只会有一个渲染。Vue3 的 diff 机制会把它们当作多个子节点处理,如果这些节点没有 key,切换时可能会复用错误的 DOM,导致子组件状态残留。解决办法很简单:给每个分支加上一个稳定的 key。
<template> <div v-if="loading" key="loading">加载中</div> <div v-else-if="error" key="error">加载失败</div> <div v-else key="content">内容区域</div> </template>加了 key 后,diff 才会把它们识别为完全不同的节点,避免跨分支复用。同理,如果你用v-for渲染多个根节点,必须给每一项加:key。很多初学者在多根组件上忘记写 key,导致列表删除或排序时出现状态错乱,这不是 Fragments 的问题,而是 diff 需要 key 来建立新老节点映射关系。
3.3 在 render 函数 / JSX 中显式使用 Fragment
模板编译器会自动生成 Fragment,但如果你直接写 render 函数或 JSX,就需要自己处理多根返回值。在 Vue3 中,render 函数可以直接返回一个子节点数组,而不需要包裹任何层。例如:
import { h } from 'vue' export default { render() { return [ h('h2', '标题'), h('p', '描述') ] } }这个数组会被 Vue 自动当作 Fragment 处理。如果你想显式地突出 Fragment,也可以直接引入Fragment作为 VNode 类型:
import { h, Fragment } from 'vue' export default { render() { return h(Fragment, null, [ h('h2', '标题'), h('p', '描述') ]) } }在 JSX 中,多根也非常直观:
const MultiRoot = () => ( <> <h2>标题</h2> <p>描述</p> </> )这里的<></>就是 React 风格的 Fragment 写法。Vue 的 JSX 插件会把它编译成Fragment节点。但要注意一点:render 函数返回数组和模板多根并不完全等价。模板多根时编译器可以精确生成静态标记,比如自动判断 Fragment 是 stable、keyed 还是 unkeyed;而手写 render 返回数组时,编译器不会帮你生成优化标记,你需要自己在 VNode 上传递 patchFlag 才能达到同等性能。不过大多数业务场景下,手写 render 的开销差异可以忽略。
3.4 多根组件的边界场景:Teleport、Suspense、组件库封装
Fragments 在特定内置组件里也有自己的位置。比如Teleport以前要求被传送的内容必须有单根,现在它也能接受多根内容。渲染器会把 Fragment 里的多个子节点直接挂载到目标容器,不会额外产生一个空壳。这在做全局弹窗、移动端出入口时很实用。
Suspense的插槽也可以返回多根内容,异步组件的加载状态和默认内容在结构上更灵活。不过实际经验是:Suspense和Transition一起用时,多根组件容易产生一些边界警告,尤其是 Transition 期望只有一个直接子元素。因此,如果需要做过渡动画,尽量保持单根。
组件库封装是最能体现 Fragments 价值的地方。比如你要做一组表单控件,包含 label 和 input,不想额外包一层 div 破坏网格布局。你可以这样设计:
<template> <label :for="id" v-bind="$attrs">{{ label }}</label> <input :id="id" type="text" /> </template>在单根限制下,你不得不包一层<div class="field-item">,然后为了对齐还要去设置该 div 的 display。有了多根,label 和 input 可以直接作为网格容器的两个单元格。这在开发后台管理系统时,能省掉大量无意义的 flex 布局容器。
4. 从 Vue2 项目迁移时的多根适配指南
4.1 迁移前先识别哪些组件是被迫包裹的
如果你正在把一个老项目从 Vue2 迁移到 Vue3,直接全局搜索模板里那些“明显为了消除警告而加的包裹层”是个好入口。怎么判断一个 div 是包裹层而不是有意义的布局层?我的经验是看它的 class 或 style。如果这个 div 没有任何 class、没有任何事件、也没有绑定样式,仅仅是为了裹住里面的两个子节点,那它大概率就是被迫的包裹层。
比如这样:
<template> <div> <div class="title">标题</div> <div class="content">内容</div> </div> </template>这个外层 div 多半可以删掉。删掉后组件变成多根,内部样式不会受影响。但要注意,如果外面的父容器通过该组件的根元素选择器来控制间距、对齐或者 hover 高亮,删掉后这些规则就会失效。迁移前最好先看 CSS 里有没有类似.xxx > div的选择器。
在高频组件中删除包裹层,不仅减少了 DOM 节点,还能简化组件树。我曾把一个列表项组件从单根 div 改为多根,配合父级的display: contents使用,渲染性能有肉眼可见的提升,尤其是在 1000+ 条数据的页面上。
4.2 自动化迁移工具能帮你做什么
Vue3 官方提供了一个迁移构建版本,可以帮你检查 Vue2 特有的写法,但真正起大作用的是社区整理的一套 codemod 工具,专门针对模板代码做自动替换。不过据我所知,自动工具对“多根包裹”的处理还不够聪明,无法准确判断哪些 div 是单纯为了满足单根限制,哪些是真正有样式的。所以自动化工具通常只能帮你把语法层面的错误修掉,比如$children、$on、$once、filter删除等,多根包裹得靠人肉识别。
我的建议是,先跑一遍迁移工具,让项目能在 Vue3 下编译运行,不要急着把所有外层 div 删掉。等编译通过、功能稳定后,再挑选核心组件逐个手工去除包裹层。这样风险最小。如果你一开始就大范围改动 DOM 结构,很容易因为某处样式依赖根节点而出问题,排查起来特别痛苦。
4.3 多根组件带来的常见警告及处理速查表
从 Vue2 转来的人,经常会在控制台看到一些以前没见过的警告,下面是几个高频问题和我自己的处理方式。
| 警告/报错信息 | 可能原因 | 处理方案 |
|---|---|---|
Component inside <Transition> renders non-element root node | Transition组件要求直接子组件必须是单根元素,但你的子组件是多根组件 | 给该子组件最外层手动包一个单根容器,或者避免在多根组件上使用 Transition |
Fragment is not a real DOM node相关调试信息 | 多根组件作为某个 API 的挂载点,比如app.mount()返回的根组件不能有多个根 | 根组件(app.mount的对象)尽量保持单根,页面级组件多根会导致挂载行为异常 |
| 列表更新后 DOM 状态错乱、输入框值串位 | Fragment 子节点使用v-for但没有提供 key,或条件分支没有稳定 key | 给所有动态根节点添加稳定的唯一 key |
| 在第三方 UI 库中传入多根组件后样式失效 | 多根组件不支持自动继承 class,外部传入的样式没有落到指定节点 | 在组件内部用$attrs手动绑定到目标节点,或改用 prop 传参 |
这个速查表是我在维护内部组件库时总结出来的,频率最高的其实是 Transition 和输入框串位这两个。如果你要封装可复用组件,建议在所有涉及动画的组件里明确单根约定,避免使用者传入多根组件导致警告。
4.4 我自己踩过的三个坑
第一个坑是“多根组件不能作为根组件直接 mount”。在 main.js 里createApp(App).mount('#app')时,App 的模板如果出现多个根节点,挂载会报错,因为 Vue 需要把实例挂载到一个明确的根 DOM 元素上。解决方法是 App 模板保持一个单根,比如一个<div id="app">。多根限制只是针对子组件,不是针对整个应用。
第二个坑是响应式依赖收集在 Fragment 中比预想更复杂。当一个多根组件的某个根节点被v-if切换掉时,Vue 会更新 Fragment 的子节点数组。如果子节点数组的元素顺序发生改变,没有 key 的情况下,旧节点的响应式数据可能绑定到错误的新节点上。表现就是“明明切走了 loading,但 loading 的动画还在影响其他区域”。我后来强制所有条件根节点都加 key,这个问题就消失了。
第三个坑是样式优先级的隐形改变。删除包裹层之后,原先依靠父标签之间相邻选择器的样式可能失效,比如div + div的间距。多根组件会把两个根节点变成兄弟节点,间距选择器需要调整成:first-child或使用gap。这种问题在视觉上很微妙,可能只是少了 8px 间距,但原因极其隐蔽。如果你的多根组件在删除 div 后布局“莫名塌了”,先去看相邻兄弟选择器。
最后分享一点个人体会。在 Vue3 里,多根组件已经是非常成熟稳定的能力,不用再像 Vue2 那样想方设法用函数式组件去伪造 Fragment。但“能用多根”不等于“处处都要多根”。我做项目时的原则很简单:单根组件是默认,除非遇到列表项、表单控件、布局碎片这些真实第三方场景,才考虑改成多根。这样既能享受 Fragments 带来的结构自由,又不会陷入 class 透传和动画兼容的麻烦。
如果你正在写基础组件库,建议从最容易被 DOM 层级拖累的布局类组件开始尝试,比如行的子项、表单的 label+input 结构、表格的单元格组合。把包裹层删掉,重新跑一遍页面,你会明显感知到样式控制变得顺手很多。这就是 Vue3 模板语法革命里,最实在的一部分收益。