React 简单地图组件封装:基于高德地图 API 的实践(附源码)

本文封装的 React 地图组件基于高德地图 JS API 2.0 开发,集成了地址搜索、经纬度定位、地图交互(点击 / 标记拖拽)、地址与经纬度互转等核心功能,同时提供了灵活的属性配置(如自定义地图高度、初始经纬度)和状态回调能力,可直接嵌入 React 项目实现基础的地图选点、地址解析需求。

一、组件开发前置准备

1. 依赖安装

组件开发依赖高德地图 API 加载器、Ant Design 组件库(用于 UI 交互)和 React 核心库,需先安装对应依赖:

复制代码
# 安装高德地图API加载器
npm install @amap/amap-jsapi-loader --save
# 安装Ant Design组件库
npm install antd --save
# 确保React核心依赖已安装
npm install react react-dom --save

2. 高德地图开发者配置

使用高德地图 API 前,需在高德开放平台注册开发者账号,创建应用并获取:

  • key:应用唯一标识,用于 API 调用授权;
  • securityJsCode:安全密钥,用于前端 API 调用的安全验证(2.0 版本必填)。

二、组件核心功能拆解与实现

1. 类型定义与基础配置

首先通过 TypeScript 接口定义组件属性,明确入参规范,提升代码可维护性:

复制代码
// 地图组件属性接口
export interface MapComponentProps {
  lng: number; // 初始经度
  lat: number; // 初始纬度
  address?: string; // 初始地址
  onPositionChange: (lng: number, lat: number, address: string) => void; // 位置变化回调
  onLoad?: () => void; // 地图加载完成回调
  height?: number; // 地图容器高度
  style?: React.CSSProperties; // 自定义样式
}

同时封装经纬度验证函数,确保定位数据的合法性:

复制代码
// 验证经纬度有效性(范围:经度-180~180,纬度-90~90)
const validateLngLat = (lng: number, lat: number) => {
  return !isNaN(lng) && !isNaN(lat) && lng >= -180 && lng <= 180 && lat >= -90 && lat <= 90;
};

2. 地图初始化逻辑

通过useEffect和AMapLoader实现地图异步加载,核心步骤包括:

  • 配置安全密钥,初始化 API 加载器;
  • 创建地图实例,设置 3D 视图、初始缩放级别和中心点;
  • 加载地理编码、地址搜索等插件,为后续功能提供支持;
  • 绑定地图控件(比例尺、工具栏)和交互事件。

核心代码如下:

复制代码
const initMap = async () => {
  try {
    // 配置安全密钥
    window._AMapSecurityConfig = {
      securityJsCode: '你的安全密钥',
    };
    // 加载高德地图API
    const AMap = await AMapLoader.load({
      key: '你的API Key',
      version: '2.0',
      plugins: ['AMap.Scale', 'AMap.Geocoder', 'AMap.PlaceSearch', 'AMap.ToolBar'],
    });
    // 创建地图实例
    const map = new AMap.Map(mapContainerRef.current!, {
      viewMode: '3D',
      zoom: 15,
      center: validateLngLat(initialLng, initialLat) ? [initialLng, initialLat] : [116.397428, 39.90923], // 默认北京坐标
    });
    mapRef.current = map; // 存储地图实例到ref,方便后续操作
    
    // 添加地图控件
    map.addControl(new AMap.Scale());
    map.addControl(new AMap.ToolBar({ position: 'RB' }));
    
    // 创建初始标记点
    createMarker(validateLngLat(initialLng, initialLat) ? [initialLng, initialLat] : [116.397428, 39.90923]);
    
    setMapLoading(false);
    onLoad?.(); // 触发地图加载完成回调
  } catch (error) {
    console.error('地图初始化失败:', error);
    setMapError('地图加载失败,请刷新页面重试');
  }
};

3. 核心交互功能实现

(1)地址搜索:地址转经纬度

通过AMap.PlaceSearch实现地址关键词搜索,将搜索结果的经纬度同步到地图和标记点:

复制代码
const searchPositionByAddress = async (keyword: string) => {
  if (!keyword.trim() || !mapRef.current) return;
  setMapLoading(true);
  
  try {
    const AMap = await AMapLoader.load({ key: '你的API Key', plugins: ['AMap.PlaceSearch'] });
    const placeSearch = new AMap.PlaceSearch({ pageSize: 1, city: '全国' });
    
    placeSearch.search(keyword, (status: string, result: any) => {
      setMapLoading(false);
      if (status === 'complete' && result.info === 'OK' && result.poiList.pois.length > 0) {
        const { lng, lat } = result.poiList.pois[0].location;
        updateMapPosition(lng, lat); // 更新地图中心和标记点
        onPositionChange(lng, lat, result.poiList.pois[0].name); // 触发位置变化回调
      } else {
        message.warning('未找到匹配的地址,请尝试其他关键词');
      }
    });
  } catch (error) {
    console.error('地址搜索失败:', error);
    setMapLoading(false);
  }
};
(2)经纬度转地址:逆地理编码

通过AMap.Geocoder实现经纬度到地址的解析,支持地图点击、标记拖拽后自动获取地址:

复制代码
const searchAddressByLngLat = (lng: number, lat: number) => {
  if (!validateLngLat(lng, lat) || !geocoderRef.current) return;
  
  geocoderRef.current.getAddress([lng, lat], (status: string, result: any) => {
    if (status === 'complete' && result.info === 'OK') {
      const address = result.regeocode.formattedAddress;
      setCurrentAddress(address);
      onPositionChange(lng, lat, address); // 同步地址到父组件
    }
  });
};
(3)地图交互:点击 / 标记拖拽
  • 地图点击:绑定click事件,点击位置同步为新的定位点;
  • 标记拖拽:标记点开启draggable,拖拽结束后更新经纬度和地址。

核心事件绑定代码:

复制代码
// 地图点击事件
map.on('click', (e: any) => {
  const { lng, lat } = e.lnglat;
  updateMapPosition(lng, lat);
  searchAddressByLngLat(lng, lat);
});

// 标记点拖拽结束事件
marker.on('dragend', (e: any) => {
  const { lng, lat } = e.lnglat;
  updateMapPosition(lng, lat);
  searchAddressByLngLat(lng, lat);
});

4. 状态管理与 UI 交互

通过 React Hooks 管理地图加载状态、错误信息、当前地址等:

  • mapLoading:控制加载中动画(Ant Design Spin 组件);
  • mapError:地图加载失败时的错误提示;
  • statusText:实时显示操作状态(如 "地址获取成功""搜索中...");
  • 搜索框支持回车触发搜索,按钮加载状态与地图加载状态联动。

三、组件使用示例

在业务组件中引入封装好的地图组件,传入初始参数并监听位置变化:

复制代码
import React from 'react';
import MapComponent from './MapComponent';

const App = () => {
  // 监听位置变化
  const handlePositionChange = (lng: number, lat: number, address: string) => {
    console.log('当前位置:', { lng, lat, address });
  };

  return (
    <div style={{ width: '800px', margin: '50px auto' }}>
      <MapComponent
        lng={116.397428}
        lat={39.90923}
        height={400}
        onPositionChange={handlePositionChange}
        onLoad={() => console.log('地图加载完成')}
      />
    </div>
  );
};

export default App;

四、组件优化与扩展建议

  1. 性能优化 :高德地图 API 加载和插件初始化可通过useMemo缓存,避免重复加载;标记点创建逻辑抽离,减少 DOM 操作。
  2. 功能扩展:支持多标记点、范围选择、自定义标记样式;增加地址模糊搜索、历史记录功能。
  3. 容错处理:补充网络异常、API 调用超限的提示;经纬度解析失败时提供默认地址兜底。
  4. 样式定制:将地图控件位置、提示框样式抽离为 Props,支持全量自定义。

总结

  1. 该 React 地图组件基于高德地图 API 2.0 封装,核心实现了地址与经纬度互转、地图点击 / 标记拖拽交互、地址搜索三大核心功能,同时提供灵活的属性配置和回调能力。
  2. 组件开发通过 TypeScript 定义接口保证类型安全,结合 React Hooks 管理状态,借助 Ant Design 实现友好的 UI 交互,可直接复用至需要地图选点的业务场景。
  3. 实际使用中可基于该基础版本扩展多标记、范围选择等功能,并补充异常处理和性能优化,适配更复杂的业务需求。

源码

复制代码
import React, { useState, useEffect, useRef } from 'react';
import { Input, Button, message, Spin } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
import AMapLoader from '@amap/amap-jsapi-loader';

// 定义地图组件属性接口
export interface MapComponentProps {
  /** 初始经度 */
  lng: number;
  /** 初始纬度 */
  lat: number;
  /** 初始地址 */
  address?: string;
  /** 位置变化回调函数 */
  onPositionChange: (lng: number, lat: number, address: string) => void;
  /** 地图加载完成回调 */
  onLoad?: () => void;
  /** 地图容器高度 */
  height?: number;
  /** 地图容器样式 */
  style?: React.CSSProperties;
}

// 验证经纬度有效性
const validateLngLat = (lng: number, lat: number) => {
  return (
    !isNaN(lng) &&
    !isNaN(lat) &&
    lng >= -180 &&
    lng <= 180 &&
    lat >= -90 &&
    lat <= 90
  );
};

const MapComponent: React.FC<MapComponentProps> = ({
  lng: initialLng,
  lat: initialLat,
  address: initialAddress = '',
  onPositionChange,
  onLoad,
  height = 300,
  style = {},
}) => {
  // 状态管理
  const [mapLoading, setMapLoading] = useState(true);
  const [mapError, setMapError] = useState('');
  const [searchKeyword, setSearchKeyword] = useState(initialAddress);
  const [currentAddress, setCurrentAddress] = useState(initialAddress);
  const [statusText, setStatusText] = useState('');
  const [isInitialized, setIsInitialized] = useState(false);

  // 地图实例引用
  const mapRef = useRef<any>(null);
  const markerRef = useRef<any>(null);
  const geocoderRef = useRef<any>(null); // 新增:地理编码实例引用
  const mapContainerRef = useRef<HTMLDivElement>(null);

  // 初始化地图
  useEffect(() => {
    if (!isInitialized && mapContainerRef.current) {
      initMap();
      setIsInitialized(true);
    }
  }, [isInitialized]);

  // 处理初始经纬度和地址更新
  useEffect(() => {
    if (mapRef.current) {
      const validLng = typeof initialLng === 'string' ? parseFloat(initialLng) : initialLng;
      const validLat = typeof initialLat === 'string' ? parseFloat(initialLat) : initialLat;

      if (validateLngLat(validLng, validLat)) {
        updateMapPosition(validLng, validLat);
        searchAddressByLngLat(validLng, validLat);
      } else {
        setStatusText('经纬度无效,使用默认位置');
        updateMapPosition(116.397428, 39.90923); // 默认北京坐标
      }
    }
  }, [initialLng, initialLat, initialAddress]);

  // 初始化地图
  const initMap = async () => {
    try {
      // 配置安全密钥
      window._AMapSecurityConfig = {
        securityJsCode: '你的密钥',
      };

      const AMap = await AMapLoader.load({
        key: '你的key值',
        version: '2.0',
        plugins: ['AMap.Scale', 'AMap.Geocoder', 'AMap.PlaceSearch', 'AMap.ToolBar'],
      });

      // 创建地图实例
      const map = new AMap.Map(mapContainerRef.current!, {
        viewMode: '3D',
        zoom: 15,
        center: validateLngLat(initialLng, initialLat)
          ? [initialLng, initialLat]
          : [116.397428, 39.90923],
      });

      // 创建地理编码实例(用于经纬度转地址)
      geocoderRef.current = new AMap.Geocoder({
        radius: 1000,
        extensions: "all"
      });

      // 存储地图实例
      mapRef.current = map;

      // 添加控件
      map.addControl(new AMap.Scale());
      map.addControl(new AMap.ToolBar({ position: 'RB' }));

      // 创建标记点
      createMarker(
        validateLngLat(initialLng, initialLat)
          ? [initialLng, initialLat]
          : [116.397428, 39.90923]
      );

      // 绑定地图点击事件
      map.on('click', (e: any) => {
        const { lng, lat } = e.lnglat;
        updateMapPosition(lng, lat);
        searchAddressByLngLat(lng, lat);
      });

      setMapLoading(false);
      setStatusText('地图加载完成');
      onLoad?.();
    } catch (error) {
      console.error('地图初始化失败:', error);
      setMapError('地图加载失败,请刷新页面重试');
      setMapLoading(false);
    }
  };

  // 创建标记点
  const createMarker = (position: [number, number]) => {
    if (!mapRef.current) return;

    // 移除现有标记
    if (markerRef.current) {
      mapRef.current.remove(markerRef.current);
    }

    // 创建新标记
    const marker = new (window as any).AMap.Marker({
      position,
      draggable: true, // 允许拖拽
      title: currentAddress,
    });

    // 绑定标记拖拽结束事件
    marker.on('dragend', (e: any) => {
      const { lng, lat } = e.lnglat;
      updateMapPosition(lng, lat);
      searchAddressByLngLat(lng, lat);
    });

    mapRef.current.add(marker);
    markerRef.current = marker;
  };

  // 更新地图位置
  const updateMapPosition = (lng: number, lat: number) => {
    if (!mapRef.current || !validateLngLat(lng, lat)) return;

    // 更新地图中心
    mapRef.current.setCenter([lng, lat]);

    // 更新标记位置
    if (markerRef.current) {
      markerRef.current.setPosition([lng, lat]);
    } else {
      createMarker([lng, lat]);
    }
  };

  // 新增:根据经纬度搜索地址(补全缺失的函数)
  const searchAddressByLngLat = (lng: number, lat: number) => {
    if (!validateLngLat(lng, lat) || !geocoderRef.current) return;

    setStatusText('获取地址中...');

    geocoderRef.current.getAddress([lng, lat], (status: string, result: any) => {
      if (status === 'complete' && result.info === 'OK') {
        const address = result.regeocode.formattedAddress;
        setCurrentAddress(address);
        setStatusText('地址获取成功');
        onPositionChange(lng, lat, address);
        // 同步更新搜索框显示
        setSearchKeyword(address);
      } else {
        setStatusText('无法获取地址信息');
        onPositionChange(lng, lat, '');
      }
    });
  };

  // 根据地址搜索位置
  const searchPositionByAddress = async (keyword: string) => {
    if (!keyword.trim() || !mapRef.current) return;

    setMapLoading(true);
    setStatusText('搜索中...');

    try {
      const AMap = await AMapLoader.load({
        key: 'key值',
        plugins: ['AMap.PlaceSearch'],
      });

      const placeSearch = new AMap.PlaceSearch({
        pageSize: 1,
        city: '全国',
        autoFitView: true,
      });

      placeSearch.search(keyword, (status: string, result: any) => {
        setMapLoading(false);

        if (status === 'complete' && result.info === 'OK' && result.poiList.pois.length > 0) {
          const { location, name } = result.poiList.pois[0];
          const { lng, lat } = location;

          updateMapPosition(lng, lat);
          setCurrentAddress(name);
          setStatusText('地址搜索成功');
          onPositionChange(lng, lat, name);
        } else {
          setStatusText('未找到匹配地址');
          message.warning('未找到匹配的地址,请尝试其他关键词');
        }
      });
    } catch (error) {
      console.error('地址搜索失败:', error);
      setMapLoading(false);
      setStatusText('搜索失败,请重试');
    }
  };

  // 处理搜索提交
  const handleSearch = () => {
    searchPositionByAddress(searchKeyword);
  };

  // 处理键盘回车搜索
  const handleKeyPress = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter') {
      handleSearch();
    }
  };

  return (
    <div style={{ width: '100%', ...style }}>
      {/* 搜索栏 */}
      <div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
        <Input
          placeholder="输入地址搜索"
          value={searchKeyword}
          onChange={(e) => setSearchKeyword(e.target.value)}
          onKeyPress={handleKeyPress}
          disabled={mapLoading || !!mapError}
          style={{ flex: 1 }}
        />
        <Button
          type="primary"
          icon={<SearchOutlined />}
          onClick={handleSearch}
          loading={mapLoading}
          disabled={!!mapError}
        >
          搜索
        </Button>
      </div>

      {/* 地图容器 */}
      <div
        ref={mapContainerRef}
        style={{
          height,
          border: '1px solid #e8e8e8',
          borderRadius: 4,
          position: 'relative',
          overflow: 'hidden',
        }}
      >
        {/* 加载状态 */}
        {mapLoading && (
          <div style={overlayStyle}>
            <Spin size="large" tip="地图加载中..." />
          </div>
        )}

        {/* 错误状态 */}
        {mapError && (
          <div style={overlayStyle}>
            <p style={{ color: '#f5222d', margin: 0 }}>{mapError}</p>
          </div>
        )}

        {/* 状态提示 */}
        {statusText && (
          <div style={statusBarStyle}>
            {statusText}
          </div>
        )}
      </div>
    </div>
  );
};

// 样式定义
const overlayStyle: React.CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  width: '100%',
  height: '100%',
  display: 'flex',
  alignItems: 'center',
  justifyContent: 'center',
  backgroundColor: 'rgba(255, 255, 255, 0.8)',
  zIndex: 100,
};

const statusBarStyle: React.CSSProperties = {
  position: 'absolute',
  bottom: 10,
  left: 10,
  backgroundColor: 'rgba(255, 255, 255, 0.8)',
  padding: '4px 8px',
  borderRadius: 4,
  fontSize: 12,
  zIndex: 99,
};

export default MapComponent;
相关推荐
miss24 分钟前
AI 改完不敢上线?OpenSpec 规约驱动开发实战
前端·代码规范·前端工程化
AiMan1 小时前
腾讯ima还是不可以导出左侧所有对话,只能单条导出,方法不对吗
前端
去伪存真1 小时前
并发量一高就装死?Agent 全链路“防猝死”手册
前端·agent
Moment1 小时前
AI 编程时代,聊聊我的 Mac mini 双屏桌面
前端·后端
AiMan1 小时前
先确认能不能一次把Microsofat Copilot所有内容打包一个文件导出
前端
总要学点什么1 小时前
第97篇:三维数据加载-城市建筑白模自定义着色器——让一条光带沿高度扫上去,快慢却由帧率说了算
前端·gis·webgl·cesium·着色器·空间分析·白模
上海懒图科技2 小时前
工厂厂区电子地图定位导航:厂区人车精准定位,安全合规双保障
前端·arcgis·vue
西安小哥2 小时前
端侧跑大模型,真正的门槛不在参数量:一次 596 秒到 10 秒的压缩路径复盘
前端·人工智能
陈随易3 小时前
Cloudflare 出钱,Vite 团队挑人,44 个维护者分 30 万美元
前端·后端·程序员
恋猫de小郭3 小时前
Flutter THLC 的 WebView 滚动终于从 70 FPS 提升到 120 FPS
android·前端·flutter