mapbox-web-performance-patterns

安装量: 355
排名: #2627

安装

npx skills add https://github.com/mapbox/mapbox-agent-skills --skill mapbox-web-performance-patterns
Mapbox Performance Patterns Skill
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.
Priority Levels
Performance issues are prioritized by their impact on user experience:
🔴 Critical (Fix First)
Directly causes slow initial load or visible jank
🟡 High Impact
Noticeable delays or increased resource usage
🟢 Optimization
Incremental improvements for polish
🔴 Critical: Eliminate Initialization Waterfalls
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.
Anti-Pattern: Sequential Data Loading
// ❌ 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
✅ Solution: Parallel Data Loading
// ✅ 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
Preload Critical Tiles
// ✅ Preload tiles for initial viewport
const
map
=
new
mapboxgl
.
Map
(
{
container
:
'map'
,
style
:
'mapbox://styles/mapbox/streets-v12'
,
center
:
[
-
122.4194
,
37.7749
]
,
zoom
:
13
,
// Preload tiles 1 zoom level up
maxBounds
:
[
[
-
122.5
,
37.7
]
,
// Southwest
[
-
122.3
,
37.85
]
// Northeast
]
}
)
;
// Prefetch tiles before user interaction
map
.
once
(
'idle'
,
(
)
=>
{
// Map is ready, tiles are cached
console
.
log
(
'Initial tiles loaded'
)
;
}
)
;
Defer Non-Critical Features
// ✅ 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 (classic styles only)
// Note: 3D buildings cannot be deferred with Mapbox Standard style
requestIdleCallback
(
(
)
=>
{
add3DBuildings
(
map
)
;
// For classic styles only
addTerrain
(
map
)
;
}
,
{
timeout
:
2000
}
)
;
// 3. Defer analytics and non-visual features
setTimeout
(
(
)
=>
{
initializeAnalytics
(
map
)
;
}
,
3000
)
;
}
)
;
Impact:
Reduces time-to-interactive by 50-70%
🔴 Critical: Optimize Initial Bundle Size
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.
Style JSON Bundle Impact
// ❌ 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%
🟡 High Impact: Optimize Marker Count
Problem:
Too many markers causes slow rendering and interaction lag.
Performance Thresholds
< 500 markers
HTML markers OK (Marker class)
500-100,000 markers
Use Canvas markers or simple symbols
100,000-250,000 markers
Clustering required
> 250,000 markers
Server-side clustering + vector tiles Anti-Pattern: Thousands of HTML Markers // ❌ 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 ✅ Solution: Use Symbol Layers (GeoJSON) // ✅ 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 ✅ Solution: Clustering for High Density // ✅ 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 // Cluster radius in pixels } ) ; // 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 → 60 FPS, instant interaction 🟡 High Impact: Optimize Data Loading Strategy Problem: Loading all data upfront wastes bandwidth and slows initial render. GeoJSON vs Vector Tiles Decision Matrix Scenario Use GeoJSON Use Vector Tiles < 5 MB data ✅ ❌ 5-20 MB data ⚠️ Consider ✅

20 MB data ❌ ✅ Data changes frequently ✅ ❌ Static data, global scale ❌ ✅ Need server-side updates ❌ ✅ ✅ Viewport-Based Loading (GeoJSON) 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 ) ; } ) ; ✅ Progressive Data Loading 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 ( ) ) ; } ✅ Vector Tiles for Large Datasets 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 → 500 KB per viewport, 20x faster load 🟡 High Impact: Optimize Map Interactions Problem: Unthrottled event handlers cause performance degradation. Anti-Pattern: Expensive Operations on Every Event // ❌ BAD: Runs 100+ times per second during pan map . on ( 'move' , ( ) => { updateVisibleFeatures ( ) ; // Expensive query fetchDataFromAPI ( ) ; // Network request updateUI ( ) ; // DOM manipulation } ) ; ✅ Solution: Debounce/Throttle Events // ✅ 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 ( ) ; } ) ; ✅ Optimize Feature Queries // ❌ 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 } ) ; // ✅ GOOD: Query specific layers with radius map . on ( 'click' , ( e ) => { const features = map . queryRenderedFeatures ( e . point , { layers : [ 'restaurants' , 'shops' ] , // Only query these layers radius : 5 // 5px radius around click point } ) ; if ( features . length

0 ) { showPopup ( features [ 0 ] ) ; } } ) ; // ✅ EVEN BETTER: Use filter to reduce results const features = map . queryRenderedFeatures ( e . point , { layers : [ 'restaurants' ] , filter : [ '==' , [ 'get' , 'type' ] , 'pizza' ] // Only pizza restaurants } ) ; ✅ Batch DOM Updates // ❌ 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 🟢 Optimization: Memory Management Problem: Memory leaks cause browser tabs to become unresponsive over time. ✅ Always Clean Up Map Resources // ✅ 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 } ; } , [ ] ) ; ✅ Clean Up Popups and Markers // ❌ 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 ) ; // Popup removed when map closes or new popup shows } ) ; // Cleanup function cleanup ( ) { popup . remove ( ) ; popup = null ; } ✅ Use Feature State Instead of New Layers // ❌ BAD: Create new layer for hover (memory overhead) 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 ] } } ) ; Impact: Prevents memory growth from 200 MB → 2 GB over session 🟢 Optimization: Mobile Performance Problem: Mobile devices have limited resources (CPU, GPU, memory, battery). Mobile-Specific Optimizations // 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 && { // Reduce tile quality on mobile (30% smaller tiles) transformRequest : ( url , resourceType ) => { if ( resourceType === 'Tile' ) { return { url : url . replace ( '@2x' , '' ) // Use 1x tiles instead of 2x } ; } } , // Disable expensive features on mobile maxPitch : 45 , // Limit 3D perspective (battery saver) // Simplify rendering fadeDuration : 100 // Faster transitions = less GPU work } ) } ) ; // Load fewer features on mobile map . on ( 'load' , ( ) => { if ( isMobile ) { // Simple marker rendering 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 ] } } ) ; } } ) ; Touch Event Optimization // ✅ Optimize touch interactions map . touchZoomRotate . disableRotation ( ) ; // Disable rotation (simpler gestures) // Debounce expensive operations during touch let touchTimeout ; map . on ( 'touchmove' , ( ) => { if ( touchTimeout ) clearTimeout ( touchTimeout ) ; touchTimeout = setTimeout ( ( ) => { updateVisibleData ( ) ; } , 500 ) ; // Wait for touch to settle } ) ; Battery-Conscious Loading // ✅ Respect battery status if ( 'getBattery' in navigator ) { navigator . getBattery ( ) . then ( ( battery ) => { const isLowBattery = battery . level < 0.2 ; if ( isLowBattery ) { // Reduce quality and features map . setMaxZoom ( 15 ) ; // Limit detail disableAnimations ( map ) ; disableTerrain ( map ) ; } } ) ; } Impact: 50% reduction in battery drain, smoother interactions on older devices 🟢 Optimization: Layer and Style Performance Consolidate Layers // ❌ 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: 20 layers → 1 layer = 95% fewer draw calls Simplify Paint Properties // ❌ BAD: Complex expression evaluated per frame map . addLayer ( { id : 'buildings' , type : 'fill-extrusion' , source : 'buildings' , paint : { 'fill-extrusion-color' : [ 'interpolate' , [ 'linear' ] , [ 'get' , 'height' ] , 0 , '#dedede' , 10 , '#c0c0c0' , 20 , '#a0a0a0' , 50 , '#808080' , 100 , '#606060' ] , 'fill-extrusion-height' : [ '*' , [ 'get' , 'height' ] , [ 'case' , [ '>' , [ 'zoom' ] , 16 ] , 1.5 , 1.0 ] ] } } ) ; // ✅ GOOD: Pre-compute where possible // Pre-process data to add computed properties const buildingsWithPrecomputed = { type : 'FeatureCollection' , features : buildings . features . map ( ( f ) => ( { ... f , properties : { ... f . properties , displayHeight : f . properties . height * 1.5 , // Pre-computed heightColor : getColorForHeight ( f . properties . height ) // Pre-computed } } ) ) } ; map . addLayer ( { id : 'buildings' , type : 'fill-extrusion' , paint : { 'fill-extrusion-color' : [ 'get' , 'heightColor' ] , 'fill-extrusion-height' : [ 'get' , 'displayHeight' ] } } ) ; Use Zoom-Based Layer Visibility // ✅ Only render layers when visible map . addLayer ( { id : 'building-details' , type : 'fill' , source : 'buildings' , minzoom : 15 , // Only render at zoom 15+ maxzoom : 22 , paint : { 'fill-color' : '#aaa' } } ) ; map . addLayer ( { id : 'poi-labels' , type : 'symbol' , source : 'pois' , minzoom : 12 , // Hide at low zoom levels layout : { 'text-field' : [ 'get' , 'name' ] , visibility : 'visible' } } ) ; Impact: 40% reduction in GPU usage at low zoom levels Summary: Performance Checklist When building a Mapbox application, verify these optimizations in order: 🔴 Critical (Do First) Load map library and data in parallel (eliminate waterfalls) Use dynamic imports for map code (reduce initial bundle) Defer non-critical features (3D, terrain, analytics) Use clustering or symbol layers for > 100 markers Implement viewport-based data loading for large datasets 🟡 High Impact Debounce/throttle map event handlers Optimize queryRenderedFeatures with layers and radius Use GeoJSON for < 1 MB, vector tiles for > 10 MB Implement progressive data loading 🟢 Optimization Always call map.remove() on cleanup Reuse popup instances (don't create on every interaction) Use feature state instead of dynamic layers Consolidate multiple layers with data-driven styling Add mobile-specific optimizations (simpler rendering, battery awareness) Set minzoom/maxzoom on layers to avoid rendering when not visible Measurement Use these tools to measure impact: // Measure initial load time console . time ( 'map-load' ) ; map . on ( 'load' , ( ) => { console . timeEnd ( 'map-load' ) ; console . log ( 'Tiles 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: Time to Interactive: < 2 seconds on 3G Frame Rate: 60 FPS during pan/zoom Memory Growth: < 10 MB per hour of usage Bundle Size: < 500 KB initial (map lazy-loaded)

返回排行榜