Vue 2 组件为什么要求单根?Vue 3 Fragment、应用挂载与 SSR
原文标题是“为什么 Vue 实例只能有一个根元素?”。这个说法只适用于 Vue 2 的组件模板/渲染函数限制,不适用于“一个页面只能有一个 Vue 应用”,也不适用于 Vue 3.5 的普通组件。
一、先区分三个概念
1. Vue 2 的组件根 VNode
Vue 2 的组件 render 函数需要返回一个根 VNode,因此 Vue 2 SFC 中并列顶级元素会报错:
<!-- Vue 2:会报 template should contain exactly one root element -->
<template>
<h1>标题</h1>
<p>说明</p>
</template>
通常需要增加一个容器:
<!-- Vue 2 -->
<template>
<section>
<h1>标题</h1>
<p>说明</p>
</section>
</template>
这是 Vue 2 编译器和 renderer 的约束,不是 HTML 规定“所有树都只能有一个根”,也不是因为多个节点无法被 JavaScript 表示。
2. 应用挂载容器
Vue 2 的 el 或 Vue 3 的 app.mount() 是一个应用的挂载点:
// Vue 2
new Vue({
el: '#app',
})
// Vue 3
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
挂载点是应用接管的入口,不等于整个网页只能存在一个入口。页面可以有多个独立 Vue 应用:
createApp(HeaderApp).mount('#header-app')
createApp(WidgetApp).mount('#widget-app')
多个应用之间默认不共享组件、插件和状态;需要共享时应显式使用模块、store 或事件协议。
3. .vue 文件不是一个实例
SFC 是组件定义或编译输入,同一个组件定义可以被创建很多个实例:
<!-- UserCard.vue 是定义,不是某一个固定实例 -->
<template>
<div>{{ name }}</div>
</template>
<script setup lang="ts">
defineProps<{ name: string }>()
</script>
<UserCard name="A" />
<UserCard name="B" />
二、虚拟 DOM 与单根
Vue 会把组件的渲染结果表示为 VNode 树,再由 renderer 创建或更新真实 DOM。树形结构通常从一个根节点开始,因此 Vue 2 的组件 render 结果需要一个根 VNode:
Vue 2 component render
↓
一个根 VNode
↓
DOM 子树
原文把这个限制解释成“多个根会导致遍历效率很低”或“虚拟 DOM 无法生成”,这些不是根本原因。Vue 2 当时的 VNode、属性 fallthrough、组件更新和编译器接口选择了单根约束;Vue 3 已经通过 Fragment 支持多个根。

例如,挂载容器内部的 DOM 本来就可以有多个子节点:
<div id="app">
<h1>标题</h1>
<span>内容</span>
普通文本
</div>

“根”是相对于某个渲染树而言的概念。一个 HTML 页面有 html 根,一个 Vue 应用有自己的组件树,一个组件在 Vue 2 中要求返回一个根 VNode;这些不能混成同一个“实例只能一个根”的结论。
三、Vue 3 的 Fragment:普通组件可以多根
Vue 3 支持 Fragment,因此下面写法是合法的:
<!-- Vue 3 -->
<template>
<header>标题</header>
<main>内容</main>
<footer>页脚</footer>
</template>
Fragment 不一定产生额外的包裹元素,但 Vue 仍会使用内部锚点来维护更新和卸载。多根组件最需要注意的是 $attrs 不会自动猜测应该落到哪个根节点:
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
</script>
<template>
<header>标题</header>
<main v-bind="$attrs">
内容
</main>
</template>
父组件传入的 class、id 和未声明的事件监听器等 fallthrough attributes,需要由组件作者明确绑定到合适的根节点,否则可能出现运行时警告或属性没有落到预期元素。
组件实例的 $el 在多根场景也不能被理解为“唯一根 DOM 元素”。需要操作具体元素时,使用 template ref:
<script setup lang="ts">
import { ref } from 'vue'
const main = ref<HTMLElement | null>(null)
</script>
<template>
<header>标题</header>
<main ref="main">内容</main>
</template>
原文后续包含一张构建目录截图,但它只是脚手架产物目录,不是 Vue 组件模板或根节点示意,因此不再作为文章配图。
四、SFC <template> 和 HTML <template> 不是一回事
原文把 SFC 外层 <template> 和浏览器原生 <template> 的 inert 特性混在了一起。
- SFC 的
<template>是 Vue 编译器的文件区块,不会原样出现在最终 DOM; - 它可以被编译成一个元素、文本、Fragment 或结构指令;
- 浏览器原生
<template>是 HTML 元素,内容放在content文档片段中,默认不会直接渲染; - Vue 2 的单根限制来自 Vue 2 编译器/renderer,不是浏览器
<template>规范要求。
Vue 3 的 <template> 还可以用来渲染一组节点而不增加包装元素:
<template v-if="visible">
<h2>标题</h2>
<p>说明</p>
</template>
<template v-for="item in items" :key="item.id">
<span>{{ item.name }}</span>
<small>{{ item.description }}</small>
</template>
五、Teleport 不等于第二个 Vue 根
Vue 3 的 Teleport 会改变 DOM 的物理位置,但不会改变组件的逻辑父子关系:
<template>
<button @click="open = true">打开</button>
<Teleport to="body">
<div v-if="open" class="modal">
弹窗内容
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const open = ref(false)
</script>
props、事件、provide/inject 和 DevTools 组件树仍然遵循逻辑层级。Teleport 不是创建第二个 Vue 应用,也不是绕过组件根节点限制的通用手段。
在 SSR 中,Teleport 的内容需要被放入正确的 teleports 输出位置;不要把“Teleport 到 body”简单理解成 SSR hydration 天然安全。Nuxt/Vue 项目应遵循对应版本的 SSR 约定。
六、SSR 与 hydration
SSR 的基本过程是:
服务端执行组件树并输出 HTML
↓
客户端使用相同的组件树接管 HTML
↓
绑定事件并恢复交互
服务端和客户端第一次渲染必须尽量得到相同结构。随机数、时间、时区、浏览器 API、无效 HTML 嵌套和不同的初始数据都可能造成 hydration mismatch:
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const width = ref(0)
onMounted(() => {
// 浏览器 API 只能在客户端读取
width.value = window.innerWidth
})
</script>
不要在服务端渲染阶段直接读取 window 或 document,也不要让服务端输出一个结构、客户端第一次渲染却输出另一个结构。Vue 会尝试修复 mismatch,但可能产生额外重建、闪动和性能损失。
七、现代 Vue 应该怎样回答这个面试题
可以这样回答:
Vue 2 的组件模板和 render 函数要求一个根 VNode,因此 Vue 2 SFC 中并列顶级元素需要包裹容器。这个限制不等于一个页面只能有一个 Vue 应用,Vue 2 和 Vue 3 都可以存在多个挂载点。Vue 3 的普通组件支持 Fragment 多根,但多根组件不会自动决定
$attrs应该落到哪个根节点,需要显式绑定。Teleport 只改变 DOM 的物理位置,SSR hydration 仍要求服务端和客户端输出一致。Nuxt 的 server component/island 还可能有单独的单根约束,需要按产品版本区分。
八、总结
- Vue 2 的“单根”说的是组件 render 结果,不是整个网页或所有 Vue 实例;
- Vue 2 SFC 多根会报错,Vue 3 普通 SFC 支持 Fragment;
.vue文件是组件定义,可以创建多个实例;- 页面可以挂载多个独立 Vue 应用;
- Vue 3 多根组件需要显式处理
$attrs; <template>、Fragment、Teleport 和应用 mount 是不同概念;- SSR hydration 要求客户端和服务端初始结构一致。
参考资料
- Vue 2 单根元素
- Vue 3 Fragment 迁移说明
- Vue 3 应用实例
- Vue 3 Fallthrough Attributes
- Vue 3 Teleport
- Vue SSR 与 hydration
原文出处:为什么 Vue 实例只能有一个根元素?。