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 期望:
- 服务端 HTML 与客户端首次渲染结果一致;
- DOM 层级和文本内容可以被对应起来;
- 服务端和客户端使用相同的初始状态;
- 组件可以在客户端建立事件监听和生命周期。
如果客户端第一次渲染的结果不同,就会出现 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')
这时 Header、Footer 即使没有任何交互,也位于同一棵需要被客户端 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、事件监听或空闲调度环境;不支持时应提供降级策略。
其行为可以分解为:
- 服务端执行异步组件,输出评论区、搜索框和页脚效果的初始 HTML;
- 浏览器先显示这些 HTML;
- 页面初始化时,组件不会全部立即完成水合;
SearchBox在用户聚焦或点击时触发水合;Comments在进入视口前约 200px 时触发水合;FooterEffects最迟在空闲调度到达 2 秒等待条件后触发水合;- 水合完成后,组件才拥有 Vue 事件、响应式更新和生命周期。
这里的“延迟”不是延迟 HTML,而是延迟客户端接管。
3. 该 API 的版本边界
hydrateOnIdle、hydrateOnVisible、hydrateOnInteraction 属于 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>
这个岛屿可以独立工作,因为它的输入是明确的 productId 和 initialQuantity,输出是 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. 岛屿边界实际上是协议边界
跨边界的数据至少要回答四个问题:
- 输入是什么:props、序列化状态、URL、Cookie 还是接口数据?
- 输出是什么:DOM 事件、HTTP 请求、浏览器事件还是重新导航?
- 时序是什么:发送方和接收方谁先水合?
- 失败怎么办:接收方未加载、网络失败、数据过期时如何恢复?
如果一个页面上的组件必须频繁共享几十个响应式状态,且需要大量父子事件传递,那么这些组件可能不适合被拆成独立岛屿。边界越多,协议越多;协议越多,调试和一致性成本越高。
六、序列化是 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. 初始成本的组成
可以把页面首次可交互前的成本近似写成:
其中:
- :下载 JavaScript 的网络成本;
- :浏览器解析 JavaScript 的成本;
- :执行模块初始化和应用启动的成本;
- :遍历 SSR DOM、创建组件实例、恢复响应式系统和绑定事件的成本。
普通 SSR 应用可能把许多页面组件都放在同一个客户端入口中:
即使第 个组件当前不可见、没有交互,仍可能贡献 。
如果采用渐进式水合,初始阶段只处理关键组件集合 :
其余组件的成本被推迟到后续时刻:
这说明渐进式水合主要改变的是成本发生的时间,不一定减少总成本。
2. Islands 还能减少客户端代码覆盖范围
如果某个区域完全是静态 HTML,那么它不仅不需要初始水合,也不需要对应的客户端组件代码:
其中 是需要客户端能力的岛屿集合。
一个普通 SPA 式页面可能把所有组件都放入:
而岛屿架构可能只发送:
这里不能简单把每个 chunk 的大小相加作为真实网络成本,因为还要考虑:
- chunk 压缩;
- HTTP/2 或 HTTP/3 并发;
- 缓存命中;
- 公共依赖重复或共享;
- 预加载和预取;
- 模块图解析成本。
所以公式适合说明因果方向,不代表固定的性能数字。
3. 一个完整算例
假设一个文章页包含:
| 区域 | 是否交互 | 假设水合成本 |
|---|---|---|
| Header | 否 | 1 |
| Article | 否 | 3 |
| SearchBox | 是 | 4 |
| Comments | 是 | 8 |
| SharePanel | 是 | 2 |
| Footer | 否 | 1 |
如果整个页面采用单一 Vue 根节点并立即水合:
如果只让首屏搜索框和分享面板立即水合,评论区进入视口时再水合:
评论区的成本 8 被延后。用户不滚动到评论区时,它可能完全不发生。
如果进一步采用 Islands,并让非交互内容不进入客户端 Vue 根节点:
这只是一个用于推导的相对模型,不是浏览器实际毫秒数。实际结果还取决于:
- 组件代码量;
- 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 修正或重新建立节点
逐步说明:
- SSR_HTML:服务端已经生成组件 HTML;
- Waiting:客户端入口已加载,但组件暂不水合;
- Loading:触发条件满足,开始加载组件 chunk;
- Hydrating:代码加载完成,Vue 将组件与现有 DOM 对应起来;
- Interactive:事件监听、响应式状态和生命周期已经生效;
- Failed:chunk 加载失败、网络断开或模块版本不一致;
- Mismatch:客户端首次输出与服务器 HTML 不一致。
失败路径一:chunk 加载失败
生产部署中的常见情况是:
- 用户打开旧页面;
- 发布新版本;
- 延迟加载的旧 chunk 被清理;
- 用户滚动到组件;
- 浏览器请求旧 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.vue、SearchBox.vue和FooterEffects.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>
预期流程是:
- SSR 输出文章和评论列表;
- 浏览器无需等待评论组件水合即可显示评论;
- 用户向下滚动,评论区进入视口前 300 像素;
- 浏览器加载
Comments对应 chunk; - Vue 在现有评论 DOM 上完成水合;
- 用户可以提交评论。
如果评论区非常重要,例如用户打开页面后的主要任务就是发表评论,就不应使用 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 版本支持的具体语义,再判断它是:
- 纯服务端组件;
- 独立的可交互 island;
- 延迟加载的异步组件;
- 通过网络请求更新的服务端渲染片段。
这几个概念在使用体验上相似,但数据流和故障路径不同。
十二、什么时候适合使用 Islands
1. 内容型页面,交互分布稀疏
以下页面通常适合 Islands:
- 博客;
- 文档;
- 新闻详情;
- 营销落地页;
- 帮助中心;
- 商品详情页;
- 搜索结果页中只有少量交互控件的场景。
它们的共同特点是:
如果文章有几百个段落,但只有一个搜索框、一个目录折叠按钮和一个评论区,让整篇文章进入同一个客户端 Vue 根节点通常没有必要。
2. 交互单元之间可以隔离
如果组件只依赖:
- 自己的 props;
- 自己的本地状态;
- 明确的 API;
- 明确的浏览器事件;
那么它适合成为一个岛屿。
例如:
- 图片放大器;
- 评论表单;
- 购物车按钮;
- 分享面板;
- 日期选择器;
- 地图预览;
- 代码复制按钮。
3. 首屏关键路径与非关键路径差异明显
如果首屏需要立即使用搜索框,但评论区、推荐区和页脚特效可以稍后加载,可以将它们分别使用:
- 立即水合;
- 进入视口水合;
- 空闲时水合;
- 用户交互时水合。
这比让整个页面统一等待所有客户端代码完成,更符合用户实际任务顺序。
十三、什么时候不适合拆成 Islands
1. 组件共享大量同步状态
例如一个后台管理系统包含:
筛选条件
├── URL 参数
├── 左侧筛选器
├── 顶部汇总卡片
├── 表格分页
├── 批量选择
├── 导出任务
└── 实时通知
这些部分经常互相更新。如果拆成多个独立岛屿,原本简单的响应式数据流会变成:
DOM 事件
→ 浏览器事件总线
→ 共享持久化状态
→ 另一个岛屿重新读取
→ 异步竞态处理
这时多个岛屿的边界成本可能超过节省的水合成本。一个统一的 Vue 应用根和清晰的 store 反而更容易维护。
2. 用户进入页面后立即需要全部交互
例如:
- 复杂编辑器;
- 交易终端;
- 实时协作应用;
- 工作流设计器;
- 全屏地图应用;
- 需要键盘快捷键覆盖整页的应用。
如果用户在首屏后马上操作大部分区域,延迟水合只会把成本推迟到用户操作时,甚至造成交互等待。
3. 边界之间需要大量父子通信
如果一个组件需要频繁调用另一个岛屿内部方法,或者依赖对方的同步响应式状态,那么这通常说明边界划分不自然。
可以用一个简单判断:
当右侧结果不为正时,拆分就失去意义。
十四、常见误解与失败表现
误解一:组件懒加载就是 Islands
错误理解:
只要使用
defineAsyncComponent,页面就是 Islands。
实际情况是,异步组件仍然可以位于同一个 Vue 根应用中。它解决的是模块加载时机和水合时机,不自动产生独立状态边界或独立客户端根。
误解二:SSR 页面不需要客户端 JavaScript
SSR 只保证服务端生成 HTML。以下能力仍然需要客户端代码:
- 点击事件;
- 输入响应;
- 前端路由;
- 浏览器 API;
- 局部状态更新;
- 客户端缓存;
- 即时表单校验。
如果组件有交互,就必须让某个客户端运行时在适当时间接管它。
误解三:延迟水合等于用户操作不会丢失
水合策略只定义“何时开始水合”。在水合完成前发生的事件是否自动重放,取决于具体实现和版本,不能默认所有事件都可靠保留。
因此要测试:
- 组件刚进入页面时立即点击;
- 网络较慢时连续点击;
- chunk 加载中按键或提交表单;
- 水合失败后再次操作;
- 页面从后台标签页恢复后操作。
对重要表单,应提供原生 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();window、document、localStorage;- 服务端和客户端时区、语言环境;
- 随机 ID;
- 异步数据初始值;
- 条件渲染顺序;
- 服务端返回数据和客户端重新获取数据的差异;
- 第三方库是否在初始化时直接修改 DOM。
不要仅仅通过关闭警告来掩盖问题。应先保证服务端和客户端的初始输出一致,再把浏览器专属变化放到水合后的生命周期中。
4. 诊断跨岛屿竞态
一个典型故障是事件早于监听器:
购物车岛屿水合
→ 派发 cart:updated
→ Header 岛屿仍未水合
→ Header 没有收到事件
→ 购物车数字停留在旧值
验证方法是人为延迟某个岛屿:
await new Promise((resolve) => {
setTimeout(resolve, 3000)
})
然后测试:
- 发送方先完成时是否丢事件;
- 接收方先完成时是否正确处理;
- 刷新页面后状态是否从 Cookie 或 API 恢复;
- 多个请求并发返回时是否出现旧数据覆盖新数据。
如果系统要求最终一致,不能只依赖一次性 DOM 事件,必须有可恢复的状态源。
十六、工程取舍:如何选择边界和策略
可以按以下顺序做设计,而不是先决定“全站 Islands”或“全站渐进式水合”。
第一步:标注用户任务
对每个区域回答:
- 用户是否会在首屏立即操作?
- 操作是否影响购买、支付、权限或数据提交?
- 没有 JavaScript 时是否仍应可读?
- 组件是否必须与其他区域同步更新?
首屏关键交互通常应立即水合;只读内容不应为了组件化而发送客户端 JavaScript。
第二步:计算交互密度
交互密度可以用一个粗略比例表示:
当 很低时,Islands 更有价值;当 接近 1 时,整个页面本身就是一个应用,强行拆岛通常会增加通信成本。
这个比例不是自动决策规则,因为一个很小的支付组件也可能具有极高业务重要性。
第三步:选择水合触发条件
| 场景 | 常见选择 | 原因 |
|---|---|---|
| 首屏搜索框 | 立即或交互触发 | 用户可能马上使用 |
| 首屏购物车按钮 | 立即水合 | 业务关键且可见 |
| 页面底部评论 | 可见时水合 | 不必阻塞首屏 |
| 页脚动画 | 空闲时水合 | 对核心任务不重要 |
| 必须支持无 JS 的表单 | 服务端表单优先 | 交互失败时仍可提交 |
| 全屏编辑器 | 立即水合 | 用户进入后即使用 |
第四步:给每个边界设计恢复路径
每个延迟或独立组件至少要有:
- 服务端 HTML 的可读状态;
- 加载中状态;
- 加载失败状态;
- 重试或刷新策略;
- 服务端 API 校验;
- 构建版本和错误日志。
如果这些状态没有设计,页面在弱网、发布切换或浏览器恢复场景中就容易出现“看起来有按钮,但点击没有反应”的失败体验。
十七、最终判断标准
Vue Islands 和渐进式水合的核心价值,不是把“SSR”换成另一个名词,而是重新划分客户端 JavaScript 的责任范围和执行时间。
可以用下面的判断概括:
- 页面大部分是内容,只有少量交互:优先考虑 Islands;
- 页面仍是一棵统一 Vue 应用树,但部分组件不必立即可交互:优先考虑渐进式水合;
- 交互组件相互依赖很深:减少边界,保留统一应用状态;
- 用户打开页面后马上使用大部分功能:不要过度延迟水合;
- 关键业务不能接受事件丢失或 chunk 失败后无反馈:优先保证可靠交互,再优化延迟;
- 性能问题来自下载量:检查代码分割和不必要的客户端代码;
- 性能问题来自主线程执行:检查水合范围、组件初始化和同步计算;
- 性能问题来自数据或 DOM 不一致:修复 SSR 与客户端首次渲染的确定性。
在 Vue 3.5+ 中,异步组件水合策略提供了较直接的渐进式水合能力;而完整的 Islands 架构通常还需要 Nuxt 或其他上层工具负责服务端渲染边界、客户端入口、序列化和构建产物管理。两者都应根据交互边界、状态依赖、首屏任务和故障恢复能力来选择,而不是仅依据组件数量或页面结构进行拆分。
系列导航与关联阅读
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论