web 跑马灯

108 阅读6分钟

实现循环跑马灯效果,如下图

PixPin_2025-10-10_15-27-02.gif

PixPin_2025-10-10_15-27-58.gif

  • 数据从左到右无缝循环滚动
  • 支持手动滚动和自动播放两种模式
  • 在自动播放模式下,鼠标移入元素可暂停滚动,鼠标移出恢复滚动
  • 可自定义自动播放的速度以及手动滚动的过渡效果时间

参数说明

参数名说明示例
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>