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. 地图初始化逻辑

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

  • 配置安全密钥,初始化 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;
相关推荐
执行部之龙2 小时前
AI对话平台核心技术解析
前端
进击的尘埃2 小时前
Service Worker + stale-while-revalidate:让页面"假装"秒开的正经方案
javascript
yuki_uix2 小时前
防抖(Debounce):从用户体验到手写实现
前端·javascript
HelloReader2 小时前
Flutter 进阶 UI搭建 iOS 风格通讯录应用(十一)
前端
张元清2 小时前
每个 React 开发者都需要的 10 个浏览器 API Hooks
前端·javascript·面试
进击的尘埃2 小时前
给 Claude Code 造个趁手的 MCP Tool Server,聊聊我踩的那些坑
javascript
HelloReader2 小时前
Flutter ListenableBuilder让界面自动响应数据变化(十)
前端
yuki_uix2 小时前
深拷贝:JavaScript 引用类型的完全复制之道
前端·javascript
默默学前端2 小时前
JavaScript 中 call、apply、bind 的区别
开发语言·前端·javascript