Swiper 12 全屏滚动:优雅处理最后一屏高度不一致的问题

技术栈: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 所在

解决方案

核心思路

修正 snapGridslidesGrid,让 Swiper 内部状态与实际渲染位置一致,slidePrev() 就能找到正确的目标位置。

具体分两步:

  1. updateFooterSlideHeight():在 slide 切换后,手动将 Footer slide 的高度设为实际内容高度,并通过 setTranslate() 修正 Swiper 的渲染位置
  2. fixSwiperSnapGrid():在向上滑动之前,重建正确的 snapGridslidesGrid,确保 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 的 snapGridslidesGrid 还是旧值。调用 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 前重建 snapGridslidesGrid
向下穿透滚动 mousewheel 未拦截 最后一屏 disable mousewheel + preventDefault
相关推荐
GISer_Jing1 小时前
TypeScript打造高效MCP工具与Skills开发
前端·javascript·typescript
智能工业品检测-奇妙智能2 小时前
如何用OpenClaw实现CSDN文章编辑发布
前端·人工智能·chrome·奇妙智能
Cache技术分享2 小时前
351. Java IO API - Java 文件操作:java.io.File 与 java.nio.file 功能对比 - 3
前端·后端
用户5757303346242 小时前
JavaScript 事件循环:宏任务与微任务执行顺序一图搞懂
javascript·react.js
YimWu2 小时前
面试官:OpenCode Tool 工具系统了解吗?
javascript·ai编程
天若有情6732 小时前
【原创发布】typechecker:一款轻量级 JS 模板化类型检查工具
开发语言·javascript·npm·ecmascript·类型检查·typechecker
A_nanda2 小时前
vue实现走马灯显示文字效果
前端·javascript·vue.js
小码哥_常2 小时前
Kotlin 延迟初始化:lateinit与by lazy的华山论剑
前端
晴栀ay2 小时前
一文详解JS中的执行顺序——事件循环(宏任务、微任务)
前端·javascript·面试