vkedit 图形插件开发实战:从零写一个图片插件的踩坑记录
vkedit 图形插件开发实战:从零写一个图片插件的踩坑记录
先聊聊 vkedit 是什么
vkedit 是一个基于 Vue 3 + Konva.js 的可视化画布设计器组件,最大的特点是插件化架构——核心能力(工具栏、选择、吸附、对齐、撤销重做、剪贴板、属性面板、JSON 导入导出、PNG/PDF 导出、预览)都是默认装好的插件,要画什么形状、要加什么行为,自己注册一个插件就行。
它的定位不是"在线版 Canva"那种通用设计平台,而是"在 Vue 3 业务系统里嵌入一个可编辑的画布"。文档开篇的适用场景表里写得很清楚:
| 场景 | 典型用户 |
|---|---|
| 标签模板设计 | 电商、物流 |
| 二维码、条形码模板 | 支付、库存、零售 |
| 名片、证书、票据排版 | 商务、教育、零售 |
| 任何"运营同学自己拖拽改"的模板 | 内部系统 |
我这次要做的项目是一个内部标签编辑器,vkedit 自带的矩形、文本、线条、二维码、条形码 5 种元素基本够用,但业务方希望标签上能贴商品图——vkedit 4.0 内置元素里没有图片,只能自己写一个图形插件。这篇文章就是这次开发过程的记录。
准备工作
开发插件前先把 vkedit 装起来。注意它把 vue、konva、vue-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 的图形插件有三个核心文件:
loadImage.ts—— 加载图片的工具函数ImageElement.ts—— 元素类(数据 + 序列化)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。这个基类已经帮你处理了:
- 毫米单位的坐标和尺寸(
xmm、ymm、wmm、hmm) - 旋转、缩放
- 基础的
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:传类,不是实例shapeComponent、iconComponent、propertyPanel:三个 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 图的回调覆盖。
alive 在 onBeforeUnmount 时设为 false,所有异步回调回来时先检查 alive,组件已经卸载就直接 return。这是异步组件的常见模式,但写第一遍的时候容易忘。
二是 <v-group> 单根节点。
vkedit 框架会给 Shape 组件传一堆事件(onDragstart、onDragmove、onDragend 等)。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 已经组装好了。我要做的只是声明"我有一个图片图形",剩下的注册、渲染、序列化框架都接管了。对一个内部系统的标签编辑器来说,这个分层是工程上能用的。
毫米坐标系很贴业务。 标签、票据这种场景,业务参数和打印尺寸都是毫米。用 xmm、ymm、wmm、hmm 写代码,比 width: 800 这种像素值自然得多。不过要注意,不是所有视觉数值都是毫米——文本的 fontSize、二维码的 marginMM 各自有自己的字段名,写的时候要区分。这是 vkedit 文档里没有完全讲清楚的一点。
插件边界清楚。 图形插件继承 GraphicPlugin<T>,不画图、只加行为的插件继承 BasePlugin。要加新图形就写图形插件,要加自动保存就写功能插件[官方自动保存插件教程],不会污染同一个文件。这个分层是有工程价值的。
TypeScript 体验是真的有。 我写代码时类型系统替我抓到了好几次误用:传错类(graphicElement 传了实例而不是类)、installPlugin 第一参数写错、TextElement 写 content 字段。完整类型比一长串 API 文档管用。
但槽点也有:
- 内置元素只有五种:矩形、文本、线条、二维码、条形码。图片、表格、印章、图表都得自己写。官方提供了完整的图片插件教程,说明扩展路径是通的,但"能扩展"不等于"零成本"。如果产品经理想把它一路扩成海报设计器,我会尽早劝停。
- 文档对某些坑的描述太轻。比如
crossOrigin的重要性,文档里只是提了一句"以匿名方式请求跨域图片",没解释为什么。imageEl不能进 JSON 也是看代码才能发现。读官方文档时必须自己读源码,不然到运行时才会发现。 - v4 是破坏性升级。如果项目还在 v3,不能只改版本号。v3 到 v4 收编了好几个管理插件为
GraphicRegistryPlugin,重命名了一堆字段(resizable、displayName),还移除了表格和图表元素。升级前必须先看 [v3 到 v4 迁移指南],不要硬升。
我会不会继续用 vkedit?
会。至少在这个标签编辑器的项目里,它解决了我最头疼的"从零搭编辑器"的问题。我不会再自己用 Konva 写工具栏、选择、撤销重做那一套——那是从造轮子变成用轮子。
但我不会无脑推荐给所有人。
如果你要做一个标签 / 票据 / 名片类的模板编辑器,vkedit 很合适,半小时就能跑起来一个原型。如果你想要"在线设计 Canva",vkedit 不合适——内置元素太少,扩展成本会迅速堆起来。先看自己的需求落在这条线的哪一段,再决定。
最后说个小建议:官方那个图片插件教程,照着做完一遍就够用了。vkedit 的核心 API 就那几个:宿主、插件、元素、Shape、属性面板。剩下的具体图形,按这个范式套就行。剩下的具体行为(自动保存、协作、版本管理),走 [功能插件路线]。这两条线走完,自定义插件这件事基本就稳了。
更多推荐


所有评论(0)