· 能力缺口分析(对标主流引擎)
日期:2026-09-10 方法(评估当日口径,2026-09-11 已增至 67 个文件 / 12,500 余行):通读
src/全部 62 个文件(10,300 行)+ 构建/测试/CI/文档全量核对,并外部对标 12 个主流开源与商业图形/图表引擎(按约定去名化,仅以类别描述)。 约定:本文不点名任何竞品,一律以类别/通行做法描述。 目的:回答「对这样一款引擎,应该做而没做的是什么」,用于排优先级。本文是评估记录,不是实现计划;每项落地应另走 superpowers 闭环(spec → plan → TDD)。阅读须知(2026-09-11 起):本文是滚动更新的文档 —— §1 结论速览每行都带「评估时 vs 当前」双列对照, §8 进展表是逐条落地记录与验证证据。§2 / §3 / §4 的正文保留评估当天的原始描述与代码证据(含行号), 它回答的是「当时凭什么这样判断」,不代表现在仍是缺口;判断当前状态请以 §1 + §8 为准。 已修的条目在其小节顶部都加了「状态」行。
范围与边界(先读)
09-roadmap.md 已明确划分「引擎原语 vs 应用层」:
- 不归引擎(由
ice-entity-designer等应用层实现):undo/redo、多选交互 UX、编组、复制粘贴、图层/属性面板。 - 本文只列 roadmap 未覆盖、但属于引擎职责的缺口。
已确认的定位决策(2026-09-10):
- 渲染后端不抽象:引擎有意绑定 Canvas 2D,不做 Renderer 抽象层,不支持 WebGL / WebGPU / SVG 作为可替换后端。
- 因此下文所有条目均不含「抽象渲染层」「为将来换后端留口」类建议;涉及 SVG/Node 出图的邻近能力,只允许以独立 exporter / 注入外部 ctx 的形式提出(见 §6)。
结论速览
换个视角的证据:两个真实应用(
ice-web-components的六页后台与全屏 Windows XP 桌面)跑下来的 复盘在 15 · 应用驱动的引擎评估 —— 那里记的是「哪里真的被卡住、归属是谁」, 与本文的「对标清单」互补。
这是一张会随进展更新的表:左三列是 2026-09-10 评估时的判断,最后一列是 2026-09-11 的复核结果。 历史版本把 §1 当成不可变的「评估快照」,导致已完成的大项(输入层、HiDPI、插件、无障碍、文本排版……) 长期显示「完全没做」,极易造成重复投入 —— 因此改为双列对照。逐条进展与证据见 §8。
| # | 缺口 | 等级 | 评估时现状(2026-09-10) | 当前状态(2026-09-11 复核) |
|---|---|---|---|---|
| 1 | 输入层只有 mouse + keyboard,无 pointer / touch / wheel / 手势 | P0 | 完全没做 | ✅ 已做:pointer* / touch* / wheel 三通道,按运行时能力自动选择。多指手势按 §5 边界归应用层 |
| 2 | 无框选、无多选(selectionList 恒为单值) | P0 | 完全没做 | ➖ 引擎侧原语已给(setSelection(components) 支持多选);框选 UX 归应用层(§5) |
| 3 | 命中检测每次全量 flattenTree + sort + 逐组件判定 | P0 | 完全没做 | ⚠️ 部分:已做视口裁剪 + O(1) 包围盒预筛(实测 1.89x);画布点击与 ICE.hitTest() 已收敛为同一实现 hitTestComponents()。空间索引(四叉树 / R-tree)仍未做(上万节点且大部分在屏内时才划算) |
| 4 | dirty-rect 遇文本 / 点集 / 半透明整场景回退全量 | P0 | 已做但用不上 | ✅ 已修(两层):门控放宽到「相交级」;并解开「非单位视口」与「dpr≠1」两个回退(脏区世界坐标收集 → 渲染坐标裁剪),分散脏区改为多块裁剪。回归见 §8(zoom / dpr2 / multi 四个像素场景全部「局部执行 > 0 且逐像素一致」) |
| 5 | 主画布无 devicePixelRatio / HiDPI 处理 | P0 | 完全没做 | ✅ 已做:ICE.init(el, { dpr })(默认 1,零行为变化);且 dpr>1 下不再牺牲局部重绘 |
| 6 | 文本无自动换行 / 省略,超宽时横向压缩字形 | P1 | 完全没做 | ✅ 已做(wrap / maxLines / ellipsis / grapheme 分段,默认关闭;letterSpacing / lineHeight / textDecoration / RTL / CJK 避头尾 / 无空格脚本断行均已落地)。仍缺富文本(同行混排粗体 / 颜色 / 字号) |
| 7 | 文本含 HTML 注入;非 DOM 环境量不出尺寸 | P1 | 缺陷 | ✅ 已修:改用 textContent;量测改为 canvas 优先 + DOM 降级 |
| 8 | 无 SVG / PDF 导出,无 SVG 导入 | P1 | 部分 | ✅ SVG 导出 + 无头出图已做(ice.toSvg() / exportSvg() 与画布共用命令流;ICE.headless() 让 Node 建树出图不依赖 DOM / rAF;examples/node/export.mjs 落盘 SVG、可选转 PNG)。仍未做:PDF 导出、SVG 导入、剪贴板 / 打印(见 §6 与 §8) |
| 9 | 无障碍零实现(无 ARIA / DOM 镜像 / 键盘焦点) | P1 | 完全没做 | ✅ 已给原语:getAccessibilityTree() / setFocusedComponent()(方案 B:DOM 镜像交应用层,见 14) |
| 10 | 无插件 / 扩展点(仅 registerType) | P1 | 完全没做 | ✅ 已做:ICE.use() 三层注册点。仍缺「自定义命中判定」注册协议(见 09 路线图) |
| 11 | 序列化以 constructor.name 为类型键;映射漏项 | P1 | 隐患 | ✅ 已修:getTypeId() 反查 + 补 ICERose + 反序列化容错 + 迁移表;2026-09-13 进一步把 typeId 统一为 namespace:Type、冲突明确抛错、且只认 canonical 一种形式。⚠️ 该缺陷随后在下游应用层复现过一次(引擎修了、应用层漏改),见 §8 末条 |
| 12 | 发行契约缺失;CI 不跑可视化回归 | P1 | 部分 | ✅ 已做:exports / sideEffects / CHANGELOG / publint+attw / 覆盖率门槛 / CI 接可视化回归。2026-09-12 复核:GitHub Actions 上的 ci.yml 是真在跑的(累计 121 次运行,dev / master 最近全绿),镜像也已是同步状态;⚠️ 下游两仓(ice-entity-designer / ice-entity-designer-dsl)尚无 CI,各自的门禁目前只靠本地 npm run 系列 |
| 13 | 动画无 delay / 序列 / spring,且 Math.floor 掉精度 | P2 | 部分 | ✅ 已做(另加关键帧时间轴、数组字段补间,结束判定改按时间) |
| 14 | 布局不随增删自动重排;容器 setState 递归置脏全部后代 | P2 | 部分 | ✅ 已做(自动重排 + 排布前 measure + dirty/paramsDirty 拆级) |
| 15 | 主题为模块级全局单例,多实例互相污染 | P2 | 部分 | ✅ 已修(主题改为实例级,含 preset 与 motion token) |
| 16 | 内部脆弱点若干(事件监听累积、销毁不彻底、死代码等) | P2 | 见 §4 | ✅ 已修(§4.3–4.5);§4.6 三条待复核项已全部核实(两条确认无问题、一条已修) |
| 17 | display: false 只判组件自身,子组件照样渲染/命中 | P1 | 评估时未列 | ✅ 已修:isEffectivelyVisible() 沿父链判断,渲染/命中/a11y/离屏缓存统一消费(与 props 文档承诺的「整棵子树」一致) |
| 18 | 布局只在 setLayout/addChild 时排一次,子项改尺寸不重排 | P2 | 评估时未列 | ✅ 已修:ICEGroup.requestLayout()(下一帧合并重排,布局期间不自激)+ getPreferredSize() 转发 |
| 19 | 修饰键(Shift/Ctrl/Alt/Meta)根本到不了组件 | P1 | 评估时未列 | ✅ 已修:输入归一化显式透传(DOM 原型上的不可枚举 getter 靠 ICEEvent 的 for...in 拷贝带不过来);据此实现 Shift 等比缩放与旋转吸附 15° |
| 20 | 拖拽移出画布丢事件(无 setPointerCapture) | P1 | 评估时未列 | ✅ 已修:pointerdown 捕获、pointerup/cancel 释放 |
| 21 | 渐变只能塞原生 CanvasGradient,不可序列化 | P1 | 评估时未列 | ✅ 已做:声明式 fillGradient/strokeGradient(纯对象、可进 JSON、可写进主题 preset) |
| 22 | 几何能力(求交/采样/点在多边形)全是私有实现,未上提 | P2 | 评估时未列 | ✅ 已做:GeoUtil.pointInPolygon / distanceToSegment / distanceToPolyline / samplePolyline / segmentIntersect,图元改为消费公共实现 |
| 23 | 产物内联 gl-matrix 但没有保留其版权声明;rollup 的 external 是死代码 | P1 | 评估时未列 | ✅ 已修:产出 dist/THIRD-PARTY-NOTICES.txt;删掉 external 死代码并在注释里写明「全部内联 = 零运行时依赖」的取舍 |
P0 · 决定「是否算交互图形引擎」
P0-1 输入模型只有鼠标 + 键盘
状态(2026-09-11):✅ 已完成 ——
pointer*/touch*/wheel三通道按运行时能力自动选择,拖拽不再依赖evt.movementX。下方为评估时的原始记录。
证据:src/consts/DOM_EVENT_MAPPING_CONSTS.ts:13-30 仅注册 mousedown / mouseup / mousemove / click / dblclick / contextmenu + keydown / keyup。全仓库检索 wheel | pointer | touch | gesture | pinch 零命中。
后果:
- 触屏 / iPad / 触控笔不可用;拖拽依赖
evt.movementX(graphic/ICEComponent.ts:274-281),触摸事件下为undefined。 - 无滚轮缩放、无双指缩放/旋转。
setViewport()只能由应用层自行监听 wheel 驱动(ICE.ts:382-390只提供 setter,无输入接入)。 - 无
focus/blur/mouseenter/mouseleave,无 hover 态。
对标:主流图形引擎普遍内建 drag 事件族(dragstart/move/end);部分跨端引擎直接提供拖拽/旋转/缩放手势;图编辑器类框架把框选、右键菜单、键盘做成开箱扩展。
P0-2 无框选、无多选
状态(2026-09-11):➖ 引擎侧原语已给,交互 UX 归应用层 ——
setSelection(components)支持多选;marquee 框选按 §5 边界由应用层实现。
证据:ICE.ts:52 声明 selectionList,注释写「支持 Ctrl 键同时选中多个组件」;但全仓库唯一赋值点是 control-panel/ICEControlPanelManager.ts:92 → this.ice.selectionList = [component],长度恒为 1。无 marquee(框选矩形)、无 Ctrl/Shift 多选、无批量变换。
附带问题(已处理):README 的核心特性曾写「完整事件系统……支持拖拽、框选、多选」——与实现不符。现措辞已改为「支持拖拽与方向键微调」,不再声称框选/多选。
P0-3 命中检测无空间索引、无视口裁剪
状态(2026-09-11):⚠️ 部分完成 —— 视口裁剪 + O(1) 包围盒预筛已落地(实测 N=6000、屏外 50% 提速 1.89x);空间索引(四叉树 / R-tree)仍未做,理由见 §7。下方为评估时的原始记录。
证据:
event/DOMEventDispatcher.ts:86-91:每次 mousedown / mouseup / click 都flattenTree整棵树 →sort→ 对每个组件调containsPoint(点集路径还走 O(顶点数) 射线法,graphic/ICEDotPath.ts:65-80)。ICE.ts:408-420的公开hitTest()同样全量 flatten + sort。renderer/CanvasRenderer.ts:181-204全量渲染遍历整个componentQueue,不按视口矩形剔除(视口缩放只在__collect里作为「回退全量」的条件出现,CanvasRenderer.ts:231-234)。
后果:1 万节点下每次点击 ≈ 1 万次命中计算 + 一次全树排序;拖拽时每帧如此,必然掉帧。
对标:主流图层化引擎的官方性能文档明确「Layer 会绘制所有节点,无论是否在舞台内」,需自行 culling,并提示上万节点要自建索引。业界标准做法是四叉树/R-tree + 视口裁剪。
P0-4 dirty-rect 在真实场景几乎不触发
证据:renderer/CanvasRenderer.ts:250-253 与 314-329 —— 只要场景中存在任一可见的 ICEText、点集路径(星形/正 N 边形/玫瑰),或任一非不透明落墨(rgba/hsla 色、阴影、globalAlpha≠1、非 source-over 合成)的组件,就整体回退全量重绘(renderer/dirty-rect-util.ts:113-123 的 isOpaqueDrawing)。
后果:ER 图 / 流程图 / 任何带文字的场景必然命中该门控,默认的 dirty-rect 实际等价于全量重绘。这是「优化做了但用不上」的典型——投入已经花了,收益被保守门控吃掉。
缓解方向(2026-09-10 已落地):已从「整场景」放宽为「相交级」——
- 干净的 risky 组件(文本/点集路径/非不透明落墨):仅当其盒与本次脏区域相交才回退
- 刚变脏的 risky 组件一律回退:实测「文本内容变更」时字形+描边的墨迹会超出几何盒, 在 clip 下重绘无法与全量逐像素一致(像素回归 step4 曾报 594 px 差异,差异色为文本的描边色)
- 干净且已离屏缓存的 risky 组件不再阻塞(主画布只是 drawImage 不透明位图)
实测:富场景(含旋转组/文本/星形/连线/半透明控制面板)的局部重绘执行次数由 0 → 2, 且 10 步逐像素比对仍 100% 一致。编辑器里控制面板长期存在,旧门控因此几乎永久失效,这是关键收益点。
P0-5 主画布无 HiDPI 处理
状态(2026-09-11):✅ 已完成 ——
ICE.init(el, { dpr }),并把坐标换算与命中统一改走内容盒语义。下方为评估时的原始记录。
证据:ICE.ts:128-131 直接读 canvasEl.width/height,未按 devicePixelRatio 缩放 canvas 尺寸,也未 ctx.scale(dpr, dpr)。devicePixelRatio 仅在离屏缓存路径使用(renderer/ObjectCache.ts:254-256)与 cross-platform/root.ts:57-70 定义。
后果:Retina / 高分屏下主画布发虚;若应用自行把 canvas.width 设为 CSS 宽 × dpr,则 offsetX(DOMEventDispatcher.ts:82-84)与 canvasBoundingClientRect(ICE.ts:130)又会失配,命中偏移。
附加:浏览器对 canvas 尺寸上限不报错、静默失效,大画布/导出场景需自检降级(业界有专门的 canvas 尺寸探测/降级方案)。
P1 · 能力与工程化
P1-1 文本:无换行、无省略、无 grapheme 感知
证据:graphic/text/ICEText.ts:440-465 仅按 \n 拆行;fillText(text, x, y, this.state.width) 传入 maxWidth → 超宽时 canvas 横向压缩字形而非换行。缺:自动折行、maxLines + 省略号、可配 lineHeight、letterSpacing / wordSpacing、RTL / ctx.direction、富文本(混排粗体/颜色)。
进展(2026-09-10):自动换行 / maxLines + 省略号 / grapheme 感知已落地,默认关闭(wrap: false)以保持既有行为不变:
wrap: true按state.width贪心断行;maxLines限制行数,末行逐 grapheme 回退加ellipsis,保证「内容+省略号」不超宽- 断行按 grapheme cluster 切分(优先
Intl.Segmenter,不可用则退化为码点),emoji / ZWJ 序列不被拆开 - 编辑态不换行(
caretIndex按原始文本计,换行会错位) - 仍缺:
wordSpacing、富文本(同行混排粗体 / 颜色 / 字号)
状态(2026-09-13):RTL /
ctx.direction、CJK 断行规则(标点避头尾)与无空格脚本(泰 / 老挝 / 高棉 / 缅甸)词典分词断行已落地(见 17 · i18n 边界);letterSpacing/lineHeight/textDecoration已按「正式配置」落地(量测 / 换行 / 渲染 / SVG 导出同口径);无 DOM 运行时的编辑 / 光标 / 选区已改为 grapheme 感知并支持多行。
grapheme 问题:caretIndex 按 UTF-16 码元计数(ICEText.ts:242-273),ICEPolyLine.ts:787 降级宽度估算用 label.length * fontSize——中文/emoji/ZWJ 序列下光标定位与估算均不正确。对标:主流引擎的新版本已引入 grapheme 感知布局;标准解法是 Intl.Segmenter(Baseline 2024)。
状态(2026-09-13):断行按 grapheme 切分、无 DOM 运行时的编辑(Backspace / Delete / 方向键)与光 标 / 选区定位已改为 grapheme 感知(RTL / 多行 / 对齐一并修正,并新增「按字形命中」
getCaretIndexAt());caretIndex仍是 UTF-16 下标(与 DOM 输入法保持一致),ICEPolyLine的降级宽度估算仍按label.length。
P1-2 文本量测的 HTML 注入与非 DOM 退化(安全缺陷)
证据:graphic/text/ICEText.ts:406 → div.innerHTML = this.state.text.split('\n').join('<br>')。若文本来自用户输入,含 <img onerror=...> 之类内容会执行。
已修复(2026-09-10):改为 div.textContent = text + white-space: pre 承担换行,innerHTML 不再被写入;并加了回归用例锁死(断言 textContent 被设置、innerHTML 未被触碰)。
仍缺:非 DOM 运行时的量测仍是「先停在默认 10×10、首帧渲染后由 calcComponentParams 重算」——可用但首帧前尺寸不准(自定义字体加载完成后可调 ice.remeasureTexts() 或 ICEText.remeasureText() 让已挂载文本重新量测)。
另:ICEText.ts:379-428 在无 document 的运行时(Node / 小程序)无法量测,退化到默认 10×10(除非调用方显式传 width/height)。
状态(2026-09-13):自动尺寸的判定改为「调用 方是否显式给尺寸」(构造参数与
setState都算显式),默认值10不再兼任哨兵——显式写width: 10的文本框不再被悄悄放大。无 DOM 运行时的量测退化行为本身未变。
P1-3 导出与互操作
已有(经代码核实,非缺失):ICE.ts:505-517 有 toDataURL / toBlob;ICE.ts:487-500 有 getImageData / putImageData / createImageData。
缺:SVG 导出(矢量对接刚需)、PNG/JPEG 带背景/切边/多倍图导出、SVG 导入、剪贴板互操作、打印。详见 §6(须以独立 exporter 形式实现)。
P1-4 无障碍零实现
证据:无 ARIA、无隐藏 DOM 镜像、无 tabIndex / focus 环、无 drawFocusIfNeeded。event/ICEEvent.ts:51-58 的 preventDefault / stopPropagation / stopImmediatePropagation 全部是 throw new Error('Method not implemented.');event/DOMEventDispatcher.ts:75 注释自认「当前不支持 DOM 冒泡特性」。
后果:canvas 内容对屏幕阅读器完全不可见;键盘事件只会派发给「上次点中的组件」(DOMEventDispatcher.ts:45-51 的 componentCache),没有 focus 概念。
对标:MDN 明确 <canvas> 只是位图、不向辅助技术暴露绘制对象,仅提供 fallback 文本;W3C 把 canvas 的命中测试、放大、动态焦点列为未解决用例。业界已有引擎用「隐藏 DOM 覆盖层 + accessible 标题/提示/类型/tabIndex 语义标注」的方案解决,可直接借鉴。
进展(2026-09-10,方案 B:引擎只给原语):新增 ICE.getAccessibilityTree() 与 ICE.setFocusedComponent(),
不自建 DOM 镜像层——理由与方案 A 需要解决的问题见 14 · 无障碍原语:
getAccessibilityTree(options):产出可访问节点快照(id / 角色建议 / 可读名称 / 屏幕坐标盒(CSS 像素,含视口换算) / 层级 / 父 id / tab 顺序 / 选中态 / 可聚焦性)。只含已上屏组件;不修改任何组件 state (用缓存的composedMatrix,不调composeMatrix(),避免点集路径dots漂移)setFocusedComponent(componentOrId):键盘事件改为派发给焦点组件;未设置焦点时行为完全不变- 应用层负责 DOM 结构、ARIA、文案、焦点环(参考实现见
examples/a11y/a11y-mirror.html) - 仍缺:若要做方案 A(引擎内建镜像层),需要先解决生命周期同步、多运行时禁用、按需加载包体、 镜像与画布命中坐标一致、焦点环绘制五个问题
P1-5 无插件 / 扩展机制
证据:唯一扩展点是 ICE.registerType()(ICE.ts:343-354),且只服务于反序列化的类名映射。新增图元须手改 consts/COMPONENT_TYPE_MAPPING.ts。
缺三层扩展点(对标业界通行形态:自定义绘制/命中回调;按「画布/节点/边/工具/属性」分层的注册体系;Path 级扩展 + painter 模式):
- 自定义图元注册;
- 自定义渲染 pass / 命中判定;
- 自定义交互工具(控制面板、手势)。
进展(2026-09-10):新增 ICE.use(plugin) / ICE.unuse(name) 与 PluginHost(src/plugin/PluginHost.ts),
开放三层注册点,并按 09-roadmap 边界保持在「原语」层面(不做应用层 UX):
| 层 | 注册点 | 实现 |
|---|---|---|
| ① 组件 | components: { 'my-app:Badge': Ctor } | 宿主代为 registerType(键必须是 canonical namespace:Type),因此自动获得 typeId 反查 → 自定义图元可序列化 |
| ② 渲染 | render(frame) | 每帧调用;坐标系为世界坐标(CTM = dpr·viewport,与组件一致);两条渲染路径都调用,局部帧在 clip 之内 |
| ③ 交互 | tools: [{ id, match(c), create(), exclusive?, onTargetChange? }] | 复用既有 toolNodes:命中 addTool、失配 removeTool,实例跨选中复用;exclusive 命中时禁用内置变换/连线面板 |
- 生命周期:
use幂等(同名不重复setup);unuse撤销渲染回调与工具并调用teardown,components的类型注册保留(反序列化仍可能依赖,撤销会让已存数据失效) - 选中改为统一入口
ICE.setSelection()并同步插件工具;match抛错按未命中处理(不影响引擎) - 仍缺:自定义命中判定注册点(目前只能覆盖
containsPoint)、自定义布局/主题的正式注册协议、 插件依赖声明与版本约束
P1-6 序列化的类型键不健壮
证据:
persistence/Serializer.ts:70写type: component.constructor.name。当前靠rollup.config.mjs:36-39的terser({ keep_classnames: true, keep_fnames: true })兜住压缩;一旦改用普通构建产物,或用户自定义子类名与映射不符,即失败。persistence/Deserializer.ts:54-58对未知类型直接new Clazz(state)(Clazz为undefined)→ 抛错,无跳过/容错。consts/COMPONENT_TYPE_MAPPING.ts:27-39漏了ICERose(另有ICELinkSlot/ICELinkHook)——这些类型存得下、读不回。
方向(2026-09-10 已落地;2026-09-13 补齐命名空间):改为「构造函数 → 注册名」反查(ICE.getTypeId()),与类的 JS 名解耦:
- 已注册类型:序列化写出注册名,terser 压缩改名不再破坏已存数据
- 未注册的自定义类 型:仍回退
constructor.name(保持既有约定,不破坏下游),但记录进Serializer.unregisteredTypes并告警——回退名在下游打包后可能读不回来,静默写出去等于埋雷 - 补齐漏注册的
ICERose(此前存得下、读不回) - 反序列化容错:未注册类型跳过该节点(含子树)并记录到
deserializer.unknownTypes, 不再new undefined(...)抛错导致整份数据打不开 - 版本迁移改为可扩展的
SERIALIZATION_MIGRATIONS(按to升序逐级执行);高于当前版本仍明确抛错 - 无 version 字段的数据继续可加载(但
type必须是 canonical typeId;无 namespace 的旧类名按未注册类型处理)
命名空间化(2026-09-13):反查解决了「改名」,没解决「撞名」——无 namespace 的 typeId 是
一张全局平面表,ICE 家族(引擎 / 实体设计器 / 图表 / 第三方)各自的自定义类型迟早同名。
现在统一为 namespace:Type(ice-render:* / ice-entity-designer:* / ice-chart:* /
第三方小写包名),并把注册表收敛成一套可解释的契约:同 typeId 注册不同构造函数、同构造函数
注册第二个 typeId 都明确抛错;类型名只有 canonical 一种形式——家族仍在发布初期,
因此不为旧的无 namespace 类名维护别名(旧数据里的节点按未注册类型处理);注册表本身改用
无原型对象,getType('constructor') 不会命中 Object.prototype。详见 06 · 序列化。