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

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

话不多说,开撸。

插件设计

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

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

首先库的入口是一个类:

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 = trueif (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 = 0let len = Markjs.pluginList.lengthlet 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 = nulllet 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 = nullthis.lastClickTime = 0this.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 = -1for (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 = ibreak}}return index}
}

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

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

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

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

检测线段交叉可以用向量叉乘的方式,详细介绍可参考这篇文章: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两侧,条件1let 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两侧,条件2let 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 = nulllet promise = new Promise((resolve) => {_resolve = resolve})// 加载图片utils.loadImage(opt.img).then((img) => {imgActWidth = image.widthimgActHeight = image.heightsetSize()drawImg()_resolve()}).catch((e) => {_resolve()})// 修改canvas的宽高function setSize () {// 容器宽高都大于图片实际宽高,不需要缩放if (elRectInfo.width >= imgActWidth && elRectInfo.height >= imgActHeight) {actEditWidth = imgActWidthactEditHeight =imgActHeight} else {// 容器宽高有一个小于图片实际宽高,需要缩放let imgActRatio = imgActWidth / imgActHeightlet elRatio = elRectInfo.width / elRectInfo.heightif (elRatio > imgActRatio) {// 高度固定,宽度自适应ratio = imgActHeight / elRectInfo.heightactEditWidth = imgActWidth / ratioactEditHeight = elRectInfo.height} else {// 宽度固定,高度自适应ratio = imgActWidth / elRectInfo.widthactEditWidth = elRectInfo.widthactEditHeight = imgActHeight / ratio}}canvas.width = actEditWidthcanvas.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/、公众号:理想青年实验室

一个简单标注库的插件化开发实践相关推荐

  1. Android插件化开发实现动态换肤

    今晚实在不想coding,于是想着整理点知识点,那么简单整理了下插件化开发实现动态更换皮肤.插件化开发大家应该不陌生或多或少用过或听过,插件化开发在项目业务拓展.模块化等方面有不小优势,当然实现一个完 ...

  2. Android之插件化开发

    个人认为: 2017年手淘Atlas插件化项目的开源标志着插件化的落幕, 2018年Android 9.0上私有API的限制几乎称得上是要退出历史主流. 如今的插件化技术朝两个方向发展: 其一,插件化 ...

  3. Android组件化开发实践(九):自定义Gradle插件

    本文紧接着前一章Android组件化开发实践(八):组件生命周期如何实现自动注册管理,主要讲解怎么通过自定义插件来实现组件生命周期的自动注册管理. 1. 采用groovy创建插件 新建一个Java L ...

  4. Android插件化开发之动态加载三个关键问题详解

    本文摘选自任玉刚著<Android开发艺术探索>,介绍了Android插件化技术的原理和三个关键问题,并给出了作者自己发起的开源插件化框架. 动态加载技术(也叫插件化技术)在技术驱动型的公 ...

  5. iOS动态库实现插件化

    1.动态库制作 p1.png 选择Framework,创建动态库. Framework分动态.静态两种,可以通过下面路径查看 TARGETS->Build Settings(搜索mach-o)- ...

  6. Android插件化开发指南——Hook技术(一)【长文】

    文章目录 1. 前言 2. 将外部dex加载到宿主app的dexElements中 3. 插件中四大组件的调用思路 4. Hook 2.1 对startActivity进行Hook 2.1.1 AMS ...

  7. Python为什么要使用包管理、插件化开发?

    一.包管理 1.为什么使用包管理 目的是为了便于共享.为了更多项目调用使用,或者共享给别人,就需要打包,目的是为了复用. Pypi(Python Package Index)公共的模块存储中心.htt ...

  8. android中使用tmf框架插件化开发的问题

    android中使用tmf框架插件化开发的问题 最近项目开发使用的是tmf框架,其中大多数都是通过源生和H5交互的方式来实现的,大体实现和别的三方框架是一样的,需要按照tmf的官方文档引入一些lib和 ...

  9. Android插件化开发之动态加载本地皮肤包进行换肤

    Android插件化开发之动态加载本地皮肤包进行换肤 前言: 本文主要讲解如何用开源换肤框架 android-skin-loader-lib来实现加载本地皮肤包文件进行换肤,具体可自行参考框架原理进行 ...

  10. Android插件化开发指南——插件化技术简介

    文章目录 1. 为什么需要插件化技术 2. 插件化技术的历史 3. 插件化实现思路 3.1 InfoQ:您在 GMTC 中的议题叫做<Android 插件化:从入门到放弃>,请问这个标题代 ...

最新文章

  1. Winforn中通过NPOI导出Excel时通过XSSFClientAnchor和XSSFPicture添加图片
  2. 【Tools】开源远程控制神器之RustDesk
  3. 使用jquery的blockui插件显示弹出层
  4. java演练0920 我们9203班 02 随机点名功能实现
  5. 知乎启动首部科幻剧《寒梅工程2021》 概念片首次曝光
  6. Jenkins打卡Day19-Jenkins重启关闭服务
  7. http://my.oschina.net/huangyong/blog/372491?fromerr=hobPLCmQ#OSC_h2_5
  8. Xshell6 Xftp6 破解
  9. c4d流体插件_C4D的Jet Fluids免费流体插件
  10. Mysql中查找附近人的查询语句
  11. 矢量控制开发笔记1—SVPWM基础
  12. Ubuntu 11.10安装QQ2012
  13. 在outlook上邮件可以撤回?邮件撤回成功后对方还能看到吗?
  14. 什么是图片的DPI?如何修改图片DPI值?
  15. 让你的Onedrive网盘秒变网站,文件展示,直连下载,视频在线播放
  16. Selenium Chrome设置代理之后验证依旧是本机ip原因
  17. 电商数据分析--常见的数据采集工具及方法
  18. Unity Shader - 水体交互
  19. 学计算机的演员,南开大学计算机系到演员 张桐回顾“不安分”的青春_TOM明星...
  20. 标准库简介—C++学习

热门文章

  1. DisplayTag详解
  2. 系统集成项目管理工程师有什么用?
  3. Server 2008 R2大改造变成梦幻Win7系统
  4. 想做数字孪生项目?这款数据可视化软件值得你了解
  5. matlab男女声识别,matlab男女声音识别
  6. DB2 改变日期格式
  7. 格雷码(Gray Code)编码、实现及规律(附编码)
  8. 算法竞赛入门经典(第2版)—索引
  9. linux gvim字体大小配置,Gvim 字体大小设置
  10. lfw分类 python_无法在sklearn中使用LFW数据集