技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTVUE

Vue 2 组件为什么要求单根?Vue 3 Fragment、应用挂载与 SSR

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/58-为什么 Vue 实例只能有一个根元素?
本文目录9 个章节
  1. 一、先区分三个概念
  2. 二、虚拟 DOM 与单根
  3. 三、Vue 3 的 Fragment:普通组件可以多根
  4. 四、SFC <template> 和 HTML <template> 不是一回事
  5. 五、Teleport 不等于第二个 Vue 根
  6. 六、SSR 与 hydration
  7. 七、现代 Vue 应该怎样回答这个面试题
  8. 八、总结
  9. 参考资料

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 支持多个根。

Vue VNode 和真实 DOM 的历史示意图(本地化)

例如,挂载容器内部的 DOM 本来就可以有多个子节点:

<div id="app">
  <h1>标题</h1>
  <span>内容</span>
  普通文本
</div>

挂载容器中的多节点 DOM 示意图(本地化)

“根”是相对于某个渲染树而言的概念。一个 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>

父组件传入的 classid 和未声明的事件监听器等 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>

不要在服务端渲染阶段直接读取 windowdocument,也不要让服务端输出一个结构、客户端第一次渲染却输出另一个结构。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 还可能有单独的单根约束,需要按产品版本区分。

八、总结

  1. Vue 2 的“单根”说的是组件 render 结果,不是整个网页或所有 Vue 实例;
  2. Vue 2 SFC 多根会报错,Vue 3 普通 SFC 支持 Fragment;
  3. .vue 文件是组件定义,可以创建多个实例;
  4. 页面可以挂载多个独立 Vue 应用;
  5. Vue 3 多根组件需要显式处理 $attrs
  6. <template>、Fragment、Teleport 和应用 mount 是不同概念;
  7. SSR hydration 要求客户端和服务端初始结构一致。

参考资料

原文出处:为什么 Vue 实例只能有一个根元素?

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS