mapbox-web-performance-patterns by mapbox/mapbox-agent-skills
npx skills add https://github.com/mapbox/mapbox-agent-skills --skill mapbox-web-performance-patterns此技能为构建快速高效的 Mapbox 应用程序提供性能优化指导。模式根据对用户体验的影响程度进行优先级排序,从最关键的性能改进开始。
性能理念: 这些并非微优化。它们会表现为等待时间、卡顿和每次用户会话都会遇到的重复成本。
性能问题根据其对用户体验的影响程度进行优先级排序:
问题: 顺序加载会产生级联延迟,其中每个资源都在等待前一个资源。
注意: 现代打包工具(Vite、Webpack 等)和 ESM 动态导入会自动处理代码分割和库加载。需要消除的主要瀑布是数据加载——在初始化地图时顺序获取数据,而不是并行获取。
// ❌ 错误:数据在地图初始化后加载
async function initMap() {
const map = new mapboxgl.Map({
container: 'map',
accessToken: MAPBOX_TOKEN,
style: 'mapbox://styles/mapbox/streets-v12'
});
// 等待地图加载,然后获取数据
map.on('load', async () => {
const data = await fetch('/api/data'); // 瀑布!
map.addSource('data', { type: 'geojson', data: await data.json() });
});
}
时间线: 地图初始化 (0.5s) → 数据获取 (1s) = 总计 1.5s
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
// ✅ 正确:立即开始数据获取
async function initMap() {
// 立即开始数据获取(不等待地图)
const dataPromise = fetch('/api/data').then((r) => r.json());
const map = new mapboxgl.Map({
container: 'map',
accessToken: MAPBOX_TOKEN,
style: 'mapbox://styles/mapbox/streets-v12'
});
// 地图加载时数据已准备就绪
map.on('load', async () => {
const data = await dataPromise;
map.addSource('data', { type: 'geojson', data });
map.addLayer({
id: 'data-layer',
type: 'circle',
source: 'data'
});
});
}
时间线: Max(地图初始化, 数据获取) = 总计约 1s
// ✅ 设置精确的中心点和缩放级别,以便地图立即获取正确的瓦片
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v12',
center: [-122.4194, 37.7749],
zoom: 13
});
// 使用 'idle' 事件来了解初始视口何时完全渲染
//(所有瓦片、精灵和其他资源都已加载;没有进行中的过渡动画)
map.once('idle', () => {
console.log('Initial viewport fully rendered');
});
如果你知道用户首先会看到的精确区域,预先设置 center 和 zoom 可以避免地图从默认视图开始然后平移/缩放到目标区域,从而浪费瓦片获取。
// ✅ 先加载关键功能,延迟其他功能
const map = new mapboxgl.Map({
/* config */
});
map.on('load', () => {
// 1. 立即添加关键图层
addCriticalLayers(map);
// 2. 延迟次要功能
// 注意:标准样式的 3D 建筑可以通过配置切换:
// map.setConfigProperty('basemap', 'show3dObjects', false);
requestIdleCallback(
() => {
addTerrain(map);
addCustom3DLayers(map); // 适用于具有自定义填充挤出图层的经典样式
},
{ timeout: 2000 }
);
// 3. 延迟分析功能和非可视化功能
setTimeout(() => {
initializeAnalytics(map);
}, 3000);
});
影响: 显著减少可交互时间,尤其是在延迟地形和 3D 图层时
问题: 大型包在慢速网络上会延迟可交互时间。
注意: 现代打包工具(Vite、Webpack 等)会自动处理基于框架的应用程序的代码分割。以下指导主要与优化打包内容和时机相关。
// ❌ 错误:内联巨大的样式 JSON(可能超过 500 KB)
const style = {
version: 8,
sources: {
/* 100s of lines */
},
layers: [
/* 100s of layers */
]
};
// ✅ 正确:引用 Mapbox 托管的样式
const map = new mapboxgl.Map({
style: 'mapbox://styles/mapbox/streets-v12' // 按需获取
});
// ✅ 或者:将大型自定义样式存储在外部
const map = new mapboxgl.Map({
style: '/styles/custom-style.json' // 单独加载
});
影响: 从内联样式切换到托管样式时,初始包大小减少 30-50%
问题: 标记点过多会导致渲染缓慢和交互延迟。
// ❌ 错误:5,000 个 HTML 标记点 = 5+ 秒渲染,平移/缩放卡顿
restaurants.forEach((restaurant) => {
const marker = new mapboxgl.Marker()
.setLngLat([restaurant.lng, restaurant.lat])
.setPopup(new mapboxgl.Popup().setHTML(restaurant.name))
.addTo(map);
});
结果: 5,000 个 DOM 元素,交互缓慢,内存占用高
// ✅ 正确:GPU 加速渲染,10,000+ 要素时依然流畅
map.addSource('restaurants', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: restaurants.map((r) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [r.lng, r.lat] },
properties: { name: r.name, type: r.type }
}))
}
});
map.addLayer({
id: 'restaurants',
type: 'symbol',
source: 'restaurants',
layout: {
'icon-image': 'restaurant',
'icon-size': 0.8,
'text-field': ['get', 'name'],
'text-size': 12,
'text-offset': [0, 1.5],
'text-anchor': 'top'
}
});
// 点击处理程序(所有要素共用一个监听器)
map.on('click', 'restaurants', (e) => {
const feature = e.features[0];
new mapboxgl.Popup().setLngLat(feature.geometry.coordinates).setHTML(feature.properties.name).addTo(map);
});
性能: 10,000 个要素渲染时间 <100ms
// ✅ 正确:50,000 个标记点在低缩放级别下聚合为约 500 个聚合点
map.addSource('restaurants', {
type: 'geojson',
data: restaurantsGeoJSON,
cluster: true,
clusterMaxZoom: 14, // 在缩放级别 15 停止聚合
clusterRadius: 50 // 相对于瓦片尺寸的半径(512 = 整个瓦片宽度)
});
// 聚合点圆圈图层
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'restaurants',
filter: ['has', 'point_count'],
paint: {
'circle-color': ['step', ['get', 'point_count'], '#51bbd6', 100, '#f1f075', 750, '#f28cb1'],
'circle-radius': ['step', ['get', 'point_count'], 20, 100, 30, 750, 40]
}
});
// 聚合点数量标签图层
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: 'restaurants',
filter: ['has', 'point_count'],
layout: {
'text-field': '{point_count_abbreviated}',
'text-size': 12
}
});
// 单个点图层
map.addLayer({
id: 'unclustered-point',
type: 'circle',
source: 'restaurants',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': '#11b4da',
'circle-radius': 6
}
});
影响: 50,000 个标记点以 60 FPS 流畅交互
问题: 预先加载所有数据会浪费带宽并减慢初始渲染速度。
| 场景 | 使用 GeoJSON | 使用矢量瓦片 |
|---|---|---|
| < 5 MB 数据 | 是 | 否 |
| 5-20 MB 数据 | 考虑 | 是 |
20 MB 数据 | 否 | 是
数据频繁变化 | 是 | 否
静态数据,全球范围 | 否 | 是
需要服务器端更新 | 否 | 是
注意: 此模式适用于在本地或外部服务器上托管 GeoJSON 数据的情况。Mapbox 托管的数据源已经针对基于视口的加载进行了优化。
// ✅ 仅加载当前视口内的数据
async function loadVisibleData(map) {
const bounds = map.getBounds();
const bbox = [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()].join(',');
const data = await fetch(`/api/data?bbox=${bbox}&zoom=${map.getZoom()}`);
map.getSource('data').setData(await data.json());
}
// 视口变化时更新(带防抖)
let timeout;
map.on('moveend', () => {
clearTimeout(timeout);
timeout = setTimeout(() => loadVisibleData(map), 300);
});
重要提示: setData() 会在 Web Worker 中触发 GeoJSON 的完全重新解析。对于频繁更新的小型数据集,考虑使用 source.updateData()(需要在源上设置 dynamic: true)进行部分更新。对于大型数据集,请切换到矢量瓦片。
注意: 此模式适用于在本地或外部服务器上托管 GeoJSON 数据的情况。
// ✅ 先加载基础数据,逐步添加细节
async function loadDataProgressive(map) {
// 1. 先加载简化数据(低分辨率)
const simplified = await fetch('/api/data?detail=low');
map.addSource('data', {
type: 'geojson',
data: await simplified.json()
});
addLayers(map);
// 2. 在后台加载完整细节
const detailed = await fetch('/api/data?detail=high');
map.getSource('data').setData(await detailed.json());
}
注意: 下面展示的 minzoom/maxzoom 优化主要针对自托管的矢量瓦片集。Mapbox 托管的瓦片集通过 Mapbox Tiling Service (MTS) 配方内置了优化,可自动处理缩放级别优化。
// ✅ 服务器生成瓦片,客户端仅加载可见区域(自托管瓦片集)
map.addSource('large-dataset', {
type: 'vector',
tiles: ['https://api.example.com/tiles/{z}/{x}/{y}.pbf'],
minzoom: 0,
maxzoom: 14
});
map.addLayer({
id: 'large-dataset-layer',
type: 'fill',
source: 'large-dataset',
'source-layer': 'data', // .pbf 文件中的图层名称
paint: {
'fill-color': '#088',
'fill-opacity': 0.6
}
});
影响: 10 MB 数据集减少到每次视口加载约 500 KB
问题: 未节流的事件处理器会导致性能下降。
// ❌ 错误:平移期间每秒运行约 60 次(每渲染帧一次)
map.on('move', () => {
updateVisibleFeatures(); // 昂贵的查询
fetchDataFromAPI(); // 网络请求
updateUI(); // DOM 操作
});
// ✅ 正确:交互期间节流,空闲时最终执行
let throttleTimeout;
// 移动期间的轻量级更新(节流)
map.on('move', () => {
if (throttleTimeout) return;
throttleTimeout = setTimeout(() => {
updateMapCenter(); // 廉价更新
throttleTimeout = null;
}, 100);
});
// 交互停止后执行昂贵操作
map.on('moveend', () => {
updateVisibleFeatures();
fetchDataFromAPI();
updateUI();
});
// ❌ 错误:查询所有要素(图层多时非常昂贵)
map.on('click', (e) => {
const features = map.queryRenderedFeatures(e.point);
console.log(features); // 可能来自所有图层的 100+ 个要素
});
// ✅ 正确:仅查询特定图层
map.on('click', (e) => {
const features = map.queryRenderedFeatures(e.point, {
layers: ['restaurants', 'shops'] // 仅查询这些图层
});
if (features.length > 0) {
showPopup(features[0]);
}
});
// ✅ 对于触摸目标或模糊点击:使用边界框
map.on('click', (e) => {
const bbox = [
[e.point.x - 5, e.point.y - 5],
[e.point.x + 5, e.point.y + 5]
];
const features = map.queryRenderedFeatures(bbox, {
layers: ['restaurants'],
filter: ['==', ['get', 'type'], 'pizza'] // 进一步缩小结果范围
});
});
// ❌ 错误:为每个要素更新 DOM
map.on('mousemove', 'restaurants', (e) => {
e.features.forEach((feature) => {
document.getElementById(feature.id).classList.add('highlight');
});
});
// ✅ 正确:使用 requestAnimationFrame 批量更新
let pendingUpdates = new Set();
let rafScheduled = false;
map.on('mousemove', 'restaurants', (e) => {
e.features.forEach((f) => pendingUpdates.add(f.id));
if (!rafScheduled) {
rafScheduled = true;
requestAnimationFrame(() => {
pendingUpdates.forEach((id) => {
document.getElementById(id).classList.add('highlight');
});
pendingUpdates.clear();
rafScheduled = false;
});
}
});
影响: 交互期间保持 60 FPS,而未优化时为 15-20 FPS
问题: 内存泄漏会导致浏览器标签页随时间推移变得无响应。在创建/销毁地图实例的单页应用中,这是一个常见的生产问题。
// ✅ 必要的清理模式
function cleanupMap(map) {
if (!map) return;
// 1. 移除事件监听器
map.off('load', handleLoad);
map.off('move', handleMove);
// 2. 移除图层(如果动态添加/移除)
if (map.getLayer('dynamic-layer')) {
map.removeLayer('dynamic-layer');
}
// 3. 移除数据源(如果动态添加/移除)
if (map.getSource('dynamic-source')) {
map.removeSource('dynamic-source');
}
// 4. 移除控件
map.removeControl(navigationControl);
// 5. 关键:移除地图实例
map.remove();
}
// React 示例
useEffect(() => {
const map = new mapboxgl.Map({
/* config */
});
return () => {
cleanupMap(map); // 在卸载时调用
};
}, []);
// ❌ 错误:每次点击都创建新弹出窗口(内存泄漏)
map.on('click', 'restaurants', (e) => {
new mapboxgl.Popup().setLngLat(e.lngLat).setHTML(e.features[0].properties.name).addTo(map);
// 弹出窗口从未移除!
});
// ✅ 正确:重用单个弹出窗口实例
let popup = new mapboxgl.Popup({ closeOnClick: true });
map.on('click', 'restaurants', (e) => {
popup.setLngLat(e.lngLat).setHTML(e.features[0].properties.name).addTo(map);
// 替换了之前的弹出窗口内容,无泄漏
});
// 清理
function cleanup() {
popup.remove();
popup = null;
}
// ❌ 错误:为悬停创建新图层(内存开销,导致重新渲染)
let hoveredFeatureId = null;
map.on('mousemove', 'restaurants', (e) => {
if (map.getLayer('hover-layer')) {
map.removeLayer('hover-layer');
}
map.addLayer({
id: 'hover-layer',
type: 'circle',
source: 'restaurants',
filter: ['==', ['id'], e.features[0].id],
paint: { 'circle-color': 'yellow' }
});
});
// ✅ 正确:使用要素状态(高效,不创建图层)
map.on('mousemove', 'restaurants', (e) => {
if (e.features.length > 0) {
// 移除之前的悬停状态
if (hoveredFeatureId !== null) {
map.setFeatureState({ source: 'restaurants', id: hoveredFeatureId }, { hover: false });
}
// 设置新的悬停状态
hoveredFeatureId = e.features[0].id;
map.setFeatureState({ source: 'restaurants', id: hoveredFeatureId }, { hover: true });
}
});
// 样式使用要素状态
map.addLayer({
id: 'restaurants',
type: 'circle',
source: 'restaurants',
paint: {
'circle-color': [
'case',
['boolean', ['feature-state', 'hover'], false],
'#ffff00', // 悬停时为黄色
'#0000ff' // 否则为蓝色
]
}
});
注意: 要素状态要求要素具有 ID。在 GeoJSON 源上使用 generateId: true 来自动分配 ID,或使用 promoteId 将现有属性用作要素 ID。
影响: 防止长时间会话中因持续创建/销毁图层而导致内存增长
问题: 移动设备资源有限(CPU、GPU、内存、电池)。
// 检测移动设备
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v12',
// 移动端优化
...(isMobile && {
// 限制最大缩放级别,减少极端缩放级别下的瓦片获取
maxZoom: 18,
// fadeDuration 仅控制符号碰撞淡入淡出动画
// 减小它可以使标签过渡更灵敏
fadeDuration: 0
})
});
// 在移动端加载更简单的图层
map.on('load', () => {
if (isMobile) {
// 圆圈图层比符号图层更廉价(无碰撞检测,无纹理图集,无文本形状处理)
map.addLayer({
id: 'markers-mobile',
type: 'circle',
source: 'data',
paint: {
'circle-radius': 8,
'circle-color': '#007cbf'
}
});
} else {
// 桌面端使用带图标和标签的丰富渲染
map.addLayer({
id: 'markers-desktop',
type: 'symbol',
source: 'data',
layout: {
'icon-image': 'marker',
'icon-size': 1,
'text-field': ['get', 'name'],
'text-size': 12,
'text-offset': [0, 1.5]
}
});
}
});
// ✅ 简化触摸手势
map.touchZoomRotate.disableRotation(); // 禁用旋转(手势更简单,减少意外旋转)
// 触摸期间对昂贵操作进行防抖
let touchTimeout;
map.on('touchmove', () => {
if (touchTimeout) clearTimeout(touchTimeout);
touchTimeout = setTimeout(() => {
updateVisibleData();
}, 500); // 等待触摸稳定
});
// 这些选项有实际的 GPU/性能成本——仅在需要时启用
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v12',
// 默认 false——仅在需要 map.getCanvas().toDataURL() 时设为 true
// 成本:阻止 GPU 缓冲区优化
preserveDrawingBuffer: false,
// 默认 false——仅在需要平滑对角线时设为 true
// 成本:启用 MSAA,增加 GPU 内存和填充成本
antialias: false
});
// ❌ 错误:为餐厅类型创建 20 个单独的图层
restaurantTypes.forEach((type) => {
map.addLayer({
id: `restaurants-${type}`,
type: 'symbol',
source: 'restaurants',
filter: ['==', ['get', 'type'], type],
layout: { 'icon-image': `${type}-icon` }
});
});
// ✅ 正确:使用数据驱动样式的单个图层
map.addLayer({
id: 'restaurants',
type: 'symbol',
source: 'restaurants',
layout: {
'icon-image': [
'match',
['get', 'type'],
'pizza',
'pizza-icon',
'burger',
'burger-icon',
'sushi',
'sushi-icon',
'default-icon' // 回退图标
]
}
});
影响: 更少的图层意味着更少的渲染开销。无论要素数量多少,每个图层都有固定的每层成本。
对于具有 100,000+ 要素的数据集,简化表达式可以减少每个要素的评估成本。对于较小的数据集,表达式引擎足够快,这种优化不会很明显。
// 依赖缩放级别的绘制属性必须使用 step 或 interpolate,而不是比较运算符
// ❌ 错误:不能在绘制属性中对 ['zoom'] 使用比较运算符
// paint: { 'fill-extrusion-height': ['case', ['>', ['zoom'], 16], ...] }
// ✅ 正确:使用 step 处理离散的缩放断点
map.addLayer({
id: 'buildings',
type: 'fill-extrusion',
source: 'buildings',
paint: {
'fill-extrusion-color': ['interpolate', ['linear'], ['get', 'height'], 0, '#dedede', 50, '#a0a0a0', 100, '#606060'],
'fill-extrusion-height': [
'step',
['zoom'],
['get', 'height'], // 默认:使用原始高度
16,
['*', ['get', 'height'], 1.5] // 在缩放级别 16+:放大
]
}
});
对于非常大的 GeoJSON 数据集,将静态属性推导(如颜色类别)预计算到源数据中,可以减少每个要素的表达式工作量:
// ✅ 为大型数据集(100K+ 要素)预计算静态推导
const buildingsWithColor = {
type: 'FeatureCollection',
features: buildings.features.map((f) => ({
...f,
properties: {
...f.properties,
heightColor: getColorForHeight(f.properties.height) // 预计算一次
}
}))
};
map.addSource('buildings', { type: 'geojson', data: buildingsWithColor });
map.addLayer({
id: 'buildings',
type: 'fill-extrusion',
source: 'buildings',
paint: {
'fill-extrusion-color': ['get', 'heightColor'], // 简单的属性查找
'fill-extrusion-height': ['get', 'height']
}
});
// ✅ 仅在适当的缩放级别渲染图层
map.addLayer({
id: 'building-details',
type: 'fill',
source: 'buildings',
minzoom: 15, // 在缩放级别 15 及以上渲染
paint: { 'fill-color': '#aaa' }
});
map.addLayer({
id: 'poi-labels',
type: 'symbol',
source: 'pois',
minzoom: 12, // 在低缩放级别隐藏,此时标签会严重重叠
layout: {
'text-field': ['get', 'name'],
visibility: 'visible'
}
});
注意: minzoom 是包含性的(在该缩放级别图层可见),maxzoom 是排除性的(在该缩放级别图层隐藏)。一个 maxzoom: 16 的图层在缩放级别 16 之前(不包括 16)都可见。
影响: 减少在图层无用的缩放级别上的 GPU 工作
构建 Mapbox 应用程序时,按顺序验证以下优化:
// 测量初始加载时间
console.time('map-load');
map.on('load', () => {
console.timeEnd('map-load');
// isStyleLoaded() 在样式、数据源、瓦片、精灵和模型全部加载后返回 true
console.log('Style loaded:', map.isStyleLoaded());
});
// 监控帧率
let frameCount = 0;
map.on('render', () => frameCount++);
setInterval(() => {
console.log('FPS:', frameCount);
frameCount = 0;
}, 1000);
// 检查内存使用情况(Chrome DevTools -> Performance -> Memory)
目标指标:
每周安装次数
423
仓库
GitHub 星标数
35
首次出现
2026 年 2 月 1 日
安全审计
安装于
codex381
opencode368
gemini-cli364
github-copilot356
amp340
kimi-cli340
This skill provides performance optimization guidance for building fast, efficient Mapbox applications. Patterns are prioritized by impact on user experience, starting with the most critical improvements.
Performance philosophy: These aren't micro-optimizations. They show up as waiting time, jank, and repeat costs that hit every user session.
Performance issues are prioritized by their impact on user experience:
Problem: Sequential loading creates cascading delays where each resource waits for the previous one.
Note: Modern bundlers (Vite, Webpack, etc.) and ESM dynamic imports automatically handle code splitting and library loading. The primary waterfall to eliminate is data loading - fetching map data sequentially instead of in parallel with map initialization.
// ❌ BAD: Data loads AFTER map initializes
async function initMap() {
const map = new mapboxgl.Map({
container: 'map',
accessToken: MAPBOX_TOKEN,
style: 'mapbox://styles/mapbox/streets-v12'
});
// Wait for map to load, THEN fetch data
map.on('load', async () => {
const data = await fetch('/api/data'); // Waterfall!
map.addSource('data', { type: 'geojson', data: await data.json() });
});
}
Timeline: Map init (0.5s) → Data fetch (1s) = 1.5s total
// ✅ GOOD: Data fetch starts immediately
async function initMap() {
// Start data fetch immediately (don't wait for map)
const dataPromise = fetch('/api/data').then((r) => r.json());
const map = new mapboxgl.Map({
container: 'map',
accessToken: MAPBOX_TOKEN,
style: 'mapbox://styles/mapbox/streets-v12'
});
// Data is ready when map loads
map.on('load', async () => {
const data = await dataPromise;
map.addSource('data', { type: 'geojson', data });
map.addLayer({
id: 'data-layer',
type: 'circle',
source: 'data'
});
});
}
Timeline: Max(map init, data fetch) = ~1s total
// ✅ Set exact center/zoom so the map fetches the right tiles immediately
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v12',
center: [-122.4194, 37.7749],
zoom: 13
});
// Use 'idle' to know when the initial viewport is fully rendered
// (all tiles, sprites, and other resources are loaded; no transitions in progress)
map.once('idle', () => {
console.log('Initial viewport fully rendered');
});
If you know the exact area users will see first, setting center and zoom upfront avoids the map starting at a default view and then panning/zooming to the target, which wastes tile fetches.
// ✅ Load critical features first, defer others
const map = new mapboxgl.Map({
/* config */
});
map.on('load', () => {
// 1. Add critical layers immediately
addCriticalLayers(map);
// 2. Defer secondary features
// Note: Standard style 3D buildings can be toggled via config:
// map.setConfigProperty('basemap', 'show3dObjects', false);
requestIdleCallback(
() => {
addTerrain(map);
addCustom3DLayers(map); // For classic styles with custom fill-extrusion layers
},
{ timeout: 2000 }
);
// 3. Defer analytics and non-visual features
setTimeout(() => {
initializeAnalytics(map);
}, 3000);
});
Impact: Significant reduction in time-to-interactive, especially when deferring terrain and 3D layers
Problem: Large bundles delay time-to-interactive on slow networks.
Note: Modern bundlers (Vite, Webpack, etc.) automatically handle code splitting for framework-based applications. The guidance below is most relevant for optimizing what gets bundled and when.
// ❌ BAD: Inline massive style JSON (can be 500+ KB)
const style = {
version: 8,
sources: {
/* 100s of lines */
},
layers: [
/* 100s of layers */
]
};
// ✅ GOOD: Reference Mapbox-hosted styles
const map = new mapboxgl.Map({
style: 'mapbox://styles/mapbox/streets-v12' // Fetched on demand
});
// ✅ OR: Store large custom styles externally
const map = new mapboxgl.Map({
style: '/styles/custom-style.json' // Loaded separately
});
Impact: Reduces initial bundle by 30-50% when moving from inlined to hosted styles
Problem: Too many markers causes slow rendering and interaction lag.
// ❌ BAD: 5,000 HTML markers = 5+ second render, janky pan/zoom
restaurants.forEach((restaurant) => {
const marker = new mapboxgl.Marker()
.setLngLat([restaurant.lng, restaurant.lat])
.setPopup(new mapboxgl.Popup().setHTML(restaurant.name))
.addTo(map);
});
Result: 5,000 DOM elements, slow interactions, high memory
// ✅ GOOD: GPU-accelerated rendering, smooth at 10,000+ features
map.addSource('restaurants', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: restaurants.map((r) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [r.lng, r.lat] },
properties: { name: r.name, type: r.type }
}))
}
});
map.addLayer({
id: 'restaurants',
type: 'symbol',
source: 'restaurants',
layout: {
'icon-image': 'restaurant',
'icon-size': 0.8,
'text-field': ['get', 'name'],
'text-size': 12,
'text-offset': [0, 1.5],
'text-anchor': 'top'
}
});
// Click handler (one listener for all features)
map.on('click', 'restaurants', (e) => {
const feature = e.features[0];
new mapboxgl.Popup().setLngLat(feature.geometry.coordinates).setHTML(feature.properties.name).addTo(map);
});
Performance: 10,000 features render in <100ms
// ✅ GOOD: 50,000 markers → ~500 clusters at low zoom
map.addSource('restaurants', {
type: 'geojson',
data: restaurantsGeoJSON,
cluster: true,
clusterMaxZoom: 14, // Stop clustering at zoom 15
clusterRadius: 50 // Radius relative to tile dimensions (512 = full tile width)
});
// Cluster circle layer
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'restaurants',
filter: ['has', 'point_count'],
paint: {
'circle-color': ['step', ['get', 'point_count'], '#51bbd6', 100, '#f1f075', 750, '#f28cb1'],
'circle-radius': ['step', ['get', 'point_count'], 20, 100, 30, 750, 40]
}
});
// Cluster count label
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: 'restaurants',
filter: ['has', 'point_count'],
layout: {
'text-field': '{point_count_abbreviated}',
'text-size': 12
}
});
// Individual point layer
map.addLayer({
id: 'unclustered-point',
type: 'circle',
source: 'restaurants',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': '#11b4da',
'circle-radius': 6
}
});
Impact: 50,000 markers at 60 FPS with smooth interaction
Problem: Loading all data upfront wastes bandwidth and slows initial render.
| Scenario | Use GeoJSON | Use Vector Tiles |
|---|---|---|
| < 5 MB data | Yes | No |
| 5-20 MB data | Consider | Yes |
20 MB data | No | Yes
Data changes frequently | Yes | No
Static data, global scale | No | Yes
Need server-side updates | No | Yes
Note: This pattern is applicable when hosting GeoJSON data locally or on external servers. Mapbox-hosted data sources are already optimized for viewport-based loading.
// ✅ Only load data in current viewport
async function loadVisibleData(map) {
const bounds = map.getBounds();
const bbox = [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()].join(',');
const data = await fetch(`/api/data?bbox=${bbox}&zoom=${map.getZoom()}`);
map.getSource('data').setData(await data.json());
}
// Update on viewport change (with debounce)
let timeout;
map.on('moveend', () => {
clearTimeout(timeout);
timeout = setTimeout(() => loadVisibleData(map), 300);
});
Important: setData() triggers a full re-parse of the GeoJSON in a web worker. For small datasets updated frequently, consider using source.updateData() (requires dynamic: true on the source) for partial updates. For large datasets, switch to vector tiles.
Note: This pattern is applicable when hosting GeoJSON data locally or on external servers.
// ✅ Load basic data first, add details progressively
async function loadDataProgressive(map) {
// 1. Load simplified data first (low-res)
const simplified = await fetch('/api/data?detail=low');
map.addSource('data', {
type: 'geojson',
data: await simplified.json()
});
addLayers(map);
// 2. Load full detail in background
const detailed = await fetch('/api/data?detail=high');
map.getSource('data').setData(await detailed.json());
}
Note: The minzoom/maxzoom optimization shown below is primarily for self-hosted vector tilesets. Mapbox-hosted tilesets have built-in optimization via Mapbox Tiling Service (MTS) recipes that handle zoom-level optimizations automatically.
// ✅ Server generates tiles, client loads only visible area (self-hosted tilesets)
map.addSource('large-dataset', {
type: 'vector',
tiles: ['https://api.example.com/tiles/{z}/{x}/{y}.pbf'],
minzoom: 0,
maxzoom: 14
});
map.addLayer({
id: 'large-dataset-layer',
type: 'fill',
source: 'large-dataset',
'source-layer': 'data', // Layer name in .pbf
paint: {
'fill-color': '#088',
'fill-opacity': 0.6
}
});
Impact: 10 MB dataset reduced to ~500 KB per viewport load
Problem: Unthrottled event handlers cause performance degradation.
// ❌ BAD: Runs ~60 times per second during pan (once per render frame)
map.on('move', () => {
updateVisibleFeatures(); // Expensive query
fetchDataFromAPI(); // Network request
updateUI(); // DOM manipulation
});
// ✅ GOOD: Throttle during interaction, finalize on idle
let throttleTimeout;
// Lightweight updates during move (throttled)
map.on('move', () => {
if (throttleTimeout) return;
throttleTimeout = setTimeout(() => {
updateMapCenter(); // Cheap update
throttleTimeout = null;
}, 100);
});
// Expensive operations after interaction stops
map.on('moveend', () => {
updateVisibleFeatures();
fetchDataFromAPI();
updateUI();
});
// ❌ BAD: Query all features (expensive with many layers)
map.on('click', (e) => {
const features = map.queryRenderedFeatures(e.point);
console.log(features); // Could be 100+ features from all layers
});
// ✅ GOOD: Query specific layers only
map.on('click', (e) => {
const features = map.queryRenderedFeatures(e.point, {
layers: ['restaurants', 'shops'] // Only query these layers
});
if (features.length > 0) {
showPopup(features[0]);
}
});
// ✅ For touch targets or fuzzy clicks: Use a bounding box
map.on('click', (e) => {
const bbox = [
[e.point.x - 5, e.point.y - 5],
[e.point.x + 5, e.point.y + 5]
];
const features = map.queryRenderedFeatures(bbox, {
layers: ['restaurants'],
filter: ['==', ['get', 'type'], 'pizza'] // Further narrow results
});
});
// ❌ BAD: Update DOM for every feature
map.on('mousemove', 'restaurants', (e) => {
e.features.forEach((feature) => {
document.getElementById(feature.id).classList.add('highlight');
});
});
// ✅ GOOD: Batch updates with requestAnimationFrame
let pendingUpdates = new Set();
let rafScheduled = false;
map.on('mousemove', 'restaurants', (e) => {
e.features.forEach((f) => pendingUpdates.add(f.id));
if (!rafScheduled) {
rafScheduled = true;
requestAnimationFrame(() => {
pendingUpdates.forEach((id) => {
document.getElementById(id).classList.add('highlight');
});
pendingUpdates.clear();
rafScheduled = false;
});
}
});
Impact: 60 FPS maintained during interaction vs 15-20 FPS without optimization
Problem: Memory leaks cause browser tabs to become unresponsive over time. In SPAs that create/destroy map instances, this is a common production issue.
// ✅ Essential cleanup pattern
function cleanupMap(map) {
if (!map) return;
// 1. Remove event listeners
map.off('load', handleLoad);
map.off('move', handleMove);
// 2. Remove layers (if adding/removing dynamically)
if (map.getLayer('dynamic-layer')) {
map.removeLayer('dynamic-layer');
}
// 3. Remove sources (if adding/removing dynamically)
if (map.getSource('dynamic-source')) {
map.removeSource('dynamic-source');
}
// 4. Remove controls
map.removeControl(navigationControl);
// 5. CRITICAL: Remove map instance
map.remove();
}
// React example
useEffect(() => {
const map = new mapboxgl.Map({
/* config */
});
return () => {
cleanupMap(map); // Called on unmount
};
}, []);
// ❌ BAD: Creates new popup on every click (memory leak)
map.on('click', 'restaurants', (e) => {
new mapboxgl.Popup().setLngLat(e.lngLat).setHTML(e.features[0].properties.name).addTo(map);
// Popup never removed!
});
// ✅ GOOD: Reuse single popup instance
let popup = new mapboxgl.Popup({ closeOnClick: true });
map.on('click', 'restaurants', (e) => {
popup.setLngLat(e.lngLat).setHTML(e.features[0].properties.name).addTo(map);
// Previous popup content replaced, no leak
});
// Cleanup
function cleanup() {
popup.remove();
popup = null;
}
// ❌ BAD: Create new layer for hover (memory overhead, causes re-render)
let hoveredFeatureId = null;
map.on('mousemove', 'restaurants', (e) => {
if (map.getLayer('hover-layer')) {
map.removeLayer('hover-layer');
}
map.addLayer({
id: 'hover-layer',
type: 'circle',
source: 'restaurants',
filter: ['==', ['id'], e.features[0].id],
paint: { 'circle-color': 'yellow' }
});
});
// ✅ GOOD: Use feature state (efficient, no layer creation)
map.on('mousemove', 'restaurants', (e) => {
if (e.features.length > 0) {
// Remove previous hover state
if (hoveredFeatureId !== null) {
map.setFeatureState({ source: 'restaurants', id: hoveredFeatureId }, { hover: false });
}
// Set new hover state
hoveredFeatureId = e.features[0].id;
map.setFeatureState({ source: 'restaurants', id: hoveredFeatureId }, { hover: true });
}
});
// Style uses feature state
map.addLayer({
id: 'restaurants',
type: 'circle',
source: 'restaurants',
paint: {
'circle-color': [
'case',
['boolean', ['feature-state', 'hover'], false],
'#ffff00', // Yellow when hover
'#0000ff' // Blue otherwise
]
}
});
Note: Feature state requires features to have IDs. Use generateId: true on the GeoJSON source to auto-assign IDs, or use promoteId to use an existing property as the feature ID.
Impact: Prevents memory growth from continuous layer churn over long sessions
Problem: Mobile devices have limited resources (CPU, GPU, memory, battery).
// Detect mobile device
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v12',
// Mobile optimizations
...(isMobile && {
// Limit max zoom to reduce tile fetching at extreme zoom levels
maxZoom: 18,
// fadeDuration controls symbol collision fade animation only
// Reducing it makes label transitions snappier
fadeDuration: 0
})
});
// Load simpler layers on mobile
map.on('load', () => {
if (isMobile) {
// Circle layers are cheaper than symbol layers (no collision detection,
// no texture atlas, no text shaping)
map.addLayer({
id: 'markers-mobile',
type: 'circle',
source: 'data',
paint: {
'circle-radius': 8,
'circle-color': '#007cbf'
}
});
} else {
// Rich desktop rendering with icons and labels
map.addLayer({
id: 'markers-desktop',
type: 'symbol',
source: 'data',
layout: {
'icon-image': 'marker',
'icon-size': 1,
'text-field': ['get', 'name'],
'text-size': 12,
'text-offset': [0, 1.5]
}
});
}
});
// ✅ Simplify touch gestures
map.touchZoomRotate.disableRotation(); // Disable rotation (simpler gestures, fewer accidental rotations)
// Debounce expensive operations during touch
let touchTimeout;
map.on('touchmove', () => {
if (touchTimeout) clearTimeout(touchTimeout);
touchTimeout = setTimeout(() => {
updateVisibleData();
}, 500); // Wait for touch to settle
});
// These options have real GPU/performance costs -- only enable when needed
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v12',
// Default false -- only set true if you need map.getCanvas().toDataURL()
// Costs: prevents GPU buffer optimization
preserveDrawingBuffer: false,
// Default false -- only set true if you need smooth diagonal lines
// Costs: enables MSAA which increases GPU memory and fill cost
antialias: false
});
// ❌ BAD: 20 separate layers for restaurant types
restaurantTypes.forEach((type) => {
map.addLayer({
id: `restaurants-${type}`,
type: 'symbol',
source: 'restaurants',
filter: ['==', ['get', 'type'], type],
layout: { 'icon-image': `${type}-icon` }
});
});
// ✅ GOOD: Single layer with data-driven styling
map.addLayer({
id: 'restaurants',
type: 'symbol',
source: 'restaurants',
layout: {
'icon-image': [
'match',
['get', 'type'],
'pizza',
'pizza-icon',
'burger',
'burger-icon',
'sushi',
'sushi-icon',
'default-icon' // fallback
]
}
});
Impact: Fewer layers means less rendering overhead. Each layer has fixed per-layer cost regardless of feature count.
For datasets with 100,000+ features, simpler expressions reduce per-feature evaluation cost. For smaller datasets, the expression engine is fast enough that this won't be noticeable.
// Zoom-dependent paint properties MUST use step or interpolate, not comparisons
// ❌ WRONG: Cannot use comparison operators on ['zoom'] in paint properties
// paint: { 'fill-extrusion-height': ['case', ['>', ['zoom'], 16], ...] }
// ✅ CORRECT: Use step for discrete zoom breakpoints
map.addLayer({
id: 'buildings',
type: 'fill-extrusion',
source: 'buildings',
paint: {
'fill-extrusion-color': ['interpolate', ['linear'], ['get', 'height'], 0, '#dedede', 50, '#a0a0a0', 100, '#606060'],
'fill-extrusion-height': [
'step',
['zoom'],
['get', 'height'], // Default: use raw height
16,
['*', ['get', 'height'], 1.5] // At zoom 16+: scale up
]
}
});
For very large GeoJSON datasets, pre-computing static property derivations (like color categories) into the source data can reduce per-feature expression work:
// ✅ Pre-compute STATIC derivations for large datasets (100K+ features)
const buildingsWithColor = {
type: 'FeatureCollection',
features: buildings.features.map((f) => ({
...f,
properties: {
...f.properties,
heightColor: getColorForHeight(f.properties.height) // Pre-computed once
}
}))
};
map.addSource('buildings', { type: 'geojson', data: buildingsWithColor });
map.addLayer({
id: 'buildings',
type: 'fill-extrusion',
source: 'buildings',
paint: {
'fill-extrusion-color': ['get', 'heightColor'], // Simple property lookup
'fill-extrusion-height': ['get', 'height']
}
});
// ✅ Only render layers at appropriate zoom levels
map.addLayer({
id: 'building-details',
type: 'fill',
source: 'buildings',
minzoom: 15, // Render at zoom 15 and above
paint: { 'fill-color': '#aaa' }
});
map.addLayer({
id: 'poi-labels',
type: 'symbol',
source: 'pois',
minzoom: 12, // Hide at low zoom levels where labels would overlap heavily
layout: {
'text-field': ['get', 'name'],
visibility: 'visible'
}
});
Note: minzoom is inclusive (layer visible at that zoom), maxzoom is exclusive (layer hidden at that zoom). A layer with maxzoom: 16 is visible up to but not including zoom 16.
Impact: Reduces GPU work at zoom levels where layers aren't useful
When building a Mapbox application, verify these optimizations in order:
// Measure initial load time
console.time('map-load');
map.on('load', () => {
console.timeEnd('map-load');
// isStyleLoaded() returns true when style, sources, tiles, sprites, and models are all loaded
console.log('Style loaded:', map.isStyleLoaded());
});
// Monitor frame rate
let frameCount = 0;
map.on('render', () => frameCount++);
setInterval(() => {
console.log('FPS:', frameCount);
frameCount = 0;
}, 1000);
// Check memory usage (Chrome DevTools -> Performance -> Memory)
Target metrics:
Weekly Installs
423
Repository
GitHub Stars
35
First Seen
Feb 1, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
codex381
opencode368
gemini-cli364
github-copilot356
amp340
kimi-cli340
Vue.js测试最佳实践:Vue 3组件、组合式函数、Pinia与异步测试完整指南
3,700 周安装