一个简单标注库的插件化开发实践

白净水金刚
• 阅读 918

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

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

一个简单标注库的插件化开发实践

话不多说,开撸。

插件设计

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

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

首先库的入口是一个类:

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/、公众号:理想青年实验室
点赞
收藏
评论区
推荐文章
20pzqm 20pzqm
3年前
golang 基于grpc的插件框架——go-plugin 使用入门
golang基于grpc的插件框架——goplugin使用入门说说我对插件的理解大家都用过vscode,当我们想要在vscode中格式化json的时候,很简单,去插件市场安装一个jsontools就好了;想要使用eclipse的键盘快捷方式,安装一个eclipsekeymap就可以.由此可见,插件帮助我们扩展原有程序的功能,同时它与原有工程是解耦
Karen110 Karen110
4年前
浅析装饰器的那些事儿
一、装饰器的简单定义外层函数返回里层函数的引用,里层函数引用外层函数的变量。二、装饰器的作用通俗来讲装饰器的作用就是在不改变已有函数代码前提下,为该函数增加新的功能。defrun():print('我会跑')fun()现在我想在原有函数的基础上新增一个功能:我会唱歌。这个时候利用装饰器则轻松可以帮我们实现这个功能。三、实例理解(1)不
Stella981 Stella981
3年前
Spring Boot快速入门(三):依赖注入
springboot使用依赖注入的方式很简单,只需要给添加相应的注解即可@Service用于标注业务层组件 @Controller用于标注控制层组件@Repository用于标注数据访问组件,即DAO组件 @Component泛指组件,当组件不好归类的时候,我们可以使用这个注解进行标注。然后在使用的地方使用@A
Wesley13 Wesley13
3年前
molicode生成工程代码实战
molicode生成工程代码实战背景描述这是一个molicode工具的经典案例场景,即利用molicode通过数据库表模型,生成大量的业务代码。通常意义上讲,目前我们的代码模板是偏向于简单化的增删改查功能。业务代码的丰富程度取决于数据model的丰富代码模板的丰富。如果这两个模型足够丰富,理论上
Stella981 Stella981
3年前
MicroPython 玩转硬件系列3:上电自动执行程序
1.引言上一篇我们在ESP32上实现了LED灯的闪烁,但是有一个问题,该功能的实现需要我们在串口终端里去手动执行代码,是否可以让ESP32上电后自动执行代码呢?当然是可以的,本篇文章介绍如何实现该功能。2.ampy安装ampy是什么,大家直接看下方的官方介绍即可,https://github.com/scientificha
Stella981 Stella981
3年前
Spring Boot 教程
1\.应用测试的介绍一般我们在写完代码之后,代码的测试是会给专门的测试人员来测试的,如果一个测试跑到你的工位上对你说,你的代码好像有Bug,你肯定会不爽,反正我就是这样的🙃。所以为了显示自己的代码质量高一点,在功能提交给测试之前,我们会自己测试一下,接下来给大家介绍一下SpringBootTest应用测试框架。Spr
可莉 可莉
3年前
20+ 个很棒的 jQuery 文件上传插件或教程
文件上传是网站很常见的功能之一,通过使用jQuery可以让上传过程更加人性化,更好的用户体验。本文介绍20个jQuery的文件上传插件,其中有一些是教程。1\.Plupload(http://www.oschina.net/p/plupload"Plupload")Plupload是一个Web浏览器上的界面友好的文件上传模块,可
Stella981 Stella981
3年前
SpringBoot2.x版本整合Redis进行数据缓存
项目放在github:在缓存开发中,有两个重要的接口:在这里面:  @Cacheable:  如果用这个注解标注在方法上,那么方法的结果就会被缓存存起来,这个多用于在查询的时候进行使用    比如: publicusergetuser(Integerid) 这个方法用这个注解标注的话,通过id查到的内容就会杯存在缓存中进行保存
Easter79 Easter79
3年前
SpringBoot2.x版本整合Redis进行数据缓存
项目放在github:在缓存开发中,有两个重要的接口:在这里面:  @Cacheable:  如果用这个注解标注在方法上,那么方法的结果就会被缓存存起来,这个多用于在查询的时候进行使用    比如: publicusergetuser(Integerid) 这个方法用这个注解标注的话,通过id查到的内容就会杯存在缓存中进行保存
飞速创软 | 在IT和业务之间:为什么低代码对数字化转型至关重要?
在商业领域,我们经常听到数字化转型。但是,是什么让那些做得很好的公司与那些连试水都做不好的公司区别开来呢?“低代码”可以成为其中一个关键工具。当然低代码技术实现的背后需要策略。如果能获得正确的执行,低代码的成功能为企业带来真正的转变。低代码应用程序开发只需要很少或根本不需要编码经验,这使得“公民开发者”或普通业务用户能够为他们的组织带来重大变革。如果做得好,
李异 李异
2年前
iPhone和iPad快速使用chatgpt的方法
自从Safari支持扩展了以后,我们在工具的选择上就更灵活了。很多功能我们不需要下载app了,安装一个扩展就可以实现。比如本文这个可以使用chatgpt的插件,比起app,插件的内存更小。
白净水金刚
白净水金刚
Lv1
我不躲不藏,等生活爱我。
文章
4
粉丝
0
获赞
0