1、InfiniteSlider.vue
javascript
<template>
<div
ref="containerRef"
class="infinite-slider"
@mouseenter="pause"
@mouseleave="resume"
>
<div
class="slider-track"
:style="trackStyle"
>
<!-- 第一组 -->
<div
v-for="(item, index) in displayItems"
:key="`first-${item.id}-${index}`"
class="slider-item"
:style="getItemStyle(index)"
>
<slot name="item" :item="item">
<div class="default-content">
<div class="item-number">{{ index + 1 }}</div>
<h3>{{ item.title }}</h3>
<p>{{ item.description }}</p>
</div>
</slot>
</div>
<!-- 第二组(完全相同的副本) -->
<div
v-for="(item, index) in displayItems"
:key="`second-${item.id}-${index}`"
class="slider-item"
:style="getItemStyle(displayItems.length + index)"
>
<slot name="item" :item="item">
<div class="default-content">
<div class="item-number">{{ index + 1 }}</div>
<h3>{{ item.title }}</h3>
<p>{{ item.description }}</p>
</div>
</slot>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
const props = defineProps({
items: {
type: Array,
default: () => []
},
speed: {
type: Number,
default: 50
},
direction: {
type: String,
default: 'left',
validator: v => ['left', 'right'].includes(v)
},
pauseOnHover: {
type: Boolean,
default: true
},
itemWidth: {
type: Number,
default: 200
},
itemGap: {
type: Number,
default: 20
}
})
const emit = defineEmits(['item-click'])
// 响应式引用
const containerRef = ref(null)
const animationId = ref(null)
const isPlaying = ref(true)
const translateX = ref(0)
const trackWidth = ref(0)
const containerWidth = ref(0)
// 计算
const totalItemWidth = computed(() => props.itemWidth + props.itemGap)
const visibleItemCount = computed(() => {
if (!containerWidth.value) return 0
return Math.ceil(containerWidth.value / totalItemWidth.value) + 1
})
// 确保有足够的项目显示
const displayItems = computed(() => {
if (!props.items.length) return []
// 如果需要,重复项目以填满容器
const needed = visibleItemCount.value
if (props.items.length >= needed) {
return props.items
}
// 重复项目直到足够
const result = []
for (let i = 0; i < needed; i++) {
result.push(props.items[i % props.items.length])
}
return result
})
// 计算轨道总宽度
const totalTrackWidth = computed(() => {
return displayItems.value.length * totalItemWidth.value
})
// 轨道样式
const trackStyle = computed(() => ({
transform: `translateX(${translateX.value}px)`,
width: `${totalTrackWidth.value * 2}px`,
display: 'flex',
willChange: 'transform'
}))
// 项目样式
const getItemStyle = (index) => ({
width: `${props.itemWidth}px`,
marginRight: `${props.itemGap}px`,
flexShrink: 0
})
// 动画循环
const animate = () => {
if (!isPlaying.value || !displayItems.value.length) {
animationId.value = requestAnimationFrame(animate)
return
}
const speed = props.direction === 'left' ? -props.speed : props.speed
const delta = speed / 60 // 60fps
translateX.value += delta
// 当移动距离达到一个轨道宽度时,重置位置
if (Math.abs(translateX.value) >= totalTrackWidth.value) {
translateX.value = 0
}
animationId.value = requestAnimationFrame(animate)
}
// 暂停
const pause = () => {
if (props.pauseOnHover) {
isPlaying.value = false
}
}
// 恢复
const resume = () => {
isPlaying.value = true
}
// 计算尺寸
const calculateDimensions = () => {
if (containerRef.value) {
containerWidth.value = containerRef.value.clientWidth
}
}
// 初始化
const init = () => {
calculateDimensions()
translateX.value = 0
if (!animationId.value) {
animationId.value = requestAnimationFrame(animate)
}
}
// 监听窗口大小变化
const handleResize = () => {
calculateDimensions()
}
// 生命周期
onMounted(() => {
nextTick(() => {
init()
})
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
if (animationId.value) {
cancelAnimationFrame(animationId.value)
}
window.removeEventListener('resize', handleResize)
})
// 监听数据变化
watch(() => props.items, () => {
nextTick(() => {
calculateDimensions()
})
}, { deep: true })
// 暴露方法
defineExpose({
pause,
resume
})
</script>
<style scoped>
.infinite-slider {
width: 100%;
height: 300px;
overflow: hidden;
position: relative;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
will-change: transform;
transform: translateZ(0);
}
.slider-track {
position: absolute;
top: 0;
left: 0;
height: 100%;
will-change: transform;
backface-visibility: hidden;
perspective: 1000;
transform: translate3d(0, 0, 0);
display: flex;
align-items: center;
}
.slider-item {
flex-shrink: 0;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
will-change: transform;
transform: translateZ(0);
backface-visibility: hidden;
}
.default-content {
width: 100%;
height: calc(100% - 20px);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
color: white;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
cursor: pointer;
margin: 10px 0;
}
.default-content:hover {
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2);
}
.item-number {
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: bold;
margin-bottom: 15px;
}
.default-content h3 {
font-size: 16px;
font-weight: 600;
margin-bottom: 8px;
line-height: 1.3;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.default-content p {
font-size: 13px;
opacity: 0.9;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
2、使用
javascript
<template>
<div class="demo-app">
<div class="header">
<h1>完美无缝循环滑块</h1>
<div class="controls">
<button @click="togglePlay">
{{ isPlaying ? '⏸️ 暂停' : '▶️ 播放' }}
</button>
<button @click="changeSpeed(-10)" :disabled="speed <= 10">
🔽 减速
</button>
<button @click="changeSpeed(10)" :disabled="speed >= 200">
🔼 加速
</button>
<button @click="addItems">
➕ 添加5个项目
</button>
<button @click="resetItems">
🔄 重置
</button>
</div>
<div class="stats">
速度: {{ speed }}px/s | 项目数: {{ items.length }} | 方向: {{ direction }}
</div>
</div>
<div class="slider-wrapper">
<InfiniteSlider
:items="items"
:speed="speed"
:direction="direction"
:item-width="220"
:item-gap="15"
@item-click="handleItemClick"
>
<template #item="{ item }">
<div class="custom-item" @click="handleItemClick(item)">
<div class="item-header">
<div class="item-id">#{{ item.id }}</div>
<div
class="item-status"
:class="item.status"
>
{{ item.status === 'hot' ? '🔥 热销' : item.status === 'new' ? '🆕 新品' : '⭐ 推荐' }}
</div>
</div>
<div class="item-image">
<img :src="item.image" :alt="item.title" />
</div>
<div class="item-content">
<h3>{{ item.title }}</h3>
<p>{{ item.description }}</p>
<div class="item-footer">
<span class="item-category">{{ item.category }}</span>
<span class="item-price">{{ item.price }}</span>
</div>
</div>
</div>
</template>
</InfiniteSlider>
</div>
<div class="direction-controls">
<label>
<input
type="radio"
v-model="direction"
value="left"
>
向左滚动
</label>
<label>
<input
type="radio"
v-model="direction"
value="right"
>
向右滚动
</label>
</div>
<div class="info">
<p>💡 提示:鼠标悬停在滑块上可以暂停,移出恢复播放</p>
<p>🎯 特性:真正的无缝循环,不会出现空白间隙</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import InfiniteSlider from './InfiniteSlider.vue'
// 状态
const speed = ref(60)
const isPlaying = ref(true)
const direction = ref('left')
const items = ref([])
// 生成示例数据
const generateItems = (count) => {
const categories = ['电子产品', '家居用品', '服装鞋包', '美妆个护', '图书音像']
const statuses = ['hot', 'new', 'recommend']
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
title: `商品 ${i + 1}`,
description: `这是第 ${i + 1} 个商品的详细描述信息,可以包含更多内容展示`,
image: `https://picsum.photos/220/140?random=${i + 1}&t=${Date.now()}`,
category: categories[Math.floor(Math.random() * categories.length)],
status: statuses[Math.floor(Math.random() * statuses.length)],
price: `¥${Math.floor(Math.random() * 999) + 100}`
}))
}
// 切换播放状态
const togglePlay = () => {
isPlaying.value = !isPlaying.value
}
// 调整速度
const changeSpeed = (delta) => {
const newSpeed = speed.value + delta
speed.value = Math.max(10, Math.min(newSpeed, 200))
}
// 添加项目
const addItems = () => {
const newItems = generateItems(5)
items.value = [...items.value, ...newItems]
}
// 重置项目
const resetItems = () => {
items.value = generateItems(8)
}
// 处理项目点击
const handleItemClick = (item) => {
alert(`点击了: ${item.title}\n价格: ${item.price}\n分类: ${item.category}`)
}
// 初始化
onMounted(() => {
items.value = generateItems(8)
})
</script>
<style scoped>
.demo-app {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.header {
text-align: center;
margin-bottom: 30px;
}
.header h1 {
color: #333;
margin-bottom: 20px;
font-size: 28px;
}
.controls {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 15px;
}
.controls button {
padding: 8px 16px;
background: #4f46e5;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 6px;
}
.controls button:hover:not(:disabled) {
background: #4338ca;
transform: translateY(-1px);
}
.controls button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.stats {
color: #666;
font-size: 14px;
padding: 8px 16px;
background: #f8fafc;
border-radius: 6px;
display: inline-block;
}
.slider-wrapper {
width: 100%;
height: 320px;
background: linear-gradient(135deg, #667eea0d 0%, #764ba20d 100%);
border-radius: 16px;
overflow: hidden;
border: 1px solid #e5e7eb;
position: relative;
margin-bottom: 30px;
}
.custom-item {
height: 100%;
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
display: flex;
flex-direction: column;
position: relative;
}
.custom-item:hover {
transform: translateY(-6px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.15);
}
.item-header {
padding: 12px 15px 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.item-id {
font-size: 12px;
color: #6b7280;
font-weight: 500;
}
.item-status {
font-size: 11px;
padding: 4px 8px;
border-radius: 4px;
font-weight: 500;
}
.item-status.hot {
background: #fee2e2;
color: #dc2626;
}
.item-status.new {
background: #dbeafe;
color: #1d4ed8;
}
.item-status.recommend {
background: #f0fdf4;
color: #16a34a;
}
.item-image {
width: 100%;
height: 140px;
overflow: hidden;
position: relative;
}
.item-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.6s ease;
}
.custom-item:hover .item-image img {
transform: scale(1.1);
}
.item-content {
padding: 15px;
flex: 1;
display: flex;
flex-direction: column;
}
.item-content h3 {
margin: 0 0 8px 0;
font-size: 15px;
font-weight: 600;
color: #1f2937;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
}
.item-content p {
margin: 0 0 12px 0;
font-size: 13px;
color: #6b7280;
line-height: 1.4;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.item-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: auto;
}
.item-category {
font-size: 12px;
color: #4f46e5;
background: #eef2ff;
padding: 4px 8px;
border-radius: 4px;
font-weight: 500;
}
.item-price {
font-size: 16px;
font-weight: 700;
color: #059669;
}
.direction-controls {
display: flex;
gap: 20px;
justify-content: center;
margin-bottom: 20px;
}
.direction-controls label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
color: #4b5563;
font-size: 14px;
}
.direction-controls input[type="radio"] {
width: 16px;
height: 16px;
cursor: pointer;
}
.info {
text-align: center;
color: #6b7280;
font-size: 14px;
line-height: 1.6;
}
.info p {
margin: 5px 0;
}
</style>