Canvas实现:实现一些有意思的进度条

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天,点击查看活动详情

引言

因为之前在开发时,遇到过进度条的需求,一开始是使用css实现的,实现起来并不复杂,最近刚好在学习canvas,于是决定使用canvas结合已掌握的一些知识实现一个进度条及其插件,收获颇多,希望对大家有所收获。

初始化画布

之前使用canvas的时候,都会直接在DOM上写上一个canvas标签,类似于这种:

<canvas id="canvas" width="500" height="100"></canvas>

这样用户再使用的时候不仅要专注于JS还要专注于HTML,觉得体验实在有些欠缺,于是我这里借鉴了echartsVue的一些模式来实现,思路步骤如下:

  1. 用户提供一个元素的选择器seletor,根据该选择器获取对应元素。
  2. 获取到元素之后,获取其自身的宽高。
  3. 生成一个canvas画布,将获取到的元素的宽高赋予该画布。
  4. 将原本的元素替换为canvas
<div id="app" style="width:500px;height:100px;"></div>
class Progress{
    constructor(currentPercent = 0) {
        // 当前进度默认为0
        this.currentPercent = currentPercent
    }
    // 初始化画布
    initCanvas(node) {
        // 获取对应元素的宽高
        const width = node.clientWidth
        const height = node.clientHeight
        // 创建画布
        const canvas = document.createElement('canvas')
        canvas.width = width
        canvas.height = height
        const parentNode = node.parentNode
        // 替换节点
        parentNode.replaceChild(canvas, node)
        this.ctx = canvas.getContext('2d')
    }
    mount(selector) {
        const node = document.querySelector(selector)
        if(!node) {
            throw new TypeError('node is not exist')
        }
        this.initCanvas(node)
      // 返回当前实例允许链式调用
        return this
    }
}
new Progress().mount('#app')

渲染进度条

接下来我们要开始绘制两条进度条了,比较复杂的是进度条要绘制一个圆角,类似于border-radius,思路步骤如下:

  1. 实现一个可以清空画布的方法,方便后续重新绘制。
  2. 实现一个可以绘制圆角矩形方法,方便绘制圆角矩形样式的进度条
  3. 根据当前画布的宽高,动态计算进度条的起始坐标与宽高。
  4. 绘制代表整体进度的灰色进度条。
  5. 根据当前进度绘制蓝色当前进度条。

绘制圆角矩形,本质就是在矩形的四个角绘制半径为R的1/4圆

class Progress{
    constructor(currentPercent = 0) {
        // ...
    }
    // 初始化画布
    initCanvas(node) {
        // ...
    }
    mount(selector) {
      // ...
    }
    // 清空画布
    clear() {
        this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height)
    }
    // 获取一个圆角矩形路径
    beginRoundRecPath(ctx, x, y, width, height, borderRadius) {
        // 当宽高不存在时,不渲染
        if(!width || !height) {
            ctx.beginPath()
            ctx.closePath()
            return
        }
        ctx.beginPath()
        // 左上角
        ctx.arc(x + borderRadius, y + borderRadius, borderRadius, Math.PI * 1.5, Math.PI, true)
        ctx.lineTo(x, y + height - borderRadius)
        // 左下角
        ctx.arc(x + borderRadius, y + height - borderRadius, borderRadius, Math.PI, Math.PI * 0.5, true)
        ctx.lineTo(x + width - borderRadius, y + height)
        // 右下角
        ctx.arc(x + width - borderRadius, y + height - borderRadius, borderRadius, 0.5 * Math.PI, 0, true)
        ctx.lineTo(x + width, y + borderRadius)
        // 右上角
        ctx.arc(x + width - borderRadius, y + borderRadius, borderRadius, 0, 1.5 * Math.PI, true)
        ctx.lineTo(x + borderRadius, y)
        ctx.closePath()
    }
    // 渲染一个边框
    renderBorder() {
        this.ctx.strokeRect(0,0, this.ctx.canvas.width, this.ctx.canvas.height)
    }
    // 渲染背景滑道
    renderProcessBar() {
        this.bar = {}
        // 动态计算宽高与坐标,宽度为80%画布大小,高度为30%画布大小,并水平垂直居中
        this.bar.width = this.ctx.canvas.width * 0.8
        this.bar.height = this.ctx.canvas.height * 0.3
        this.bar.x = this.ctx.canvas.width * 0.1
        this.bar.y = this.ctx.canvas.height * 0.5 - this.bar.height * 0.5
        this.renderTotalProcessBar(this.bar.x, this.bar.y, this.bar.width, this.bar.height)
        this.renderCurrentProcessBar(this.bar.x, this.bar.y, this.bar.width, this.bar.height, this.currentPercent)
    }
    // 绘制整体进度条
    renderTotalProcessBar(x, y, width, height) {
        this.ctx.save()
        this.ctx.fillStyle = 'rgba(0,0,0,0.2)'
        this.beginRoundRecPath(this.ctx, x, y, width, height, height * 0.5)
        this.ctx.fill()
        this.ctx.restore()
    }
    // 填充当前进度条
    renderCurrentProcessBar(x, y, width, height, currentPercent) {
        this.ctx.save()
        this.ctx.fillStyle = '#409eff'
        this.beginRoundRecPath(this.ctx, x, y, width * (currentPercent / 100), height, height * 0.5)
        this.ctx.fill()
        this.ctx.restore()
    }
    // 渲染方法
    render() {
        // 渲染前先清空
        this.clear()
        // 渲染一个边框方便看的清除
        this.renderBorder()
        // 渲染进度条
        this.renderProcessBar()
        // 返回当前实例允许链式调用
        return this
    }
}
new Progress(60).mount('#app').render()

image.png

动态渲染进度

接下来我们要是实现一个对外暴露的方法setPercent,让用户可以自行修改当前进度,简单点来说我们只需要设置当前的进度为传入的进度,并重新render一下即可:

// 设置当前进度
setPercent(percent) {
    this.currentPercent = percent
    this.render()
}

但我希望在在设置时有一个过渡动画,让用户可以看到进度条加载的过程,这里我们需要使用到requestAnimationFrame�cancelAnimationFrame�两个方法,第一个方法允许传入一个回调函数,浏览器会在下一次刷新时,执行该回调。第二个方法是用以取消执行该回调。通过递归requestAnimationFrame,我们可以实现一个渲染动画。

这里setPercent返回一个Promise,方便告知用户是否渲染完成。

class Progress{
    constructor(currentPercent = 0) {
        //...
    }
    //...
    // 渲染方法
    render() {
        //...
    }
    // 设置当前进度
    setPercent(percent) {
        if(percent < 0 || percent > 100) {
            return
        }
        return this.startAnimate(percent)
    }
    // 开始动画
    startAnimate(percent) {
        return new Promise(resolve => {
            this.stopAnimate()
            const loop = () => {
                this.requestId = requestAnimationFrame(loop)
                // 相等时停止渲染
                if(percent === this.currentPercent) {
                    this.stopAnimate()
                    resolve()
                    return
                }
                if(percent < this.currentPercent) {
                    this.currentPercent --
                } else {
                    this.currentPercent ++
                }
                this.render()
            }
            loop()
        })
    }
    // 取消动画
    stopAnimate() {
        if(this.requestId) {
            cancelAnimationFrame(this.requestId)
            this.requestId = null
        }
    }
}


new Progress(10).mount('#app').render().setPercent(60)

2022-12-09 17-13-14.2022-12-09 17_13_27.gif

插件

进度条的整体功能已经完成了,而且整体功能也已经比较“庞大”了,如果继续在主体添加额外的功能会使我们的代码变得越来越难以维护,且越来越不健壮,于是接下来的其他额外功能,我们开发一些插件来实现。我这里将插件分为:基础插件业务插件。

  • 基础插件:负责底层功能的实现,可以为上层提供服务,通用性较强,功能简单,方便维护。
  • 业务插件:负责具体实现某一个功能,使用基础插件为自身提供服务,针对实现某一特定业务场景,不要求通用性,高内聚保证自身功能强大。

但在开发这些插件时,我们首先需要完善一些进度条主体的功能:

  • 插件可以获取使用一些主体的内部数据,这些数据是**单向的,**以确保数据流不会混乱。
  • 在插件需要修改主体的数据时,主体会暴露一些公共方法
  • 插件需要主体暴露特定的钩子函数,以确保在合适的时机做合适的事。

这里我们使用一个常用的设计模式:发布订阅模式,各个插件作为订阅者,主体作为发布者,每当主体到特定时机时,会通知所有订阅者,这里我们借鉴了现成的EventEmitter,相信不论是熟悉nodejs或者是Vue的朋友都不会陌生。

class EventEmitter {
    constructor() {
        this._events = {}
    }
    // 绑定事件
    on(eventName, fn) {
        if (typeof fn !== 'function') {
            throw new TypeError('The listener must be a function')
        }
        if (!this._events[eventName]) {
            this._events[eventName] = [fn]
            return
        }
        this._events[eventName].push(fn)
    }
    // 暴露事件
    emit(eventName, ...args) {
        if (!this._events[eventName]) {
            return
        }
        this._events[eventName].forEach(fn => {
            fn(...args)
        })
    }
}

钩子函数实现思路如下:

  1. Process继承于EventEmitter,这样Process就可以作为发布者,对外暴露emit事件了。
  2. 初期对外暴露三个钩子函数,createdmountedupdated
  3. created会在构造函数中触发,表示初始数据在家完成。
  4. mounted会在Processmount方法中触发,表示canvas已挂载完成。
  5. updated会在Processrender方法中触发,表示canvas已被更新。
class EventEmitter {
    //...
}

class Progress extends EventEmitter{
    constructor(currentPercent = 0) {
        super()
        // 当前进度默认为0%
        this.currentPercent = currentPercent
        // 生命周期created
        this.emit('created')
    }
    // ...
    mount(selector) {
        const node = document.querySelector(selector)
        if(!node) {
            throw new TypeError('node is not exist')
        }
        this.initCanvas(node)
      // 生命周期mounted
        this.emit('mounted')
        // 返回当前实例允许链式调用
        return this
    }
    // ...
    render() {
        // 渲染前先清空
        this.clear()
        // 渲染一个边框方便看的清除
        this.renderBorder()
        // 渲染进度条
        this.renderProcessBar()
        // 生命周期updated
        this.emit('updated')
        // 返回当前实例允许链式调用
        return this
    }
    // ...
}


new Progress(10).mount('#app').render().setPercent(60)

对外暴露公共方法如下:

  1. 提供一个use方法,传入一个插件实例,会自动调用插件的install方法,将当前进度条实例传入到插件内部。这里是仿照着Vue,不过传入的是都是实例。
  2. 获取当前进度currentPercent的方法。
  3. 获取当前上下文ctx的方法。
  4. 获取当前进度条bar的坐标、宽高的方法。
class Progress extends EventEmitter{
  // ...
      // 注册插件
    use(plugin) {
        if(!plugin || typeof plugin.install !== 'function') {
            return
        }
        plugin.install(this)
    }
    // 获取当前进度
    getCurrentPercent() {
        return this.currentPercent
    }
    // 获取当前上下文与画布信息
    getContext() {
        return this.ctx
    }
    // 获取进度条信息
    getProcessBarInfo() {
        return this.bar
    }
}

基础插件:百分比文字

首先,我们先使用现有的资源实现一个百分比数字展示的功能插件,具体实现:当进度条触发updated的时候,根据当前进度currentPercent与进度条bar信息,在ctx上绘制一个内部或外部的文字。

// 文字插件
class ProcessTextPlugin {
    constructor({fontSize = 12, color = '#000', textIndent = false, margin = 10, format }) {
        this.fontSize = fontSize // 文字大小
        this.color = color // 文字颜色
        this.textIndent = textIndent // 是否展示在文字内部
        this.margin = margin // 文字距离进度条的安全距离
        // 格式化文字内容
        const defaultFormat = currentPercent => {
            return currentPercent + '%'
        }
        this.format = typeof format === 'function' ? format : defaultFormat
    }
    install(process) {
        this.process = process
        this.process.on('updated', () => {
            this.render()
        })
    }
    render() {
        if(this.textIndent) {
            this.renderInnerText()
            return
        }
        this.renderText()
    }
    // 渲染外部文字
    renderText() {
        const { x, y, width, height } = this.process.getProcessBarInfo()
        const currentPercent = this.process.getCurrentPercent()
        const ctx = this.process.ctx
        ctx.save()
        ctx.font = `${this.fontSize}px Arial`
        ctx.fillStyle = this.color
        const text = this.format(currentPercent)
        ctx.textBaseline = 'middle'
        ctx.fillText(text, x + width + this.margin, y + height / 2)
    }
    // 渲染内部文字
    renderInnerText() {
        const { x, y, width, height } = this.process.getProcessBarInfo()
        const currentPercent = this.process.getCurrentPercent()
        const ctx = this.process.ctx
        const text = this.format(currentPercent)
        const textWidth = ctx.measureText(text).width
        const currentBarWidth = width * (currentPercent / 100)
        // 进度条宽度若比文字宽度还要小,则不进行渲染
        if(currentBarWidth < (textWidth + this.margin)) {
            return
        }
        ctx.save()
        ctx.font = `${this.fontSize}px Arial`
        ctx.fillStyle = this.color
        ctx.textBaseline = 'middle'
        ctx.fillText(text, x + currentBarWidth - textWidth - this.margin, y + height / 2)
    }
}
const process = new Progress()
// const processTextPlugin = new ProcessTextPlugin({fontSize: 12})
// process.use(processTextPlugin)
const processTextInnerPlugin = new ProcessTextPlugin({fontSize: 12, color: '#fff', textIndent: true})
process.use(processTextInnerPlugin)
process.mount('#app').render().setPercent(60)

2022-12-09 23-12-07.2022-12-09 23_12_16.gif
2022-12-09 23-12-33.2022-12-09 23_12_40.gif

基础插件:进度条颜色

接下来我们要实现一个设置颜色的插件,实现步骤如下:

  1. 首先需要在Process中将两条进度条颜色设置为变量,并新建两个公共方法setTotalProcessBarCoorsetCurrentProcessBarColor方法,通过调用该公共方法,可以修改两条进度条颜色。
  2. 插件当进度条触发updated的时候,根据当前进度currentPercent,修改颜色。
  3. 颜色支持三种格式数据:单个颜色/数组/回调函数。

2022-12-10 01-00-16.2022-12-10 01_00_31.gif
首先我们先修改下主体类的颜色变量与公共方法:

class Progress extends EventEmitter{
    constructor(currentPercent = 0) {
        super()
        // 当前进度默认为0%
        this.currentPercent = currentPercent
        // 当前进度条颜色
        this.currentProcessBarColor = '#409eff'
        // 整体进度条颜色
        this.totalProcessBarColor = 'rgba(0,0,0,0.2)'
        // 生命周期created
        this.emit('created')
    }
    // ...
    // 绘制整体进度条
    renderTotalProcessBar(x, y, width, height) {
        this.ctx.save()
        this.ctx.fillStyle = this.totalProcessBarColor
        this.beginRoundRecPath(this.ctx, x, y, width, height, height * 0.5)
        this.ctx.fill()
        this.ctx.restore()
    }
    // 填充当前进度条
    renderCurrentProcessBar(x, y, width, height, currentPercent) {
        this.ctx.save()
        this.ctx.fillStyle = this.currentProcessBarColor
        this.beginRoundRecPath(this.ctx, x, y, width * (currentPercent / 100), height, height * 0.5)
        this.ctx.fill()
        this.ctx.restore()
    }
  // ...
    // 设置当前进度条颜色
    setCurrentProcessBarColor(currentProcessBarColor) {
        this.currentProcessBarColor = currentProcessBarColor
    }
    // 设置全部进度条颜色
    setTotalProcessBarColor(totalProcessBarColor) {
        this.totalProcessBarColor = totalProcessBarColor
    }
}

// 颜色插件
class ProcessColorPlugin {
    constructor(color, isBg = false) {
        this.color = color
        this.isBg = isBg // 设置的是否为全部进度条
    }
    install(process) {
        if(!this.color) {
            return
        }
        this.process = process
        // 这里会改变this的指向,所以我们需要重新给指回来
        this.setColorFn = this.isBg ?
            this.process.setTotalProcessBarColor.bind(this.process) :
            this.process.setCurrentProcessBarColor.bind(this.process)
        // 数组形式
        if(Array.isArray(this.color)) {
            this.handleColorList()
            return
        }
        // 回调函数形式
        if(typeof this.color === 'function') {
            this.handleColorCallback()
            return
        }
        // 单个颜色形式
        this.handleColor()
    }
    handleColor() {
        this.process.on('mounted', () => {
            this.setColorFn(this.color)
        })
    }
    // 数组形式
    handleColorList() {
        this.process.on('updated', () => {
            const colorList = this.color.sort((a, b) => a.percent - b.percent)
            const currentPercent = this.process.getCurrentPercent()
            const target = colorList.find(item => item.percent > currentPercent)
            const currentProcessBarColor = target ? target.color : colorList[colorList.length - 1].color
            this.setColorFn(currentProcessBarColor)
        })
    }
    // 回调函数形式
    handleColorCallback() {
        this.process.on('updated', () => {
            const percent = this.process.getCurrentPercent()
            const ctx = this.process.getContext()
            const bar = this.process.getProcessBarInfo()
            const currentProcessBarColor = this.color({percent, ctx, bar})
            if(!currentProcessBarColor) {
                return
            }
            this.setColorFn(currentProcessBarColor)
        })
    }
}


const process = new Progress()
const color = [
    {color: '#f56c6c', percent: 20},
    {color: '#e6a23c', percent: 40},
    {color: '#5cb87a', percent: 60},
    {color: '#1989fa', percent: 80},
    {color: '#6f7ad3', percent: 100}
]
const processColorPlugin = new ProcessColorPlugin(color)
const processColorBgPlugin = new ProcessColorPlugin('#fff', true)
process.use(processColorPlugin)
process.use(processColorBgPlugin)
process.mount('#app').render().setPercent(100)

基础插件:插画

接下来我们要开发一个插画插件,这个插件可以让我们在进度条更新updated过后插入一张图片或者canvas

这个插件功能很开放,我们将画布交给了用户,让用户自己去绘制,you can you up!

实现步骤如下:

  • 为了更加灵活,这个插件的构造函数接受一个参数picture,这个参数可以为两种类型。
  • 第一种类型:picture为一个图片对象,图片对象应包含imagexy属性,刚好对应canvasdrawImage方法,当每次进度条updated时,会渲染该图片对象
  • 第二种类型:picture为一个回调函数形式,当每次进度条updated时,会执行该回调函数,并传入一些进度条当前的自身信息,该回调函数需要返回一个图片对象,我们会渲染该图片对象

2022-12-10 11-29-10.2022-12-10 11_29_23.gif

// 绘图插件
class ProcessPicturePlugin {
    constructor(picture) {
        this.picture = picture
    }
    install(process) {
        this.process = process
        const picture = this.picture
        if(this.isPicture(this.picture)) {
            this.handlePicture(picture)
            return
        }
        if(typeof picture === 'function') {
            this.handlePictureCallback(picture)
        }
    }
    // 判断是否为一张图片
    isPicture(picture) {
        return picture && picture.image && picture.x != null && picture.y != null
    }
    // 渲染单张图片
    handlePicture(picture) {
        this.process.on('updated', () => {
            const context = this.process.getContext()
            context.drawImage(picture.image, picture.x, picture.y)
        })
    }
    // 回调函数形式渲染
    handlePictureCallback(pictureCallback) {
        this.process.on('updated', () => {
            const context = this.process.getContext()
            const bar = this.process.getProcessBarInfo()
            const percent = this.process.getCurrentPercent()
            const picture = pictureCallback({context, bar, percent})
            context.drawImage(picture.image, picture.x, picture.y)
        })
    }
}

下面是一个使用该插件的例子:

const pictureCallback = ({ bar, percent}) => {
    const canvas = document.createElement('canvas')
    const width = bar.height * 2
    const height = bar.height * 2
    canvas.width = width
    canvas.height = height
    const ctx = canvas.getContext('2d')
    ctx.save()
    ctx.beginPath()
    ctx.arc(width / 2, height / 2, width / 2 - 1, 0, Math.PI * 2)
    ctx.fillStyle = '#fff'
    ctx.strokeStyle = '#000'
    ctx.closePath()
    ctx.stroke()
    ctx.fill()
    ctx.textBaseline = 'middle'
    ctx.textAlign = 'center'
    ctx.strokeText(percent + '%', width / 2, height / 2)
    ctx.restore()
    return {
        image: canvas,
        x: bar.x + (percent / 100) * bar.width - width,
        y: bar.y + bar.height / 2 - height / 2
    }
}
const processPicturePlugin = new ProcessPicturePlugin(pictureCallback)
const process = new Progress()
process.use(processPicturePlugin)
process.mount('#app').render().setPercent(100)

业务插件:吃豆人

接下来我们做点有意思的事情,使用已有的几个插件,将他们组合使用,做一个可以吃豆人主题风格的进度条。
2022-12-10 13-25-16.2022-12-10 13_25_43.gif

// 吃豆人插件
class ProcessPacmanPlugin {
    constructor() {
        // 豆子数量
        this.pacNum = 10
    }
    install(process) {
        this.initBackground(process)
        this.initPac(process)
        this.initPacman(process)
        this.initPacText(process)
    }
    // 初始化背景色插件,用以隐藏两条进度条颜色
    initBackground(process) {
        const current = new ProcessColorPlugin('rgba(0,0,0,0)')
        const total = new ProcessColorPlugin('rgba(0,0,0,0)', true)
        process.use(current)
        process.use(total)
    }
    // 初始化吃豆人
    initPacman(process) {
        const callback = ({percent, context, bar}) => {
            const canvas = document.createElement('canvas')
            const width = Math.min(bar.height, bar.width) * 2
            const height = Math.min(bar.height, bar.width) * 2
            canvas.width = width
            canvas.height = height
            const ctx = canvas.getContext('2d')
            // 渲染身体
            ctx.save()
            ctx.beginPath()
            const x = width / 2
            const y = height / 2
            const r = width / 2 - 1
            let startAngle = 30
            let endAngle = 330
            // 当吃到豆子是,会闭合,这里为了开闭不是瞬间的,使用了[0-5]作为一个区间
            if(percent % this.pacNum < 5) {
                startAngle = 0
                endAngle = 360
            }
            const startRadina = Math.PI * startAngle / 180
            const endRadina = Math.PI * endAngle / 180
            ctx.moveTo(x, y)
            ctx.arc(x, y, r, startRadina, endRadina)
            ctx.fillStyle = '#f5e524'
            ctx.fill()
            ctx.closePath()
            ctx.stroke()
            ctx.restore()
            // 渲染眼睛
            ctx.save()
            ctx.beginPath()
            ctx.moveTo(width * 0.7, height * 0.2)
            ctx.arc(width * 0.6, height * 0.2, width * 0.07, 0, Math.PI * 2)
            ctx.fillStyle  = '#000'
            ctx.fill()
            ctx.closePath()
            ctx.restore()
            return {
                image: canvas,
                x: bar.x + (percent / 100) * bar.width - width,
                y: bar.y + bar.height / 2 - height / 2
            }
        }
        const pacman = new ProcessPicturePlugin(callback)
        process.use(pacman)
    }
    // 初始化豆子
    initPac(process) {
        const callback = ({percent, context, bar}) => {
            const canvas = document.createElement('canvas')
            const width = bar.width
            const height = bar.height
            canvas.width = width
            canvas.height = height
            const r = 2
            const ctx = canvas.getContext('2d')
            for(let i = 0; i < this.pacNum; i++) {
                const pacPercent = (i + 1) / (this.pacNum + 1)
                if(percent / 100 >= pacPercent) {
                    continue
                }
                ctx.beginPath()
                const x = pacPercent * width
                const y = height / 2
                ctx.moveTo(x, y)
                ctx.arc(x, y, r, 0, Math.PI * 2)
                ctx.fillStyle = '#000'
                ctx.fill()
                ctx.closePath()
            }
            return {
                image: canvas,
                x: bar.x,
                y: bar.y
            }
        }
        const pacman = new ProcessPicturePlugin(callback)
        process.use(pacman)
    }
    // 初始化剩余豆子数量
    initPacText(process) {
        const format = percent => {
            if(percent === 100) {
                return '完成!'
            }
            return percent + '%'
        }
        const textPlugin = new ProcessTextPlugin({fontSize: 12, format})
        process.use(textPlugin)
    }
}



const processPacmanPlugin = new ProcessPacmanPlugin()
const process = new Progress()
process.use(processPacmanPlugin)
process.mount('#app').render().setPercent(100)

代码片段

基础插件:交互事件

接下来我们希望一些能有用户交互的进度条,我们可以将canvas点击事件封装一下,实现一个通用的事件插件,从而更方便我们的使用。思路如下:

  • PC端封装:mousedownmousemovemouseup
  • 移动端封装:touchstarttouchmovetouchend
  • 根据不同的端自动使用不同的事件,对外统一暴露touch-downtouch-movetouch-up,使用者无需再为不同的端做不同的适配。
  • 暴露事件的同时,携带事件触发时canvas坐标。
// 点击事件插件
class ProcessTouchEventPlugin extends EventEmitter {
    constructor() {
        super();
        this.isMobile = /Mobi|Android|iPhone/i.test(navigator.userAgent)
    }
    install(process) {
        process.on('mounted', () => {
            const ctx = process.getContext()
            const canvas = ctx.canvas
            this.addTouchDownEvent(canvas)
            this.addTouchMoveEvent(canvas)
            this.addTouchUpEvent(canvas)
        })
    }
    addTouchDownEvent(canvas) {
        const eventName = !this.isMobile ? 'mousedown' : 'touchstart'
        canvas.addEventListener(eventName, event => {
            const position = this.getCanvasPosition(canvas, event, eventName)
            this.handleEmitEvent('touch-down', position)
        })
    }
    addTouchMoveEvent(canvas) {
        const eventName = !this.isMobile ? 'mousemove' : 'touchmove'
        canvas.addEventListener(eventName, event => {
            const position = this.getCanvasPosition(canvas, event, eventName)
            this.handleEmitEvent('touch-move', position)
        })
    }
    addTouchUpEvent(canvas) {
        const eventName = !this.isMobile ? 'mouseup' : 'touchend'
        canvas.addEventListener(eventName, event => {
            const position = this.getCanvasPosition(canvas, event, eventName)
            this.handleEmitEvent('touch-up', position)
        })
    }
    // 获取当前坐标
    getCanvasPosition(canvas, event, eventName) {
        let clientTarget = null
        switch(eventName) {
            case 'mousedown':
            case 'mousemove':
            case 'mouseup':
                clientTarget = event
                break
            case 'touchstart':
            case 'touchmove':
                clientTarget = event.touches[0]
                break
            case 'touchend':
                clientTarget = event.changedTouches[event.changedTouches.length - 1]
        }
        if(!clientTarget) {
            return { x: null, y: null}
        }
        const clientX = clientTarget.clientX
        const clientY = clientTarget.clientY
        const rect = canvas.getBoundingClientRect()
        const x = clientX - (rect.left + (rect.width - canvas.width) / 2)
        const y = clientY - (rect.top + (rect.height - canvas.height) / 2)
        return { x,y }
    }
    // 对外暴露事件
    handleEmitEvent(eventName, params) {
        this.emit(eventName, params)
    }
}
// 按下
plugin.on('touch-down', position => {
    console.log(position)
})
// 移动
plugin.on('touch-move', position => {
    console.log(position)
})
// 抬起
plugin.on('touch-up', position => {
    console.log(position)
})

业务插件:滑块

我们接下来要实现的是一个用户可以拖动的滑块,有兴趣的朋友可以研究下。
2022-12-11 12-30-16.2022-12-11 12_30_52.gif


// 滑块插件
class ProcessSliderPlugin extends EventEmitter {
    constructor() {
        super();
        this.sliderButton = { x: 0, y: 0, width: 0, height: 0, isActive: false }
    }
    install(process) {
        this.initSliderButton(process)
        this.initProcessNumber(process)
        this.initSliderEvent(process)
    }
    // 初始化滑块按钮
    initSliderButton(process) {
        const pictureCallback = ({ bar, percent}) => {
            const canvas = document.createElement('canvas')
            const width = bar.height * 2
            const height = bar.height * 2
            canvas.width = width
            canvas.height = height
            const ctx = canvas.getContext('2d')
            ctx.save()
            ctx.beginPath()
            ctx.arc(width / 2, height / 2, width / 2 - 1, 0, Math.PI * 2)
            ctx.fillStyle = '#fff'
            ctx.strokeStyle = this.sliderButton.isActive ? '#409eff' : '#000'
            ctx.closePath()
            ctx.stroke()
            ctx.fill()
            ctx.restore()
            this.sliderButton.width = width
            this.sliderButton.height = height
            this.sliderButton.x =  bar.x + (percent / 100) * bar.width - width / 2
            this.sliderButton.y = bar.y + bar.height / 2 - height / 2
            return {
                image: canvas,
                x: this.sliderButton.x,
                y: this.sliderButton.y
            }
        }
        const plugin = new ProcessPicturePlugin(pictureCallback)
        process.use(plugin)
    }
    // 初始化滑动事件
    initSliderEvent(process) {
        const plugin = new ProcessTouchEventPlugin()
        let originPosition = null
        plugin.on('touch-down', position => {
            if(!this.isTouchSliderButton(position)) {
                return
            }
            originPosition = position
        })
        plugin.on('touch-move', position => {
            if(!originPosition) {
                return
            }
            const {x, width} = process.getProcessBarInfo()
            // 当滑动超出进度条时,也不做处理
            if(position.x < x || position.x > x + width) {
                return
            }
            const percent = parseInt((position.x - x) / (width)  * 100)
            process.setPercent(percent)
            this.handleEmitPercentChangeEvent(percent)
        })
        plugin.on('touch-up', () => {
            originPosition = null
        })
        // 高亮按钮
        plugin.on('touch-move', position => {
            this.sliderButton.isActive = this.isTouchSliderButton(position)
            process.render()
        })
        process.use(plugin)
    }
    // 判断是否点击到滑块
    isTouchSliderButton(position) {
        const { x, y, width, height } = this.sliderButton
        return position.x >= x && position.x <= x + width && position.y >= y && position.y <= y + height
    }
    // 数字插件
    initProcessNumber(process) {
        const plugin = new ProcessTextPlugin({fontSize: 12})
        process.use(plugin)
    }
    // 对外暴露修改事件
    handleEmitPercentChangeEvent(percent) {
        this.emit('change', percent)
    }
}

const processSliderPlugin = new ProcessSliderPlugin()
const percentNode = document.querySelector('#percent')
processSliderPlugin.on('change', percent => {
    percentNode.innerText = percent + '%'
})
const process = new Progress()
process.use(processSliderPlugin)
process.mount('#app').render()

代码片段

总结

  • 符合单一职责原则,将主体、插件相互独立,一个程序只做好一件事。
  • 符合开放封闭原则,插件只调用主体的公共方法,主体不断地扩展开放新的公共方法。
  • 不符合接口独立原则,这一点体现在颜色插件与插画插件,入参出现了多种情况,导致用户体验可能会混乱。

最后送给大家一个彩蛋,实现一个我最喜欢的Webstorm进度条插件:Nyan Process Bar

有谁能拒绝一只可爱的彩虹猫呢?

2022-12-10 22-20-34.2022-12-10 22_20_56.gif

// 彩虹猫插件
class ProcessNyanPlugin {
    constructor() {
    }
    install(process) {
        this.initProcessBarColor(process)
        this.initNyanCat(process)
    }
    initProcessBarColor(process) {
        const rainbowColor = [
            {color: 'rgb(255,0,0)', percent: 14},
            {color: 'rgb(255,165,0)', percent: 28},
            {color: 'rgb(255,255,0)', percent: 42},
            {color: 'rgb(0,255,0)', percent: 56},
            {color: 'rgb(0,127,255)', percent: 70},
            {color: 'rgb(0,0,255)', percent: 84},
            {color: 'rgb(139,0,255)', percent: 100},
        ]
        const colorCallback = ({ctx, percent, bar}) => {
            const { x, y, width, height } = bar
            const linearGradient = ctx.createLinearGradient(x, y, x, y + height)
            rainbowColor.forEach(item => {
                linearGradient.addColorStop(item.percent / 100, item.color)
            })
            return linearGradient
        }
        const processColorPlugin = new ProcessColorPlugin(colorCallback)
        const processColorBgPlugin = new ProcessColorPlugin('#fff', true)
        process.use(processColorPlugin)
        process.use(processColorBgPlugin)
    }
    loadImage(src) {
        return new Promise((resolve, reject) => {
            const image = new Image()
            image.src = src
            image.onload = () => {
                resolve(image)
            }
            image.onerror = error => {
                reject(error)
            }
        })
    }
    async initNyanCat(process) {
        const src = 'https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/cc26825baece4142bf4d525d705e1de4~tplv-k3u1fbpfcp-watermark.image?'
        const image = await this.loadImage(src)
        const pictureCallback = ({ bar, percent}) => {
            const width = bar.height * 3.5
            const height = bar.height * 2
            const canvas = document.createElement('canvas')
            canvas.width = width
            canvas.height = height
            const ctx = canvas.getContext('2d')
            ctx.drawImage(image, 0, 0, width, height)
            return {
                image: canvas,
                x: bar.x + (percent / 100) * bar.width - width * 0.9,
                y: bar.y + bar.height / 2 - height / 2
            }
        }
        const processPicturePlugin = new ProcessPicturePlugin(pictureCallback)
        process.use(processPicturePlugin)
    }
}

const processNyanPlugin = new ProcessNyanPlugin()
const process = new Progress()
process.use(processNyanPlugin)
process.mount('#app').render().setPercent(100)
© 版权声明
THE END
喜欢就支持一下吧
点赞5 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容