封装腾讯地图实现选点功能

98 阅读4分钟

背景:由于腾讯地图选点api和我们第三方合作商的地址有些差异,所以公司决定基于腾讯地图封装一个地图选点组件,实现定制化操作。

需求

  1. 支持关键字搜索和拖拽地图选点功能,并且地图选点和关键字搜索都调用自己提供的接口;
  2. 添加点标记的,水平垂直居中地图,可拖动地图实现定位;
  3. 每次执行操作后请求数据,初始化十条数据,默认选中第一条,支持滚动加载;
  4. 限制城市搜索,超出后提示,并且回到上一个有效坐标点;

技术栈:React + Fusion组件库

实现过程

  1. 第一步(初始化腾讯地图)

首先注册腾讯地图账号,申请key用于后续初始化地图等操作,接下来就是初始化腾讯地图了

useEffect(() => {
    if (visible) {
      (async () => {
        const apiKey = await globalThis._mapsKey(); // 动态获取key
        // 加载腾讯地图 SDK
        const script = document.createElement('script');
        script.src = `https://map.qq.com/api/gljs?v=1.exp&key=${apiKey}`;
        script.charset = 'utf-8';
        script.onload = initMap; // 确保地图脚本加载完毕后调用 initMap
        document.body.appendChild(script);

        // 请求地址列表数据
        await initGetList(coord || '');

        // 在组件卸载时清理脚本
        return () => {
          document.body.removeChild(script);
          rest();
        };
      })()
    }
  }, [visible]);
  
  
 // 初始化地图操作 创建腾讯地图实例
 const initMap = () => {
    let location;
    if (coord) {
      location = strTransferNumber(coord);
    }
    const { lat, lng } = location;
    const center = new TMap.LatLng(lng, lat); // 设置地图中心点
    const map = new TMap.Map(containerId, {
      rotation: 0, // 设置地图旋转角度
      pitch: 0, // 设置俯仰角度(0~45)
      zoom: 14, // 设置地图缩放级别
      center: center, // 设置地图中心点坐标
    });

    mapRef.current = map;
 }
  
 // 渲染地图实例
 return (<div id={containerId} className={Styles.map_container} ></div>)

  1. 第二步(实现地图选点功能)

实现思路:在地图区域上方添加一个search组件,用于时下关键字搜索的功能,接下来在地图底部添加一个地图list列表用于处理请求到的数据

// 在点击搜索的时候处理数据请求,根据拿到的数据请求数据用useState保存起来,用于下列渲染数据,
// 在handleOnSearch监听输入操作 记录输入的数据  点击搜素的操作里面可以调用接口请求数据然后动态渲染到底部


  const handleOnSearch = (v) => {
    setSearch(v);
    setPage(0); // 重置页码
  };

  const handleOnClick = async () => {
    if (search && !loading) { // 确保不在加载状态时才触发请求
      try {
        setLoading(true);
        const cityName = cityNameRef.current;
        await getRegionList(cityName, search, false);
        setPage(prevPage => prevPage + 1); // 增加页码
      } catch (error) {
        console.error('Error fetching place search:', error.message);
      } finally {
        setLoading(false); // 确保无论是否成功,最终都会将 loading 设置为 false
      }
    }
  };



<Box direction='row' wrap={false}>
    <Search
      placeholder={t`搜索地点`}
      key="2"
      shape="simple"
      hasClear
      onChange={(value) => handleOnSearch(value)}
      onSearch={handleOnClick}
      value={search}
      style={{ width: '100%', marginBottom: 10 }}
    />
    {search && <Button
      type="primary"
      text
      onClick={handleOnClick}
      style={{ marginLeft: 10 }}
    >
      <span style={{ fontSize: 16, fontWeight: 500, marginTop: 5 }}>
        {t`搜索`}
      </span>
    </Button>}
</Box>
<div id={containerId} className={Styles.map_container} ></div>
<div className={Styles.region_list}>
<List
  loading={loading}
  size="small"
  style={{ height: 230 }}
  dataSource={regionList}
  renderItem={(item, i) => (
    <List.Item
      onClick={() => handleRegionList(item)}
      key={i}
      extra={action === item.name ? <Icon type="success" style={{ color: 'rgb(52, 119, 246)' }} /> : null}
      title={<span style={{ fontWeight: 500 }}>{item.name}</span>}
      media={<Icon size={14} style={{ marginTop: 5 }} type="search" />}
    >
      <span style={{ fontSize: 13 }}>{item.address}</span>
    </List.Item>
  )}
/>
</div>
  1. 第三步(实现marker定位和拖拽地图功能)

实现思路:初始化地图时添加一个点标记,拖动地图的时候拿到地图中心的位置重新赋值

// 创建一个 Marker,初始化时放在中心
const marker = new TMap.MultiMarker({
  map: map, // 将 marker 添加到地图上
  styles: { // 点标注的相关样式
    marker: new TMap.MarkerStyle({
      width: 34,
      height: 50,
      anchor: { x: 17, y: 25 },
      src: marker1,
      zIndex: 9999
    })
  },
  geometries: [
    {
      id: 'marker1',
      position: center, // 设置 marker 的初始位置
      styleId: 'marker'
    }
  ]
});

// 添加一个拖动事件
markerRef.current = marker;  // 保存 marker 引用

// 监听地图的 'move' 事件,更新 marker 位置
map.on('move', async () => {
  // 防止点击list列表触发地图移动事件
  if (currentMoveStatusRef.current) {
    currentMoveStatusRef.current = false;
    return;
  }

  const centerLocation = map.getCenter();  // 获取地图的中心点
  marker.setGeometries([
    {
      id: 'marker1',
      position: centerLocation,
      styleId: 'marker'
    }
  ]);

  setPickerValue(centerLocation);
  // 使用防抖后的函数 防止无限更新  影响性能
  debouncedInitGetList(deconstructionMapObj(centerLocation), false, true);
});
  1. 第四步(列表滚动加载)
// 在列表底部加一个footer属性 加上ref引用 通过useEffect监听到底部位置
footer={
    regionList.length > 0 && (
      <div ref={lastItemRef} style={{ height: 1, marginBottom: 20 }} />
    )
}

// 滚动请求数据
useEffect(() => {
    const observer = new IntersectionObserver(entries => {
      if (entries[0].isIntersecting && !loading) { // 确保不在加载状态时才触发请求
        handleOnClick();
      }
    },
      { threshold: 0.1 } // 设置阈值,确保在元素进入视口 10% 时触发
    );

    // 如果列表不足10条数据就不请求 表示当前位置只有10条不到的数据 滚动加载不请求了
    if (lastItemRef.current && regionList.length === 10) {
      observer.observe(lastItemRef.current);
    }

    return () => {
      if (lastItemRef.current) {
        observer.unobserve(lastItemRef.current);
      }
    };
}, [lastItemRef, loading, handleOnClick]); // 添加 handleOnClick 作为依赖项


  1. 第五步(地区范围限制)

实现思路:记录上一次有效的地址和城市名称,每次更新位置时,比对城市是否相同,不相同则限制不允许操作,重新赋值到上一个有效的地址

  async function initGetList(location: string, init: boolean, isMove: boolean) {
    try {
      setLoading(true);
      const params = location;
      const { lat, lng } = strTransferNumber(params);
      const res = await apiLbs.lbsPlaceNearby({ center: strTransferArr(params), keyword: cityName });
      const { city, name, province } = res[0] || {};
      const currentCity = city || province;

      // 初始化
      if (init) {
        cityNameRef.current = city;
        setRegionList(res);
      }

      // 移动地图时
      if (isMove) {
        setRegionList(res);
      }

      // 超出当前城市  不允许后续操作
      if (limitCity && (!currentCity || currentCity !== cityNameRef.current)) {
        Message.warning({ content: t`当前位置超出【${cityNameRef.current}】范围,请重新选择`, duration: 5000 });
        const { _lat, _lng } = lastUseFulLocationRef.current;
        if (mapRef) {
          const map = mapRef.current;
          const center = new TMap.LatLng(_lng, _lat);
          map.setCenter(center);
          markerRef.current.setGeometries([
            {
              id: 'marker1',
              position: center,
              styleId: 'marker'
            }
          ]);

        }
        return;
      }
      lastUseFulLocationRef.current = { _lat: lat, _lng: lng };
      cityNameRef.current = currentCity;
      // search 默认值给上站点名称
      if (!init) await getRegionList(city || province, name, true, isMove);
    } catch (error) {
      throw error;
    } finally {
      setLoading(false);
    }
  }

完工!接下来附上全部完整代码:

// src/components/TencentMap.tsx
import React, { useEffect, useState, useRef } from 'react';
import { Box, Button, Dialog, Icon, List, Message, Search } from '@alifd/next';
import marker1 from '@/assets/marker1.png';
import { t } from '@lingui/macro';
import { observer } from 'mobx-react-lite';
import { apiLbs } from '@/services';
import useDenounce from '@/hooks/useDebounce';
import { strTransferNumber, deconstructionMapObj, arrTransferObj, strTransferArr } from '@/utils/util'
import * as _ from 'lodash';
import { toJS } from 'mobx';
import Styles from './MapModal.module.scss';

interface MapModelProps extends DialogProps {
  coord?: string;
  limitCity?: boolean;
  cityName: string;
  onPick: (coord?: string) => void;
  containerId?: string;
  visible: boolean;
}

interface RegionListItemProps {
  name: string;
  address: string;
  location: Array<number>;
  adcode: number;
}

interface PickerValueProps {
  lat: number;
  lng: number;
}

const TencentMap = (props: MapModelProps) => {
  const { coord, cityName, limitCity = true, visible, containerId, onPick, ...otherProps } = props;
  const [loading, setLoading] = useState<boolean>(false);
  const [search, setSearch] = useState<string>('');
  const [regionList, setRegionList] = useState<Array<RegionListItemProps>>([]);
  const [pickerValue, setPickerValue] = useState<PickerValueProps>();
  const [action, setAction] = useState<string>('');
  const [page, setPage] = useState<number>(0); // 页码

  const currentMoveStatusRef = useRef<boolean>(false); // 存储当前地图移动的状态
  const cityNameRef = useRef<string>(''); // 城市名称
  const mapRef = useRef(null); // 用于保存 map 对象
  const markerRef = useRef(null);  // 用于保存 marker 对象
  const lastItemRef = useRef(null); // 用于观察最后一个元素
  const lastUseFulLocationRef = useRef(null); // 用于记录最有一个有用坐标

  useEffect(() => {
    if (visible) {
      (async () => {
        const apiKey = await globalThis._mapsKey(); // 动态获取key
        // 加载腾讯地图 SDK
        const script = document.createElement('script');
        script.src = `https://map.qq.com/api/gljs?v=1.exp&key=${apiKey}`;
        script.charset = 'utf-8';
        script.onload = initMap; // 确保地图脚本加载完毕后调用 initMap
        document.body.appendChild(script);

        // 请求地址列表数据
        await initGetList(coord || '', true, true);

        // 在组件卸载时清理脚本
        return () => {
          document.body.removeChild(script);
        };
      })()
    }
    return () => {
      rest();
    };
  }, [visible]);

  /**
   * 初始化数据
   */
  function rest() {
    setSearch('');
    setRegionList([]);
    setPickerValue(null);
    setPage(0);
    currentMoveStatusRef.current = false;
    cityNameRef.current = '';
  }

  async function initGetList(location: string, init: boolean, isMove: boolean) {
    try {
      setLoading(true);
      const params = location;
      const { lat, lng } = strTransferNumber(params);
      const res = await apiLbs.lbsPlaceNearby({ center: strTransferArr(params), keyword: cityName });
      const { city, name, province } = res[0] || {};
      const currentCity = city || province;

      // 初始化
      if (init) {
        cityNameRef.current = city;
        setRegionList(res);
      }

      // 移动地图时
      if (isMove) {
        setRegionList(res);
      }

      // 超出当前城市  不允许后续操作
      if (limitCity && (!currentCity || currentCity !== cityNameRef.current)) {
        Message.warning({ content: t`当前位置超出【${cityNameRef.current}】范围,请重新选择`, duration: 5000 });
        const { _lat, _lng } = lastUseFulLocationRef.current;
        if (mapRef) {
          const map = mapRef.current;
          const center = new TMap.LatLng(_lng, _lat);
          map.setCenter(center);
          markerRef.current.setGeometries([
            {
              id: 'marker1',
              position: center,
              styleId: 'marker'
            }
          ]);

        }
        return;
      }
      lastUseFulLocationRef.current = { _lat: lat, _lng: lng };
      cityNameRef.current = currentCity;
      // search 默认值给上站点名称
      if (!init) await getRegionList(city || province, name, true, isMove);
    } catch (error) {
      throw error;
    } finally {
      setLoading(false);
    }
  }

  // 拖动地图防抖操作 避免过度请求
  const debouncedInitGetList = useDenounce(initGetList, 500);

  const initMap = () => {
    let location;
    if (coord) {
      location = strTransferNumber(coord);
    }
    const { lat, lng } = location;
    const center = new TMap.LatLng(lng, lat); // 设置地图中心点
    const map = new TMap.Map(containerId, {
      rotation: 0, // 设置地图旋转角度
      pitch: 0, // 设置俯仰角度(0~45)
      zoom: 14, // 设置地图缩放级别
      center: center, // 设置地图中心点坐标
    });

    mapRef.current = map;

    // 创建一个 Marker,初始化时放在中心
    const marker = new TMap.MultiMarker({
      map: map, // 将 marker 添加到地图上
      styles: { // 点标注的相关样式
        marker: new TMap.MarkerStyle({
          width: 34,
          height: 50,
          anchor: { x: 17, y: 25 },
          src: marker1,
          zIndex: 9999
        })
      },
      geometries: [
        {
          id: 'marker1',
          position: center, // 设置 marker 的初始位置
          styleId: 'marker'
        }
      ]
    });
    markerRef.current = marker;  // 保存 marker 引用

    // 监听地图的 'move' 事件,更新 marker 位置
    map.on('move', async () => {
      // 防止点击list列表触发地图移动事件
      if (currentMoveStatusRef.current) {
        currentMoveStatusRef.current = false;
        return;
      }

      const centerLocation = map.getCenter();  // 获取地图的中心点
      marker.setGeometries([
        {
          id: 'marker1',
          position: centerLocation,
          styleId: 'marker'
        }
      ]);

      setPickerValue(centerLocation);
      // 使用防抖后的函数
      debouncedInitGetList(deconstructionMapObj(centerLocation), false, true);
    });
  };

  const handleOK = () => {
    if (pickerValue) {
      onPick(pickerValue);
    } else {
      Message.error(t`请选择地址`);
      onPick();
    }
  };

  const handleOnSearch = (v) => {
    setSearch(v);
    setPage(0); // 重置页码
  };

  const handleOnClick = async () => {
    if (search && !loading) { // 确保不在加载状态时才触发请求
      try {
        setLoading(true);
        const cityName = cityNameRef.current;
        await getRegionList(cityName, search, false, false);
        setPage(prevPage => prevPage + 1); // 增加页码
      } catch (error) {
        console.error('Error fetching place search:', error.message);
      } finally {
        setLoading(false); // 确保无论是否成功,最终都会将 loading 设置为 false
      }
    }
  };

  async function getRegionList(city: string, keyword: string, init: boolean, isMove: boolean) {
    if (isMove) {
      return;
    }
    const res = await apiLbs.placeSearch({
      region: city,
      keyword: keyword,
      page: page, // 当前页码
    });
    if (init) {
      const { name, address, location } = res[0]
      const { lat, lng } = arrTransferObj(location)
      setAction(() => `${name}-${address}`)
      setPickerValue({ lat, lng });
    }
    if (page === 0) {
      setRegionList(res);
    } else {
      setRegionList((prevList) => [...prevList, ...res]);
    }
  }

  const handleRegionList = (result) => {
    const { name, address, location } = toJS(result);
    const { lat, lng } = arrTransferObj(location)
    setAction(() => `${name}-${address}`)
    setPickerValue({ lat, lng });
    currentMoveStatusRef.current = true;
    if (mapRef.current) {
      const map = mapRef.current;
      const center = new TMap.LatLng(lat, lng);
      map.setCenter(center);
      markerRef.current.setGeometries([
        {
          id: 'marker1',
          position: center,
          styleId: 'marker'
        }
      ]);
    }
  };

  // 滚动请求数据
  useEffect(() => {
    const observer = new IntersectionObserver(entries => {
      if (entries[0].isIntersecting && !loading) { // 确保不在加载状态时才触发请求
        handleOnClick();
      }
    },
      { threshold: 0.1 } // 设置阈值,确保在元素进入视口 10% 时触发
    );

    if (lastItemRef.current && regionList.length === 10) {
      observer.observe(lastItemRef.current);
    }

    return () => {
      if (lastItemRef.current) {
        observer.unobserve(lastItemRef.current);
      }
    };
  }, [lastItemRef, loading, handleOnClick]); // 添加 handleOnClick 作为依赖项

  return (
    <Dialog
      v2
      title={(
        <Box direction='row' wrap={false}>
          {t`选择地点`}
          <div style={{ marginLeft: 20 }}>
            <Icon className="iconfont icon-location" size={18} style={{ color: 'red' }} />
            <span>{cityNameRef.current}</span>
          </div>
        </Box>
      )}
      centered
      width="70%"
      height="100vh"
      onOk={handleOK}
      visible={visible}
      {...otherProps}
    >
      <Box direction='row' wrap={false}>
        <Search
          placeholder={t`搜索地点`}
          key="2"
          shape="simple"
          hasClear
          onChange={(value) => handleOnSearch(value)}
          onSearch={handleOnClick}
          value={search}
          style={{ width: '100%', marginBottom: 10 }}
        />
        {search && <Button
          type="primary"
          text
          onClick={handleOnClick}
          style={{ marginLeft: 10 }}
        >
          <span style={{ fontSize: 16, fontWeight: 500, marginTop: 5 }}>
            {t`搜索`}
          </span>
        </Button>}
      </Box>
      <div id={containerId} className={Styles.map_container} ></div>
      <div className={Styles.region_list}>
        <List
          loading={loading}
          size="small"
          style={{ height: 300 }}
          dataSource={regionList}
          renderItem={(item, i) => (
            <List.Item
              onClick={() => handleRegionList(item)}
              key={i}
              extra={action === `${item.name}-${item.address}` ? <Icon type="success" style={{ color: 'rgb(52, 119, 246)' }} /> : null}
              title={<span style={{ fontWeight: 500 }}>{item.name}</span>}
              media={<Icon size={14} style={{ marginTop: 5 }} type="search" />}
            >
              <span style={{ fontSize: 13 }}>{item.address}</span>
            </List.Item>
          )}
          footer={
            regionList.length > 0 && (
              <div ref={lastItemRef} style={{ height: 1, marginBottom: 20 }} />
            )
          }
        />
      </div>
    </Dialog>
  );
};

export default observer(TencentMap);