资讯动态

Angular 6 跑马灯组件封装:requestAnimationFrame 实现无缝滚动与悬停暂停

发布时间:2026/10/14 1:06:11 来源:尧图企业网站定制
1. 为什么还要自己写一个跑马灯组件跑马灯这玩意儿说起来简单做起来坑不少。你可能第一反应是CSS 动画不就搞定了吗keyframes加个translateX来回滚十分钟收工。但真正在业务里用过的人都知道纯 CSS 方案在遇到动态内容长度、滚动速度自适应、鼠标悬停暂停、无缝循环衔接这些需求时就开始捉襟见肘了。尤其是当你的技术栈是 Angular 6 的时候你会发现社区里能直接拿来用的、维护活跃的跑马灯组件少得可怜大部分要么是 AngularJS 时代的遗物要么是给 Angular 2 写的、API 设计已经过时。我在一个后台管理系统的项目里就碰到了这个需求顶部需要一条通知栏滚动展示多条系统公告每条公告长度不一有的十几个字有的上百字。产品的要求是——所有公告以统一速度滚动不能因为文字长短导致快慢不一鼠标移上去要暂停移开继续滚完一轮无缝衔接下一轮中间不能有空白间隙。我试过用纯 CSS 方案结果在“统一速度”这个点上就卡住了因为 CSS 动画的时长是固定的内容越长跑得越快视觉上非常突兀。所以最终决定自己封装一个基于 Angular 6 的跑马灯组件。这篇文章就是把这个组件的完整设计思路、核心实现细节、踩过的坑和排查经验全部摊开来聊。适合正在用 Angular 6或相近版本做项目、需要实现滚动展示效果的前端开发者也适合想了解 Angular 组件封装思路的朋友。不管你是刚接触 Angular 的新手还是已经写过几个组件的熟手应该都能从中找到可以直接抄作业的东西。2. 组件整体设计与技术选型拆解2.1 为什么选 Angular 6 的原生方案而不是引入第三方库Angular 6 是 2018 年发布的版本带来了 Angular Elements、CLI 升级、RxJS 6 等重要更新。在这个版本下做组件封装有几个天然优势一是Renderer2已经成熟稳定操作 DOM 比早期版本安全得多二是 RxJS 6 的管道操作符让事件流处理更简洁三是组件的ChangeDetectionStrategy可以精细控制对跑马灯这种高频动画场景很关键。我当时评估过几个第三方库比如 ngx-marquee 之类的但要么版本不兼容要么 API 太重引入一堆用不上的依赖。自己写的话核心逻辑其实就三块内容测量、动画驱动、状态控制。代码量可控维护起来也放心。更重要的是自己写的组件可以完全按照业务需求定制不用去改别人的源码或者等别人发版。2.2 核心设计思路用 requestAnimationFrame 替代 CSS 动画这是整个组件最关键的决策点。CSS 动画虽然性能好但它的时间轴是固定的你没法在运行时动态调整速度。而跑马灯需要根据内容宽度和容器宽度来计算滚动距离再结合设定的速度像素/秒算出总时长这个时长是动态的。所以最终方案是用requestAnimationFrame简称 rAF来驱动动画。rAF 的好处是它与浏览器刷新率同步通常是 60fps动画流畅度有保障而且每一帧都可以拿到时间戳方便做精确的位置计算。具体来说每一帧根据elapsedTime * speed算出当前应该偏移的像素值然后通过Renderer2.setStyle设置transform: translateX()。这里有个细节为什么用transform而不是left或margin-left因为transform不会触发浏览器的重排reflow只会触发重绘repaint甚至合成composite性能好得多。跑马灯是持续动画如果用left来驱动每一帧都会导致整个布局重新计算在低端设备上直接卡成幻灯片。2.3 无缝滚动的实现原理无缝滚动的核心思路是“复制一份内容首尾相接”。假设原始内容宽度为 W容器宽度为 C。当内容完全滚出容器左侧时偏移量达到 -W立刻把偏移量重置为 0同时因为复制了一份相同内容在后面视觉上看起来是连续的。但这里有个容易忽略的点如果内容宽度小于容器宽度那根本不需要滚动直接静态展示就行。所以组件初始化时要先测量内容宽度和容器宽度只有内容宽度大于容器宽度时才启动动画。这个判断逻辑看似简单但如果不做内容短的时候会出现奇怪的抖动。另外复制内容的时机也很讲究。不能在组件初始化时就复制因为那时候内容可能还没渲染完宽度测量不准。正确的做法是在ngAfterViewInit里等视图初始化完成后再测量和复制。如果内容是异步加载的比如从接口获取的公告列表还需要在内容变化时重新测量。2.4 组件输入输出设计组件的 API 设计遵循“简单够用”原则输入属性包括speed滚动速度单位像素/秒默认 50。这个值是根据经验设定的太快看不清太慢显得拖沓。direction滚动方向支持left和right默认left。pauseOnHover鼠标悬停是否暂停默认true。content要滚动的内容可以是字符串或字符串数组。输出事件只有一个scrollComplete在每一轮滚动结束时触发方便外部做埋点或联动。这里解释一下为什么速度用像素/秒而不是像素/帧。因为不同设备的刷新率可能不同有些高刷屏是 120Hz如果用像素/帧在高刷屏上速度会翻倍。用像素/秒配合时间戳计算就能保证在任何刷新率下速度一致。3. 核心细节解析与实操要点3.1 内容宽度测量的正确姿势测量 DOM 元素宽度最直接的方法是element.offsetWidth或getBoundingClientRect().width。但在 Angular 里直接操作 DOM 是不推荐的应该通过ElementRef和Renderer2。不过测量宽度这个操作Renderer2并没有提供直接的方法所以还是得通过ElementRef.nativeElement来访问原生元素。具体做法是在模板里给内容容器加一个模板引用变量然后用ViewChild获取ViewChild(contentWrapper) contentWrapper: ElementRef;然后在ngAfterViewInit里const contentWidth this.contentWrapper.nativeElement.getBoundingClientRect().width; const containerWidth this.containerRef.nativeElement.getBoundingClientRect().width;这里有个坑如果内容里有图片图片还没加载完时宽度是不准的。解决办法是监听图片的load事件或者干脆要求使用方给图片设定固定宽高。我在项目里是约定内容里不直接放图片如果有图标需求用字体图标或 SVG这样宽度在渲染时就是确定的。还有一个坑是getBoundingClientRect返回的宽度可能包含小数而后续计算偏移量时如果精度处理不当会导致累积误差。我的做法是统一用Math.ceil向上取整保证偏移量是整数避免亚像素渲染带来的模糊问题。3.2 requestAnimationFrame 循环的启动与销毁rAF 循环的管理是组件生命周期管理的重点。启动很简单private startAnimation(): void { this.lastTimestamp performance.now(); this.rafId requestAnimationFrame(this.animate.bind(this)); } private animate(timestamp: number): void { const deltaTime timestamp - this.lastTimestamp; this.lastTimestamp timestamp; this.offset - this.speed * deltaTime / 1000; // 边界处理和重置逻辑 this.renderer.setStyle(this.contentWrapper.nativeElement, transform, translateX(${this.offset}px)); this.rafId requestAnimationFrame(this.animate.bind(this)); }销毁的时候必须在ngOnDestroy里调用cancelAnimationFrame(this.rafId)否则组件销毁后动画还在跑造成内存泄漏。这个坑我踩过——在一个频繁切换路由的页面里没取消 rAF 导致内存一路飙升排查了半天才发现是这里的问题。另外bind(this)每次调用都会创建一个新函数虽然在这个场景下影响不大但更优雅的做法是用箭头函数属性private animate (timestamp: number): void { ... }这样this指向就是确定的也不需要反复 bind。3.3 鼠标悬停暂停的实现细节悬停暂停看起来简单监听mouseenter和mouseleave事件暂停和恢复 rAF 就行。但这里有个细节暂停时不能简单地cancelAnimationFrame因为恢复时你需要知道暂停了多久否则时间戳会跳变导致位置突变。正确的做法是用一个isPaused标志位在animate函数里判断private animate (timestamp: number): void { if (this.isPaused) { this.lastTimestamp timestamp; this.rafId requestAnimationFrame(this.animate); return; } // 正常动画逻辑 }这样暂停期间虽然 rAF 还在跑但不更新位置恢复时lastTimestamp已经更新到当前时间不会出现跳变。虽然暂停期间还在消耗一点点 CPU但相比时间戳跳变带来的视觉问题这个代价完全可以接受。3.4 动态内容变化的处理如果跑马灯的内容是异步获取的内容变化后需要重新测量宽度、重新计算偏移量、重新启动动画。Angular 提供了ngOnChanges生命周期钩子可以在输入属性变化时触发重新初始化。但这里有个时序问题ngOnChanges触发时新的内容还没有渲染到 DOM 上此时测量宽度拿到的是旧值。解决办法是用ChangeDetectorRef.detectChanges()手动触发变更检测或者用setTimeout延迟到下一个事件循环。我推荐前者更可控ngOnChanges(changes: SimpleChanges): void { if (changes.content !changes.content.firstChange) { this.cdr.detectChanges(); this.reinitialize(); } }reinitialize方法里先取消旧的 rAF重置偏移量重新测量宽度再启动新的动画。4. 完整实操过程与核心环节实现4.1 组件文件结构一个完整的跑马灯组件至少包含三个文件模板文件、样式文件、TypeScript 逻辑文件。如果项目里用了 Angular CLI直接ng generate component marquee就能生成骨架。文件结构如下marquee/ marquee.component.ts marquee.component.html marquee.component.scss如果组件要发布成库还需要marquee.module.ts来导出组件。这里先按项目内组件来写。4.2 模板实现模板的核心结构很简单一个外层容器一个内层内容包裹器div classmarquee-container #container (mouseenter)onMouseEnter() (mouseleave)onMouseLeave() div classmarquee-content #contentWrapper [style.transform]translateX( offset px) ng-content/ng-content /div /div这里用了ng-content做内容投影使用方可以自由传入任意内容。但前面说了内容宽度需要测量所以还需要一个隐藏的测量容器来获取原始内容宽度。更稳妥的做法是在组件内部维护一个内容副本div classmarquee-container #container div classmarquee-content #contentWrapper span classmarquee-item *ngForlet item of content{{ item }}/span span classmarquee-item *ngForlet item of content{{ item }}/span /div /div注意这里*ngFor写了两遍这就是前面说的“复制一份内容”的实现方式。第一份是正常展示第二份用于无缝衔接。当第一份完全滚出视野时偏移量重置第二份刚好接上。4.3 样式实现要点样式上有几个关键点.marquee-container { overflow: hidden; white-space: nowrap; position: relative; width: 100%; } .marquee-content { display: inline-block; white-space: nowrap; will-change: transform; } .marquee-item { display: inline-block; padding-right: 50px; }overflow: hidden是必须的否则内容会溢出容器。white-space: nowrap保证内容不换行。display: inline-block让内容宽度由内容本身决定而不是撑满容器。will-change: transform是一个性能优化提示告诉浏览器这个元素会频繁变化 transform让浏览器提前做好合成层准备。padding-right是每个条目之间的间距这个值需要和内容一起参与宽度计算。如果间距太小两条公告会挤在一起太大则显得空旷。我一般设 50px 左右具体看字体大小和内容密度。4.4 TypeScript 核心逻辑完整的组件逻辑如下import { Component, ElementRef, Input, OnDestroy, AfterViewInit, OnChanges, SimpleChanges, ChangeDetectorRef, Renderer2, ViewChild } from angular/core; Component({ selector: app-marquee, templateUrl: ./marquee.component.html, styleUrls: [./marquee.component.scss] }) export class MarqueeComponent implements AfterViewInit, OnDestroy, OnChanges { Input() content: string[] []; Input() speed 50; Input() direction: left | right left; Input() pauseOnHover true; ViewChild(container) containerRef: ElementRef; ViewChild(contentWrapper) contentWrapperRef: ElementRef; offset 0; private rafId: number | null null; private lastTimestamp 0; private isPaused false; private contentWidth 0; private containerWidth 0; private singleContentWidth 0; constructor( private renderer: Renderer2, private cdr: ChangeDetectorRef ) {} ngAfterViewInit(): void { this.initMarquee(); } ngOnChanges(changes: SimpleChanges): void { if (changes.content !changes.content.firstChange) { this.cdr.detectChanges(); this.reinitMarquee(); } } ngOnDestroy(): void { this.stopAnimation(); } private initMarquee(): void { this.measure(); if (this.singleContentWidth this.containerWidth) { return; } this.startAnimation(); } private reinitMarquee(): void { this.stopAnimation(); this.offset 0; this.initMarquee(); } private measure(): void { const containerEl this.containerRef.nativeElement; const contentEl this.contentWrapperRef.nativeElement; this.containerWidth Math.ceil(containerEl.getBoundingClientRect().width); this.contentWidth Math.ceil(contentEl.getBoundingClientRect().width); this.singleContentWidth this.contentWidth / 2; } private startAnimation(): void { this.lastTimestamp performance.now(); this.rafId requestAnimationFrame(this.animate); } private stopAnimation(): void { if (this.rafId ! null) { cancelAnimationFrame(this.rafId); this.rafId null; } } private animate (timestamp: number): void { if (this.isPaused) { this.lastTimestamp timestamp; this.rafId requestAnimationFrame(this.animate); return; } const deltaTime timestamp - this.lastTimestamp; this.lastTimestamp timestamp; const distance this.speed * deltaTime / 1000; if (this.direction left) { this.offset - distance; if (Math.abs(this.offset) this.singleContentWidth) { this.offset this.singleContentWidth; } } else { this.offset distance; if (this.offset 0) { this.offset - this.singleContentWidth; } } this.renderer.setStyle( this.contentWrapperRef.nativeElement, transform, translateX(${this.offset}px) ); this.rafId requestAnimationFrame(this.animate); }; onMouseEnter(): void { if (this.pauseOnHover) { this.isPaused true; } } onMouseLeave(): void { if (this.pauseOnHover) { this.isPaused false; } } }这段代码有几个值得展开讲的地方。关于singleContentWidth的计算因为内容复制了一份所以contentWidth是原始内容宽度的两倍。singleContentWidth就是原始内容的宽度也是偏移量重置的阈值。当偏移量达到这个值时说明第一份内容已经完全滚出此时把偏移量加上这个值因为是负数所以是就相当于重置到起点而第二份内容刚好在相同位置视觉上无缝。关于方向处理向右滚动时逻辑稍有不同。初始偏移量设为-singleContentWidth然后逐渐增大到 0到 0 时再减去singleContentWidth这样也能实现无缝。不过向右滚动的场景比较少大部分跑马灯都是向左。关于Math.abs因为偏移量是负数比较时要用绝对值。这里也可以直接比较this.offset -this.singleContentWidth效果一样。4.5 在模块中声明和使用组件写好后需要在模块的declarations里声明NgModule({ declarations: [MarqueeComponent], exports: [MarqueeComponent] }) export class SharedModule {}使用时就很简单了app-marquee [content]notices [speed]60 [pauseOnHover]true/app-marqueenotices是一个字符串数组比如[系统将于今晚维护, 新版本已发布请查看更新日志, ...]。4.6 性能优化OnPush 策略跑马灯组件是高频更新的如果每次 rAF 都触发 Angular 的变更检测性能会很差。所以应该把组件设置为ChangeDetectionStrategy.OnPushComponent({ // ... changeDetection: ChangeDetectionStrategy.OnPush })但要注意OnPush 下输入属性变化时组件才会重新检测。因为我们用Renderer2直接操作 DOM不依赖 Angular 的绑定所以 OnPush 完全没问题。唯一需要注意的是content变化时要手动调用detectChanges这个前面已经处理了。5. 常见问题与排查技巧实录5.1 滚动到一半出现空白间隙这是最常见的问题根本原因是内容复制不完整或者偏移量重置时机不对。排查思路检查模板里是否确实复制了两份内容且两份内容完全一致。检查singleContentWidth的计算是否正确如果内容里有 margin 或 padding 没算进去会导致阈值偏小提前重置。检查padding-right是否加在了每个条目上如果只加在最后一个条目上复制后中间会缺一个间距。我的经验是把padding-right加在每个.marquee-item上这样无论复制多少份间距都是均匀的。5.2 内容短的时候出现抖动前面提到过内容宽度小于容器宽度时不应该滚动。但有时候判断逻辑写对了还是会有轻微抖动。这通常是因为getBoundingClientRect返回的宽度有小数而容器宽度是整数比较时出现误差。解决办法是两边都Math.ceil后再比较并且加一个小的容差比如singleContentWidth containerWidth 1就不滚动。5.3 鼠标悬停后恢复时位置跳变这个问题的根源是暂停期间lastTimestamp没有更新。如果暂停了 5 秒恢复时deltaTime会是 5000 多毫秒导致偏移量瞬间跳变。解决办法就是前面代码里写的暂停期间仍然更新lastTimestamp只是不更新偏移量。5.4 组件销毁后动画仍在运行这是内存泄漏的典型表现。排查方法是看ngOnDestroy里是否调用了cancelAnimationFrame。另外如果组件内部有订阅比如订阅了路由事件或定时器也要在ngOnDestroy里取消订阅。我一般会在组件里维护一个destroy$Subject用takeUntil统一管理订阅的生命周期。5.5 低端设备上动画卡顿如果目标用户可能使用低端设备需要做一些额外的性能优化减少每一帧的 DOM 操作只更新transform不要更新其他样式。避免在动画过程中触发 Angular 的变更检测用 OnPush 策略。如果内容里有复杂的 DOM 结构考虑用canvas渲染文字但这会增加复杂度一般没必要。下面这张表整理了几个典型问题的排查路径问题现象可能原因排查方法解决方案滚动出现空白间隙内容复制不完整或间距不均检查模板复制份数和 padding 设置确保两份内容完全一致间距加在每个条目上内容短时抖动宽度比较精度问题打印宽度值对比两边取整后比较加容差悬停恢复跳变暂停期间时间戳未更新检查 animate 函数暂停分支暂停时仍更新 lastTimestamp销毁后动画仍在跑未取消 rAF检查 ngOnDestroy调用 cancelAnimationFrame低端设备卡顿每帧操作过重用 Performance 面板分析OnPush 策略只更新 transform5.6 一个容易被忽略的细节SSR 兼容性如果项目用了 Angular Universal 做服务端渲染跑马灯组件在服务端会报错因为requestAnimationFrame和getBoundingClientRect在 Node 环境里不存在。解决办法是用isPlatformBrowser判断当前平台constructor(Inject(PLATFORM_ID) private platformId: Object) {} ngAfterViewInit(): void { if (isPlatformBrowser(this.platformId)) { this.initMarquee(); } }这样服务端渲染时会跳过动画初始化客户端接管后再启动。虽然跑马灯在 SSR 场景下意义不大但至少不会报错导致整个页面渲染失败。5.7 实操心得速度参数的调优speed默认值 50 像素/秒是我经过多次调整后确定的。这个速度下一个 20 字左右的公告大约需要 8-10 秒滚完阅读节奏比较舒适。如果内容普遍较长可以适当提高到 60-80如果内容很短可以降到 30-40。但不要低于 30否则会感觉像在爬也不要高于 100否则看不清。另外如果公告条数很多可以考虑分组滚动比如每 5 条一组滚完一组换下一组而不是把所有公告拼成一条超长内容。这样单次滚动时间可控用户也不会因为等待太久而失去耐心。5.8 关于无障碍访问的补充跑马灯内容对屏幕阅读器用户是不友好的因为内容在不停移动。一个折中的方案是给容器加上aria-liveoff同时在页面其他地方提供一个静态的公告列表入口。如果项目对无障碍有要求这一点需要提前考虑。6. 组件扩展与复用建议这个组件目前只支持文字内容但通过ng-content投影实际上可以支持任意 HTML 内容。如果要在多个项目里复用建议把它抽成一个独立的 Angular 库用ng-packagr打包发布。这样版本管理更清晰也不会和业务代码耦合。另外如果项目里同时有多个跑马灯实例要注意它们各自独立管理 rAF不要共用一个循环否则暂停逻辑会互相干扰。每个组件实例维护自己的rafId和状态这是最稳妥的做法。我在实际项目里还遇到过一个需求跑马灯内容需要支持点击。这个实现起来要注意因为内容在移动点击事件的坐标判断会比较麻烦。简单的做法是给每个条目加(click)事件浏览器会自动处理坐标映射不需要额外计算。但如果滚动速度很快用户可能点不准这时候可以考虑悬停暂停来辅助点击。最后分享一个小技巧如果调试时想看清滚动边界可以临时把overflow: hidden改成overflow: visible这样能看到复制的内容和重置的瞬间对排查无缝衔接问题非常有帮助。调好之后再改回来就行。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑