第02章 - 环境搭建与快速上手

2.1 先确认三件事,再谈安装

拿到一个 MapLibre 项目,最常见的失败不是代码写错,而是环境前提没对齐。6.x 有三条硬约束:

前提 要求 不满足时的表现
浏览器 支持 WebGL2 构造函数抛 GPUInitializationError,地图区域空白
模块格式 ESM require('maplibre-gl') 报 ERR_PACKAGE_PATH_NOT_EXPORTED
构建目标 ES2022 老构建链(如 webpack 4 + 旧 babel)解析失败或产物语法报错

这三条都不是”建议”,是 6.0.0 的破坏性变更:UMD 包和独立 CSP 包被删除,WebGL1 回退路径被移除,TypeScript 目标从 ES2016/ES2019 统一到 ES2022。判断项目能不能升上来,先跑这三项检查,比先改代码省事。

# 确认将安装的版本
npm view maplibre-gl version

# 如果项目里还有 require 调用,先找出来
grep -rn "require('maplibre-gl')" src/ 2>/dev/null

装依赖本身只有一行:

npm install maplibre-gl@^6.17.0

不要写 @latest。v6 的迁移指南明确提醒过:页面被固定在 @latest 上时,主版本切换会让产物与导入方式不匹配,典型症状是控制台 404 加一片灰色画布。锁定 ^6.17.0 这类范围,升级由人决定。

2.2 四种引入方式与 worker 配置

MapLibre 的运行时代码分成两部分:主线程的 maplibre-gl.mjs,以及负责解析瓦片的 worker maplibre-gl-worker.mjs。worker 的 URL 怎么来,取决于引入方式,这是新手上手阶段最容易卡住的一步。

方式 主包来源 worker URL 来源 是否需要 setWorkerUrl
浏览器直接 ESM CDN 的 .mjs 从 import.meta.url 自动推导,跨域时转成同源 Blob URL 不需要
Vite maplibre-gl ?worker&url 查询参数 需要
webpack 5 / rspack maplibre-gl new URL(..., import.meta.url) 需要
esbuild maplibre-gl 手动 copy 到输出目录,按版本号加查询参数 需要

2.2.1 最省事:一个 HTML 文件

不装 npm 也能验证环境。把下面内容存成 index.html,用任意静态服务器打开(file:// 协议下部分浏览器会限制模块加载)。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>MapLibre 最小页面</title>
  <link rel="stylesheet" href="https://unpkg.com/maplibre-gl@6.17.0/dist/maplibre-gl.css" />
  <style>
    html, body { margin: 0; height: 100%; }
    #map { height: 100%; }
  </style>
</head>
<body>
  <div id="map"></div>
  <script type="module">
    import * as maplibregl from 'https://unpkg.com/maplibre-gl@6.17.0/dist/maplibre-gl.mjs';

    const map = new maplibregl.Map({
      container: 'map',
      style: 'https://demotiles.maplibre.org/style.json',
      center: [116.391, 39.907],
      zoom: 10
    });

    map.addControl(new maplibregl.NavigationControl(), 'top-right');

    map.on('load', () => {
      console.log('版本', maplibregl.getVersion());
    });
  </script>
</body>
</html>

CSS 必须引,否则画布能显示,但缩放按钮、版权信息会错位甚至不可见。maplibre-gl.css gzip 后约 9.9 KiB,自托管成本很低,生产环境建议放自己的 CDN 而不是 unpkg。

2.2.2 Vite

Vite 用 ?worker&url 把 worker 单独打成一个文件并返回它的 URL:

npm create vite@latest my-map -- --template vanilla-ts
cd my-map
npm install maplibre-gl@^6.17.0
// src/main.ts
import {Map, NavigationControl, setWorkerUrl, getVersion} from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url';

setWorkerUrl(workerUrl);

const map = new Map({
  container: 'map',
  style: 'https://demotiles.maplibre.org/style.json',
  center: [116.391, 39.907],
  zoom: 10
});

map.addControl(new NavigationControl(), 'top-right');
console.log(getVersion());

setWorkerUrl 必须在创建第一个 Map 实例之前调用。worker 池在第一次需要时创建,之后再改 URL 不影响已经建好的池。

2.2.3 webpack 5

webpack 依赖 new URL(..., import.meta.url) 让打包器把 worker 文件当成资源输出,并重写成最终路径:

import {Map, setWorkerUrl} from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';

setWorkerUrl(new URL('maplibre-gl/dist/maplibre-gl-worker.mjs', import.meta.url).toString());

const map = new Map({
  container: 'map',
  style: 'https://demotiles.maplibre.org/style.json',
  center: [116.391, 39.907],
  zoom: 10
});

Next.js 用同一套写法(包括 next build --webpack 模式)。Turbopack 与 rspack 的差异只在资源处理细节,官方仓库的 test/integration/bundler/ 下每个打包器都有一个最小可跑工程,可以直接对照。

2.2.4 esbuild

esbuild 不会自动处理 worker 的相对路径,需要把文件复制到输出目录,并把版本号写进查询参数:

// build.js
import * as esbuild from 'esbuild';
import {copyFileSync, mkdirSync} from 'node:fs';

mkdirSync('dist', {recursive: true});

await esbuild.build({
  entryPoints: ['src/main.ts'],
  bundle: true,
  outdir: 'dist',
  format: 'esm'
});

copyFileSync(
  'node_modules/maplibre-gl/dist/maplibre-gl-worker.mjs',
  'dist/maplibre-gl-worker.mjs'
);
// src/main.ts
import {Map, getVersion, setWorkerUrl} from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';

setWorkerUrl(
  new URL(`./maplibre-gl-worker.mjs?v=${getVersion()}`, import.meta.url).toString()
);

const map = new Map({
  container: 'map',
  style: 'https://demotiles.maplibre.org/style.json',
  center: [116.391, 39.907],
  zoom: 10
});

?v= 不是多余装饰。worker 文件名在每个版本都一样,浏览器可能命中旧缓存,导致主线程与 worker 的协议对不上,报出难以定位的解析错误。加上版本号,缓存自然失效。

2.3 容器尺寸:绝大多数”地图不出来”都在这

MapLibre 把 canvas 塞进你给的容器,容器的 clientWidth/clientHeight 决定画布尺寸。容器高度为 0 时,画布也接近 0,看起来就是”地图没出来”。

源码里的处理逻辑在 _containerDimensions():宽高取 Math.floor(clientWidth) 与 Math.floor(clientHeight),结果为 0 时回落到 400×300。这个回落是防御性的,不是推荐做法——它只会让你误以为”地图出来了但是很小”。

// 容器必须有明确的尺寸来源,下面三种写法任选其一
// 1) 内联高度
// <div id="map" style="height: 400px"></div>

// 2) CSS 高度
// #map { height: 100vh; }

// 3) flex 布局占满剩余空间(父容器有确定高度)
// .app { display: flex; flex-direction: column; height: 100vh; }
// #map { flex: 1; min-height: 0; }

// 创建后检查实际尺寸,快速定位问题
const map = new maplibregl.Map({container: 'map', style: 'https://demotiles.maplibre.org/style.json'});
const {clientWidth, clientHeight} = map.getContainer();
console.log(clientWidth, clientHeight); // 逻辑像素,不是设备像素

画布的物理像素由 pixelRatio 决定,默认取 devicePixelRatio。在 2 倍屏上,400×300 的容器会有 800×600 的后备像素,内存与填充率随之翻倍。maxCanvasSize 默认 [4096, 4096],超出时会自动降低像素比并打印一次警告,而不是拒绝创建。

尺寸变化默认由 trackResize: true 接管:内部用 ResizeObserver 监听容器,回调做了 50 ms 节流。但有三种情况它管不到:

  1. 容器在 display: none 里创建,之后才显示。
  2. 容器尺寸由父级动画(transform、grid 过渡)改变,布局尺寸未变。
  3. 用 iframe 或 WebView 承载,窗口尺寸变了但容器没触发观察。

这三种都是手动补一次 map.resize(),而不是重建地图。

// 侧边栏展开后地图区域变化,等过渡结束再量
sidebar.addEventListener('transitionend', () => map.resize());

// 标签页切回来
document.addEventListener('visibilitychange', () => {
  if (!document.hidden) map.resize();
});

2.4 加载时序:load、idle、style.load 的区别

新手写出的第一个 bug 通常是”在 new Map() 之后立刻 addLayer,控制台报错”。原因是样式还没解析完,addLayer 没有可插入的图层列表。

import {Map} from 'maplibre-gl';

const map = new Map({
  container: 'map',
  style: 'https://demotiles.maplibre.org/style.json'
});

// 错:style 尚未加载,内部会抛 'Style is not done loading.'
try {
  map.addLayer({id: 'bg', type: 'background', paint: {'background-color': '#fff'}});
} catch (err) {
  console.warn(err.message);
}

// 对:等 style.load —— 它表示样式本身解析完成,瓦片可以还没加载
map.on('style.load', () => {
  map.addLayer({id: 'bg', type: 'background', paint: {'background-color': '#fff'}});
});

三个事件的分工要记清楚:

事件 触发时机 适合做什么
style.load 样式 JSON 与 sprite/glyphs 等资源请求已发出,图层结构可用 增删图层与数据源、读取 style 对象
load 首屏所有必要资源就绪、第一帧完成绘制 埋点、隐藏 loading、开始播放动画
idle 无相机动画、无待加载瓦片、无淡入淡出 截图、批量导出、触发下一步的数据请求
const map = new maplibregl.Map({container: 'map', style: 'https://demotiles.maplibre.org/style.json'});

map.on('load', () => {
  // 只触发一次也可以用 once
  document.getElementById('loading').hidden = true;
  console.log('loaded?', map.loaded()); // true
});

map.on('idle', () => {
  console.log('地图像素稳定了,可以截图或统计');
});

注意 load 只对当前样式触发一次,但 setStyle 之后会再次触发(新样式重新走一遍)。如果初始化逻辑依赖”样式已就绪”,用 style.load 而不是 load;如果要做一次性初始化,用 once。

2.5 第一个交互:控件、点击查询、鼠标状态

一个可用的地图除了底图,还需要控件和点击响应。下面这段把三者串起来,可以直接放进上文的 Vite 工程里跑。

import {Map, NavigationControl, ScaleControl, AttributionControl} from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';

const map = new Map({
  container: 'map',
  style: 'https://demotiles.maplibre.org/style.json',
  center: [116.391, 39.907],
  zoom: 10,
  // 默认已带 attributionControl(compact: true),关掉后自己加,便于定制
  attributionControl: false
});

map.addControl(new NavigationControl({showCompass: true, visualizePitch: true}), 'top-right');
map.addControl(new ScaleControl({maxWidth: 100, unit: 'metric'}), 'bottom-left');
map.addControl(
  new AttributionControl({compact: true, customAttribution: '示例数据 © MapLibre'}),
  'bottom-right'
);

// 点击查询:只查当前可见的 symbol 图层
map.on('click', (e) => {
  const features = map.queryRenderedFeatures(e.point, {layers: ['country-label']});
  if (!features.length) return;

  const f = features[0];
  console.log(f.layer.id, f.properties, f.geometry.type);
  new maplibregl.Popup({closeButton: false})
    .setLngLat(e.lngLat)
    .setHTML('<b>' + (f.properties.name || '未命名') + '</b>')
    .addTo(map);
});

// 鼠标移入图层时换光标,是"地图有没有响应"的最直观反馈
map.on('mouseenter', 'country-label', () => {
  map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', 'country-label', () => {
  map.getCanvas().style.cursor = '';
});

三个细节值得说明:

  • queryRenderedFeatures 的第二参数传 layers 数组能明显降低开销;不传时它会遍历所有已渲染图层和要素。
  • 图层级事件 map.on('click', 'layerId', fn) 是上文的简写形式,内部仍走 queryRenderedFeatures,但只对指定图层派发。
  • getCanvas().style.cursor 是官方示例里的写法,直接改内联样式。生产项目更稳的做法是切一个 CSS 类,把光标样式写在样式表里。

2.6 常见报错与排查路径

下面这些错误信息都来自源码,可以直接在控制台里搜到对应文案。

2.6.1 Container 'map' not found.

容器 id 拼错,或者脚本执行时 DOM 还没解析到该节点。

// 脚本在 <head> 里执行,document.getElementById('map') 还是 null
// 修法一:把 script 放到 </body> 前
// 修法二:等 DOMContentLoaded
document.addEventListener('DOMContentLoaded', () => {
  new maplibregl.Map({container: 'map', style: 'https://demotiles.maplibre.org/style.json'});
});

另外容器参数只接受字符串 id 或 HTMLElement。传 jQuery 对象、NodeList、null 会得到 Invalid type: 'container' must be a String or HTMLElement.。

2.6.2 WebGL2 is required to display this map...

GPUInitializationError 的完整文案是 WebGL2 is required to display this map. We are sorry, but it seems that your browser does not support WebGL2...。它同时携带两个字段:requestedAttributes(请求的上下文属性)和 statusMessage(浏览器给的 webglcontextcreationerror 说明),排查驱动或远程桌面问题时很有用。

try {
  const map = new maplibregl.Map({container: 'map', style: url});
} catch (err) {
  if (err.name === 'GPUInitializationError') {
    console.error('请求的上下文属性:', err.requestedAttributes);
    console.error('浏览器说明:', err.statusMessage);
  }
}

MapLibre 强制 alpha、depth、stencil、premultipliedAlpha 四个属性为 true,其余属性(antialias、powerPreference、preserveDrawingBuffer 等)走默认值:antialias: false、powerPreference: 'high-performance'、preserveDrawingBuffer: false。需要截图导出时,改 preserveDrawingBuffer: true,代价是部分设备上掉帧。

2.6.3 worker 加载失败

症状是控制台出现 404 或 Failed to fetch worker script (404): ...,地图能显示底图但缩放后瓦片不再更新。

原因 现象 修法
没调 setWorkerUrl 控制台尝试从当前页面路径取 worker 按 2.2 的方式设置 URL
打包器把 worker 内联成字符串 请求 404,路径里带奇怪的 hash 用 ?worker&url 或 new URL 形式
worker 文件被 CDN 改了 MIME Uncaught TypeError: Failed to construct 'Worker' 确保返回 text/javascript
CSP 禁止 blob: 跨域 CDN 场景下 worker 建不起来 worker-src 'self' blob:

CSP 的写法官方给了两套:从 CDN 跨域加载时,worker 会以同源 Blob URL 构造,需要 worker-src 'self' blob:;自托管 worker 时同源即可,worker-src 'self' 就够。两种都要保留 img-src data: blob: 'self',因为 sprite 与图标会用到 data URL。

2.6.4 ERR_PACKAGE_PATH_NOT_EXPORTED

在 Node 里 require('maplibre-gl'),或者在 Jest 这类默认 CJS 的测试环境里导入,会撞上这个错。包已改为纯 ESM 导出,没有 require 入口。

// 错的
const maplibregl = require('maplibre-gl');

// 对的(Node 侧只做类型或工具函数时)
import {getVersion} from 'maplibre-gl';

前端项目里很少直接踩到,但 SSR 框架、脚本工具、单元测试很容易踩。测试环境需要把 maplibre-gl 加进 transformIgnorePatterns 或 deps.inline 之类的白名单。

2.6.5 地图”只有一半”或”拉伸变形”

九成是容器尺寸在创建后变了而地图没重新量。判断方法:map.getCanvas().width 与 container.clientWidth * devicePixelRatio 对不上就是这个问题。修法是 map.resize(),或者干脆在容器可见之后再创建地图。

2.7 生产环境建议

把静态资源放到自己的域名下。 主包、worker、CSS、sprite、glyphs 五类资源,能自托管就自托管:省一次跨域握手,也避免公共 CDN 的可用性波动。

worker 走同源。 同源 worker 不需要 blob:,CSP 更简单,缓存策略也统一。跨域 CDN 虽然能自动转 Blob,但每次页面加载都要多一次 fetch 与 URL 构造。

锁定版本并记录升级窗口。 v5 到 v6 的破坏性变更说明一件事:主版本升级需要专门排期。把 maplibre-gl 写进依赖锁定文件,升级时按迁移指南过一遍导入方式、事件对象、worker 配置。

首屏用骨架屏而不是”地图容器”。 地图创建前容器是空的,直接用灰色占位块;load 事件里再淡入 canvas,避免用户看到一阵白。

别在 SSR 里创建 Map。 服务端没有 DOM 与 WebGL 上下文,只能在客户端挂载(React 的 useEffect、Vue 的 onMounted)。

提前探一次 WebGL2。 面向政企内网或远程桌面的项目,GPU 能力不可控。在地图模块加载前做一次 canvas.getContext('webgl2') 探测,不通过时直接给降级说明页,比让用户盯着白屏好。

2.8 本章小结

主题 结论
安装 npm install maplibre-gl@^6.17.0,不要 @latest
引入 纯 ESM;CDN 用 <script type="module">,npm 用命名导入
worker 打包器场景必须 setWorkerUrl,且要在创建 Map 之前
CSS 必须引入 maplibre-gl.css,否则控件错位
容器 必须有确定高度;0 尺寸时内部回落 400×300,属于异常状态
尺寸变化 默认 trackResize + ResizeObserver(50 ms 节流);隐藏转可见要手动 resize()
时序 style.load 加图层,load 做首屏收尾,idle 做稳定后统计
上下文属性 alpha/depth/stencil/premultipliedAlpha 强制为 true;截图需 preserveDrawingBuffer: true
报错定位 Container ... not found、GPUInitializationError、Style is not done loading.、ERR_PACKAGE_PATH_NOT_EXPORTED