经验分享2:SSR 项目中响应式组件的闪动陷阱与修复实践

背景

最近在开发公司官网的响应式 Banner 组件时,遇到了一个移动端首屏加载闪动的问题。 一套代码做PC端和移动端的适配,作为团队新人,我最初采用了 JS 动态判断的方案,但被 TL 指出存在性能问题。在这次代码 Review 中,我深刻理解了 SSR 项目中响应式适配的正确姿势。

一、组件设计:从 Props 接收到样式应用

1.1 组件设计思路

我们需要一个通用的 Banner 组件,支持:

  • PC 端和移动端使用不同的图片
  • PC 端和移动端使用不同的高度
  • 未提供移动端资源时,自动降级使用 PC 端资源

组件代码:

vue 复制代码
<script lang="ts" setup>

import { imgUrlPatch } from '~/util'

defineOptions({ name: 'HeroBanner' })

defineProps<{
  // 图像两侧不足时填充的背景色
  bgColor?: string
  // 最小高度
  height?: any
  // 图像网址
  url?: string
}>()
</script>
<template lang="pug">
.page-banner(
  :style="{ backgroundColor: bgColor, backgroundImage: url ? `url(${imgUrlPatch(url)})` : '', minHeight: height && (isFinite(height) ? height + 'px' : height) }"
)
  slot
</template>

<style lang="less">
.page-banner {
  background-position: center;
  background-repeat: no-repeat;
  background-size: auto 100%;
}
</style>

二、踩坑:首屏加载的高度闪动

2.1 问题复现

最初我在页面中这样使用组件:

vue 复制代码
<script lang="ts" setup>
const isMobileDevice = ref(false)

onMounted(() => {
  isMobileDevice.value = window.innerWidth < 768
})

const bannerHeight = computed(() => {
  return isMobileDevice.value ? '180px' : '360px'
})
</script>

<template>
  <HeroBanner 
    class="company-banner" 
    background-color="#2B5A8E" 
    :height="bannerHeight" 
    image="hero/company-banner.webp" 
  />
</template>

现象: 在移动端首次加载时,Banner 会出现明显的高度跳变(360px → 180px)。

2.2 TL 的修复方案

vue 复制代码
//父组件使用
<template>
  <HeroBanner 
    class="company-banner" 
    background-color="#2B5A8E" 
    :pc-height="360" 
    :mobile-height="180"
    pc-image="hero/company-banner.webp" 
  />
</template>

//子组件:HeroBanner 组件代码,背景图组件,自适应宽度,居中对齐
<script lang="ts" setup>
import { processImageUrl } from '~/utils'

defineOptions({ name: 'HeroBanner' })

const props = defineProps<{
  // 背景填充色
  backgroundColor?: string
  // PC 端高度
  pcHeight?: any
  // 移动端高度
  mobileHeight?: any
  // 移动端图片地址
  mobileImage?: string
  // PC 端图片地址
  pcImage?: string
}>()

// PC 端高度处理
const pcHeight = computed(() => {
  const h = props.pcHeight
  return h && (isFinite(h) ? h + 'px' : h)
})

// 移动端高度处理:优先使用 mobileHeight,否则降级使用 pcHeight
const mobileHeight = computed(() => {
  const h = props.mobileHeight || props.pcHeight
  return h && (isFinite(h) ? h + 'px' : h)
})

// 移动端图片:优先使用 mobileImage,否则降级使用 pcImage
const mobileImage = computed(() => {
  const img = props.mobileImage || props.pcImage
  return img ? `url(${processImageUrl(img)})` : ''
})

// PC 端图片
const pcImage = computed(() => {
  const img = props.pcImage
  return img ? `url(${processImageUrl(img)})` : ''
})
</script>

<template lang="pug">
.hero-banner(:style="{ backgroundColor }")
  slot
</template>

<style lang="less">
.hero-banner {
  background-color: v-bind(backgroundColor);
  background-image: v-bind(mobileImage);
  background-position: center;
  background-repeat: no-repeat;
  background-size: auto 100%;
  min-height: v-bind(mobileHeight);

  @media (min-width: 768px) {
    background-image: v-bind(pcImage);
    min-height: v-bind(pcHeight);
  }
}
</style>

删掉了所有 JS 判断逻辑,问题神奇地消失了。

三、原因深度剖析

3.1 错误方案的执行时序

我的方案(JS 动态判断):

arduino 复制代码
1. SSR 服务端渲染
   └─ 服务端无法获取屏幕宽度
   └─ isMobileDevice 默认为 false
   └─ 输出 HTML: height="360px"

2. 浏览器接收 HTML
   └─ 用户看到 360px 高度的 Banner ⚠️

3. JavaScript 水合(Hydration)
   └─ onMounted 执行
   └─ window.innerWidth < 768 → true
   └─ isMobileDevice 变为 true
   └─ 触发响应式更新 → height="180px"
   └─ 用户看到高度跳变 ⚡

时间差: 从 HTML 渲染到 JS 执行完成,通常有 100-500ms 延迟。

3.2 正确方案的执行时序

TL 的方案(CSS 媒体查询):

arduino 复制代码
1. SSR 服务端渲染
   └─ 同时输出两套高度值
   └─ 生成 CSS:
       min-height: 180px;  /* 默认 */
       @media (min-width: 768px) {
         min-height: 360px;  /* PC 端 */
       }

2. 浏览器接收 HTML
   └─ 浏览器原生解析 CSS
   └─ 媒体查询立即生效
   └─ 移动端直接显示 180px ✅
   └─ PC 端直接显示 360px ✅

3. JavaScript 水合
   └─ 无需任何操作
   └─ 样式已经正确 ✅

零时间差: CSS 在浏览器渲染引擎层面就已确定,不依赖 JS 执行。

3.3 核心差异对比表

对比维度 JS 动态判断(我的方案) CSS 媒体查询(TL 方案)
判断时机 JavaScript 执行后 浏览器解析 CSS 时
SSR 兼容 ❌ 服务端无法判断设备 ✅ 样式同时输出
首屏表现 需等待 hydration 立即应用正确样式
性能开销 有 JS 计算 + 响应式更新 浏览器原生能力
CLS 影响 有布局偏移 无布局偏移

四、经验总结

4.1 响应式适配的黄金法则

在 Nuxt/Vue SSR 项目中:

markdown 复制代码
✅ 静态配置 → CSS 媒体查询
   - 固定高度、宽度
   - 静态资源路径
   - 固定颜色、字号

❌ 动态配置 → JS 运行时判断
   - API 返回的数据
   - 用户交互状态
   - 复杂业务逻辑

4.2 isFinite() 的实用技巧

遇到需要支持多种单位的场景,用原生 API 优雅处理:

javascript 复制代码
function normalizeSize(value: any) {
  return value && (isFinite(value) ? `${value}px` : value)
}

// 使用
normalizeSize(100)      // '100px'
normalizeSize('50vh')   // '50vh'
normalizeSize('100%')   // '100%'
normalizeSize(null)     // null

4.3 组件设计的降级思维

提供 mobile*pc* 两套 props 时,始终实现降级逻辑:

javascript 复制代码
const mobileValue = computed(() => {
  return props.mobileValue || props.pcValue  // 降级逻辑
})

这让组件使用更灵活,既支持"一套配置通用",也支持"精细化适配"。


五、延伸思考

5.1 什么时候必须用 JS 判断?

遇到以下场景,CSS 无法胜任,必须用 JS:

javascript 复制代码
// ❌ CSS 无法实现:根据设备加载不同的组件
const DynamicComponent = computed(() => {
  return isMobile.value ? MobileChart : PCChart
})

// ❌ CSS 无法实现:根据屏幕尺寸调整 Swiper 配置
const swiperConfig = computed(() => ({
  slidesPerView: isMobile.value ? 1 : 3,
  spaceBetween: isMobile.value ? 10 : 30
}))

5.2 如何避免 SSR 水合不一致?

核心原则:服务端渲染的内容必须与客户端首次渲染一致

javascript 复制代码
// ❌ 错误:服务端和客户端结果不一致
const currentTime = new Date().toLocaleString()

// ✅ 正确:仅在客户端执行
const currentTime = ref('')
onMounted(() => {
  currentTime.value = new Date().toLocaleString()
})

总结

这次代码 Review 让我深刻认识到:

  1. 性能优化不仅是算法,更是架构选择 - CSS 能做的事不要用 JS
  2. SSR 项目需要时序思维 - 区分服务端、客户端、水合三个阶段
  3. 组件设计要考虑降级 - 让开发者用得爽,而不是记一堆规则

感谢 TL 的耐心指导,以后写响应式组件会优先考虑 CSS 方案了!


参考资料:

首屏渲染中的hydration(水合)是现代前端框架(如React、Vue)在服务端渲染(SSR)或静态生成(SSG)中,将服务器生成的静态HTML内容激活为可交互应用的关键过程。

Hydration的核心作用是结合SSR和客户端渲染(CSR)的优势:

  • 服务器预先渲染完整的HTML并发送到浏览器,使用户快速看到内容,提升首次内容绘制(FCP)和SEO;
  • 随后客户端JavaScript下载并执行,通过对比虚拟DOM与现有真实DOM结构,将事件监听器和状态绑定到DOM元素上,使页面从静态视图变为可交互应用。

Hydration过程涉及以下关键步骤:

  1. 服务端渲染:服务器执行组件逻辑生成初始HTML和数据;
  2. 客户端激活:浏览器下载JavaScript bundle后,框架重新运行渲染逻辑生成虚拟DOM,与真实DOM对比并匹配结构,若一致则附加交互功能,不一致则触发hydration mismatch错误。
相关推荐
心.c9 小时前
如何基于 RAG 技术,搭建一个专属的智能 Agent 平台
开发语言·前端·vue.js
一条咸鱼_SaltyFish9 小时前
[Day15] 若依框架二次开发改造记录:定制化之旅 contract-security-ruoyi
java·大数据·经验分享·分布式·微服务·架构·ai编程
智航GIS9 小时前
10.7 pyspider 库入门
开发语言·前端·python
华仔啊9 小时前
写 CSS 用 px?这 3 个单位能让页面自动适配屏幕
前端·css
静听松涛13310 小时前
提示词注入攻击的防御机制
前端·javascript·easyui
晚风予星10 小时前
简记 | 一个基于 AntD 的高效 useDrawer Hooks
前端·react.js·设计
栗子叶10 小时前
网页接收服务端消息的几种方式
前端·websocket·http·通信
菩提小狗10 小时前
Sqli-Labs Less-3 靶场完整解题流程解析-豆包生成
前端·css·less
澄江静如练_10 小时前
优惠券提示文案表单项(原生div写的)
前端·javascript·vue.js