07|Nuxt 4 与延迟水合:把交互预算花在会动的地方
第八章 / 07。目标:让服务端 HTML 先可读,并为非关键组件明确“何时才需要变成交互组件”。
先有服务端可读的 Comments
沿用 Nuxt 最小环境。保存为 app/components/Comments.vue;文件名决定自动注册名 Comments,LazyComments 是按需加载版本,不需要自己定义另一个组件。
<script setup lang="ts">
const props = defineProps<{ articleId: string }>()
const draft = ref('')
const inputEvents = ref(0)
</script>
<template>
<section aria-label="评论区">
<p>文章 {{ props.articleId }}:已有两条评论</p>
<label>评论<input v-model="draft" @input="inputEvents++"></label>
<output data-testid="draft">{{ draft }}</output>
<output data-testid="input-events">{{ inputEvents }}</output>
</section>
</template>app/pages/comments.vue:
<script setup lang="ts">
const unrelated = ref(0)
const hydrated = ref(false)
function onHydrated() {
hydrated.value = true
performance.mark('comments:hydrated')
}
</script>
<template>
<main>
<h1>评论实验</h1>
<button @click="unrelated++">无关计数 {{ unrelated }}</button>
<LazyComments article-id="1" hydrate-on-interaction="focusin" @hydrated="onHydrated" />
<p data-testid="hydrated">{{ hydrated ? 'ready' : 'waiting' }}</p>
</main>
</template>启动或构建预览后直接访问 /comments。首次响应应已有摘要和输入;点击无关计数仍为 waiting;把焦点移入评论输入才转为 ready,之后输入的字同时出现在 output。performance 在事件函数里访问,函数只在客户端事件发生时执行,不依赖模板把浏览器全局当组件属性解析。
这里使用 focusin,因为监听器需要在组件根 section 观察内部 input 的聚焦;focus 不冒泡,若直接用在这个根结构上会错过子输入事件。水合完成后会重放触发事件,业务若监听同一事件应避免重复副作用。输入值仍要验证,不把“事件已重放”当成所有输入设备均正确的证明。
自动注册的组件加 Lazy 前缀后,每个实例选择一种策略,@hydrated 标记完成。本节使用显式模板属性,不用动态组件或整对象 v-bind,以便 Nuxt 编译器识别策略。
策略不是同义词
| 策略 | 触发 | 合适场景 | 必测失败 |
|---|---|---|---|
hydrate-on-visible | IntersectionObserver 命中 | 视口下方评论/图表 | 快速滚动到达时无空白 |
hydrate-on-idle | 空闲回调或超时 | 非关键但希望尽早可用 | 主线程繁忙时仍有上限 |
hydrate-on-interaction | 指定事件,完成后重放 | 点击才用的部件 | 首次事件只生效一次且值不丢 |
hydrate-on-media-query | 媒体查询匹配 | 仅特定布局交互 | 条件改变后的产品语义 |
hydrate-never | 不水合 | 永远静态的服务端内容 | 不存在按钮等交互合同 |
lazy import 与 lazy hydration 也不同:前者决定何时下载代码,后者决定何时接管 SSR DOM。组件被 v-if=false 根本未渲染时,通常先用条件 + Lazy 即可。
会提前打破计划的条件
延迟水合组件的 prop 变化会触发立即水合;共享 v-model 或频繁变化的父状态可能让策略形同虚设。hydrate-never 也不应放交互组件。包含关键导航、登录、搜索输入的首屏区域应优先保证立即可用。
纯内容路由可以在 routeRules 中组合 prerender 与 noScripts,彻底不发客户端脚本;页面只要有交互岛,就保留脚本并按组件延迟水合。不要为了少量 JS 破坏无 JS 可读性、焦点或第一次点击。
可重放测量
对本页 Comments 记录原始响应中的摘要、初始及聚焦后的 chunk 请求、focusin 发生顺序、@hydrated 时间和输入结果。若把策略改成 visible,另行固定视口与滚动步骤,对比同一组件首次进入观察区的时间。两种实验只改变触发条件,不把浏览器预取行为当成已水合证据。
练习:评论区的首次交互合同
把本例作为测试夹具,SSR 输出评论摘要和一个可聚焦输入。完成第九章 E2E 环境后回访:测试首次 focusin 触发水合,水合后焦点仍在输入且随后键入的文本只出现一次;父级无关计数变化不得通过 prop 传入而提前水合。记录 chunk 请求与 hydrated 事件;不把 chunk 提前预取等同于水合已发生。
参考答案:第 8 章第 07 题。