Skip to main content

基于 ice-render 内核二次开发

本页回答一个问题:怎么在 ice-render 这个“内核”之上,做出自己的图形编辑器?

标准答案:不要重写图元,把内核当底座,往上叠「领域组件 + 应用层 +(可选)框架绑定」。 本章用官方成品 ice-entity-designer(ER 建模工具)作为完整范例,把每一层都对应到内核能力。


一、内核给你什么,不给你什么

ice-render 只负责「画什么」和「怎么交互」:

  • 图元基类 ICEComponent 及一族成品图元(ICERect / ICECircle / ICEText / ICEGroup / 连线族 …)
  • 坐标 / 变换 / 事件 / 脏矩形渲染 / 序列化等通用能力
  • 不绑定任何业务语义

二次开发要补的,正是「业务语义」这一层:

你要做的内核提供的钩子
定义领域图元(实体框、流程节点…)继承 ICEComponent / ICERect / ICEGroup,实现 static typeId + 序列化方法
让图元能被反序列化ice.registerType('my-app:Badge', Class)
把交互串成「应用」(增删改 / 连接 / 校验 / 导出)自建一个 App 类,持有 ICE 实例,监听事件总线 evtBus
工程化打包把内核 inline 进产物,或让上游 import 'ice-render'
接入 React / Vue客户端组件 + 生命周期对齐(见 §五)

二、最小可运行:注册一个自定义图元

下面这段在浏览器里直接跑(无需服务端):定义一个 Badge 继承自 ICERect,用 ice.registerType 登记,再加进画布——运行后拖动它,天生可交互。

核心就两行:

注册自定义图元
class Badge extends ICE.ICERect {
static typeId = 'my-app:Badge'; // 序列化用的类型标识:namespace:Type
}
// 登记后才能被 Serializer/Deserializer 识别
ice.registerType(Badge.typeId, Badge);

类型标识 typeId 统一为 namespace:Type:namespace 用你自己的小写包名 (引擎内置 ice-render:*、设计器 ice-entity-designer:*、图表 ice-chart:*), 因此不同厂商 / 不同包之间的自定义图元不会撞名。同一个 namespace 内仍然要求唯一, 而且冲突是明确抛错的:同一个 typeId 注册不同构造函数、或同一个构造函数注册第二个 typeId 都会立刻失败(不再静默覆盖)。

类型名只有 canonical 这一种形式:引擎不做「旧的无 namespace 类名」兼容(家族仍在发布初期, 引用者少、没有历史包袱)。

两个方向的告警都要留意:未注册的类型反序列化时会被跳过(记入 Deserializer.unknownTypes), 未注册的类型序列化时只能回退写类名(记入 Serializer.unregisteredTypes,而类名可能在打包时被 mangle)。


三、entity-designer 是怎么叠出来的(范例拆解)

ice-entity-designer 是「内核 + 领域层」的范本,结构正好对应上面的钩子:

src/
├── er-component/
│ ├── Entity.ts # 领域图元:继承 ICEGroup,表头 + 字段列表 + 约束标记 + TypeORM 序列化
│ └── Relation.ts # 领域图元:继承连线族,基数 / 箭头 / 标签语义
├── designer/
│ └── EntityDesigner.ts # 应用层:选择 / 增删改 / 连接 / 校验 / 历史 / 项目存取 / 变更订阅
├── react/ # 可选:React 绑定(EntityDesignerCanvas 等)
└── index.ts # 对外导出(核心,不含 React)

它的 EntityDesigner 构造函数里,正是把领域图元登记给内核:

src/designer/EntityDesigner.ts
constructor(ice) {
this.ice = ice;
// typeId 形如 'ice-entity-designer:Entity'
this.ice.registerType(Entity.typeId, Entity);
this.ice.registerType(Relation.typeId, Relation);
this.ice.evtBus.on('mousedown', this.__mousedownHandler, this);
}

应用层把建模闭环串起来(节选自其 README):

ice-entity-designer 用法
import { ICE, EntityDesigner } from 'ice-entity-designer';

const ice = new ICE().init('canvas-1');
const designer = new EntityDesigner(ice);

const user = designer.createEntity({ entityName: 'User' });
const role = designer.createEntity({ entityName: 'Role' });
designer.createRelation({
sourceId: user.state.id,
targetId: role.state.id,
relationType: 'many-to-many',
joinTableName: 'user_roles',
});

const schema = designer.toSchemaObject(); // TypeORM Schema(对象),可直接 new EntitySchema(obj)
const issues = designer.validate(); // 校验问题列表
designer.serializeProject(); // 项目快照(字符串,可自动保存)

关键原则:entity-designer 不重复实现底层图元,只在 ICEGroup、连线族与事件总线之上,收敛出 ER 建模最常用的交互。这正是二次开发应该有的姿势——内核负责「通用图形」,你只填「业务语义」。

下面这个 playground 是 ice-entity-designer/src/er-component/Entity.tssrc/utils/serialization_util.ts忠实复刻,跑在同一个 UMD 内核上。点「复制节点」会按字段列表深拷贝出一个 <name>_copy 骨架;「+ / - 字段」对选中实体命令式增删字段;右侧面板即 toSchemaObject() 实时输出的 TypeORM Schema,可直接 new EntitySchema(obj)

节点骨架:继承 ICEGroup 的领域图元

ER 节点本质是一个 extends ICE.ICEGroup 的组件,构造时用 mergeDeep 把默认样式与传入 props 合并,再 syncEntityNameAndFields() 把「表头文本 + 表头背景 + 分隔线 + 每个字段一行文本」拼成子节点:

EREntityNode(节选自 ERNodePlayground)
class EREntityNode extends ICE.ICEGroup {
constructor(props) {
const param = mergeDeep(
{ entityName: 'Entity Name', fields: [], width: 250,
style: { strokeStyle: '#334155', fillStyle: '#ffffff', radius: 8, lineWidth: 1.5 },
headerStyle: { textColor: '#0f172a', backgroundColor: '#f1f5f9', fontSize: 18, fontWeight: 'bold', paddingTop: 12, paddingLeft: 14, paddingRight: 14, paddingBottom: 12 },
fieldStyle: { textColor: '#334155', fontSize: 16, paddingTop: 9, paddingLeft: 14, paddingRight: 14 },
dividerStyle: { strokeStyle: '#cbd5e1', fillStyle: '#cbd5e1', lineWidth: 1 } },
props, { transformable: false });
super(param);
this.syncEntityNameAndFields();
}

fieldDisplay(field) {
const tags = [];
if (field.primary) tags.push('PK');
if (field.foreignKey) tags.push('FK');
if (field.unique) tags.push('UQ');
if (field.autoIncrement) tags.push('AI');
if (field.nullable === false) tags.push('NN');
const keyText = tags.length ? `${tags.join(' ')} ` : '';
const typeText = field.type ? `${field.type}${field.length ? `(${field.length})` : ''}` : '';
return { text: `${keyText}${field.name}${typeText ? ` ${typeText}` : ''}` };
}

toEntityObject() {
const result = { name: this.state.entityName, columns: {} };
(this.state.fields || []).forEach((f) => {
const col = {};
if (f.type !== undefined) col.type = f.type;
if (f.primary) col.primary = true;
if (f.autoIncrement) { col.generated = true; col.strategy = 'increment'; }
if (f.nullable === false) col.nullable = false;
if (f.unique) col.unique = true;
result.columns[f.name] = col;
});
return result;
}

// 深拷贝出一个副本骨架
clone(opts) {
const fields = (this.state.fields || []).map((f) => ({ ...f }));
return new EREntityNode({
left: (this.state.left || 0) + (opts?.dx || 0),
top: (this.state.top || 0) + (opts?.dy || 0),
entityName: (this.state.entityName || 'Entity') + '_copy',
fields,
});
}
}

TypeORM 序列化:画布约定 → EntitySchema

字段类型按 TYPE_MAP 归一化(number → intstring → varchardecimal(12,2) → precision+scale 等),约束直接落到列上;关系(one-to-many / many-to-one …)按「外键归属」规则补 joinColumn——这些都在 serialization_util.toSchemaObject() 里完成。单实体维度等价于:

toSchemaObject(单实体维度)
function normalizeColumn(field) {
const column = {};
const type = TYPE_MAP[String(field.type).toLowerCase()] || field.type;
if (type !== undefined) column.type = type;
if (field.primary) column.primary = true;
if (field.autoIncrement) { column.generated = true; column.strategy = 'increment'; }
if (field.nullable === false) column.nullable = false;
if (field.unique) column.unique = true;
return column;
}

function toSchemaObject(nodes) {
return nodes.map((n) => {
const obj = n.toEntityObject();
const columns = {};
Object.keys(obj.columns).forEach((k) => { columns[k] = normalizeColumn(obj.columns[k]); });
return { name: obj.name, columns };
});
}
// 用法:new EntitySchema(toSchemaObject([userNode, userNode_copy])[0])

四、序列化:让自定义图元可存可取

二次开发迟早要保存 / 加载项目。内核已提供 serializeProject() / loadProject(),但前提是自定义类型先登记

// 顺序很重要:先 registerType,再反序列化
ice.registerType('my-app:Badge', Badge);

const snapshot = ice.serializeProject(); // 整张图 → JSON 字符串
ice.loadProject(snapshot); // 从 JSON 还原(内部走 Deserializer)

注意 Deserializer 对未知类型是宽容跳过 + 告警,不会整库崩。所以发布插件 / 自定义图元时,务必在 init 阶段就 registerType,避免用户加载旧工程时丢节点。

想做成「可插拔」的扩展?内核还提供 PluginHost:插件用 components: { 'my-app:Badge': Badge } 声明自定义图元类型,宿主代为 registerType, 插件因此自动获得序列化 / 反序列化能力;注册失败的错误信息会带上插件名(见 src/plugin/PluginHost.ts)。


五、两种消费内核的方式

A. 直接依赖 ice-render(自己构建)

npm install ice-render
import { ICE, ICERect } from 'ice-render';

适合你自己的工程要独立打包、且愿意把 ice-render 作为依赖来管版本。

B. 消费「内核已内联」的产物(如 entity-designer)

entity-designer 把 ice-render 在构建时 inline 进 dist,并 export * from 'ice-render',所以调用方:

import { ICE, EntityDesigner } from 'ice-entity-designer'; // 同源内核,无需再装 ice-render

好处:调用方永远拿到与本包一致的 ICE 实例,避免「两个 ice-render 版本共存导致 instanceof 失真」。做对外发布的产品时推荐这种形态。


六、接入 React(可选)

内核不绑框架。若要 SPA 集成,包一层客户端组件即可。entity-designer 已内置 ice-entity-designer/react

React 集成
import { EntityDesignerCanvas, useEntityDesigner } from 'ice-entity-designer/react';

function Toolbar() {
const designer = useEntityDesigner(); // 子树内直接取 EntityDesigner 实例
return <button onClick={() => designer?.addEntity({ entityName: 'User' })}>新增实体</button>;
}

export default function App() {
return (
<EntityDesignerCanvas
width={1200}
height={800}
onChange={({ snapshot, schema }) => save(snapshot)} // 模型变更(带循环保护)
>
<Toolbar />
</EntityDesignerCanvas>
);
}
  • ref 命令式 API:addEntity / connect / updateEntity / toSchemaObject / undo
  • useEntityDesigner():在子树内取 EntityDesigner 实例
  • 受控 value / 非受控 defaultValueonChange 上报带循环保护
  • StrictMode 安全:引擎 init 幂等、destroy 解绑全局监听,双挂载无泄漏

自己做绑定时,记住三条(与 IceCanvas 组件一致):

  1. BrowserOnly(Docusaurus)或 dynamic(..., { ssr: false })(Next.js)避免 SSR 访问 canvas;
  2. 卸载时调用 ice.destroy() 释放全局监听与帧循环;
  3. 同一 canvas 重复 init 直接返回自身,换 canvas 前先 destroy

七、落地清单 / 易错点

  • 自定义图元实现 static typeId,格式是 namespace:Type(namespace 用自己包名,本 namespace 内唯一)
  • 反序列化前必须 registerType(顺序:先登记,再 loadProject / Deserializer
  • 序列化方法返回纯数据,不要把运行时对象 / DOM 引用塞进快照
  • 渲染模式:renderMode: 'dirty-rect'(默认,局部重绘)vs 'full'(全量);含文本 / 点集 / 半透明场景引擎会自动回退全量,无需手动处理
  • 多版本共存:发布成品建议 inline 内核,避免 instanceof 失真
  • 框架绑定:生命周期对齐 + SSR 关闭 + 卸载 destroy

延伸阅读