Canvas实现点赞效果以及使用离屏画卡实现

image.png

本文会介绍Web Worker实现离屏Canvas点赞和单纯Canvas实现点赞效果。

Canvas实现点赞

创建Canvas

页面元素上新建 canvas 标签,初始化 canvas。

canvas 上可以设置 width 和 height 属性,也可以在 style 属性里面设置 width 和 height。

  • canvas 上 style 的 width 和 height 是 canvas 在浏览器中被渲染的高度和宽度,即在页面中的实际宽高。
  • canvas 标签的 width 和 height 是画布实际宽度和高度。
  <canvas ref={canvasNode} width="180" height="400"></canvas>
复制代码

图片加载

初始化加载送花图片,获取图片相关信息

 /**
 * 加载图片
 * @private
 * @memberof SendLove
 */
private loadImage() {
    const imgs = [
        'https://img.qlchat.com/qlLive/activity/image/LCP31WOW-4IMP-NLAE-1620807553972-OYWXNLLNFNJI.png',
        'https://img.qlchat.com/qlLive/activity/image/LCP31WOW-4IMP-NLAE-1620807553972-OYWXNLLNFNJI.png',
        'https://img.qlchat.com/qlLive/activity/image/LCP31WOW-4IMP-NLAE-1620807553972-OYWXNLLNFNJI.png',
        'https://img.qlchat.com/qlLive/activity/image/LCP31WOW-4IMP-NLAE-1620807553972-OYWXNLLNFNJI.png',
        'https://img.qlchat.com/qlLive/activity/image/LCP31WOW-4IMP-NLAE-1620807553972-OYWXNLLNFNJI.png',
        'https://img.qlchat.com/qlLive/activity/image/LCP31WOW-4IMP-NLAE-1620807553972-OYWXNLLNFNJI.png',
        'https://img.qlchat.com/qlLive/activity/image/LCP31WOW-4IMP-NLAE-1620807553972-OYWXNLLNFNJI.png',
        'https://img.qlchat.com/qlLive/activity/image/LCP31WOW-4IMP-NLAE-1620807553972-OYWXNLLNFNJI.png',
    ]
    // 承接所有加载后的图片信息
    const promiseAll: Array<Promise<HTMLImageElement>> = []
    imgs.forEach((img: string) => {
        const p = new Promise<HTMLImageElement>((resolve, reject) => {
            const image = new Image()
            image.src = img
            image.crossOrigin = 'Anonymous'
            image.onerror = image.onload = resolve.bind(null, image)
        })
        promiseAll.push(p)
    })
    // 获取所有图片信息
    Promise.all(promiseAll)
        .then(lists => {
            this.listImage = lists.filter((img: HTMLImageElement) => img && img.width > 0)
        })
        .catch(err => {
            console.error('图片加载失败...', err)
        })
}
复制代码

平滑移动位置

如果要做到平滑曲线,其实可以使用我们再熟悉不过的正弦( Math.sin )函数来实现均匀曲线。

 /**
 *  绘制每一个点赞;这里使用了闭包,初始化
 * @private
 * @returns {(Loop<number, boolean | void>)}
 * @memberof SendLove
 */
private createRender(): Loop<number, boolean | void> {
    if (!this.listImage.length) return null
    // 以下是在创建时,初始化默认值
    const context = this.ctx
    // 随机取出scale值
    const basicScale = [0.6, 0.9, 1.2][this.getRandom(0, 2)]
    //随机取一张图片
    const img = this.listImage[this.getRandom(0, this.listImage.length - 1)]
    const offset = 20
    // 随机动画X轴的位置,是动画不重叠在一起
    const basicX = this.width / 2 + this.getRandom(-offset, offset)
    const angle = this.getRandom(2, 12)
    // x轴偏移量10 - 30
    let ratio = this.getRandom(10, 30) * (this.getRandom(0, 1) ? 1 : -1)
    // 获取X轴值
    const getTranslateX = (diffTime: number): number => {
        if (diffTime < this.scaleTime) {
            return basicX
        } else {
            return basicX + ratio * Math.sin(angle * (diffTime - this.scaleTime))
        }
    }
    // 获取Y轴值
    const getTranslateY = (diffTime: number): number => {
        return Number(img.height) / 2 + (this.height - Number(img.height) / 2) * (1 - diffTime)
    }
    // scale方法倍数 针对一个鲜花创建一个scale值
    const getScale = (diffTime: number): number => {
        if (diffTime < this.scaleTime) {
            return Number((diffTime / this.scaleTime).toFixed(2)) * basicScale
        } else {
            return basicScale
        }
    }
    // 随机开始淡出时间,
    const fadeOutStage = this.getRandom(16, 20) / 100
    // 透明度
    const getAlpha = (diffTime: number): number => {
        const left = 1 - diffTime
        if (left > fadeOutStage) {
            return 1
        } else {
            return 1 - Number(((fadeOutStage - left) / fadeOutStage).toFixed(2))
        }
    }
    return diffTime => {
        if (diffTime >= 1) return true
        const scale = getScale(diffTime)
        context.save()
        context.beginPath()
        context.translate(getTranslateX(diffTime), getTranslateY(diffTime))
        context.scale(scale, scale)
        context.globalAlpha = getAlpha(diffTime)
        context.drawImage(img, -img.width / 2, -img.height / 2, Number(img.width), Number(img.height))
        context.restore()
    }
}
复制代码

实时绘制

开启实时绘制扫描器,将创建的渲染对象放入 renderList 数组,数组不为空,说明 canvas 上还有动画,就需要不停的去执行 scan,直到 canvas 上没有动画结束为止。

/**
 * 扫描
 * @private
 * @memberof SendLove
 */
private scan() {
    // 清屏(清除上一次绘制内容)
    this.ctx.clearRect(0, 0, this.width, this.height)
    this.ctx.fillStyle = '#fff'
    this.ctx.fillRect(0, 0, 180, 400)
    let index = 0
    let len = this.renderList.length
    if (len > 0) {
        // 重新扫描后index= 0;重新获取长度
        requestFrame(this.scan.bind(this))
        this.scanning = true
    } else {
        this.scanning = false
    }
    while (index < len) {
        const curRender = this.renderList[index]
        if (!curRender || !curRender.render || curRender.render.call(null, (Date.now() - curRender.timestamp) / curRender.duration)) {
            // 动画已结束,删除绘制
            this.renderList.splice(index, 1)
            len--
        } else {
            index++
        }
    }
}
复制代码

提供对外的接口触发动画

/**
 * 提供对外的点赞的接口
 * @returns
 * @memberof SendLove
 */
public likeStart() {
    // 初始化礼物数据、回调函数
    const render = this.createRender()
    const duration = this.getRandom(1500, 3000)
    this.renderList.push({
        render,
        duration,
        timestamp: Date.now()
    })
    if (!this.scanning) {
        this.scanning = true
        requestFrame(this.scan.bind(this))
    }
    return this
}
复制代码

Web Worker实现离屏Canvas点赞

什么是Web Worker

Web Worker允许Javascript创造多线程环境,允许主线程创建Worker线程,将任务分配在后台运行。这样高延迟,密集型的任务可以由Worker线程负担,主线程负责UI交互就会很流畅,不会会阻塞或拖慢

怎么在项目中使用Web Worker

  • 在webpack 中引入worker-plugin
   const WorkerPlugin = require('worker-plugin')
   // 在plugins添加
   new WorkerPlugin()
复制代码
  • 主线程使用new命令调用Worker()构造函数创建一个Worker线程
   const worker = new Worker('./like.worker', { type: 'module' })
复制代码
  • 主线程与Worker线程存在通信限制,不再同一个上下文中,所以只能通过消息完成
worker.postMessage({ canvas: offscreenCanvas }, [offscreenCanvas as OffscreenCanvas])
worker.onmessage = function (event) {console.log(event.data)}

复制代码
  • Worker使用注意事项:

    1. 无法操作DOM,无法获取window, document, parent等对象
    2. 遵守同源限制, Worker线程的脚本文件,必须于主线程同源。并且加载脚本文件是阻塞的
    3. 不当的操作或者疏忽容易引起性能问题
    4. postMessage不能传递函数

初始化

首先要判断浏览器是否支持离屏Canvas

const init = async () => {
   // offscreenCanvas离屏画卡很多浏览器不兼容, offscreenCanvas可以在window下可以使用也可以在web worker下使用, canvas只能在window下使用
   if ('OffscreenCanvas' in window) {
       const worker = new Worker('./like.worker', { type: 'module' })
       const offscreenCanvas = canvasNode.current.transferControlToOffscreen()
       worker.postMessage({ canvas: offscreenCanvas }, [offscreenCanvas as OffscreenCanvas])
       worker.addEventListener('error', error => {
           console.log(error)
       })
       setNewWorker(worker)
   } else {
       const thumbsUpAni = new SendLove(canvasNode.current)
       setCavasAni(thumbsUpAni)
   }
}
复制代码

关于Worker 图片加载问题

Worker中没办法操作DOM, 所以new Image()会报错;使用来加载图片

fetch(img)
.then(response => response.blob())
.then(blob => resolve(createImageBitmap(blob)))
复制代码
// 初始化图片
private loadImage() {
    const imgs = [
        'https://img.qlchat.com/qlLive/activity/image/LCP31WOW-4IMP-NLAE-1620807553972-OYWXNLLNFNJI.png',
        'https://img.qlchat.com/qlLive/activity/image/LCP31WOW-4IMP-NLAE-1620807553972-OYWXNLLNFNJI.png',
        'https://img.qlchat.com/qlLive/activity/image/LCP31WOW-4IMP-NLAE-1620807553972-OYWXNLLNFNJI.png',
        'https://img.qlchat.com/qlLive/activity/image/LCP31WOW-4IMP-NLAE-1620807553972-OYWXNLLNFNJI.png',
        'https://img.qlchat.com/qlLive/activity/image/LCP31WOW-4IMP-NLAE-1620807553972-OYWXNLLNFNJI.png',
        'https://img.qlchat.com/qlLive/activity/image/LCP31WOW-4IMP-NLAE-1620807553972-OYWXNLLNFNJI.png',
        'https://img.qlchat.com/qlLive/activity/image/LCP31WOW-4IMP-NLAE-1620807553972-OYWXNLLNFNJI.png',
        'https://img.qlchat.com/qlLive/activity/image/LCP31WOW-4IMP-NLAE-1620807553972-OYWXNLLNFNJI.png',
    ]
    const promiseAll: Array<Promise<any>> = []
    imgs.forEach((img: string) => {
        const p = new Promise((resolve, reject) => {
            // 用于处理图片数据,用于离屏画图
            fetch(img)
                .then(response => response.blob())
                .then(blob => resolve(createImageBitmap(blob)))
        })
        promiseAll.push(p)
    })
    // 这里处理有点慢
    Promise.all(promiseAll)
        .then(lists => {
            this.listImage = lists.filter((img: ImageData) => img && img.width > 0)
        })
        .catch(err => {
            console.error('图片加载失败...', err)
        })
}

复制代码

点赞效果逻辑还是和Canvas处理一致

// 绘制
private createRender(): Loop<boolean | void> {
    if (!this.listImage.length) return null
    // 一下是在创建时,初始化默认值
    const context = this.ctx
    // 随机取出scale值
    const basicScale = [0.6, 0.9, 1.2][this.getRandom(0, 2)]
    //随机取一张图片
    const img = this.listImage[this.getRandom(0, this.listImage.length - 1)]
    const offset = 20
    // 随机动画X轴的位置,是动画不重叠在一起
    const basicX = this.width / 2 + this.getRandom(-offset, offset)
    const angle = this.getRandom(2, 12)
    // x轴偏移量10 - 30
    let ratio = this.getRandom(10, 30) * (this.getRandom(0, 1) ? 1 : -1)
    // 获取X轴值
    const getTranslateX = (diffTime: number): number => {
        if (diffTime < this.scaleTime) {
            return basicX
        } else {
            return basicX + ratio * Math.sin(angle * (diffTime - this.scaleTime))
        }
    }
    // 获取Y轴值
    const getTranslateY = (diffTime: number): number => {
        return Number(img.height) / 2 + (this.height - Number(img.height) / 2) * (1 - diffTime)
    }
    // scale方法倍数 针对一个鲜花创建一个scale值
    const getScale = (diffTime: number): number => {
        if (diffTime < this.scaleTime) {
            return Number((diffTime / this.scaleTime).toFixed(2)) * basicScale
        } else {
            return basicScale
        }
    }
    // 随机开始淡出时间,
    const fadeOutStage = this.getRandom(16, 20) / 100
    // 透明度
    const getAlpha = (diffTime: number): number => {
        const left = 1 - diffTime
        if (left > fadeOutStage) {
            return 1
        } else {
            return 1 - Number(((fadeOutStage - left) / fadeOutStage).toFixed(2))
        }
    }
    return diffTime => {
        if (diffTime >= 1) return true
        const scale = getScale(diffTime)
        context.save()
        context.beginPath()
        context.translate(getTranslateX(diffTime), getTranslateY(diffTime))
        context.scale(scale, scale)
        context.globalAlpha = getAlpha(diffTime)
        context.drawImage(img, -img.width / 2, -img.height / 2, Number(img.width), Number(img.height))
        context.restore()
    }
}
// 扫描渲染列表
private scan() {
    // 清屏(清除上一次绘制内容)
    this.ctx.clearRect(0, 0, this.width, this.height)
    this.ctx.fillStyle = '#fff'
    this.ctx.fillRect(0, 0, 180, 400)
    let index = 0
    let len = this.renderList.length
    if (len > 0) {
        // 重新扫描后index= 0;重新获取长度
        requestFrame(this.scan.bind(this))
        this.scanning = true
    } else {
        this.scanning = false
    }
    while (index < len) {
        const curRender = this.renderList[index]
        if (!curRender || !curRender.render || curRender.render.call(null, (Date.now() - curRender.timestamp) / curRender.duration)) {
            // 动画已结束,删除绘制
            this.renderList.splice(index, 1)
            len--
        } else {
            index++
        }
    }
}
// 点赞开始
public likeStart() {
    // 初始化礼物数据、回调函数
    const render = this.createRender()
    const duration = this.getRandom(1500, 3000)
    this.renderList.push({
        render,
        duration,
        timestamp: Date.now()
    })
    if (!this.scanning) {
        this.scanning = true
        requestFrame(this.scan.bind(this))
    }
    return this
}
复制代码

最后

两种方式渲染点赞动画都已经完成。完整代码

本文到此结束。希望对你有帮助。

小编第一次写文章文笔有限、才疏学浅,文中如有不正之处,万望告知。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享