
前言在直播场景中横幅通知Banner Notification是最常见的消息触达方式之一——欢迎新用户、通知活动上线、推送热门方案更新……一个好的横幅组件不仅要视觉上融入直播间氛围还要在动画体验上做到流畅自然。最近我们在 uni-app 项目中开发了一个直播间横幅组件核心交互是这样的横幅从屏幕右侧缓缓出现 → 在屏幕中间停留设定时间 → 最后从左侧缓缓离开并支持位置上/中/下、颜色、循环播放、长文本自动滚动等灵活配置。这篇文章将分享组件从设计到实现的全过程以及踩过的坑和解决方案。需求分析我们需要一个通用横幅组件核心能力包括需求说明动画流程右侧进入 → 屏幕中央停留 → 左侧退出位置控制支持顶部、中间、底部三个位置时钟控制可配置停留时长视觉定制支持自定义背景色、文字颜色点击交互点击横幅可触发回调跳转或执行操作循环播放支持循环出现可配置循环间隔长文本适配内容超出横幅宽度时自动横向滚动内容插槽支持 slot 自定义复杂内容架构设计组件定位横幅组件作为直播间页面的子组件通过v-ifkey控制挂载和重建每次triggerBanner()调用都会强制生成新实例避免动画状态残留。room.vue └─ live-banner.vue ← 纯展示 动画逻辑不关心业务数据父页面通过 props 下发配置通过 events 监听横幅生命周期和点击事件职责清晰单一。动画引擎三相位状态机这是组件最核心也最坑的部分。问题起源为什么动画会直接闪现最初我们是这么写的// 初始状态 style.transform translateX(110%); // 右侧外面 // 下一帧切换到目标位置 requestAnimationFrame(() { style.transform translateX(0); // 屏幕中央 });但在实际运行中浏览器可能把初始位置和目标位置合并到同一帧渲染CSS transition 被直接跳过——横幅瞬移到屏幕中央动画效果完全丢失。解决方案三相位状态机引入了animationPhase状态机每个相位严格分离且初始相位禁用 transitionPhase 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 1CSS 过渡才能正常触发。长文本滚动量体裁衣当横幅内容如 恭喜用户 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; } } }); }); };滚动机制使用translateXtransition: 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 classbanner-scroll-track text{{ content }}/text text v-ifisOverflownbsp;nbsp;nbsp;/text text v-ifisOverflow{{ content }}/text /view这样无论滚动到哪个位置都能看到完整的文本内容。循环机制Vue key 重建策略最初我们遇到了一个经典的 Vue 状态残留问题两个 triggerBanner 调用时间靠近时第二个横幅的 props 只是覆盖到了同个组件实例上旧实例的动画周期执行完毕后会emit(close)→ 父级销毁组件 → 第二个横幅根本没机会展示。解决方案是使用:key强制重建live-banner :keybannerKey ... /每次调用triggerBanner()时递增bannerKeyconst 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类型默认值说明contentString横幅文本内容positionStringtop显示位置top/middle/bottomdurationNumber3000屏幕中央停留时间(ms)bgColorString自定义背景色默认红色渐变textColorString自定义文字颜色默认白色loopBooleanfalse是否循环出现intervalNumber3000循环间隔时间(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 1watch 回调 函数内三重守卫完整组件代码template view v-ifvisible classlive-banner :classpositionClass clickhandleClick view classbanner-body :stylebannerStyle slot view classbanner-text-wrap refwrapRef view classbanner-scroll-track :class{ marquee-loop: isOverflow isMarqueeActive } :stylemarqueeActiveStyle reftrackRef text classbanner-text{{ content }}/text text v-ifisOverflow classbanner-text separatornbsp;nbsp;nbsp;/text text v-ifisOverflow classbanner-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 }, /** 循环间隔时间单位毫秒仅 looptrue 时生效 */ 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 langscss 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 组件开发感兴趣欢迎留言交流