第19章 - 源码架构与二次开发

19.1 从一次渲染看模块划分

要读源码,先知道一次渲染里谁调谁。v6.17.0 的 src/ 目录按职责切得很干净:

src/
├── index.ts            对外导出(Map、控件、源、工具函数、全部类型)
├── ui/                 对外 API 层:map.ts、camera.ts、control/、handler/、popup.ts、marker.ts
├── style/              Style 与图层:style.ts、style_layer/、properties.ts、sky.ts、light.ts
├── source/             数据源:vector_tile_source.ts、geojson_source.ts、raster_dem_tile_source.ts…
├── tile/               瓦片生命周期:tile_manager.ts、tile.ts、tile_cache.ts
├── data/               瓦片解析后的结构:bucket.ts、feature_index.ts、array_types.g.ts
├── symbol/             符号布局与碰撞:placement.ts、collision_index.ts
├── render/             Painter 与绘制支撑:painter.ts、terrain.ts、subdivision.ts
├── webgl/              GL 层:context.ts、program.ts、draw/(每类图层的 draw_*.ts)
├── geo/                坐标与投影:lng_lat.ts、transform.ts、projection/
└── util/               基础设施:ajax.ts、worker_pool.ts、actor.ts、evented.ts

一次帧的调用链(ARCHITECTURE.md 与 src/render/painter.ts 一致):

Map._render()
  └─ Painter.render(style, options)
       └─ Painter.renderPass()               按 layer 顺序
            └─ drawXxxx(painter, source, layer, coords)
                 ├─ 取 program(ProgramCache 缓存编译结果)
                 ├─ 设置 uniform(图层属性 + 变换矩阵)
                 └─ gl.drawElements()

瓦片解析发生在 worker 侧:WorkerTile#parse() 反序列化 PBF,为同一”layout 家族”的图层建一个 Bucket,Bucket 直接持有顶点数组与索引数组,通过 transferable 传回主线程。主线程只管把 buffer 绑上去画。

这套结构决定了二次开发的三个切入点:

想做的事 切入点 稳定性
加一种数据格式 addProtocol 或 addSourceType 公开 API,稳定
加一种绘制效果 CustomLayerInterface 公开接口,稳定
改渲染管线本身 src/render/painter.ts、src/webgl/draw/ 内部实现,随版本变

19.2 对外边界:index.ts 导出什么

src/index.ts 是唯一的公开边界,package.json 的 exports 只暴露 dist/maplibre-gl.mjs 与类型声明。它导出的东西分四类:

  • 类:Map(别名 MapLibreMap)、Style、Marker、Popup、各控件、各 Source 类、LngLat、MercatorCoordinate、Hash、Evented
  • 全局函数:addProtocol、removeProtocol、addProjection、addSourceType、prewarm、clearPrewarmedResources、setWorkerCount、setMaxParallelImageRequests、setRTLTextPlugin、getVersion
  • 配置:config(MAX_PARALLEL_IMAGE_REQUESTS、REGISTERED_PROTOCOLS 等)
  • 类型:export type * from '@maplibre/maplibre-gl-style-spec',样式规范的类型全部再导出

写 TypeScript 时最容易踩的是第三类:MapOptions、IControl、CustomLayerInterface 都是 export type,必须 import type 或 import {type X},用普通 import 会报错或被摇掉。

19.3 线程模型:主线程 + Worker

MapLibre 把”解析”和”渲染”分在两个线程:

侧 文件 干什么
主线程 ui/map.ts、render/painter.ts 相机、事件、GL 绘制、DOM 覆盖物
Worker source/worker.ts、source/worker_tile.ts PBF 解析、几何布局、索引构建、supercluster 聚合

两侧通过 Actor 消息通信,消息类型集中在 src/util/actor_messages.ts 的 MessageType 枚举里(loadTile、getClusterExpansionZoom、setImages 等)。这条边界解释了若干”反直觉”的行为:

  • getClusterExpansionZoom 等 supercluster 方法是 Promise:要跨线程往返。
  • querySourceFeatures 只能查已加载瓦片:瓦片在 worker 里解析完才回到主线程。
  • 自定义源(addSourceType)如果要做解析,必须同时实现 worker 侧逻辑,否则拿不到 WorkerTile。

worker 数量默认 1(Safari 为 min(核心数/2, 3)),这意味着多个源的解析任务在同一个 worker 里排队——这是”开第二个矢量源后首屏变慢”的常见原因。

19.4 三个稳定的扩展点

19.4.1 addSourceType:自定义源

import {addSourceType, type SourceClass} from 'maplibre-gl';

class MySource extends Evented implements Source { /* … */ }

addSourceType('mysource', MySource as unknown as SourceClass, () => Promise.resolve());

这是最”重”的扩展:要实现完整 Source 接口(load、serialize、onAdd、onRemove 等),还要处理 worker 协议。除非确实需要新数据通道,否则优先用 addProtocol:它只需一个函数,且自动复用请求管理与缓存。

19.4.2 addProtocol:自定义协议

maplibregl.addProtocol('myscheme', async (params, abortController) => {
  const res = await fetch(params.url.replace('myscheme://', 'https://'), { signal: abortController.signal });
  if (!res.ok) throw new Error('HTTP ' + res.status);
  return { data: await res.arrayBuffer() };
});

协议名即 URL 前缀(getProtocol() 用 url.substring(0, url.indexOf('://')) 取键),官方文档明确:注册发生在主线程,worker 不认识该协议时会回调主线程的处理函数。要在 worker 里直接处理,得用 importScriptInWorkers 注入脚本并在 worker 里再注册一次。

19.4.3 addProjection:自定义 CRS

import {addProjection} from 'maplibre-gl';
addProjection(myCRS);

v6 新增的能力,允许渲染非墨卡托的预投影瓦片。这套改动贯穿 geo/projection/、transform、瓦片覆盖计算与查询,是理解 v6 架构变化的最好入口——developer-guides/custom-crs.md 讲清了”哪些地方必须走 WorldCoordinateHelper 而不是直接调墨卡托函数”。

19.4.4 消息类型:跨线程通信的实际形状

src/util/actor_messages.ts 里的 MessageType 定义了主线程与 worker 之间的全部消息。常见的几条:

MessageType 方向 载荷
loadTile 主 → worker 瓦片 id、源 id、图层集合、请求参数
reloadTile 主 → worker 同上,用于数据变化后重解析
getClusterExpansionZoom 主 → worker clusterId
querySourceFeatures 主 → worker sourceLayer、filter
setImages 主 → worker sprite 图集与可用图标列表

消息走 Actor.sendAsync() 时返回 Promise,走 send() 则不等结果。GeoJSONSource.getClusterExpansionZoom() 的实现只有一行证据:

async getClusterExpansionZoom(clusterId: number): Promise<number> {
    return (await this.actorPromise).sendAsync({
        type: MessageType.getClusterExpansionZoom,
        data: {type: this.type, clusterId, source: this.id}
    });
}

这也解释了为什么在 map.on('load') 里同步调用这些方法拿不到结果——它们必须 await。同一原因下,setClusterOptions() 也返回 Promise:改参数要重新在 worker 里聚合。

19.4.5 图层序列化的边界

Style.serialize() 组装 getStyle() 的返回值时,逐层调用 layer.serialize();但自定义图层不参与:

serializedLayers = this._serializedLayers = {};
for (const layerId of allLayerIds) {
    const layer = this._layers[layerId];
    if (layer.type !== 'custom') {
        serializedLayers[layerId] = layer.serialize();
    }
}

所以 map.setStyle(map.getStyle()) 这种”取出来再塞回去”的换肤写法会静默丢弃自定义图层。这不是 bug,而是设计使然:自定义图层的 render 是函数,无法 JSON 序列化。要在换样式后保留,得把 addLayer 逻辑挂在 style.load 上重放。

19.5 读源码的方法

从 API 反查实现。 想搞清 setPaintProperty 做了什么,从 ui/map.ts 找到方法,跟到 style/style.ts,再看它如何标记 _changed 并触发重绘。这条链路几乎覆盖所有样式类 API。

优先看 *.test.ts。 测试文件比实现更聚焦:src/ui/map_tests/map_terrain.test.ts 里能看到地形在各种边界条件下的预期行为,比读 terrain.ts 一千行更快建立心智模型。

用 developer-guides/ 先建地图。 仓库里的 life-of-a-tile.md、covering-tiles.md、custom-crs.md、globe.md 是官方维护的深度文档,比 README 有用得多。

改源码前先确认版本策略。 package.json 的 exports 只暴露 dist,意味着 fork 后必须重新打包;dist/maplibre-gl.d.ts 由 tsc 生成,类型改动要同步 tsdoc 注释。

19.6 打包与本地构建

git clone --depth 1 --branch v6.17.0 https://github.com/maplibre/maplibre-gl-js.git
cd maplibre-gl-js
npm ci
npm run build-dist        # 产出 dist/maplibre-gl.js 与 .mjs
npm run start             # 本地示例服务

构建用 rolldown(rolldown.config.ts),产物两类:dist/maplibre-gl.js(UMD/IIFE,全局 maplibregl)和 dist/maplibre-gl.mjs(ESM)。自己 fork 后替换 CDN 引用时,要同时替换 dist/maplibre-gl.css,否则控件样式会缺失。

调试源码时,在 package.json 里把 module 指向 src/index.ts 并在 Vite/Rspack 里加 .ts 解析,可以直接断点进 TS 源码,比读打包产物高效。

19.7 二次开发的边界

需求 推荐做法 不建议
新数据格式 addProtocol fork 后改 ajax.ts
新绘制效果 CustomLayerInterface 改 painter.ts 的 renderPass
新控件 实现 IControl 改 control/control.ts
新投影 addProjection 改 transform.ts
修 bug 提 PR 上游 / 本地 patch 长期维护 fork
加日志/埋点 包一层 Map 子类或代理 改内部 _render

判断标准是是否碰私有字段。公开 API 有语义化版本承诺,私有字段(_ 前缀、@internal 标注)随时可能改。源码里大量字段标了 @internal,它们在 dist 类型声明里会被剥离,用户代码引用会编译失败——这本身也是一种保护。

19.8 排错

现象 原因 修法
Custom layers cannot be serialized 调用了 layer.serialize() 自定义图层不参与序列化,改走 getStyle() 之外的路径
自定义源在 worker 里报 “unknown source type” 只注册了主线程 用 importScriptInWorkers 在 worker 里再注册
import type 报错 用了值导入语法引类型 改 import type {MapOptions}
fork 后样式丢失 只替换了 JS,没替换 CSS 同时部署 dist/maplibre-gl.css
升级后私有 API 失效 依赖了 @internal 字段 改用公开 API 或锁版本

19.9 本章小结

要点 事实
模块分层 ui(API)→ style(图层)→ tile/source(数据)→ render/webgl(绘制)
线程边界 解析在 worker,绘制在主线程,靠 Actor 消息通信
公开边界 src/index.ts,类型全部 export type
稳定扩展点 addProtocol、CustomLayerInterface、addProjection、addSourceType
worker 默认数 1(Safari 例外),解析任务排队是首屏慢的常见原因
阅读顺序 developer-guides/ → *.test.ts → 实现