解决 Nuxt SSR (服务端渲染) 环境下的水合错误 (Hydration Mismatch)

0 阅读3分钟

问题背景

在 SSR 应用中,代码会在两个不同环境执行:

  1. 服务端 (Node.js) : 没有 window 对象,无法获取屏幕宽度
  2. 客户端 (浏览器) : 有 window.innerWidth,可以判断是否为移动端

如果直接在组件初始化时判断 window.innerWidth <= 768,会导致:

  • 服务端渲染的 HTML 和客户端期望的 DOM 结构不一致
  • Vue 抛出 hydration mismatch 警告或错误
  • 页面可能闪烁或强制重新渲染
/**
 * 响应式布局就绪 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(() => {
    layout.mounted = true
    layoutCheck()
    window.addEventListener('resize', layoutCheck)
  })

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

  return layout
}

封装策略

const layout = reactive({
  isMobile: false,      // 初始值不重要,因为不会被立即使用
  mounted: false,       // 关键标志:确保服务端和客户端首次渲染一致
})

关键机制:

  1. 延迟渲染: 通过 v-if="layout.mounted" 让组件在服务端和客户端首次渲染时都不显示内容

  2. 客户端激活: 只有在 onMounted 钩子执行后(仅在客户端运行)才:

    • 设置 mounted = true 显示内容
    • 执行 layoutCheck() 正确判断 isMobile
  3. 响应式更新: 监听 resize 事件,支持窗口大小变化时动态调整布局

实际使用分析

1. 案例:移动端和PC端使用完全不同的组件,避免服务端渲染错误的结构

.ProductList(v-if="layout.mounted" ref="elRef")
  //- 移动端: Swiper 轮播
  Swiper(v-if="layout.isMobile" ...)
  //- PC端: Sticky 列表
  .list-container(v-else ...)

2. 案例:两端布局差异大(侧边栏位置、内容宽度不同),必须等客户端挂载后再渲染

<div v-if="layout.mounted">
  <!-- 移动端布局 -->
  <div v-if="layout.isMobile">...</div>
  <!-- PC端布局 -->
  <div v-else>...</div>
</div>

3. 案例:移动端和PC端使用不同的交互组件(按钮 vs 下拉菜单)

.NewsListCategory(v-if="layout.mounted")
  //- 移动端: 按钮组
  template(v-if="layout.isMobile")
    ButtonCategory(...)
  //- PC端: 下拉菜单
  template(v-else)
    a-dropdown(...)

设计意图总结

这个封装避免了:

  1. ❌ Hydration mismatch 错误: 服务端和客户端渲染结果不一致
  2. ❌ 闪烁问题: 页面先显示PC版,再切换到移动版
  3. ❌ SEO 问题: 搜索引擎抓取到错误的移动端/PC端内容

实现了:

  1. ✅ 一致的初始渲染: 服务端和客户端首次都不显示内容(skeleton 或空白)
  2. ✅ 正确的布局判断: 只在客户端执行窗口尺寸检测
  3. ✅ 响应式适配: 支持窗口大小变化时动态调整

为什么不能简化?

// ❌ 错误做法 1: 直接判断会导致服务端报错
const isMobile = ref(window.innerWidth <= 768)

// ❌ 错误做法 2: 即使加 typeof 判断,服务端渲染 false,客户端可能是 true
const isMobile = ref(typeof window !== 'undefined' && window.innerWidth <= 768)
// 服务端渲染: <div class="pc-layout">...</div>
// 客户端期望: <div class="mobile-layout">...</div>
// 结果: Hydration mismatch!

// ✅ 正确做法: 延迟到客户端挂载后再显示
const layout = useLayoutReady()
// 模板: v-if="layout.mounted"
// 服务端和客户端首次都不渲染,避免水合错误

这个设计体现了 Nuxt SSR 应用的最佳实践: 对于依赖浏览器环境的响应式布局,必须等到客户端挂载后再渲染,以保证服务端和客户端的一致性。