vkedit 图形插件开发实战:从零写一个图片插件的踩坑记录

先聊聊 vkedit 是什么

vkedit 是一个基于 Vue 3 + Konva.js 的可视化画布设计器组件,最大的特点是插件化架构——核心能力(工具栏、选择、吸附、对齐、撤销重做、剪贴板、属性面板、JSON 导入导出、PNG/PDF 导出、预览)都是默认装好的插件,要画什么形状、要加什么行为,自己注册一个插件就行。
在这里插入图片描述

它的定位不是"在线版 Canva"那种通用设计平台,而是"在 Vue 3 业务系统里嵌入一个可编辑的画布"。文档开篇的适用场景表里写得很清楚:

场景 典型用户
标签模板设计 电商、物流
二维码、条形码模板 支付、库存、零售
名片、证书、票据排版 商务、教育、零售
任何"运营同学自己拖拽改"的模板 内部系统

我这次要做的项目是一个内部标签编辑器,vkedit 自带的矩形、文本、线条、二维码、条形码 5 种元素基本够用,但业务方希望标签上能贴商品图——vkedit 4.0 内置元素里没有图片,只能自己写一个图形插件。这篇文章就是这次开发过程的记录。

准备工作

开发插件前先把 vkedit 装起来。注意它把 vuekonvavue-konva 列为 peerDependencies,需要自己装。Node 版本要 20.19+ 或 22.12+。

pnpm create vite@latest vkedit-image-demo --template vue-ts
cd vkedit-image-demo
pnpm install
pnpm add vkedit konva vue-konva

入口文件 main.ts 必须注册 VueKonva 并引入 vkedit 样式——这一步少了 app.use(VueKonva),画布里的 <v-rect><v-image> 这些组件就注册不到 Vue 上,运行时直接白屏。我第一次就栽在这里,找了半小时才反应过来是漏了这一行。

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import VueKonva from 'vue-konva'
import 'vkedit/dist/vkedit.css'

const app = createApp(App)
app.use(VueKonva)
app.mount('#app')

vkedit 依赖 vue-konva 渲染 Konva 节点,app.use(VueKonva) 必加。

第 1 步:跑一个空白编辑器

插件先不装,先确认环境通了。

<!-- App.vue -->
<template>
  <div style="width: 100%; height: 100vh">
    <Vkedit :host="host" />
  </div>
</template>

<script setup lang="ts">
import { createEditorHost, RectPlugin, Vkedit } from 'vkedit'

const host = createEditorHost()
host.installPlugin('rect-plugin', RectPlugin)
host.setStatus({ wmm: 100, hmm: 100 })
</script>

createEditorHost() 是整个编辑器的入口。它会自动装好工具栏、选择、吸附、剪贴板、对齐、撤销重做、图层、导入、导出、预览这些核心能力。我之前自己写 Konva 的时候,光工具栏 + 撤销重做就够写一周——vkedit 这一步把"编辑器底座"的事全做了。

<Vkedit> 组件只是个外壳,它接收 host 作为 prop,所有状态都在 host 里。host.setStatus({ wmm: 100, hmm: 100 }) 把画布设成 100mm × 100mm。

跑起来能看到工具栏和白色画布,环境就 OK 了。

第 2 步:写核心三件套

vkedit 的图形插件有三个核心文件:

  1. loadImage.ts —— 加载图片的工具函数
  2. ImageElement.ts —— 元素类(数据 + 序列化)
  3. ImagePlugin.ts —— 插件类(声明 + 注册)

放项目结构上是 src/plugins/image/,自己挑个位置就行。

2.1 loadImage.ts:图片加载小工具

// src/plugins/image/loadImage.ts
export function loadHTMLImage(src: string): Promise<HTMLImageElement> {
  return new Promise((resolve, reject) => {
    if (!src) {
      reject(new Error('empty src'))
      return
    }
    const img = new window.Image()
    img.crossOrigin = 'anonymous'
    img.onload = () => resolve(img)
    img.onerror = () => reject(new Error(`failed to load image: ${src}`))
    img.src = src
  })
}

这里有个坑,文档里只用了一句话带过,我必须单独强调一下:crossOrigin = 'anonymous'

我当时为了图省事没加这行,结果图片加载都正常,但导出 PNG 的时候直接报:

Tainted canvases may not be exported

这是浏览器跨域安全策略。Canvas 一旦画了没声明 CORS 的图片,就成了"被污染的画布",toDataURL() 立刻失败。要让 Canvas 能导出图片,必须满足两个条件之一:

  • 图片是同源的
  • 图片是带 Access-Control-Allow-Origin 响应头的跨域图片

crossOrigin = 'anonymous' 是告诉浏览器"我以匿名方式请求图片"。但这只对支持 CORS 的图片有效——如果你们公司图床没配 Access-Control-Allow-Origin,要么去配,要么就别用前端 Canvas 导出。这个坑文档里没有展开讲,我自己掉进去过。

2.2 ImageElement.ts:元素类

vkedit 的元素都继承 BaseGraphicElement。这个基类已经帮你处理了:

  • 毫米单位的坐标和尺寸(xmmymmwmmhmm
  • 旋转、缩放
  • 基础的 serialize() / deserialize()

我们只需要扩展图片特有的字段。

// src/plugins/image/ImageElement.ts
import {
  BaseGraphicElement,
  type BaseGraphicElementOptions,
  type EditorHost,
} from 'vkedit'

export interface ImageOptions extends BaseGraphicElementOptions {
  src?: string
}

export class ImageElement extends BaseGraphicElement {
  type = 'image' as const
  src: string

  // 运行时字段,不进入 serialize
  imageEl: HTMLImageElement | null = null

  constructor(host: EditorHost, options: Partial<ImageOptions> = {}) {
    super(host, options)
    this.src = options.src ?? ''
  }

  get config() {
    return {
      ...super.config,
      src: this.src,
      image: this.imageEl ?? undefined,
    }
  }

  serialize() {
    return {
      ...super.serialize(),
      src: this.src,
    }
  }

  deserialize(data: Record<string, unknown>): void {
    super.deserialize(data)
    if (typeof data.src === 'string') {
      this.src = data.src
    }
  }
}

这里我犯过一个错误:把 imageEl: HTMLImageElement 也写进了 serialize()

HTMLImageElement 是 DOM 节点,JSON.stringify 不支持。我偷懒图省事,结果保存 JSON 时控制台一直报 TypeError: Converting circular structure to JSON,折腾半天才反应过来。

正确做法是:

  • src(字符串)进 JSON;
  • imageEl(运行时对象)只放在内存里,组件挂载时通过 loadHTMLImage 重新加载。

另一个要注意的:type = 'image' as const 里的 'image' 必须和插件的 graphicType 完全一致。这俩字符不匹配,反序列化、工具箱、属性面板全部失灵,而且报错位置通常离真正问题很远——别问我是怎么知道的。

2.3 ImagePlugin.ts:插件组装

// src/plugins/image/ImagePlugin.ts
import { GraphicPlugin } from 'vkedit'
import type { Component } from 'vue'
import { ImageElement } from './ImageElement'
import ImageShape from './ImageShape.vue'
import ImageIcon from './ImageIcon.vue'
import ImagePropertyPanel from './ImagePropertyPanel.vue'

export class ImagePlugin extends GraphicPlugin<ImageElement> {
  name = 'image-plugin'
  version = '1.0.0'

  graphicType = 'image'
  graphicElement = ImageElement
  shapeComponent = ImageShape as Component
  iconComponent = ImageIcon as Component
  typeDisplayName = '图片'

  propertyPanel = ImagePropertyPanel
}

注意几个字段:

  • name = 'image-plugin':必须和 host.installPlugin('image-plugin', ImagePlugin) 的第一个参数一致
  • graphicType = 'image':必须和 ImageElement.type 一致
  • graphicElement = ImageElement传类,不是实例
  • shapeComponenticonComponentpropertyPanel:三个 Vue 组件

我第一次写时照着例子复制,结果 graphicElement 我传成了 new ImageElement(host)。然后 GraphicPlugin 内部用 new this.graphicElement(host, options) 二次实例化,控制台告诉我"不是一个构造器"。

ImagePlugin 不需要写任何生命周期钩子。GraphicRegistryPlugin 在基类 onActivate 时会读这些声明式字段,自动完成注册。这是 vkedit 设计上很聪明的地方——你只声明"我是什么",注册逻辑框架自己处理。

注意 v4.0.0 有一个已知问题:自定义图形插件不会自动激活,需要在 oninstall 中手动调用 this.onActivate()。这个 bug 在后续版本已修复,升级后就可以删掉这段。

第 3 步:写 ImageShape.vue

ImageShape.vue 是图片在画布上的 Vue 渲染组件。图片加载是异步的,所以这里要处理四种状态:空地址、加载中、加载成功、加载失败。

<!-- src/plugins/image/ImageShape.vue -->
<script setup lang="ts">
import { ref, watch, onBeforeUnmount } from 'vue'
import type { EditorHost } from 'vkedit'
import type { ImageElement } from './ImageElement'
import { loadHTMLImage } from './loadImage'

const props = defineProps<{
  element: ImageElement
  host: EditorHost
}>()

const status = ref<'empty' | 'loading' | 'ready' | 'error'>(
  props.element.src ? 'loading' : 'empty',
)

let alive = true

async function reload(src: string) {
  if (!src) {
    props.element.imageEl = null
    status.value = 'empty'
    return
  }
  status.value = 'loading'
  try {
    const img = await loadHTMLImage(src)
    if (!alive) return
    props.element.imageEl = img
    status.value = 'ready'
  } catch (e) {
    if (!alive) return
    props.element.imageEl = null
    status.value = 'error'
    console.warn('[ImageShape]', e)
  }
}

watch(
  () => props.element.src,
  (src) => {
    void reload(src ?? '')
  },
  { immediate: true },
)

onBeforeUnmount(() => {
  alive = false
})
</script>

<template>
  <v-group :config="element.config">
    <v-image
      v-if="status === 'ready' && element.imageEl"
      :config="{
        image: element.imageEl,
        width: element.config.width,
        height: element.config.height,
      }"
    />
    <v-rect
      v-else
      :config="{
        width: element.config.width,
        height: element.config.height,
        fill: status === 'error' ? '#FEE2E2' : '#F3F4F6',
        stroke: '#D1D5DB',
        strokeWidth: 1,
      }"
    />
  </v-group>
</template>

两个我反复想过、想写的点:

一是 alive 标志。

加载图片是异步的。当用户快速切换 src,旧图片的 onload 回调可能在新图片已经开始加载之后才触发。如果不做处理,新元素的状态会被旧回调写脏,UI 上表现成"图片串了"——A 图还没加载完就被 B 图的回调覆盖。

aliveonBeforeUnmount 时设为 false,所有异步回调回来时先检查 alive,组件已经卸载就直接 return。这是异步组件的常见模式,但写第一遍的时候容易忘。

二是 <v-group> 单根节点。

vkedit 框架会给 Shape 组件传一堆事件(onDragstartonDragmoveonDragend 等)。Vue 3 默认 inheritAttrs: true,这些事件会自动注册到唯一的根节点 <v-group> 上,拖拽链路直接接通。

如果以后改成多根节点,必须 defineOptions({ inheritAttrs: false }) 然后手动 v-bind="$attrs",不然拖拽事件就接不上了——这个我是在自己写另一个图形时踩到的。

第 4 步:写 ImageIcon 和 ImagePropertyPanel

ImageIcon.vue 是工具箱里那个图片按钮的图标,简单 SVG:

<!-- src/plugins/image/ImageIcon.vue -->
<template>
  <svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
    <path
      fill="currentColor"
      d="M21 19V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"
    />
  </svg>
</template>

18×18,viewBox="0 0 24 24",Material Symbols 的 image 路径。fill="currentColor" 让它跟着工具栏主题色变。

属性面板才是重点:

<!-- src/plugins/image/ImagePropertyPanel.vue -->
<script setup lang="ts">
import {
  usePropertyCommand,
  VkInput,
  VkInputNumberMM,
  VkLabel,
} from 'vkedit'
import type { EditorHost } from 'vkedit'
import type { ImageElement } from './ImageElement'

const { element, host, selection } = defineProps<{
  host: EditorHost
  element: ImageElement
  selection: ImageElement[]
}>()

const { updateProperty } = usePropertyCommand(host)
</script>

<template>
  <div class="image-pp">
    <VkLabel>图片地址</VkLabel>
    <VkInput
      :model-value="element.src"
      placeholder="https://... 或 data:image/..."
      @update:model-value="(v) => updateProperty(element, 'src', String(v))"
    />

    <VkLabel>X</VkLabel>
    <VkInputNumberMM
      :model-value="element.xmm"
      @update:model-value="(v) => updateProperty(element, 'xmm', v)"
    />

    <VkLabel>Y</VkLabel>
    <VkInputNumberMM
      :model-value="element.ymm"
      @update:model-value="(v) => updateProperty(element, 'ymm', v)"
    />

    <VkLabel>宽</VkLabel>
    <VkInputNumberMM
      :model-value="element.wmm"
      @update:model-value="(v) => updateProperty(element, 'wmm', v)"
    />

    <VkLabel>高</VkLabel>
    <VkInputNumberMM
      :model-value="element.hmm"
      @update:model-value="(v) => updateProperty(element, 'hmm', v)"
    />
  </div>
</template>

注意 updateProperty 这个调用。

我当时第一反应是写 element.src = newSrc,完事。跑了一下:

  • 改 src → 图片确实变了
  • 按 Ctrl+Z → 没反应
  • 看历史命令 → 没有这一条修改

因为 vkedit 的撤销/重做是基于命令模式。element.src = newSrc 这种直接修改,框架根本不知道发生过。

正确做法是 updateProperty(element, 'src', newSrc)。这个函数会构造一个 UpdatePropertyCommand 推入命令栈,撤销和重做才能接管。

后来我把这条写进了项目约定:任何面板里改元素属性,都走 updateProperty,不直接赋值。

第 5 步:在 App.vue 装上插件

三个文件都写好之后,回到 App.vue 装插件:

<!-- App.vue -->
<script setup lang="ts">
import { createEditorHost, RectPlugin, Vkedit } from 'vkedit'
import { ImagePlugin } from './plugins/image/ImagePlugin'

const host = createEditorHost()

host
  .installPlugin('rect-plugin', RectPlugin)
  .installPlugin('image-plugin', ImagePlugin)

host.setStatus({ wmm: 100, hmm: 100 })
</script>

<template>
  <div style="width: 100%; height: 100vh">
    <Vkedit :host="host" />
  </div>
</template>

installPlugin 的第一个参数是插件名,必须和 ImagePlugin.name 一致。第二个参数是,不是实例。

跑起来,工具箱里应该出现"图片"工具。点一下,可以在画布上拖出图片元素。改 src 字段可以加载图片。Ctrl+Z 能撤销。

第 6 步:验收

我做了一个验收清单:

# 操作 期望
1 打开页面 无控制台红错;工具箱出现"图片"工具
2 拖出图片元素,粘贴公网 PNG URL 画布显示该图片
3 改 X/Y/宽/高,按 Ctrl+Z 画布回到修改前;Ctrl+Shift+Z 重做
4 host.toJSON() 保存,host.loadJSON() 恢复 元素和 src 完整恢复
5 src 填非法 URL 后清空 浅红占位 → 浅灰占位;不抛错

我专门写了一段控制台自检脚本,用来验证 JSON 往返稳定性:

import {
  AddElementCommand,
  GraphicRegistryPlugin,
  RectPlugin,
  createEditorHost,
} from 'vkedit'
import { ImagePlugin, ImageElement } from './plugins/image'

const host = createEditorHost()
host
  .installPlugin('rect-plugin', RectPlugin)
  .installPlugin('image-plugin', ImagePlugin)
host.setStatus({ wmm: 100, hmm: 100 })

const registry = host.getPlugin<GraphicRegistryPlugin>('graphic-registry-plugin')
const el = registry.createElement('image') as ImageElement

el.src = 'https://placehold.co/200x200/png'
el.xmm = 10
el.ymm = 10
el.wmm = 40
el.hmm = 30

host.executeCommand(new AddElementCommand(host, el))

const json = host.toJSON()
console.log(json.includes('"type":"image"'))   // 预期: true
console.log(json.includes('"src"'))             // 预期: true
console.log(json.includes('imageEl'))           // 预期: false(运行时字段不该进 JSON)

host.loadJSON(json)
const json2 = host.toJSON()
console.log(json === json2)                     // 预期: true(往返稳定)

任何一项为 false 都说明 serialize() / deserialize() 或命令链路有问题。这种脚本比"打开页面点一点"靠谱——我后来把所有图形插件的验收都改成这种命令行脚本。

我对 vkedit 的评价

写完这个图片插件,聊聊我自己作为一个普通前端的看法。

vkedit 最值得夸的地方是省事。 选择、拖拽、属性面板、快捷键、吸附、对齐、撤销重做、图层、JSON 导入导出、PNG/PDF 导出、预览——这些编辑器底座能力,vkedit 已经组装好了。我要做的只是声明"我有一个图片图形",剩下的注册、渲染、序列化框架都接管了。对一个内部系统的标签编辑器来说,这个分层是工程上能用的。

毫米坐标系很贴业务。 标签、票据这种场景,业务参数和打印尺寸都是毫米。用 xmmymmwmmhmm 写代码,比 width: 800 这种像素值自然得多。不过要注意,不是所有视觉数值都是毫米——文本的 fontSize、二维码的 marginMM 各自有自己的字段名,写的时候要区分。这是 vkedit 文档里没有完全讲清楚的一点。

插件边界清楚。 图形插件继承 GraphicPlugin<T>,不画图、只加行为的插件继承 BasePlugin。要加新图形就写图形插件,要加自动保存就写功能插件[官方自动保存插件教程],不会污染同一个文件。这个分层是有工程价值的。

TypeScript 体验是真的有。 我写代码时类型系统替我抓到了好几次误用:传错类(graphicElement 传了实例而不是类)、installPlugin 第一参数写错、TextElementcontent 字段。完整类型比一长串 API 文档管用。

但槽点也有:

  • 内置元素只有五种:矩形、文本、线条、二维码、条形码。图片、表格、印章、图表都得自己写。官方提供了完整的图片插件教程,说明扩展路径是通的,但"能扩展"不等于"零成本"。如果产品经理想把它一路扩成海报设计器,我会尽早劝停。
  • 文档对某些坑的描述太轻。比如 crossOrigin 的重要性,文档里只是提了一句"以匿名方式请求跨域图片",没解释为什么。imageEl 不能进 JSON 也是看代码才能发现。读官方文档时必须自己读源码,不然到运行时才会发现。
  • v4 是破坏性升级。如果项目还在 v3,不能只改版本号。v3 到 v4 收编了好几个管理插件为 GraphicRegistryPlugin,重命名了一堆字段(resizabledisplayName),还移除了表格和图表元素。升级前必须先看 [v3 到 v4 迁移指南],不要硬升。

我会不会继续用 vkedit?

会。至少在这个标签编辑器的项目里,它解决了我最头疼的"从零搭编辑器"的问题。我不会再自己用 Konva 写工具栏、选择、撤销重做那一套——那是从造轮子变成用轮子。

但我不会无脑推荐给所有人。

如果你要做一个标签 / 票据 / 名片类的模板编辑器,vkedit 很合适,半小时就能跑起来一个原型。如果你想要"在线设计 Canva",vkedit 不合适——内置元素太少,扩展成本会迅速堆起来。先看自己的需求落在这条线的哪一段,再决定。

最后说个小建议:官方那个图片插件教程,照着做完一遍就够用了。vkedit 的核心 API 就那几个:宿主、插件、元素、Shape、属性面板。剩下的具体图形,按这个范式套就行。剩下的具体行为(自动保存、协作、版本管理),走 [功能插件路线]。这两条线走完,自定义插件这件事基本就稳了。

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐