做了快大半年的在线图设计工具也就是这个 diagram-design 项目总算从“能画几个框连几条线”进化到了可以正经画拓扑图、流程图、架构图的可用状态。这期间踩的坑、推倒重来的设计、跟性能死磕的过程我觉得比最终产品本身更有记录价值。这篇就当是自己项目复盘也顺便给想搞同类 Web 图编辑器的朋友一些可参考的实操细节。diagram-design 本质上是一个纯前端的图形化编辑工具核心能力是让用户在画布上自由创建节点、拖拽连线、框选、缩放同时支持 JSON 数据的导入导出和当前主流图片格式的渲染导出。适合想快速搭一个内部绘图工具的个人开发者也适合需要在一个大平台里嵌入图形编辑能力的团队项目做参考。我写这篇文章的时候项目已经跑通了几万级节点的渲染和编辑所以整篇内容会集中在架构选型、数据模型、渲染交互、自动布局、性能优化这几个方向每个部分我都会把“为什么这么选”和“实际怎么落地”尽量讲透。1. 项目定位与整体架构拆解1.1 核心需求解析一个图编辑器到底要做什么很多人一提到 diagram-design第一反应就是“画框 箭头”。但真要做到好用问题清单一下就铺开了画布需要无线扩展吗节点拖拽要不要吸附对齐连线怎么跟节点边缘而不是中心点锚定框选跨区域怎么处理撤销重做栈怎么设计浏览器崩溃之后数据能不能恢复图表导出的清晰度能不能扛住打印需求。我前期的调研结论比较朴素市面上的通用图工具可以分两类一类是 Tableau / 板类工具那种“画布 自由图形”的轻量方案另一类是专业建模工具那种“强约束 表单驱动”的重型方案。diagram-design 取的是中间态既要支持自由绘图又要提供可编程的数据结构。这个定位直接影响了后续几乎所有技术决策底层数据结构必须支持任意节点类型画布渲染必须独立于业务数据交互层要做到识别手势意图而不是简单响应鼠标坐标。这里有一个容易被忽略的点图编辑器的核心是“编辑器”三个字不是“图”。也就是说重点不仅仅是把节点画出来而是让用户能够编辑这篇图、维护这篇图、导出一篇图。数据模型、状态管理、指令系统、渲染引擎都必须围绕“可编辑、可追溯、可恢复”来设计。这是我在项目早期最大的认知转变。1.2 技术选型为什么这么定技术栈上我最终选了 TypeScript Canvas 2D 自研渲染配合 Web Worker 做布局计算组件层用了类 React 的响应式框架但没有直接上一套现成的图形编辑器框架核心原因有三点。第一可编程性是刚需。图网格的数据结构比如节点、边、画布视图等必须可以直接被业务代码读取和修改这意味着数据层和渲染层要严格分离。市面上的成熟绘图框架大多封装很重你想在每次拖拽结束的时候拿到“哪个节点的连接锚点被拖到了哪个索引位置”往往需要绕很多圈子。第二渲染性能可控。我对比过 Canvas 和 SVG 方案SVG 的优势是 DOM 天然可事件绑定做 100 个节点以内的小图非常轻松但是一旦节点数量上到 3000 以上DOM 节点太多带来的内存和事件代理压力会非常明显。Canvas 全部走绘制指令配合脏矩形和视口裁剪理论上能支持几万节点的平铺展示和基础交互。第三做图编辑器的核心壁垒其实在布局算法和交互细节上与其花大量时间熟悉第三方框架的开发范式不如把精力放到这些核心逻辑上。这个选择让我在后期排查问题时有十足的掌控力代价是初期做了不少渲染基础工作比如坐标变换、命中检测、脏矩形更新这些说实话工程量不小。1.3 整体架构与模块划分项目从模块上拆成了四层层与层之间通过接口约定连接不互相引用具体实现。数据层负责图的业务状态节点的位置尺寸、边的路径、分组的层级结构、画布的视图变换和缩放级别。渲染层只负责把数据层的内存对象绘制出来它不关心对象是节点还是多边形组件只关心拿到什么 Shape 描述就画什么。交互层接收鼠标、键盘、触摸事件转换成标准指令比如 MOVE_NODE、ADD_EDGE、SELECT_AREA然后交给指令系统执行。指令系统再去改数据层并广播“数据已变更”的事件让渲染层刷新。这样分层的好处是显而易见的。就拿渲染层切换来说项目早期我用的是上一版 Canvas 渲染后期想增加离屏渲染优化只需要在渲染层内部做改造数据层和交互层几乎不用动。第二个好处是便于做测试指令系统是纯函数式的输入输出可以单测覆盖不需要开浏览器去模拟用户行为。interface GraphCommand { type: CommandType; payload: Recordstring, unknown; undo?: () void; redo?: () void; }指令系统是所有编辑操作的标准入口类似 git 的思想每一步修改都是可逆的提交。实际开发时我强烈建议从一开始就建立这个抽象否则后期补撤销和重做功能会极其痛苦。2. 数据模型与核心数据结构2.1 图数据模型设计节点、边、分组与锚点图编辑器最基础的数据结构是节点Node和边Edge。节点至少包含 id、x、y、width、height、zIndex 和 type 字段type 决定了渲染层用哪种图形来绘制这个节点。边至少包含 id、sourceNodeId、targetNodeId、sourceAnchor、targetAnchor 和路径字段。只记录 id 而不直接嵌套坐标是数据模型设计里的关键决策。如果边里面直接存了“连接线的起点坐标”那么节点一旦被拖动边的起点坐标就失效了必须手动去同步每一条边的数据。正确做法是让边永远引用源节点和目标节点的 id布局变化后程序根据节点的当前位置动态计算锚点坐标。这就好比导航系统只记录目的地地址而不是记录目的地的经纬度快照每次出发前现算路线才对。锚点这个概念值得展开说一下。节点四周的上下左右中心或者自由位置的几个固定点可以作为边的连接口。实际画图时边不是从节点中心连出来的而是从锚点出发沿着节点边缘贝塞尔曲线连到另一个锚点。如果边从中心连出线会覆盖节点内部的内容很难看。我设计的锚点数据结构interface AnchorPort { id: string; nodeId: string; offsetX: number; // 相对于节点左上角的偏移 offsetY: number; direction: top | bottom | left | right; }方向字段用于自动布局时计算连线的出口走向没有这个字段排布算法很难知道一条边应该从节点的哪个方向出门。分组结构也要在数据模型里预留好。常见做法是给节点增加 parentGroupId 字段组节点本身也是一个 Node只不过它有 children 子节点集合。嵌套分组在编辑时要注意一个不变量子节点的坐标一定是相对父组节点的相对坐标父组移动时子节点的画布坐标跟随变化。这个约束如果不在数据层卡死交互层很容易把坐标搞乱。2.2 序列化与版本兼容让 JSON 既通用又可迁移图数据最终要落盘和导入导出最自然的选择是 JSON。但 JSON 本身只是序列化格式真正的难点是版本兼容。我遇到的一个经典问题是早期保存的 JSON 文件里节点只有宽高没有圆角半径和颜色配置后期增加样式字段后老文件打开后样式字段是 undefined渲染层要兜底给默认样式。解决思路是给整个文件加一个 schemaVersion 字段导入的时候根据版本信息走迁移通道。迁移函数做成纯函数输入老版本的 JSON 对象输出新版本的对象链式调用逐个版本升级。我实际项目中出现了 4 个版本代码大致像这样const migrations { 1: (data) ({ ...data, nodes: data.nodes.map(node ({ ...node, borderRadius: node.round ? 8 : 0, })), }), 2: (data) ({ ...data, edges: data.edges.map(edge ({ ...edge, lineType: edge.dashed ? dashed : solid, })), }, // ...更多版本迁移逻辑 };带上版本号之后画布的核心画布视图参数也要跟着走zoom 缩放级别、panX 平移量、panY 平移量这些字段决定了用户打开文件之后第一眼看到的是哪个区域。没有记录这些的话用户打开文件总是得手动挪到自己的画布中心体验很差。2.3 数据流转从 JSON 到内存对象再到渲染指令一次完整的打开文件过程数据会经过四个阶段原始 JSON 字符串、解析后的 JSON 对象、规范化后的内存模型Map 结构、渲染层消费的绘制指令列表。为什么内存模型要用 Map 而不是数组因为图编辑里高频操作是“给我 id 为 xxx 的节点”数组查一次是 O(n)Map 是 O(1)。一旦项目规模上来每次连线都要查两端节点的当前位置数组遍历的耗时可以直接卡掉交互帧率。我在一次拓扑图导入测试里6000 个节点就用数组实现连线时明显感觉到卡顿换成 Map 之后这个瓶颈立刻消失了。规范化过程中要处理数据校验。节点缺字段补默认值、边的端点引用了不存在的节点则丢弃并告警、坐标切成两位小数避免浮点误差积累。校验逻辑往往在项目后期会越加越多因为用户会通过不同渠道产出格式略不一样的 JSON 文件防御性编程在这里非常值得投入。3. 画布渲染与交互实现细节3.1 自研 Canvas 渲染引擎的技术要点我实现基于 Canvas 2D 的自研渲染引擎时从抽象层面只做三件事收集绘制指令、按视口裁剪指令、批量执行绘制调用。核心接口是一个 draw 函数interface RenderObject { id: string; transform: { x: number; y: number; scale: number }; shapes: ShapeInstruction[]; } type ShapeInstruction | { type: rect; bounds: [number, number, number, number]; fill: string; stroke: string; radius: number } | { type: path; d: string; stroke: string; fill: string; lineWidth: number } | { type: text; text: string; x: number; y: number; fontSize: number; fill: string };绘制指令是序列化的描述结构业务代码改数据层渲染层根据数据生成指令不直接持有 DOM 元素或 Canvas 对象实例这让图形切换样式和重新绘制变得很轻量。多图层渲染是性能优化的重要基石。我把画布拆成三层底图层的网格背景、主图层的内容节点和边、覆盖层的选中框和交互反馈。每次变更只重绘对应层而不是整张画布。比如拖拽节点的时候网格背景和选中框都不用重新生成只需要重绘主图层的内容。这样一帧的绘制量从“所有图像素”降到“移动区域附近的一小片”帧率提升非常明显。3.2 鼠标事件与坐标变换从屏幕坐标到画布坐标Canvas 事件处理最基础也最核心的东西是坐标变换。浏览器给我们的鼠标事件坐标是视口坐标即屏幕内坐标而画布有 pan 偏移和 zoom 缩放所以必须经过一次矩阵转换才能得到画布坐标function screenToCanvas(sx: number, sy: number, view: { zoom: number; panX: number; panY: number }) { return { x: (sx - view.panX) / view.zoom, y: (sy - view.panY) / view.zoom, }; }这个公式是整个交互系统的地基。坐标不对后面所有命中检测、连线的反馈预览全部歪掉。另一个容易掉进的坑是缩放是以鼠标位置为中心还是以画布中心为中心。专业图工具基本都是跟随鼠标位置缩放用户的习惯是“鼠标指着哪里就放大哪里”实现上要先把鼠标屏幕坐标转成画布坐标再按比例调整 panX 和 panYfunction zoomAt(mouseX: number, mouseY: number, newZoom: number, view: View) { const canvasXY screenToCanvas(mouseX, mouseY, view); view.zoom Math.min(Math.max(newZoom, 0.2), 5); view.panX mouseX - canvasXY.x * view.zoom; view.panY mouseY - canvasXY.y * view.zoom; }我自己早期用了一个笨办法先改 zoom 再手动平移让鼠标指向的画布坐标保持不变试了一个多小时总觉得缩放逻辑稳定不了后来坐下来把公式推导了一遍才搞清楚关键就在于同时修改 panX 和 panY顺序问题反而次要。3.3 节点拖拽、连线与框选的关键实现节点拖拽看着简单实际触发的操作很多。一次完整拖拽包含以下几个状态mousedown 时命中检测选中节点、记录鼠标按下时节点初始坐标和鼠标初始坐标、mousemove 时按初始坐标加差值算出新位置、mouseup 时判定是否需要做吸附对齐。吸附对齐是个加分项也是减分项。减分是说对齐逻辑写得不好会让节点像被“粘住”一样拖动不跟手。我的做法是只检测跟鼠标移动方向垂直的若干条网格线比如水平移动时优先对齐横着的参考线而且对齐判定通过后就返回对齐位置不再继续吸附。阈值我设置为 5 像素灵敏度刚好。连线交互的关键在于区分两种意图用户是在已有节点之间建立连接还是想把边拖拽到空白区域创建一个新节点。我用拖拽距离阈值来区分超过 8 像素才进入连线预览状态这时候渲染一条从锚点出发跟随鼠标移动的贝塞尔曲线同时高亮显示可以连接的锚点。如果鼠标落在空白区域在 mouseup 时创建一个新的默认节点并把边连上这个新节点。这个小功能用起来会觉得工具很“懂”实现成本其实不高。框选则是把选择矩形内的所有节点加进选中集合。边界情况是勾选时如果按住 Shift应该是追加选择而不是重新选择。我踩过的一个坑是框选范围内包含被遮挡节点时表现不一致后来明确定义框选只按坐标边界判断不考虑遮挡关系因为为了假想遮挡去做射线检测性能代价完全不划算。4. 自动布局算法的工程落地4.1 为什么需要自动布局从手工排布到可复用编排图编辑里最花时间的事情之一就是手工调整节点位置。用户拖拽几十个节点去适配一种美观的排布方式费时且结果主观。自动布局的价值在于批量生成的图比如 JSON 导入、数据库表结构逆向出来的 ER 图可以一键排布成结构清晰、连线不交叉的状态。我最初决定集成一个开源布局库后来评估后发现很难直接复用因为布局库通常要求输入统一的图对象输出所有节点的坐标但我的数据模型带有分组、锚点、方向这些扩展字段开源库的信息建模不足以表达这些语义。所以在自己项目里集成了一个精简的布局引擎模块实现了两种算法分层布局和力引导布局。4.2 分层布局树形图与流程图的自动化分层布局适合流程和树状结构算法核心思路就是拓扑排序再加上行列分配。第一步做拓扑排序搞清楚节点的层级深度。源节点层数为 0它的下游节点层数为 1以此类推。如果图里有环比如 A 连到 B、B 连到 C、C 连回 A就没办法做严格的分层需要先检测环并打断一条临时边排完布再把临时边恢复。第二步做层内排序目的是尽量减少连线交叉。我用的是启发式方法先按入度排序然后反复把同一层节点按“连接到下一层节点的顺序”重新排列迭代若干次。实测下来交叉数能减少约 60%效果已经能满足大部分流程图的可读性要求。第三步分配坐标。每个节点纵向坐标由层数乘以固定间距决定横向坐标由同层节点的索引顺序决定。为了保持布局紧凑不固定画布横向宽度而是按该层最大节点宽度动态求和。分层布局的输出是一个蕴含层级语义的坐标分配处理完之后再用贝塞尔曲线连接边。因为层级关系明确边的走向可以设为垂直正交模式视觉上比任意曲线更符合流程图的标准观感。4.3 力导向布局通用图的物理模拟力导向布局适合没有明显层级关系的图比如社交关系、依赖关系复杂度高的网络图。核心思想是把每个节点看作物理粒子节点之间连边类似弹簧没有边的点会被均匀力推开整体收敛到能量最低的位置。我维护了三类力弹簧力沿边方向长度越偏离理想距离受力越大、库仑斥力所有节点之间互相排斥距离越近力越大、重力把所有节点轻微向画布中心牵引防止整体散出视野。每帧迭代更新function tick(graph, positions, params) { graph.nodes.forEach(n1 { let fx 0, fy 0; graph.nodes.forEach(n2 { if (n1.id n2.id) return; const delta distance(positions[n1.id], positions[n2.id]); const repulse params.repulsion / (delta * delta); fx - (positions[n2.id].x - positions[n1.id].x) / delta * repulse; fy - (positions[n2.id].y - positions[n1.id].y) / delta * repulse; }); graph.edges.forEach(e { if (e.sourceId ! n1.id e.targetId ! n1.id) return; const target e.sourceId n1.id ? e.targetId : e.sourceId; const delta distance(positions[n1.id], positions[target.id]); const spring params.springStiffness * (delta - params.idealLength); fx (positions[target.id].x - positions[n1.id].x) / delta * spring; fy (positions[target.id].y - positions[n1.id].y) / delta * spring; }); velocities[n1.id].x fx * damp; velocities[n1.id].y fy * damp; }); // 更新位置并限制迭代次数 }三层嵌套循环的时间复杂度接近立方阶节点多了很容易成为性能杀手。实测在 300 个节点、800 条边时JavaScript 直接跑力导向迭代明显掉帧。后来我把布局计算挪进了 Web Worker主线程稳定 60 帧布局计算在后台完成完成后把坐标数据回传这个分工很有效。4.4 布局参数调优与实战示例布局算法不是配好参数就能通吃所有场景我总结出几组经验参数场景分层布局间距力导向刚度迭代次数系统架构图纵向 80px横向 120px不使用力导向不适用数据库 ER 图纵向 100px横向 160px不使用力导向不适用社交关系图不适用0.02 刚度斥力 500400 次依赖关系图不适用0.015 刚度斥力 800600 次参数表不是一个死规矩它只是我项目里的默认值。跑布局算法时最好出一个“预览 应用”的流程用户可以先看一轮结果觉得不满意再调整参数。自动布局功能如果做成一键定死往往是功能上线两周后就没多少人使用了相反“可干预、可调整、可回退”的体验会让功能复用率提高很多。5. 性能优化实录5.1 瓶颈定位从 Draw Call 到内存分配diagram-design 开发到了中后期我遇到最大的性能瓶颈出现在打开大文件的时候。那次我用脚本生成了一个包含 12000 个节点、16000 条边的模拟拓扑图文件导入后画布渲染几乎卡成幻灯片。先别急着优化渲染代码第一步要做的是确认瓶颈在哪个环节。我使用浏览器 performance 面板抓取了一段 Heatmap发现渲染帧时间里占比最大的是计算节点坐标变换和绘制填充文字其次是事件处理中的命中检测需要遍历所有节点边界。分析下来核心瓶颈其实有三个方向遍历成本太高每个操作都要全量遍历节点数组或边数组。绘制指令没有复用每次重绘都重新计算一遍所有样式即使样式根本没变。事件命中检测直接用线性扫描全部节点没有空间索引就无论如何都快不了。5.2 针对性优化视口裁剪与图元批量重绘优化的第一刀砍向视口裁剪。Canvas 的 drawRect 本身很快但前提是不要绘一大堆在视口之外的图形。我加了一个简单的可见性判断function isVisible(rect: Rect, viewport: Rect) { return rect.x viewport.x viewport.width rect.x rect.width viewport.x rect.y viewport.y viewport.height rect.y rect.height viewport.y; }在 12000 个节点的场景下一屏大概只能看到 300 到 400 个节点裁剪后绘制指令量降低到原来的约 3%那帧率立刻就好了很多。第二刀优化了重绘策略。原先的做法是任何节点变化都重绘整层拖拽一个节点时周围 12000 个节点也要一起重新绘制自然是浪费。我引入了区域脏矩形鼠标按下到抬起这个时间段内记录的移动最小包围盒重绘时只针对这个包围盒放大 20% 的矩形区域。Canvas 配合 save/restore 的 clipping 就能做到局部重绘。第三刀是事件命中检测用格子索引。我把画布按 256 像素划分格子节点登记到它覆盖的所有格子中鼠标移动时只需要查鼠标坐标所在格子的候选节点从全量扫描变成局部小范围扫描从数万次比较降到个位数的比较class SpatialGrid { private grid new Mapstring, string[](); private cellSize: number; insert(nodeId: string, rect: Rect): void { const cells this.getCoveredCells(rect); cells.forEach(cell { if (!this.grid.has(cell)) this.grid.set(cell, []); this.grid.get(cell).push(nodeId); }); } query(rect: Rect): string[] { // 查覆盖格子内节点的交集 } }这套网格索引刚开始只服务于鼠标命中检测后来发现区域框选和视口裁剪也都能利用它复用率很高。5.3 优化后的数据表现与基线参考优化完成后我重新跑了一组基线数据作为自己的回归依据也推荐任何一个图编辑器项目都建立这样的基线测试避免以后改出性能滑坡还没察觉。优化前的性能表节点数全量重绘帧耗时命中检测耗时整体交互帧率100018ms0.6ms55fps500080ms3.2ms12fps12000220ms8ms4fps加了视口裁剪和脏矩形之后节点数视口重绘帧耗时命中检测耗时整体交互帧率100012ms0.02ms60fps500018ms0.03ms60fps1200022ms0.04ms60fps可以看到大文件场景从原来几乎不可用恢复到了流畅可编辑的水平。但另一边也暴露出新瓶颈就是文字数量变大后 Canvas 的 fillText 开销撑起来了。后面我又做了一层文本离线渲染成位图再贴回画布的处理这个技巧在节点很多、文字很短的情况下堪称救命优化。6. 常见问题与排查经验速查表6.1 高频问题清单与脚本化排查开发图编辑器这类交互密集应用问题往往藏在状态同步和坐标计算上面我把自己清过的典型问题整理成一张速查表。现象根因处理方式节点拖到边缘后松手结果位置跑偏坐标变换中用了缩放后的偏差值没有先除以 zoom所有屏幕坐标进画布坐标前统一做 screenToCanvas拖动节点时边上文字位置不同步边文字坐标只计算过一次在每次节点移动事件里重新计算边的文字位置并标记重绘撤销一步后节点还在但连线断了撤销逻辑只保存了节点快照忘了边数据指令快照保存节点和边的完整版本号框选后按键删除有的节点删不掉选中集合里面有被 Group 授权的子节点删除逻辑没处理选中集合删除前先展开分组导入 JSON 后节点坐标全在左上角JSON 没有保存 view 参数默认 view 是 0 坐标迁移逻辑默认设置 pan 和 zoom力导向布局迭代到 600 次后还在震荡参数里阻尼值设得偏小弹簧刚度偏大增加阻尼系数到 0.6降低弹簧刚度到 0.02这类问题大多不复杂难在复现条件很隐蔽。我自己的经验是给每个交互动作都写一个录屏回放 事件序列 dump 的调试入口只要用户反馈一个 Bug就让他一键导出事件序列我可以按序列回放整个操作直接定位到出问题的那一步。这个工具开发成本不高但排查效率至少提升一个数量级。6.2 印象最深的三个 Bug定位过程复盘第一个是“文本框缩放时文字消失”。现象是缩小画布到 0.5 倍以下时部分节点的文字不见了。排查发现是因为渲染层为了性能在低缩放级别跳过了 drawText 调用初衷是减少绘制量但跳过的条件判断写成了“if (zoom 0.5) return”导致文本图层从绘制队列彻底移除。修复方案是改为判断视口尺寸内文本位图是否已生成如果已生成就直接 drawImage没有生成就走 fallback 画文字。第二个是“撤销后边线变成岔路”。这问题极其隐蔽用户操作是拖拽 A 节点使得 B 节点和 C 节点之间的连线被重新路由然后撤销。多次撤销后偶现连接线变成奇怪的折线。定位后才发现连线路径在节点移动时被动态重新计算并缓存到了边数据字段里面但撤销恢复的是旧的路径字段而新路径计算逻辑认为路径字段已经是最新的就不会重新算。本质是缓存一致性被破坏。修复方式是去掉边数据中的路径缓存路径完全实时计算换来一致性但增加了一点计算量。第三个是“框选时特别大的节点选中区域错位”。大节点宽 2000 像素按格子索引插入时正确查询时用鼠标坐标直接查到了相邻格子于是命不中这其实是格子索引设计缺陷框选时应该查询粉红线覆盖的所有格子而不是只查鼠标落点所在的单格。修复后把 query 函数改成用矩形相交查询框选大节点就能稳定命中了。6.3 开发与维护图编辑器值得养成的几个习惯一些经验放这里对后来者应该有参考价值。第一任何坐标变换公式都要写成纯函数并做单测。我吃过没有坐标单测导致缩放中心移位的亏后来把所有数学变换逻辑集中到一个模块里主要函数全部补了单测之后这类问题很少复发。第二图层重绘时机的控制要有统一入口。我原来各个模块想 flush 就 flush导致同一帧内重复绘制了三四次。后面改成统一调度器模式只有关键输入如鼠标移动才会触发下一次渲染其他任何状态更新只标记重绘区域一个 requestAnimationFrame 周期内最多执行一次绘制。第三图数据文件导出要带完整环境信息。画布尺寸、节点数量、schemaVersion、view 快照都放进 JSON这样即便是未来换了实现老数据也能轻松迁移。不要只保存节点数组不然总有一天你会吃数据显示不完整的亏。最后想分享一个我踩过几次坑之后学到的真香操作给每个节点和边加一个 updatedAt 时间戳字段。最初觉得这是冗余信息后来做增量保存、多人协同的冲突合并、以及异常数据的排查都要靠这个时间戳来追责和比对价值超出预期。反正字段不参与渲染序列化成本可以忽略。对这种长期维护的项目来说多留一点元数据永远比少留好。