前言
在直播场景中,横幅通知(Banner Notification)是最常见的消息触达方式之一------欢迎新用户、通知活动上线、推送热门方案更新......一个好的横幅组件不仅要视觉上融入直播间氛围,还要在动画体验上做到流畅自然。
最近我们在 uni-app 项目中开发了一个直播间横幅组件,核心交互是这样的:横幅从屏幕右侧缓缓出现 → 在屏幕中间停留设定时间 → 最后从左侧缓缓离开,并支持位置(上/中/下)、颜色、循环播放、长文本自动滚动等灵活配置。这篇文章将分享组件从设计到实现的全过程,以及踩过的坑和解决方案。
需求分析
我们需要一个通用横幅组件,核心能力包括:
| 需求 | 说明 |
|---|---|
| 动画流程 | 右侧进入 → 屏幕中央停留 → 左侧退出 |
| 位置控制 | 支持顶部、中间、底部三个位置 |
| 时钟控制 | 可配置停留时长 |
| 视觉定制 | 支持自定义背景色、文字颜色 |
| 点击交互 | 点击横幅可触发回调,跳转或执行操作 |
| 循环播放 | 支持循环出现,可配置循环间隔 |
| 长文本适配 | 内容超出横幅宽度时自动横向滚动 |
| 内容插槽 | 支持 slot 自定义复杂内容 |
架构设计
组件定位
横幅组件作为直播间页面的子组件,通过 v-if + key 控制挂载和重建,每次 triggerBanner() 调用都会强制生成新实例,避免动画状态残留。
room.vue
└─ live-banner.vue ← 纯展示 + 动画逻辑,不关心业务数据
父页面通过 props 下发配置,通过 events 监听横幅生命周期和点击事件,职责清晰单一。
动画引擎:三相位状态机
这是组件最核心也最"坑"的部分。
问题起源:为什么动画会直接"闪现"?
最初我们是这么写的:
// 初始状态
style.transform = 'translateX(110%)'; // 右侧外面
// 下一帧切换到目标位置
requestAnimationFrame(() => {
style.transform = 'translateX(0)'; // 屏幕中央
});
但在实际运行中,浏览器可能把初始位置和目标位置合并到同一帧渲染,CSS transition 被直接跳过------横幅"瞬移"到屏幕中央,动画效果完全丢失。
解决方案:三相位状态机
引入了 animationPhase 状态机,每个相位严格分离,且初始相位禁用 transition:
Phase 0 Phase 1 Phase 2
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐
│ 右侧外(110%) │ │ 屏幕中央(0%) │ │ 左侧外(-110%)│
│ opacity: 0 │ │ opacity: 1 │ │ opacity: 0 │
│ 无 transition │ │ 有 transition │ │ 有 transition │
└──────────────┘ └──────────────────┘ └──────────────┘
│ │ │
│ 双层 RAF 后 │ 停留 duration 后 │ 退出动画完成后
└─────────────────────┘──────────────────────┘
const runAnimationCycle = () => {
animationPhase.value = 0; // Phase 0:位置在右边外面,无过渡
visible.value = true;
// 双层 RAF:确保 Phase 0 已被浏览器绘制
requestAnimationFrame(() => {
requestAnimationFrame(() => {
animationPhase.value = 1; // Phase 1:触发进入过渡
});
});
// 停留设定时间后退出
exitTimer = setTimeout(() => {
animationPhase.value = 2; // Phase 2:触发退出过渡
}, TRANSITION_DURATION + props.duration);
};
对应的 computed 样式:
const bannerStyle = computed(() => {
switch (animationPhase.value) {
case 0:
style.transform = 'translateX(110%)';
style.opacity = 0;
// 无 transition
break;
case 1:
style.transform = 'translateX(0)';
style.opacity = 1;
style.transition = `transform 600ms cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 600ms ease-in-out`;
break;
case 2:
style.transform = 'translateX(-110%)';
style.opacity = 0;
style.transition = `transform 600ms cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 600ms ease-in-out`;
break;
}
});
关键点:Phase 0 不设置 transition,加上双层 requestAnimationFrame 确保浏览器完整绘制了 Phase 0 的初始状态后再切换到 Phase 1,CSS 过渡才能正常触发。
长文本滚动:量体裁衣
当横幅内容(如"🔥 恭喜用户 XXX 在本次竞猜中获得 100000 积分奖励")超出横幅宽度时,我们希望它能自动滚动展示,而不是截断或换行。
溢出检测
通过 uni.createSelectorQuery() 测量容器宽度和文本实际宽度:
const measureOverflow = () => {
nextTick(() => {
const query = uni.createSelectorQuery();
query.select('.banner-text-wrap').boundingClientRect();
query.select('.banner-scroll-track').boundingClientRect();
query.exec((rects) => {
if (rects[0] && rects[1]) {
if (rects[1].width > rects[0].width) {
isOverflow.value = true;
}
}
});
});
};
滚动机制
使用 translateX + transition: linear 实现平滑滚动:
const scrollDistance = computed(() => textWidth.value - wrapWidth.value);
const scrollDuration = computed(() => Math.ceil(scrollDistance.value / 0.04)); // ~40px/s
const scrollStyle = computed(() => {
if (isOverflow.value && isMarqueeActive.value) {
return {
transform: `translateX(${-scrollDistance.value}px)`,
transition: `transform ${scrollDuration.value}ms linear`,
};
}
});
为了防止滚动提前启动(溢出检测还没完成时动画就已经开始了),我们加了三重保险:
watch(animationPhase)--- Phase 1 时尝试启动滚动measureOverflow()回调末尾 --- 检测完成后再次尝试scheduleMarquee()内部守卫 --- 只有isOverflow && phase === 1才真正启动
文本副本
为了实现视觉上的无缝循环,溢出的文本复制一份并追加在后面,用空格间隔:
<view class="banner-scroll-track">
<text>{{ content }}</text>
<text v-if="isOverflow"> </text>
<text v-if="isOverflow">{{ content }}</text>
</view>
这样无论滚动到哪个位置,都能看到完整的文本内容。
循环机制:Vue key 重建策略
最初我们遇到了一个经典的 Vue 状态残留问题:两个 triggerBanner 调用时间靠近时,第二个横幅的 props 只是覆盖到了同个组件实例上,旧实例的动画周期执行完毕后会 emit('close') → 父级销毁组件 → 第二个横幅根本没机会展示。
解决方案是使用 :key 强制重建:
<live-banner :key="bannerKey" ... />
每次调用 triggerBanner() 时递增 bannerKey:
const triggerBanner = (content, position, duration, options) => {
bannerKey.value++; // 递增 key,强制 Vue 销毁旧组件创建新组件
// ... 设置其他 props ...
bannerVisible.value = true;
};
对于循环模式 ,还做了一个关键设计:循环时不 emit('close'),避免父级销毁组件中断循环:
// 退出动画完成后
if (props.loop) {
// 循环模式:不通知父级,interval 后重新开始
restartTimer = setTimeout(() => runAnimationCycle(), props.interval);
} else {
emit('close'); // 非循环:通知父级销毁
}
组件完整 Props 一览
| Prop | 类型 | 默认值 | 说明 |
|---|---|---|---|
content |
String | '' |
横幅文本内容 |
position |
String | 'top' |
显示位置:top/middle/bottom |
duration |
Number | 3000 |
屏幕中央停留时间(ms) |
bgColor |
String | '' |
自定义背景色,默认红色渐变 |
textColor |
String | '' |
自定义文字颜色,默认白色 |
loop |
Boolean | false |
是否循环出现 |
interval |
Number | 3000 |
循环间隔时间(ms) |
在页面中使用
<script setup>
const triggerBanner = (content, position = 'top', duration = 3000, options = {}) => {
bannerKey.value++;
bannerContent.value = content;
bannerPosition.value = position;
bannerDuration.value = duration;
bannerBgColor.value = options.bgColor || '';
bannerTextColor.value = options.textColor || '';
bannerLoop.value = options.loop || false;
bannerInterval.value = options.interval || 3000;
bannerVisible.value = true;
};
// 使用示例:单次横幅
triggerBanner('🎉 欢迎进入直播间!', 'top', 3000);
// 使用示例:循环横幅 + 自定义颜色
triggerBanner('💬 参与互动赢好礼', 'bottom', 3000, {
bgColor: 'linear-gradient(135deg, rgba(50,150,255,0.95), rgba(30,100,220,0.95))',
textColor: '#fff',
loop: true,
interval: 5000,
});
</script>
性能与可维护性
-
定时器统一管理 ---
exitTimer/hideTimer/restartTimer/marqueeTimer四个定时器,在onUnmounted中全部清理,防止内存泄漏 -
组件自包含 --- 所有动画逻辑封装在组件内部,父页面只需调
triggerBanner和监听@click/@close -
平台兼容 --- 使用
uni.createSelectorQuery()而非 DOM API,确保在 H5 / 小程序 / App 各端一致
踩坑总结
| 坑 | 原因 | 解决方案 |
|---|---|---|
| 横幅直接闪现没有过渡 | 单层 RAF 不足以将初始位置和目标位置分离到不同帧 | 双层 RAF + Phase 0 禁用 transition |
| 循环不生效 | 循环结束后 emit('close') 导致父级销毁组件 |
循环模式不 emit close,内部自管理 |
| 第二个横幅不展示 | 更新 props 但组件还在执行旧周期,旧周期完成时 emit close 销毁了组件 | 用 :key 递增强制重建 |
| 长文本不滚动 | measureOverflow 异步未完成时动画已进入 Phase 1 |
watch + 回调 + 函数内三重守卫 |
完整组件代码
<template>
<view v-if="visible" class="live-banner" :class="positionClass" @click="handleClick">
<view class="banner-body" :style="bannerStyle">
<slot>
<view class="banner-text-wrap" ref="wrapRef">
<view
class="banner-scroll-track"
:class="{ 'marquee-loop': isOverflow && isMarqueeActive }"
:style="marqueeActiveStyle"
ref="trackRef"
>
<text class="banner-text">{{ content }}</text>
<text v-if="isOverflow" class="banner-text separator"> </text>
<text v-if="isOverflow" class="banner-text">{{ content }}</text>
</view>
</view>
</slot>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue';
const props = defineProps({
/** 横幅文本内容(可通过 slot 自定义更复杂的内容) */
content: { type: String, default: '' },
/** 显示位置:top-顶部 / middle-中间 / bottom-底部 */
position: { type: String, default: 'top' },
/** 在屏幕中央的停留时间,单位毫秒 */
duration: { type: Number, default: 3000 },
/** 自定义背景色(默认红色渐变) */
bgColor: { type: String, default: '' },
/** 自定义文字颜色(默认白色) */
textColor: { type: String, default: '' },
/** 是否循环出现 */
loop: { type: Boolean, default: false },
/** 循环间隔时间,单位毫秒,仅 loop=true 时生效 */
interval: { type: Number, default: 3000 },
});
const emit = defineEmits(['close', 'click']);
/** 过渡动画时长(进入/退出各 600ms) */
const TRANSITION_DURATION = 600;
const visible = ref(true);
/**
* 动画相位:
* 0 --- 初始渲染,右侧外 off-screen,无过渡
* 1 --- 进入动画,过渡到屏幕中央
* 2 --- 退出动画,过渡到左侧外 off-screen
* 3 --- 隐藏
*/
const animationPhase = ref(0);
const positionClass = computed(() => `banner-${props.position}`);
/** 根据动画相位计算样式,确保初始帧无 transition,后续帧才追加过渡 */
const bannerStyle = computed(() => {
const style = {
background:
props.bgColor ||
'linear-gradient(135deg, rgba(255, 75, 75, 0.95), rgba(220, 30, 50, 0.95))',
color: props.textColor || '#fff',
};
switch (animationPhase.value) {
case 0:
style.transform = 'translateX(110%)';
style.opacity = 0;
break;
case 1:
style.transform = 'translateX(0)';
style.opacity = 1;
style.transition = `transform ${TRANSITION_DURATION}ms cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity ${TRANSITION_DURATION}ms ease-in-out`;
break;
case 2:
style.transform = 'translateX(-110%)';
style.opacity = 0;
style.transition = `transform ${TRANSITION_DURATION}ms cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity ${TRANSITION_DURATION}ms ease-in-out`;
break;
}
return style;
});
// ==================== 长文本滚动检测与动画 ====================
const wrapRef = ref(null);
const trackRef = ref(null);
const isOverflow = ref(false);
const isMarqueeActive = ref(false);
const wrapWidth = ref(0);
const textWidth = ref(0);
/** 滚动速度 px/ms,约 40px/s */
const SCROLL_SPEED = 0.04;
/** 跑马灯单次循环时长(移动半个 track 宽度 = 一份文本的距离) */
const marqueeDuration = computed(() =>
textWidth.value > 0 ? Math.ceil(textWidth.value / 2 / SCROLL_SPEED) : 0
);
/** 尝试启动文本滚动(需等溢出检测完成 + 进入动画结束) */
const scheduleMarquee = () => {
if (isOverflow.value && animationPhase.value === 1 && !marqueeTimer) {
marqueeTimer = setTimeout(() => {
isMarqueeActive.value = true;
}, TRANSITION_DURATION);
}
};
// 监听到 phase 1 时尝试启动滚动(isOverflow 可能已提前就绪)
watch(animationPhase, (phase) => {
if (phase === 1) {
scheduleMarquee();
}
});
/** 跑马灯激活样式(通过 CSS 自定义属性控制动画时长,实现无限循环) */
const marqueeActiveStyle = computed(() => {
if (isOverflow.value && isMarqueeActive.value && marqueeDuration.value > 0) {
return {
'--marquee-duration': `${marqueeDuration.value}ms`,
};
}
return {};
});
/** 检测文本是否溢出容器宽度 */
const measureOverflow = () => {
nextTick(() => {
const query = uni.createSelectorQuery();
query.select('.banner-text-wrap').boundingClientRect();
query.select('.banner-scroll-track').boundingClientRect();
query.exec((rects) => {
const wrap = rects[0];
const track = rects[1];
if (wrap && track) {
wrapWidth.value = wrap.width;
if (track.width > wrap.width) {
isOverflow.value = true;
textWidth.value = track.width;
} else {
isOverflow.value = false;
}
}
// 溢出检测完成后重试启动滚动(此时 animationPhase 可能已经是 1)
scheduleMarquee();
});
});
};
const handleClick = () => {
emit('click');
};
let exitTimer = null;
let hideTimer = null;
let restartTimer = null;
let marqueeTimer = null;
const clearAnimationTimers = () => {
if (exitTimer) { clearTimeout(exitTimer); exitTimer = null; }
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
if (restartTimer) { clearTimeout(restartTimer); restartTimer = null; }
if (marqueeTimer) { clearTimeout(marqueeTimer); marqueeTimer = null; }
};
const runAnimationCycle = () => {
animationPhase.value = 0;
visible.value = true;
isMarqueeActive.value = false;
// 清除旧周期的 marquee 定时器,确保新周期 scheduleMarquee 能正常工作
if (marqueeTimer) {
clearTimeout(marqueeTimer);
marqueeTimer = null;
}
// 每次新周期重新检测溢出
measureOverflow();
// 双层 requestAnimationFrame:确保初始 off-screen 位置已被浏览器绘制后,再触发进入过渡
requestAnimationFrame(() => {
requestAnimationFrame(() => {
animationPhase.value = 1;
});
});
// 停留 duration 后开始退出:从中央缓缓滑出到左侧
exitTimer = setTimeout(() => {
isMarqueeActive.value = false;
animationPhase.value = 2;
// 退出动画完成后隐藏组件并通知父级
hideTimer = setTimeout(() => {
visible.value = false;
if (props.loop) {
// 循环模式:不通知父级,间隔 interval 后重新开始
restartTimer = setTimeout(() => {
restartTimer = null;
runAnimationCycle();
}, props.interval);
} else {
// 非循环:通知父级销毁组件
emit('close');
}
}, TRANSITION_DURATION);
}, TRANSITION_DURATION + props.duration);
};
onMounted(() => {
runAnimationCycle();
});
onUnmounted(() => {
clearAnimationTimers();
});
</script>
<style lang="scss" scoped>
.live-banner {
position: fixed;
left: 0;
right: 0;
z-index: 100;
pointer-events: auto;
overflow: hidden;
}
/* ========== 三种位置 ========== */
.banner-top {
top: 160rpx;
}
.banner-middle {
top: 50%;
transform: translateY(-50%);
}
.banner-bottom {
bottom: 280rpx;
}
/* ========== 横幅内容样式 ========== */
.banner-body {
margin: 0 60rpx;
padding: 22rpx 40rpx;
border-radius: 16rpx;
font-size: 28rpx;
font-weight: 500;
text-align: center;
line-height: 1.5;
letter-spacing: 2rpx;
box-shadow: 0 6rpx 24rpx rgba(0, 0, 0, 0.3);
overflow: hidden;
cursor: pointer;
}
.banner-text-wrap {
overflow: hidden;
width: 100%;
position: relative;
}
@keyframes marquee-loop {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
.banner-scroll-track {
display: flex;
white-space: nowrap;
width: fit-content;
align-items: center;
}
.banner-scroll-track.marquee-loop {
animation: marquee-loop var(--marquee-duration, 3s) linear infinite;
}
.banner-text {
flex-shrink: 0;
display: inline-block;
white-space: nowrap;
}
.banner-text.separator {
flex-shrink: 0;
}
</style>
最后
这个横幅组件经历了几轮迭代,从最初的简单"从右到左"动画,逐步演进到支持位置控制、颜色定制、循环播放、长文本自适应滚动等能力。核心的三相位状态机设计思路其实可以复用到很多有入场→展示→退场三阶段需求的 UI 组件上(Toast、引导提示、跑马灯等)。
完整的组件代码已在项目中投入使用,如果有类似需求的朋友,欢迎参考这个设计思路。代码量不大(约 290 行),但每一个细节都经过实际场景的验证。
如果你对 uni-app / Vue 组件开发感兴趣,欢迎留言交流!