写在前面
最近在维护一个带地图的项目,底图从高德换到天地图,用户还是反馈 API 消耗太大、成本太高。地图在这个项目里只是辅助,不需要导航级精度,所以想换一种便宜、自己能托管的方案。
第三方底图的麻烦其实不止贵:CORS 偶发失败、配额打满、样式突然 403,地图直接空白。更稳的做法是自己托管一份矢量瓦片。
调研下来选了 PMTiles。它不是一套"无服务器地图应用",而是一种单文件瓦片归档格式 :把 OSM 矢量瓦片打成一个 .pmtiles,放到支持 HTTP Range 的对象存储上。浏览器不会下载整档,只拉当前视口,每次平移/缩放大约 50--200KB。
接入很直接:在 MapLibre 上注册 pmtiles:// 协议,失败时再回退到免费底图。下面按这个套路做成一个 React 组件。
先说清楚边界
这套方案省的是计费底图 API,换来的是 OSM 数据和对象存储账单。它适合辅助底图,不适合当成高德 / 天地图的等价替换。
- 精度和要素:路网、POI、行政区划都来自 OpenStreetMap,国内细节和更新频率不如商业底图。
- 没有瓦片服务器进程:不用养 tileserver-gl 或 TileServer。运维从"养一套服务"变成"管一个文件 + CORS"。不是零维护。
- 文件体积 :行星数据大约 120GB(zoom 0--15),一般项目用不到。用
go-pmtiles extract按国家或 bbox 裁一块,通常是几 GB;--maxzoom每降一级,体积大约减半。 - 并没有完全自给 :切片可以放自己桶里,字体和精灵图默认仍走
protomaps.github.io;构图失败还会回退 OpenFreeMap。要完全断网可用,这两类静态资源也得镜像。 - 为什么不是 MBTiles:MBTiles 是 SQLite,通常还要一个读库的服务。PMTiles 是为 Range 设计的,对象存储直接出片。
行星文件从 Protomaps Builds 下载。切片必须放在支持 Range 的存储上(R2、S3、Nginx),并配好 CORS:允许前端 origin,以及 Range、HEAD、OPTIONS。
你最终会得到什么
- 一个
<Basemap />:挂载时初始化 MapLibre,卸载时销毁 - 主题可切换:
black/dark/light等 - 没配自托管 URL 时,自动用免费 OpenFreeMap
- PMTiles 加载失败(CORS、403、超时)时,按深浅主题回退,避免空白图
1. 安装依赖
bash
npm i maplibre-gl pmtiles @protomaps/basemaps
npm i -D @types/geojson
版本参考:
maplibre-gl^5pmtiles^4.4@protomaps/basemaps^5.7
在入口引入一次样式:
tsx
// src/main.tsx
import 'maplibre-gl/dist/maplibre-gl.css';
环境变量:
bash
# .env.local
# 留空 = 只用 OpenFreeMap(开源项目零成本可跑)
VITE_PMTILES_URL=https://your-cdn.example/planet.pmtiles
2. 注册协议(全局只做一次)
pmtiles:// 不是真实 URL,而是 MapLibre 自定义协议。库读归档目录后,对同一个 .pmtiles 发 Range。
React 18 Strict Mode 会把 effect 跑两遍,协议注册必须做成单飞,避免重复 addProtocol。
ts
// src/lib/pmtiles-protocol.ts
import maplibregl from 'maplibre-gl';
let registered = false;
let pending: Promise<void> | null = null;
export async function registerPMTilesProtocol(): Promise<void> {
if (registered) return;
pending ??= (async () => {
const { Protocol } = await import('pmtiles');
if (registered) return;
maplibregl.addProtocol('pmtiles', new Protocol().tile);
registered = true;
})().catch((err) => {
pending = null;
throw err;
});
await pending;
}
pmtiles 用动态 import:用户没用自托管底图时,不会把这包打进首屏。
3. 现场拼 Style,不要提交整份 JSON
@protomaps/basemaps 的 namedFlavor() 按主题生成图层。切片可以自托管;字体和精灵图默认仍走 Protomaps CDN。国内项目把 lang 设成 zh,注记会优先用 OSM 的中文名,没有再回退英文。
ts
// src/lib/basemap-style.ts
import type { StyleSpecification } from 'maplibre-gl';
import { registerPMTilesProtocol } from './pmtiles-protocol';
export type PMTilesTheme = 'black' | 'dark' | 'grayscale' | 'light' | 'white';
const TILES_URL = import.meta.env.VITE_PMTILES_URL ?? '';
export const hasPMTilesUrl = Boolean(TILES_URL);
export const FALLBACK_DARK = 'https://tiles.openfreemap.org/styles/dark';
export const FALLBACK_LIGHT = 'https://tiles.openfreemap.org/styles/positron';
export function isLightTheme(theme: string): boolean {
return theme === 'light' || theme === 'white' || theme === 'positron';
}
export async function buildPMTilesStyle(
flavor: PMTilesTheme,
): Promise<StyleSpecification | null> {
if (!hasPMTilesUrl) return null;
const { layers, namedFlavor } = await import('@protomaps/basemaps');
await registerPMTilesProtocol();
const spriteName = flavor === 'light' || flavor === 'white' ? 'light' : 'dark';
return {
version: 8,
glyphs: 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf',
sprite: `https://protomaps.github.io/basemaps-assets/sprites/v4/${spriteName}`,
sources: {
basemap: {
type: 'vector',
url: `pmtiles://${TILES_URL}`,
attribution:
'<a href="https://protomaps.com">Protomaps</a> © <a href="https://openstreetmap.org/copyright">OpenStreetMap</a>',
},
},
layers: layers('basemap', namedFlavor(flavor), { lang: 'zh' }) as StyleSpecification['layers'],
};
}
export async function resolveStyle(
flavor: PMTilesTheme,
): Promise<StyleSpecification | string> {
try {
const style = await buildPMTilesStyle(flavor);
if (style) return style;
} catch (err) {
console.warn('[basemap] PMTiles 不可用,改用 OpenFreeMap', err);
}
return isLightTheme(flavor) ? FALLBACK_LIGHT : FALLBACK_DARK;
}
注意:url 是 pmtiles:// + https://... 。写成 pmtiles://planet.pmtiles 或丢掉 https 都会解析失败。
4. React 地图组件
地图实例放 useRef,不要放进 state。style 解析是异步的,组件可能已卸载,要用取消标记。
tsx
// src/components/Basemap.tsx
import { useEffect, useRef } from 'react';
import maplibregl, { type Map as MapLibreMap } from 'maplibre-gl';
import {
FALLBACK_DARK,
FALLBACK_LIGHT,
isLightTheme,
resolveStyle,
type PMTilesTheme,
} from '../lib/basemap-style';
type Props = {
theme?: PMTilesTheme;
center?: [number, number];
zoom?: number;
};
export function Basemap({
theme = 'black',
center = [116.4, 39.9],
zoom = 4,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<MapLibreMap | null>(null);
const usedFallbackRef = useRef(false);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
let cancelled = false;
let map: MapLibreMap | null = null;
const attachFallbackMonitor = (instance: MapLibreMap) => {
let ok = false;
let errCount = 0;
let timer: number | null = null;
const cleanup = () => {
instance.off('error', onError);
instance.off('data', onData);
if (timer != null) window.clearTimeout(timer);
};
const fallback = () => {
if (usedFallbackRef.current) return;
usedFallbackRef.current = true;
cleanup();
const next = isLightTheme(theme) ? FALLBACK_LIGHT : FALLBACK_DARK;
console.warn('[basemap] 瓦片失败,回退到', next);
instance.setStyle(next, { diff: false });
};
const onError = (e: { error?: Error; message?: string }) => {
const msg = e.error?.message ?? e.message ?? '';
if (!/Failed to fetch|AJAXError|CORS|NetworkError|403|Forbidden/.test(msg)) {
return;
}
errCount += 1;
if (!ok && errCount >= 2) fallback();
};
const onData = (e: { dataType?: string }) => {
if (e.dataType === 'source') {
ok = true;
cleanup();
}
};
instance.on('error', onError);
instance.on('data', onData);
timer = window.setTimeout(() => {
if (!ok) fallback();
}, 10_000);
};
void (async () => {
const style = await resolveStyle(theme);
if (cancelled || !containerRef.current) return;
if (typeof style === 'string') {
usedFallbackRef.current = true;
}
map = new maplibregl.Map({
container: containerRef.current,
style,
center,
zoom,
attributionControl: true,
});
mapRef.current = map;
if (typeof style !== 'string') {
attachFallbackMonitor(map);
}
})();
return () => {
cancelled = true;
map?.remove();
mapRef.current = null;
};
}, [theme, center[0], center[1], zoom]);
return <div ref={containerRef} style={{ width: '100%', height: '100%' }} />;
}
回退分两层,主题深浅单独对齐:
- 构图期 :
import('pmtiles')或拼 style 失败 → 立刻用 OpenFreeMap - 运行期 :10 秒内出现 ≥2 次网络/CORS/403,或 10 秒内没有任何
source成功事件 →setStyle切过去
浅色主题回退 Positron,深色回退 Dark,避免深浅反转。
5. 页面里用起来
tsx
// src/App.tsx
import { useState } from 'react';
import { Basemap } from './components/Basemap';
import type { PMTilesTheme } from './lib/basemap-style';
const THEMES: PMTilesTheme[] = ['black', 'dark', 'grayscale', 'light', 'white'];
export function App() {
const [theme, setTheme] = useState<PMTilesTheme>('black');
return (
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
<label style={{ padding: 12 }}>
底图主题{' '}
<select
value={theme}
onChange={(e) => setTheme(e.target.value as PMTilesTheme)}
>
{THEMES.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
</label>
<div style={{ flex: 1 }}>
<Basemap theme={theme} />
</div>
</div>
);
}
上面的写法在主题变化时会重建地图。生产里可以改成对已有实例调用 setStyle,避免相机位置被重置。
6. 怎么确认它真的在用 Range
打开 DevTools → Network,过滤 .pmtiles:
- 应该看到多次 对同一个文件的请求
- 状态码是 206 Partial Content
- 单次大约几十到两百 KB
- 不应该出现一次下完几 GB 的请求
如果全是 200 且体积巨大,说明存储没正确处理 Range。
7. 工程上建议一并做的事
配置和运行时拆开。 设置页只读 VITE_PMTILES_URL、写 localStorage,不要 import maplibre-gl / pmtiles。否则偏好面板会把整套 WebGL 栈拖进入口包。React 里就是:偏好 hook 一个文件,地图组件另一个文件。
没配 URL 就不要露出「PMTiles」选项。 社区克隆才能零配置跑起来。
ts
export const providerOptions = hasPMTilesUrl
? (['auto', 'pmtiles', 'openfreemap'] as const)
: (['openfreemap'] as const);
字体精灵默认不在你的桶里。 切片自托管了,glyphs / sprite 还指向 protomaps.github.io。CSP 要放行;要完全自托管,再镜像这两类静态资源。
PWA 如果缓存 .pmtiles,缓存的是 Range 片段,不是整份行星文件。 用 NetworkFirst,并确认能处理 206,不要按普通 200 文件去假设。
8. 常见坑
- 存储必须支持 Range(206)。不支持时,客户端可能把整档当一次下载,或直接失败。
- CORS 要覆盖
Range、HEAD、OPTIONS。R2 不支持https://*.example.com,每个 origin 单独列。 - 桌面 WebView(Tauri / Electron)的 origin 和网页不同。如果网页走的是没有 CORS 的反向代理,桌面端需要另配一条公网直连 URL。
pmtiles://后面仍是完整https://URL。- 一定要有免费底图兜底。对象存储宕机、403、CORS 配错都会让地图空白。
- 不要把行星
.pmtiles打进前端仓库或静态托管平台。大文件走对象存储 + CDN。 - Strict Mode 下协议注册和
new Map()都要能扛二次挂载:前者单飞,后者在 cleanup 里remove()。
9. 和业务图层的边界
PMTiles 只负责街道/地形底图 。标记、热力、航线是你在 MapLibre 之上再加的 source/layer,或用 deck.gl 叠一层,数据和 .pmtiles 无关。
一句话: 在 React 里接 PMTiles,就是「动态 import 注册协议 → 拼一份 pmtiles:// 的 Style → 放进 useEffect 里的 MapLibre → 用 OpenFreeMap 托底」。切片一个文件,浏览器只买当前视口。