Meta2d.js 使用指北
做工业组态、设备监控这类可视化项目时,你大概率会面临一个选择题:是从零用 Canvas 手撸一套,还是找个现成的框架?如果你选了后者,Meta2d.js 值得认真看看。
Meta2d.js 是一个开源的 2D 可视化引擎,支持实时数据展示、动态交互和数据管理,基本覆盖了组态应用的核心需求。我在实际项目中用了一段时间,踩了不少坑,这篇文章就把上手过程和关键知识点梳理一遍,希望能帮你少走些弯路。
从安装到跑起来
安装依赖
Meta2d.js 采用了模块化的包结构,核心库必装,其他图元库按需引入:
安装指南
# 安装核心库
pnpm install @meta2d/core --save
# 可选功能模块
pnpm install @meta2d/activity-diagram --save
pnpm install @meta2d/chart-diagram --save
pnpm install @meta2d/class-diagram --save
pnpm install @meta2d/flow-diagram --save
pnpm install @meta2d/fta-diagram --save
pnpm install @meta2d/form-diagram --save
pnpm install @meta2d/sequence-diagram --save
pnpm install @meta2d/le5le-charts --save
pnpm install @meta2d/svg --save
这些图元库分别对应活动图、类图、流程图、时序图等常见图形,不用的就别装,减小打包体积。
在 Vue3 中跑通第一个 Demo
注意: 本文主要介绍 Meta2d.js 在 Vue3 中的使用方法,其他框架的使用方式类似。
要让 Meta2d.js 在 Vue3 项目中正常工作,首先得在 index.html 里引入一些外部资源。这一步容易被忽略,但跳过了后面会出各种奇怪的问题:
在 index.html 中引入资源
// index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Meta2d Vue3</title>
<!-- Meta2d 图标库 -->
<script src="//at.alicdn.com/t/c/font_4042197_vr5c62twlzh.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<!-- Meta2d 依赖 -->
<script src="https://assets.le5lecdn.com/2d/canvas2svg.js"></script>
<script defer src="/meta2d/marked.min.js"></script>
</body>
</html>
准备工作做好了,接下来创建 Meta2d 实例。这里有个小细节:Meta2d 在创建时会在全局挂载一个 meta2d 对象,后续可以直接通过它来操作画布。组件卸载时记得调用 destroy() 释放资源,不然会有内存泄漏的风险。
基础 Meta2d 组件
<script setup lang="ts">
import { Meta2d } from '@meta2d/core';
import { onMounted, onUnmounted } from 'vue';
// Meta2d 配置选项
const meta2dOptions = {};
// 右键菜单处理函数
function contextmenu(e) {
console.log('右键菜单事件', e);
}
// 点击事件处理函数
function click(e) {
console.log('点击事件', e);
}
onMounted(() => {
// 创建 Meta2d 实例
// eslint-disable-next-line no-new
new Meta2d('meta2d', meta2dOptions);
// 注册事件监听
meta2d.on('contextmenu', contextmenu);
meta2d.on('click', click);
// 打开已有画布数据(如果有)
// meta2d.open(json);
});
onUnmounted(() => {
// 销毁实例,释放资源
meta2d.destroy();
});
</script>
<template>
<div id="meta2d" class="w-full h-full" />
</template>
<style scoped>
#meta2d {
width: 100%;
height: 100%;
}
</style>
到这里,一个最简单的 Meta2d 画布就跑起来了。接下来的重头戏是让它真正可用——支持拖拽图元、编辑属性、接收实时数据。
核心功能:拖拽与图元
组态应用的核心交互就是”拖拽”:用户从图元库里选中一个图元,拖到画布上,调整位置和大小。Meta2d.js 对这一流程提供了开箱即用的支持。
注册图元库
在创建 Meta2d 实例之后,需要把用到的图元库注册进去。这一步决定了画布上能使用哪些预置图形:
注册图元
<script setup lang="ts">
import { activityDiagram } from '@meta2d/activity-diagram';
import { classPens } from '@meta2d/class-diagram';
import { Meta2d } from '@meta2d/core';
import { flowPens } from '@meta2d/flow-diagram';
import { formPens } from '@meta2d/form-diagram';
import { sequencePens, sequencePensbyCtx } from '@meta2d/sequence-diagram';
import { onMounted, onUnmounted } from 'vue';
const meta2dOptions = {};
onMounted(() => {
// 创建 Meta2d 实例
// eslint-disable-next-line no-new
new Meta2d('meta2d', meta2dOptions);
// 注册各类图元
meta2d.register(flowPens());
meta2d.register(activityDiagram());
meta2d.register(classPens());
meta2d.register(sequencePens());
meta2d.registerCanvasDraw(sequencePensbyCtx());
meta2d.registerCanvasDraw(formPens());
// 注册事件监听
meta2d.on('contextmenu', contextmenu);
meta2d.on('click', click);
// 打开已有画布数据(如果有)
// meta2d.open(json);
});
onUnmounted(() => {
// 销毁实例
meta2d.destroy();
});
</script>
<template>
<div id="meta2d" class="w-full h-full" />
</template>
搭建图元库面板
光注册了图元还不够,还需要一个侧边栏让用户能看到并拖拽它们。下面是一个图元库面板的实现,关键在于 dragStart 函数——它通过 HTML5 的 Drag API 把图元数据传递给 Meta2d 画布:
图元库组件
<script setup lang="ts">
import { ref } from 'vue';
// 图元分组数据
const graphicGroups = [
{
name: '基本形状',
show: true,
list: [
{
name: '正方形',
icon: 'l-rect',
id: 1,
data: {
width: 100,
height: 100,
name: 'square',
},
},
{
name: '矩形',
icon: 'l-rectangle',
id: 2,
data: {
width: 200,
height: 50,
borderRadius: 0.1,
name: 'rectangle',
},
},
// 更多图元...
],
},
// 更多分组...
];
// 拖拽开始事件处理
function dragStart(e, elem) {
if (!elem)
return;
e.stopPropagation();
if (e instanceof DragEvent) {
// 设置拖拽数据
e.dataTransfer?.setData('Meta2d', JSON.stringify(elem.data));
} else {
// 平板模式:支持点击添加图元
meta2d.canvas.addCaches = [elem.data];
}
}
</script>
<template>
<div class="graphics-panel">
<div class="panel-title">
图元库
</div>
<div v-for="group in graphicGroups" :key="group.name" class="graphic-group">
<div class="group-header">
{{ group.name }}
</div>
<div class="group-content">
<div
v-for="elem in group.list"
:key="elem.id"
class="graphic-item"
:draggable="true"
@dragstart="dragStart($event, elem)"
@click.prevent="dragStart($event, elem)"
>
<svg class="icon" aria-hidden="true">
<use :xlink:href="`#${elem.icon}`" />
</svg>
<div class="graphic-name" :title="elem.name">
{{ elem.name }}
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.graphics-panel {
width: 220px;
height: 100%;
border-right: 1px solid #e0e0e0;
overflow-y: auto;
}
.panel-title {
padding: 12px 16px;
font-size: 16px;
font-weight: 500;
border-bottom: 1px solid #e0e0e0;
}
.graphic-group {
margin-bottom: 8px;
}
.group-header {
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
background-color: #f5f5f5;
cursor: pointer;
}
.group-content {
display: grid;
grid-template-columns: repeat(3, 1fr);
padding: 8px;
gap: 8px;
}
.graphic-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 8px 4px;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.graphic-item:hover {
border-color: #d9d9d9;
background-color: #f9f9f9;
}
.icon {
width: 24px;
height: 24px;
margin-bottom: 4px;
}
.graphic-name {
font-size: 12px;
text-align: center;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
拖拽事件的两种模式
值得注意的是,dragStart 函数里做了两种处理。在桌面端,它通过 DragEvent 的 dataTransfer 传递数据;在平板端(没有拖拽 API 的场景),则直接把数据塞到 meta2d.canvas.addCaches 里,实现点击添加。这个细节在做移动端适配时很有用。
定义拖拽事件
function dragStart(e: any, elem: any) {
if (!elem)
return;
e.stopPropagation();
// 拖拽事件
if (e instanceof DragEvent) {
// 设置拖拽数据
e.dataTransfer?.setData('Meta2d', JSON.stringify(elem.data));
}
else {
// 支持单击添加图元。平板模式
meta2d.canvas.addCaches = [elem.data];
}
}
绑定事件
在模板中,每个图元同时绑定了 @dragstart 和 @click.prevent,确保两种交互模式都能正常工作:
<template>
<div
class="graphic"
:draggable="true"
@dragstart="dragStart($event, elem)"
@click.prevent="dragStart($event, elem)"
>
<svg class="l-icon" aria-hidden="true">
<use :xlink:href="`#${elem.icon}`" />
</svg>
<p :title="elem.name">
{{ elem.name }}
</p>
</div>
</template>
核心功能:属性编辑
光能把图元拖到画布上还不够,真正的组态应用需要让用户能编辑每个图元的属性——位置、大小、颜色、文字等等。这就需要监听图元的选中/取消选中事件,然后把属性显示到一个表单里。
管理图元选中状态
为了让属性面板知道当前选中的是什么,我封装了一个 useMeta2dSelection 组合式函数。它会根据选中图元的类型(普通图元、设备、测点、连线)自动判断并设置不同的编辑模式:
图元选择辅助函数
import type { Pen } from '@meta2d/core';
export enum SelectionMode {
File = 'file',
Pen = 'pen',
Equip = 'equip',
Point = 'point',
Line = 'line',
}
const selections = reactive<{
mode: SelectionMode
pen?: Pen
pens?: Pen[]
}>({
mode: SelectionMode.File,
pen: undefined,
pens: [],
});
export function useMeta2dSelection() {
const select = (pens?: Pen[]) => {
if (!pens || pens.length !== 1) {
selections.mode = SelectionMode.File;
selections.pen = undefined;
if (pens?.length)
selections.pens = pens;
else
selections.pens = [];
return;
}
// name 为 'image' 默认为设备
// name 以 'point-combine' 开头 默认为测点
// 其余为基础图元
const pen = pens[0];
const penName = pen.name || '';
if (penName === 'image')
selections.mode = SelectionMode.Equip;
else if (penName.startsWith('point-combine'))
selections.mode = SelectionMode.Point;
else if (pen.type === 1)
selections.mode = SelectionMode.Line;
else
selections.mode = SelectionMode.Pen;
selections.pen = pen;
selections.pens = pens;
};
return {
selections,
select,
};
}
属性面板与双向绑定
有了选中状态管理,就可以构建属性面板了。核心逻辑是:选中图元时读取其属性填入表单,修改表单时通过 meta2d.setValue() 回写到图元上。这里用 watch 监听选中图元的变化,自动刷新表单数据:
绑定选择事件并修改属性
<script setup lang="ts">
import { Meta2d } from '@meta2d/core';
import { onMounted, onUnmounted, reactive, watch } from 'vue';
import { useMeta2dSelection } from './useMeta2dSelection';
const { selections, select } = useMeta2dSelection();
// 属性表单
const form = reactive({
id: '',
x: 0,
y: 0,
width: 0,
height: 0,
text: '',
color: '', // 边框颜色
background: '', // 背景色
fontSize: 12,
fontFamily: 'Arial',
lineWidth: 1,
// 更多属性...
});
// 图元激活事件处理
function active(pens) {
select(pens);
}
// 图元取消激活事件处理
function inactive() {
select();
}
// 获取当前选中图元的属性
function getPenProperties() {
const pen = selections.pen;
if (!pen)
return;
// 基本属性
form.id = pen.id || '';
form.text = pen.text || '';
form.color = pen.color || '';
form.background = pen.background || '';
form.fontSize = pen.fontSize || 12;
form.fontFamily = pen.fontFamily || 'Arial';
form.lineWidth = pen.lineWidth || 1;
// 位置和大小
const rect = meta2d.getPenRect(pen);
if (rect) {
form.x = Math.round(rect.x) || 0;
form.y = Math.round(rect.y) || 0;
form.width = Math.round(rect.width) || 0;
form.height = Math.round(rect.height) || 0;
}
}
// 更新图元属性
function updateProperty(prop) {
if (!selections.pen)
return;
const update = { id: form.id };
update[prop] = form[prop];
meta2d.setValue(update, { render: true });
}
// 监听选中图元变化
watch(() => selections.pen?.id, getPenProperties);
onMounted(() => {
// 创建 Meta2d 实例
// eslint-disable-next-line no-new
new Meta2d('meta2d', {});
// 注册图元选择事件
meta2d.on('active', active);
meta2d.on('inactive', inactive);
});
onUnmounted(() => {
meta2d.destroy();
});
</script>
<template>
<div class="editor-container">
<!-- 图元库 -->
<div class="sidebar">
<GraphicsPanel />
</div>
<!-- 画布 -->
<div class="canvas-container">
<div id="meta2d" class="canvas" />
</div>
<!-- 属性面板 -->
<div v-if="selections.pen" class="properties-panel">
<div class="panel-title">
属性设置
</div>
<div class="property-group">
<div class="group-title">
位置和大小
</div>
<div class="property-row">
<div class="property-label">
X 坐标
</div>
<input
v-model.number="form.x"
type="number"
class="property-input"
@change="updateProperty('x')"
>
</div>
<div class="property-row">
<div class="property-label">
Y 坐标
</div>
<input
v-model.number="form.y"
type="number"
class="property-input"
@change="updateProperty('y')"
>
</div>
<div class="property-row">
<div class="property-label">
宽度
</div>
<input
v-model.number="form.width"
type="number"
class="property-input"
@change="updateProperty('width')"
>
</div>
<div class="property-row">
<div class="property-label">
高度
</div>
<input
v-model.number="form.height"
type="number"
class="property-input"
@change="updateProperty('height')"
>
</div>
</div>
<div class="property-group">
<div class="group-title">
样式
</div>
<div class="property-row">
<div class="property-label">
边框颜色
</div>
<input
v-model="form.color"
type="color"
class="property-color"
@change="updateProperty('color')"
>
</div>
<div class="property-row">
<div class="property-label">
背景颜色
</div>
<input
v-model="form.background"
type="color"
class="property-color"
@change="updateProperty('background')"
>
</div>
<div class="property-row">
<div class="property-label">
边框宽度
</div>
<input
v-model.number="form.lineWidth"
type="number"
class="property-input"
@change="updateProperty('lineWidth')"
>
</div>
</div>
<div class="property-group">
<div class="group-title">
文本
</div>
<div class="property-row">
<div class="property-label">
内容
</div>
<input
v-model="form.text"
type="text"
class="property-input"
@change="updateProperty('text')"
>
</div>
<div class="property-row">
<div class="property-label">
字体大小
</div>
<input
v-model.number="form.fontSize"
type="number"
class="property-input"
@change="updateProperty('fontSize')"
>
</div>
<div class="property-row">
<div class="property-label">
字体
</div>
<select
v-model="form.fontFamily"
class="property-select"
@change="updateProperty('fontFamily')"
>
<option value="Arial">
Arial
</option>
<option value="Verdana">
Verdana
</option>
<option value="Helvetica">
Helvetica
</option>
<option value="Times New Roman">
Times New Roman
</option>
<option value="Courier New">
Courier New
</option>
</select>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.editor-container {
display: flex;
width: 100%;
height: 100vh;
}
.sidebar {
width: 220px;
height: 100%;
border-right: 1px solid #e0e0e0;
}
.canvas-container {
flex: 1;
height: 100%;
position: relative;
}
.canvas {
width: 100%;
height: 100%;
}
.properties-panel {
width: 280px;
height: 100%;
border-left: 1px solid #e0e0e0;
overflow-y: auto;
padding: 0 16px;
}
.panel-title {
padding: 12px 0;
font-size: 16px;
font-weight: 500;
border-bottom: 1px solid #e0e0e0;
margin-bottom: 16px;
}
.property-group {
margin-bottom: 16px;
}
.group-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
}
.property-row {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.property-label {
width: 80px;
font-size: 14px;
}
.property-input,
.property-select {
flex: 1;
height: 32px;
padding: 0 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
}
.property-color {
width: 32px;
height: 32px;
border: none;
padding: 0;
background: none;
}
</style>
进阶:多画布与实时数据
当基础功能搭建完成后,你很可能会遇到两个进阶需求:同一页面管理多个画布,以及接入实时数据让画布”活”起来。
多画布的那个坑
Meta2d.js 有个设计上的特点:每次 new Meta2d() 都会把实例挂到全局 meta2d 对象上,后创建的会覆盖先创建的。这意味着如果你在主画布的弹窗里又开了一个次画布,全局的 meta2d 就指向次画布了,主画布的操作全都会出问题。
解决方法是在创建次画布后,手动把全局引用恢复回来:
多画布管理辅助函数
/**
* 全局 meta2d 对象保存的是最后一次创建的 meta2d 对象
*
* 如果次画布在主画布后面实例化(比如主画布页面弹窗一个次画布),则我们需要转换一下
*
* https://doc.le5le.com/document/119752831
*/
import type { Options } from '@meta2d/core';
import { Meta2d } from '@meta2d/core';
export function getExtraMeta2d(id: string, options?: Options) {
const mainMeta2d = (globalThis as any).meta2d;
const newMeta2d = new Meta2d(id, options)
;(globalThis as any).meta2d = mainMeta2d;
return newMeta2d;
}
用法很简单:主画布正常创建,次画布通过 getExtraMeta2d() 创建。函数内部会在创建完次画布后立即把全局 meta2d 恢复成主画布的引用,两个画布互不干扰。
三种实时数据通信方式
组态应用的灵魂在于”实时”——设备状态变了,画布上的图元要跟着变。Meta2d.js 支持三种数据通信方式,适用于不同的场景。
MQTT 通信适合物联网场景,设备数量多、数据推送频繁。需要注意的是,浏览器环境只能用 ws(s):// 协议,不能用 mqtt://。
使用 Meta2d 内置 MQTT 功能
import type { Meta2d } from '@meta2d/core';
/**
* Meta2d MQTT 通信辅助函数
*/
export function useMeta2dMqtt() {
/**
* 初始化 MQTT 连接
*/
function init() {
const params = {
mqtt: 'ws://broker.example.com:8083/mqtt', // MQTT Broker WebSocket 地址
mqttTopics: 'topic1/#,topic2', // 多个主题用逗号分隔
mqttOptions: {
clientId: `meta2d_${Date.now()}`, // 客户端ID
username: 'username', // 用户名
password: 'password', // 密码
// 如果 clientId 不为空,默认会随机重新生成一个 clientId,避免连接冲突
// 设置 customClientId = true 可使用用户自定义的固定 clientId
customClientId: false,
},
};
// 连接 MQTT
meta2d.connectMqtt(params);
// 自定义数据处理函数
meta2d.socketFn = (data: string, context?: {
meta2d?: Meta2d
type?: string
topic?: string
url?: string
}) => {
try {
// 解析数据
const message = JSON.parse(data);
// 处理数据,更新图元
if (message.penId && message.value !== undefined) {
meta2d.setValue({
id: message.penId,
value: message.value,
// 其他需要更新的属性...
});
}
// return false 表示仅执行自定义回调函数
// return true 表示除了执行自定义回调外,还会执行核心库方法
return false;
} catch (e) {
console.error('处理 MQTT 消息出错:', e);
return false;
}
};
}
/**
* 关闭 MQTT 连接
*/
function close() {
meta2d.closeMqtt();
}
return {
init,
close,
};
}
如果内置的 MQTT 功能无法满足需求(比如需要更精细的连接管理),也可以自己引入 mqtt 库来实现:
自定义 MQTT 客户端
import mqtt from 'mqtt';
import { ref } from 'vue';
// MQTT 配置
const MQTT_URL = 'ws://broker.example.com:8083/mqtt';
const MQTT_TOPICS = ['device/+/data', 'system/status'];
const MQTT_OPTIONS = {
username: 'username',
password: 'password',
clientId: `meta2d_client_${Date.now()}`,
};
/**
* 自定义 MQTT 客户端辅助函数
*/
export function useCustomMqtt() {
const client = ref(null);
const connectionStatus = ref('disconnected');
/**
* 连接 MQTT 并处理消息
* @param messageHandler 消息处理回调函数
*/
function connect(messageHandler) {
// 创建 MQTT 客户端
client.value = mqtt.connect(MQTT_URL, MQTT_OPTIONS);
// 连接成功事件
client.value.on('connect', () => {
connectionStatus.value = 'connected';
console.log('MQTT 连接成功');
// 订阅主题
client.value.subscribe(MQTT_TOPICS, (err) => {
if (err) {
console.error(`MQTT 订阅主题 [${MQTT_TOPICS.join(', ')}] 失败:`, err);
} else {
console.log(`MQTT 已订阅主题: ${MQTT_TOPICS.join(', ')}`);
}
});
});
// 错误事件
client.value.on('error', (err) => {
console.error('MQTT 连接错误:', err);
connectionStatus.value = 'error';
});
// 断开连接事件
client.value.on('close', () => {
console.log('MQTT 连接已关闭');
connectionStatus.value = 'disconnected';
});
// 重连事件
client.value.on('reconnect', () => {
console.log('MQTT 正在重连...');
connectionStatus.value = 'reconnecting';
});
// 消息事件
client.value.on('message', (topic, payload) => {
try {
const message = JSON.parse(payload.toString());
console.log(`收到 MQTT 消息,主题: ${topic}`, message);
// 调用消息处理函数
messageHandler(topic, message);
} catch (e) {
console.error('处理 MQTT 消息失败:', e);
}
});
}
/**
* 断开 MQTT 连接
*/
function disconnect() {
if (client.value && client.value.connected) {
client.value.end(false, () => {
console.log('MQTT 连接已断开');
connectionStatus.value = 'disconnected';
});
}
}
/**
* 发布消息到指定主题
* @param topic 主题
* @param message 消息内容
* @param options 发布选项
*/
function publish(topic, message, options = {}) {
if (!client.value || !client.value.connected) {
console.error('MQTT 客户端未连接,无法发布消息');
return false;
}
try {
const payload = typeof message === 'object' ? JSON.stringify(message) : message;
client.value.publish(topic, payload, options, (err) => {
if (err) {
console.error(`发布消息到主题 ${topic} 失败:`, err);
}
});
return true;
} catch (e) {
console.error('发布 MQTT 消息失败:', e);
return false;
}
}
return {
client,
connectionStatus,
connect,
disconnect,
publish,
};
}
WebSocket 通信是最常见的方案,适合大多数 Web 应用场景:
WebSocket 连接方法
/**
* Meta2d WebSocket 通信
*/
export function useMeta2dWebsocket() {
/**
* 初始化 WebSocket 连接
* @param url WebSocket 服务器地址
*/
function connect(url: string) {
// 方式一:直接连接
meta2d.connectWebsocket(url);
// 方式二:先设置地址再连接
// meta2d.store.data.websocket = url;
// meta2d.connectWebsocket();
// 自定义数据处理函数
meta2d.socketFn = (data: string, context?: {
meta2d?: Meta2d
type?: string
topic?: string
url?: string
}) => {
try {
const message = JSON.parse(data);
console.log('收到 WebSocket 消息:', message);
// 处理数据,更新图元
if (message.penId && message.value !== undefined) {
meta2d.setValue({
id: message.penId,
value: message.value,
// 其他需要更新的属性...
});
}
return false; // 仅执行自定义回调
} catch (e) {
console.error('处理 WebSocket 消息出错:', e);
return false;
}
};
}
/**
* 关闭 WebSocket 连接
*/
function disconnect() {
meta2d.closeWebsocket();
}
return {
connect,
disconnect
};
}
HTTP 轮询虽然实时性不如前两者,但胜在简单可靠,不需要额外的服务端支持。特别适合数据更新频率不高的场景:
HTTP 轮询配置
/**
* Meta2d HTTP 轮询
*/
export function useMeta2dHttp() {
/**
* 配置单个 HTTP 轮询
* @param url API 地址
* @param interval 轮询间隔(毫秒),默认 1000ms
* @param headers 请求头
*/
function setupSingleHttp(url: string, interval: number = 1000, headers: Record<string, string> = {}) {
// 配置 HTTP 轮询
meta2d.store.data.http = url;
meta2d.store.data.httpTimeInterval = interval;
meta2d.store.data.httpHeaders = headers;
// 启动轮询
meta2d.connectHttp();
}
/**
* 配置多个 HTTP 轮询(1.0.26 版本以上支持)
* @param configs HTTP 配置数组
*/
function setupMultiHttp(configs: Array<{
url: string
method?: 'GET' | 'POST'
interval?: number
headers?: Record<string, string>
}>) {
// 确保 https 数组存在
if (!meta2d.store.data.https) {
meta2d.store.data.https = [];
}
// 配置多个 HTTP 轮询
configs.forEach((config, index) => {
// 确保数组长度足够
while (meta2d.store.data.https.length <= index) {
meta2d.store.data.https.push({});
}
// 设置配置
meta2d.store.data.https[index].http = config.url;
meta2d.store.data.https[index].method = config.method || 'GET';
meta2d.store.data.https[index].httpTimeInterval = config.interval || 1000;
meta2d.store.data.https[index].httpHeaders = config.headers || {};
});
// 启动轮询
meta2d.connectHttp();
}
/**
* 停止 HTTP 轮询
*/
function stopHttp() {
meta2d.closeHttp();
}
return {
setupSingleHttp,
setupMultiHttp,
stopHttp
};
}
工程化实践
当项目复杂度上来之后,把 Meta2d 的各个功能模块拆分清楚就变得很重要了。下面分享几个在实际项目中总结出的实践。
组件化封装
与其让 Meta2d 的初始化代码散落在各个页面,不如封装成一个通用的 Vue 组件。通过 props 接收配置和初始数据,通过 emit 暴露事件,父组件用起来会清爽很多:
Meta2d 核心组件
<!-- Meta2dCanvas.vue -->
<script setup lang="ts">
import type { Pen } from '@meta2d/core';
import { Meta2d } from '@meta2d/core';
import { onMounted, onUnmounted, ref, watch } from 'vue';
const props = defineProps({
// 画布配置选项
options: {
type: Object,
default: () => ({})
},
// 初始数据
initialData: {
type: Object,
default: null
},
// 画布ID
canvasId: {
type: String,
default: 'meta2d'
}
});
const emit = defineEmits([
'active',
'inactive',
'click',
'contextmenu',
'mousedown',
'mouseup',
'mousemove'
]);
// Meta2d 实例引用
const meta2dInstance = ref(null);
// 初始化 Meta2d
function initMeta2d() {
// 创建实例
meta2dInstance.value = new Meta2d(props.canvasId, props.options);
// 注册事件
meta2dInstance.value.on('active', (pens: Pen[]) => {
emit('active', pens);
});
meta2dInstance.value.on('inactive', () => {
emit('inactive');
});
meta2dInstance.value.on('click', (e: MouseEvent) => {
emit('click', e);
});
meta2dInstance.value.on('contextmenu', (e: MouseEvent) => {
emit('contextmenu', e);
});
meta2dInstance.value.on('mousedown', (e: MouseEvent) => {
emit('mousedown', e);
});
meta2dInstance.value.on('mouseup', (e: MouseEvent) => {
emit('mouseup', e);
});
meta2dInstance.value.on('mousemove', (e: MouseEvent) => {
emit('mousemove', e);
});
// 加载初始数据
if (props.initialData) {
meta2dInstance.value.open(props.initialData);
}
}
// 监听初始数据变化
watch(() => props.initialData, (newData) => {
if (meta2dInstance.value && newData) {
meta2dInstance.value.open(newData);
}
}, { deep: true });
// 暴露方法给父组件
defineExpose({
getInstance: () => meta2dInstance.value
});
onMounted(() => {
initMeta2d();
});
onUnmounted(() => {
if (meta2dInstance.value) {
meta2dInstance.value.destroy();
}
});
</script>
<template>
<div :id="canvasId" class="meta2d-canvas" />
</template>
<style scoped>
.meta2d-canvas {
width: 100%;
height: 100%;
}
</style>
数据持久化
画布上精心搭建的组态画面,当然需要保存下来。下面这个工具函数集合涵盖了导出 JSON、从文件加载、导出为图片等常见操作:
数据保存与加载
/**
* Meta2d 数据持久化辅助函数
*/
export function useMeta2dData() {
/**
* 保存画布数据
* @param filename 文件名
*/
function saveData(filename: string = 'meta2d-data.json') {
try {
// 获取画布数据
const data = meta2d.data();
// 创建下载链接
const dataStr = JSON.stringify(data, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
// 创建下载元素
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
// 清理
URL.revokeObjectURL(url);
return true;
} catch (e) {
console.error('保存画布数据失败:', e);
return false;
}
}
/**
* 加载画布数据
* @param data 画布数据对象
*/
function loadData(data: any) {
try {
meta2d.open(data);
return true;
} catch (e) {
console.error('加载画布数据失败:', e);
return false;
}
}
/**
* 从文件加载画布数据
* @param file 文件对象
* @returns Promise
*/
function loadFromFile(file: File): Promise<boolean> {
return new Promise((resolve, reject) => {
if (!file) {
reject(new Error('未提供文件'));
return;
}
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result as string);
meta2d.open(data);
resolve(true);
} catch (err) {
console.error('解析文件数据失败:', err);
reject(err);
}
};
reader.onerror = (e) => {
console.error('读取文件失败:', e);
reject(e);
};
reader.readAsText(file);
});
}
/**
* 导出为图片
* @param filename 文件名
* @param type 图片类型 'png' | 'jpeg' | 'svg'
*/
function exportImage(filename: string = 'meta2d-canvas', type: 'png' | 'jpeg' | 'svg' = 'png') {
try {
if (type === 'svg') {
meta2d.toSVG(filename);
} else {
meta2d.toPng(filename, type);
}
return true;
} catch (e) {
console.error(`导出${type}图片失败:`, e);
return false;
}
}
return {
saveData,
loadData,
loadFromFile,
exportImage
};
}
自定义图元
内置图元满足不了业务需求时,就需要自己造了。Meta2d.js 支持通过自定义渲染函数来绘制任意图形,下面是一个包含设备图元和指标图元的示例:
自定义图元示例
/**
* 创建自定义图元
*/
export function createCustomPens() {
// 自定义设备图元
const customDevicePen = {
name: 'customDevice',
icon: 'l-custom-device',
data: {
text: '自定义设备',
width: 120,
height: 80,
name: 'customDevice',
// 自定义属性
deviceId: '',
status: 'normal',
// 自定义图形
path: [
['M', 0, 0],
['L', 120, 0],
['L', 120, 80],
['L', 0, 80],
['Z'],
['M', 10, 20],
['L', 110, 20],
['L', 110, 60],
['L', 10, 60],
['Z']
],
// 自定义样式
lineWidth: 2,
color: '#1890ff',
background: '#e6f7ff',
// 状态样式映射
statusStyles: {
normal: { background: '#e6f7ff', color: '#1890ff' },
warning: { background: '#fff7e6', color: '#fa8c16' },
error: { background: '#fff1f0', color: '#f5222d' }
}
}
};
// 自定义指标图元
const customMetricPen = {
name: 'customMetric',
icon: 'l-custom-metric',
data: {
text: '0',
width: 80,
height: 40,
name: 'customMetric',
// 自定义属性
metricId: '',
unit: '',
min: 0,
max: 100,
// 自定义样式
textAlign: 'center',
textBaseline: 'middle',
fontSize: 16,
fontWeight: 'bold',
// 自定义渲染
onRender: (ctx, pen) => {
// 绘制背景
ctx.fillStyle = pen.background || '#f0f0f0';
ctx.strokeStyle = pen.color || '#d9d9d9';
ctx.lineWidth = pen.lineWidth || 1;
const x = pen.calculative.worldRect.x;
const y = pen.calculative.worldRect.y;
const w = pen.calculative.worldRect.width;
const h = pen.calculative.worldRect.height;
// 绘制圆角矩形
const r = 4; // 圆角半径
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
ctx.fill();
ctx.stroke();
// 绘制文本
ctx.fillStyle = pen.fontColor || '#000';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `${pen.fontWeight || ''} ${pen.fontSize || 16}px ${pen.fontFamily || 'Arial'}`;
let text = pen.text || '0';
if (pen.unit) {
text += ` ${pen.unit}`;
}
ctx.fillText(text, x + w / 2, y + h / 2);
return true; // 返回 true 表示自定义渲染
}
}
};
return [customDevicePen, customMetricPen];
}
// 注册自定义图元
export function registerCustomPens() {
const customPens = createCustomPens();
meta2d.register(customPens);
}
Demo 项目与总结
在学习和实践 Meta2d.js 的过程中,我搭了一个 Vue3 的演示项目,把上面提到的功能都集成了进去,有兴趣的话可以看看:
回顾整个使用过程,Meta2d.js 给我的感觉是”够用且灵活”。它不像一些重量级框架那样大而全,但在 2D 组态这个垂直领域,该有的功能基本都有——图元拖拽、属性编辑、多种数据通信方式、自定义图元、多画布管理。上手成本不算高,但要用好确实需要花些时间去理解它的设计思路,特别是全局 meta2d 对象的管理和 socketFn 回调的工作机制。
如果你正在做组态监控、流程图编辑器或者其他 2D 可视化项目,Meta2d.js 是一个值得尝试的选择。