开发一个支持vue3+typescript的标注组件并发布到npm

319 阅读7分钟

要做什么

实现一个方便前端使用的标注组件,作为组件一定是使用起来越简单越好,输入更少的参数,而能获取到正确的输出数据。

当我们要实现标注功能的时候,最基本的需求,一支持切换不同的画框类型,比如画框、画点、多边形。二可以满足画框,删除框甚至移动框和改变框的功能。三当标注完成的时候,可以输出所有的框的数据。

怎么做

使用vite来构建工具包,开发一个基于vue3和typescript的组件,画框工具库使用fabricjs。fabricjs提供各种类,比如一个fabric.Canvas实例代表一个画布,可以在画布实例上添加不同的对象,可以是图片对象,也可以是内部支持的矩形或者其他画框类型的对象,也可以基于已有类型开发自定义类型,比如基于当前类型添加标签的绘制。

	1.搭建项目
	2.基于fabric开发基础的工具类
	3.实现画布组件
	4.打包发布
	5.在项目中使用

搭建npm包项目

使用vite官方文档提到的npm create命令创建vue项目,选择支持typescript。

首先需要确认新建的项目名称在npm中是不存在的。

  npm create vite@latest project-name --template vue

在根目录下添加packages文件夹作为包的目录,把src改为examples,删除不需要的assets文件夹和components文件夹。现在文件目录如下

	examples
		App.vue
		main.ts
		style.css
		vite-env.d.ts
	packages
	public
	.giignore
	index.html
	package.json
	README.md
	tsconfig.app.json
	tsconfig.json
	tsconfig.node.json
	vite.config.ts

接下来第二步将在packages文件夹开发我们的插件。

基于fabric开发基础的fabric工具类

这个工具类需要实现的是,输入参数画布dom对象和相关配置参数,返回一个实例,通过实例方法,比如修改配置参数,可以切换不同类型的框,通过实例方法可以获取到当前的画框数据。

输入输出有了,然后就是内部实现,根据fabricjs的特性,实现思路是监听鼠标键盘事件,根据不同类型判断是否画完了,如果没有画完,就在画布上画未完成的比如类型为折线时已有的折线,如果完成,则给实例对象添加一个画框对象。

当获取数据时,调用fabricjs内部提供的方法获取所有除了图片以外的对象。

篇幅原因下面是主要代码,详细请前往github查看。

class Annotator {
	  private _canvasEl // 画布dom元素
	  private _fabricCanvas // 画布
	  private _canvasContainer // 画布dom父级元素
	  private _mouseStatus // 鼠标状态
	  private _keyStatus // 键盘状态
	  private _drawStatus // 绘制状态
	  private _toolStatus // 工具状态
	  private _supportTypes // 支持的画框类型
	  ...
	  // 画框信息列表
	  get frameInfo() {
	    return this.parseData(this._fabricCanvas.getObjects())
	  }
	  // 解析画布数据
	  parseData(objs, isEmpty?)
	  // 初始化画布
	  _initCanvas(el, options) {
	    this._fabricCanvas = new fabric.Canvas(el, {
	      imageSmoothingEnabled: false,
	      includeDefaultValues: false,
	      selection: false,
	      enableRetinaScaling: false,
	      ...options
	    })
	    this.fabricCanvas = this._fabricCanvas
	  }
	  ...
	  // 画布的事件监听
	  _addCanvasEventListener() {
	  	const canvas = this._fabricCanvas,
		      mouseStatus = this._mouseStatus,
		      drawStatus = this._drawStatus,
		      keyStatus = this._keyStatus,
		      toolStatus = this._toolStatus
		fabric.util.addListener(this._canvasContainer, 'mousewheel'...
		fabric.util.addListener(document.body, 'keydown',...
		fabric.util.addListener(document.body, 'keyup'...
		// 移动画布
	      canvas.on('mouse:down', options => {......
	      // 绑定画布操作事件
	      canvas.on('mouse:down:before', options => {......
	      canvas.on('mouse:move', options => {
	      canvas.on('mouse:out', () => {
	      canvas.on('mouse:up:before', options => {
	      canvas.on('mouse:up', () => {
	      // 画框选中操作
	      canvas.on('selection:created', options => {
	      canvas.on('selection:updated', options => {
	      canvas.on('selection:cleared', options => {
	  }
	  ...
	  // 设置选中对象
	  setActiveObject(obj)
	  // 删除对象
	  removeObj(obj)
	  // 根据工具类型画框
	  draw(option, type)
	  // 删除选中的框
	  deleteActive()
	  // 为选中的框添加标签
	  addLabel(labelName)
	  ....
}

实现画框组件

组件开发同样先从输入和输出开始,从组件角度考虑,图片链接是需要外部提供的,回显的画框数据是需要外部提供的,还有画布的其他状态设置。输入容易实现,难的是输出,如果是通过vue的事件emit的话,就有点耦合了,所以决定提供一个全局的服务。

类似于vuex和vue-router,全局provide一个服务实例,在用到的组件中inject。

这个服务维护一个eventBus和当前的画布状态,eventBus提供一些方法如选中画框添加标签,删除当前画框,设置选中画框,获取画框数据等。

首先实现一个eventBus类

// eventBus.ts
type fn = (obj: object | string) => void

interface Events {
  [eventName: string]: Array<fn>;
}

export interface EventBus {
  events: Events,
  $emit: (eventName: string, data: object | string) => void;
  $on: (eventName: string, cb: fn) => void;
  $off: (eventName: string, cb: fn) => void;
  $clear: () => void;
}
export class eventBus {
  events = {}
  $on(eventName: string, callback: fn) {
    if (!this.events[eventName]) {
      (this.events[eventName] as Array<fn>) = [];
    }
    (this.events[eventName] as Array<fn>).push(callback);
  }
  $emit(eventName: string, data) {
    if (this.events[eventName]) {
      (this.events[eventName] as Array<fn>).forEach((callback: fn) => callback(data));
    }
  }

  $off(eventName: string, callback: fn) {
    if (!this.events[eventName]) return;
    (this.events[eventName] as Array<fn>) = (this.events[eventName] as Array<fn>).filter(cb => cb !== callback);
  }

  $clear() {
    this.events = {}
  }
}

画框工具的服务类,这里提供了vue插件机制,通过vue实例

app.use(createLabelToolService())

全局注册

在组件中用useLabelToolService()注入。

// labelToolService.ts
import { App, inject, reactive } from "vue";
import {eventBus as eventBusControl} from '../tools/eventBus.js'
import { LabelToolService as toolService } from "./labelToolService.type.js";

const labelToolServiceToken = '__LabelToolService__'
function createLabelToolService() {
  return new LabelToolService()
}
function useLabelToolService() {
  return (inject(labelToolServiceToken) as toolService)
}

class LabelToolService {
  eventBus
  editorStatus
  constructor() {
    this.eventBus = new eventBusControl()
    this.editorStatus = reactive({
      toolStatus: {
        referenceLine: false,
        hideLabel: null,
        rectArea: true,
        type: '',
        rotateClipStatus: {
          rotate: 0
        }
      },
      frameInfo: [],
      annotator: {},
      activeFrame: {}
    })
  }
  get frameInfo() {
    return this.editorStatus.annotator.frameInfo
  }
  get activeFrame() {
    return this.editorStatus.activeFrame
  }
  addLabel(labelName: string) {
    this.eventBus.$emit('addLabel', labelName)
  }
  onAddLabel(cb: (str: string | object) => void) {
    this.eventBus.$on('addLabel', cb)
  }
  rerender(obj: object) {
    this.eventBus.$emit('rerender', obj)
  }
  onRerender(cb: () => void) {
    this.eventBus.$on('rerender', cb)
  }
  deleteFrame(obj: object) {
    this.eventBus.$emit('deleteFrame', obj)
  }
  onDeleteFrame(cb: (obj: object | string) => void) {
    this.eventBus.$on('deleteFrame', cb)
  }
  setActiveObj(obj: object) {
    this.eventBus.$emit('setActiveObj', obj)
  }
  onSetActiveObj(obj: () => void) {
    this.eventBus.$on('setActiveObj', obj)
  }
  clearAllEmits() {
    this.eventBus.$clear()
  }
  setType(type) {
    this.editorStatus.toolStatus.type = type
  }
  install(app: App) {
    app.provide(labelToolServiceToken, this)
  }
}

export {useLabelToolService, createLabelToolService}

准备工作做好,下一步是在开发的组件中使用service去监听这些需要组件响应的事件,比如画框类型修改。这里使用vue3的组合式api,把这一系列操作抽离出来,做为一个单独的方法使用。

// setEmiter.ts
import { onMounted, onUnmounted } from "vue";
import { LabelToolService as toolService } from "../services/labelToolService.type";

export function setLabelToolEmiter(toolService: toolService) {
  const onAddLabel = (labelName: string) => {
    if (toolService?.editorStatus.annotator.hasActiveObject && labelName) {
      toolService.editorStatus.annotator.addLabel(labelName)
    }
  }
  const onRender = () => {
    toolService?.editorStatus.annotator.rerender()
  }
  const onDeleteFrame = (obj: object) => {
    toolService?.editorStatus.annotator.removeObj(obj)
    toolService?.editorStatus.annotator.rerender()
  }
  const onSetActiveObj = (obj: object) => {
    if (toolService?.editorStatus.annotator.currentActiveObject) {
      toolService.editorStatus.annotator.directiveObj(obj)
    }
    if (obj) {
      toolService?.editorStatus.annotator.setActiveObject(obj)
    }
  }
  const addEmiter = () => {
    toolService?.onAddLabel(onAddLabel)
    toolService?.onRerender(onRender)
    toolService?.onDeleteFrame(onDeleteFrame)
    toolService?.onSetActiveObj(onSetActiveObj)
  }
  onMounted(() => {
    addEmiter()
  })
  onUnmounted(() => {
    toolService?.clearAllEmits()
  })
}

在组件中执行这个方法就

// canvasLabel.vue
<template>
  <div class="box">
    <canvas class="canvas" ref="theCanvas" id="theCanvas"></canvas>
  </div>
</template>
<script setup lang="ts">
...
const toolService: LabelToolService = useLabelToolService()
setLabelToolEmiter(toolService)
...
</script>

接下来作为一个可以引入的包,我们提供两种引入方式,一种是把当前包作为组件库整体引入,另一种是只引入需要的组件。

在组件的同级目录添加一个index.ts,在这里提供作为vue插件机制的install方法

// index.ts
import { App } from 'vue'
import canvasLabel from './canvasLabel.vue'

canvasLabel.install = function(Vue: App) {
  Vue.component('canvasLabel', canvasLabel)
}

export default canvasLabel

在包的根目录就是packages下新建app.ts,引入标注组件并抛出,同时实现整个包的作为插件的install

import { App } from 'vue'
import canvasLabel from './canvasLabel'

const comment = [
  canvasLabel
]

export {
	canvasLabel
}

const install = function (App: App) {
	comment.forEach((item: {name: string}) => {
		App.component(item.name, item)
	})
}

export default install

在当前目录新建index.ts导出所有服务和组件

import vueLabel, {canvasLabel} from "./app";
import {useLabelToolService, createLabelToolService} from "./services/labelToolService";

export {
  vueLabel,
  canvasLabel,
  useLabelToolService,
  createLabelToolService,
}

打包发布

我们需要打包出什么,最主要的三个packages.json对应的属性,需要在文件中配置修改:

	main:commonjs引入模块,对应*.umd.js文件;
	module:esmodule引入文件对应*.js;
	types:对应的类型文件;
	
	"main": "dist/vue3-label.umd.js",
	"module": "dist/vue3-label.js",
	"types": "dist/lib/index.d.ts",

前两者在vite正常打包中都实现了,重要的是第三个,这里用到vite-plugin-dts插件,会根据ts文件生成对应的类型声明文件*.d.ts。

在vite中配置如下:

import vitePluginDts from 'vite-plugin-dts'
export default defineConfig({
	plugins: [
	    vue(),
	    vitePluginDts({
	      entryRoot: "./packages",
	      outDir: ["./dist/lib"],
	      tsconfigPath: "./tsconfig.buildts.json",
	    })
	],
	...
}

接下来执行默认vite生成的build命令打包

npm run build

接着在本地试用构建好的npm包,npm link 在本地试用存在各种各样的问题,这里使用yalc工具。执行之后就可以在项目中使用了,具体用法可参考项目examples目录。

// 全局安装 yalc
npm install yalc -g
// 在npm包目录下执行
yalc publish
// 在要使用的项目中执行
yalc add vue3-label
// 删除link饮用
yalc remove vue3-label / yalc remove --all
// 推送远程npm,在vue3-label包的根目录下执行,有时候需要多执行几次才会成功
yalc push / npm publish

在项目中使用

在项目中安装依赖包

npm install vue3-label

在全局引入canvasLabel组件

// main.js
import {vueLabel, createLabelToolService} from 'vue3-label'
import 'vue3-label/style.css'

// 全局服务,用来获取当前的画框数据和一些全局监听事件
const labelToolService = createLabelToolService()
createApp(App).use(labelToolService).use(vueLabel).mount('#app')

在vue组件中使用

<canvasLabel
  :existingFrameInfo="state.existingFrameInfo"  // 画框信息,可用于回显,需与输出信息格式保持一致
  :imageUrl="state.imageUrl"  // 当前要标注的图片
  :onlySelected="false"  //  是否只可选择,是则不能进行画框操作
></canvasLabel>

设置画布状态

import {useLabelToolService} from 'vue3-label'
const toolService = useLabelToolService()
//  支持的画框类型  'rect' | 'polygon' | 'point' | 'brush' | 'polyline'
toolService.editorStatus.toolStatus.type = 'ract'

// 获取画框信息
toolService.editorStatus.annotator.frameInfo

// 给当前选中画框添加标签
toolService.addLabel(<labelName>)

// 删除选中画框
toolService.deleteFrame(toolService.editorStatus.annotator.currentActiveObject)

项目git地址:github.com/chaikd/vue3…