从右到左的优雅入场:uni-app 直播间横幅通知组件开发实践

前言

在直播场景中,横幅通知(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`,
    };
  }
});

为了防止滚动提前启动(溢出检测还没完成时动画就已经开始了),我们加了三重保险

  1. watch(animationPhase) --- Phase 1 时尝试启动滚动
  2. measureOverflow() 回调末尾 --- 检测完成后再次尝试
  3. scheduleMarquee() 内部守卫 --- 只有 isOverflow && phase === 1 才真正启动

文本副本

为了实现视觉上的无缝循环,溢出的文本复制一份并追加在后面,用空格间隔:

复制代码
<view class="banner-scroll-track">
  <text>{{ content }}</text>
  <text v-if="isOverflow">&nbsp;&nbsp;&nbsp;</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>

性能与可维护性

  1. 定时器统一管理 --- exitTimer / hideTimer / restartTimer / marqueeTimer 四个定时器,在 onUnmounted 中全部清理,防止内存泄漏

  2. 组件自包含 --- 所有动画逻辑封装在组件内部,父页面只需调 triggerBanner 和监听 @click/@close

  3. 平台兼容 --- 使用 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">&nbsp;&nbsp;&nbsp;</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 组件开发感兴趣,欢迎留言交流!