一个简单标注库的插件化开发实践
最近在提煉一個功能的時候發現可配置項過多,如果全都耦合在一起,首先是代碼上不好維護、擴展性不好,其次是如果我不需要該功能的話會帶來體積上的冗余,考慮到現在插件化的流行,于是小小的嘗試了一番。
先介紹一下這個庫的功能,一個簡單的讓你可以在一個區域,一般是圖片上標注一個區域范圍,然后返回頂點坐標的功能:
話不多說,開擼。
插件設計
插件我理解就是一個功能片段,代碼上可以有各種組織方式,函數或類,各個庫或框架可能都有自己的設計,一般你需要暴露一個規定的接口,然后調用插件的時候也會注入一些接口或狀態,在此基礎上擴展你需要的功能。
我選擇的是以函數的方式來組織插件代碼,所以一個插件就是一個獨立的函數。
首先庫的入口是一個類:
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() })上面出現了很多新方法和屬性,都詳細注釋了,具體實現很簡單就不展開了,有興趣自行閱讀源碼,重點來看一下其中的兩個方法,checkInPathItem和render。
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}} }checkInPath和checkInPoints是MarkItem原型上的兩個方法,分別用來檢測某個位置是否在該標注區域路徑內和該標注的各個頂點內:
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實例的繪制方法,繪制邏輯和上面的檢測路徑的邏輯基本一致,只是檢測路徑的時候只要繪制路徑而繪制需要調用stroke、fill等方法來描邊和填充,不然不可見。
到這里單擊創建新標注和激活標注就完成了,雙擊要做只要閉合一下未閉合的路徑就可以了:
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 }總結
本文通過一個簡單的標注功能來實踐了一下插件化的開發,毫無疑問,插件化是一個很好的擴展方式,比如vue、Vue CLi、VuePress、BetterScroll、markdown-it、Leaflet等等都通過插件系統來分離模塊、完善功能,但是這也要求有一個良好的架構設計,我在實踐過程中遇到的最主要問題就是沒找到一個好的方法來判斷某些屬性、方法和事件是否要暴露出去,而是在編寫插件時遇到才去暴露,這樣的最主要問題是三方來開發插件的話如果需要的某個方法訪問不到有點麻煩,其次是對插件的功能邊界也沒有考慮清楚,無法確定哪些功能是否能實現,這些還需要日后了解及完善。
源碼已經上傳到github:https://github.com/wanglin2/markjs。
博客:http://lxqnsys.com/、公眾號:理想青年實驗室
總結
以上是生活随笔為你收集整理的一个简单标注库的插件化开发实践的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: SQL SERVER 修改表字段长度
- 下一篇: 统计app用户在线时长_「云工作普及系列