背景
最近在开发公司官网的响应式 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 让我深刻认识到:
- 性能优化不仅是算法,更是架构选择 - CSS 能做的事不要用 JS
- SSR 项目需要时序思维 - 区分服务端、客户端、水合三个阶段
- 组件设计要考虑降级 - 让开发者用得爽,而不是记一堆规则
感谢 TL 的耐心指导,以后写响应式组件会优先考虑 CSS 方案了!
参考资料:
首屏渲染中的hydration(水合)是现代前端框架(如React、Vue)在服务端渲染(SSR)或静态生成(SSG)中,将服务器生成的静态HTML内容激活为可交互应用的关键过程。
Hydration的核心作用是结合SSR和客户端渲染(CSR)的优势:
- 服务器预先渲染完整的HTML并发送到浏览器,使用户快速看到内容,提升首次内容绘制(FCP)和SEO;
- 随后客户端JavaScript下载并执行,通过对比虚拟DOM与现有真实DOM结构,将事件监听器和状态绑定到DOM元素上,使页面从静态视图变为可交互应用。
Hydration过程涉及以下关键步骤:
- 服务端渲染:服务器执行组件逻辑生成初始HTML和数据;
- 客户端激活:浏览器下载JavaScript bundle后,框架重新运行渲染逻辑生成虚拟DOM,与真实DOM对比并匹配结构,若一致则附加交互功能,不一致则触发hydration mismatch错误。