1

最近在提炼一个功能的时候发现可配置项过多,如果全都耦合在一起,首先是代码上不好维护、扩展性不好,其次是如果我不需要该功能的话会带来体积上的冗余,考虑到现在插件化的流行,于是小小的尝试了一番。

先介绍一下这个库的功能,一个简单的让你可以在一个区域,一般是图片上标注一个区域范围,然后返回顶点坐标的功能:

话不多说,开撸。

插件设计

插件我理解就是一个功能片段,代码上可以有各种组织方式,函数或类,各个库或框架可能都有自己的设计,一般你需要暴露一个规定的接口,然后调用插件的时候也会注入一些接口或状态,在此基础上扩展你需要的功能。

我选择的是以函数的方式来组织插件代码,所以一个插件就是一个独立的函数。

首先库的入口是一个类:

class Markjs {}

插件首先需要注册,比如常见的vue

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)

参考该方式,我们的插件也是这么注册:

import Markjs from 'markjs'
import imgPlugin from 'markjs/src/plugins/img'
Markjs.use(imgPlugin)

首先来分析一下这个use要做什么事,因为插件是一个函数,所以在use里直接调用该函数是不是就可以了?在这里其实是不行的,因为Markjs是一个类,使用的时候需要new Markjs来创建一个实例,插件需要访问的变量和方法都要实例化后才能访问到,所以use只做一个简单的收集工作就可以了,插件函数的调用在实例化的同时进行,当然,如果你的插件像vue一样只是添加一些mixin或给原型添加一些方法,那么是可以直接调用的:

class Markjs {
    // 插件列表
    static pluginList = []

    // 安装插件
    static use(plugin, index = -1) {
        if (!plugin) {
            return Markjs
        }
        if (plugin.used) {
            return Markjs
        }
        plugin.used = true
        if (index === -1) {
            Markjs.pluginList.push(plugin)
        } else {
            Markjs.pluginList.splice(index, 0, plugin)
        }
        return Markjs
    }
}

代码很简单,定义了一个静态属性pluginList用来存储插件,静态方法use用来收集插件,会给插件添加一个属性用来判断是否已经添加了,避免重复添加,其次还允许通过第二个参数来控制插件要插入到哪个位置,因为有些插件可能有先后顺序要求。返回Markjs可以进行链式调用。

之后实例化的时候遍历调用插件函数:

class Markjs {
    constructor(opt = {}) {
        //...
        // 调用插件
        this.usePlugins()
    }
    
    // 调用插件
    usePlugins() {
        let index = 0
        let len = Markjs.pluginList.length
        let loopUse = () => {
            if (index >= len) {
                return
            }
            let cur = Markjs.pluginList[index]
            cur(this, utils).then(() => {
                index++
                loopUse()
            })
        }
        loopUse()
    }
}

在创建实例的最后会进行插件的调用,可以看到这里不是简单的循环调用,而是通过promise来进行链式调用,这样做的原因是因为某些插件的初始化可能是异步的,比如这个图片插件里的图片加载就是个异步的过程,所以对应的插件函数必须要返回一个promise

export default function ImgPlugin(instance) {
    let _resolve = null
    let promise = new Promise((resolve) => {
        _resolve = resolve
    })
    
    // 插件逻辑...
    setTimeout(() => {
        _resolve()
    },1000)
    
    return promise
}

到这里,这个简单的插件系统就完成了,instance就是创建的实例对象,可以访问它的变量,方法,或者监听你需要的事件等等。

Markjs

因为已经选择了插件化,所以核心功能,这里指的是标注的相关功能也考虑作为一个插件,所以Markjs这个类只做一些变量定义、事件监听派发及初始化工作。

标注功能使用canvas来实现,所以主要逻辑就是监听鼠标的一些事件来调用canvas的绘图上下文进行绘制,事件的派发用了一个简单的订阅发布模式。

class Markjs {
    constructor(opt = {}) {
        // 配置参数合并处理
        // 变量定义
        this.observer = new Observer()// 发布订阅对象
        // 初始化
        // 绑定事件
        // 调用插件
    }
}

上述就是Markjs类做的全部工作。初始化就做了一件事,创建一个canvas元素然后获取一下绘图上下文,直接来看绑定事件,这个库的功能上需要用到鼠标单击、双击、按下、移动、松开等等事件:

class Markjs {
    bindEvent() {
        this.canvasEle.addEventListener('click', this.onclick)
        this.canvasEle.addEventListener('mousedown', this.onmousedown)
        this.canvasEle.addEventListener('mousemove', this.onmousemove)
        window.addEventListener('mouseup', this.onmouseup)
        this.canvasEle.addEventListener('mouseenter', this.onmouseenter)
        this.canvasEle.addEventListener('mouseleave', this.onmouseleave)
    }
}

双击事件虽然有ondblclick事件可以监听,但是双击的时候click事件也会触发,所以就无法区分是单击还是双击,一般双击都是通过click事件来模拟,当然也可以监听双击事件来模拟单击事件,不这么做的一个原因是不清楚系统的双击间隔时间,所以定时器的时间间隔不好确定:

class Markjs {
    // 单击事件
    onclick(e) {
        if (this.clickTimer) {
            clearTimeout(this.clickTimer)
            this.clickTimer = null
        }

        // 单击事件延迟200ms触发
        this.clickTimer = setTimeout(() => {
            this.observer.publish('CLICK', e)
        }, 200);

        // 两次单击时间小于200ms则认为是双击
        if (Date.now() - this.lastClickTime <= 200) {
            clearTimeout(this.clickTimer)
            this.clickTimer = null
            this.lastClickTime = 0
            this.observer.publish('DOUBLE-CLICK', e)
        }

        this.lastClickTime = Date.now()// 上一次的单击时间
    }
}

原理很简单,延迟一定时间才派发单击事件,比较两次单击的时间是否小于某个时间间隔,若小于则认为是单击,这里选的是200毫秒,当然也可以再小一点,不过100毫秒我的手速已经不行了。

标注功能

标注无疑是这个库的核心功能,上面所述这也作为一个插件:

export default function EditPlugin(instance) {
    // 标注逻辑...
}

先来理一下功能,鼠标单击确定标注区域的各个顶点,双击后闭合区域路径,可以再次单击激活进行编辑,编辑只能拖拽整体或者某个顶点,不能再删除或添加顶点,同一画布上可以同时存在多个标注区域,但是某一时刻只允许单击激活其中一个进行编辑。

因为同一画布可以存在多个标注,每个标注也可以编辑,所以每个标注都得维护它的状态,那么可以考虑用一个类来表示标注对象:

export default class MarkItem {
    constructor(ctx = null, opt = {}) {
        this.pointArr = []// 顶点数组
        this.isEditing = false// 是否是编辑状态
        // 其他属性...
    }
    // 方法...
}

然后需要定义两个变量:

export default function EditPlugin(instance) {
    // 全部的标注对象列表
    let markItemList = []
    // 当前编辑中的标注对象
    let curEditingMarkItem = null
    // 是否正在创建新标注中,即当前标注仍未闭合路径
    let isCreateingMark = false
}

存储所有标注及当前激活的标注区域,接下来就是监听鼠标事件来进行绘制了。单击事件要做的是检查当前是否存在激活对象,存在的话再判断是否已经闭合,不存在的话检测鼠标点击的位置是否存在标注对象,存在的话激活它。

instance.on('CLICK', (e) => {
    let inPathItem = null
    // 正在创建新标注中
    if (isCreateingMark) {
        // 当前存在未闭合路径的激活对象,点击新增顶点
        if (curEditingMarkItem) {
            curEditingMarkItem.pushPoint(x, y)// 这个方法往当前标注实例的顶点数组里添加顶点
        } else{// 当前不存在激活对象则创建一个新标注实例
            curEditingMarkItem = createNewMarkItem()// 这个方法用来实例化一个新标注对象
            curEditingMarkItem.enable()// 将标注对象设为可编辑状态
            curEditingMarkItem.pushPoint(x, y)
            markItemList.push(curEditingMarkItem)// 添加到标注对象列表
        }
    } else if (inPathItem = checkInPathItem(x, y)) {// 检测鼠标点击的位置是否存在标注区域,存在则激活它
        inPathItem.enable()
        curEditingMarkItem = inPathItem
    } else {// 否则清除当前状态,比如激活状态等
        reset()
    }
    render()
})

上面出现了很多新方法和属性,都详细注释了,具体实现很简单就不展开了,有兴趣自行阅读源码,重点来看一下其中的两个方法,checkInPathItemrender

checkInPathItem函数循环遍历markItemList来检测当前某个位置是否在该标注区域路径内:

function checkInPathItem(x, y) {
    for (let i = markItemList.length - 1; i >= 0; i--) {
        let item = markItemList[i]
        if (item.checkInPath(x, y) || item.checkInPoints(x, y) !== -1) {
            return item
        }
    }
}

checkInPathcheckInPointsMarkItem原型上的两个方法,分别用来检测某个位置是否在该标注区域路径内和该标注的各个顶点内:

export default class MarkItem {
    checkInPath(x, y) {
        this.ctx.beginPath()
        for (let i = 0; i < this.pointArr.length; i++) {
            let {x, y} = this.pointArr[i]
            if (i === 0) {
                this.ctx.moveTo(x, y)
            } else {
                this.ctx.lineTo(x, y)
            }
        }
        this.ctx.closePath()
        return this.ctx.isPointInPath(x, y)
    }
}

先根据标注对象当前的顶点数组绘制及闭合路径,然后调用canvas接口里的isPointInPath方法来判断点是否在该路径内,isPointInPath方法仅针对路径且是当前路径有效,所以如果顶点是正方形形状的话不能用fillRect;来绘制,要用rect

export default class MarkItem {
    checkInPoints(_x, _y) {
        let index = -1
        for (let i = 0; i < this.pointArr.length; i++) {
            this.ctx.beginPath()
            let {x, y} = this.pointArr[i]
            this.ctx.rect(x - pointWidth, y - pointWidth, pointWidth * 2, pointWidth * 2)
            if (this.ctx.isPointInPath(_x, _y)) {
                index = i
                break
            }
        }
        return index
    }
}

render方法同样也是遍历markItemList,调用MarkItem实例的绘制方法,绘制逻辑和上面的检测路径的逻辑基本一致,只是检测路径的时候只要绘制路径而绘制需要调用strokefill等方法来描边和填充,不然不可见。

到这里单击创建新标注和激活标注就完成了,双击要做只要闭合一下未闭合的路径就可以了:

instance.on('DOUBLE-CLICK', (e) => 
    if (curEditingMarkItem) {
        isCreateingMark = false
        curEditingMarkItem.closePath()
        curEditingMarkItem.disable()
        curEditingMarkItem = null
        render()
    }
})

到这里,核心标注功能就完成了,接下来看一个提升体验的功能:检测线段交叉。

检测线段交叉可以用向量叉乘的方式,详细介绍可参考这篇文章:https://www.cnblogs.com/tuyang1129/p/9390376.html

// 检测线段AB、CD是否相交
// a、b、c、d:{x, y}
function checkLineSegmentCross(a, b, c, d) {
    let cross = false
    // 向量
    let ab = [b.x - a.x, b.y - a.y]
    let ac = [c.x - a.x, c.y - a.y]
    let ad = [d.x - a.x, d.y - a.y]
    // 向量叉乘,判断点c,d分别在线段ab两侧,条件1
    let abac = ab[0] * ac[1] - ab[1] * ac[0]
    let abad = ab[0] * ad[1] - ab[1] * ad[0]

    // 向量
    let dc = [c.x - d.x, c.y - d.y]
    let da = [a.x - d.x, a.y - d.y]
    let db = [b.x - d.x, b.y - d.y]
    // 向量叉乘,判断点a,b分别在线段cd两侧,条件2
    let dcda = dc[0] * da[1] - dc[1] * da[0]
    let dcdb = dc[0] * db[1] - dc[1] * db[0]

    // 同时满足条件1,条件2则线段交叉
    if (abac * abad < 0 && dcda * dcdb < 0) {
        cross = true
    }
    return cross
}

有了上面这个检测两条线段交叉的方法,要做的就是遍历标注的顶点数组来连接线段,然后两两进行比较即可。

拖拽标注和顶点的方法也很简单,监听鼠标的按下事件利用上面检测点是否在路径内的方法分别判断按下的位置是否在路径或顶点内,是的话监听鼠标的移动事件来更新整体的pointArr数组或某个顶点的x,y坐标。

到这里全部的标注功能就完成了。

插件示例

接下来看一个简单的图片插件,这个图片插件就是加载图片,然后根据图片实际的宽高来调整canvas的宽高,很简单:

export default function ImgPlugin(instance) {
    let _resolve = null
    let promise = new Promise((resolve) => {
        _resolve = resolve
    })
    
    // 加载图片
    utils.loadImage(opt.img)
        .then((img) => {
            imgActWidth = image.width
            imgActHeight = image.height
            setSize()
            drawImg()
            _resolve()
        })
        .catch((e) => {
            _resolve()
        })
    
    // 修改canvas的宽高
    function setSize () {
        // 容器宽高都大于图片实际宽高,不需要缩放
        if (elRectInfo.width >= imgActWidth && elRectInfo.height >= imgActHeight) {
            actEditWidth = imgActWidth
            actEditHeight =imgActHeight
        } else {// 容器宽高有一个小于图片实际宽高,需要缩放
            let imgActRatio = imgActWidth / imgActHeight
            let elRatio = elRectInfo.width / elRectInfo.height
            if (elRatio > imgActRatio) {
                // 高度固定,宽度自适应
                ratio = imgActHeight / elRectInfo.height
                actEditWidth = imgActWidth / ratio
                actEditHeight = elRectInfo.height
            } else {
                // 宽度固定,高度自适应
                ratio = imgActWidth / elRectInfo.width
                actEditWidth = elRectInfo.width
                actEditHeight = imgActHeight / ratio
            }
        }
        
        canvas.width = actEditWidth
        canvas.height = actEditHeight
    }
    
    // 创建一个新canvas元素来显示图片
    function drawImg () {
        let canvasEle = document.createElement('canvas')
        instance.el.appendChild(canvasEle)
        let ctx = canvasEle.getContext('2d')
        ctx.drawImage(image, 0, 0, actEditWidth, actEditHeight)
    }
    
    return promise
}

总结

本文通过一个简单的标注功能来实践了一下插件化的开发,毫无疑问,插件化是一个很好的扩展方式,比如vueVue CLiVuePressBetterScrollmarkdown-itLeaflet等等都通过插件系统来分离模块、完善功能,但是这也要求有一个良好的架构设计,我在实践过程中遇到的最主要问题就是没找到一个好的方法来判断某些属性、方法和事件是否要暴露出去,而是在编写插件时遇到才去暴露,这样的最主要问题是三方来开发插件的话如果需要的某个方法访问不到有点麻烦,其次是对插件的功能边界也没有考虑清楚,无法确定哪些功能是否能实现,这些还需要日后了解及完善。

源码已经上传到github:https://github.com/wanglin2/markjs

博客:http://lxqnsys.com/、公众号:理想青年实验室

街角小林
886 声望773 粉丝