开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天,点击查看活动详情
引言
因为之前在开发时,遇到过进度条的需求,一开始是使用css实现的,实现起来并不复杂,最近刚好在学习canvas
,于是决定使用canvas
结合已掌握的一些知识实现一个进度条及其插件,收获颇多,希望对大家有所收获。
初始化画布
之前使用canvas
的时候,都会直接在DOM
上写上一个canvas标签
,类似于这种:
<canvas id="canvas" width="500" height="100"></canvas>
这样用户再使用的时候不仅要专注于JS
还要专注于HTML
,觉得体验实在有些欠缺,于是我这里借鉴了echarts
与Vue
的一些模式来实现,思路步骤如下:
- 用户提供一个元素的选择器
seletor
,根据该选择器获取对应元素。 - 获取到元素之后,获取其自身的宽高。
- 生成一个
canvas
画布,将获取到的元素的宽高赋予该画布。 - 将原本的元素替换为
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
,思路步骤如下:
- 实现一个可以清空画布的方法,方便后续重新绘制。
- 实现一个可以绘制圆角矩形方法,方便绘制圆角矩形样式的进度条
- 根据当前画布的宽高,动态计算进度条的起始坐标与宽高。
- 绘制代表整体进度的灰色进度条。
- 根据当前进度绘制蓝色当前进度条。
绘制圆角矩形,本质就是在矩形的四个角绘制半径为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()
动态渲染进度
接下来我们要是实现一个对外暴露的方法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)
插件
进度条的整体功能已经完成了,而且整体功能也已经比较“庞大”了,如果继续在主体添加额外的功能会使我们的代码变得越来越难以维护,且越来越不健壮,于是接下来的其他额外功能,我们开发一些插件来实现。我这里将插件分为:基础插件与业务插件。
- 基础插件:负责底层功能的实现,可以为上层提供服务,通用性较强,功能简单,方便维护。
- 业务插件:负责具体实现某一个功能,使用基础插件为自身提供服务,针对实现某一特定业务场景,不要求通用性,高内聚保证自身功能强大。
但在开发这些插件时,我们首先需要完善一些进度条主体的功能:
- 插件可以获取使用一些主体的内部数据,这些数据是**单向的,**以确保数据流不会混乱。
- 在插件需要修改主体的数据时,主体会暴露一些公共方法。
- 插件需要主体暴露特定的钩子函数,以确保在合适的时机做合适的事。
这里我们使用一个常用的设计模式:发布订阅模式,各个插件作为订阅者,主体作为发布者,每当主体到特定时机时,会通知所有订阅者,这里我们借鉴了现成的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)
})
}
}
钩子函数实现思路如下:
- 让
Process
继承于EventEmitter
,这样Process
就可以作为发布者,对外暴露emit
事件了。 - 初期对外暴露三个钩子函数,
created
、mounted
、updated
。 created
会在构造函数中触发,表示初始数据在家完成。mounted
会在Process
的mount
方法中触发,表示canvas
已挂载完成。updated
会在Process
的render
方法中触发,表示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)
对外暴露公共方法如下:
- 提供一个
use
方法,传入一个插件实例,会自动调用插件的install
方法,将当前进度条实例传入到插件内部。这里是仿照着Vue,不过传入的是都是实例。 - 获取当前进度
currentPercent
的方法。 - 获取当前上下文
ctx
的方法。 - 获取当前进度条
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)
基础插件:进度条颜色
接下来我们要实现一个设置颜色的插件,实现步骤如下:
- 首先需要在
Process
中将两条进度条颜色设置为变量,并新建两个公共方法setTotalProcessBarCoor
和setCurrentProcessBarColor
方法,通过调用该公共方法,可以修改两条进度条颜色。 - 插件当进度条触发
updated
的时候,根据当前进度currentPercent
,修改颜色。 - 颜色支持三种格式数据:单个颜色/数组/回调函数。
首先我们先修改下主体类的颜色变量与公共方法:
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
为一个图片对象
,图片对象应包含image
、x
、y
属性,刚好对应canvas
的drawImage
方法,当每次进度条updated
时,会渲染该图片对象 - 第二种类型:
picture
为一个回调函数形式,当每次进度条updated
时,会执行该回调函数,并传入一些进度条当前的自身信息,该回调函数需要返回一个图片对象
,我们会渲染该图片对象
。
// 绘图插件
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)
业务插件:吃豆人
接下来我们做点有意思的事情,使用已有的几个插件,将他们组合使用,做一个可以吃豆人主题风格的进度条。
// 吃豆人插件
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端封装:
mousedown
、mousemove
、mouseup
- 移动端封装:
touchstart
、touchmove
、touchend
- 根据不同的端自动使用不同的事件,对外统一暴露
touch-down
、touch-move
、touch-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)
})
业务插件:滑块
我们接下来要实现的是一个用户可以拖动的滑块,有兴趣的朋友可以研究下。
// 滑块插件
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
有谁能拒绝一只可爱的彩虹猫呢?
// 彩虹猫插件
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)
暂无评论内容