主题
设计原则
这页讲代码为什么长成这样。每条原则按同一个套路展开:是什么、为什么、反例是什么。全部指向真实文件与类名,可以按图索骥去读。架构规则的权威原文在 editor/docs/refactor/ARCHITECTURE-RULES.md(团队内部习惯叫「铁律」),本页是讲解版,两者冲突时以原文与代码为准。
1. 单一数据源:一个事实只存一份
样式数据只有 StyleStore 一个数据源(editor/src/app/core/style-store.service.ts)。地图渲染、属性面板、导出终检全部读它的 signal;任何派生(选中层、底图名、导入清单)都是 computed,不是第二份存储。
为什么值得这样付出:编辑器类应用的状态 bug 几乎全部来自「同一事实两处存储」。收敛数据源之后,撤销、持久化、地图同步才有单点可挂;派生不落第二份,也就不存在两份派生谁新谁旧的问题。
文档同理:每个主题只有一个权威出处,登记在文档地图,两处维护同一份事实即违规。2026 年 10 月的审计轮就清理过 README 与 changelog 的重复面。
反例:把 selectedLayer 存成另一个服务的可写 signal。看起来省事,撤销一次样式变更后它指向已删除的图层,得再写一堆同步代码去补,而这些代码每行都是新的 bug 面。
2. 写通道唯一:改动必须过命名方法
StyleStore 的样式 signal 是 private,对外只有 asReadonly() 的读面。一切修改走 update(mutator, label) 这一个入口:immer produce 产出补丁、入撤销栈、diff 后同步地图,全部内聚在这个方法里。其余便捷操作(toggleVisibility、removeLayers、addLayer、setLayersGroup 等)全部经 update,无一例外。
方法即审计点。想回答「谁在改样式、改了什么、能否撤销」,grep 一个函数名就够。样式受保护时,assertEditable 在这个单点拒绝并 toast 指引;保护开关自身走 config 通道(ConfigStore.setStyleProtected),不存在锁死无法解锁。
反例就是图里下半段:直接调 maplibre 的原生 API 改画布。画布立刻变了,看起来更省事,但样式文档不知道这次修改,撤销栈没有它,导出的 JSON 不含它,保护锁也拦不住它。三个机制同时被绕过,而且用户只看到「地图变了」,问题要到导出或撤销时才暴露。
3. Signals 优先:同步派生用 computed,异步流用 RxJS
2026-09-30 的双轨决策(ADR-15)定了分工:同步派生继续用 computed,组件模板不算状态、不自己算值;异步流与事件编排优先 RxJS operators 管道,先例是 core/style-fetch.ts 的回退链、autosave 的 debounce、UiService 计数通道的 Subject 化。
为什么不全量切到某一侧:纯 Signals 写回退链、取消旧请求、防抖这些异步编排会很绕;纯 RxJS 管同步派生又丢了细粒度追踪。各干各擅长的事。落地纪律有三条:
- 手写
.subscribe(必须同语句配takeUntilDestroyed,架构测试文本扫描强制。 - effect 只做副作用下发,不得回写自身依赖的信号,回写就是无限循环的起点。
- 跨组件的「打开面板加联动动作」走计数信号通道(如
ui.sourcesAddRequest):计数而非布尔,未挂载期请求累积不丢,消费即焚。为什么不用事件总线:组件晚挂载就错过事件,计数是状态,挂载后 effect 自然读到最新值。
反例:在组件模板里调用每次变更检测都会重跑的函数算坐标文本。zoneless 下没有 zone 兜底,这种值要么算错要么不刷新,正确做法是 computed(先例:状态栏的 zoomText 与 centerText)。
4. 分层单向:app 到 features 到 core 到 shared
四层依赖只许向下,组合根(app.component)例外。这条规则不是靠评审盯出来的,是机器红线:editor/tests/architecture.test.ts 固化了 shared 到 core 为零、shared 到 features 为零、core 到 features 为零、import 图无环,裸目录导入与动态 import 两类逃逸路径也在扫描范围内。测试红了就禁止合入。
为什么用测试而不是约定:分层违规是渐进发生的,每次「就这一次从 core 引一下 features」单看都有理由,半年后依赖图就成一团毛线。机器红线让每次违规立刻可见,而不是埋到重构时才发现。
需要双向引用时怎么办:不放松方向,把共享物抽成叶模块。已有案例:zoom-case-hoist、project-format.model、source-layer-meta、layer-list-fns,都是这么拆出来的。
5. 纯函数层优先:能不沾状态就不沾状态
新代码先问一句:这能写成纯函数吗?纯函数不注入服务、不读信号、同输入同输出,测试不需要任何装配。落位上,跨域复用的纯函数进 shared/(56 个模块的主体,如 pop-anchor 定位数学、zoom-eval、layer-display、color-convert),单域的纯函数跟域走(如 layer-list-fns.ts、map-diff.ts、core/map/style-diff.ts、core/config/basemap-delivery.ts)。
这轮重构就在持续做这个动作:StyleStore 从 2851 行瘦身到约 2100 行,方式就是把和类零状态耦合的底图交付纯函数挪到 core/config/basemap-delivery.ts、结构 diff 挪到 core/map/style-diff.ts、spec 静态表挪到 core/spec-tables.ts。类瘦下来之后,这些函数可以独立单测,map 子树也不再反向依赖 store 本体。
反例:把「计算拖拽落点」写进组件的私有方法。看起来只是三行代码,结果是 multi-merge 面板要用同样的判定时只能复制一份,两份逻辑各自漂移。抽成 layer-list-fns.ts 的纯函数后,两边消费同一份,单测直接覆盖。
6. 元数据驱动:行为跟着表走,不跟着 if 走
有一类复杂度来自「枚举很多、每个枚举的处理又相似」,比如 spec 属性怎么分组、Global 面板有哪些行、双引擎各自支持什么 API。这类问题的标准解法是把枚举变成数据表,代码只剩一张遍历表:
- spec 属性分组与中文标签:
core/spec.service.ts读 style-spec 元数据,静态对照表在core/spec-tables.ts,spec 升级后跑对账测试就知道面板缺了什么。 - Global 八面板:
features/global/global-panels.ts一张表声明面板、属性、控件、默认值、依赖,加一行属性就自动出面板行。 - 双引擎能力矩阵:
core/map/map-capabilities.ts是唯一事实源,人读文档与对账测试都从它派生,不允许文档和代码各维护一份。 - 表达式算子注册表:89 个算子在
core/expression/registry/,查询按上下文走表。
为什么:if 链会随枚举增长线性膨胀,散落在多个文件里;表是单点,加枚举加一行,覆盖测试对着表跑。
反例:给每个属性写一个「它属于哪个分组」的 if。spec 26.4 新增六个属性时,你就得记住所有散落点。这就是 spec-coverage 对账测试存在的理由。
7. 不可变更新:immer 补丁就是撤销语义
样式更新走 immer produce 的 patch 模式(enablePatches):一次编辑同时产出 patches 与 inversePatches,undo 就是补丁反演,不需要快照整份样式。三个参数值得记住:连续编辑传相同 coalesceKey,800ms 窗口内合并为单条撤销(COALESCE_MS);栈深上限 100(UNDO_LIMIT),超深丢最旧;跨文档的写用 transact(fn) 合并为单条撤销,config 快照是 immer 冻结引用,零拷贝。
为什么选补丁式而不是快照式:快照式每条历史都要整份深拷贝样式,内存与拷贝成本随样式体积线性涨;补丁只记差异,草稿持久化也只需携带补丁序列。代价是所有修改必须过 produce,这正是原则 2 写通道唯一的另一个理由,两条原则互为因果。
反例:在 mutator 里对某个嵌套对象做原地 push 再整体替换。替换式赋值会丢前序补丁,coalesce 合并时撤销行为开始诡异。规矩是让 immer 记录路径级变更,不要手工制造大颗粒替换。
8. 界面意图不入样式文档
中文显示名、分组、附属交互配置、底图注册、过滤器骨架这些「界面意图」收敛进 ms-config 配置文档(core/config/),样式文档保持纯 MapLibre 渲染语义。这样导出的样式 JSON 在任何纯 maplibre 宿主里都能直接装载与校验,零私有约定键污染。
两档的一致性由一个不变量静态保证:
applyConfigLayer(styleLayerToConfigLayer(layer), layer) ≡ layer显式键集合与值恒等,由单测锁定,不建运行时双向转换元数据。
导出同理分两条通道不混:分享与发布走 exportSharedStyle,经 sanitizeExportedStyle 剥掉 mapstudio:* 约定键并对交互覆盖形态做等价改写;文档原样导出走 exportStyle。混了会怎样:「编辑样式 JSON」的回写会把交互存储形态和 UI 约定键一起洗掉。
反例:早期把显示名塞进图层 metadata 的 mapstudio:title 键。样式在编辑器里看是对的,拿到外部工具里就多了规范外键,严格校验器报警,用户不知道该不该删。
9. 组件薄、控件受控
全部组件(75 个组件文件,2026-10 实测)OnPush 加 inject(),不持有业务状态;组件自己的 signal 仅限局部瞬时 UI 态(开合、草稿、拖拽锚点、会话记忆)。zoneless 下这不只是风格问题,模板只依赖信号输入,跨组件联动走信号通道,没有隐式刷新可指望。
控件层契约更严一层:features/controls/ 的受控控件是值进值出,不落库、不感知存储,落库统一由容器组件走 service 写通道。请求也不进组件,样式、字体、sprite 拉取全部收编 core 内核,src 组件零 fetch。
反例:取色器控件里直接注入 StyleStore 写 fill-color。后果是这个控件只能用于图层属性,Global 面板想复用就得再写一个几乎一样的。受控化之后,同一个取色器出现在常量色行、停靠行、条件分支色、底图配置四处。
10. 约定字面量单源:数据兼容性的锚点
写进样式文档与配置文档的约定键必须从单源引用,禁止散写字符串:BASEMAP_META_KEY、MAPSTUDIO_META_PREFIX、UI_ONLY_LAYER_KEYS 在 shared/style-conventions.ts;交互事件词表 INTERACTION_EVENT_KEYS 在 shared/interaction-layer.ts;JSON 相等只认 shared/json-eq.ts 的 jsonEquals 与 jsonEqualsLoose 两个口径,禁止自写第三个比较实现。
为什么单列一条:这些键名一旦漂移,旧项目文件读进来就静默失配,症状是「底图不见了」这类难查问题,而不是一个报错。所以改任一键名等于数据兼容性变更,必须走迁移注册表(core/persistence/migrations.ts),禁止散落 if。
反例:某处硬编码了字符串 'mapstudio:basemap' 而不是引用常量。后来键名升级,引用处全改了,这处漏网,旧档的底图从此认不出来,grep 字符串才能找到它。
11. 复用优先级与源码最少化
新增可复用物按此优先级落位(权威:editor/docs/refactor/DEVELOPMENT-GUIDE.md 复用登记节):
配置复用 > 组合复用 > 函数复用 > Service 复用 > 指令/管道 > 组件 > 继承(禁止) > 复制(禁止)新共享物必须登记并配单测。反方向的防线是防过度抽象:prop-editor 不重写为「动态 Schema 渲染器」,控件分发复杂度来自 MapLibre spec 本身,模板分支是诚实的;conditions-editor 与 filter-editor 不合并,数据模型不同只共享解析纯函数。这些是有记录的评审决策,推翻要走用户裁决。
每轮重构以净删除行数为正向指标,但删除必须带等价性证据:DOM 逐字节等价的机器验证、同参等价对账测试、视觉基线零漂移,任一可得即附上。复用收敛是减法的手段,等价证据是减法的安全网。刚完成的「toast 单源化」是个小而典型的例子:「样式已受保护」的提示原先二十余处逐字复制,现在收敛为 UiService.notifyStyleLocked() 一个方法,行为不变,删掉的是将来会各自漂移的副本。
非目标(边界)
不做多用户协作与账号体系;不做 MapLibre spec 之外的私有渲染语义;不做云托管(自托管单进程);GISSDK 双引擎一致性以能力矩阵门禁维持,不追求逐像素等价。边界即设计:拒绝了什么与选择了什么同样构成架构,翻案需走演进指南的决策流程。