跳到主要内容

轮播图组件

  • 由于直接使用组件库的轮播图,例如指示器交互效果,需要等到客户端渲染期间才会出来,这不利于用户体验

实现轮播图效果

  • SSR 阶段:轮播 DOM + 分页器静态结构都输出,避免白屏/丢失指示器。
  • 客户端阶段:Swiper 接管 DOM,分页器变为交互式(圆点高亮 + 可点击)

Swiper API​

API / 参数类型说明企业级使用建议
slidesPerViewnumber / 'auto'每屏显示的数量PC 建议固定数值,移动端用 auto
spaceBetweennumberslide 间距广告/卡片类推荐 8-16px
loopboolean循环模式谨慎开启,数据动态更新时容易出错
autoplayobject自动播放设置 { delay, disableOnInteraction }建议加上 pauseOnMouseEnter: true,提升 UX
lazyobject图片懒加载企业级必须开启,减少首屏压力
paginationobject分页器SSR 注意延迟渲染,服务端需输出结构
navigationobject前进/后退按钮自定义 UI,避免与设计冲突
effectstring'slide' / 'fade' / 'cube' / 'coverflow' / 'flip'首页 Banner 推荐 fade
breakpointsobject响应式配置必须为移动端和 PC 提供不同配置
observerboolean自动监测 Swiper/Slide 变化动态数据场景必开
virtualobject虚拟列表大量数据场景必开(如小说章节)

Swiper使用注意问题​

  • 1、在循环模式下,当滑动到最后一个索引时,Swiper 的 slides-per-view 配置可能会失效。这通常发生在循环模式的边界处理上
  • 2、当启用循环模式时,Swiper 会创建额外的克隆幻灯片,这会导致 activeIndex 与实际的数据索引不匹配,应当使用 realIndex 而不是 activeIndex

在循环模式下问题

  • 不要使用slideTo,应使用slideToLoop
    • 可以保证:在循环模式下滑动到指定的幻灯片索引
  • 不要使用activeIndex,应使用realIndex
    • realIndex 会返回实际的幻灯片索引,不受循环克隆影响
    • 保证循环模式下,索引正确

SSR 场景​

  • 首屏渲染问题:
    • SSR 直出 DOM 结构,避免首屏空白
    • Swiper 初始化放到 onMounted 或 useEffect
  • 分页器/导航器丢失:
    • 在服务端输出 <div class="swiper-pagination"></div> 占位结构
    • 客户端再激活 Swiper

CarouselSwiper组件​

vue
<template>
<div class="swiper-container">
<swiper
ref="swiperRef"
:modules="[Autoplay, Pagination]"
:autoplay="{ delay: 3000, disableOnInteraction: false }"
:slides-per-view="1.02"
:space-between="0"
:touch-ratio="1"
:touch-angle="45"
:grab-cursor="true"
:allow-touch-move="true"
:loop="true"
@swiper="onSwiper"
@slide-change="onSlideChange"
>
<swiper-slide
v-for="(item, index) in items"
:key="index"
class="swiper-slide"
>
<div class="carousel-card">
<div class="card-image">
<img :src="item.image" :alt="item.title" class="image">
</div>
<div class="card-content">
<h3 class="card-title">
{{ item.title }}
</h3>
<p class="card-episodes">
{{ item.episodes || "75 Episodes" }}
</p>
<p class="card-description">
{{
item.description
|| "To repay a debt of kindness, Neil signs a secret contract with Keira's..."
}}
</p>
<div class="card-tags">
<span
v-for="tag in item.tags || ['Urban', 'Romance']"
:key="tag"
class="tag"
>
{{ tag }}
</span>
</div>
</div>
</div>
</swiper-slide>
</swiper>

<!-- 自定义分页器 -->
<div v-if="showPagination" class="swiper-pagination">
<span
v-for="(_, index) in items"
:key="index"
class="dot"
:class="{ active: index === currentSlide }"
@click="goToSlide(index)"
/>
</div>
</div>
</template>

<script setup lang="ts">
import type { Swiper as SwiperType } from 'swiper'
import { Autoplay, Pagination } from 'swiper/modules'
import { Swiper, SwiperSlide } from 'swiper/vue'
import { ref } from 'vue'
import 'swiper/css'
import 'swiper/css/autoplay'
import 'swiper/css/pagination'

type CarouselSwiperProps = {
items?: any[]
autoplay?: boolean
loop?: boolean
showPagination?: boolean
enableMousewheel?: boolean
}

withDefaults(defineProps<CarouselSwiperProps>(), {
items: () => [],
autoplay: true,
loop: true,
showPagination: true,
enableMousewheel: true,
options: () => ({}),
})

const currentSlide = ref(0)
const swiperRef = ref<SwiperType>()
const swiperInstance = ref<SwiperType>()

function onSwiper(swiper: SwiperType) {
swiperInstance.value = swiper
}

function onSlideChange(swiper: SwiperType) {
// 当使用 loop 模式时,需要使用 realIndex 而不是 activeIndex
// realIndex 会返回实际的幻灯片索引,不受循环克隆影响
const realIndex = swiper.realIndex
currentSlide.value = realIndex
}

function goToSlide(index: number) {
if (
swiperInstance.value
&& typeof swiperInstance.value.slideTo === 'function'
) {
// swiperInstance.value.slideTo(index)
// 保证:在循环模式下滑动到指定的幻灯片索引
swiperInstance.value.slideToLoop(index)
currentSlide.value = index
}
}
</script>

<style scoped lang="less">
.swiper-container {
width: 100%;
height: 100%;

.swiper-slide {
width: 98% !important;
.carousel-card {
display: flex;
background: #f8f9fa;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-height: 200px;
max-width: 95%;
}

.card-image {
flex-shrink: 0;
width: 120px;
height: 200px;
position: relative;
}

.image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 12px 0 0 12px;
}

.card-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
min-width: 0; /* 防止内容溢出 */
padding: 12px 12px;
}

.card-title {
font-size: 14px;
font-weight: 600;
color: #1a1a1a;
margin: 0 0 8px 0;
line-height: 1.3;
word-wrap: break-word;
// 展示一行
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: -webkit-box;
-webkit-line-clamp: 1;
line-clamp: 1;
-webkit-box-orient: vertical;
}

.card-episodes {
font-size: 14px;
color: #666;
margin: 0 0 12px 0;
}

.card-description {
font-size: 14px;
color: #888;
line-height: 1.4;
margin: 0 0 16px 0;
flex: 1;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
word-wrap: break-word;
}

.card-tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: auto;
}

.tag {
background: #e9ecef;
color: #495057;
padding: 4px 12px;
border-radius: 16px;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
}

.swiper-pagination {
position: relative;
display: flex;
justify-content: center;
align-items: center;
margin-top: 20px;
gap: 8px;
width: 100%;
height: auto;
min-height: 8px;

.dot {
width: 8px;
height: 8px;
min-width: 8px;
min-height: 8px;
background: #d1d5db;
border-radius: 50%;
cursor: pointer;
transition: all 0.3s ease;
display: inline-block;
flex-shrink: 0;
opacity: 1;
}

.dot.active {
background: #ef4444;
transform: scale(1.2);
}
}
}
</style>