Master AMap Integration with Reusable React Hooks and Responsive Info Windows
This article details how to integrate Gaode (AMap) maps into a React frontend, covering script and npm imports, reusable map hooks, responsive marker info windows, event handling strategies, rendering abstractions for various map entities, and addressing common pitfalls such as marker overlap, bubble penetration, and memory leaks.
Background
GuMing, a tea‑drink chain, needs to analyze store locations for profitability; visualizing site distribution, coverage, and competition using Gaode (AMap) maps provides a solution.
Import Methods
JS script import
<script src="https://webapi.amap.com/maps?v=2.0.0&key=YOUR_KEY"></script>
<script type="text/javascript">
const map = new AMap.Map('container', {
center: [117.000923, 36.675807],
zoom: 11
});
</script>npm package import
import AMapLoader from '@amap/amap-jsapi-loader';
window._AMapSecurityConfig = { securityJsCode: 'YOUR_SECURITY_CODE' };
AMapLoader.load({
key: '',
version: '2.0',
plugins: []
}).then(AMap => {
const map = new AMap.Map('container');
}).catch(e => console.log(e));AMap Capabilities
Map Instance Extraction
Map is used across multiple pages; extracting common capabilities improves reuse.
Various independent states (POI search, district boundaries, measuring tools, zoom level, view range, WebGL memory) need to be managed and cleaned up.
Encapsulation
Extract and encapsulate common basic capabilities for on‑demand use.
Expose map instance, zoom level, view bounds, measuring tool, mouse tool, POI search instance, district layer instance.
Internally destroy instances to avoid memory buildup.
import { debounce } from 'lodash';
import { useEffect, useState, useRef, useMemo } from 'react';
import { RangingTool } from '@/pages/map-mode/utils/index';
import { destroyMapWebGl, districtLayerPlugin } from '@/utils/AMapUtils';
const AMap = window.AMap;
export default (props) => {
const { id, city, mapConfig = {} } = props;
const defaultZoom = mapConfig.zoom || 10;
const mapRef = useRef();
const rangingToolRef = useRef();
const mouseToolRef = useRef();
const searchRef = useRef();
const districtLayerPluginRef = useRef();
const [currentZoom, setCurrentZoom] = useState(defaultZoom);
const [currentBound, setCurrentBound] = useState(undefined);
const [mapInstance, setMapInstance] = useState();
const handleMapMove = useMemo(() => debounce(() => {
const bound = mapRef.current.getBounds();
setCurrentBound(bound);
}, 50), []);
useEffect(() => {
if (!mapRef.current) {
mapRef.current = new AMap.Map(id, mapConfig || {});
}
const map = mapRef.current;
map.on('complete', () => {
rangingToolRef.current = new RangingTool({ mapInstance: mapRef.current });
mouseToolRef.current = new AMap.MouseTool(map);
});
map.on('zoomchange', () => {
setCurrentZoom(mapRef.current.getZoom());
setCurrentBound(mapRef.current.getBounds());
});
map.on('mapmove', () => handleMapMove());
map.on('click', () => {});
setMapInstance(map);
return () => {
const parentDom = document.getElementById(id);
const canvas = parentDom?.getElementsByTagName('canvas');
destroyMapWebGl(canvas);
mapRef.current.destroy();
mapRef.current = null;
setMapInstance(null);
};
}, []);
useEffect(() => {
if (city && mapRef.current) {
initMapSearch();
mapRef.current.setCity(city);
mapRef.current.setZoom(defaultZoom);
}
}, [city]);
const initMapSearch = () => {
searchRef.current = new AMap.PlaceSearch({
city,
citylimit: true,
pageSize: 50,
extensions: 'all'
});
};
return {
mapRef,
mapInstance,
rangingToolRef,
mouseToolRef,
searchRef,
currentZoom,
currentBound,
districtLayerPluginRef,
};
};Usage
const createId = () => `${Math.random().toString(36).substr(2)}_${+new Date}`;
const [mapId] = useState(createId());
const { mapInstance, searchRef, currentZoom } = useInitMap({
id: mapId,
city,
mapConfig: { zoom: 10, isHotspot: true }
});
return (<>
<div id={mapId}></div>
</>);Responsive Info Window
Background
Clicking a marker should open a popup that is tightly attached to the marker and responsive; using Marker.setLabel() or Marker.setContent() only accepts a string or DOM element, which limits component‑based development.
Problem
Cannot write marker content as a React component.
Popup content is static, not responsive.
Event binding is cumbersome, requiring manual DOM queries.
Form field values are hard to retrieve.
Solution
Insert a globally unique empty div into the marker content, render the React component into that div, and thus achieve a responsive, component‑based popup.
import ClueCreateDialog from "@c/omponents/ClueCreateDialog/index";
const uid = () => `${Math.random().toString(36).substr(2)}_${new Date().getTime()}`;
marker.setContent(`<div id="${uid}"></div>`);
mapInstance.add(marker);
ReactDom.render(<ClueCreateDialog />, document.getElementById(uid));This approach resolves all listed issues.
Event Handling
Map Event Specificity
Map events and marker events are separate; binding them at creation can lock the current state, causing stale data in callbacks.
Both event types may need “penetration” control, which AMap does not support dynamically, leading to conflicts.
Two solutions:
Re‑bind events on state change (may affect performance for massive markers).
Keep initial bindings and use refs to store state, though refs hinder reactivity.
Comprehensive Trade‑off Solution
Map events are few; they can be dynamically re‑bound to always have the latest state.
Marker events are massive; bind once at initialization and use internal logic to decide execution.
Solid line = persistent event, dashed line = dynamic binding. Marker events cannot be dynamically toggled, so they remain bound; map events can be cleared when not needed.
Map Event Binding
Use use-map-events hook to automatically re‑bind events based on state.
export default (props) => {
const { mapInstance, mapCurrentEventType, handleClickHotSpot, handleClickAddClue } = props;
useEffect(() => {
if (mapInstance) {
mapInstance.clearEvents('hotspotclick');
mapCurrentEventType === MAP_EVENTS.addGatherPoint && mapInstance.on('hotspotclick', handleClickHotSpot);
mapInstance.clearEvents('click');
mapCurrentEventType === MAP_EVENTS.addClue && mapInstance.on('click', handleClickAddClue);
}
}, [mapInstance, mapCurrentEventType]);
return {};
};Marker Event Binding
Bind once; inside the handler check a ref to decide whether to execute map‑level logic.
const handleClickPointMark = async (data) => {
if (mapCurrentEventTypeRef.current) return;
const { position } = data;
mapRef.current && mapRef.current.panTo(position);
setCurPointData(data);
setShowPonitCard(true);
};
useRenderPointMarkers({
pointsData: pointData,
mapInstance,
mapRef,
handleClickPointMark,
});Render Extraction
Entity Rendering Hooks
Point markers: use-render-point-markers Clue markers: use-render-clue-markers Competitor POI markers: use-render-poi-markers Planning polygons: use-render-plan-polygons Traffic lines: use-render-traffic-lines Each hook receives data and uses the appropriate AMap API to create the visual entity.
export default (props) => {
const { pointsData = [], mapInstance, mapRef, handleClickPointMark } = props;
const pointMarkersRef = useRef({});
useEffect(() => {
if (!mapInstance) return;
Object.keys(pointMarkersRef.current).forEach(id => {
mapRef.current.remove(pointMarkersRef.current[id]);
delete pointMarkersRef.current[id];
});
pointsData?.forEach(item => {
const size = POINT_MARKES_SIZE;
const Icon = new AMap.Icon({
size,
anchor: POINT_MARKES_ANCHOR,
image: item.iconConfig?.iconUrl,
imageSize: size,
});
const marker = new AMap.Marker({
position: new AMap.LngLat(item.position[0], item.position[1]),
title: item.name,
icon: Icon,
extData: { item },
visible: true,
anchor: new AMap.Pixel(0, 0),
offset: POINT_MARKES_OFFSET,
bubble: true,
zIndex: 999,
});
marker.on('click', () => handleClickPointMark?.(marker.getExtData().item));
mapRef.current.add(marker);
pointMarkersRef.current[item.id] = marker;
});
}, [pointsData, mapInstance]);
return { pointMarkersRef };
};Other Issues
AMap Defects
massMarker click penetration bug : Overlapping markers may register clicks on the lowest layer; this is a known bug.
Dynamic bubble property : Cannot change bubble at runtime; must set during event binding.
Marker Icon Offset
Different icons require proper anchor settings; using the correct anchor eliminates the need for manual offset adjustments.
Marker Drag Latitude/Longitude Offset
Using e.lnglat after dragging a non‑anchor point causes offset; the correct approach is e.target.getPosition() .
// Incorrect
markerRef.current.on('dragend', async e => {
mapInstnce.setCenter([e.lnglat.lng, e?.lnglat.lat]);
});
// Correct
markerRef.current.on('dragend', async e => {
mapInstnce.setCenter(e.target.getPosition());
});Memory Leak
AMap’s destroy does not fully release WebGL resources, leading to memory growth when switching pages; the issue is acknowledged by AMap.
Workaround: lose WebGL context manually.
export const destroyMapWebGl = (canvass = []) => {
for (let i = 0; i < canvass.length; i++) {
const canvas = canvass[i];
const gl = canvas.getContext('webgl');
gl.getExtension('WEBGL_lose_context')?.loseContext?.();
}
};Summary
Extracting reusable map capabilities simplifies integration across business scenarios.
Responsive info windows are achieved by rendering React components into dynamically created DOM nodes.
Event handling balances performance and state freshness: map events are dynamically bound, marker events are bound once.
Separate hooks for each entity type promote modularity and reuse.
Goodme Frontend Team
Regularly sharing the team's insights and expertise in the frontend field
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
