在 React 里用 PMTiles 自托管底图

写在前面

最近在维护一个带地图的项目,底图从高德换到天地图,用户还是反馈 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,以及 RangeHEADOPTIONS

你最终会得到什么

  • 一个 <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 ^5
  • pmtiles ^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/basemapsnamedFlavor() 按主题生成图层。切片可以自托管;字体和精灵图默认仍走 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;
}

注意:urlpmtiles:// + 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%' }} />;
}

回退分两层,主题深浅单独对齐:

  1. 构图期import('pmtiles') 或拼 style 失败 → 立刻用 OpenFreeMap
  2. 运行期 :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. 常见坑

  1. 存储必须支持 Range(206)。不支持时,客户端可能把整档当一次下载,或直接失败。
  2. CORS 要覆盖 RangeHEADOPTIONS。R2 不支持 https://*.example.com,每个 origin 单独列。
  3. 桌面 WebView(Tauri / Electron)的 origin 和网页不同。如果网页走的是没有 CORS 的反向代理,桌面端需要另配一条公网直连 URL。
  4. pmtiles:// 后面仍是完整 https:// URL。
  5. 一定要有免费底图兜底。对象存储宕机、403、CORS 配错都会让地图空白。
  6. 不要把行星 .pmtiles 打进前端仓库或静态托管平台。大文件走对象存储 + CDN。
  7. Strict Mode 下协议注册和 new Map() 都要能扛二次挂载:前者单飞,后者在 cleanup 里 remove()

9. 和业务图层的边界

PMTiles 只负责街道/地形底图 。标记、热力、航线是你在 MapLibre 之上再加的 source/layer,或用 deck.gl 叠一层,数据和 .pmtiles 无关。


一句话: 在 React 里接 PMTiles,就是「动态 import 注册协议 → 拼一份 pmtiles:// 的 Style → 放进 useEffect 里的 MapLibre → 用 OpenFreeMap 托底」。切片一个文件,浏览器只买当前视口。

相关推荐
tanghonghanhaoli1 小时前
【图书翻译】有意义的游戏设计:桌面游戏的方法论与心理学(第1、2章)
前端·游戏·ui
光影少年1 小时前
react native打包优化、包体积瘦身、离线包/热更新方案
前端·react native·react.js
Cache技术分享1 小时前
504. Java 反射 - 创建一个简单的依赖注入框架
前端·后端
_codeOH1 小时前
# 手把手复刻 DeepSeek 官网效果:玻璃拟态 + WebGL 流体 + Spring 动画,一篇讲透
前端
名字还没想好☜1 小时前
Next.js 用 cookies()/headers() 读请求信息:动态渲染触发、缓存失效与在 Server Action 里读写 cookie
前端·javascript·缓存·react·next.js·app router
今日无bug2 小时前
从「拿来主义」到「亲手造轮子」:2 种 MCP 文件服务器写法对比
前端·node.js·mcp
xcyxiner2 小时前
flutter 运行到模拟器上
android·前端·flutter
用户921080262862 小时前
从 COT 到 ThoughtChain:AI 应用为什么需要展示“思考过程”
前端
天真小巫2 小时前
2026.8.23总结
前端·html