Vue 基础体系 · 第 70/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue Islands 与渐进式水合:交互边界、成本和适用场景

在 Vue SSR 应用中,浏览器拿到的 HTML 可以先用于展示内容,但“能显示”不等于“已经具备交互能力”。服务端渲染负责生成 HTML,水合负责让这份 HTML 与客户端 Vue 组件建立对应关系,并恢复事件监听、响应式状态和组件生命周期。

Vue Islands渐进式水合都试图减少首屏必须立即执行的客户端 JavaScript,但它们优化的边界不同:

  • Vue Islands(岛屿架构):把页面拆成多个相互独立的交互单元。静态区域可以完全不发送客户端 JavaScript,每个交互单元单独渲染、单独水合。
  • 渐进式水合(progressive hydration):仍然使用 SSR 页面和组件树,但把水合动作延迟到满足某个条件时,例如浏览器空闲、组件进入视口或用户交互。

二者都不是“只要延迟执行 JavaScript 就一定更快”。它们会引入新的边界、状态传递方式、故障路径和维护成本。真正需要回答的问题是:

哪些内容需要交互?这些交互是否可以彼此隔离?用户何时需要它们?为了延迟或拆分水合,应用愿意承担多少复杂度?


一、先建立三个基础概念:SSR、客户端渲染和水合

1. SSR 只生成初始 HTML

以一个文章页面为例,服务端可以执行 Vue 组件:

<!-- ArticlePage.vue -->
<script setup lang="ts">
defineProps<{
  title: string
  body: string
}>()
</script>

<template>
  <main>
    <h1>{{ title }}</h1>
    <article v-html="body" />
  </main>
</template>

服务端拿到数据后,Vue SSR 会生成类似这样的 HTML:

<main>
  <h1>Vue Islands 与渐进式水合</h1>
  <article>
    <p>服务端生成的文章内容。</p>
  </article>
</main>

这份 HTML 可以直接用于:

  • 浏览器首次绘制;
  • 搜索引擎抓取;
  • 无 JavaScript 环境下的基本阅读;
  • 降低用户等待客户端应用启动的时间。

但 HTML 本身没有 Vue 的响应式系统,也没有 @click 监听器。下面的按钮虽然显示出来了,却不会自动具备交互能力:

<button @click="count++">
  {{ count }}
</button>

服务端只会把它渲染成某个初始文本,例如:

<button>0</button>

2. 客户端渲染从空容器开始

纯客户端渲染通常先发送:

<div id="app"></div>

然后浏览器下载 JavaScript,执行 Vue 应用,再创建真正的 DOM。

它的主要问题是首屏内容依赖 JavaScript 执行完成。对于内容型页面、弱网环境和搜索引擎抓取,这往往不如 SSR。

3. 水合是在已有 DOM 上恢复 Vue 运行时

SSR + 水合的基本流程是:

sequenceDiagram
    participant B as 浏览器
    participant S as 服务端
    participant V as Vue SSR
    participant C as 客户端 Vue

    B->>S: 请求页面
    S->>V: 执行组件与数据获取
    V-->>S: 返回 HTML 和序列化状态
    S-->>B: HTML + JavaScript + payload
    B->>B: 先绘制 HTML
    B->>C: 下载并执行客户端入口
    C->>B: 在已有 DOM 上进行水合
    C->>B: 绑定事件、恢复响应式状态

“在已有 DOM 上进行水合”意味着客户端 Vue 期望:

  1. 服务端 HTML 与客户端首次渲染结果一致;
  2. DOM 层级和文本内容可以被对应起来;
  3. 服务端和客户端使用相同的初始状态;
  4. 组件可以在客户端建立事件监听和生命周期。

如果客户端第一次渲染的结果不同,就会出现 hydration mismatch(水合不匹配)。

例如:

<template>
  <p>{{ new Date().toISOString() }}</p>
</template>

服务端和客户端很可能得到不同时间,因此初始输出不一致。又如:

<template>
  <p>{{ window.innerWidth }}</p>
</template>

服务端没有 window,即使通过条件判断避免报错,客户端首次渲染的宽度也可能与服务端占位内容不同。

水合不是重新“证明”服务端 HTML 正确,而是要求客户端首次虚拟 DOM 与服务端结果具有可对应性。水合不匹配会导致警告、局部修正、额外 DOM 操作,严重时可能放弃复用已有 DOM。


二、什么是 Vue Islands

1. 岛屿是独立的交互边界

一个 Island 是页面中的一块自包含区域,通常具有以下特征:

  • 服务端可以独立生成它的 HTML;
  • 客户端可以独立加载它需要的 JavaScript;
  • 它可以独立决定何时水合;
  • 它与其他岛屿之间不依赖同一个客户端 Vue 根实例;
  • 岛屿之外的静态内容不需要因为某个交互组件而加载 Vue 运行时。

例如,一个商品详情页可以拆成:

页面
├── Header:静态或服务端渲染
├── ProductInfo:服务端渲染
├── ImageGallery:交互岛屿
├── AddToCart:交互岛屿
├── Reviews:交互岛屿
├── Recommendation:服务端渲染
└── Footer:静态

其中:

  • 商品标题、描述、价格可以只输出 HTML;
  • 图片画廊需要点击切换,因此成为一个岛屿;
  • 加入购物车需要事件和请求,因此成为另一个岛屿;
  • 推荐列表如果没有交互,可以不进入客户端 JavaScript。

这里的核心不是“组件写在不同文件里”,而是客户端运行时边界不同。普通 Vue 组件即使被拆成多个文件,也可能仍然属于同一个客户端应用,最终一起被根应用水合。

2. 岛屿与普通组件的区别

普通组件树通常是:

一个 Vue App
└── Page
    ├── Header
    ├── Gallery
    ├── CartButton
    └── Footer

客户端入口对整个根节点执行一次:

const app = createSSRApp(App)
app.mount('#app')

这时 HeaderFooter 即使没有任何交互,也位于同一棵需要被客户端 Vue 接管的组件树中。

岛屿架构更接近:

文档
├── 静态 HTML
├── <div data-island="gallery">...</div>
├── 静态 HTML
└── <div data-island="cart-button">...</div>

客户端根据标记分别挂载:

hydrateGallery('#gallery-island')
hydrateCartButton('#cart-island')

因此,岛屿强调的是:

页面由多个独立的客户端入口或水合根组成,而不是一个客户端应用包揽整页。


三、什么是渐进式水合

1. 渐进式水合延迟“接管”,而不是删除组件

渐进式水合仍然会为组件生成 SSR HTML,但不在页面初始化阶段立即让所有组件完成客户端水合。

常见触发条件包括:

  • idle:浏览器进入空闲状态后水合;
  • visible:组件进入视口附近后水合;
  • interaction:用户点击、聚焦或按键时水合;
  • media:满足某个媒体查询时水合;
  • manual:由业务代码显式触发。

例如评论区位于页面底部,用户打开文章时通常不会立即操作它。可以先显示服务端生成的评论,再在即将进入视口时水合。

2. Vue 3.5+ 的异步组件水合策略

Vue 3.5 引入了异步组件的惰性水合策略。下面的示例使用 Composition API 和 TypeScript:

// src/components/lazy.ts
import {
  defineAsyncComponent,
  hydrateOnIdle,
  hydrateOnInteraction,
  hydrateOnVisible,
} from 'vue'

export const Comments = defineAsyncComponent({
  loader: () => import('./Comments.vue'),
  hydrate: hydrateOnVisible({
    rootMargin: '200px',
  }),
})

export const SearchBox = defineAsyncComponent({
  loader: () => import('./SearchBox.vue'),
  hydrate: hydrateOnInteraction(['focus', 'click']),
})

export const FooterEffects = defineAsyncComponent({
  loader: () => import('./FooterEffects.vue'),
  hydrate: hydrateOnIdle(2000),
})

然后在 SSR 页面中使用:

<script setup lang="ts">
import { Comments, SearchBox, FooterEffects } from './lazy'
</script>

<template>
  <main>
    <h1>文章标题</h1>

    <SearchBox />

    <article>
      <p>文章正文……</p>
    </article>

    <Comments />

    <FooterEffects />
  </main>
</template>

这个例子要求:

  • Vue 3.5 或更高版本;
  • 使用支持 SSR 的 Vue 构建方式;
  • 异步组件的服务端和客户端导入路径一致;
  • 客户端具备对应的 IntersectionObserver、事件监听或空闲调度环境;不支持时应提供降级策略。

其行为可以分解为:

  1. 服务端执行异步组件,输出评论区、搜索框和页脚效果的初始 HTML;
  2. 浏览器先显示这些 HTML;
  3. 页面初始化时,组件不会全部立即完成水合;
  4. SearchBox 在用户聚焦或点击时触发水合;
  5. Comments 在进入视口前约 200px 时触发水合;
  6. FooterEffects 最迟在空闲调度到达 2 秒等待条件后触发水合;
  7. 水合完成后,组件才拥有 Vue 事件、响应式更新和生命周期。

这里的“延迟”不是延迟 HTML,而是延迟客户端接管。

3. 该 API 的版本边界

hydrateOnIdlehydrateOnVisiblehydrateOnInteraction 属于 Vue 3.5 引入的异步组件水合策略。使用 Vue 3.4 或更早版本时,不能直接假设这些 API 存在。

旧版本通常需要:

  • 使用框架提供的惰性水合封装;
  • 手动创建客户端挂载点;
  • onMounted 后根据 IntersectionObserver 动态加载组件;
  • 或升级 Vue。

这几种方案的语义并不完全相同。尤其要区分:

  • 延迟异步组件水合:仍处于一个 Vue SSR 应用的组件树中;
  • 独立岛屿:页面存在多个可以分别挂载的客户端根;
  • 客户端动态加载:如果没有对应 SSR HTML,可能变成纯客户端渲染。

四、两种机制的关系:重叠但不等价

可以用两个维度理解它们:

维度 Vue Islands 渐进式水合
主要解决的问题 缩小客户端应用边界 延迟已有组件的水合时机
静态区域是否可以完全无客户端 JS 可以 通常不一定
是否需要多个独立挂载边界 通常需要 不一定
是否仍可共享同一个 Vue 应用状态 默认不应直接共享 可以继续位于同一应用树
复杂度 更高 相对较低
适合对象 交互分散、边界清晰的内容页 大型页面中的低优先级组件

它们可以组合使用:

页面
├── 静态文章内容
├── 搜索岛屿:用户聚焦时水合
├── 评论岛屿:进入视口时水合
└── 购物车岛屿:独立加载并立即水合

也可以只使用渐进式水合:

一个 Vue SSR 应用
└── 多个异步组件
    ├── 首屏组件:立即水合
    ├── 评论:进入视口水合
    └── 页脚效果:空闲时水合

因此,“使用了惰性水合”不等于“使用了 Islands 架构”。


五、交互边界:岛屿最重要的设计问题

1. 交互边界决定状态边界

假设页面中有两个独立岛屿:

ProductInfoIsland
CartIsland

商品信息岛屿负责展示价格,购物车岛屿负责添加商品。服务端可以向两个岛屿分别传递:

type ProductProps = {
  id: string
  title: string
  price: number
}

type CartProps = {
  productId: string
  initialQuantity: number
}

在组件内部使用:

<script setup lang="ts">
import { ref } from 'vue'

const props = defineProps<{
  productId: string
  initialQuantity: number
}>()

const quantity = ref(props.initialQuantity)
const pending = ref(false)
const errorMessage = ref('')

async function addToCart() {
  pending.value = true
  errorMessage.value = ''

  try {
    const response = await fetch('/api/cart/items', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        productId: props.productId,
        quantity: quantity.value,
      }),
    })

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`)
    }
  } catch (error) {
    errorMessage.value =
      error instanceof Error ? error.message : '加入购物车失败'
  } finally {
    pending.value = false
  }
}
</script>

<template>
  <section>
    <label>
      数量
      <input v-model.number="quantity" type="number" min="1" />
    </label>

    <button :disabled="pending" @click="addToCart">
      {{ pending ? '提交中…' : '加入购物车' }}
    </button>

    <p v-if="errorMessage" role="alert">
      {{ errorMessage }}
    </p>
  </section>
</template>

这个岛屿可以独立工作,因为它的输入是明确的 productIdinitialQuantity,输出是 HTTP 请求,而不是依赖页面中某个隐式的全局 Vue store。

2. 跨岛屿状态不能假设天然共享

在一个普通 Vue 应用中,可以这样共享状态:

import { reactive } from 'vue'

export const cartStore = reactive({
  count: 0,
})

如果两个组件属于同一个 Vue 应用根,这种模块级状态通常可以被共同导入。

但在岛屿架构中,常见情况是:

Island A:独立客户端入口
Island B:独立客户端入口

两者可能:

  • 各自加载了不同的 JavaScript chunk;
  • 各自创建了 Vue 应用实例;
  • 在某些部署方式下甚至运行于不同的边界;
  • 无法通过父子组件关系传递事件。

因此,不能把“多个岛屿”当作“同一棵 Vue 组件树中的兄弟组件”。

跨岛屿通信需要显式协议,例如:

方式一:浏览器自定义事件

// CartIsland.vue
window.dispatchEvent(
  new CustomEvent('cart:updated', {
    detail: { count: 3 },
  }),
)
// HeaderIsland.vue
import { onMounted, onUnmounted, ref } from 'vue'

const cartCount = ref(0)

function onCartUpdated(event: Event) {
  const customEvent = event as CustomEvent<{ count: number }>
  cartCount.value = customEvent.detail.count
}

onMounted(() => {
  window.addEventListener('cart:updated', onCartUpdated)
})

onUnmounted(() => {
  window.removeEventListener('cart:updated', onCartUpdated)
})

这种方式简单,但事件只能通知当时已经注册监听器的组件。若 Header 还没有水合,事件可能丢失。

方式二:共享持久化状态

可以把购物车数量写入:

  • Cookie;
  • localStorage
  • BroadcastChannel
  • 服务端会话。

例如 Cookie 更适合服务端和客户端都需要读取的关键状态,但必须考虑:

  • Cookie 大小限制;
  • 篡改和签名;
  • 隐私数据;
  • 多标签页一致性;
  • 请求中携带 Cookie 的安全影响。

方式三:通过服务端重新获取

操作成功后,岛屿只更新服务端状态,其他岛屿在下一次请求或显式刷新时重新读取。这种方式一致性较好,但即时反馈不如本地事件直接。

3. 岛屿边界实际上是协议边界

跨边界的数据至少要回答四个问题:

  1. 输入是什么:props、序列化状态、URL、Cookie 还是接口数据?
  2. 输出是什么:DOM 事件、HTTP 请求、浏览器事件还是重新导航?
  3. 时序是什么:发送方和接收方谁先水合?
  4. 失败怎么办:接收方未加载、网络失败、数据过期时如何恢复?

如果一个页面上的组件必须频繁共享几十个响应式状态,且需要大量父子事件传递,那么这些组件可能不适合被拆成独立岛屿。边界越多,协议越多;协议越多,调试和一致性成本越高。


六、序列化是 SSR 与岛屿之间的桥梁

服务端不能把一个 Vue ref、数据库连接或函数直接传给浏览器。通常必须把服务端数据转换为可序列化的值:

type ProductPayload = {
  id: string
  title: string
  price: number
  tags: string[]
}

安全的做法是只传递组件真正需要的数据:

const payload: ProductPayload = {
  id: product.id,
  title: product.title,
  price: product.price,
  tags: product.tags,
}

而不是把整个数据库实体直接注入 HTML:

// 不建议:可能包含内部字段、权限字段或敏感信息
const payload = productRecord

序列化数据通常会进入 HTML 或内联脚本,因此必须注意:

  • 不要把密码、访问令牌、内部权限信息放入 payload;
  • 正确处理 <>& 等字符,避免脚本注入;
  • 在服务端校验客户端提交的价格、数量和权限;
  • 不要因为某个字段来自 SSR 就认为它可信。

尤其是购物车、价格、权限和库存,客户端 payload 只能用于初始化展示,不能作为最终业务依据。服务端 API 必须重新查询或验证。


七、成本模型:为什么减少水合可能有效

1. 初始成本的组成

可以把页面首次可交互前的成本近似写成:

Cinitial=Cdownload+Cparse+Cexecute+ChydrateC_{\text{initial}} = C_{\text{download}} + C_{\text{parse}} + C_{\text{execute}} + C_{\text{hydrate}}

其中:

  • CdownloadC_{\text{download}}:下载 JavaScript 的网络成本;
  • CparseC_{\text{parse}}:浏览器解析 JavaScript 的成本;
  • CexecuteC_{\text{execute}}:执行模块初始化和应用启动的成本;
  • ChydrateC_{\text{hydrate}}:遍历 SSR DOM、创建组件实例、恢复响应式系统和绑定事件的成本。

普通 SSR 应用可能把许多页面组件都放在同一个客户端入口中:

Chydrate=i=1nhiC_{\text{hydrate}} = \sum_{i=1}^{n} h_i

即使第 ii 个组件当前不可见、没有交互,仍可能贡献 hih_i

如果采用渐进式水合,初始阶段只处理关键组件集合 KK

CinitialCshell+iKhiC_{\text{initial}} \approx C_{\text{shell}} + \sum_{i \in K} h_i

其余组件的成本被推迟到后续时刻:

Cdeferred=iKhiC_{\text{deferred}} = \sum_{i \notin K} h_i

这说明渐进式水合主要改变的是成本发生的时间,不一定减少总成本。

2. Islands 还能减少客户端代码覆盖范围

如果某个区域完全是静态 HTML,那么它不仅不需要初始水合,也不需要对应的客户端组件代码:

Jclient=Jshell+iIJiJ_{\text{client}} = J_{\text{shell}} + \sum_{i \in I} J_i

其中 II 是需要客户端能力的岛屿集合。

一个普通 SPA 式页面可能把所有组件都放入:

Jclient=Jheader+Jarticle+Jgallery+Jcart+JfooterJ_{\text{client}} = J_{\text{header}} + J_{\text{article}} + J_{\text{gallery}} + J_{\text{cart}} + J_{\text{footer}}

而岛屿架构可能只发送:

Jclient=Jgallery+JcartJ_{\text{client}} = J_{\text{gallery}} + J_{\text{cart}}

这里不能简单把每个 chunk 的大小相加作为真实网络成本,因为还要考虑:

  • chunk 压缩;
  • HTTP/2 或 HTTP/3 并发;
  • 缓存命中;
  • 公共依赖重复或共享;
  • 预加载和预取;
  • 模块图解析成本。

所以公式适合说明因果方向,不代表固定的性能数字。

3. 一个完整算例

假设一个文章页包含:

区域 是否交互 假设水合成本
Header 1
Article 3
SearchBox 4
Comments 8
SharePanel 2
Footer 1

如果整个页面采用单一 Vue 根节点并立即水合:

Call=1+3+4+8+2+1=19C_{\text{all}} = 1 + 3 + 4 + 8 + 2 + 1 = 19

如果只让首屏搜索框和分享面板立即水合,评论区进入视口时再水合:

Cinitial=1+3+4+2+1=11C_{\text{initial}} = 1 + 3 + 4 + 2 + 1 = 11

评论区的成本 8 被延后。用户不滚动到评论区时,它可能完全不发生。

如果进一步采用 Islands,并让非交互内容不进入客户端 Vue 根节点:

Cinitial,islands=4+2=6C_{\text{initial,islands}} = 4 + 2 = 6

这只是一个用于推导的相对模型,不是浏览器实际毫秒数。实际结果还取决于:

  • 组件代码量;
  • DOM 节点数量;
  • 响应式依赖数量;
  • 主线程是否被其他脚本占用;
  • 网络状况;
  • 浏览器设备性能;
  • 是否触发了额外数据请求。

4. 延迟水合不一定提升用户感知速度

若一个组件在首屏可见区域,但使用 hydrateOnIdle,可能发生:

HTML 已显示
↓
用户立即点击
↓
水合尚未完成
↓
点击没有按预期产生结果,或需要等待水合

因此,性能指标不能只看“初始水合少了多少”。还需要检查:

  • 首次交互延迟;
  • 交互触发到事件处理的时间;
  • 组件进入视口时是否已完成加载;
  • 水合是否与用户输入竞争主线程;
  • 延迟加载是否造成布局变化。

八、渐进式水合的完整生命周期

hydrateOnVisible 为例,组件大致经历以下状态:

stateDiagram-v2
    [*] --> SSR_HTML
    SSR_HTML --> Waiting: 浏览器加载页面
    Waiting --> Loading: 进入视口阈值
    Loading --> Hydrating: 异步组件代码加载成功
    Loading --> Failed: 代码加载失败
    Hydrating --> Interactive: 水合成功
    Hydrating --> Mismatch: 服务端与客户端输出不一致
    Failed --> Retry: 允许重试或用户再次触发
    Failed --> StaticFallback: 保留服务端 HTML
    Mismatch --> Interactive: Vue 修正或重新建立节点

逐步说明:

  1. SSR_HTML:服务端已经生成组件 HTML;
  2. Waiting:客户端入口已加载,但组件暂不水合;
  3. Loading:触发条件满足,开始加载组件 chunk;
  4. Hydrating:代码加载完成,Vue 将组件与现有 DOM 对应起来;
  5. Interactive:事件监听、响应式状态和生命周期已经生效;
  6. Failed:chunk 加载失败、网络断开或模块版本不一致;
  7. Mismatch:客户端首次输出与服务器 HTML 不一致。

失败路径一:chunk 加载失败

生产部署中的常见情况是:

  1. 用户打开旧页面;
  2. 发布新版本;
  3. 延迟加载的旧 chunk 被清理;
  4. 用户滚动到组件;
  5. 浏览器请求旧 URL,返回 404。

组件此时不能只停留在无限 Loading。应根据业务重要性选择:

  • 展示服务端 HTML,允许只读;
  • 提供“重试”按钮;
  • 刷新页面获取新资源;
  • 记录构建版本、chunk URL 和错误信息。

对评论区而言,保留只读内容可能可以接受;对支付按钮而言,必须明确告诉用户交互不可用,不能静默失败。

失败路径二:水合不匹配

下面的组件在 SSR 中容易产生不稳定结果:

<script setup lang="ts">
const id = Math.random()
</script>

<template>
  <p>{{ id }}</p>
</template>

服务端和客户端分别执行 Math.random(),结果几乎必然不同。

正确方向是让随机值由服务端生成并序列化:

type Props = {
  requestId: string
}
<script setup lang="ts">
defineProps<Props>()
</script>

<template>
  <p>请求编号:{{ requestId }}</p>
</template>

如果某段内容必须依赖浏览器环境,应把它设计为水合后的更新,而不是服务端和客户端首次渲染都各自计算不同结果:

<script setup lang="ts">
import { ref, onMounted } from 'vue'

const width = ref<number | null>(null)

onMounted(() => {
  width.value = window.innerWidth
})
</script>

<template>
  <p v-if="width === null">正在读取窗口宽度</p>
  <p v-else>窗口宽度:{{ width }}</p>
</template>

这里服务端和客户端初始输出都是“正在读取窗口宽度”,水合完成后再更新为真实值。


九、可运行的 Vue 3.5+ 示例

下面给出一个最小的组件代码示例,重点演示渐进式水合,而不是完整的 SSR 服务端工程。它需要:

  • Vue 3.5+;
  • Vite;
  • 一个支持 Vue SSR 的服务端入口;
  • Comments.vueSearchBox.vueFooterEffects.vue 存在。

1. 交互组件

<!-- src/components/Comments.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const props = defineProps<{
  initialComments: Array<{
    id: string
    author: string
    content: string
  }>
}>()

const comments = ref(props.initialComments)
const draft = ref('')
const submitting = ref(false)
const errorMessage = ref('')

async function submitComment() {
  const content = draft.value.trim()

  if (!content || submitting.value) {
    return
  }

  submitting.value = true
  errorMessage.value = ''

  try {
    const response = await fetch('/api/comments', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ content }),
    })

    if (!response.ok) {
      throw new Error(`评论提交失败:HTTP ${response.status}`)
    }

    const created = (await response.json()) as {
      id: string
      author: string
      content: string
    }

    comments.value.push(created)
    draft.value = ''
  } catch (error) {
    errorMessage.value =
      error instanceof Error ? error.message : '未知错误'
  } finally {
    submitting.value = false
  }
}
</script>

<template>
  <section aria-labelledby="comments-title">
    <h2 id="comments-title">评论</h2>

    <ul>
      <li v-for="comment in comments" :key="comment.id">
        <strong>{{ comment.author }}</strong>
        <p>{{ comment.content }}</p>
      </li>
    </ul>

    <form @submit.prevent="submitComment">
      <label>
        添加评论
        <textarea v-model="draft" :disabled="submitting" />
      </label>

      <button type="submit" :disabled="submitting || !draft.trim()">
        {{ submitting ? '提交中…' : '提交评论' }}
      </button>

      <p v-if="errorMessage" role="alert">
        {{ errorMessage }}
      </p>
    </form>
  </section>
</template>

组件的 initialComments 用于 SSR 输出初始列表;提交操作仍然必须由服务端 API 验证用户身份和权限。客户端不能因为自己持有 author、评论 ID 或其他字段就绕过服务端校验。

2. 使用可见时水合

<!-- src/pages/ArticlePage.vue -->
<script setup lang="ts">
import { defineAsyncComponent, hydrateOnVisible } from 'vue'

const Comments = defineAsyncComponent({
  loader: () => import('../components/Comments.vue'),
  hydrate: hydrateOnVisible({
    rootMargin: '300px',
  }),
})

const initialComments = [
  {
    id: 'c-1',
    author: 'Alice',
    content: '这是一条服务端渲染的评论。',
  },
]
</script>

<template>
  <main>
    <article>
      <h1>一篇文章</h1>
      <p>文章正文在服务端生成,页面打开后可以立即阅读。</p>
    </article>

    <Comments :initial-comments="initialComments" />
  </main>
</template>

预期流程是:

  1. SSR 输出文章和评论列表;
  2. 浏览器无需等待评论组件水合即可显示评论;
  3. 用户向下滚动,评论区进入视口前 300 像素;
  4. 浏览器加载 Comments 对应 chunk;
  5. Vue 在现有评论 DOM 上完成水合;
  6. 用户可以提交评论。

如果评论区非常重要,例如用户打开页面后的主要任务就是发表评论,就不应使用 hydrateOnVisible 作为默认策略,而应立即水合,或采用交互触发并确保用户可以获得明确的加载反馈。


十、如何设计真正的 Islands 架构

Vue 核心提供的是组件系统、SSR 和水合能力;完整的 Islands 页面编排通常由上层框架或应用构建约定完成。实现一个岛屿系统,至少需要以下组件:

服务端渲染器
├── 生成岛屿 HTML
├── 写入 island 类型和 props
└── 生成客户端入口清单

客户端引导器
├── 扫描 island 标记
├── 根据类型动态加载组件
├── 反序列化 props
├── 选择 hydrate 时机
└── 记录加载和水合错误

边界协议
├── props schema
├── 事件协议
├── 错误状态
└── 版本标识

一个抽象的 HTML 结果可能是:

<article>
  <h1>文章标题</h1>
  <p>静态文章内容。</p>
</article>

<div
  data-island="search-box"
  data-props='{"placeholder":"搜索文章"}'
>
  <form>
    <input placeholder="搜索文章">
    <button type="submit">搜索</button>
  </form>
</div>

对应的客户端引导器可以是:

type IslandManifest = {
  'search-box': () => Promise<{
    mount: (element: Element, props: unknown) => void
  }>
}

const manifest: IslandManifest = {
  'search-box': () => import('./islands/search-box.client'),
}

async function bootIslands() {
  const elements = document.querySelectorAll<HTMLElement>(
    '[data-island]',
  )

  for (const element of elements) {
    const name = element.dataset.island
    if (!name || !(name in manifest)) {
      console.error(`未知岛屿类型:${name}`)
      continue
    }

    try {
      const rawProps = element.dataset.props ?? '{}'
      const props: unknown = JSON.parse(rawProps)

      const module = await manifest[
        name as keyof IslandManifest
      ]()

      module.mount(element, props)
    } catch (error) {
      console.error(`岛屿 ${name} 启动失败`, {
        error,
        element,
      })
    }
  }
}

void bootIslands()

这个示例展示了机制,但生产实现还需要补充:

  • props schema 校验;
  • HTML 属性中的安全序列化;
  • island 名称与构建 manifest 的一致性;
  • chunk 失败重试;
  • 版本不匹配处理;
  • 可观测性;
  • 服务端和客户端组件的渲染一致性。

需要特别注意,服务端和客户端必须使用相同的组件输出规则。如果服务端输出的是一个 <form>,客户端首次渲染却输出一个 <div>,独立岛屿并不会自动消除水合不匹配。


十一、Nuxt 中如何理解这些能力

Nuxt 在 Vue SSR 之上提供了页面级 SSR、数据获取、代码分割、懒加载和 Islands 相关能力。具体 API 会随 Nuxt 版本变化,因此应以当前项目版本对应的 Nuxt Documentation 为准,不能把某个版本的实验性能力当作 Vue 核心 API。

在 Nuxt 项目中可以使用 Vue 3.5 的异步组件水合策略:

<script setup lang="ts">
import {
  defineAsyncComponent,
  hydrateOnInteraction,
  hydrateOnVisible,
} from 'vue'

const ProductGallery = defineAsyncComponent({
  loader: () => import('~/components/ProductGallery.vue'),
  hydrate: hydrateOnInteraction(['click', 'keydown']),
})

const Reviews = defineAsyncComponent({
  loader: () => import('~/components/Reviews.vue'),
  hydrate: hydrateOnVisible({ rootMargin: '250px' }),
})
</script>

<template>
  <div>
    <ProductGallery />
    <Reviews />
  </div>
</template>

这段代码的重点是“异步组件 + 水合策略”,不等于整个页面自动变成多个独立 Island。

Nuxt 的 Island 相关组件和服务端组件能力还涉及:

  • 哪些组件只在服务端执行;
  • 客户端是否需要为该组件生成入口;
  • props 如何序列化;
  • island 是否通过独立请求获得内容;
  • 页面导航后 island 如何更新;
  • 服务端组件能否访问客户端状态。

因此,在 Nuxt 中选择 Islands 时,应先确认当前 Nuxt 版本支持的具体语义,再判断它是:

  1. 纯服务端组件;
  2. 独立的可交互 island;
  3. 延迟加载的异步组件;
  4. 通过网络请求更新的服务端渲染片段。

这几个概念在使用体验上相似,但数据流和故障路径不同。


十二、什么时候适合使用 Islands

1. 内容型页面,交互分布稀疏

以下页面通常适合 Islands:

  • 博客;
  • 文档;
  • 新闻详情;
  • 营销落地页;
  • 帮助中心;
  • 商品详情页;
  • 搜索结果页中只有少量交互控件的场景。

它们的共同特点是:

静态内容面积交互内容面积\text{静态内容面积} \gg \text{交互内容面积}

如果文章有几百个段落,但只有一个搜索框、一个目录折叠按钮和一个评论区,让整篇文章进入同一个客户端 Vue 根节点通常没有必要。

2. 交互单元之间可以隔离

如果组件只依赖:

  • 自己的 props;
  • 自己的本地状态;
  • 明确的 API;
  • 明确的浏览器事件;

那么它适合成为一个岛屿。

例如:

  • 图片放大器;
  • 评论表单;
  • 购物车按钮;
  • 分享面板;
  • 日期选择器;
  • 地图预览;
  • 代码复制按钮。

3. 首屏关键路径与非关键路径差异明显

如果首屏需要立即使用搜索框,但评论区、推荐区和页脚特效可以稍后加载,可以将它们分别使用:

  • 立即水合;
  • 进入视口水合;
  • 空闲时水合;
  • 用户交互时水合。

这比让整个页面统一等待所有客户端代码完成,更符合用户实际任务顺序。


十三、什么时候不适合拆成 Islands

1. 组件共享大量同步状态

例如一个后台管理系统包含:

筛选条件
├── URL 参数
├── 左侧筛选器
├── 顶部汇总卡片
├── 表格分页
├── 批量选择
├── 导出任务
└── 实时通知

这些部分经常互相更新。如果拆成多个独立岛屿,原本简单的响应式数据流会变成:

DOM 事件
→ 浏览器事件总线
→ 共享持久化状态
→ 另一个岛屿重新读取
→ 异步竞态处理

这时多个岛屿的边界成本可能超过节省的水合成本。一个统一的 Vue 应用根和清晰的 store 反而更容易维护。

2. 用户进入页面后立即需要全部交互

例如:

  • 复杂编辑器;
  • 交易终端;
  • 实时协作应用;
  • 工作流设计器;
  • 全屏地图应用;
  • 需要键盘快捷键覆盖整页的应用。

如果用户在首屏后马上操作大部分区域,延迟水合只会把成本推迟到用户操作时,甚至造成交互等待。

3. 边界之间需要大量父子通信

如果一个组件需要频繁调用另一个岛屿内部方法,或者依赖对方的同步响应式状态,那么这通常说明边界划分不自然。

可以用一个简单判断:

边界收益=被移除或延后的成本通信与一致性成本\text{边界收益} = \text{被移除或延后的成本} - \text{通信与一致性成本}

当右侧结果不为正时,拆分就失去意义。


十四、常见误解与失败表现

误解一:组件懒加载就是 Islands

错误理解:

只要使用 defineAsyncComponent,页面就是 Islands。

实际情况是,异步组件仍然可以位于同一个 Vue 根应用中。它解决的是模块加载时机和水合时机,不自动产生独立状态边界或独立客户端根。

误解二:SSR 页面不需要客户端 JavaScript

SSR 只保证服务端生成 HTML。以下能力仍然需要客户端代码:

  • 点击事件;
  • 输入响应;
  • 前端路由;
  • 浏览器 API;
  • 局部状态更新;
  • 客户端缓存;
  • 即时表单校验。

如果组件有交互,就必须让某个客户端运行时在适当时间接管它。

误解三:延迟水合等于用户操作不会丢失

水合策略只定义“何时开始水合”。在水合完成前发生的事件是否自动重放,取决于具体实现和版本,不能默认所有事件都可靠保留。

因此要测试:

  1. 组件刚进入页面时立即点击;
  2. 网络较慢时连续点击;
  3. chunk 加载中按键或提交表单;
  4. 水合失败后再次操作;
  5. 页面从后台标签页恢复后操作。

对重要表单,应提供原生 HTML 降级行为、明确的 disabled/loading 状态,或者让关键交互立即水合。

误解四:减少水合节点就一定减少总下载量

如果组件仍然需要客户端交互,只是延迟水合,那么它的代码通常仍然要在之后下载。渐进式水合主要降低初始主线程压力,不一定降低总 JavaScript 传输量。

只有当某些区域变成纯服务端 HTML,或真正不再发送对应客户端代码时,才会减少该区域的客户端 JavaScript。

误解五:岛屿之间可以随意共享单例 store

独立岛屿通常不共享父级组件上下文,也不应依赖默认的 provide/inject 关系。即使通过打包器共享了同一个模块,也还要确认:

  • 是否确实运行在同一个 JavaScript 上下文;
  • 是否创建了多个 Vue 应用实例;
  • 服务端请求之间是否发生状态泄漏;
  • 多标签页和多窗口是否需要同步。

服务端 SSR 尤其不能把带有用户数据的可变模块级对象当作全局 store,否则可能发生请求之间的数据串读。


十五、诊断方法:先确认瓶颈属于哪一层

1. 诊断下载瓶颈

检查浏览器 Network 面板:

  • 首屏加载了哪些 JS chunk;
  • 评论或页脚代码是否在首屏就被请求;
  • 是否存在重复依赖;
  • chunk 是否因为预加载而提前下载;
  • 缓存未命中时的传输大小和请求数量。

如果低优先级组件仍然被入口静态导入,它可能无法实现真正的代码延迟:

// 这会把组件纳入入口依赖图
import Comments from './Comments.vue'

而异步导入才允许构建工具拆分:

const Comments = defineAsyncComponent(() => import('./Comments.vue'))

具体是否拆 chunk 仍由 Vite/Rollup 的构建结果决定,应以产物和 Network 面板验证。

2. 诊断水合瓶颈

检查 Performance 面板中的主线程任务:

  • Vue 应用启动时间;
  • 组件实例创建;
  • 大量 DOM 节点遍历;
  • 同步计算和事件绑定;
  • 水合期间是否阻塞输入。

可以在客户端入口附近记录时间:

const start = performance.now()

app.mount('#app')

performance.measure(
  'vue-mount',
  {
    start,
    end: performance.now(),
  },
)

对于异步岛屿,还应在:

  • 开始加载 chunk;
  • chunk 加载完成;
  • 开始水合;
  • 水合完成;
  • 首次交互处理完成;

这些节点记录标记,才能区分“下载慢”“执行慢”和“水合慢”。

3. 诊断水合不匹配

重点检查:

  • Date.now()Math.random()
  • windowdocumentlocalStorage
  • 服务端和客户端时区、语言环境;
  • 随机 ID;
  • 异步数据初始值;
  • 条件渲染顺序;
  • 服务端返回数据和客户端重新获取数据的差异;
  • 第三方库是否在初始化时直接修改 DOM。

不要仅仅通过关闭警告来掩盖问题。应先保证服务端和客户端的初始输出一致,再把浏览器专属变化放到水合后的生命周期中。

4. 诊断跨岛屿竞态

一个典型故障是事件早于监听器:

购物车岛屿水合
→ 派发 cart:updated
→ Header 岛屿仍未水合
→ Header 没有收到事件
→ 购物车数字停留在旧值

验证方法是人为延迟某个岛屿:

await new Promise((resolve) => {
  setTimeout(resolve, 3000)
})

然后测试:

  • 发送方先完成时是否丢事件;
  • 接收方先完成时是否正确处理;
  • 刷新页面后状态是否从 Cookie 或 API 恢复;
  • 多个请求并发返回时是否出现旧数据覆盖新数据。

如果系统要求最终一致,不能只依赖一次性 DOM 事件,必须有可恢复的状态源。


十六、工程取舍:如何选择边界和策略

可以按以下顺序做设计,而不是先决定“全站 Islands”或“全站渐进式水合”。

第一步:标注用户任务

对每个区域回答:

  • 用户是否会在首屏立即操作?
  • 操作是否影响购买、支付、权限或数据提交?
  • 没有 JavaScript 时是否仍应可读?
  • 组件是否必须与其他区域同步更新?

首屏关键交互通常应立即水合;只读内容不应为了组件化而发送客户端 JavaScript。

第二步:计算交互密度

交互密度可以用一个粗略比例表示:

D=需要客户端状态和事件的区域页面总区域D = \frac{\text{需要客户端状态和事件的区域}}{\text{页面总区域}}

DD 很低时,Islands 更有价值;当 DD 接近 1 时,整个页面本身就是一个应用,强行拆岛通常会增加通信成本。

这个比例不是自动决策规则,因为一个很小的支付组件也可能具有极高业务重要性。

第三步:选择水合触发条件

场景 常见选择 原因
首屏搜索框 立即或交互触发 用户可能马上使用
首屏购物车按钮 立即水合 业务关键且可见
页面底部评论 可见时水合 不必阻塞首屏
页脚动画 空闲时水合 对核心任务不重要
必须支持无 JS 的表单 服务端表单优先 交互失败时仍可提交
全屏编辑器 立即水合 用户进入后即使用

第四步:给每个边界设计恢复路径

每个延迟或独立组件至少要有:

  • 服务端 HTML 的可读状态;
  • 加载中状态;
  • 加载失败状态;
  • 重试或刷新策略;
  • 服务端 API 校验;
  • 构建版本和错误日志。

如果这些状态没有设计,页面在弱网、发布切换或浏览器恢复场景中就容易出现“看起来有按钮,但点击没有反应”的失败体验。


十七、最终判断标准

Vue Islands 和渐进式水合的核心价值,不是把“SSR”换成另一个名词,而是重新划分客户端 JavaScript 的责任范围和执行时间。

可以用下面的判断概括:

  • 页面大部分是内容,只有少量交互:优先考虑 Islands;
  • 页面仍是一棵统一 Vue 应用树,但部分组件不必立即可交互:优先考虑渐进式水合;
  • 交互组件相互依赖很深:减少边界,保留统一应用状态;
  • 用户打开页面后马上使用大部分功能:不要过度延迟水合;
  • 关键业务不能接受事件丢失或 chunk 失败后无反馈:优先保证可靠交互,再优化延迟;
  • 性能问题来自下载量:检查代码分割和不必要的客户端代码;
  • 性能问题来自主线程执行:检查水合范围、组件初始化和同步计算;
  • 性能问题来自数据或 DOM 不一致:修复 SSR 与客户端首次渲染的确定性。

在 Vue 3.5+ 中,异步组件水合策略提供了较直接的渐进式水合能力;而完整的 Islands 架构通常还需要 Nuxt 或其他上层工具负责服务端渲染边界、客户端入口、序列化和构建产物管理。两者都应根据交互边界、状态依赖、首屏任务和故障恢复能力来选择,而不是仅依据组件数量或页面结构进行拆分。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。