关于javascript:一个简单标注库的插件化开发实践

9次阅读

共计 7785 个字符,预计需要花费 20 分钟才能阅读完成。

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

先介绍一下这个库的性能,一个简略的让你能够在一个区域,个别是图片上标注一个区域范畴,而后返回顶点坐标的性能:

话不多说,开撸。

插件设计

插件我了解就是一个性能片段,代码上能够有各种组织形式,函数或类,各个库或框架可能都有本人的设计,个别你须要裸露一个规定的接口,而后调用插件的时候也会注入一些接口或状态,在此基础上扩大你须要的性能。

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

首先库的入口是一个类:

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/、公众号:现实青年实验室

正文完
 0