Vue2时代写组件最烦的一条规则就是“模板必须只有一个根节点”。一旦你写了两个平级的div编译器立刻甩出一句Component template should contain exactly one root element。Vue3把这条规则彻底改了支持多根节点底层靠的就是Fragments片段这类特殊虚拟节点。这篇我围绕Vue3的Fragments好好聊一聊它到底怎么实现的、什么时候应该用、怎么写、会遇到哪些坑以及从Vue2迁移时怎么判断哪些包裹层该拆、哪些该留。无论你是在做组件库还是在维护老项目迁移这篇文章都值得看看。1. 从一次编译报错说起Vue2的单根限制到底卡住了什么1.1 Vue2为什么非要单根Vue2的模板最终要编译成一个render函数render函数执行后必须返回一个VNode而VNode在渲染器看来对应的是“一个DOM位置”。问题就出在这里如果模板顶层有多个节点render函数返回一个数组渲染器就不知道把这个数组里的节点往哪里放更不知道更新时该怎么对比。所以在Vue2里模板对多根的态度是直接禁止编译失败。这个限制的代价就是我们在Vue2里写几乎每个组件都要在最外层包一个无意义的div。比如一个简单的卡片template div header标题/header main内容/main footer操作区/footer /div /template中间那个div纯粹是为了满足编译器的约束和业务语义毫无关系。早期大家没觉得有问题但当页面层级越来越深、flex和grid布局普及之后这种多余的包裹层就开始变成麻烦。1.2 被逼出来的各种绕行方案在Vue2里想绕过单根限制常见的办法有这么几种。第一种是函数式组件。函数式组件的render函数可以返回一个VNode数组因为它没有常规组件的实例和生命周期框架对它的返回值约束更松。代价很明显函数式组件不能使用this没有状态没有生命周期钩子使用场景非常受限。除了做一些极简的展示型封装基本干不了什么复杂的事。第二种是引入第三方Fragment库比如当时社区里有人做了vue-fragment组件。它本质上是利用函数式组件返回数组的能力让模板里可以写多个子节点。但它需要额外引入依赖而且底层还是用注释节点或者辅助节点来占位并不解决语义化和样式穿透的问题只能说是权宜之计。第三种是接受多包一层然后靠CSS的外部手段来“抹平”副作用。最常见的就是给包裹元素加display: contents让这个div在布局计算中“不存在”视觉上不产生额外嵌套。这个方法确实有效但兼容性、可访问性和调试体验都比较一般而且它解决不了DOM语义问题——表格里塞一个div浏览器解析后照样会把tr“拽出来”。1.3 单根限制的实际业务痛点Vue2的单根限制在几个场景里特别疼。第一个是表格。封装一个包含多个tr的组件在 Vue2 里基本无解。你总不能在tbody里包一个div那是非法的HTML结构浏览器渲染时会把div踢出去顺序和VNode树对不上diff直接出问题。所以很多人只能把整张表格写在一个大组件里组件的复用粒度被卡得非常死。第二个是flex和grid布局。多包一层div子元素的flex: 1或者grid-column布局就可能被中间层干扰。flex布局中父容器会和直接子元素建立尺寸分配关系多一层就多了一个“中间人”经常要额外写display: contents或者调整结构来补救。第三个是语义化标签。像dl、ul这类列表标签合理的子节点应该是dt/dd、liVue2里却经常被迫包上div导致语义被破坏对无障碍和SEO都不友好。Vue3正是看到了这些根深蒂固的问题才在渲染层引入了Fragments把“单根”这个历史包袱直接扔掉。2. Fragments底层原理Vue3是怎么让多根成为现实的2.1 特殊的vnodetype为Symbol的Fragment节点Vue3的vnode在设计上做了大幅调整。type字段除了可以是字符串对应原生元素、组件对象之外还能是几个内置的Symbol其中就包括Fragment、Text、Comment。Fragment在源码中大致是这样定义的// 源码简化示意 export const Fragment Symbol(Fragment) export const Text Symbol(Text) export const Comment Symbol(Comment)渲染器在做初始挂载时会先判断type的类型。如果发现vnode.type Fragment就不走创建真实DOM元素的分支而是走processFragment这条独立路径。你可以手动创建这样一个vnodeimport { Fragment, h } from vue const vnode h(Fragment, null, [h(div, A), h(div, B)])这个vnode本身不渲染任何容器节点它的两个子节点会被“平铺”到父容器里去。也就是说你在模板里看到的是template divA/div divB/div /template渲染到DOM里就是两个平级的div中间没有任何包裹元素。不过为了后续更新、移动、卸载时能找到整个片段的位置Vue3会在Fragment子节点前后各插入一个注释节点作为锚点。你在DevTools里看到的!-- --注释节点有一部分就是Fragment的定位标记。这也解释了为什么多根组件渲染出的DOM会多出几个注释节点——它们不是垃圾是定位用的。2.2 渲染器对Fragment的挂载、更新与卸载在挂载阶段processFragment会先创建开始锚点和结束锚点两个注释节点插入到容器中然后调用mountChildren把Fragment的所有子节点逐个挂载到两个锚点之间。因为Fragment没有自己的DOM节点所以挂载的过程实际上是“把自己摊开”的过程。在更新阶段如果新旧vnode都是Fragment渲染器调用的是patchChildren。Vue3的diff算法和Vue2相比变化不大依然有首尾对比、key对比这些策略。Fragment的子节点可以是静态节点也可以是动态节点。动态节点会携带PatchFlag标记这样更新时只需要精确更新变化的部分不会把整个片段全部重渲染。在移动阶段Fragment的锚点就派上用场了。比如父组件的v-for对多个多根组件做了排序调整需要把整个片段平移到新位置。渲染器通过锚点用insertBefore把两个注释节点之间的所有DOM节点整体搬移而不需要逐个计算位置。在卸载阶段Fragment本身没有DOM可删渲染器直接递归卸载所有子节点。因为结束锚点也在Fragment的区间里只要把开始锚点到结束锚点之间的DOM清掉整个片段就干净地移除了。2.3 模板编译器如何产出FragmentVue3的模板编译过程也围绕Fragment做了配套改动。拿这个模板举例template h1标题/h1 p{{ msg }}/p /template编译后的render函数大致是这样的简化产物return function render(_ctx, _cache) { return (openBlock(), createElementBlock(Fragment, null, [ createElementVNode(h1, null, 标题), createElementVNode(p, null, toDisplayString(_ctx.msg), 1 /* TEXT */) ])) }最外层用createElementBlock包了一个Fragment第二个参数是null表示没有额外属性。两个根节点作为children传入。这里openBlock和createElementBlock的组合是Vue3的block tree优化机制组件从最外层这个Fragment开始内部只追踪动态子节点。上面第二个节点后面那个数字1就是PatchFlag表示它的文本内容是动态的更新时只需要做文本替换不需要重新创建节点。2.4 和Vue2编译器对比本质上的差异Vue2的编译器处理多根时是直接把编译错误抛给你让你自己回去包一层div。它没有任何办法把多个根节点组织起来因为在它渲染器的模型里一个组件对应的就是一个vnode、一个DOM位置。Vue3的编译器则不同它把“多个根节点”组织成一个Fragment类型的block节点这个block节点既是组件渲染树的顶层也是动态节点收集的容器。所以多根在Vue3里不是“勉强能用”而是和性能优化机制深度绑定在一起的。这里顺便说一个细节在组件的render函数里如果你直接返回一个数组Vue内部也会自动把它normalize成Fragment vnode。所以很多人用JSX写多根时习惯写.../这个语法编译后的结果就是Fragment。也就是说模板多根、render返回数组、JSX Fragment最终都会汇到同一条处理路径上。3. 实战场景多根组件在真实项目里带来什么改变3.1 表格相关tr/td必须多根时的救星表格是我认为多根组件带来的最大改善之一。以前我想封装一个“带空状态的表格列组”在Vue2里根本做不到。现在可以直接这样写!-- TableRows.vue -- template template v-foritem in items :keyitem.id tr td{{ item.name }}/td td{{ item.status }}/td /tr /template tr v-if!items.length td colspan2暂无数据/td /tr /template这个组件的根是两个平级的tr渲染进tbody之后浏览器得到的DOM结构和VNode树完全一致。省掉了包裹层也避免了非法嵌套。更关键的是组件复用的粒度终于可以细化到“行”这个层级而不是被迫把整张表拆成一个巨型组件。3.2 布局与语义化告别多余的包裹层布局场景里多根最大的价值是让flex和grid的层级变得干净。举个例子一个组件要渲染两个平级卡片在Vue2里你可能会这样template div classcards div classcard.../div div classcard.../div /div /template外面那个cardsdiv如果只是为了满足单根限制在flex父容器里就会多出一个中间层。Vue3里直接去掉两个卡片根节点直接作为flex容器的子项参与布局少一层嵌套布局计算少一道“中间人”。语义化标签同样受益。写dl描述列表时组件的根直接是dt和ddtemplate dt名称/dt dd{{ description }}/dd /template这在Vue2里是完全不可能的现在则是最自然的写法。3.3 组件封装h函数配合Fragment输出多个节点多根在封装通用型小组件时也很有用。比如做一个“图标文本”的组合希望它在不同父容器里都以平级节点出现用render函数写非常直观import { defineComponent, h, Fragment } from vue export const IconText defineComponent({ name: IconText, props: { icon: { type: String, required: true }, text: { type: String, required: true } }, setup(props) { return () h(Fragment, null, [ h(span, { class: icon icon-${props.icon} }), h(span, { class: text }, props.text) ]) } })父组件用这个组件时拿到的就是两个平级span不会多一个包装容器。这种“不占DOM位置”的组件很适合在特殊布局里插入整体结构更加可控。3.4 场景边界什么时候不该用多根多根不是万能的有几个场景我建议老老实实用单根。第一个是使用Transition的场景。过渡动画要求被包裹的内容是单个元素或单个组件多根组件作为Transition的子级会直接触发警告动画也可能乱掉。如果你组件里有多根的切换动画需求需要在组件内部自行处理而不是指望外层Transition接管。第二个是父组件需要对整个子组件做整体控制时。比如父组件希望通过类名给子组件全局加边框、加背景单根组件可以直接通过class属性继承多根组件则无法自动继承需要显式手动绑定。这个下面会详细说。第三个是组件本身代表一个语义单元时。比如一个CardItem它所有内容合在一起就是“一张卡片”那它根节点就应该是一层卡片容器而不是把card-header和card-body都拆成根。多根适合“组件内容不是一个独立语义单元”的情况。4. 实操示例手把手写一个多根组件4.1 用模板声明多根组件最直接的方式就是模板里写多个根。这里以表单场景为例把label和input做成一个多根组件!-- FormField.vue -- template label classfield-label :forinputId{{ label }}/label input :idinputId :valuemodelValue :class$attrs.class v-bindomitAttrs($attrs, [class]) input$emit(update:modelValue, $event.target.value) / /template script setup langts import { useId, useAttrs } from vue defineOptions({ name: FormField, inheritAttrs: false }) defineProps{ label: string modelValue: string }() const emit defineEmits{ (e: update:modelValue, value: string): void }() const attrs useAttrs() function omitAttrs(attrs: Recordstring, unknown, keys: string[]) { const rest: Recordstring, unknown {} for (const key of Object.keys(attrs)) { if (!keys.includes(key)) { rest[key] attrs[key] } } return rest } const inputId useId() /script这个组件渲染后DOM里就是平级的label和input没有多余div。useId()是我个人习惯用的一个生成唯一ID的方式Vue 3.5之后内置可用如果你用的版本较低可以用Math.random()或者crypto.randomUUID()代替。4.2 用render函数/JSX返回多根除了模板语法render函数是更接近底层的方式。下面两种写法是等价的import { defineComponent, h, Fragment } from vue export const MultiRoot defineComponent({ setup() { return () [ h(div, 根A), h(div, 根B) ] } })import { defineComponent, h, Fragment } from vue export const MultiRoot defineComponent({ setup() { return () h(Fragment, null, [ h(div, 根A), h(div, 根B) ]) } })第一种写法里返回数组Vue会帮你自动包成Fragment第二种是显式写Fragment。实际项目中显式写Fragment的好处是意图更清楚也方便在某个特定逻辑分支里控制是否返回多根。JSX写法也类似const MultiRoot defineComponent({ setup() { return () ( div根A/div div根B/div / ) } })编译后就是Fragment。三种方式最后走的是同一条渲染路径你可以按团队习惯自由选择。4.3 传参、插槽与属性继承的写法多根组件最容易踩的坑就是属性继承。上面的FormField里我特意写了inheritAttrs: false因为多根组件不会像单根组件那样自动把父组件传下来的class、style和事件绑定到唯一根节点上。如果你不设置inheritAttrs: false开发环境下控制台会看到类似这样的警告Extraneous non-props attributes (class) were passed to component but could not be automatically inherited because component renders fragment or text root nodes.意思是父组件传了多余的属性但由于组件是多根或文本根框架不知道该把属性挂到哪个节点上只能丢弃。解决办法就是手动接管。把inheritAttrs设为false然后在明确想要接收属性的根节点上绑定v-bind$attrs或者使用useAttrs()拆分配置。比如有多个根你想让第一个根接收class第二个根接收事件就可以自己做一个拆分函数把attrs里的class挑出来单独绑。插槽在多根组件里没有额外限制。多根组件的默认插槽和命名插槽都可以正常使用只是渲染后的插槽内容本身也可能是Fragment这完全兼容。如果你的多根组件里有多个根分别想放不同插槽模板里直接写就行template header slot nameheader / /header main slot / /main /template4.4 多根组件之间的更新与重渲染多根组件在更新时的行为和单根组件没有本质区别。每个根节点仍然是普通的vnode动态部分通过PatchFlag标记。唯一需要注意的是如果多根组件里的根节点顺序可能在运行时变化或者某个根被v-for循环要给循环节点加上key。template RowItem v-forrow in rows :keyrow.id :datarow / EmptyRow v-if!rows.length / /template这里的RowItem和EmptyRow是两个平级根节点v-for生成的节点列表里每个节点有唯一key这样Fragment做diff时才能精确匹配复用DOM而不是整段重建。如果只是稳定的两个根节点相互切换比如v-if/v-else不需要额外加key因为它们在编译后处于同一个数组位置上是同一个节点的条件分支。5. 多根组件最容易踩的坑问题排查实录5.1 attrs自动继承失效的警告这个前面已经说到了我再补充一个排查思路。如果你的多根组件突然发现父组件传的class不生效了第一反应不应该是去查样式优先级而应该是打开控制台看有没有Extraneous non-props attributes警告。绝大多数情况就是多根导致的attrs继承问题处理方式就是inheritAttrs: false加手动绑定。在手动拆分attrs时有一个小细节值得注意v-bind$attrs绑定的优先级问题。如果你写了:classcustomClass同时又有v-bind$attrs那么$attrs.class可能会覆盖前面的customClass。建议要么做拆分要么只在一个根上使用完整的$attrs避免同名属性冲突。5.2 scoped样式在多根下的行为很多人在多根组件上用scoped样式时担心样式丢失。实际上Vue3的scoped样式机制是给模板编译出的每个元素都加上组件的>