Fantasy Map Generator 样式存储迁移实战:以 styles 单一存储收敛遗留样式体系的渐进式改造
Fantasy Map Generator 样式存储迁移实战:以 styles 单一存储收敛遗留样式体系的渐进式改造
导读
本篇文章围绕 docs/prd/style-migration.md 展开,完整还原 Fantasy Map Generator 将"四处分立的样式来源"收敛为统一 styles 单一存储(store)的分步迁移方案。你将掌握这套迁移的七个落地步骤、底层寻址契约(data-layer/data-group)、预设与旧地图的兼容升级策略,以及支撑"每一步单独通过 tsc / vitest / e2e"的验证体系——这套方法论可以直接复用到任何"渐进式重构遗留样式系统"的项目中。
背景:迁移前样式散落在四个地方
在引入统一存储之前,Fantasy Map Generator 的样式并不存在单一事实来源,而是散落在四个相互独立的载体中:
| 载体 | 内容 | 持久化位置 |
|---|---|---|
遗留 style JS 对象 |
labels.groups、burgIcons、anchors、relief |
地图文件中独立的一条记录 |
| SVG 分组(group)上的属性 | 各图层分组的 fill/stroke/opacity 等 SVG 属性 | 序列化后的 SVG 内部 |
| 选择器键控(selector-keyed)的预设 JSON | 以 #layer 选择器为键、直接映射 DOM 属性的样式包 |
public/styles/*.json |
图层注册表条目上的静态 attrs 包 |
图层注册时声明的默认属性 | 源码中的注册数据 |
这四个来源最终都作用于同一个渲染输出,但它们各自为政:样式编辑器修改 SVG 属性、预设直接写 DOM、style 对象另存一份——没有任何一个环节能回答"当前地图到底是怎么被样式化的"。
迁移的目标是让这四个来源全部收敛到同一个 styles 存储。对应的核心实现位于 src/generators/styles.ts、src/generators/styles-schema.ts 与 src/generators/styles-legacy.ts,下文各步骤将逐一对应。
迁移总策略:分步渐进,不做 Big Bang
style-migration.md 最核心的设计原则是:"一步一步从 master 走过去,而不是一次性大爆炸"。每一步都必须独立保持绿色(通过 tsc、vitest、e2e),因此每个 PR 的评审范围都很小。七个步骤的推进顺序为:
- 引入样式库 + 地址契约(寻址基础设施)
- 吸收遗留 JS
style对象 - 预设管线切换
- 持久化(styles 记录落地)
- 按消费者逐个迁移决策属性
- 编辑器全部经由
styles写入 - 删除遗留层
每一步的"完成状态"与原文档一致:步骤 1–4 已随分支完成,步骤 5 的 zoom 家族(markers rescale、states halo data-width)已完成、其余开放,步骤 6–7 已完成。
步骤 1:样式库 + 地址契约(寻址基础设施)
这一步把两层东西落到代码里,但保持"休眠"状态:
- 库本身:
src/styles/先以休眠形式存在——没有任何模块 import 它,zod 因此贡献零 bundle 字节。这保证了引入不产生运行时成本。 - 地址契约(活的一半):图层注册表(registry)在图层分组及其声明的子元素上盖章
data-layer/data-group,渲染器(renderers)在它们创建的元素上盖章同样的属性。此后保存的地图会携带这些"惰性"属性,在任何地方都无害。
这个契约是整套迁移的寻址地基。应用侧的实现可以精确看到它的工作方式——src/generators/styles.ts 中 Styles.write 通过 document.querySelector(\[data-layer="{id}"]\`)` 定位图层根节点,`writeNode` 再沿 `[data-group="{CSS.escape(group)}"]` 递归寻址子分组:
function write(...ids: StyleLayerId[]): void {
for (const id of ids) {
const root = document.querySelector(`[data-layer="${id}"]`);
if (!root) continue;
writeNode(root, styles[id]);
}
}
function apply(...ids: StyleLayerId[]): void {
write(...ids);
Layers.draw(...ids.filter((id): id is StyleLayerId & LayerId => id !== "map"));
}
注意 apply 在写完属性后还会调用 Layers.draw 重绘图层,而 write 只写属性不重绘。DOM 侧的浏览器模式测试 src/generators/styles.dom.test.ts 直接验证了这一寻址行为:将 styles.rivers.attrs.fill = "#123456"、filter = null 后 Styles.apply("rivers"),断言元素 fill 被写入、filter 被移除(null 语义 = 移除属性),并确认 Layers.draw("rivers") 被调用。
步骤 2:吸收遗留 JS style 对象
已经迁移到 JS 的领域直接搬进 styles,从第一天起就只有一个 JS 存储。按领域逐个提交、每步独立保持绿色:
labels.groups→styles.labels.groups:读者(label-groups、label-arc、fit-state-label、label-spread、3d 渲染器、groups 编辑器)与写者(预设路由、编辑器、submap)全部重新定向。burgIcons+anchors→styles.burgIcons.{burgIcons,anchors}.groups:绘制期在createIconGroups中的 DOM 收割逻辑重新定向;它在编辑器经由styles写入(步骤 6)之前一直保留。relief→styles.relief.options:set/size/density原样搬迁。注意文档特别强调:把density挪到应用级选项(它重新生成数据,严格说不是样式)属于独立的后续步骤,不能夹带在这次搬迁里——这是一条"一个 PR 只做一件事"的纪律示范。
在 src/generators/styles-schema.ts 中可以看到 relief 的存储形态:attrs(opacity/filter/mask)+ options(set、size、density),并在注释里写明"density 决定图标摆放位置,修改它会重新生成图标而不只是重设样式"。
步骤 3:预设管线切换(12 个预设 JSON 转换)
旧的预设体系以 #layer 选择器为键直接对 DOM 写属性。迁移后:
- 12 个预设 JSON 由脚本转换为新格式;
- 应用预设变成
setStyles(parseStyles(json))+applyStyles(...),不再是选择器键控的 DOM 直写; - 自定义 localStorage 预设和用户上传的旧预设继续可用:通过遗留升级器(legacy upgrader)——这是一个独立的函数,
parseStyles永远不见旧格式,因为用户会永远上传旧预设; - 渲染器完全不受影响:应用器写出的正是渲染器已经在读的那些属性,DOM 输出逐字节一致。
新格式的解析入口在 src/generators/styles-legacy.ts:PRESET_ROUTES 定义了旧选择器到存储路径的映射表(如 #terrs > #landHeights → ["heightmap", "landHeights"]、#markets → ["markets"]),并附带旧键到新选项名的重命名规则(如 "data-size" → "fontSize"、"data-filter" → "dataFilter")。presetFromLegacy 以 Styles.defaults 为种子、把遗留属性包叠加上去:缺失的键保留默认值,显式 null 清空属性。
解析器本体在 src/generators/styles.ts:parseSections 会针对"自定义分组名在默认样式里不存在"的场景,用同记录的任意库存分组充当模板。默认样式从 src/generators/default-styles.json 读取,通过 zod 校验后成为 globalThis.styles 的初始值。
步骤 4:持久化与旧地图自动升级
从这一步起,JSON.stringify(styles) 成为地图文件中自己的记录(record)。加载时解析该记录并覆盖在恢复出的 SVG 之上。
对更老的地图,自动升级逻辑会:
- 从恢复出的 SVG 上刮取已知的样式属性;
- 把它们喂给同一个升级器(upgrader);
- 以记录的形状(shape)而非版本号作为门控——用
isStoreStyles判断,因为"版本号会说谎"; - 步骤 2 的桥接代码在此终结。
isStoreStyles 的实现极其简单(src/generators/styles-legacy.ts):只要对象里存在 "map" 键,就判定为存储格式。对应的单测 src/generators/styles-legacy.test.ts 验证三种输入:Styles.defaults 判定为 true,旧形状 { labels: { groups: {} }, burgIcons: {}, anchors: {}, relief: {} } 判定为 false,null 判定为 false。
自动升级的入口 migrateStyles(legacyStyleString)(src/generators/styles-legacy.ts)完整串联了三个动作:迁移 pre-v1.150 的旧 style 对象 → 从 SVG 收割样式(harvestStylesFromSvg)→ 剥离已迁移属性(stripMigratedAttributes)。它被 src/services/io/auto-update.ts 在 data<a href="https://link.gitcode.com/i/56a25b5da384f01919f1f5b4a603e322" target="_blank">48](styles 记录槽位)上调用,并有配套的旧地图加载测试 [src/services/io/auto-update.test.ts。
值得注意的两个细节:
- 标签的
display处理:pre-1.140 的缩放自动显隐曾用内联display: none隐藏分组,缩放时保存的地图会把这一"图层状态"带进 style 属性。它不属于样式、不该持久化,因此stripDisplay(src/generators/styles-legacy.ts)会在迁移时把display: *声明从 style 字符串里剔除——测试 src/services/io/auto-update.test.ts 专门锁定了这一行为。 - 收割的优先级:
harvestStylesFromSvg对多个属性做了"仅当旧地图的 DOM 没带该属性时才采用 store 默认值"的处理,例如rescale、data-width、scheme、data-size等,确保旧地图的用户设置胜过新默认值。
步骤 5:按消费者迁移决策属性
每个"决策属性"(会改变渲染行为而非仅样式的属性)作为一个小 PR 独立迁移:渲染器读取、样式编辑器输入、以及该属性的 DOM 移除三者同步完成。文档点名的属性家族包括:
data-size家族(labels/burgIcons/rulers/coordinates 等的字号);- heightmap 的
scheme/terracing/skip/relax/curve; rescale(markers 缩放)、halodata-width(states)、oceanlayers;- texture / vignette / scale-bar 的几何参数。
每条纪律是:该属性只有在迁移其最后一个读取者的同一个 PR 里,才允许离开 DOM,绝不提前。 这一步完成之后,stripMigratedAttributes(src/generators/styles-legacy.ts)负责在加载时统一剥离这些已被存储接管、遗留在 DOM 上的属性——因为它们会被收割逻辑读到,残留一条陈旧属性会永远压过 store 记录。当前分支已完成 zoom 家族(markers.rescale 与 states.statesHalo 的 data-width),其余开放。
从 schema 侧看,markers 的 rescale 目前在 src/generators/styles-schema.ts 仍带有 TODO: move to global options.markers.resizeOnZoom 注释——这正是文档所说"可挪到应用选项"的后续工作,印证了步骤 5 是渐进推进、并非一蹴而就。
步骤 6:编辑器全部经由 styles 写入
编辑器控制的迁移原则是:每个编辑器控件都改写 store,并且只把被编辑的那一个属性写到 DOM——整层重写会重置 zoom 派生出的兄弟属性值。
这一步完成后:
- burg 图标 DOM 收割与遗留 Style Saver 序列化器被移除;
- 自定义预设以 store 格式保存;
- defs 资源(
oceanicPattern、vignette-rect)获得了由 store 驱动的应用器(appliers); - 通用的 DOM 读取点刻意保留(它们在 stale-group-select 窗口期运行,且按不变量其值与 store 一致)。
样式编辑器定位元素/分组的逻辑与预设升级器共用同一张路由表:styleNodeFor(element, group)(src/generators/styles-legacy.ts)把用户选择的 element/group 解析成选择器,再通过 routeFor 走 PRESET_ROUTES 或正则回退,最终落到 store 路径 route.path——第一个路径段就是要改写的存储图层。
编辑器的验证方式很有工程价值:步骤 6 的测试要求通过真实的 input/change 事件驱动编辑器写入(而非直接调用内部函数),确保验证的是用户在界面上的真实路径。
步骤 7:删除遗留层
最后一步收敛:
- 保存时直接序列化 store——保存期的 DOM 收割及其权限门控退化为纯加载期迁移代码:
syncStylesFromMap现在只为"没有 style 记录的旧地图"运行,和预设升级器一样永远驻留; - 预设投影(preset projection)被移除;
removeBurgIcons、removePrecipitation的覆盖擦除逻辑被删除,统一使用eraseContent(因为此时"擦除内容"已足够安全)。
这体现了文档的核心信念:旧文件永远能加载——遗留地图记录(步骤 4)与遗留预设(步骤 3)永久转换;被删除的只是"产生遗留形状"的代码,而不是读取它们的代码。
贯穿始终的不变量(Invariants)
两条不变量是所有步骤的验收红线:
- 步骤 2–4 期间:应用器写出渲染器仍在读取的每一个属性;一个 options 属性只允许在其最后一个读取者被迁移的同一个 PR(步骤 5)中离开 DOM——绝不提前。
- 旧文件永远可加载:遗留地图记录(步骤 4)与遗留预设(步骤 3)永久转换;只有"生产遗留形状"的代码会被删除。
验证体系:如何证明每一步是绿色的
迁移最怕"重构完发现渲染输出悄悄变了"。该项目的验证体系层层设防:
1. 属性快照 e2e 基线(Attribute-snapshot) 对一张加载的参考地图和一张新生成的地图(种子稳定)锁定每个图层分组的渲染样式。这些基线在步骤 2–4 期间必须保持逐字节不变,且只会因每个步骤 5 的 PR 精确地缩减掉该 PR 移除的属性。这是"DOM 输出逐字节一致"承诺的机器验证。
2. 每步附加验证
- 步骤 3:全部 12 个转换后预设的 schema 往返(round-trip)测试。见 src/generators/styles.test.ts:
Styles.parse(Styles.defaults)往返不变、未知键被严格 schema 拒绝、空burgIcons/anchors分组被修复、旧海洋/湖泊/heightmap 记录自动补齐新默认(如bands.render = false、embellishment = "none"、contours.mode = "off")、非法单属性只回退该属性而非整层(src/generators/styles.test.ts)。 - 步骤 4:旧地图加载测试,断言收割出的样式符合预期(见 src/services/io/auto-update.test.ts 的 styles record 断言)。
- 步骤 6:编辑器写入通过真实
input/change事件驱动。
3. DOM 侧浏览器模式测试
src/generators/styles.dom.test.ts 在 vitest.browser.config.ts 下运行,覆盖:属性写入与 null 移除、options 永不落 DOM(Styles.apply("markers") 后 el.hasAttribute("rescale") === false)、store 中动态分组在 DOM 缺席时安全跳过、burgIcons/anchors 经容器寻址、缺失图层是 no-op、write 不触发重绘。
4. Schema 校验作为最后防线
src/generators/styles-schema.ts 用 zod 为每个图层定义严格的 attrs + options 形状:opacity/color/strokeWidth 等共享类型统一管理,null 表示"属性未设置";lake、heights、oceanWaves 等提供带取值范围(如 density: 0.1–4、count: 1–8、interval: 1–20)的默认值。默认样式本身也要通过 schema 校验(src/generators/styles.test.ts),从制度上防止"默认值和 schema 漂移"。解析时逐属性修复(per-attribute repair):能修的属性单独回退,整层不可修复才整层回退,且每次回退只产生一条警告。
总结与复用要点
从 docs/prd/style-migration.md 出发,这套迁移方案的价值不止于项目本身:
- 用"地址契约"解耦样式来源:
data-layer/data-group让"谁写的属性"和"属性写给谁"彻底分离,渲染器从此不必关心样式来自预设、编辑器还是旧地图升级。 - 用"形状门控"代替版本号:
isStoreStyles只认数据结构,不信任版本行——旧格式转换逻辑可以永远驻留而不产生债务。 - 用"逐 PR 移走属性"保证安全:属性离开 DOM 的唯一条件是它不再被任何人读取,配合属性快照 e2e,重构对渲染输出零扰动。
- 用"双层升级器"兼容永恒:
parseStyles只管新格式,旧格式(预设在用户手里、旧地图在磁盘上)由独立升级器永久消化。
对于任何面临"多个样式来源各自为政、不敢动又不得不动"的渲染型应用,这套七步走、步步可验证的迁移骨架都值得直接借鉴。想深入代码的读者可以继续查阅 src/generators/styles.ts、src/generators/styles-schema.ts、src/generators/styles-legacy.ts 及其测试 src/generators/styles.test.ts、src/generators/styles.dom.test.ts,以及配套 PRD docs/prd/style.md。