第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 → 实现 |