OpenMontage这个项目,一开始真的没想过要做成一个可以分享出来的开源工具。它起源于我在日常工作中近乎失控的碎片化:浏览器标签页常年三位数,IM窗口里置顶了无数条“重要”消息,公司内部看板、日志平台、监控大盘各开一套系统,每个系统都有自己的URL、账号和界面语言。为了盯一个发布流程,我常常要在六七个页面之间来回切换,一天下来人累得不行,但真正处理事情的产出并没有多少。
OpenMontage的定位很简单:一个自托管、可自我定制的“信息拼贴工作台”。你可以把任意网页、RSS流、监控图表、自定义脚本输出,甚至是AI生成的摘要卡片,当作一个个独立的组件,自由地摆放在一张无限大的画布上。它有点像把Notion的page、各类dashboard和“无限画布”的概念揉在一起,但核心目标只有一个:把所有需要盯的信息,放到一屏内,按照你的思维习惯排列好。
这篇文章会从需求拆解、架构设计、核心交互实现、部署细节到实战案例,完整复盘我做这个项目的全过程。如果你也在为“信息太多、切换成本太高”头疼,或者想自己搭一个真正顺手的工作台,这篇文章应该能给你不少可以“抄作业”的东西。
1. 从“标签页地狱”到无限画布:OpenMontage解决的真实痛点
1.1 碎片化办公的真实场景
我做这个项目之前,先记录了自己一个上午的工作动线:打开电脑,先看企业微信里的未读消息,再切到监控平台看一眼服务是否健康,然后去日志平台翻发布日志,接着打开公司CRM处理几个审批,中途还要抽空盯一下某个数据同步任务跑完了没。这几个系统之间没有任何关联,我唯一能做的就是把它们全部铺开在桌面上,靠窗口管理器把屏幕硬切成几块。
问题在于,窗口管理器切出来的“网格”是死的。监控图表的刷新频率、内容密度、需要占用的视觉空间各不相同。有的信息只需要一个很小的红绿灯状态灯,有的信息需要一块很大的表格区域,还有的信息是临时冒出来的、看完就要关闭。用固定网格去组织动态信息,本质上还是在用“页面思维”做事——只不过把多个页面换成了多个窗口。
更麻烦的是协作场景。团队里常见的做法是拉一个“作战群”,把机器人报警、发布通知、客户反馈全扔进去。群消息确实集中了,但信息是线性流动的,几分钟就被刷上去。想回看昨天的某条报警和当时的处理状态,你得在历史记录里翻好久。这不是工具不够多,而是缺少一个“空间容器”,让不同类型、不同生命周期、不同重要等级的信息能按照人的空间记忆习惯来组织。
1.2 现有方案为什么让人难受
动手之前,我把市面上常见的方案都试了一遍,也说下真实的感受。
Notion风格的知识库和看板:适合整理文档和结构化数据,但它的页面仍然是“页面”。我想把实时监控页面和内部工具页面以卡片形式自由拼贴,Notion做不到,嵌入iframe的支持也很有限,而且编辑态和浏览态的割裂感很明显。
Dashy、Glance这类自托管导航页:它们能聚合链接和部分widget,默认带有网格布局,部署也很方便。问题在于布局太规整,本质上是一个美观的“收藏夹”。我想把某个监控系统的局部区域直接嵌进来,或者把一个自定义脚本输出渲染成卡片,这类工具的扩展方式要么是受限的插件API,要么需要hack源码,改造成本不小。
传统窗口管理器:比如i3、微软的PowerToys FancyZones。它解决的问题是“窗口排列”,前提是每个信息源必须先有个窗口。而很多信息源是网页内嵌模块、命令行输出、API返回的JSON,根本没有独立窗口。即便有,窗口的生命周期和“打开/关闭”成本也很高,不适合长期挂墙式的信息展示。
浏览器扩展式的“新标签页”方案:类似Infinity Tab。它们擅长把新标签页变成导航页,但依旧受限于浏览器标签页的呈现模型,不支持真正的无限画布缩放,也不方便做自定义数据组件。
1.3 我想要的那个终极形态
经过这些尝试,我确定了自己真正想要的形态:一个自托管的Web应用,打开后就是一张无限大的画布;画布上没有预定义的行列结构,组件可以自由移动、缩放、层叠;组件能承载三类信息源——普通网页嵌入、后台服务渲染的数据卡片、通过API拉取的第三方数据;组件之间可以互相通信,比如点击A卡片可以联动刷新B卡片;整张画布的布局和组件配置持久化存储,下次打开还是老样子;支持团队共享同一张画布,每个人看到相同的信息空间。
这就是OpenMontage的雏形。它不追求做成一个“新浏览器”,而是做一个“信息的装订台”。就像剪辑师把镜头素材摊在时间线上一样,我可以把各种信息源当成“素材”,在画布上排出一部自己能随时读懂的“信息电影”。
2. 系统架构设计:画布层、组件层与数据层的分工逻辑
2.1 三个核心抽象:CanvasWidget、DataSource、Presenter
架构设计阶段,我给自己定了一条原则:组件画布和组件的内容来源必须解耦。画布只负责“框定一块区域”,至于这块区域里显示什么内容、内容从哪来,由另外两层决定。
所以我在代码里定了三个抽象概念:
- CanvasWidget(画布挂件):是画布上的容器实体,记录位置、尺寸、层级、旋转等几何属性,同时持有指向某个组件类型的引用。
- DataSource(数据源):指组件内容的后端来源,可以是一个URL、一个REST API描述、一个RSS地址、一段可执行的定时任务脚本,甚至是一个WebSocket订阅。DataSource层的职责是提供统一的数据获取与刷新接口。
- Presenter(呈现器):指真正把数据渲染成可视内容的前端代码。同一个数据源,可以有不同的Presenter,比如同一个监控接口,可以渲染成数字卡片、折线图卡片或表格卡片。
这三个概念的配合关系是:CanvasWidget只决定“这块区域在哪”,DataSource决定“这段区域的数据来自哪”,Presenter决定“这段数据长什么样”。好处非常明显——我可以把同一个数据源放到画布的多个位置,用不同的Presenter独立展示,或者在不改动画布布局的情况下,只替换Presenter组件就完成一次视觉升级。
这种分层在最初看起来有点过度设计,但实际开发中帮了大忙。尤其是后来团队想把同一个报警流水数据源,既作为总览数字看板,又作为详细滚动列表时,我只需要复用DataSource,新增一个Presenter就行了,画布上的位置信息完全不受影响。
2.2 无限画布的坐标系、缩放与“世界窗口”
无限画布的核心不在于“画布很大”,而在于你如何描述一个“无限”的空间。我采用的是最常见的二维世界坐标系:所有CanvasWidget都以逻辑坐标(worldX, worldY)定位,单位是逻辑像素。而屏幕上实际显示的区域,则通过一个视口(viewport)参数来描述:视口中心点对应世界坐标系中的位置,视口缩放比例zoom决定从世界坐标到屏幕像素的映射关系。
实际渲染时,每个组件的屏幕位置都由同一个变换函数实时计算:screenX = (worldX - viewportX) * zoom + canvasOffsetX,screenY同理。这样我只需要在视口变化时对全部组件做一次坐标变换,而不是修改每个组件的布局属性。这个设计是画布类项目的基本功,但真正容易忽略的是精度问题。
我在开发中遇到过很有代表性的精度坑:当zoom放大到200%以上、画布滚动到很远位置时,直接用浮点数做坐标累加会出现像素漂移,组件边缘会慢慢变得模糊错位。解决办法是每次变换都基于“世界坐标原始值”重新计算屏幕坐标,而不是把上一次的屏幕坐标做增量叠加。简单说就是:永远从world坐标出发,不要从screen坐标演算世界坐标。
2.3 组件注册表:用JSON Schema描述组件,而不是写死代码
画布上的组件不能是“铁板一块”的代码,否则每加一种卡片都要改画布核心。我把组件做成了可注册、可配置的插件系统。每个组件类型在注册时,不仅要有React组件本身,还要附一份JSON Schema,描述它支持哪些配置项、每个配置项的类型和默认值。
比如一个“RSS阅读器”组件的Schema会声明两个配置:feedUrl(字符串类型,必填)和maxItems(整数类型,默认10)。用户在画布上选中这个组件做配置时,编辑器直接根据Schema渲染出表单,组件代码本身不需要关心表单怎么呈现。冷知识是:JSON Schema原本是给表单和API校验用的,现在我把它的能力扩展到了组件配置系统,等于免费获得了配置项的校验、类型检查、默认值补全和编辑器表单自动生成四样东西。
这样做还有一个隐藏好处:画布的持久化存储变得非常干净。CanvasWidget记录的类型、配置和位置,全部是JSON可序列化的数据,跟渲染代码无关。就算某一天我把整个前端框架从React换成别的,画布数据也不需要迁移。
3. 画布核心交互的难点与我的实现方案:拖拽、网格磁吸与组件通信
3.1 拖拽缩放与网格磁吸:事件流设计
画布类应用最容易翻车的就是拖拽交互。OpenMontage的组件不仅支持拖拽移动,还要支持拖拽调整大小,而且在缩放状态下拖拽必须依然准确。我把整个交互抽象成了三段式的事件流:pointerdown时记录起点世界坐标和组件原始几何信息,pointermove时计算当前世界坐标与起点的差值并实时更新组件的预览位置,pointerup时将最终的几何信息提交到store并持久化。
看起来很简单,但有两个细节直接决定了手感。第一是事件绑定对象。如果你把pointermove监听绑在组件DOM上,拖拽速度稍快就会因为指针移出组件区域而丢事件。我最终把move和up监听都挂在全局window上,pointerdown阶段再通过组件DOM的getBoundingClientRect和当前viewport换算得到世界坐标。第二是针对“拖拽过程中的对齐反馈”,不能让组件一下子跳到网格上,而是提供连续的位置预览、同时实时计算它和最近网格线的偏移量,当偏移小于GRID_SIZE的一半(默认10像素)时,才显示出磁吸吸附效果。
网格磁吸的算法我也调整过好几版。第一版用的是“拖拽松手后再对齐”,测试下来手感发涩,总感觉组件是突然被“吸过去”的。后来改成在拖拽过程中持续计算吸附目标位置,并且让组件的预览位置始终是“平滑位置 + 吸附修正量”,这样用户既能感觉到网格的存在,又不会有突兀的跳变。简单说:磁吸是一种渐进式反馈,而不是最终快照。
3.2 组件间通信:SharedStore与事件总线的取舍
组件如果各管各的,画布就只是一堆卡片拼盘,谈不上“工作台”。OpenMontage从设计之初就考虑了组件间通信,但我不想把组件做成“必须互相知道对方存在”的紧耦合结构。
我采用了一种混合方案:中心化的SharedStore + 事件总线。SharedStore是一个全局可观察的状态对象,任何组件都可以通过声明依赖Key来订阅其中对应的数据片段。比如“部署状态”组件会在SharedStore里写入当前构建状态,“发布日志”组件订阅这个Key,一旦状态从building变成success,日志组件就自动拉取最新日志并滚动到底部。这是“数据共享”路径。
事件总线负责“行为触发”路径。组件可以发布事件,也可以订阅事件。比如点击“全局刷新”按钮组件时,发布一个refreshAll事件,所有注册了事件监听的组件都会收到通知并各自决定是否刷新。这种松耦合设计的好处是:新增一个组件时,不需要修改任何已有组件的代码,只需在新组件里声明它要订阅的事件和要读取的store Key即可。团队后来加了一个“暗黑模式切换”组件,通过事件总线广播给所有组件,大家各自调样式,画布核心一行没改。
3.3 为什么不用现成的grid布局库
很多做dashboard的项目都会选用react-grid-layout这类现成库,我在原型阶段也试过。它的网格系统成熟、拖拽和缩放处理完善,文档也不错,但我最终没有用在OpenMontage里,原因有两个。
一是自由层叠的需求。react-grid-layout默认是流式网格布局,组件可以拖拽但会互相推挤,不允许交叉层叠。而我的目标画布上,有些信息卡片是半透明的状态浮层,需要悬在图表卡片上方显示告警信息,流式布局无法表达这种空间关系。
二是我需要的是“世界坐标 + 缩放”模型,而grid库的布局计算是基于容器像素的。如果硬把它包在缩放层外面,要让组件在缩放后依然对齐网格,要做很多hack。与其花时间对抗框架的默认行为,不如自己实现一个精简的画布引擎,控制力反而更强。事实也证明,自己写的这套核心只有几百行代码,但每一次交互优化都改得非常顺畅。
4. 数据模型与自托管部署:SQLite、Docker与反向代理的实操细节
4.1 数据模型设计:布局快照与组件配置分离
数据模型相对简单,但有一个原则我坚持下来了:布局数据和组件配置数据分开存。核心表结构大致如下:
| 表名 | 字段 | 说明 |
|---|---|---|
| canvases | id, name, created_at, updated_at | 画布实例 |
| widgets | id, canvas_id, type, world_x, world_y, width, height, z_index | CanvasWidget的几何数据 |
| widget_configs | widget_id, schema_key, value_json | 每个组件的配置项,JSON存储 |
| data_sources | id, type, url, config_json | 可复用的数据源定义 |
| audit_log | id, action, target_type, target_id, created_at | 操作审计日志 |
这样拆分后,复制一张画布变得异常简单:只需要复制widgets表里的几何数据和widget_configs里的配置,组件和画布之间没有任何硬编码的外键依赖。团队协作时,如果某个组件的DataSource因为内部系统变动而失效,我可以只更新data_sources表里对应的那条记录,所有引用该数据源的组件会统一感知,不需要逐个改配置。
这里也顺便提一个我在真实部署中得到的教训:SQLite在这种场景下完全够用,甚至比PostgreSQL更省心。OpenMontage的并发写入频率很低的,主要还是读取画布快照,偶尔有几个人同时编辑布局。SQLite的WAL模式配合better-sqlite3的同步API,读多写少的负载完全没压力。如果你只是自托管给团队十几个人用,就别为了“听起来更专业”去上PostgreSQL,那只会增加运维负担。
4.2 Docker Compose最小部署步骤
部署我选的是Docker Compose,整个应用打包成一个镜像,前端静态资源由Fastify直接托管,不需要单独跑Nginx容器。用一个最小化的docker-compose.yml可以这样写:
version: "3.8" services: openmontage: image: openmontage/om:latest container_name: openmontage restart: unless-stopped ports: - "3320:3320" environment: - NODE_ENV=production - DATABASE_PATH=/data/openmontage.db - SESSION_SECRET=请改成随机长字符串 volumes: - ./data:/data部署步骤就三步:docker compose up -d拉起来,浏览器访问http://服务器IP:3320完成初始化,然后创建一个管理员账号。真正的坑集中在权限上,如果你没有任何容器运行经验,很容易遇到“容器起来但写不进SQLite”的问题。大多数情况下是由于宿主机上的./data目录当前用户不是容器内默认用户所致,手动执行chown给目录授权就能解决。注意不要图省事用chmod 777,安全风险不值得,正确做法是给容器指定一个固定的UID,并与宿主机目录所有者保持一致。
4.3 反向代理与HTTPS的配置建议
如果OpenMontage只在局域网使用,直接IP加端口也可以,但建议还是套一层反向代理。Nginx配置大概长这样:
server { listen 443 ssl; server_name om.example.com; ssl_certificate /etc/nginx/certs/om.crt; ssl_certificate_key /etc/nginx/certs/om.key; location / { proxy_pass http://127.0.0.1:3320; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } }注意两个容易被忽视的细节。第一是必须带上Upgrade和Connection这两个Header,因为OpenMontage使用了WebSocket接口来做组件间的实时数据推送,而WebSocket在反向代理下必须做协议升级支持。第二是要在应用环境变量里声明信任代理头,具体做法是在docker-compose里加TRUST_PROXY=true,否则Fastify会认为所有请求都来自127.0.0.1,日志记录和限流策略都会出错。
跨域问题也要提前规划。组件里经常要嵌入其他系统的iframe,或者通过API拉取外部数据。如果组件和后端系统不在同一个域名上,就需要对目标系统配置CORS允许来源。这里我建议在OpenMontage里设置一个“受信任来源”白名单,而不是直接允许所有跨域请求。虽然操作上麻烦一点,但自托管工具一旦暴露到公网,CORS配置失守就意味着画布上的所有数据可能被任意网页读取。
5. 实战:我如何把OpenMontage搭成团队的数据总台
5.1 场景设计与组件规划
项目跑通以后,我拿真实团队场景做了一次完整落地。当时团队每天早晨都要先看一遍:夜间批量任务是否成功、主服务的错误率曲线、待处理工单数量、最近十条发布日志。以前是分四个系统查看,大概要十五分钟;现在OpenMontage上一张“晨会总台”画布就解决了。
我规划了这样几个组件:
- 任务运行状态灯:一个只有红绿黄三种颜色的大圆点组件,数据来自任务调度系统API,定时每30秒轮询一次。
- 错误率趋势图:用视觉上更紧凑的迷你折线图组件展示最近24小时错误率,挂在监控平台的数据接口上。
- 工单数字卡片:显示当前待处理工单数和最老工单的等待时长,数据来自CRM系统。
- 发布日志滚动条:只显示最近十条,带自动滚动到底部,数据来自CI系统事件流。
组件之间的通信在这里体现出来了:“任务运行状态灯”一旦由绿变红,会往SharedStore里写一个task_failed=true的状态,右侧的“工单数字卡片”会订阅这个状态并在标题旁显示一个警示角标。这个过程不需要人工干预,信息的联动是自动产生的。
5.2 手写一个自定义Widget:从REST接口到可视化卡片
为了让读者对OpenMontage的扩展机制有直观感受,我用一个真实例子演示如何写自定义组件。假设我想做一个“数据库连接数监控”的小卡片,它要从后端的REST接口GET /api/db/status拉取数据,然后渲染出当前连接数和峰值。
在OpenMontage里,这个组件分为三部分。首先是数据源描述,在后台界面创建一个DataSource配置:
{ "type": "rest", "url": "http://db-admin.internal:8080/api/db/status", "method": "GET", "refreshInterval": 30000, "headers": { "X-Internal-Token": "改为自己的令牌" } }然后是Presenter的注册代码,用React函数式组件实现。如果组件需要配置项(比如要设置告警阈值),就通过useWidgetConfig钩子读取:
import { useDataSource, useWidgetConfig } from '@openmontage/react'; export function DbStatusCard() { const threshold = useWidgetConfig('alertThreshold', 80); const { data, error, loading } = useDataSource('db-status'); if (loading) return <div className="card-center">加载中...</div>; if (error) return <div className="card-center error">接口异常</div>; const current = data?.currentConnections ?? 0; const isOver = current > threshold; return ( <div className={`db-card ${isOver ? 'db-card--alert' : ''}`}> <div className="db-card__label">数据库连接数</div> <div className="db-card__value">{current}</div> <div className="db-card__peak">峰值: {data?.peakConnections}</div> </div> ); }最后在组件注册表里声明这个组件,关联上JSON Schema,让编辑器知道这个组件有哪些可配置项。整个过程不需要改画布核心代码,组件代码独立在任何git仓库里都可以。我建议把自定义组件都放到独立目录,并保持“只依赖开放API”的原则,这样未来升级OpenMontage本体时,组件不会碎一地。
这里有一个我在实际开发中觉得非常值得强调的经验:自定义组件的数据拉取不要写在useEffect里自己管,而是统一交给DataSource层。原因很简单,自己管的写法会在组件每次重渲染时都重复发起请求,而且组件卸载后容易产生内存泄漏和请求竞态。统一走useDataSource钩子,数据缓存、定时刷新、错误重试这些通用逻辑都由框架兜底,组件代码能保持很纯的渲染逻辑。
5.3 性能调优:虚拟化渲染与刷新型号策略
画布上组件多了以后,性能问题一定会出现。我第一次放了45个组件,滚动画布时明显感觉到掉帧,放大缩小时尤其严重。做了一些内存检查和React Profiler分析后,把优化重点放在了两块。
第一是视口虚拟化。无限画布的屏幕区域是有限的,视口外的组件其实根本不需要渲染DOM。我给每个CanvasWidget加了一个可见性判断:只有与当前屏幕视口矩形相交的组件才会被挂载到组件树上,视口外的组件用一个占位空div替代。这样一张大画布哪怕有一百个组件,实际渲染的通常只有十几个,React的重渲染负担瞬间降下来了。注意虚拟化弹入弹出时要注意组件内部的state是否会丢失,我采用的是“保留组件实例、仅隐藏不可见组件”策略,并把每个组件包在memo里,避免父组件状态变化时所有组件盲目重渲染。
第二是组件数据刷新的“节流”。不是所有组件都需要同样的刷新频率。我最后给DataSource加了三档刷新策略:实时档(10秒内,用于核心监控)、普通档(30秒,用于一般指标)、低频档(5分钟,用于报表类卡片)。同时,所有定时刷新都基于后端的配置下发,而不是前端写死,这样团队调整刷新频率时不需要重新发布前端。这里我特别提醒一点:刷新型号不要单纯叠加,很多人在做监控类应用时会犯“每个组件一个定时器”的毛病,这样整体会变得碎片化,还要处理大量定时器的重启。正确做法是让OpenMontage统一管理一个调度器,组件到期时只触发需要更新的部分。
实测下来,这套优化之后,45个组件的总台在普通办公笔记本上保持流畅,CPU占用稳定在5%以下,内存也只在卡片切入切出时有小幅波动。
6. 开发过程中踩过的坑:拖拽更新风暴、渲染闪烁与事件系统的翻车记录
6.1 拖拽更新风暴:状态切分与批量提交
第一个大坑,也是让我返工最多的一个,是拖拽时的“更新风暴”。最初的实现里,拖拽移动会实时调用全局store的updateWidgetPosition方法,每帧可能触发二三十次更新。这个状态一更新,画布上所有订阅了widgets列表的组件全部重新渲染。刚开始组件少还能忍,组件一多,拖拽手感直接变成“拖一步卡三下”。
排查过程花了不少时间才定位到根因:不是React本身慢,而是我把“拖拽过程中的位移预览”和“拖拽结束后的布局持久化”混在同一个状态管道里了。拖拽过程中需要的只是组件的绝对坐标快速变化,它只要影响被拖拽组件自身的渲染就够了,根本没必要让所有组件都知道“有个组件正在被挪位置”。
我的修法是把组件状态拆成两个层面:第一个层面是“拖拽预览状态”,它独立维护当前拖拽中的组件ID和实时位置,每次pointermove只更新这个轻量状态,并且只触发被拖拽组件自身的重渲染;第二个层面才是“稳定布局状态”,组件的位置值只在pointerup时批量提交一次,提交之后再统一触发所有关联组件的联动更新。拆完之后,拖拽流畅度大幅提升,卡顿消失了,而且由于提交是批量的,React的re-render次数也少了很多,画布整体的帧率恢复到了接近原生应用的水平。
6.2 组件刷新时的渲染闪烁:版本号与竞态处理
另一个让我印象深刻的bug是组件快速刷新时的“旧数据覆盖新数据”问题。现象是:数据源的刷新间隔很短(比如5秒),如果在一次请求还没返回时用户手动触发了刷新,第二次请求先返回了,紧接着第一次请求也返回了,结果组件的画面先是显示新数据,然后又“闪”回旧数据,视觉上非常明显,而且容易误导人以为是数据源出了故障。
这就是典型的异步请求竞态。我一开始用简单的“开关变量”来处理,比如请求前加锁、请求完成后解锁,但很快发现这种写法锁不住并发请求,两个请求之间的先后顺序没法控制。最后换成了业界成熟的版本号方案:组件每次发起一个新的数据请求时,在本地维护一个请求版本号,只有当前最新版本号的响应才允许写入SharedStore和触发渲染。旧请求即使返回了,也会因为版本号不匹配被直接丢弃。再加上AbortController主动取消旧请求,双重保险之后,闪烁问题彻底绝迹。
代码层面大致是这样:
const requestSeq = useRef(0); async function fetchData() { const current = ++requestSeq.current; const result = await api.getStatus(); if (current !== requestSeq.current) return; // 旧响应,丢弃 setData(result); }这段代码写起来没几行,但值得放到组件库的公共封装里,而不是每个组件都重复实现一遍。
6.3 大画布卡顿的终极排查:坐标浮点误差与事件脱离的联动问题
最后这个坑是隐藏比较深的。当画布多次缩放、平移后,某些组件会出现视觉上的“偏移漂移”,也就是组件自身生成的内容和它所在的位置框对不上。起初我以为是CSS transform精度问题,排查了很久才发现,根因是坐标计算链路里混入了浮点误差。
具体是这样的:我做拖拽时,为了方便计算,把“当前拖拽距离”存成了像素偏移,然后叠加到组件上一次的世界坐标上。第一次移动没问题,第二次移动时拿到的“上次记下来的世界坐标”其实已经在第一次移动时被四舍五入过一次了,叠加第二次偏移后又四舍五入一次,误差会逐渐累积。视觉上,组件每完成一次拖拽,就会比预期的网格位置多漂移零点几像素,几十次下来,偏差就很明显了。
修法很简单,却极考验耐心:把“记录组件上一次坐标”的逻辑全部去掉,改为每次拖拽都从“组件在画布上的锚点世界坐标”重新计算新位置。锚点坐标在pointerdown时读取一次,之后整个拖拽过程都用它作为基准。这样无论拖拽多少次,计算链路始终是“从基准值出发”,误差不会累积。
这三个坑解决完以后,OpenMontage的交互才真正达到了我心里“可以拿出去分享”的水准。实际上,开源项目大多如此——真正值钱的部分往往不是最初的设计方案,而是这些翻车后沉淀下来的修复经验和判断力。如果你也在做类似的画布型工具,希望这篇复盘能帮你少走几步弯路。哪怕只避开其中一个坑,这篇文章写下来也就值得了。