墨穗app.notebase.cn
控制台
内容库
动态
管理
账户
U
用户
--
在线
v1.0.165 · 墨穗笔记
笔记

Notebase墨穗
静水流深,落墨成穗。

0笔记
0工具
30推荐

分类导航

按主题直达

编辑精选

站内用户贡献 · 真实笔记

最新收录

每日更新
继续浏览全部内容 →

笔记

0
加载中...

工具

0

此页用于记录用户反馈问题后的每一次改进

关于

笔记用法

“写笔记”支持四种格式——Word 文档、Excel 表格、Markdown、纯文本,起稿或二次编辑时都能随时切换,同一篇笔记想用哪种形态来记,都由你说了算。

md、txt、csv、json 这类纯文本则原样载入,不做多余加工。拿一张现成的表倒进来、改几笔、再导出去,等于白用一台免费的格式转换器。

要带走就在右上角点“下载”,可导出 PDF、Word、Markdown、Excel、TXT 等格式;列表卡片“⋯”菜单里,也有同样的下载入口。

工具用法

在“工具”页点“+ 上传工具”即可发布:填好名称与链接,再用 Markdown 把使用方法写清楚——能解决什么问题、怎么装、怎么用,比堆介绍实在。

要分发安装包就一并上传压缩包(ZIP、RAR、7Z、TAR.GZ,最大 35MB),别人在详情页一键下载;只放链接不带附件也可以。

工具按大家的收藏热度排序,好用的自然会被顶上来。发布后可在详情页或卡片菜单里编辑、下架。

隐藏笔记

写笔记时勾上“隐藏”,这篇就只存在于你自己的账号里:不进列表、不进搜索、不上首页精选,也不会出现在任何公开的页面,链接发给别人同样打不开。

适合放密码、草稿、日记这类只给自己看的内容;想公开,去“发布”打开它,把“隐藏”的勾去掉再保存,之后编辑会默认保持原状态,不会悄悄变回公开。

不想公开、只想临时给人看:点“分享”生成一条带密码和有效期的链接,到期自动失效,你也能随时撤销。

数据安全

你的内容会同时保存在多个副本上,系统定期做备份与完整性校验,再配合异地容灾机制:就算某台机器出问题,数据也不会丢,可以长期放心存放;特别重要的资料,仍建议你另外再留一份备份。

技术

全站跑在容器化、模块化的现代架构上,更新、部署、回滚都很快,扩展性和稳定性都按长期运营的标准来设计(Built for reliability, designed to scale)。

理念

这个网站最早只是一个人的笔记仓库,后来慢慢长成现在的知识中枢。设计上很克制——没有广告、没有追踪、没有推荐算法,只是干干净净地存放一些东西;既然做好了,就公开出来,万一有人用得上呢。

原则

不做大而全,不做平台梦,保持简单、保持克制、保持好奇。所有内容都由用户贡献、由用户维护:不会突然冒出付费墙,不会在角落塞广告位,也不会把你的数据卖给第三方。

更多

产品会持续迭代,站内日志页记录着每一次改动,改了什么都有迹可循;想了解这个站是怎么一步步走到今天的,翻翻日志就能看到来龙去脉。

举报

如果在这里看到涉嫌违规的内容,点对应卡片右侧的“举报”按钮就能提交,我们会尽快核实处理;也谢谢你花一点时间,一起把这里维护干净。

趋势

// 点击导航加载发现
归档
// 归档为空
最近浏览
// 暂无浏览记录
发布
// 加载中...
用户发布
// 加载中...
用户管理
// 加载中...
访问统计
// 加载中...
内容审核
// 加载中...
个人信息
// 加载中...
返回首页

我们重写交互式地图编辑器学到的教训:Fabric.js、CORS 和两万行遗留 TypeScript 代码

2026-07-07编程开发

去年我们团队接手了一个让我至今记忆犹新的任务——重写一个交互式办公室地图编辑器。说实话,刚听到“重写”这两个字的时候,我后背是发凉的。干过开发的都知道,这句话后面通常跟着的是几个月的加班、数不清的回归测试,以及没完没了的架构争论。但这次不一样,我们不仅活下来了,还学到了不少硬核的东西。今天我就把这些踩过的坑和收获分享出来,希望能帮到正在跟类似问题死磕的你。

故事的起点:一个 2270 行的“上帝组件”

先说说这个编辑器是怎么走到今天这一步的。我们做的是一款企业级的工位管理平台,公司用它来管理办公室空间和预订座位。核心功能之一就是这个地图编辑器——管理员上传楼层平面图,在上面摆放办公桌、会议室,然后发布给员工使用。

这个编辑器最初是在 AngularJS 时代写的。你没看错,就是那个已经被谷歌打入冷宫的 AngularJS。主编辑器组件是一个单文件,里面塞了将近 2270 行代码。它负责的事情包括但不限于:

  • 加载地图
  • 操作 Fabric.js 画布
  • 增删改查各种对象
  • 处理键盘快捷键
  • 管理弹窗
  • 保存数据
  • 事件处理

基本上你能想到的编辑器功能,全都在这个文件里。更吓人的是,它背后还藏着一个将近两万行 TypeScript 的“地图引擎”,分布在 230 多个文件里。

最要命的架构问题是什么?是一个无限渲染循环。代码大概是这样的:

fabric.util.requestAnimFrame(() => this.tick());

你没看错,即使用户啥也没干,编辑器也在不停地重新渲染。它确实能工作,但每加一个新功能,成本就翻倍。就像往一个已经塞满的行李箱里硬塞东西——总有一天会炸。

为什么决定重写?不是因为 AngularJS

很多人可能会觉得,重写肯定是因为 AngularJS 太老了。其实不是。真正推动我们动手的是业务需求。产品需要几个全新的能力:

  • 地图草稿:管理员可以在不发布的情况下保存编辑进度
  • 安全发布:确保发布过程中不会影响到正在使用的员工
  • 高质量打印:打印出来的地图要清晰,不能糊
  • 多种工位模式:支持开放式工位、固定工位、共享工位等
  • 更容易支持新对象类型:比如将来要加的会议室设备、消防设施等

每加一个新功能,就像在豆腐上雕花——不是不能做,但代价极大。我们逐渐意识到,自己不再是在修 bug,而是在跟架构本身作斗争。与其搞一次风险极高的“大爆炸式”重写,不如在旁边新建一套管理模块,跟旧的查看器并行运行。

新架构:把“大泥球”拆成三个子系统

我们保留了 Fabric.js,因为它在 Canvas 操作方面确实做得好。但其他的全部推倒重来。新的架构长这样:

Map Editor
    │
    ▼
Map Engine
    │
    ┌──────┼─────────┐
    │      │         │
    ▼      ▼         ▼
Viewport  Registry  Changes Tracker
    │
    ▼
Fabric.js

每个子系统只关心一件事:

  • Viewport(视口):只负责相机控制,比如缩放、平移。它不关心地图上有什么对象。
  • Object Registry(对象注册表):管理地图上的所有实体——办公桌、会议室、走廊标记等。它只负责增删改查,不关心渲染。
  • Changes Tracker(变更追踪器):只记录哪些对象被修改了、哪些是新增的、哪些被删除了。它不关心对象长什么样。

而 Map Engine 对外暴露一个统一的 API,编辑器通过这个 API 跟底层交互。

这么做的好处很快就体现出来了。有一次产品经理过来说:“我们要加一个‘批量旋转所有工位’的功能。”换作以前,我得先搞清楚渲染循环的哪个环节会受影响,然后祈祷别碰坏别的东西。现在呢?我只需要在 Object Registry 里加一个批量操作方法,然后让 Changes Tracker 记录变更,最后通知 Viewport 重新渲染。整个改动不到 50 行代码。

移动相机,而不是移动对象

还有一个架构决策让我们省了不少事。以前的做法是:用户拖动画布时,我们遍历所有对象,挨个修改它们的坐标。听起来就笨,对吧?

新做法很简单:利用 Fabric.js 的 viewportTransform,我们只移动相机(视口),对象永远待在它们原来的坐标上。

// 旧做法:遍历所有对象,逐个移动
objects.forEach(obj => {
    obj.left -= deltaX;
    obj.top -= deltaY;
    obj.setCoords();
});
canvas.renderAll();

// 新做法:移动视口变换矩阵
const currentTransform = canvas.viewportTransform;
currentTransform[4] += deltaX;
currentTransform[5] += deltaY;
canvas.setViewportTransform(currentTransform);
canvas.renderAll();

这个决策让很多事情变得简单到令人发指:

  • 缩放:只需要修改 viewportTransform 的前两个元素(缩放因子),对象坐标完全不用动。
  • 平移:如上,改最后两个元素就行。
  • 导出与打印:因为对象坐标是固定的,导出时不需要做坐标转换。
  • 碰撞检测:比较两个对象的坐标时,不需要考虑当前缩放级别。

有时候一个架构决策就能同时解决好几个未来可能遇到的问题。这个就是典型的例子。

最难的问题居然不是两万行 TypeScript

当我们开始迁移时,团队里最担心的是那两万行遗留代码。结果呢?这部分反而相对顺利。真正让我们头疼的,是一个看起来人畜无害的需求:打印办公室地图。

第一次尝试:window.print()

最直观的做法:

window.print();

结果呢?浏览器打印时,DOM 渲染的分辨率大概只有屏幕分辨率。对于大型办公室地图来说,打印出来的东西糊得像马赛克。更糟的是,编辑器 UI 的按钮、工具栏、弹窗也出现在了打印文档里。这显然不行。

第二次尝试:导出 Canvas 为高分辨率图片

下一个想法明显更好:把 Fabric.js 画布导出为高分辨率图片。

canvas.toDataURL();

结果浏览器甩给我一个错误:

SecurityError: The canvas has been tainted by cross-origin data.

当时我差点把咖啡喷到屏幕上。为什么?因为楼层平面图存储在 Amazon S3 上。虽然它在浏览器里正常显示,但当你试图导出画布时,浏览器会检查所有图片资源是否都有正确的 CORS 头。没有的话,画布就被标记为“被污染(tainted)”,任何导出操作都会被禁止。

这里需要补充一点背景知识:Canvas 安全机制是浏览器为了保护用户隐私而设计的。如果一个画布中包含跨域图片(比如来自 S3 的图片),而这张图片没有通过 CORS 明确允许跨域访问,那么浏览器就不允许你从画布中读取像素数据(包括导出为图片)。想象一下,如果你在某个网站上传了一张自己的照片,然后另一个网站通过 Canvas 偷偷读取了这张照片的像素信息——这显然是个安全漏洞。所以浏览器直接一刀切:只要画布里有跨域资源,且 CORS 配置不对,就别想导出。

解决方案其实有两个方向:

  1. 给 S3 加上正确的 CORS 配置。但有时候这不是你能控制的——比如图片来自第三方 CDN,或者运维团队不愿意改配置。
  2. 分层导出。我们选了这条路。

第三次尝试:分层导出,终于成了

我们不再尝试导出单一的 PNG,而是把打印分成两个独立的层:

  1. 背景图片层:原始的楼层平面图,直接从 S3 加载,不经过 Canvas。
  2. 对象层:Fabric.js 画布上的所有对象(办公桌、会议室等),导出时不包含背景。

最终打印文档由这两层拼合而成:

背景图片 + 对象层 = 可打印文档

代码实现大概是这样的:

// 导出对象层,隐藏背景
const objectsDataUrl = exportCanvasRegion({
    hideBackground: true,
});

// 返回两个独立的资源
return {
    backgroundUrl: 'https://s3-bucket/floor-plan.png',  // 直接引用原图URL
    objectsDataUrl: objectsDataUrl,                      // 只包含对象的base64图片
};

然后在打印时,我们用 CSS 把这两个图片叠在一起:

<div class="print-container">
    <!-- 背景图片 -->
    <img src="https://s3-bucket/floor-plan.png" class="background-layer" />
    <!-- 对象层,透明背景 -->
    <img src="data:image/png;base64,..." class="objects-layer" />
</div>
.print-container {
    position: relative;
}
.background-layer {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
}
.objects-layer {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
}

这个方案看起来比直接导出复杂,但实际运行起来非常可靠。因为背景图片直接从 S3 加载,不受 Canvas 安全限制;对象层因为不包含跨域图片,也可以正常导出。

又一个意想不到的问题:坐标漂移

解决了 CORS 问题后,我们以为万事大吉了。结果打印出来的地图上,对象的位置全偏了——办公桌不在它们应该在的地方,会议室标记跑到走廊里去了。

排查过程非常痛苦。我们检查了坐标计算、打印缩放、CSS 布局,全都看不出问题。最后发现原因非常微妙:

  • 背景图片的定位用的是像素值(比如 left: 100px; top: 200px)。
  • 对象层的定位用的是百分比(比如 left: 12.5%; top: 25%)。

在屏幕上,这两者看起来完全一致,因为浏览器的渲染引擎会把百分比换算成像素。但是当打印时,浏览器的布局引擎会重新计算所有尺寸——因为打印页面的大小跟屏幕不一样。这时候,百分比换算出来的像素值就跟原来的像素值对不上了。

解决方案出奇地简单:把背景图片的定位也改成百分比。

.background-layer {
    position: absolute;
    left: 12.5%;  /* 原来是 100px */
    top: 25%;     /* 原来是 200px */
    width: 75%;
    height: 50%;
}

改完之后,两层立即完美对齐。

这种问题最难调试的地方在于:代码本身没有 bug。没有空指针,没有类型错误,没有逻辑错误。问题是出在坐标模型本身——两个层用了不同的参考系。这就像你在画地图时,一个层用经纬度,另一个层用墨卡托投影坐标,看起来差不多,但一放大就全偏了。

重写之后,什么变了?

有趣的是,成功不是用代码行数减少来衡量的。新的实现实际上还增加了功能:

  • 草稿支持:管理员可以保存多个版本,随时回退
  • 安全发布:发布前自动检查冲突,确保不会覆盖别人的修改
  • 高质量打印:分辨率可配置,支持 A3/A4 纸张自适应
  • 多种工位模式:开箱支持三种工位类型,每种有不同的交互行为
  • 更干净的架构:每个模块的职责一目了然

代码行数变成了一个毫无意义的指标。真正重要的是另一件事:加新功能不再让人提心吊胆了。

以前加一个功能,我得先花半天时间搞清楚渲染循环的哪个环节会受影响,然后祈祷测试能覆盖所有边界情况。现在呢?大部分时候,我只需要在对应的子系统里加代码,然后写几个单元测试就完事了。因为每个子系统只关心自己的事,改动的影响范围是可控的。

学到的教训

做这个项目让我深刻体会到一件事:最难的工程挑战很少属于某个特定的框架。

Angular 会过时,Fabric.js 会更新,TypeScript 会迭代。但真正的复杂性通常出现在这些技术之间的边界地带。CORS 不是 Angular 的问题,它是浏览器安全模型的问题。坐标漂移不是 Fabric.js 的问题,它是 CSS 布局模型的问题。无限渲染循环不是 TypeScript 的问题,它是架构设计的问题。

有时候,重写两万行代码不是项目中最难的部分。最难的部分往往是那个看起来人畜无害的“打印按钮”。就是这个按钮,逼着我们去深入理解浏览器渲染机制、CORS 策略、Canvas 安全限制、坐标系统和软件架构——这些知识比任何框架迁移都更有价值。

写在最后

回头看,这次迁移本质上跟 Angular 没什么关系。它关乎的是如何让产品更容易演进。新架构并没有大幅减少代码量,但它降低了未来变更的成本。有时候,这才是比代码行数更有价值的衡量标准。

如果你对这次迁移的技术细节感兴趣——比如我们是怎么设计 MapEngine 的、为什么决定保留 Fabric.js、以及如何在不停产的情况下完成管理模块的迁移——我可以再写一篇后续文章。这次先到这里,我得去修下一个 bug 了。


这篇文章基于我们构建和维护企业级工位管理平台的经验。代码示例已经过简化,实现细节做了泛化处理,在保留工程决策和教训的前提下,尊重产品机密性。

相似推荐
uBlock Origin 开发版被 Chrome 商店拒绝:一场关于扩展单一用途政策的争议逆向工程实战:我如何绕过亚马逊Kindle网页端的DRM加密Heroku的丑陋秘密:云之王如何背离Rails并欺骗客户Firefox 成为最后一个支持 uBlock Origin 的主流浏览器:广告拦截的终局之战GitHub Codespaces 深度解读:云端开发环境如何重塑编码工作流认知负荷才是关键:软件设计的根本度量
编写使用方法
Markdown 格式 · Ctrl+Enter 确定
0 字新建笔记
欢迎回来
登录你的墨穗笔记账户
忘记密码?
还没有账户?立即注册
创建账户
注册你的专属墨穗笔记
已有账户?去登录
找回密码
输入注册邮箱获取验证码
返回登录
请输入图片中的验证码以继续注册
加载中...
取消
新建收藏
手动添加你喜欢的内容
取消
编辑头像与昵称
上传新头像或修改你的显示昵称
支持 JPG/PNG,最大 2MB
取消

问题反馈

隐私提醒

取消
编辑工具
受控分享
为这篇笔记生成限时 / 带密码的临时链接
关闭