Nuxt SSR 水合错误处理实践:响应式布局的正确姿势

5 阅读8分钟

一、什么是水合错误 (Hydration Mismatch)

1.1 SSR 渲染流程

在 Nuxt SSR 应用中,页面会经历两次渲染:

服务端 (Node.js)          客户端 (浏览器)
     ↓                        ↓
  渲染 HTML      →      接收 HTML
     ↓                        ↓
  返回字符串            Vue 激活 (Hydrate)
     ↓                        ↓
  发送到客户端          期望 DOM 一致

水合 (Hydration) 是指 Vue 在客户端激活服务端渲染的静态 HTML,将其转换为响应式的动态应用。

1.2 水合错误的产生

当服务端渲染的 HTML 结构与客户端期望的 DOM 结构不一致时,就会触发水合错误:

// ❌ 错误示例:导致水合错误
const isMobile = ref(window.innerWidth <= 768)

// 服务端执行:window 未定义,抛出错误
// 或即使用 typeof 判断:
const isMobile = ref(typeof window !== 'undefined' && window.innerWidth <= 768)

// 服务端渲染:false (没有 window)
//   → 生成 <div class="pc-layout">PC 内容</div>

// 客户端激活:true (实际是手机)
//   → 期望 <div class="mobile-layout">移动内容</div>

// 结果:Vue 发现 DOM 不匹配,抛出 Hydration Mismatch 警告

1.3 水合错误的危害

  1. 控制台警告/错误:影响开发调试,难以定位其他问题
  2. 页面闪烁:用户先看到错误布局,然后突然切换
  3. 强制重新渲染:性能损失,失去 SSR 的性能优势
  4. SEO 问题:搜索引擎抓取到错误的内容结构
  5. 交互异常:事件监听器可能绑定到错误的元素上

二、问题场景:响应式布局

2.1 典型需求

移动端和 PC 端需要不同的布局方案:

布局特点交互组件
移动端单列布局、全屏宽度轮播图、底部菜单、抽屉
PC 端多列布局、固定最大宽度下拉菜单、侧边栏、Sticky 定位

2.2 错误做法

<script setup>
// ❌ 直接判断屏幕宽度
const isMobile = ref(window.innerWidth <= 768)
</script>

<template>
  <!-- 服务端和客户端可能渲染不同的结构 -->
  <div v-if="isMobile">移动端布局</div>
  <div v-else>PC 端布局</div>
</template>

问题

  • 服务端无法获取 window.innerWidth
  • 即使设置默认值,也无法确保与客户端实际情况一致
  • 用户可能在不同设备上访问同一个 URL

三、解决方案:useLayoutReady Composable

3.1 核心思想

延迟渲染:在客户端挂载完成之前,服务端和客户端都不渲染内容,确保初始 DOM 一致。

3.2 完整实现

/**
 * 响应式布局就绪 composable
 * @param breakpoint 断点宽度,≤ 该值认为是移动端页面,> 该值认为是 PC 端页面,默认 768px
 * @returns 布局对象
 */
export function useLayoutReady(breakpoint = 768) {
  // 运行环境不一致,导致水合错误
  // globalThis['innerWidth'] <= breakpoint
  const layout = reactive({
    // 是否为移动端页面
    isMobile: false,
    // 是否已挂载,已执行 onMounted
    mounted: false,
  })

  const layoutCheck = () => {
    if (typeof window !== 'undefined') {
      layout.isMobile = window.innerWidth <= breakpoint
    }
  }

  onMounted(() => {
    // 关键步骤 1:设置挂载标志
    layout.mounted = true
    // 关键步骤 2:执行布局检测
    layoutCheck()
    // 关键步骤 3:监听窗口变化
    window.addEventListener('resize', layoutCheck)
  })

  onUnmounted(() => {
    window.removeEventListener('resize', layoutCheck)
  })

  return layout
}

3.3 工作原理

时间线          服务端               客户端
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
初始化          mounted: false      mounted: false
                isMobile: false     isMobile: false
                ↓                   ↓
渲染            v-if="false"        v-if="false"
                不渲染任何内容        不渲染任何内容
                ↓                   ↓
输出/接收       HTML: (空)          接收到空 HTML
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
                                    ↓
                                 onMounted 钩子
                                    ↓
                                 mounted = true
                                 isMobile = true/falsev-if="true"
                                 渲染正确的布局

关键点

  1. mounted: false 确保服务端和客户端初始渲染一致(都不渲染)
  2. onMounted 只在客户端执行,避免服务端访问 window
  3. layoutCheck 在挂载后立即执行,获取正确的屏幕宽度
  4. resize 监听 支持窗口大小变化时动态调整

四、实践案例

4.1 案例一:产品列表 - 轮播 vs 堆叠卡片

<script lang="ts" setup>
// 使用 1024px 断点(平板以下为移动端)
const layout = useLayoutReady(1024)
</script>

<template lang="pug">
//- 等待挂载完成
.ProductList(v-if="layout.mounted" ref="elRef")
  PageContent
    //- 移动端:轮播图
    Swiper(
      v-if="layout.isMobile"
      :slides="viewData.productList"
      theme="dark"
    )
      template(#default="{ slide: item }")
        ProductItemMobile(:item="item")

    //- PC 端:Sticky 堆叠效果
    .list-container(v-else ref="listContainerRef")
      ProductItem(
        v-for="item in viewData.productList"
        :key="item.key"
        :item="item"
        :stickyTopVar="stickyTopVar"
      )
</template>

设计考量

  • 移动端使用 Swiper 轮播组件,支持触摸滑动
  • PC 端使用 Sticky 定位实现卡片堆叠动画
  • 两种组件的 DOM 结构完全不同,必须避免水合错误

4.2 案例二:新闻详情 - 单列 vs 双列

<script setup>
const layout = useLayoutReady()
</script>

<template>
  <div v-if="layout.mounted">
    <PageContent>
      <div class="max-w-1180px mx-auto">
        <!-- 移动端:单列布局 -->
        <div v-if="layout.isMobile" class="flex justify-between py-5">
          <div class="mt-20px">
            <NewsMain :news="viewData.detail" />
          </div>
        </div>

        <!-- PC 端:双列布局(内容 + 侧边栏) -->
        <div v-else class="flex justify-between py-5">
          <div class="mr-4" style="width: calc(100% - 340px)">
            <NewsMain :news="viewData.detail" />
          </div>
          <NewsAside class="w-340px" :news="viewData.detail" />
        </div>
      </div>
    </PageContent>
  </div>
</template>

设计考量

  • 移动端:全宽单列,取消侧边栏以节省空间
  • PC 端:主内容 + 340px 固定宽度侧边栏
  • 宽度计算依赖客户端环境,必须延迟渲染

4.3 案例三:分类筛选 - 按钮组 vs 下拉菜单

<script lang="ts" setup>
import { useLayoutReady } from '~/composables/useLayoutReady'
import FilterButton from './FilterButton.vue'

const layout = useLayoutReady()
const selectedTag = ref(route.query.tag as string)
</script>

<template lang="pug">
.FilterBar(
  v-if="layout.mounted"
  ref="filterBarRef"
)
  .filter-group.flex.gap-4.justify-between(ref="groupRef")
    <!-- 移动端:横向滚动按钮组 -->
    <template v-if="layout.isMobile">
      <FilterButton
        v-for="group in filterGroups"
        :key="group.key"
        :active="group.tags.includes(selectedTag)"
        :group="group"
        @change="handleSelect({ key: $event })"
      />
    </template>

    <!-- 桌面端:下拉菜单 -->
    <template v-else>
      <Dropdown
        v-for="dropdown in filterGroups"
        :key="dropdown.key"
      >
        <FilterButton :active="dropdown.tags.includes(selectedTag)">
          {{ dropdown.label }}
          <Icon name="chevron-down" class="ml-2" />
        </FilterButton>
        <template #overlay>
          <Menu>
            <MenuItem
              v-for="tag in dropdown.tags"
              :key="tag"
              :class="{ selected: selectedTag === tag }"
              @click="handleSelect({ key: tag })"
            >
              {{ tag }}
            </MenuItem>
          </Menu>
        </template>
      </Dropdown>
    </template>
</template>

设计考量

  • 移动端:横向滚动按钮组,所有选项平铺显示
  • PC 端:Ant Design 下拉菜单,节省空间
  • 组件类型不同(原生 vs 第三方组件),DOM 结构差异大

五、最佳实践总结

5.1 何时使用 useLayoutReady

必须使用的场景:

  1. 移动端和 PC 端使用不同的组件(如 Swiper vs Grid)
  2. 布局结构差异显著(单列 vs 多列、有无侧边栏)
  3. 依赖浏览器 API 判断渲染内容(window.innerWidthmatchMedia
  4. 交互方式不同(按钮 vs 下拉菜单、触摸 vs 鼠标)

不需要使用的场景:

  1. 仅通过 CSS 媒体查询控制样式(不涉及条件渲染)
  2. 组件内容相同,只是尺寸或间距不同
  3. 使用 display: none 隐藏元素(而非 v-if 条件渲染)

5.2 对比其他方案

方案 A:CSS 媒体查询(推荐优先使用)

<template>
  <div class="responsive-layout">
    <div class="sidebar">侧边栏</div>
    <div class="main">主内容</div>
  </div>
</template>

<style>
.responsive-layout {
  display: grid;
  grid-template-columns: 1fr;
}

@media (min-width: 768px) {
  .responsive-layout {
    grid-template-columns: 300px 1fr;
  }
}
</style>

优点

  • ✅ 无水合错误风险
  • ✅ 性能最优(纯 CSS)
  • ✅ 支持 SSR 和静态生成

缺点

  • ❌ 无法改变组件类型(Swiper vs Grid)
  • ❌ DOM 始终存在(即使隐藏),可能影响性能

方案 B:ClientOnly 组件

<template>
  <ClientOnly>
    <div v-if="isMobile">移动端</div>
    <div v-else>PC 端</div>
    <template #fallback>
      <div>加载中...</div>
    </template>
  </ClientOnly>
</template>

优点

  • ✅ Nuxt 官方提供,简单易用
  • ✅ 自动处理服务端渲染

缺点

  • ❌ 服务端渲染占位内容(SEO 不友好)
  • ❌ 不支持响应式更新(窗口 resize 不会触发重新判断)

方案 C:useLayoutReady(本文方案)

优点

  • ✅ 完全避免水合错误
  • ✅ 支持窗口 resize 响应式更新
  • ✅ 可自定义断点宽度
  • ✅ 布局判断逻辑可复用

缺点

  • ❌ 首次渲染略有延迟(需等待 onMounted
  • ❌ 服务端渲染的 HTML 为空(SEO 稍有影响,但可通过 skeleton 优化)

5.3 性能优化建议

1. 使用骨架屏减少 CLS

<template lang="pug">
//- 挂载前显示骨架屏
PageContent(v-if="!layout.mounted")
  a-skeleton(active :paragraph="{ rows: 6 }")

//- 挂载后显示实际内容
.ProductList(v-else-if="layout.mounted")
  Swiper(v-if="layout.isMobile" ...)
  .list-container(v-else ...)
</template>

2. 合理选择断点值

// 移动端优先(大部分用户)
const layout = useLayoutReady(768)

// 平板也视为移动端
const layout = useLayoutReady(1024)

// 针对超宽屏幕的特殊布局
const layout = useLayoutReady(1440)

3. 避免在 layoutCheck 中执行重操作

// ❌ 错误示例
const layoutCheck = () => {
  layout.isMobile = window.innerWidth <= 768
  fetchData() // 每次 resize 都重新请求数据!
}

// ✅ 正确做法:只更新布局状态
const layoutCheck = () => {
  layout.isMobile = window.innerWidth <= 768
}

// 在 watch 中响应布局变化
watch(() => layout.isMobile, (isMobile) => {
  if (isMobile) {
    // 移动端特定逻辑
  }
})

4. 防抖优化 resize 事件

import { useDebounceFn } from '@vueuse/core'

export function useLayoutReady(breakpoint = 768) {
  const layout = reactive({
    isMobile: false,
    mounted: false,
  })

  const layoutCheck = () => {
    if (typeof window !== 'undefined') {
      layout.isMobile = window.innerWidth <= breakpoint
    }
  }

  // 防抖优化,避免频繁触发
  const debouncedLayoutCheck = useDebounceFn(layoutCheck, 150)

  onMounted(() => {
    layout.mounted = true
    layoutCheck() // 立即执行一次
    window.addEventListener('resize', debouncedLayoutCheck)
  })

  onUnmounted(() => {
    window.removeEventListener('resize', debouncedLayoutCheck)
  })

  return layout
}

六、常见问题

Q1: 为什么不能在 setup 中直接判断 process.client

// ❌ 仍然会有水合错误
const isMobile = ref(process.client ? window.innerWidth <= 768 : false)

:虽然服务端会渲染 false,但如果客户端实际是移动端(true),仍会导致 DOM 不匹配。

Q2: v-show 能避免水合错误吗?

<!-- 使用 v-show 而非 v-if -->
<div v-show="isMobile">移动端</div>
<div v-show="!isMobile">PC 端</div>

:可以避免水合错误,但两套 DOM 都会渲染,移动端用户会下载 PC 端的 DOM,影响性能。

Q3: 能否在服务端通过 User-Agent 判断设备类型?

// 服务端中间件
export default defineEventHandler((event) => {
  const ua = getHeader(event, 'user-agent')
  const isMobile = /mobile/i.test(ua)
  // 设置到 cookie 或 response header
})

:可以,但:

  1. User-Agent 不可靠(iPad 显示为桌面,可被修改)
  2. 无法响应窗口 resize(用户旋转屏幕、调整浏览器窗口)
  3. 增加服务端逻辑复杂度

useLayoutReady 方案更简单可靠。

Q4: 为什么不用 @media (hover: hover) 判断触摸设备?

/* 仅在支持 hover 的设备显示(通常是 PC) */
@media (hover: hover) {
  .pc-only { display: block; }
}

:这是样式层面的判断,适合纯 CSS 方案。但无法改变组件类型(如 Swiper vs Grid),而 useLayoutReady 提供了 JS 层面的条件渲染能力

七、总结

核心要点

  1. 水合错误的本质:服务端和客户端渲染的 DOM 结构不一致
  2. 响应式布局的挑战:服务端无法获取屏幕宽度,无法提前决定渲染哪种布局
  3. 解决方案:延迟渲染 + mounted 标志,确保初始 DOM 一致
  4. 适用场景:移动端和 PC 端使用不同组件或布局结构的情况

方案选择指南

是否需要改变 DOM 结构?
  ├─ 否 → 使用 CSS 媒体查询(最优)
  └─ 是 → 是否需要响应 resize?
       ├─ 否 → 使用 ClientOnly(简单)
       └─ 是 → 使用 useLayoutReady(本文方案)

代码清单

完整 composable 实现

  • 核心:mounted 标志 + onMounted 钩子 + resize 监听

使用模板

<script setup>
const layout = useLayoutReady(768) // 可自定义断点
</script>

<template>
  <div v-if="layout.mounted">
    <MobileLayout v-if="layout.isMobile" />
    <DesktopLayout v-else />
  </div>
</template>

参考资源