第23章 - 源码架构与二次开发
上一章讲怎么“加一个插件”,这一章往下走一层:GeoLibre 的源码是怎么组织的、状态为什么这样流动、四种渲染引擎如何共存,以及你想改它时应该动哪个文件。文中的路径、类型名、常量都取自仓库(CLAUDE.md、docs/architecture.md、docs/tutorials/source-code-deep-dive-zh.md 与源码本身)。
说明:本章基于 GeoLibre 3.4(HEAD 354ee2fa)。源码里的数字(阈值、体积、文件行数)会随后续版本变化,引用时请以当前仓库为准。
23.1 monorepo 与包职责
23.1.1 一个仓库,三种交付形态
GeoLibre 是单一 npm workspaces monorepo(apps/*、packages/*、workers/*),外加两个非 npm 组件:Python FastAPI sidecar(backend/geolibre_server)与独立 Python 包(python/,Jupyter anywidget + geolibre-mcp)。根目录一次 npm install 就把所有 JS workspace 接好,包管理器固定用 npm(仓库跟踪 package-lock.json),Node 22+。
同一套 React 应用有三种落地方式:
| 形态 | 外壳 | 入口/产物 |
|---|---|---|
| 浏览器 | nginx 托管静态产物(Docker) | apps/geolibre-desktop/dist/ |
| 桌面 | Tauri v2(apps/geolibre-desktop/src-tauri) |
安装包 / 便携版 / 商店包 |
| Jupyter | python/ 包内置一份 Web 构建 |
npm run build:embed → dist-embed → wheel |
23.1.2 包职责一览
| 包 | 职责 | 关键文件 |
|---|---|---|
@geolibre/core |
领域类型、.geolibre.json 工程 schema、Zustand store(唯一事实源) |
types.ts、project.ts、store.ts、store/、layer-selectors.ts |
@geolibre/map |
四种渲染引擎的生命周期与适配、图层同步、GeoJSON/栅格/瓦片/MBTiles/控件/选择高亮 | map-controller.ts、layer-sync.ts、*-engine.ts、layer-control-host.ts |
@geolibre/ui |
shadcn 风格 UI 原语 | 组件与 DirectionProvider |
@geolibre/processing |
客户端算法注册表与执行器 | registry.ts、runner.ts、vector-tools.ts、wasm-tool-runner.ts、sidecar-client.ts |
@geolibre/plugins |
插件接口、内置插件、插件宿主 | types.ts、plugin-manager.ts、plugins/ |
@geolibre/embed |
无依赖的类型化 iframe 客户端(发布到 npm) | src/index.ts |
@geolibre/collab-core |
与传输无关的协作协议、权限、校验(被两个 relay 共用) | 协议与 payload 校验 |
geolibre-desktop |
壳层:布局、Tauri I/O、各包组合 | src/main.tsx、src/App.tsx、hooks/usePlugins.ts、lib/app-api.ts |
导出面大小能说明“依赖方向”:@geolibre/plugins/src/index.ts 约 150 条 export、@geolibre/core 约 70 条、@geolibre/map 约 50 条、@geolibre/processing 约 36 条、@geolibre/ui 约 18 条、@geolibre/embed 约 13 条、@geolibre/collab-core 4 条。
依赖方向的硬规则:core 只依赖 zustand、zundo、uuid、@maplibre/maplibre-gl-style-spec——不依赖 maplibre-gl 本体。样式表达式求值用 style-spec 包,所以状态层与渲染层是真分开的。
提示:想判断“这段逻辑该放哪”时,用一条经验法则:能写进工程文件、且不依赖任何地图 SDK 的,放
core;要碰 SDK 的,放map;纯算法的,放processing。
23.1.3 常用命令的语义
| 命令 | 实际行为 |
|---|---|
npm run dev |
浏览器开发服务器 → http://localhost:5173 |
npm run tauri:dev |
桌面应用(文件对话框、本地 MBTiles/栅格必需) |
npm run build |
生产 Web 构建 → apps/geolibre-desktop/dist/ |
npm run typecheck |
等价于完整构建(tsc -b && vite build),会写 dist/,不是纯类型检查 |
npm run typecheck:fast |
tsc -b,只留增量缓存;先构建 @geolibre/embed(应用要用它的 dist/ 类型) |
npm run lite:build |
DuckDB-WASM 走 jsDelivr 的轻量构建,给有单资源体积上限的托管平台 |
npm run build:embed / build:jupyterlite / build:mcp-app / build:chrome-extension |
各交付产物的构建 |
npm run bundle:report |
构建后生成 bundle-report/bundle-report.md 与 .json |
23.2 store-driven 单向数据流
23.2.1 七步数据流
CLAUDE.md 把这条链写成了七步,值得一条条对上文件名:
- 数据入口:Add Data 菜单、Tauri 对话框、浏览器文件选择、拖拽、插件控件。
- 本地矢量转换:MapLibre 读不了的格式先在浏览器里由 DuckDB-WASM Spatial 转 GeoJSON(
INSTALL spatial; LOAD spatial;→ST_Read;GeoParquet 走 Parquet reader;zip Shapefile 先shpjs、读不了再交给 DuckDB;KMZ 客户端解压)。 - 写 store:转换结果交给
addGeoJsonLayer;瓦片/服务/栅格/ArcGIS/MBTiles/插件图层则创建GeoLibreLayer记录(带 source 元数据与原生图层 id)。 - 引擎订阅:选中的
MapEngine订阅layers并与自己的 SDK 对账。MapLibre 走MapController.syncLayers,另三种引擎走各自适配器;两个 Style Spec 引擎共用的图层控件桥在packages/map/src/layer-control-host.ts。 - 样式/面板改动:改的是图层状态,再由 sync 落到 paint、可见性、不透明度、顺序与删除。
- 属性表选择:更新高亮要素源,可缩放地图到选中要素。
- 保存:
projectFromStore写.geolibre(旧名.geolibre.json仍可读)。
禁止的做法:UI 里直接 map.addLayer()/setPaintProperty()。改 store,让 sync 去应用。这不是洁癖——四引擎、分屏 pane、工程保存、撤销栈全都建立在这条约束上。
23.2.2 为什么每层编辑都会重渲染,以及怎么避免
任何一次图层操作都会替换整个 layers 数组,所以 useAppStore((s) => s.layers) 会在任何图层任何字段变化时重渲染。只有真正需要“每条记录的所有字段”的订阅者该这么写:地图画布的 sync、图层面板。其余一律用 packages/core/src/layer-selectors.ts 的窄钩子:
| 钩子 | 返回 | 适用 |
|---|---|---|
useLayer(id) |
单条图层记录 | 单图层详情面板 |
useLayerIds() |
id 列表 | 只关心“有几层/顺序”的列表 |
useLayerSummaries() |
{ id, name, type, visible, groupId }[] |
图层面板的行 |
useLayersWhen(open) |
仅在 open 期间订阅完整数组 |
常挂载但只在打开时用全量的对话框 |
23.2.3 store 的切片与撤销栈
useAppStore 是一个 Zustand store,但代码按切片拆在 packages/core/src/store/:project、map-view、layers、layer-groups、project-content、processing、libraries、session、collaboration、capabilities、ui(各类对话框开关),在 packages/core/src/store.ts 组合。
| 规则 | 说明 |
|---|---|
| 新字段/新动作放哪 | 放进拥有该数据的切片;必须同时改别的切片字段的动作(如 removeLayer 要清各处引用)通过 set/get 读写整个 state |
| 撤销历史跟踪什么 | 只有 partializeHistory 列出的字段:图层、图层组、底图、故事地图、评论、仪表盘组件、图例 |
| 不跟踪什么 | ui 整个子状态、选择、相机都不记录撤销步,也不会被 undo/redo 还原 |
| 次级视图 | 分屏 pane 与 Print Layout 引用图层但不进历史,每一步 undo/redo 都会去清理它们对“已被删图层”的引用 |
23.2.4 一次图层编辑的完整调用链
以“在样式面板把填充色改掉”为例,链条是这样的:
components/panels/StylePanel*派发一个 store 动作(layers-slice),不改 MapLibre。- store 生成新的
layers数组(新引用,旧记录被替换)。 MapCanvas的订阅触发,MapController.syncLayers(prev, next)做差量对账。layer-sync.ts把样式变化翻译成setPaintProperty/setLayoutProperty/moveLayer等调用。- 图层控件(
layer-control-host.ts)与图例/色标等派生物跟着刷新。 - 若记录被标记进撤销历史,
store/undo-history.ts在合并窗口内记一步。
提示:调试时在第 2 步下断点看“新旧数组谁被替换了”,比在第 4 步猜
setPaintProperty为什么没生效快得多。
23.3 项目 schema 与类型系统
23.3.1 常量数组派生类型:一份定义两处使用
packages/core/src/types.ts 里 20 个图层类型是这么写的:
export const LAYER_TYPES = [
"geojson", "raster", "wms", "wmts", "xyz", "vector-tiles", "arcgis",
"pmtiles", "mbtiles", "zarr", "lidar", "gaussian-splat", "3d-tiles",
"cog", "flatgeobuf", "geoparquet", "duckdb-query", "deckgl-viz",
"video", "image",
] as const;
export type LayerType = (typeof LAYER_TYPES)[number];
注释说明了原因:写成运行时数组,才能校验不可信输入(导入的 Layer Library bundle、手改过的工程);LayerType 由数组派生,两者不可能漂移。这是 TypeScript 项目里“有限枚举 + 外部输入校验”的标准写法。
23.3.2 视图状态刻意做小
export interface MapViewState {
center: [number, number];
zoom: number; bearing: number; pitch: number;
bbox?: [number, number, number, number];
}
只有五个字段是刻意的:状态越小,越有把握序列化进工程文件,接第二个消费者(第二个地图面板、另一个渲染器、一个导出器)的成本越低。
23.3.3 工程文件的读写边界
| 方向 | 入口 |
|---|---|
| 写 | projectFromStore → .geolibre |
| 读 | 工程 schema 校验 + normalize* 归一化函数(如 normalizeSecondaryMapViews、normalizePrimaryRenderer) |
| 兼容 | 旧名 .geolibre.json 仍可读;只在非默认时才写 primaryRenderer,所以 2D 工程与旧版本字节一致 |
注意:
getProjectState/applyProjectState是插件自己的序列化钩子,和工程 schema 是两层。插件状态写进plugins.settings,且在分享/导出/协作快照里会被整段删除(见第21章)。
23.4 四种渲染引擎的抽象
23.4.1 MapEngine 与两个“选择器”
store 是渲染无关的:里面是普通 GeoLibreLayer 记录、偏好与 MapViewState,没有 SDK 对象。MapLibre、Mapbox、Cesium、ArcGIS 各自实现共享的 MapEngine 接口(相机操作、能力查询、拾取、控件、放置、范围绘制、截图)。
| 选择器 | 决定什么 |
|---|---|
primaryRenderer |
主画布用哪个引擎(View → Rendering engine → Cesium 等) |
SecondaryMapView.viewKind |
每个分屏 pane 用哪个引擎(pane 自带相机与可见性覆盖) |
改任一个值都会卸载旧引擎、把新引擎接到同一份工程状态上——不是隐藏,是真的 unmount,GPU 资源随之释放。
能力是显式声明而非假设:壳层按当前 MapEngine 门控命令、图层报告引擎兼容性、插件声明 engines。这让“切引擎后保留一个不支持的图层/挂起一个不支持的插件”成为可能,而不必要求每个 SDK 去模拟全部特性。
23.4.2 反跨引擎导入:一条被 ESLint 强制的边界
在 packages/map/src 里,以引擎命名的文件(arcgis-*、cesium-*、mapbox-*,以及 MapLibre 的 layer-sync、map-controller、MapCanvas)不得 import 另一个引擎的文件。两个引擎共用的代码放中性模块,按“它做什么”命名:pmtiles-archive.ts、protocol-tiles.ts、gl-style-compiler.ts、feature-style.ts。
这条边界由 ESLint 规则 local/no-cross-engine-imports(eslint-rules/no-cross-engine-imports.mjs)强制,规则里维护着分类清单。所以“加第五个引擎”不是把代码粘过来就行,得先想清楚哪些逻辑属于中性层。
23.4.3 Cesium 球面:懒加载、相机换算、图层同步
| 机制 | 实现 |
|---|---|
| 懒加载 | 整个 Cesium 引擎(约 4.8 MB)只在球面第一次挂载时 import(),单独成 chunk(vite.config.ts 的 CODE_SPLITTING_GROUPS),不进 2D 启动路径 |
| 预算门禁 | bootBundleBudgetPlugin 在启动 chunk 里出现 Cesium、或 JS 超过 3 MB 时构建失败 |
| 运行时资源 | vite-plugins/copy-cesium-assets.ts 把 Workers/Assets/Widgets 拷到 public/cesium/,画布设置 window.CESIUM_BASE_URL |
| 相机同步 | packages/map/src/cesium-camera.ts 在 MapLibre 的 Web Mercator 视图与 Cesium 相机之间换算,按地面分辨率(米/像素)对齐,并抑制 apply→moveEnd 回声 |
| 图层同步 | CesiumLayerSync(cesium-layer-sync.ts)像 MapController.syncLayers 那样对账 store,GeoJSON 走 drape 的 GeoJsonDataSource,栅格/WMS/ArcGIS MapServer/三维 Tiles 各用对应 provider |
| 插件控件 | CesiumControlHost 交给控件一张记录式 MapLibre 地图:样式调用被记录,由 CesiumLayerSync 绘制镜像图层,其余 GeoJSON 由 shadowOverlayGraphics 求值后画成贴地实体 |
几个只有在球面上才会遇到的细节:自定义图层会抛错(宁可挂不上,也不要“黑着挂上”);Cesium 会取消 pointerdown,所以宿主替控件重新派发被浏览器丢掉的 mousedown/mousemove/mouseup;插件必须用 engines: ["maplibre", "cesium"] 声明,并在 deactivate 里恢复场景状态。
23.4.4 引擎能力矩阵(要点)
| 能力 | MapLibre | Mapbox | Cesium | ArcGIS |
|---|---|---|---|---|
自定义协议(addProtocol) |
有 | 无 | 无 | 无 |
transformRequest(瓦片请求头) |
有 | 仅构造时 | 无 | 无 |
CustomLayerInterface |
有 | capabilities.customLayers 为 false |
抛错 | 抛错 |
| 插件控件宿主 | 真地图 | Style Spec 适配器 | 记录式门面 | 记录式门面 |
| 三维地形/倾斜 | 部分 | 部分 | 原生 | 原生(本地场景) |
23.5 Processing 注册表与 runner
23.5.1 三层结构
packages/processing/src 的组织方式值得单独看,因为它把“注册什么工具”和“怎么执行”彻底分开:
| 层 | 文件 | 职责 |
|---|---|---|
| 注册表 | registry.ts、types.ts、vector-tool-registry.ts |
工具元数据(id、分组、参数 schema)+ 查找 |
| 执行器 | runner.ts、vector-tool-runner.ts、wasm-tool-runner.ts |
选择引擎、组织输入输出、进度与取消 |
| 引擎客户端 | sidecar-client.ts、wasm-client.ts、raster-client.ts |
各自协议的传输层 |
| 工具实现 | vector-tools.ts(13.7 万字节)、raster-tools.ts、statistics-tools.ts、terrain-viewshed.ts、obia.ts、network-tools.ts、topology-tools.ts |
具体算法 |
| Worker | vector-tool.worker.ts、wasm-tool.worker.ts、terrain-viewshed.worker.ts、obia-felzenszwalb.worker.ts |
把重计算移出主线程 |
23.5.2 一个算法三种跑法
矢量工具是三引擎可选的:
| 引擎 | 实现 | 何时使用 |
|---|---|---|
| Turf.js(客户端) | 纯 JS,默认引擎 | 不需要侧车、开箱即用 |
| Sidecar(GeoPandas/Shapely) | 后端 /vector 端点,/vector/status 报可用性 |
装了 vector extra,需要投影感知结果 |
| Python(Pyodide) | 浏览器里跑同一份 GeoPandas/Shapely 代码 | Web 构建也想用 GeoPandas 路径 |
关键是同一份源码:几何逻辑抽成无框架模块 backend/geolibre_server/geolibre_server/vector_ops.py,sidecar 与浏览器都跑它。Vite 插件 vite-plugins/copy-vector-ops.ts 把它拷进应用包,经典 Web Worker public/pyodide/pyodide-worker.js 从 CDN 载入 Pyodide、装 geopandas、以 JSON 字符串边界调用 run_vector_tool。一份实现意味着两个引擎结果必然一致。
23.5.3 模型构建器与 WASM 工具箱
| 模块 | 说明 |
|---|---|
model-graph.ts |
模型(节点+连线)的图结构、拓扑与执行顺序 |
wasm-tool-runner.ts + wasm-client.ts |
Whitebox Next Gen 与自有 WASM 工具的执行(1000+ 工具) |
ort.ts |
onnxruntime-web;WASM 产物从 CDN 拉,CDN 版本必须与 npm pin 的版本严格一致 |
提示:凡“JS 胶水在包里、WASM 产物在 CDN”的库都有版本对齐坑。做法是把版本号写成常量,两边都从它取。
23.6 插件宿主与 app API 的实现位置
第21章讲的是接口契约,这里补“谁实现了它”:
| 角色 | 文件 | 说明 |
|---|---|---|
| 注册表与生命周期 | packages/plugins/src/plugin-manager.ts |
见 21.3.2 的激活流程 |
| app API 实现 | apps/geolibre-desktop/src/lib/app-api.ts(约 1100 行) |
createAppAPI(mapControllerRef, host),把 store、引擎引用、插件管理器、凭据宿主、助手注册表拼成 GeoLibreAppAPI |
| 注册与加载 | apps/geolibre-desktop/src/hooks/usePlugins.ts |
BUILT_IN_PLUGINS、外部插件加载、?plugin= 深链、项目插件状态持久化 |
| 作用域包装 | plugin-manager.ts 的 scopeAppToPlugin |
注入 owner、限制激活期注册 |
AppApiHost 是壳层与插件 API 的接缝:它要求宿主提供 plugins(管理器子集)、projectPluginStateSnapshot、persistProjectPluginState、reportPluginError、bindTemporalLayer、buildProjectSnapshot、credentials、getCesiumScene 等能力。想在没有 Tauri/React 的环境里复用插件 API,就实现这个接口,而不是 fork app-api.ts。
// 形状示意(字段名取自 app-api.ts 的 AppApiHost)
export interface AppApiHost extends Pick</* store/引擎/设置等宿主能力 */ any, never> {
plugins: Pick<PluginManager, "isActive" | "activate" | "deactivate" | "list">;
projectPluginStateSnapshot: () => unknown;
persistProjectPluginState: (previousJson: string) => void;
reportPluginError: (pluginId: string, action: string, error: unknown) => void;
credentials: typeof pluginCredentialHost;
}
注意:上面这段是结构示意,不是可直接编译的声明——真实接口在
app-api.ts顶部,字段更多且带精确类型。读源码时从createAppAPI往下看,它的每个成员都能对应到 store 动作或引擎方法。
23.7 sidecar 通信
23.7.1 进程与路由
backend/geolibre_server 是一个可选的 FastAPI 服务,跑在 127.0.0.1:8765,桌面端按需启动,把重的 Python 处理栈留在浏览器包外。
路由模块(backend/geolibre_server/geolibre_server/app/) |
能力 |
|---|---|
raster.py |
栅格工具箱(rasterio/numpy/contourpy) |
vector.py |
可选 GeoPandas 矢量引擎(/vector、/vector/status) |
sql.py |
Sedona 引擎(/sql/status、/sql/run) |
postgis.py / mssql.py |
把 PostGIS / SQL Server 表读成可编辑图层并写回 |
pointcloud.py |
把点云标注写回整份 LAS/LAZ/COPC |
ml.py |
反向代理 AI 分割到 samgeo-api(SAM 3 需要多 GB 的 GPU Torch 构建,故独立进程) |
23.7.2 浏览器构建怎么连它
| 事项 | 做法 |
|---|---|
| 同源代理 | 浏览器构建把 sidecar 代理在 /sidecar,避免 CORS |
| 路径限制 | 限制在 GEOLIBRE_CONVERSION_ROOTS(默认 /data)内 |
| 可选依赖 | extras:conversion、vector、raster;sedona 另装 |
| 本地 MBTiles | 不走 HTTP,用 Tauri 命令支撑的自定义 MapLibre 协议 |
| 容器 | 镜像内置 uvicorn 并反向代理 /sidecar,仅当策略授予 processing:run 或 data:add 且未设 GEOLIBRE_DISABLE_SIDECAR=1 时启动 |
警告:客户端策略与 nginx 守卫不强制桌面端处理、浏览器 WASM、单独暴露的 sidecar,也不强制插件执行。安全边界要按部署形态分别评估(见第22章)。
23.8 PWA 与离线
23.8.1 三层缓存
独立 Web 构建是可安装的 PWA:vite-plugin-pwa(配置在 apps/geolibre-desktop/vite.config.ts)产出 webmanifest 与 Workbox service worker,src/main.tsx 把它与 installStaleChunkReload 一起注册。Service Worker 只为 Web 构建生成:Tauri 桌面端(资源已随包)与 Jupyter wheel(GEOLIBRE_EMBED=1)里 registerSW 是空操作。
| 层 | 策略 | 内容 | 为什么这样分 |
|---|---|---|---|
| 预缓存 | Precache | HTML + 启动地图必需的 JS/CSS chunk | 首访后无网也能开壳;重 chunk 排除在外,避免首屏巨量下载 |
| 同源运行时 | CacheFirst | /assets/ 下的内容哈希产物:MapLibre、DuckDB-WASM 及其 spatial 扩展、插件 chunk |
哈希文件名让 CacheFirst 安全:重新部署即新 URL,不会把旧条目当新的 |
| CDN 引擎 | CacheFirst(规则 geolibre-cdn-engines) |
jsDelivr 上的 Pyodide、PGlite/PostGIS、CereusDB、gdal3.js | URL 里嵌精确版本号;jsDelivr 的 CORS 让响应是正常 200 而非 opaque,可校验可淘汰 |
所以“CDN 引擎离线可用”的准确表述是:首次成功取回需要网络,CacheFirst 只有真存下响应后才走缓存;此后离线可用。桌面端根本不注册 Service Worker,这条对它不成立。
23.8.2 更新与页面恢复:一处教训
新部署用 registerType: "autoUpdate"(skipWaiting + clientsClaim),但 Workbox 默认的“激活时强制刷新”被故意压掉(src/main.tsx 的 onNeedReload):在 /demo/ 这种相对 base 的子路径下它会误触发,把用户正在编辑的地图状态冲掉。页面恢复交给 installStaleChunkReload——只在孤儿 lazy chunk 404 时才重载,且带冷却保护;sessionStorage 被禁时跳过重载,让预加载错误照常暴露。
23.8.3 离线相关构建开关
| 变量 | 作用 |
|---|---|
GEOLIBRE_PGLITE_CDN=0 / GEOLIBRE_CEREUS_CDN=0 |
把 PGlite/PostGIS、CereusDB 塞回 /assets/(PGlite 一个就给 Tauri 二进制加回约 22 MB) |
GEOLIBRE_GDAL_CDN=0 |
gdal3.js 从不打包;置 0 等于关掉依赖它的导出功能 |
GEOLIBRE_DUCKDB_WASM_CDN=1 |
DuckDB 改从 jsDelivr 取(不能与 GEOLIBRE_NO_EXTERNAL_CDN=1 同时用) |
GEOLIBRE_NO_EXTERNAL_CDN=1 |
隐含 GEOLIBRE_PGLITE_CDN=0、GEOLIBRE_CEREUS_CDN=0、GEOLIBRE_GDAL_CDN=0、GEOLIBRE_DUCKDB_WASM_CDN=0,并禁用源码里嵌了 CDN URL 的功能(故事地图 HTML 导出、内置检测模型、ONNX WASM 等) |
VITE_PYODIDE_INDEX_URL |
指向自建 Pyodide 镜像(注意:除非镜像在 /assets/ 下,否则落入普通 HTTP 缓存而非 CacheFirst 规则) |
VITE_DUCKDB_SPATIAL_EXTENSION_PATH |
直接 LOAD 本地 spatial 扩展,跳过 INSTALL(内网/离线/桌面的关键一行) |
# 把 PGlite/PostGIS 与 CereusDB 内置,去掉首次联网依赖(代价见上表)
GEOLIBRE_PGLITE_CDN=0 GEOLIBRE_CEREUS_CDN=0 npm run build
# 完全禁止 GeoLibre 控制的外部 CDN(会关掉依赖它们的若干功能)
GEOLIBRE_NO_EXTERNAL_CDN=1 npm run build
23.9 容器与安全
23.9.1 镜像启动时发生了什么
根 Dockerfile 打包的是浏览器应用:Node 构建阶段跑 geolibre-desktop workspace 构建,再把 apps/geolibre-desktop/dist 拷进 nginx;nginx 提供静态资源并对浏览器入口 URL 回退 index.html。
启动时 entrypoint 依次做五件事:
- 校验选定的来源策略(policy source)。
- 应用非空的运行时环境覆盖。
- 生成 nginx 守卫规则。
- 视策略条件启动 Python sidecar。
- 提供生成的公开
/deployment.json。
浏览器在首次渲染前拉取这份策略,然后按 policy → runtime environment → build environment 的顺序解析设置。镜像名是 ghcr.io/opengeos/geolibre,PR 构建、main 推送、版本 tag、手动运行都会发布。
23.9.2 CSP:两个文件、一个必须同步的清单
| 构建 | CSP 位置 | 关键点 |
|---|---|---|
| 桌面(Tauri) | apps/geolibre-desktop/src-tauri/tauri.conf.json |
script-src 钉到带版本号的 jsDelivr 路径 + blob:(外部插件用 blob URL 执行);connect-src 对 https:/http: 开放(用户随便填的数据源) |
| Web(Docker) | docker/nginx.conf |
同一份脚本清单,另有 worker-src blob:(CDN worker 需要同源 blob 包装) |
tests/tauri-csp.test.ts 会重新推导这些路径,所以升级 CDN 脚本版本时,两处 CSP 必须同步改,否则测试失败。这是“别把版本写死在两个地方”的可执行版本。
23.9.3 凭据存储:三套后端、一条读取通道
| 后端 | 存哪 | 细节 |
|---|---|---|
| 桌面 | OS 凭据库(Keychain / Credential Manager / Secret Service),服务名 org.geolibre.desktop |
Tauri 命令 secure_store_*(src-tauri/src/secure_store.rs);geolibre.desktopSettings 里的同名字段留空 |
| Web / Jupyter / 移动 | localStorage |
插件凭据为 geolibre.pluginCredential.<pluginId>.<name>;Web OAuth refresh token 放 tab 级 sessionStorage |
| 工程文件 | 不进文件 | 保存时问“保留明文还是剥离”,从不未经选择就写入 |
读取通道的设计值得单独记:lib/credential-hydration.ts 在应用渲染前把凭据载入内存,并迁移旧版本留下的明文值(每次删除都发生在其 keychain 写入成功之后)。secure_store_get_many 每个 webview 页面加载只应答一次,lib.rs 里的 reload 钩子会重新打开;而 secure_store_seal(外部插件加载器在 import 前调用)关闭读取而不读,所以插件代码拿不到已存 token。写入与删除始终开放。
两个顺序性细节:凭据索引 geolibre.postgres.connectionIds 在写入新条目之前写,崩溃不会留下无引用的凭据;启动时若发现“有索引无凭据”,删索引并告警。OAuth refresh token 按 issuer 存为 share.oauth.refreshToken.<issuer>,且 issuer 先记入非密的 geolibre.share.oauth.unsavedIssuers,写入完成后才移除——启动时发现自己被列在其中就删 token 并以登出状态启动,而不是信任一个可能已被消费的 token。
23.9.4 原生 HTTP 与 mTLS
桌面端把部分远程请求(瓦片/URL 解析、OGC GetCapabilities 等)交给 Rust 进程而不是 WebView。那条路径(src-tauri/src/lib.rs 的 guarded_http_client)除内置 Mozilla 根证书外,还信任操作系统证书库,所以企业 CA 签发的服务无需额外配置。
| 环境变量 | 用途 |
|---|---|
GEOLIBRE_HTTP_CA_CERT |
额外信任的 CA PEM 包(给未装进系统库的私有 CA) |
GEOLIBRE_HTTP_CLIENT_CERT |
要出示的客户端证书;.pem(证书链 + 未加密 PKCS#8 私钥)或 PKCS#12(.p12/.pfx) |
GEOLIBRE_HTTP_CLIENT_CERT_PASSWORD |
PKCS#12 口令;它的存在也会强制走 PKCS#12 分支 |
警告:配置的客户端证书挂在共享的原生 HTTP 客户端上,因此任何在原生 fetch 中索要客户端证书的 HTTPS 服务器都会收到它,而不只是签发它的那个端点。只在所有目标主机都可信时配置。
23.10 源码级可复用经验
23.10.1 DuckDB-WASM Spatial:一个库同时当格式驱动和计算引擎
WebAssembly 只是“能跑原生代码”的能力;DuckDB-WASM 是 DuckDB 的 WASM 产物;spatial 扩展是另一份独立 .wasm,要单独 INSTALL/LOAD,不加载就没有任何空间函数。ST_Read 背后是扩展自带的 GDAL 子集,能读哪些取决于当前构建(SELECT * FROM ST_Drivers() 可列)。
| 用途 | 走的接口 | 备注 |
|---|---|---|
| GeoParquet | read_parquet |
本地与远程都走它,远程按 HTTP Range |
| FlatGeobuf / GML / DXF / TAB 等 | ST_Read |
GDAL 后端 |
| Shapefile(zip) | 先 shpjs,读不了再交给 Spatial |
轻量优先,重武器兜底 |
| KML | 先自研解析器(保留符号化),失败再交给 Spatial | Spatial 只有几何,样式会丢 |
| CSV 里的 WKT | DuckDB SQL | 文本几何转图层 |
| SQL 工作区 | 完整 DuckDB SQL | 已加载图层注册成表,可 JOIN |
四个可直接搬走的实现细节:
- bundle 按浏览器能力在运行时选:
duckdb-wasm-bundles.ts用 Vite 的?url拿到 mvp/eh 两份产物,交给duckdb.selectBundle();手动列 bundle 是为了让 Vite 把 wasm 与 worker 打成带哈希的本地产物,才能被 Service Worker 的 CacheFirst 安全缓存。 - 扩展加载按实例记忆化:缓存的是 Promise 不是布尔值(并发共享同一次加载),用
WeakMap按 DB 实例存(实例重建即失效),失败时先校验身份再清除(避免清掉别人刚写入的新加载)。三条缺一条,生产环境就会出现“偶发的空间函数不存在”。 - 留一个离线口子:
VITE_DUCKDB_SPATIAL_EXTENSION_PATH配了就直接LOAD本地路径,跳过INSTALL。同样的模式也用于 h3 社区扩展(INSTALL h3 FROM community)。 - Worker 与生命周期自己管:WASM 堆跟着 Worker 走,只有
terminate()才真正还回系统;SQL 工作区实例做引用计数,有查询在跑就标记“空闲时销毁”。
还有一个真实的坑值得写进教科书:duckdb-wasm 1.33.1-dev45 会永久破坏一个实例的远程 read_parquet——只要该实例在做完第一次远程读取之前执行了 LOAD spatial,之后所有远程 Parquet 都报 stoi: no conversion,且无法原地恢复。GeoLibre 的应对是两手:预热(ensureSpatialExtension 的 beforeLoad 钩子先用 SELECT 1 FROM read_parquet(...) LIMIT 0 只取 footer)与重建(捕获 stoi: no conversion → resetSqlDatabase(db) 换实例 → 重试一次,且只在语句里确实有远程 reader 调用时触发)。
边界也要记住:远程文件上限 2 GiB(32 位存大小,且在开始摄取之前判定)、单标签页内存约 4 GiB、要素数确认阈值 500,000、单线程;拷贝模式内存随解压后数据量走,超过 100 MB 会提示改用流式,而真正“原地查询不拷贝”的只有 GeoParquet——所以那个按钮只对它显示。
23.10.2 阈值化:定好数字,过线换实现
| 常量 | 值 | 位置 | 保护什么 |
|---|---|---|---|
LARGE_VECTOR_FEATURE_THRESHOLD |
50,000 | core/src/types.ts |
主线程 GeoJSON 解析 |
maxHistoryFeatureCount |
500,000 | core/src/history.ts |
撤销栈内存 |
DUCKDB_VECTOR_FEATURE_WARN_COUNT |
100,000 | core/src/types.ts |
结果物化内存 |
MAX_CEREUS_FEATURES |
50,000 | lib/sedona-workspace.ts |
WASM 堆 |
MAX_DERIVED_FEATURES |
50,000 | map/src/derived-geometry.ts |
派生几何计算 |
historyCoalesceMs |
400 ms | core/src/history.ts |
撤销记录爆炸 |
这些阈值全是命名常量 + 文档注释,且 historyCoalesceMs、maxHistoryFeatureCount 都有 setter 可运行时改(测试里设成 0)。判据很清楚:每个阈值都要说得出“它保护什么”,说不清的就难以维护。
23.10.3 客户端切片:超过 5 万要素就现场切瓦片
流水线在 packages/map/src/geojson-vt-protocol.ts(协议与注册表)与 geojson-vt-index.ts(建索引与编码):索引用 @maplibre/geojson-vt(点图层用 Supercluster),编码用 @maplibre/vt-pbf 的 fromGeojsonVt,通过自定义协议 geolibre-gjvt 喂给 MapLibre;TILE_EXTENT = 4096、TILE_MAX_ZOOM = 16(超过就让 MapLibre over-zoom);建索引与编码在 geojson-vt.worker.ts 里跑,没有 Worker 的环境下同一套代码在主线程跑。
两个细节:瓦片注册表放模块级 Map,不进 store(注释原话是“tile indexes are large, non-serializable objects that must not enter app state or be written to .geolibre.json”——这给出一个好判据:能写进工程文件的才进全局状态);编码前先看中止信号(if (signal?.aborted) return empty;),因为 MapLibre 会取消滚出屏幕的瓦片请求,结果反正会被丢掉。
23.10.4 撤销栈的三个精细处理
packages/core/src/history.ts 用 zundo 做时间旅行,但每个快照都持有图层完整 GeoJSON,反复编辑会把多份副本钉在内存里。处理方式:
- 软预算按要素数算,不按字节:为了度量内存去序列化几何,本身就是性能问题。
- 永远保留最新快照:从新往旧累计,装不下就停——但最新一个即使单独超预算也保留。“内存不够就清空撤销栈”意味着用户刚做完大编辑时恰好没有撤销。
- 按对象引用去重:
distinctFeatureCount用Set记住见过的 payload,没改动的图层在多个快照间共享同一引用只算一次,于是“保留很多小图层的快照”几乎无额外开销。
另外那个 400 ms 不是普通防抖,而是前沿防抖(leading-edge):要记录的是“这串连续操作之前”的状态,所以必须在第一次触发时就记;尾部防抖存下来的是拖拽途中的中间值。
23.10.5 虚拟化只管渲染
属性表用 @tanstack/react-virtual 虚拟化,但排序、筛选、选择跑在完整数据模型上。很多虚拟列表实现“只对已渲染的行排序”,于是排序结果随滚动变化、全选只选可见行——而这类 bug 在小数据量测试时根本复现不出来。
23.10.6 懒加载与体积账本
| 重资源 | 体积 | 处理 |
|---|---|---|
| CesiumJS | 约 4.7–4.8 MB | 独立 chunk,切到球面才 import(),且构建门禁阻止它进启动图 |
| PGlite + PostGIS | 约 25 MB(打进桌面二进制会多约 22 MB 几乎不可压缩体积) | 默认走 jsDelivr,不进构建;GEOLIBRE_PGLITE_CDN=0 可改内置 |
| gdal3.js | wasm 约 28 MB + data 约 12 MB | 从不打包,只从 CDN 取;GEOLIBRE_GDAL_CDN=0 等于关功能 |
| Pyodide / CereusDB | 各数十 MB | CDN 加载,后两者可用开关改成内置 |
| h5wasm | 约 4.8 MB | 曾因 Worker 各自打包被发了两份;现在主构建产出显式 chunk,Worker 按 URL import,且构建在出现非唯一 hdf5_hl-*.js 时失败 |
配套的一条硬要求:所有外部资源地址都做成运行时可配置(VITE_PYODIDE_INDEX_URL、VITE_DUCKDB_SPATIAL_EXTENSION_PATH、VITE_CESIUM_TOKEN),CDN 地址、WASM 版本、镜像路径三样任何一样写死,等着的就是现场断网时重新打包。
23.10.7 跨语言常量的 SYNC: 标记
那 17 个可恢复矢量扩展名在两处重复:TS 的 VECTOR_FILE_DIALOG_EXTENSIONS(lib/file-io/paths.ts)与 Rust 的 RESTORABLE_VECTOR_EXTENSIONS: [&str; 17](src-tauri/src/lib.rs)——跨语言没法共享常量。处理方式是在两边都写注释,并用统一可 grep 的标记把它们钉在一起:
SYNC: RESTORABLE_VECTOR_EXTENSIONS in src-tauri/src/lib.rs must list the same
extensions, or a format added here would be rejected by the Rust restore guard
on every project reopen. Grep "SYNC:" to find the partner list.
能合并就合并;合并不了(跨语言、跨进程、跨仓库)就用统一标记钉住,并在注释里写清“不同步会出什么事”。 这比“大家注意保持一致”有用得多。
23.10.8 云原生格式:换掉的是分发架构,不只是前端库
传统链路是“数据入库 → GeoServer/ArcGIS Server 发布 → 切瓦片 → 前端请求服务”,要养一台服务器、一套发布流程、一份切片缓存,还要为它做监控、备份、扩容。云原生格式把这条路换成“一个文件 + 一个静态服务器 + HTTP Range”。
| 格式 | 它替代了什么 | 直读的硬前提 |
|---|---|---|
| PMTiles | 切片服务与瓦片缓存 | 存储支持 Range 与 CORS;单文件即整层数据 |
| COG | 影像服务/瓦片金字塔 | 文件内部要有分块与金字塔;改后缀不会把 TIFF 变成 COG |
| GeoParquet | 空间数据库导出与格式转换 | 列式 + 行组统计,read_parquet 按 Range 取所需部分 |
| Zarr | 时序/多维栅格服务 | 分块存储 + 元数据 JSON;多维切片靠 selector |
四条可迁移的经验:
- 真正的门槛不是格式转换,是 CORS 与 Range。 存储不返回
Accept-Ranges,再好的格式也只能整份下载。 - 目录浏览器只该做一件事:产出 URL。 多数据源接入要收敛到同一套格式读取实现,面板不要各自写解析。
- 对着外部 API 编程时校验解析后的结构,不要只看
response.ok——STAC/Catalog 的返回形状会变。 - 元数据要单独想清楚:哪些字段必须走代理(鉴权、许可、配额),哪些可以公开在静态 JSON 里。
提示:投入产出比最高的一步通常是 PMTiles——一个文件替掉一整套瓦片发布流程,很多项目一天内就能验证完。
23.11 二次开发指南
23.11.1 改 UI
| 目标 | 该动的地方 |
|---|---|
| 面板/对话框 | apps/geolibre-desktop/src/components/(panels/、layout/、dialogs/),原语用 @geolibre/ui |
| 菜单与命令 | src/components/toolbar/constants.ts(存catalog key 而非英文串)、src/lib/commands.ts |
| 新用户可见文案 | 先在 src/i18n/locales/en.json 加键,再用 t();i18next.d.ts 会把键类型化 |
| 布局方向 | 用 Tailwind 逻辑工具类(ms-/me-/start-/text-start),别用 ml-/left-(local/no-physical-tailwind 会告警) |
| 第三方控件样式 | src/index.css 里按控件类名收窄覆盖,不改 node_modules |
23.11.2 加一个客户端处理工具
- 在
packages/processing/src的对应注册表模块里加工具定义(id、分组、参数 schema、执行函数)。 - 若需要新引擎分支,改
vector-tool-runner.ts/wasm-tool-runner.ts,不要在注册表里写引擎判断。 - 在
packages/processing/src/index.ts导出。 - 重生成 i18n 基线:
npm run i18n:tools(只重写en.json的processing.toolMeta/toolGroup),i18n:tools:check在 CI 防漂移。 - 加测试:工具函数放纯模块,
tests/里直接 import 测;重计算的走 Worker 时,测试主线程侧的协议层。
23.11.3 加插件
见第21章 21.4.1 的三步(实现 → 从 packages/plugins/src/index.ts 导出 → 加入 usePlugins.ts 的 BUILT_IN_PLUGINS)。改完记得跑 npm run plugins:docs 并更新 scripts/plugin-reference.json 里的一行描述。
23.11.4 加一种渲染引擎
这是最大的一类改动,顺序建议如下:
- 先定能力边界:新引擎支持哪些图层类型、哪些交互、哪些插件面。写进能力声明,让壳层据此门控,而不是“先接上再说”。
- 实现
MapEngine:相机、能力查询、拾取、控件、放置、范围绘制、截图。 - 新建引擎文件:
packages/map/src/<engine>-*.ts,并只 import 中性模块;跨引擎 import 会被local/no-cross-engine-imports拦。 - 接两个选择器:主画布走
primaryRenderer,分屏走SecondaryMapView.viewKind。 - 接图层同步:仿
CesiumLayerSync写一个syncLayers等价物,复用同一份按 pane 的可见性覆盖与组效果。 - 控件宿主:若引擎不能直接跑 MapLibre 控件,就提供“记录式地图”(参考
CesiumControlHost/ ArcGIS 的同一套思路)。 - 门控:把不支持的菜单项灰掉,而不是让它点了没反应。
- 测试与文档:
tests/<engine>-*.test.ts,以及在docs/里加一篇<engine>-renderer.md。
23.11.5 加一个构建开关
| 步骤 | 说明 |
|---|---|
加进 BUILD_ENV_KEYS |
apps/geolibre-desktop/vite.config.ts 里的白名单;不在名单里的 VITE_ 变量会被 pruneBuildEnv() 删掉,表现为“总是 undefined” |
| 判断是否敏感 | 需要认证/计费的放进 CREDENTIAL_ENV_KEYS;可再分发构建(GEOLIBRE_EMBED=1 或 GEOLIBRE_STRIP_CREDENTIALS=1)里它们会被置空(不是删除,避免 .env 重新引入) |
| 读取方式 | 用 getBuildEnvironment()(@geolibre/core),不要自己读 import.meta.env——整对象读取会让 Vite 把整份 env 内联进每个触达的 chunk |
| 运行时覆盖 | 需要运行时可改的,走 getRuntimeEnvironment()(覆盖 window.__GEOLIBRE_RUNTIME_ENV__) |
| 策略面 | 若是部署能力,还要在 capabilities 与策略文档里登记 |
23.11.6 打补丁与发布包
| 场景 | 做法 |
|---|---|
| 修第三方包的 bug | patches/ 下放 patch,postinstall 跑 scripts/apply-dependency-patches.mjs 应用 |
| 上游未导出、被手抄的内部实现 | 在 docs/maintenance.md 里登记(“private internals”“mirrored by hand”),升级依赖前先读那一节并跑前端测试;漂移会静默失败 |
发布 @geolibre/core / @geolibre/map |
GitHub Release 由 publish-packages.yml 发布;仓库里包的 main/types 指向 TS 源码(Vite/tsc/tsx 都走包自己的 exports),npm tarball 走 dist,靠 publishConfig + scripts/prepare-npm-package.mjs 在上传前抬升字段;tests/prepare-npm-package.test.ts 守住两侧 |
| 内置 sidecar | backend/geolibre_server/uv.lock 必须提交(根 .gitignore 对它做了例外);桌面端用 uv run --frozen --project <resource dir> 启动,无锁文件会尝试写入只读目录并 exit 2 |
警告:不要为了“让 CI 绿”去改
dist指向。把顶层入口指到dist会让每个 import@geolibre/map子路径的前端测试ERR_MODULE_NOT_FOUND——dist是 gitignore 的,测试套件之前没有任何构建步骤。
23.11.7 加一种图层类型
新增 LayerType 是一次“横切”改动,按下面的顺序做可以少返工:
| 步骤 | 位置 | 检查点 |
|---|---|---|
| 1. 加类型常量 | core/src/types.ts 的 LAYER_TYPES |
由数组派生 LayerType,运行时校验自动生效 |
| 2. 定义记录字段 | GeoLibreLayer 的 source/metadata |
必须可 JSON 序列化;不要塞函数或 SDK 对象 |
| 3. 工程读写 | 工程 schema 与 normalize* |
老工程缺字段要能补默认值 |
| 4. 引擎同步 | map/src/layer-sync.ts + 各引擎适配器 |
不支持的引擎要保留记录并标记不可绘制,不能静默丢弃 |
| 5. 面板与样式 | 图层面板行、Style 面板分支 | 不可绘制的类型不该出现点了没反应的控件 |
| 6. 数据入口 | Add Data 对话框/插件 API | 走 addGeoJsonLayer 或对应 add*Layer,别直接 addSource |
| 7. 测试 | tests/<type>-*.test.ts |
至少覆盖“进 store → 出 paint/source”的一段 |
23.12 调试技巧
23.12.1 分层定位
| 现象 | 先看哪里 |
|---|---|
| 地图上没东西,但 store 里有图层 | MapController.syncLayers 与该图层类型的 sync 分支;瓦片类先看网络面板 |
| 面板不更新 | 是否用了窄钩子(useLayer/useLayerSummaries);是否在 act() 外改了 store |
| 换引擎后功能消失 | 该功能的 engines/能力声明;壳层是否把它门控掉了 |
| 构建报“启动包超预算” | bundle-report/bundle-report.json 里的最大启动 chunk;多半是 CODE_SPLITTING_GROUPS/manualChunks 把懒包拖上了启动路径 |
| 插件相关问题 | 第21章 21.15 的故障速查表 |
23.12.2 内置诊断模块
apps/geolibre-desktop/src/lib/diagnostics.ts 是统一诊断采集层:
| 导出 | 作用 |
|---|---|
DiagnosticCategory |
"app" \| "console" \| "map" \| "network" \| "runtime" |
DiagnosticLevel |
"error" \| "info" \| "warning" |
appendDiagnostic / clearDiagnostics |
写入/清空记录(带 id 与时间戳) |
getDiagnosticsSnapshot / useDiagnosticsSnapshot |
取快照 / React 订阅 |
installDiagnosticsCapture |
安装采集(返回卸载函数) |
observeNetworkResponses / setCaptureNetworkInfo |
网络响应观察与开关 |
EXPECTED_STATUS_HEADER / OPTIONAL_RESOURCE_HEADER |
与第21章 21.9.4 对应的两个客户端头常量 |
它把 4xx/5xx 与失败请求记为 error,把标了“预期状态”的记为 info——所以插件里那些“正常返回 400”的协议不会污染错误列表。
23.12.3 常用验证组合
npm run dev # 快速迭代 UI 与 store 逻辑
npm run tauri:dev # 必须验证文件对话框、原生 HTTP、凭据库时
npm run typecheck:fast # 纯类型检查(不写 dist)
npm run build && npm run bundle:report # 体积账本
npm run test:frontend -- tests/<name>.test.ts # 单个前端测试文件
npm run test:e2e:core # 建站并跑核心 e2e(需先装 chromium)
npm run test:frontend -- tests/<name>.test.ts 在 node --import tsx --test 下只会跑指定文件;直接写 node --import tsx --test tests/<name>.test.ts 也行。e2e 默认在 4173 端口用 vite preview 起构建产物,本地会复用已监听的服务器(E2E_PORT 可改端口)。
23.12.4 在测试里复现 store 场景
组件与 store 的测试不需要真实地图:tests/helpers/dom.ts 装好 happy-dom、注册 Vite 风格的 loader 钩子、并用 en.json 初始化 i18n;状态用 useAppStore.setState(...) 直接铺,断言看用户可见文本或 store 结果。地图相关的行为应尽量挪到 lib/ 的纯逻辑里测——一个需要真地图才能测的面板,本身就是设计信号。
23.13 本章小结
- 结构是“一个 monorepo + 两个 Python 组件”:
apps/(壳)、packages/(领域)、workers/(边缘)、backend/与python/。 - 数据流是单向的:入口 → DuckDB-WASM 转换 →
addGeoJsonLayer/GeoLibreLayer→ 引擎订阅 →syncLayers。UI 不直接改地图。 @geolibre/core不依赖maplibre-gl;常量数组派生类型、极小的MapViewState、切片化 store 与partializeHistory共同支撑“可序列化、可多消费者”。- 四引擎共用一个
MapEngine抽象与两份选择器(primaryRenderer、SecondaryView.viewKind),反跨引擎 import 由 ESLint 强制。 - Processing 分层为“注册表 / 执行器 / 引擎客户端 / Worker”;矢量工具在三引擎间共享同一份
vector_ops.py,结果必然一致。 - sidecar 是可选的重活后端:
127.0.0.1:8765,浏览器经/sidecar同源代理,路径限制在GEOLIBRE_CONVERSION_ROOTS。 - 离线靠三层 Workbox 缓存;桌面端不注册 Service Worker;自动更新不强制刷新页面。
- 安全的三根柱子:CSP(两处必须同步)、凭据存储(一次读取 + seal)、原生 HTTP 信任库与 mTLS。
- 值得复用的源码经验:按实例记忆化 Promise、阈值化、客户端切片 + Abort、撤销栈三处理、虚拟化只管渲染、懒加载账本、
SYNC:标记。
23.14 实践练习
- 追踪一条数据流:用
npm run dev加一个 5 万要素以上的 GeoJSON,在addGeoJsonLayer、MapController.syncLayers、geojson-vt-protocol.ts三处下断点,记录触发顺序,并说明为什么第 4 步之后才有瓦片请求。 - 写一个窄订阅:给图层面板加一个只显示“可见图层数”的小组件,要求只用
useLayerSummaries()(不得直接订阅layers),并用 React DevTools 验证其它字段变化时它不重渲染。 - 加一个处理工具:在
packages/processing/src加一个“按字段去重”的矢量工具,跑npm run i18n:tools,再补一个tests/里的纯函数测试。 - 读撤销栈:构造 3 个各 20 万要素的图层并连续改样式 20 次,观察内存与
maxHistoryFeatureCount的行为;解释“为什么最新快照一定保留”。 - 查一次 CSP 漂移:把
tauri.conf.json里某个 CDN 路径的版本号改错,跑npm run test:frontend -- tests/tauri-csp.test.ts,记录失败信息,再说明docker/nginx.conf为什么也要改。 - 算一次体积账:
npm run build && npm run bundle:report,找出启动集合里最大的三个 chunk,并判断它们是否“必须在启动路径上”。
23.15 延伸阅读
- 第08章 - 渲染引擎与三维可视化:四引擎的用户视角与分屏行为。
- 第12章 - 地理处理工具箱:Processing 注册表对应的界面与参数。
- 第22章 - 服务端、自托管与部署运维:容器、策略与 sidecar 的部署面。
- 第24章 - 测试、国际化与贡献流程:本章提到的测试、覆盖率棘轮与生成物检查的完整流程。