如何打造一款高可用的全屏红包雨

蚊子前端博客
发布于 2021-01-18 19:21
在各个大活动中,经常会遇到红包雨的特效,那么红包雨具体是怎么实现的呢?

我们腾讯新闻在年底前搞了一波大的烟花活动和跨年红包雨活动,我正好负责了全屏红包雨的特效。在活动结束后,我们来复盘下整个红包雨的实现过程,或许对您有什么帮助呢!

如何打造一款高可用的全屏红包雨-厉害厉害-蚊子的前端博客

在实现红包雨时,我们首先要明确实现什么样子的功能,要给上层的调用方什么配置,调用方需要做什么,就能启动这个红包雨。

  1. 为提高性能用 canvas 实现,用 div 的话,需要频繁地创建 div 并进行重绘,红包比较多时,可能会特别消耗性能;

  2. 每个红包要有不同的降落速度,一样的速度太单调;

  3. 确定红包的属性(如速度、位置坐标、大小、放大缩小和旋转的变形数据、是否已被销毁等);

  4. 可以手动启动和暂停红包雨;

  5. 点击时,告诉调用方是否命中了红包;

接下来我们一步步地来实现一套完整的红包雨。代码较多,请静下心来。

1. 创建红包雨的画布

我们设置组件的原则是要开箱即用,让调用方尽可能少的配置就能使用我们的组件。我们要实现红包雨,首先就要创建一个画布来画这个红包雨。

COPYJAVASCRIPT

class RedpackRain { private rainCtx: CanvasRenderingContext2D | null = null; private containerRect = { width: 0, height: 0, top: 0, left: 0 }; private ratio = window.devicePixelRatio || 3; // 画布放大的倍数 // 在指定容器内创建canvas private creatCanvas = () => { // 传入一个选择器,表示要在哪个里面展示红包雨 const selector: HTMLElement = this.config.selector as HTMLElement; // 获取外层容器的属性,根据容器的属性来设置画布 const { top, left, width, height } = selector.getBoundingClientRect(); // 将容器的属性记下来 // ratio表示放大的倍数,这是为了避免在一些高清屏下canvas模糊的问题 this.containerRect.width = width * this.ratio; this.containerRect.height = height * this.ratio; this.containerRect.top = top; this.containerRect.left = left; // 容器中还没有创建canvas画布时,就创建一个 // 若已经有了,则不再创建 if (selector.getElementsByTagName('canvas').length === 0) { const canvasRain = document.createElement('canvas'); canvasRain.style.cssText = 'position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 2'; canvasRain.width = this.containerRect.width; canvasRain.height = this.containerRect.height; const div = document.createElement('div'); div.style.cssText = 'position: relative; height: 100%'; div.appendChild(canvasRain); selector.appendChild(div); const rainCanvasSelector: HTMLCanvasElement | null = selector.querySelector('.rain-redpack-canvas'); if (rainCanvasSelector) { // 获取画布 this.rainCtx = rainCanvasSelector.getContext('2d'); } } } }

这里其实我们要注意两点:

  • 获取容器的属性(top, left, width, height),width 和 height 是为了设置画布的宽度和高度,而 top 和 left 的值,是为了在计算用户的点击坐标时,要减去容器的 top 和 left,才是用户在画布上的坐标;为了通用性,也是考虑了容器不是全屏的情况;

  • 一个是 ratio 属性,为了避免在一些高清屏下 canvas 模糊的问题,对 canvas 进行了放大处理;那么 canvas 中所有的数据都是等比例放大了,这点在后面判断点击是否命中红包时非常有用,因为红包的坐标、宽度和高度都是等比例处理过了,点击事件产生的 top 和 left 也要先乘以对应的系数后,才能跟红包的坐标对应上。

如何打造一款高可用的全屏红包雨-继续继续-蚊子的前端博客

2. 红包元素

在创建完成画布后,我们就可以画红包元素了,在 canvas 中,我们直接画一个图片即可。这里我们分成几个小节来分开将红包元素。

2.1 红包的创建

一个红包特有的几个属性:

红包实例-蚊子的前端博客

具体代码如下:

COPYJAVASCRIPT

class Item { redpackId = 1; // 红包标识 x = 100; // 红包的横坐标 y = 200; // 红包的竖坐标 width = 100; height = 150; speed = 10; // 下降的速度,创建后则确定 angle = 2; // 渲染的角度 ratio = 1.1; // 放大的系数 imgurl = ''; // 红包素材的地址 redpackCtx: CanvasRenderingContext2D | null = null; // 画布 constructor({ redpackId, x, y, imgurl, width, height, speedMax, speedMin, redpackCtx, onDestoryed }) { this.redpackId = redpackId; this.x = x; this.y = y; this.width = width; this.height = height; this.speed = (speedMax - speedMin) * Math.random() + speedMin; this.imgurl = imgurl; this.redpackCtx = redpackCtx; this.containerHeight = containerHeight; this.onDestoryed = onDestoryed; const random = Math.random(); const angle = ((random * 30 + 10) * Math.PI) / 180; // 随机一个旋转偏移角度 this.angle = random < 0.5 ? angle : 0 - angle; // 随机向左或者向右旋转 this.ratio = random * 0.4 + 0.8; // 随机放大的效果,0.8~1.2倍之间 } }

虽然整个红包雨中,有的红包落的快,有的红包落的慢,但具体到某一个红包中,我们则在创建时,就已经确定了这个红包的速度,一直到当前红包被销毁。

为了不让红包显得过于单调,我们在红包素材固定的情况下,对元素进行了渲染和放大处理:

COPYJAVASCRIPT

const random = Math.random(); const angle = ((random * 30 + 10) * Math.PI) / 180; // 随机一个旋转偏移角度 this.angle = random < 0.5 ? angle : 0 - angle; // 随机向左或者向右旋转 this.ratio = random * 0.4 + 0.8; // 随机放大的效果,0.8~1.2倍之间

2.2 红包的移动

在 canvas 中的移动,其实就是擦除刚才坐标的图像,然后在新的坐标重新画一个。将这个行为一直持续下去,红包就会一直动起来了。

COPYJAVASCRIPT

class Item { render() { const { redpackCtx } = this; // 还在画布内时 if (this.y < this.containerHeight) { // 绘制下一个位置 const nextx = this.x; // 横轴坐标不变 const nexty = this.y + this.speed; // 根据速度获取下一个竖轴坐标 redpackCtx.save(); redpackCtx.beginPath(); redpackCtx.rect(nextx, nexty, this.width, this.height); redpackCtx.translate(nextx + this.width / 2, nexty + this.height / 2); redpackCtx.scale(this.ratio, this.ratio); redpackCtx.rotate(this.angle); redpackCtx.drawImage(this.redpackImg, -this.width / 2, -this.height / 2, this.width, this.height); redpackCtx.strokeStyle = 'transparent'; redpackCtx.stroke(); redpackCtx.restore(); this.y = nexty; } else { // 超过边界,回调该元素被销毁的事件 // 告诉主流程当前红包已销毁,可以将其剔除 if (typeof this.onDestoryed === 'function') { this.onDestoryed(this.redpackId); } } } }

当红包一直绘制到边界了,还没有被用户点中,则告诉主控制流程,这个红包已经达到边界了,可以将其从列表中删除了。细心的人也发现了,这个 render()只是一次的绘制操作,它怎么能动的起来呢?

本来做 demo 的过程中,我是把 requestAnimationFrame 放在红包 Item 中,也就是说每一个红包都有自己完整的一套绘制流程;而且每次移动的过程中,只擦除自己刚才所在的位置,并不擦除整个画布。同时 canvas 中还有一个isPointInPath方法用来判断点击是否在画出的封闭路径内,那么我正好可以用isPointInPath方法用户是否命中了红包?

然而这里有一个问题:isPointInPath方法只能判断最后一次绘制的图形,如果画布中有多个红包时,需要把每个红包都挨个儿重新绘制一次,然后一个一个进行判断。这样就会多次的重绘,每个红包运动时,自己都有一个 requestAnimationFrame 在重复绘制;用户点击时,还是会清除重新绘制,如果用户点击比较快,那么画布就需要频繁的擦除和重绘。

而且在真机测试的过程中发现,准确率不高,手指明明点中了红包,程序却判定没有点中。体验效果不好。

如何打造一款高可用的全屏红包雨-学不动了-蚊子的前端博客

是不是有点跟不上了,别急,还没到最后呢!

3. 主流程

针对上面存在的问题,这里我在进行了下一步的优化。

主流程中一个变量redpackItemList保存着当前整个画布中正在存在的红包实例,若被点击命中或者降落到查出了画布,则将其移除。

3.1 全局只有一个 rAF 来控制

把用 requestAnimationFrame 控制红包下一次绘制的操作,放在了主流程中,整个红包雨只有一个 requestAnimationFrame 来控制所有的红包绘制,每次绘制前,先把整个画布擦除掉,然后循环redpackItemList,调用每个红包实例中的 render()再重新绘制出来。如果 redpackMap 已经不存在某个红包实例,则下次绘制时,则不会再进行绘制。

在修改了绘制流程后,我并没有同步修改对点击命中的判断逻辑,发现在 PC 上调试时命中率更低了,isPointInPath不好使了。主要是因为所有的红包都是在一次的渲染中完成的,点击的红包在 for 循环的渲染过程中,非常快地就过去了,根本就没有被做为当时最后一个画布的元素来判断。为了解决判定不准的问题,这里改成了通过坐标进行判断。

COPYJAVASCRIPT

class RedpackRain { clickListener() { // 获取所有的手指坐标 touchClients.forEach(({ clientX, clientY }) => { // 减去外层容器的偏移,并乘以画布放大的系数 const myClientX = (clientX - left) * this.ratio; const myClientY = (clientY - top) * this.ratio; // 循环当前所有存在画布上的红包坐标 for (const key in this.redpackItemList) { const redpackItem = this.redpackItemList[key]; const { x, y, width, height } = redpackItem; const diff = 14; // 比红包区域大一点,类似于padding let hitedNum = 0; // 被命中的红包个数 if ( myClientX >= x - diff && myClientX <= x + width + diff && myClientY >= y - diff && myClientY <= y + height + diff ) { delete this.redpackItemList[key]; redpackItem.addBubble(); hitedNum += 1; } if (typeof this.config.onClick === 'function') { this.config.onClick(hitedNum); // 返回当前次点击,一共命中了几个红包 } } }); } }

我们这里特意进行了一个坐标的转换,首先减去外层容器相对可视窗口的偏移量,然后再乘以画布的放大系数,使点击的坐标能正好和画布的坐标能对应上:

COPYJAVASCRIPT

// 减去外层容器的偏移,并乘以画布放大的系数 const myClientX = (clientX - left) * this.ratio; const myClientY = (clientY - top) * this.ratio;

3.2 创建红包实例

每个降落的红包,都是一个红包实例,那么如何创建一个红包实例呢?又要考虑哪些问题呢?

其实创建红包实例很简单,我们在上面已经定义好红包类需要的属性,把这些数据传进去并初始化,初始化的实例放到redpackItemList中。

额外特别需要注意的就是红包降落的横轴 x 的值,我们制定了两个标准:

  1. 不与最近一次降落的红包重叠,红包的速度有快有慢,如果重叠的话,看起来不好看;

  2. 不出现在边界,避免用户不方便点击,同时也考虑到曲面屏手机的情况;

因此我专门写了一个方法,每次创建前,获取下即将降落的红包横坐标:

COPYJAVASCRIPT

class RedpackRain { /** * 返回红包创建时的x轴坐标 * @param width 红包的宽度 * @return x轴坐标 */ private getRedpackItemX(width: number): number { let x = this.lastRedpackX; // 上次红包的坐标 do { x = Math.floor(Math.random() * (this.containerRect.width - width * 3) + width); // 避免红包产生在边界 } while (Math.abs(this.lastRedpackX - x) <= width * 1.5); // 避免先后两个红包重叠 this.lastRedpackX = x; return x; } }

这个方法产生的坐标坐标会在 width <= x < containerRect.width-width*2 中间产生,右边为什么是减去 2 个红包的宽度呢,因为红包本身还占有一个宽度,红包实例使用这样计算出来的 x 值,会在两边各留下一个红包宽度的间隙。

有读者可能会有疑问,这里通过 while 循环和 Math.random()算出一个范围内的数值,会不会出现每次随机出来的数值,都不满足要求,导致 while 循环次数过多的情况呢?其实并不会,我们在测试过程发现,大部分最多循环 2-3 次就能得到符合要求的数据,少部分会有 4 次的情况。

得到横轴的数值后,就可以创建红包了。

COPYJAVASCRIPT

class RedpackRain { // 创建红包 private createRedpackItem() { const { width, height, speedMax, speedMin, imgUrl } = this.config.redpack || {}; // 配置 const redpackItemId = Date.now(); // 红包实例的ID,这里我们使用时间戳来作为ID const x = this.getRedpackItemX(width); // 获取横轴的坐标 const redpackItem = new RedpackItem({ redpackId: redpackItemId, x, y: -this.config.redpack.height, imgUrl, width, height, speedMax, speedMin, containerHeight: this.containerRect.height, redpackCtx: this.rainCtx, // 当前画布 onDestoryed: (id) => { // 自身被销毁时的回调 // 从列表中删除 delete this.redpackItemList[id]; }, }); // 将红包实例推送到列表中 this.redpackItemList[redpackItemId] = redpackItem; // start中先判断红包图片加载完毕,然后调用render方法 redpackItem.start(); } }

3.3 页面不可见时

使用 requestAnimationFrame 来循环对页面渲染,而不是使用 setTimeout 或者 setInterval,想必大家也能知道原因。这里我们主要探讨页面可见性发生变化时,对红包雨造成的影响。

我们考虑一个这样的场景:用户已经开启了红包雨,点击了几个红包,但是女朋友在微信忽然发了一条很重要的消息,必须马上要回复;这时就要从腾讯新闻切换到微信页面进行回复,回复完毕后再重新回到腾讯新闻。这里就涉及到了页面可见性的问题。

我们在测试的过程中发现,当前页面不可见时,requestAnimationFrame 在大多数浏览器中会停止执行,如果不做特殊处理,用户重新回来后,会发现在页面的顶部积攒了好几个红包。因为创建红包的 setInterval 定时器并没有停止执行(虽然定时器的时间间隔增加,但并没有彻底停止),而 requestAnimationFrame 则是直接停止了执行,这就造成虽然创建了好几个红包,但没有降落的操作。

这里我们就需要监听页面的可见性,当页面不可见时,则停止创建红包,当页面重新可见时,则继续创建。

COPYJAVASCRIPT

// 我们已经封装了监听页面可见性的方法 // 这里就是看下在页面可见性发生变化时的定时器的处理 pageVisibility.visibilityChange((isShow) => { if (this.timer) { clearInterval(this.timer); this.timer = null; } if (isShow) { // 创建一个新的红包雨 this.timer = setInterval(() => { this.createRedpackItem(); }, this.config.interval); } });

这里我做了一个比较粗暴的处理,凡是页面可见性发生变化时,都会清除定时器,然后页面可见时再重新创建定时器。

如何打造一款高可用的全屏红包雨-摩羯三连-蚊子的前端博客

4. 红包雨的使用

目前我们已经基本把红包雨的流程跑通了,红包雨已经可以下了。我们就要考虑对外提供什么方法,方便调用者使用了。

这里我提供了 3 个方法来供调用者使用。

4.1 start

启动整个红包雨。用户可能会重复多次启动红包雨,这里我们就要先清除上次的配置,然后重新设置:

COPYJAVASCRIPT

class RedpackRain { start() { // 先停止上一个 /** * 1. 清除定时器; * 2. 移除点击事件的监听; * 3. 移动页面可见性的监听; * 4. 停止创建红包 & 停止画布渲染; */ this.clear(); // 下面会讲 // 注册画布的点击事件 this.config.selector.addEventListener(this.config.eventType, this.clickListener, false); // 创建红包 this.createRedpackItem(); // 创建一个新的红包雨 this.timer = setInterval(() => { this.createRedpackItem(); }, this.config.interval); this.render(); // 注册监听页面可见性事件 this.pageVisibility?.visibilityChange((isShow) => { if (this.timer) { clearInterval(this.timer); this.timer = null; } if (isShow) { // 创建一个新的红包雨 this.timer = setInterval(() => { this.createRedpackItem(); }, this.config.interval); } }); } }

4.2 clear

该方法类似于暂停事件,将清除画布中所有的事件和红包。但红包数据还在,若重新 start 时,则还继续上次的红包雨。

4.3 stop

先调用 clear()方法,然后清除所有的红包数据。当调用该方法后并重新 start 时,则是一场新的红包雨。

4.4 setOptions

在红包雨的过程中,可能还会有红包雨越来越快的情况,这就需要随时可以修改配置。于是我就提供了一个setOptions方法,可以随时修改任何配置(除所在的容器外):

COPYJAVASCRIPT

class RedpackRain { setOptions(options) { if (!this.timer) { // 若没有启动红包雨,则无法调用该方法 return console.error('please use start() before setOptions'); } const beforeInterval = this.config.interval; this.createConfig(options); // 当下红包雨的间隔变化后,则重置定时器; // 若间隔不变,则还是使用之前的定时器 if (beforeInterval !== this.config.interval) { this.start(); } } }

由此,我们形成一个完整的类图:

canvas红包雨-类图-蚊子的前端博客

4.5 最终的成果

我们把内部的原理,都一步步的实现了,那么来看下最终的效果吧。

RedpackRain是一个类,在构建实例时,除selector外,其他均为非必须参数。

参数是否必填类型默认值说明
selectorstring 或 HTMLElement 要渲染红包雨的容器
可以是选择器也可以是 dom 元素
interval number1600下红包雨的间隔,单位毫秒
onClick function(hited: number){}整个红包雨区域的点击,hited 表示命中红包的个数
onMonitor function({fps}){}红包雨的 fps 监控
redpack 红包配置
redpack.speedMin number10红包下降速度的最小值
redpack.speedMax number10红包下降速度的最大值
redpack.imgUrl string 红包的图片
redpack.width number192红包的宽度
redpack.height number216红包的高度
bubble 命中红包后的上升气泡的配置
bubble.imgUrl string 气泡的图片
bubble.width number156气泡的宽度
bubble.height number111气泡的高度
bubble.speed number5气泡每帧上升的高度
bubble.opacitySpeed number0.04气泡每帧减少的透明度

使用方式:

COPYJAVASCRIPT

const rain = new RedpackRain({ selector: document.body, interval: 1600, redpack: { speedMin: 10, speedMax: 10, imgUrl: 'https://sola.gtimg.cn/aoi/sola/20201226100322_I1ltnkzJVc.png', width: 126, height: 174, }, bubble: { imgUrl: 'https://sola.gtimg.cn/aoi/sola/20201225103914_2QQ9bXg2rU.png', width: 156, height: 111, speed: 5, opacitySpeed: 0.04, }, onClick: (isHited) => { console.log(isHited); }, onMonitor(monitors) { console.log(monitors); }, }); rain.start(); // rain.stop(); // rain.setOptions({ // interval: 400, // })

当时线上的效果没有截图,这里就用 demo 样例做参考吧。

canvas红包雨-蚊子的前端博客

点击链接查看demo:全屏红包雨

5. 总结

虽然红包雨只是整个活动的一部分而已,但是也学到了很多,尤其是在 canvas 操作和组件的代码组织上。这次贴的代码也有点多,感谢大家能看到最后。

如何打造一款高可用的全屏红包雨-迟早骚死-蚊子的前端博客

标签:
阅读(960)

公众号:

qrcode

微信公众号:前端小茶馆

公众号:

qrcode

微信公众号:前端小茶馆