技术栈:Vue 3 + TypeScript + Swiper 12
场景:前几屏全屏展示内容,最后一屏是高度不固定的 Footer
问题描述
用 Swiper 做全屏滚动页面时,前几个 slide 都是 100vh 的全屏内容,最后一个 slide 是网站 Footer,高度通常只有 200~400px。
这个需求看起来简单,实际藏着两个比较隐蔽的 bug:
Bug 1 --- 底部空白 :Swiper 默认给每个 slide 分配等高空间,Footer 内容不足 100vh 时,底部会留出大片空白。
Bug 2 --- 向上滚动跳回第一屏:在 Footer 屏向上滚动时,Swiper 不是切换到倒数第二屏,而是直接跳回 index 0。
根因分析
Bug 1:底部空白
Swiper 垂直模式下,slides-per-view="1" 会让每个 slide 的高度等于容器高度(即 100vh)。Footer slide 的实际内容高度远小于此,多余的空间就形成了空白。
Bug 2:跳回第一屏
这个 bug 的根因链比较长,需要理清楚:
Swiper 内部维护了三个关键数据结构来计算滚动位置:
snapGrid:每个 snap 点对应的 translate 值slidesGrid:每个 slide 起始位置的 translate 值virtualSize:所有 slide 的总高度
当我们为了修正 Footer 高度,手动调用 setTranslate() 和修改 virtualSize 时,这三个值就产生了不一致:
手动 setTranslate(correctTranslate)
↓ 实际渲染位置被修改
手动覆盖 swiper.virtualSize
↓ 总高度被修改
snapGrid / slidesGrid 没有同步更新
↓ Swiper 内部状态不一致
调用 slidesPrev() 时 Swiper 用 snapGrid 反查当前位置
↓ 找不到匹配项
fallback 到 index 0 ← Bug 所在
解决方案
核心思路
修正 snapGrid 和 slidesGrid,让 Swiper 内部状态与实际渲染位置一致,slidePrev() 就能找到正确的目标位置。
具体分两步:
updateFooterSlideHeight():在 slide 切换后,手动将 Footer slide 的高度设为实际内容高度,并通过setTranslate()修正 Swiper 的渲染位置fixSwiperSnapGrid():在向上滑动之前,重建正确的snapGrid和slidesGrid,确保slidePrev()能正确定位到倒数第二屏
同时,在最后一屏的滚轮事件上需要统一接管:
- 向上滚动 :先修正 snapGrid,再调用
slidePrev(),加防抖避免连续触发 - 向下滚动 :
preventDefault()阻止继续滚动
完整代码
FullPageScroll.vue
vue
<script setup lang="ts">
/**
* FullPageScroll 全屏滚动展示组件
* 基于 Swiper 12,支持垂直/水平全屏滚动、视差效果、响应式设计
*/
import type { Block } from '~/blocks/types'
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { EffectFade, Mousewheel, Pagination, Parallax } from 'swiper/modules'
import { Swiper, SwiperSlide } from 'swiper/vue'
import type { Swiper as SwiperInstance } from 'swiper'
import { useLayoutStore } from '~/stores/layout'
import BlockRenderer from './BlockRenderer.vue'
import 'swiper/css'
import 'swiper/css/pagination'
import 'swiper/css/effect-fade'
import 'swiper/css/parallax'
// ---- Types ----
interface PaginationConfig {
clickable?: boolean
type?: 'bullets' | 'fraction' | 'progressbar' | 'custom'
dynamicBullets?: boolean
hideOnClick?: boolean
renderBullet?: (index: number, className: string) => string
renderFraction?: (currentClass: string, totalClass: string) => string
renderProgressbar?: (progressbarFillClass: string) => string
renderCustom?: (swiper: any, current: number, total: number) => string
}
interface Props {
lists?: Block[]
direction?: 'horizontal' | 'vertical'
effect?: 'fade' | 'parallax' | 'slide'
speed?: number
mousewheel?: boolean
keyboard?: boolean
fullPage?: boolean
pagination?: boolean | PaginationConfig
}
// ---- Props ----
const props = withDefaults(defineProps<Props>(), {
lists: () => [],
direction: 'vertical',
effect: 'slide',
speed: 800,
mousewheel: true,
keyboard: true,
fullPage: true,
pagination: false,
})
// ---- State ----
const layoutStore = useLayoutStore()
const swiperRef = ref<SwiperInstance | null>(null)
const currentSwiperIndex = ref(0)
const isSliding = ref(false) // 防抖标志,防止连续触发 slidePrev
// ---- Swiper 模块 & 配置 ----
const getModules = () => {
const modules = [Mousewheel, Pagination]
if (props.effect === 'parallax') modules.push(Parallax)
if (props.effect === 'fade') modules.push(EffectFade)
return modules
}
const getPaginationConfig = () => {
if (props.pagination === false) return false
if (props.pagination === true) return {}
return props.pagination
}
// ---- 核心逻辑 ----
/**
* 修正 Footer slide 的渲染高度
* 将 Footer slide 的高度设为实际内容高度,并修正 Swiper 的 translate
*/
const updateFooterSlideHeight = async () => {
await nextTick()
if (!swiperRef.value) return
const swiper = swiperRef.value
const totalSlides = swiper.slides.length
const lastSlide = swiper.slides[totalSlides - 1]
if (!lastSlide?.classList.contains('l-footer-swiper-slide')) return
const footerContent = lastSlide.firstElementChild as HTMLElement
if (!footerContent) return
const actualHeight = footerContent.offsetHeight
// 将 Footer slide 高度设为实际内容高度
lastSlide.style.height = `${actualHeight}px`
lastSlide.style.minHeight = `${actualHeight}px`
await nextTick()
swiper.update()
// 如果当前正在最后一屏,修正 translate 到正确位置
if (swiper.activeIndex === totalSlides - 1) {
const viewportHeight = swiper.height
const correctTotalHeight = (totalSlides - 1) * viewportHeight + actualHeight
const correctTranslate = -(correctTotalHeight - viewportHeight)
swiper.setTranslate(correctTranslate)
}
}
/**
* 修正 Swiper 内部 snapGrid 和 slidesGrid
*
* 问题根因:setTranslate 修改了实际渲染位置,但 snapGrid/slidesGrid 没有同步,
* 导致 slidePrev() 用 snapGrid 反查当前位置时找不到匹配项,fallback 到 index 0。
*
* 解决:在调用 slidePrev() 之前,重建正确的 snapGrid 和 slidesGrid。
* - 前 N-1 个 slide:每个占 viewportHeight
* - 最后一个 Footer slide:占实际内容高度
*/
const fixSwiperSnapGrid = (swiper: SwiperInstance) => {
if (typeof window === 'undefined') return
const totalSlides = swiper.slides.length
const viewportHeight = window.innerHeight
const lastSlide = swiper.slides[totalSlides - 1] as HTMLElement
const footerHeight = (lastSlide?.firstElementChild as HTMLElement)?.offsetHeight ?? viewportHeight
const correctSnapGrid: number[] = []
const correctSlidesGrid: number[] = []
for (let i = 0; i < totalSlides - 1; i++) {
correctSnapGrid.push(i * viewportHeight)
correctSlidesGrid.push(i * viewportHeight)
}
// Footer slide 的 snap 点 = 总高度 - viewportHeight(即滚动到底时的位置)
const footerSnapPos = (totalSlides - 1) * viewportHeight - (viewportHeight - footerHeight)
correctSnapGrid.push(footerSnapPos)
correctSlidesGrid.push((totalSlides - 1) * viewportHeight)
swiper.snapGrid = correctSnapGrid
swiper.slidesGrid = correctSlidesGrid
}
// ---- 事件处理 ----
const onSwiperInit = (swiper: SwiperInstance) => {
swiperRef.value = swiper
updateFooterSlideHeight()
}
const onSwiperSlideChange = (swiper: SwiperInstance) => {
currentSwiperIndex.value = swiper.activeIndex
// 每次切换后重新修正 Footer 高度(防止内容动态变化)
updateFooterSlideHeight()
// 到达最后一屏时禁用 Swiper 的 mousewheel,交给 handleMouseWheel 统一接管
const isLastSlide = swiper.activeIndex === swiper.slides.length - 1
if (isLastSlide) {
swiper.mousewheel?.enabled && swiper.mousewheel.disable()
} else {
!swiper.mousewheel?.enabled && swiper.mousewheel?.enable()
}
}
/**
* 全局滚轮事件处理(只处理最后一屏)
*
* 在最后一屏时 Swiper 的 mousewheel 已被禁用,由此函数接管:
* - 向上滚动:修正 snapGrid → slidePrev() → 防抖
* - 向下滚动:preventDefault 阻止浏览器继续滚动
*/
const handleMouseWheel = (e: WheelEvent) => {
if (!swiperRef.value || props.direction !== 'vertical') return
const swiper = swiperRef.value
const isLastSlide = swiper.activeIndex === swiper.slides.length - 1
if (!isLastSlide) return
if (e.deltaY < 0) {
// 向上滚动:切换到上一屏
if (!isSliding.value) {
isSliding.value = true
fixSwiperSnapGrid(swiper) // 先修正内部状态
swiper.slidePrev(props.speed) // 再切换,位置正确
setTimeout(() => {
isSliding.value = false
}, props.speed + 100)
}
} else {
// 向下滚动:已是最后一屏,阻止继续滚动
e.preventDefault()
}
}
// ---- 生命周期 ----
onMounted(() => {
if (typeof window === 'undefined') return
document.addEventListener('wheel', handleMouseWheel, { passive: false })
window.addEventListener('resize', updateFooterSlideHeight)
})
onUnmounted(() => {
if (typeof window === 'undefined') return
document.removeEventListener('wheel', handleMouseWheel)
window.removeEventListener('resize', updateFooterSlideHeight)
})
</script>
<template>
<Swiper
v-if="lists && lists.length > 0"
class="l-full-swiper l-full l-min-h l-fullvh"
:class="[`l-effect-${effect}`]"
:direction="direction"
:slides-per-view="1"
:space-between="0"
:speed="speed"
:mousewheel="mousewheel ? { sensitivity: 1, releaseOnEdges: true } : false"
:keyboard="keyboard ? { enabled: true, onlyInViewport: true } : false"
:effect="effect === 'slide' ? 'slide' : effect === 'fade' ? 'fade' : undefined"
:parallax="effect === 'parallax'"
:modules="getModules()"
:pagination="getPaginationConfig()"
@swiper="onSwiperInit"
@slide-change="onSwiperSlideChange"
>
<!-- 普通全屏 slide -->
<SwiperSlide
v-for="(item, index) in lists"
:key="index"
class="l-full-swiper-slide"
:data-swiper-parallax="effect === 'parallax' ? -300 : undefined"
>
<div
class="l-full-slide-wrapper"
:data-swiper-parallax="effect === 'parallax' ? '-200' : undefined"
>
<BlockRenderer
:block="item"
:class="{ 'parallax-content': effect === 'parallax' }"
:full-page="fullPage"
:is-active="index === currentSwiperIndex"
/>
</div>
</SwiperSlide>
<!-- Footer slide:高度自适应,非全屏 -->
<SwiperSlide
v-if="!layoutStore.curtShowFooter"
class="l-footer-swiper-slide"
>
<ClientOnly>
<SiteFooter />
<template #fallback>
<div style="height: 280px; background: #26293f" />
</template>
</ClientOnly>
</SwiperSlide>
</Swiper>
</template>
<style scoped lang="scss">
.l-full-swiper {
overflow: hidden;
}
.l-full-swiper-slide {
display: flex;
align-items: center;
justify-content: center;
.l-full-slide-wrapper {
width: 100%;
height: 100%;
overflow: hidden;
.parallax-content {
transition: transform 0.3s ease;
}
}
}
/* Footer slide 高度由内容决定,不强制全屏 */
.l-footer-swiper-slide {
align-items: flex-start !important;
width: 100%;
height: auto !important;
min-height: auto !important;
&[style*='height'] {
height: auto !important;
min-height: auto !important;
}
}
</style>
关键点梳理
1. updateFooterSlideHeight:修正渲染位置
Footer slide 被 Swiper 默认分配了 100vh 高度,需要强制将其设为实际内容高度,并修正 setTranslate 让 Swiper 滚动到正确的位置。
ts
lastSlide.style.height = `${actualHeight}px`
const correctTranslate = -(correctTotalHeight - viewportHeight)
swiper.setTranslate(correctTranslate)
2. fixSwiperSnapGrid:修正内部状态
setTranslate 只改了渲染位置,Swiper 的 snapGrid 和 slidesGrid 还是旧值。调用 slidePrev() 时 Swiper 会用 snapGrid 反查"当前在第几屏",查不到就 fallback 到 index 0。
解决方法是在 slidePrev() 之前重建这两个数组:
ts
// 前 N-1 屏:每屏占 viewportHeight
for (let i = 0; i < totalSlides - 1; i++) {
correctSnapGrid.push(i * viewportHeight)
}
// Footer 屏的 snap 点 = 实际可滚动到的位置
const footerSnapPos = (totalSlides - 1) * viewportHeight - (viewportHeight - footerHeight)
correctSnapGrid.push(footerSnapPos)
swiper.snapGrid = correctSnapGrid
swiper.slidesGrid = correctSlidesGrid
3. 滚轮事件接管策略
| 情况 | 处理方式 |
|---|---|
| 到达最后一屏 | mousewheel.disable(),交给 handleMouseWheel |
| 最后一屏向上滚动 | fixSwiperSnapGrid() → slidePrev() → 防抖 |
| 最后一屏向下滚动 | e.preventDefault() 阻止穿透 |
| 其他屏 | Swiper 原生 mousewheel 处理 |
4. 防抖
鼠标滚轮事件触发频率很高,必须用 isSliding 标志 + setTimeout 防抖,避免在动画未完成时重复触发 slidePrev()。
ts
if (!isSliding.value) {
isSliding.value = true
fixSwiperSnapGrid(swiper)
swiper.slidePrev(props.speed)
setTimeout(() => { isSliding.value = false }, props.speed + 100)
}
为什么不用 autoHeight?
Swiper 提供了 autoHeight 参数,看起来能自动处理高度问题。但在全屏滚动场景下有一个致命缺陷:每次切换 slide 时容器高度会随当前 slide 的内容高度改变。
也就是说,从全屏 slide(100vh)切换到下一个全屏 slide,容器会先缩小再撑开,产生明显的高度跳变动画,体验很差。
本文方案只修改最后一个 Footer slide 的高度,其余 slide 保持 100vh,动画过渡完全正常。
总结
| 问题 | 根因 | 解决方案 |
|---|---|---|
| Footer 底部空白 | Swiper 给所有 slide 分配等高空间 | 手动设置 Footer slide 高度 + setTranslate 修正位置 |
| 向上滚动跳 index 0 | setTranslate 修改渲染位置后 snapGrid 未同步 |
调用 slidePrev 前重建 snapGrid 和 slidesGrid |
| 向下穿透滚动 | mousewheel 未拦截 | 最后一屏 disable mousewheel + preventDefault |