基于 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 构造函数里,正是把领域图元登记给内核:
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):
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.ts 与 src/utils/serialization_util.ts 的忠实复刻,跑在同一个 UMD 内核上。点「复制节点」会按字段列表深拷贝出一个 <name>_copy 骨架;「+ / - 字段」对选中实体命令式增删字段;右侧面板即 toSchemaObject() 实时输出的 TypeORM Schema,可直接 new EntitySchema(obj):
节点骨架:继承 ICEGroup 的领域图元
ER 节点本质是一个 extends ICE.ICEGroup 的组件,构造时用 mergeDeep 把默认样式与传入 props 合并,再 syncEntityNameAndFields() 把「表头文本 + 表头背景 + 分隔线 + 每个字段一行文本」拼成子节点:
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 → int、string → varchar、decimal(12,2) → precision+scale 等),约束直接落到列上;关系(one-to-many / many-to-one …)按「外键归属」规则补 joinColumn——这些都在 serialization_util.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:
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/ 非受控defaultValue,onChange上报带循环保护 - StrictMode 安全:引擎
init幂等、destroy解绑全局监听,双挂载无泄漏
自己做绑定时,记住三条(与 IceCanvas 组件一致):
- 用
BrowserOnly(Docusaurus)或dynamic(..., { ssr: false })(Next.js)避免 SSR 访问 canvas; - 卸载时调用
ice.destroy()释放全局监听与帧循环; - 同一 canvas 重复
init直接返回自身,换 canvas 前先destroy。
七、落地清单 / 易错点
- 自定义图元实现
static typeId,格式是namespace:Type(namespace 用自己包名,本 namespace 内唯一) - 反序列化前必须
registerType(顺序:先登记,再loadProject/Deserializer) - 序列化方法返回纯数据,不要把运行时对象 / DOM 引用塞进快照
- 渲染模式:
renderMode: 'dirty-rect'(默认,局部重绘)vs'full'(全量);含文本 / 点集 / 半透明场景引擎会自动回退全量,无需手动处理 - 多版本共存:发布成品建议 inline 内核,避免
instanceof失真 - 框架绑定:生命周期对齐 + SSR 关闭 + 卸载
destroy
延伸阅读
- 实时示例(图元):核心概念 · 第一个实时示例 · 图元总览 · 折线 / 曲线 / 连线 · 主题与样式
- 代码展示规范:代码展示规范
- 引擎架构:运行时链路 · 序列化
- 范例仓库:ice-entity-designer · ice-flow