背景:由于腾讯地图选点api和我们第三方合作商的地址有些差异,所以公司决定基于腾讯地图封装一个地图选点组件,实现定制化操作。
需求:
- 支持关键字搜索和拖拽地图选点功能,并且地图选点和关键字搜索都调用自己提供的接口;
- 添加点标记的,水平垂直居中地图,可拖动地图实现定位;
- 每次执行操作后请求数据,初始化十条数据,默认选中第一条,支持滚动加载;
- 限制城市搜索,超出后提示,并且回到上一个有效坐标点;
技术栈:React + Fusion组件库
实现过程
- 第一步(初始化腾讯地图)
首先注册腾讯地图账号,申请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>)
- 第二步(实现地图选点功能)
实现思路:在地图区域上方添加一个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>
- 第三步(实现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);
});
- 第四步(列表滚动加载)
// 在列表底部加一个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 作为依赖项
- 第五步(地区范围限制)
实现思路:记录上一次有效的地址和城市名称,每次更新位置时,比对城市是否相同,不相同则限制不允许操作,重新赋值到上一个有效的地址
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);