实现循环跑马灯效果,如下图
- 数据从左到右无缝循环滚动
- 支持手动滚动和自动播放两种模式
- 在自动播放模式下,鼠标移入元素可暂停滚动,鼠标移出恢复滚动
- 可自定义自动播放的速度以及手动滚动的过渡效果时间
参数说明
| 参数名 | 说明 | 示例 |
|---|---|---|
| data | 走马灯数据 | 数组 |
| containerStyle | 容器样式 | 对象 |
| cardSpacing | 走马灯元素之间的间距,单位px | 数字:10 |
| cardNumber | 同屏显示数 | 数字:3 |
| isAutoPlay | 是否自动播放 | boolean:false |
| speed | 自动连续滚动速度,单位px | 数字:60 |
| transitionDuration | 手动翻页动画,单位ms | 数字:350 |
代码
vue2版本
<template>
<div
ref="containerRef"
class="marquee-container"
:style="containerStyle"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<div
v-if="isAutoPlay"
class="marquee-track auto"
ref="autoTrackRef"
:style="{
transform: `translateX(-${autoScrollPos}px)`,
gap: cardSpacing + 'px',
marginLeft: `-${cardSpacing}px`,
}"
>
<div
v-for="(item, idx) in autoDuplicated"
:key="`auto-${idx}`"
class="marquee-card"
:style="{ width: cardWidth + 'px' }"
>
<slot :data="item">{{ item }}</slot>
</div>
</div>
<div v-else class="manual-wrapper">
<button class="arrow left" @click="manualPrev">‹</button>
<div
class="marquee-track manual"
ref="manualTrackRef"
:style="{
transform: `translateX(-${manualTranslate}px)`,
transition: manualTransition ? `transform ${transitionDuration}ms ease` : 'none',
gap: cardSpacing + 'px',
marginLeft: `-${cardSpacing}px`,
}"
@transitionend="onManualTransitionEnd"
>
<div
v-for="(item, idx) in manualDisplayList"
:key="`manual-${idx}`"
class="marquee-card"
:style="{ width: cardWidth + 'px' }"
>
<slot :data="item">{{ item }}</slot>
</div>
</div>
<button class="arrow right" @click="manualNext">›</button>
</div>
</div>
</template>
<script>
// 注意:Vue 2 环境下,ResizeObserver 需要 polyfill 或用其他方式替代,
// 这里先保留 ResizeObserver 逻辑,但如果目标环境不支持,会回退到 window.resize。
export default {
// Vue 2 Options API
props: {
data: { type: Array, required: true }, // 走马灯数据
containerStyle: { type: Object, default: () => ({}) }, // 容器样式
cardSpacing: { type: Number, default: 10 }, // 走马灯元素之间的间距
cardNumber: { type: Number, default: 3 }, // 同屏显示数
isAutoPlay: { type: Boolean, default: false }, // 是否自动播放
speed: { type: Number, default: 60 }, // px (自动连续滚动速度)
transitionDuration: { type: Number, default: 350 }, // 手动翻页动画 ms
},
data() {
return {
/* 公共:卡片宽、单组总宽(不含重复) */
cardWidth: 0,
totalSingleWidth: 0, // (cardWidth + gap) * data.length
/* ----- 自动模式(continuous RAF) ----- */
autoScrollPos: 0, // px
rafId: null,
lastTs: 0,
isPaused: false,
/* ----- 手动模式(分页 + 前后预留项) ----- */
manualStartIndex: 0,
manualTranslate: 0,
manualTransition: false,
/* 监听 resize */
ro: null, // ResizeObserver instance
};
},
computed: {
/* ----- 自动模式(continuous RAF) ----- */
autoDuplicated() {
// duplicated list for seamless continuous scroll
return this.data && this.data.length ? this.data.concat(this.data) : [];
},
/* ----- 手动模式(分页 + 前后预留项) ----- */
effectiveCardNumber() {
return Math.min(this.cardNumber, Math.max(1, this.data.length));
},
manualDisplayList() {
// 前后各多渲染一个,用于无缝过渡
const out = [];
const len = this.data.length;
if (len === 0) return out;
for (let i = -1; i < this.effectiveCardNumber + 1; i++) {
const idx = (this.manualStartIndex + i + len) % len;
out.push(this.data[idx]);
}
return out;
},
},
watch: {
isAutoPlay: {
immediate: true,
handler(val) {
// 切换模式:确保各自状态初始化与清理
if (val) {
// start auto
this.$nextTick(() => {
this.computeSizes();
this.startAuto();
});
} else {
// stop auto, init manual position
this.stopAuto();
this.manualTransition = false;
this.manualStartIndex = 0;
this.manualTranslate = this.cardWidth + this.cardSpacing;
}
},
},
data: {
deep: true,
immediate: true,
handler() {
// 数据更新要重新计算尺寸和位置
this.$nextTick(() => {
this.computeSizes();
});
},
},
},
methods: {
/* ---------------- 计算与工具 ---------------- */
computeSizes() {
const cont = this.$refs.containerRef;
if (!cont) return;
const cw = cont.clientWidth;
// 使用 effectiveCardNumber 计算卡片宽(保证一屏显示 N 个)
const visible = this.effectiveCardNumber;
const w = (cw - this.cardSpacing * (visible - 1)) / visible;
this.cardWidth = Math.max(0, Math.floor(w)); // 向下取整以避免小数视觉误差
this.totalSingleWidth = (this.cardWidth + this.cardSpacing) * this.data.length;
// 初始化手动位置到“中心”(预留一项的中间位置)
this.manualTranslate = this.cardWidth + this.cardSpacing;
// 保证 autoScrollPos 在合法范围
if (this.totalSingleWidth > 0) {
this.autoScrollPos =
((this.autoScrollPos % this.totalSingleWidth) + this.totalSingleWidth) %
this.totalSingleWidth;
} else {
this.autoScrollPos = 0;
}
},
/* 监听 resize(使用 ResizeObserver 更可靠) */
startResizeObserver() {
if (typeof ResizeObserver !== 'undefined' && this.$refs.containerRef) {
this.ro = new ResizeObserver(() => {
this.computeSizes();
});
this.ro.observe(this.$refs.containerRef);
} else {
window.addEventListener('resize', this.computeSizes);
}
},
stopResizeObserver() {
if (this.ro && this.$refs.containerRef) {
this.ro.unobserve(this.$refs.containerRef);
this.ro.disconnect();
this.ro = null;
} else {
window.removeEventListener('resize', this.computeSizes);
}
},
/* ---------------- 自动播放 RAF ---------------- */
rafLoop(ts) {
if (!this.lastTs) this.lastTs = ts;
const dt = ts - this.lastTs;
this.lastTs = ts;
if (this.isAutoPlay && !this.isPaused && this.totalSingleWidth > 0) {
// delta px based on speed (px/sec)
const delta = (this.speed * dt) / 1000;
this.autoScrollPos += delta;
// seamless wrap: 当超过单组宽度, 减去单组宽度并保留超出的部分,避免“回0”的瞬间跳动
if (this.autoScrollPos >= this.totalSingleWidth) {
this.autoScrollPos = this.autoScrollPos - this.totalSingleWidth;
}
}
this.rafId = requestAnimationFrame(this.rafLoop);
},
startAuto() {
this.stopAuto();
this.lastTs = 0;
this.rafId = requestAnimationFrame(this.rafLoop);
},
stopAuto() {
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this.lastTs = 0;
},
/* ---------------- 手动翻页逻辑(基于 manualTranslate) ---------------- */
manualNext() {
if (this.manualTransition) return;
this.manualTransition = true;
// move one card to the right visually (translate increases)
this.manualTranslate += this.cardWidth + this.cardSpacing;
},
manualPrev() {
if (this.manualTransition) return;
this.manualTransition = true;
this.manualTranslate -= this.cardWidth + this.cardSpacing;
},
onManualTransitionEnd() {
// 在 transition 完成后,把 startIndex调整,并把 translate 重置回中间位置(无过渡)
const center = this.cardWidth + this.cardSpacing;
// moveDistance = manualTranslate - center
const moveDistance = this.manualTranslate - center;
if (Math.abs(moveDistance) < 1) {
this.manualTransition = false;
this.manualTranslate = center;
return;
}
const step = Math.round(Math.abs(moveDistance) / (this.cardWidth + this.cardSpacing));
if (moveDistance > 0) {
// 向右移动了 step 个(下一页)
this.manualStartIndex = (this.manualStartIndex + step) % this.data.length;
} else {
// 向左移动了 step 个(上一页)
this.manualStartIndex =
(this.manualStartIndex - step + this.data.length) % this.data.length;
}
// 立即关闭 transition,并重置 translate 到 center(无过渡)
this.manualTransition = false;
// 强制下一 tick 设回中心,避免瞬跳可见(这里是同步设置,但因为 transition 关闭,不会动画)
this.manualTranslate = center;
},
/* ---------------- 鼠标悬停暂停/恢复 ---------------- */
handleMouseEnter() {
if (this.isAutoPlay) this.isPaused = true;
},
handleMouseLeave() {
if (this.isAutoPlay) this.isPaused = false;
},
},
/* ---------------- 生命周期 ---------------- */
mounted() {
this.computeSizes();
this.startResizeObserver();
if (this.isAutoPlay) this.startAuto();
},
beforeDestroy() {
this.stopAuto();
this.stopResizeObserver();
},
};
</script>
<style scoped>
.marquee-container {
width: 100%;
overflow: hidden;
position: relative;
box-sizing: border-box;
}
/* 通用 track */
.marquee-track {
display: flex;
align-items: center;
will-change: transform;
/* 不在这里写 transition,手动/自动分别控制 */
}
/* 卡片基础样式,可自定义 slot 内部 */
.marquee-card {
flex: 0 0 auto;
box-sizing: border-box;
user-select: none;
overflow: hidden;
}
/* 手动模式包裹:按钮在左右 */
.manual-wrapper {
position: relative;
}
/* 按钮 */
.arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 20;
width: 36px;
height: 56px;
border-radius: 8px;
color: #fff;
background: rgba(0, 0, 0, 0.45);
border: none;
font-size: 22px;
cursor: pointer;
}
.left {
left: 8px;
}
.right {
right: 8px;
}
</style>
vue3版本
<template>
<div
ref="containerRef"
class="marquee-container"
:style="containerStyle"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<!-- 自动播放模式 -->
<div
v-if="isAutoPlay"
class="marquee-track auto"
ref="autoTrackRef"
:style="{
transform: `translateX(-${autoScrollPos}px)`,
gap: cardSpacing + 'px',
marginLeft: `-${cardSpacing}px`,
}"
>
<div
v-for="(item, idx) in autoDuplicated"
:key="`auto-${idx}`"
class="marquee-card"
:style="{ width: cardWidth + 'px' }"
>
<slot :data="item">{{ item }}</slot>
</div>
</div>
<!-- 手动翻页模式 -->
<div v-else class="manual-wrapper">
<button class="arrow left" @click="manualPrev">‹</button>
<div
class="marquee-track manual"
ref="manualTrackRef"
:style="{
transform: `translateX(-${manualTranslate}px)`,
transition: manualTransition ? `transform ${transitionDuration}ms ease` : 'none',
gap: cardSpacing + 'px',
marginLeft: `-${cardSpacing}px`,
}"
@transitionend="onManualTransitionEnd"
>
<div
v-for="(item, idx) in manualDisplayList"
:key="`manual-${idx}`"
class="marquee-card"
:style="{ width: cardWidth + 'px' }"
>
<slot :data="item">{{ item }}</slot>
</div>
</div>
<button class="arrow right" @click="manualNext">›</button>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue';
/* ---------------- props ---------------- */
const props = defineProps({
data: { type: Array, required: true }, // 走马灯数据
containerStyle: { type: Object, default: () => ({}) }, // 容器样式
cardSpacing: { type: Number, default: 10 }, // 走马灯元素之间的间距
cardNumber: { type: Number, default: 3 }, // 同屏显示数
isAutoPlay: { type: Boolean, default: false }, // 是否自动播放
speed: { type: Number, default: 60 }, // px (自动连续滚动速度)
transitionDuration: { type: Number, default: 350 }, // 手动翻页动画 ms
});
/* ---------------- refs & state ---------------- */
const containerRef = ref(null);
const autoTrackRef = ref(null);
const manualTrackRef = ref(null);
/* 公共:卡片宽、单组总宽(不含重复) */
const cardWidth = ref(0);
const totalSingleWidth = ref(0); // (cardWidth + gap) * data.length
/* ----- 自动模式(continuous RAF) ----- */
const autoDuplicated = computed(() => {
// duplicated list for seamless continuous scroll
return props.data && props.data.length ? props.data.concat(props.data) : [];
});
const autoScrollPos = ref(0); // px
let rafId = null;
let lastTs = 0;
const isPaused = ref(false);
/* ----- 手动模式(分页 + 前后预留项) ----- */
const effectiveCardNumber = computed(() =>
Math.min(props.cardNumber, Math.max(1, props.data.length))
);
const manualDisplayList = computed(() => {
// 前后各多渲染一个,用于无缝过渡
const out = [];
const len = props.data.length;
if (len === 0) return out;
for (let i = -1; i < effectiveCardNumber.value + 1; i++) {
const idx = (manualStartIndex.value + i + len) % len;
out.push(props.data[idx]);
}
return out;
});
const manualStartIndex = ref(0);
const manualTranslate = ref(0);
const manualTransition = ref(false);
/* ---------------- 计算与工具 ---------------- */
function computeSizes() {
const cont = containerRef.value;
if (!cont) return;
const cw = cont.clientWidth;
// 使用 effectiveCardNumber 计算卡片宽(保证一屏显示 N 个)
const visible = effectiveCardNumber.value;
const w = (cw - props.cardSpacing * (visible - 1)) / visible;
cardWidth.value = Math.max(0, Math.floor(w)); // 向下取整以避免小数视觉误差
totalSingleWidth.value = (cardWidth.value + props.cardSpacing) * props.data.length;
// 初始化手动位置到“中心”(预留一项的中间位置)
manualTranslate.value = cardWidth.value + props.cardSpacing;
// 保证 autoScrollPos 在合法范围
if (totalSingleWidth.value > 0) {
autoScrollPos.value =
((autoScrollPos.value % totalSingleWidth.value) + totalSingleWidth.value) %
totalSingleWidth.value;
} else {
autoScrollPos.value = 0;
}
}
/* 监听 resize(使用 ResizeObserver 更可靠) */
let ro = null;
function startResizeObserver() {
if (typeof ResizeObserver !== 'undefined' && containerRef.value) {
ro = new ResizeObserver(() => {
computeSizes();
});
ro.observe(containerRef.value);
} else {
window.addEventListener('resize', computeSizes);
}
}
function stopResizeObserver() {
if (ro && containerRef.value) {
ro.unobserve(containerRef.value);
ro.disconnect();
ro = null;
} else {
window.removeEventListener('resize', computeSizes);
}
}
/* ---------------- 自动播放 RAF ---------------- */
function rafLoop(ts) {
if (!lastTs) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (props.isAutoPlay && !isPaused.value && totalSingleWidth.value > 0) {
// delta px based on speed (px/sec)
const delta = (props.speed * dt) / 1000;
autoScrollPos.value += delta;
// seamless wrap: 当超过单组宽度, 减去单组宽度并保留超出的部分,避免“回0”的瞬间跳动
if (autoScrollPos.value >= totalSingleWidth.value) {
autoScrollPos.value = autoScrollPos.value - totalSingleWidth.value;
}
}
rafId = requestAnimationFrame(rafLoop);
}
function startAuto() {
stopAuto();
lastTs = 0;
rafId = requestAnimationFrame(rafLoop);
}
function stopAuto() {
if (rafId) {
cancelAnimationFrame(rafId);
rafId = null;
}
lastTs = 0;
}
/* ---------------- 手动翻页逻辑(基于 manualTranslate) ---------------- */
function manualNext() {
if (manualTransition.value) return;
manualTransition.value = true;
// move one card to the right visually (translate increases)
manualTranslate.value += cardWidth.value + props.cardSpacing;
}
function manualPrev() {
if (manualTransition.value) return;
manualTransition.value = true;
manualTranslate.value -= cardWidth.value + props.cardSpacing;
}
function onManualTransitionEnd() {
// 在 transition 完成后,把 startIndex调整,并把 translate 重置回中间位置(无过渡)
const center = cardWidth.value + props.cardSpacing;
// moveDistance = manualTranslate - center
const moveDistance = manualTranslate.value - center;
if (Math.abs(moveDistance) < 1) {
manualTransition.value = false;
manualTranslate.value = center;
return;
}
const step = Math.round(Math.abs(moveDistance) / (cardWidth.value + props.cardSpacing));
if (moveDistance > 0) {
// 向右移动了 step 个(下一页)
manualStartIndex.value = (manualStartIndex.value + step) % props.data.length;
} else {
// 向左移动了 step 个(上一页)
manualStartIndex.value =
(manualStartIndex.value - step + props.data.length) % props.data.length;
}
// 立即关闭 transition,并重置 translate 到 center(无过渡)
manualTransition.value = false;
// 强制下一 tick 设回中心,避免瞬跳可见(这里是同步设置,但因为 transition 关闭,不会动画)
manualTranslate.value = center;
}
/* ---------------- 鼠标悬停暂停/恢复 ---------------- */
function handleMouseEnter() {
if (props.isAutoPlay) isPaused.value = true;
}
function handleMouseLeave() {
if (props.isAutoPlay) isPaused.value = false;
}
/* ---------------- 初始化与切换模式 ---------------- */
watch(
() => props.isAutoPlay,
(val) => {
// 切换模式:确保各自状态初始化与清理
if (val) {
// start auto
computeSizes();
startAuto();
} else {
// stop auto, init manual position
stopAuto();
manualTransition.value = false;
manualStartIndex.value = 0;
manualTranslate.value = cardWidth.value + props.cardSpacing;
}
},
{ immediate: true }
);
watch(
() => props.data,
async () => {
// 数据更新要重新计算尺寸和位置
await nextTick();
computeSizes();
},
{ deep: true, immediate: true }
);
/* ---------------- 生命周期 ---------------- */
onMounted(() => {
computeSizes();
startResizeObserver();
if (props.isAutoPlay) startAuto();
});
onUnmounted(() => {
stopAuto();
stopResizeObserver();
});
</script>
<style scoped>
.marquee-container {
width: 100%;
overflow: hidden;
position: relative;
box-sizing: border-box;
}
/* 通用 track */
.marquee-track {
display: flex;
align-items: center;
will-change: transform;
/* 不在这里写 transition,手动/自动分别控制 */
}
/* 卡片基础样式,可自定义 slot 内部 */
.marquee-card {
flex: 0 0 auto;
box-sizing: border-box;
user-select: none;
overflow: hidden;
}
/* 手动模式包裹:按钮在左右 */
.manual-wrapper {
position: relative;
}
/* 按钮 */
.arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 20;
width: 36px;
height: 56px;
border-radius: 8px;
color: #fff;
background: rgba(0, 0, 0, 0.45);
border: none;
font-size: 22px;
cursor: pointer;
}
.left {
left: 8px;
}
.right {
right: 8px;
}
/* 小优化:强制使用 flex gap 支持(大多数现代浏览器支持) */
.marquee-track.manual {
gap: 12px;
}
.marquee-track.auto {
gap: 12px;
}
</style>