轮播图组件
- 由于直接使用组件库的轮播图,例如指示器交互效果,需要等到客户端渲染期间才会出来,这不利于用户体验
实现轮播图效果
- SSR 阶段:轮播 DOM + 分页器静态结构都输出,避免白屏/丢失指示器。
- 客户端阶段:Swiper 接管 DOM,分页器变为交互式(圆点高亮 + 可点击)
Swiper API
| API / 参数 | 类型 | 说明 | 企业级使用建议 |
|---|---|---|---|
slidesPerView | number / 'auto' | 每屏显示的数量 | PC 建议固定数值,移动端用 auto |
spaceBetween | number | slide 间距 | 广告/卡片类推荐 8-16px |
loop | boolean | 循环模式 | 谨慎开启,数据动态更新时容易出错 |
autoplay | object | 自动播放设置 { delay, disableOnInteraction } | 建议加上 pauseOnMouseEnter: true,提升 UX |
lazy | object | 图片懒加载 | 企业级必须开启,减少首屏压力 |
pagination | object | 分页器 | SSR 注意延迟渲染,服务端需输出结构 |
navigation | object | 前进/后退按钮 | 自定义 UI,避免与设计冲突 |
effect | string | 'slide' / 'fade' / 'cube' / 'coverflow' / 'flip' | 首页 Banner 推荐 fade |
breakpoints | object | 响应式配置 | 必须为移动端和 PC 提供不同配置 |
observer | boolean | 自动监测 Swiper/Slide 变化 | 动态数据场景必开 |
virtual | object | 虚拟列表 | 大量数据场景必开(如小说章节) |
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组件
<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>