Fantasy Map Generator 样式存储迁移实战:以 styles 单一存储收敛遗留样式体系的渐进式改造

原创2026-10-03 15:47:18172 阅读
文章标签:前端图形学3D渲染

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 的评审范围都很小。七个步骤的推进顺序为:

  1. 引入样式库 + 地址契约(寻址基础设施)
  2. 吸收遗留 JS style 对象
  3. 预设管线切换
  4. 持久化(styles 记录落地)
  5. 按消费者逐个迁移决策属性
  6. 编辑器全部经由 styles 写入
  7. 删除遗留层

每一步的"完成状态"与原文档一致:步骤 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 缩放)、halo data-width(states)、ocean layers;
  • 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)

两条不变量是所有步骤的验收红线:

  1. 步骤 2–4 期间:应用器写出渲染器仍在读取的每一个属性;一个 options 属性只允许在其最后一个读取者被迁移的同一个 PR(步骤 5)中离开 DOM——绝不提前。
  2. 旧文件永远可加载:遗留地图记录(步骤 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。

登录后查看全文
Fantasy-Map-Generator