文章目录
-
- 每日一句正能量
- 摘要
- [1. 背景与痛点分析](#1. 背景与痛点分析)
-
- [1.1 传统地图集成的问题](#1.1 传统地图集成的问题)
- [1.2 Hooks封装的优势](#1.2 Hooks封装的优势)
- [2. 核心架构设计](#2. 核心架构设计)
-
- [2.1 整体架构图](#2.1 整体架构图)
- [2.2 模块划分](#2.2 模块划分)
- [3. 核心Hooks实现](#3. 核心Hooks实现)
-
- [3.1 useBMapLoader - 地图加载器](#3.1 useBMapLoader - 地图加载器)
- [3.2 useMapInstance - 地图实例管理](#3.2 useMapInstance - 地图实例管理)
- [3.3 useMapEvents - 地图事件管理](#3.3 useMapEvents - 地图事件管理)
- [3.4 useMapOverlays - 覆盖物管理](#3.4 useMapOverlays - 覆盖物管理)
- [4. 完整示例:地图组件封装](#4. 完整示例:地图组件封装)
-
- [4.1 基础地图组件](#4.1 基础地图组件)
- [4.2 高级地图组件(带控件和工具)](#4.2 高级地图组件(带控件和工具))
- [4.3 自定义地图控件](#4.3 自定义地图控件)
- [4.4 地图工具函数](#4.4 地图工具函数)

每日一句正能量
所谓门槛,能力够了就是门,能力不够就是坎。
与其抱怨门槛太高,不如承认那是自己能力的边界;而边界,是可以移动的。
摘要
在React项目中集成地图功能时,传统的组件封装方式往往面临状态管理混乱、生命周期耦合、性能优化困难等问题。本文提出一种基于React Hooks的百度地图高阶封装方案,通过自定义hooks实现地图核心功能的模块化、按需加载和自动清理,为React项目提供一套轻量化、工程化、可维护的地图解决方案。
1. 背景与痛点分析
1.1 传统地图集成的问题
在React项目中集成百度地图时,开发者通常面临以下挑战:
- 全局污染问题 :百度地图SDK会向全局注入
BMap对象,在多页面应用中容易造成命名冲突 - 生命周期管理复杂:地图实例的创建、销毁需要手动管理,容易导致内存泄漏
- 状态同步困难:地图状态与React组件状态需要手动同步,代码耦合度高
- 性能优化不足:地图资源加载时机难以控制,影响页面首屏加载性能
- 代码复用性差:地图相关逻辑分散在各个组件中,难以抽象和复用
1.2 Hooks封装的优势
使用React Hooks封装地图功能具有以下优势:
- 逻辑复用:将地图相关逻辑抽象为自定义hooks,实现跨组件复用
- 状态隔离:每个hooks实例拥有独立的状态和作用域
- 生命周期自动化 :利用
useEffect自动管理地图实例的生命周期 - 按需加载:通过动态导入实现地图SDK的按需加载
- 类型安全:配合TypeScript提供完整的类型提示
2. 核心架构设计
2.1 整体架构图
#mermaid-svg-OkivnxnJToiTZSjo{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-OkivnxnJToiTZSjo .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-OkivnxnJToiTZSjo .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-OkivnxnJToiTZSjo .error-icon{fill:#552222;}#mermaid-svg-OkivnxnJToiTZSjo .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-OkivnxnJToiTZSjo .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-OkivnxnJToiTZSjo .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-OkivnxnJToiTZSjo .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-OkivnxnJToiTZSjo .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-OkivnxnJToiTZSjo .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-OkivnxnJToiTZSjo .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-OkivnxnJToiTZSjo .marker{fill:#333333;stroke:#333333;}#mermaid-svg-OkivnxnJToiTZSjo .marker.cross{stroke:#333333;}#mermaid-svg-OkivnxnJToiTZSjo svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-OkivnxnJToiTZSjo p{margin:0;}#mermaid-svg-OkivnxnJToiTZSjo .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-OkivnxnJToiTZSjo .cluster-label text{fill:#333;}#mermaid-svg-OkivnxnJToiTZSjo .cluster-label span{color:#333;}#mermaid-svg-OkivnxnJToiTZSjo .cluster-label span p{background-color:transparent;}#mermaid-svg-OkivnxnJToiTZSjo .label text,#mermaid-svg-OkivnxnJToiTZSjo span{fill:#333;color:#333;}#mermaid-svg-OkivnxnJToiTZSjo .node rect,#mermaid-svg-OkivnxnJToiTZSjo .node circle,#mermaid-svg-OkivnxnJToiTZSjo .node ellipse,#mermaid-svg-OkivnxnJToiTZSjo .node polygon,#mermaid-svg-OkivnxnJToiTZSjo .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-OkivnxnJToiTZSjo .rough-node .label text,#mermaid-svg-OkivnxnJToiTZSjo .node .label text,#mermaid-svg-OkivnxnJToiTZSjo .image-shape .label,#mermaid-svg-OkivnxnJToiTZSjo .icon-shape .label{text-anchor:middle;}#mermaid-svg-OkivnxnJToiTZSjo .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-OkivnxnJToiTZSjo .rough-node .label,#mermaid-svg-OkivnxnJToiTZSjo .node .label,#mermaid-svg-OkivnxnJToiTZSjo .image-shape .label,#mermaid-svg-OkivnxnJToiTZSjo .icon-shape .label{text-align:center;}#mermaid-svg-OkivnxnJToiTZSjo .node.clickable{cursor:pointer;}#mermaid-svg-OkivnxnJToiTZSjo .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-OkivnxnJToiTZSjo .arrowheadPath{fill:#333333;}#mermaid-svg-OkivnxnJToiTZSjo .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-OkivnxnJToiTZSjo .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-OkivnxnJToiTZSjo .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OkivnxnJToiTZSjo .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-OkivnxnJToiTZSjo .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OkivnxnJToiTZSjo .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-OkivnxnJToiTZSjo .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-OkivnxnJToiTZSjo .cluster text{fill:#333;}#mermaid-svg-OkivnxnJToiTZSjo .cluster span{color:#333;}#mermaid-svg-OkivnxnJToiTZSjo div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-OkivnxnJToiTZSjo .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-OkivnxnJToiTZSjo rect.text{fill:none;stroke-width:0;}#mermaid-svg-OkivnxnJToiTZSjo .icon-shape,#mermaid-svg-OkivnxnJToiTZSjo .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OkivnxnJToiTZSjo .icon-shape p,#mermaid-svg-OkivnxnJToiTZSjo .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-OkivnxnJToiTZSjo .icon-shape .label rect,#mermaid-svg-OkivnxnJToiTZSjo .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OkivnxnJToiTZSjo .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-OkivnxnJToiTZSjo .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-OkivnxnJToiTZSjo :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 地图服务层
地图配置服务
坐标转换服务
地理编码服务
路径规划服务
地图Hooks层
useBMapLoader
地图加载器
useMapInstance
地图实例管理
useMapControls
地图控件管理
useMapEvents
地图事件管理
useMapOverlays
地图覆盖物管理
React应用层
百度地图SDK
2.2 模块划分
我们的hooks封装方案分为以下核心模块:
- 加载器模块:负责地图SDK的异步加载和初始化
- 实例模块:管理地图实例的创建、配置和销毁
- 控件模块:封装地图控件的添加、移除和交互
- 事件模块:统一管理地图事件的绑定和解绑
- 覆盖物模块:管理标记、折线、多边形等地图覆盖物
- 工具模块:提供坐标转换、地理编码等工具函数
3. 核心Hooks实现
3.1 useBMapLoader - 地图加载器
typescript
import { useState, useEffect } from 'react';
interface BMapLoaderOptions {
ak: string; // 百度地图AK
version?: string; // SDK版本
plugins?: string[]; // 需要加载的插件
}
interface BMapLoaderResult {
BMap: typeof window.BMap | null;
isLoading: boolean;
error: Error | null;
}
/**
* 百度地图SDK异步加载Hook
*/
export const useBMapLoader = (options: BMapLoaderOptions): BMapLoaderResult => {
const [BMap, setBMap] = useState<typeof window.BMap | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let mounted = true;
const loadBMap = async () => {
try {
// 检查是否已加载
if (window.BMap) {
if (mounted) {
setBMap(window.BMap);
setIsLoading(false);
}
return;
}
// 动态创建script标签
const script = document.createElement('script');
const { ak, version = '3.0', plugins = [] } = options;
let url = `https://api.map.baidu.com/api?v=${version}&ak=${ak}&callback=initBMapCallback`;
if (plugins.length > 0) {
url += `&plugins=${plugins.join(',')}`;
}
script.src = url;
script.async = true;
script.defer = true;
// 定义全局回调函数
window.initBMapCallback = () => {
if (mounted) {
setBMap(window.BMap);
setIsLoading(false);
}
};
script.onerror = (err) => {
if (mounted) {
setError(new Error(`Failed to load Baidu Map SDK: ${err}`));
setIsLoading(false);
}
};
document.head.appendChild(script);
// 清理函数
return () => {
mounted = false;
if (script.parentNode) {
script.parentNode.removeChild(script);
}
delete window.initBMapCallback;
};
} catch (err) {
if (mounted) {
setError(err as Error);
setIsLoading(false);
}
}
};
loadBMap();
return () => {
mounted = false;
};
}, [options.ak, options.version, JSON.stringify(options.plugins)]);
return { BMap, isLoading, error };
};
3.2 useMapInstance - 地图实例管理
typescript
import { useState, useEffect, useRef, useCallback } from 'react';
interface MapInstanceOptions {
container: string | HTMLElement; // 地图容器
center: { lng: number; lat: number }; // 中心点坐标
zoom: number; // 缩放级别
minZoom?: number;
maxZoom?: number;
enableScrollWheelZoom?: boolean;
enableDragging?: boolean;
mapType?: 'BMAP_NORMAL_MAP' | 'BMAP_SATELLITE_MAP' | 'BMAP_HYBRID_MAP';
}
interface MapInstanceResult {
map: BMap.Map | null;
setCenter: (center: { lng: number; lat: number }) => void;
setZoom: (zoom: number) => void;
panTo: (point: { lng: number; lat: number }) => void;
destroy: () => void;
}
/**
* 地图实例管理Hook
*/
export const useMapInstance = (
BMap: typeof window.BMap | null,
options: MapInstanceOptions
): MapInstanceResult => {
const [map, setMap] = useState<BMap.Map | null>(null);
const mapRef = useRef<BMap.Map | null>(null);
const containerRef = useRef<string | HTMLElement>(options.container);
// 创建地图实例
useEffect(() => {
if (!BMap || !containerRef.current) return;
const container =
typeof containerRef.current === 'string'
? document.getElementById(containerRef.current)
: containerRef.current;
if (!container) {
console.error('Map container not found');
return;
}
// 创建地图实例
const mapInstance = new BMap.Map(container);
const point = new BMap.Point(options.center.lng, options.center.lat);
mapInstance.centerAndZoom(point, options.zoom);
mapInstance.enableScrollWheelZoom(options.enableScrollWheelZoom ?? true);
mapInstance.enableDragging(options.enableDragging ?? true);
// 设置地图类型
if (options.mapType) {
mapInstance.setMapType((BMap as any)[options.mapType]);
}
// 设置缩放范围
if (options.minZoom !== undefined) {
mapInstance.setMinZoom(options.minZoom);
}
if (options.maxZoom !== undefined) {
mapInstance.setMaxZoom(options.maxZoom);
}
mapRef.current = mapInstance;
setMap(mapInstance);
// 清理函数
return () => {
if (mapRef.current) {
mapRef.current.destroy();
mapRef.current = null;
setMap(null);
}
};
}, [BMap]);
// 更新中心点
const setCenter = useCallback((center: { lng: number; lat: number }) => {
if (!mapRef.current || !BMap) return;
const point = new BMap.Point(center.lng, center.lat);
mapRef.current.setCenter(point);
}, [BMap]);
// 更新缩放级别
const setZoom = useCallback((zoom: number) => {
if (!mapRef.current) return;
mapRef.current.setZoom(zoom);
}, []);
// 平移到指定点
const panTo = useCallback((point: { lng: number; lat: number }) => {
if (!mapRef.current || !BMap) return;
const bmapPoint = new BMap.Point(point.lng, point.lat);
mapRef.current.panTo(bmapPoint);
}, [BMap]);
// 销毁地图
const destroy = useCallback(() => {
if (mapRef.current) {
mapRef.current.destroy();
mapRef.current = null;
setMap(null);
}
}, []);
return {
map,
setCenter,
setZoom,
panTo,
destroy,
};
};
3.3 useMapEvents - 地图事件管理
typescript
import { useEffect, useRef } from 'react';
type MapEventType =
| 'click' | 'dblclick' | 'rightclick'
| 'mousemove' | 'mouseover' | 'mouseout'
| 'movestart' | 'moving' | 'moveend'
| 'zoomstart' | 'zoomend' | 'dragstart' | 'dragend';
interface MapEventHandlers {
[key: string]: (e: any) => void;
}
/**
* 地图事件管理Hook
*/
export const useMapEvents = (
map: BMap.Map | null,
eventHandlers: MapEventHandlers
) => {
const handlersRef = useRef<MapEventHandlers>(eventHandlers);
const eventListenersRef = useRef<{ [key: string]: any }>({});
// 更新事件处理器引用
useEffect(() => {
handlersRef.current = eventHandlers;
}, [eventHandlers]);
// 绑定和解绑事件
useEffect(() => {
if (!map) return;
const currentListeners = eventListenersRef.current;
// 绑定新事件
Object.entries(handlersRef.current).forEach(([eventType, handler]) => {
if (handler && typeof handler === 'function') {
const listener = (e: any) => {
handler(e);
};
// 移除旧的监听器(如果存在)
if (currentListeners[eventType]) {
map.removeEventListener(eventType as any, currentListeners[eventType]);
}
// 添加新的监听器
map.addEventListener(eventType as any, listener);
currentListeners[eventType] = listener;
}
});
// 清理函数:移除所有事件监听器
return () => {
Object.entries(currentListeners).forEach(([eventType, listener]) => {
if (map && listener) {
map.removeEventListener(eventType as any, listener);
}
});
eventListenersRef.current = {};
};
}, [map]);
};
3.4 useMapOverlays - 覆盖物管理
typescript
import { useState, useEffect, useRef, useCallback } from 'react';
interface MarkerOptions {
position: { lng: number; lat: number };
title?: string;
icon?: string;
label?: string;
draggable?: boolean;
}
interface PolylineOptions {
path: Array<{ lng: number; lat: number }>;
strokeColor?: string;
strokeWeight?: number;
strokeOpacity?: number;
}
/**
* 地图覆盖物管理Hook
*/
export const useMapOverlays = (map: BMap.Map | null, BMap: typeof window.BMap | null) => {
const [markers, setMarkers] = useState<BMap.Marker[]>([]);
const [polylines, setPolylines] = useState<BMap.Polyline[]>([]);
const overlaysRef = useRef<{
markers: BMap.Marker[];
polylines: BMap.Polyline[];
}>({ markers: [], polylines: [] });
// 添加标记
const addMarker = useCallback((options: MarkerOptions) => {
if (!map || !BMap || !options.position) return null;
const point = new BMap.Point(options.position.lng, options.position.lat);
const marker = new BMap.Marker(point, {
title: options.title,
enableDragging: options.draggable,
});
// 设置图标
if (options.icon) {
const icon = new BMap.Icon(options.icon, new BMap.Size(30, 30));
marker.setIcon(icon);
}
// 设置标签
if (options.label) {
const label = new BMap.Label(options.label, {
offset: new BMap.Size(20, -10),
});
marker.setLabel(label);
}
map.addOverlay(marker);
overlaysRef.current.markers.push(marker);
setMarkers([...overlaysRef.current.markers]);
return marker;
}, [map, BMap]);
// 添加折线
const addPolyline = useCallback((options: PolylineOptions) => {
if (!map || !BMap || !options.path || options.path.length < 2) return null;
const points = options.path.map(p => new BMap.Point(p.lng, p.lat));
const polyline = new BMap.Polyline(points, {
strokeColor: options.strokeColor || '#FF0000',
strokeWeight: options.strokeWeight || 2,
strokeOpacity: options.strokeOpacity || 0.8,
});
map.addOverlay(polyline);
overlaysRef.current.polylines.push(polyline);
setPolylines([...overlaysRef.current.polylines]);
return polyline;
}, [map, BMap]);
// 清除所有覆盖物
const clearAllOverlays = useCallback(() => {
if (!map) return;
// 清除标记
overlaysRef.current.markers.forEach(marker => {
map.removeOverlay(marker);
});
overlaysRef.current.markers = [];
setMarkers([]);
// 清除折线
overlaysRef.current.polylines.forEach(polyline => {
map.removeOverlay(polyline);
});
overlaysRef.current.polylines = [];
setPolylines([]);
}, [map]);
// 组件卸载时清理
useEffect(() => {
return () => {
clearAllOverlays();
};
}, [clearAllOverlays]);
return {
markers,
polylines,
addMarker,
addPolyline,
clearAllOverlays,
};
};
4. 完整示例:地图组件封装
4.1 基础地图组件
tsx
import React, { useMemo } from 'react';
import { useBMapLoader, useMapInstance, useMapEvents, useMapOverlays } from './hooks';
interface BMapComponentProps {
ak: string;
containerId: string;
center: { lng: number; lat: number };
zoom: number;
onMapClick?: (e: any) => void;
onMapMoveEnd?: (e: any) => void;
markers?: Array<{
position: { lng: number; lat: number };
title?: string;
icon?: string;
}>;
}
const BMapComponent: React.FC<BMapComponentProps> = ({
ak,
containerId,
center,
zoom,
onMapClick,
onMapMoveEnd,
markers = [],
}) => {
// 1. 加载地图SDK
const { BMap, isLoading, error } = useBMapLoader({
ak,
plugins: ['Mapv'],
});
// 2. 创建地图实例
const { map } = useMapInstance(BMap, {
container: containerId,
center,
zoom,
enableScrollWheelZoom: true,
enableDragging: true,
mapType: 'BMAP_NORMAL_MAP',
});
// 3. 绑定地图事件
useMapEvents(map, {
click: onMapClick || (() => {}),
moveend: onMapMoveEnd || (() => {}),
});
// 4. 管理覆盖物
const { addMarker, clearAllOverlays } = useMapOverlays(map, BMap);
// 5. 根据props添加标记
useMemo(() => {
if (!map || !BMap || markers.length === 0) return;
clearAllOverlays();
markers.forEach(markerConfig => {
addMarker({
position: markerConfig.position,
title: markerConfig.title,
icon: markerConfig.icon,
});
});
}, [map, BMap, markers, addMarker, clearAllOverlays]);
// 加载状态处理
if (isLoading) {
return (
<div className="map-loading">
<div className="loading-spinner"></div>
<p>地图加载中...</p>
</div>
);
}
if (error) {
return (
<div className="map-error">
<p>地图加载失败: {error.message}</p>
<button onClick={() => window.location.reload()}>重试</button>
</div>
);
}
// 6. 渲染地图容器
return (
<div className="bmap-container">
<div
id={containerId}
style={{
width: '100%',
height: '500px',
borderRadius: '8px',
overflow: 'hidden',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)'
}}
/>
{/* 地图控制面板 */}
<div className="map-controls" style={{ marginTop: '16px' }}>
<div className="map-info">
<p>中心点: {center.lng.toFixed(6)}, {center.lat.toFixed(6)}</p>
<p>缩放级别: {zoom}</p>
<p>标记数量: {markers.length}</p>
</div>
<div className="map-actions" style={{ marginTop: '12px' }}>
<button
onClick={() => {
if (map) {
map.setZoom(map.getZoom() + 1);
}
}}
style={{ marginRight: '8px' }}
>
放大
</button>
<button
onClick={() => {
if (map) {
map.setZoom(map.getZoom() - 1);
}
}}
style={{ marginRight: '8px' }}
>
缩小
</button>
<button
onClick={() => {
if (map) {
map.setCenter(new BMap.Point(center.lng, center.lat));
}
}}
>
回到中心
</button>
</div>
</div>
</div>
);
};
// 样式定义(可提取到单独的CSS文件)
const styles = `
.bmap-container {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.map-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 500px;
background: #f5f5f5;
border-radius: 8px;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #e0e0e0;
border-top: 4px solid #1890ff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.map-error {
padding: 24px;
text-align: center;
background: #fff2f0;
border: 1px solid #ffccc7;
border-radius: 8px;
color: #cf1322;
}
.map-error button {
margin-top: 12px;
padding: 8px 16px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.map-error button:hover {
background: #40a9ff;
}
.map-controls {
background: #fafafa;
padding: 16px;
border-radius: 8px;
border: 1px solid #f0f0f0;
}
.map-info p {
margin: 4px 0;
color: #666;
font-size: 14px;
}
.map-actions button {
padding: 6px 12px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.map-actions button:hover {
background: #40a9ff;
}
.map-actions button:active {
background: #096dd9;
}
`;
// 注入样式
if (typeof document !== 'undefined') {
const styleElement = document.createElement('style');
styleElement.textContent = styles;
document.head.appendChild(styleElement);
}
export default BMapComponent;
/**
* 使用示例:
*
* ```tsx
* import React from 'react';
* import BMapComponent from './BMapComponent';
*
* const App = () => {
* const handleMapClick = (e: any) => {
* console.log('地图点击坐标:', e.point.lng, e.point.lat);
* };
*
* const handleMapMoveEnd = (e: any) => {
* console.log('地图移动结束');
* };
*
* const markers = [
* {
* position: { lng: 116.404, lat: 39.915 },
* title: '天安门',
* icon: 'https://api.map.baidu.com/images/marker_red.png'
* },
* {
* position: { lng: 116.414, lat: 39.925 },
* title: '故宫',
* icon: 'https://api.map.baidu.com/images/marker_blue.png'
* }
* ];
*
* return (
* <div>
* <h1>百度地图示例</h1>
* <BMapComponent
* ak="你的百度地图AK"
* containerId="myMap"
* center={{ lng: 116.404, lat: 39.915 }}
* zoom={15}
* onMapClick={handleMapClick}
* onMapMoveEnd={handleMapMoveEnd}
* markers={markers}
* />
* </div>
* );
* };
*
* export default App;
* ```
*/
4.2 高级地图组件(带控件和工具)
tsx
import React, { useState, useCallback } from 'react';
import BMapComponent from './BMapComponent';
import { useMapTools } from './hooks/useMapTools';
interface AdvancedBMapProps {
ak: string;
containerId: string;
initialCenter: { lng: number; lat: number };
initialZoom: number;
showControls?: boolean;
showTools?: boolean;
}
const AdvancedBMap: React.FC<AdvancedBMapProps> = ({
ak,
containerId,
initialCenter,
initialZoom,
showControls = true,
showTools = true,
}) => {
const [center, setCenter] = useState(initialCenter);
const [zoom, setZoom] = useState(initialZoom);
const [markers, setMarkers] = useState<Array<{
position: { lng: number; lat: number };
title: string;
icon?: string;
}>>([]);
const { calculateDistance, geocodeAddress, reverseGeocode } = useMapTools();
const handleMapClick = useCallback((e: any) => {
const point = e.point;
console.log('点击坐标:', point.lng, point.lat);
// 添加标记
const newMarker = {
position: { lng: point.lng, lat: point.lat },
title: `标记${markers.length + 1}`,
icon: 'https://api.map.baidu.com/images/marker_green.png'
};
setMarkers(prev => [...prev, newMarker]);
// 反向地理编码
reverseGeocode(point.lng, point.lat).then(address => {
console.log('地址信息:', address);
});
}, [markers.length, reverseGeocode]);
const handleMapMoveEnd = useCallback((e: any) => {
const newCenter = e.target.getCenter();
const newZoom = e.target.getZoom();
setCenter({ lng: newCenter.lng, lat: newCenter.lat });
setZoom(newZoom);
console.log('地图移动结束,新中心点:', newCenter, '新缩放级别:', newZoom);
}, []);
const handleAddMarker = () => {
const newMarker = {
position: center,
title: `自定义标记${markers.length + 1}`,
icon: 'https://api.map.baidu.com/images/marker_red.png'
};
setMarkers(prev => [...prev, newMarker]);
};
const handleClearMarkers = () => {
setMarkers([]);
};
const handleGeocode = async () => {
const address = prompt('请输入地址:');
if (address) {
try {
const result = await geocodeAddress(address);
if (result) {
setCenter(result);
alert(`找到位置:${result.lng}, ${result.lat}`);
}
} catch (error) {
alert('地址解析失败');
}
}
};
const handleCalculateDistance = () => {
if (markers.length >= 2) {
const distance = calculateDistance(
markers[0].position,
markers[1].position
);
alert(`两点距离:${distance.toFixed(2)} 公里`);
} else {
alert('请先添加至少两个标记点');
}
};
return (
<div className="advanced-bmap-container">
<div className="toolbar">
{showControls && (
<>
<button onClick={handleAddMarker} className="toolbar-btn">
添加标记
</button>
<button onClick={handleClearMarkers} className="toolbar-btn">
清除标记
</button>
<button onClick={handleGeocode} className="toolbar-btn">
地址解析
</button>
<button onClick={handleCalculateDistance} className="toolbar-btn">
计算距离
</button>
</>
)}
{showTools && (
<div className="coordinate-display">
<span>中心点: {center.lng.toFixed(6)}, {center.lat.toFixed(6)}</span>
<span>缩放级别: {zoom}</span>
<span>标记数量: {markers.length}</span>
</div>
)}
</div>
<BMapComponent
ak={ak}
containerId={containerId}
center={center}
zoom={zoom}
onMapClick={handleMapClick}
onMapMoveEnd={handleMapMoveEnd}
markers={markers}
/>
<div className="info-panel">
<h3>地图信息</h3>
<div className="info-content">
<p><strong>当前中心点:</strong>{center.lng.toFixed(6)}, {center.lat.toFixed(6)}</p>
<p><strong>当前缩放级别:</strong>{zoom}</p>
<p><strong>标记点列表:</strong></p>
<ul className="marker-list">
{markers.map((marker, index) => (
<li key={index}>
标记{index + 1}: {marker.position.lng.toFixed(6)}, {marker.position.lat.toFixed(6)}
<button
onClick={() => {
const newMarkers = [...markers];
newMarkers.splice(index, 1);
setMarkers(newMarkers);
}}
className="remove-btn"
>
删除
</button>
</li>
))}
</ul>
</div>
</div>
</div>
);
};
// 样式
const advancedStyles = `
.advanced-bmap-container {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 16px;
padding: 12px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
flex-wrap: wrap;
align-items: center;
}
.toolbar-btn {
padding: 8px 16px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.toolbar-btn:hover {
background: #40a9ff;
}
.toolbar-btn:active {
background: #096dd9;
}
.coordinate-display {
display: flex;
gap: 16px;
margin-left: auto;
font-size: 14px;
color: #666;
}
.coordinate-display span {
padding: 4px 8px;
background: white;
border-radius: 4px;
border: 1px solid #e8e8e8;
}
.info-panel {
margin-top: 20px;
padding: 16px;
background: #fafafa;
border-radius: 8px;
border: 1px solid #f0f0f0;
}
.info-panel h3 {
margin-top: 0;
margin-bottom: 12px;
color: #1890ff;
}
.info-content {
font-size: 14px;
}
.info-content p {
margin: 8px 0;
}
.marker-list {
list-style: none;
padding: 0;
margin: 8px 0;
max-height: 200px;
overflow-y: auto;
}
.marker-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
margin: 4px 0;
background: white;
border-radius: 4px;
border: 1px solid #e8e8e8;
}
.remove-btn {
padding: 2px 8px;
background: #ff4d4f;
color: white;
border: none;
border-radius: 2px;
cursor: pointer;
font-size: 12px;
}
.remove-btn:hover {
background: #ff7875;
}
@media (max-width: 768px) {
.toolbar {
flex-direction: column;
align-items: stretch;
}
.coordinate-display {
margin-left: 0;
margin-top: 8px;
justify-content: space-between;
}
}
`;
// 注入样式
if (typeof document !== 'undefined') {
const styleElement = document.createElement('style');
styleElement.textContent = advancedStyles;
document.head.appendChild(styleElement);
}
export default AdvancedBMap;
4.3 自定义地图控件
tsx
import React, { useEffect, useRef } from 'react';
interface CustomControlProps {
map: BMap.Map | null;
position?: BMap.ControlPosition;
children: React.ReactNode;
className?: string;
}
/**
* 自定义地图控件组件
*/
const CustomControl: React.FC<CustomControlProps> = ({
map,
position = BMap.ControlPosition.TOP_RIGHT,
children,
className = '',
}) => {
const controlRef = useRef<HTMLDivElement>(null);
const controlInstanceRef = useRef<any>(null);
useEffect(() => {
if (!map || !controlRef.current) return;
// 创建自定义控件
const CustomControlClass = function(this: any) {
this.defaultAnchor = position;
this.defaultOffset = new BMap.Size(10, 10);
};
CustomControlClass.prototype = new BMap.Control();
CustomControlClass.prototype.initialize = function(map: BMap.Map) {
const div = controlRef.current;
if (div) {
map.getContainer().appendChild(div);
return div;
}
return null;
};
// 创建控件实例
const control = new (CustomControlClass as any)();
map.addControl(control);
controlInstanceRef.current = control;
return () => {
if (map && controlInstanceRef.current) {
map.removeControl(controlInstanceRef.current);
}
};
}, [map, position]);
return (
<div
ref={controlRef}
className={`custom-control ${className}`}
style={{ display: map ? 'block' : 'none' }}
>
{children}
</div>
);
};
// 缩放控件
export const ZoomControl: React.FC<{ map: BMap.Map | null }> = ({ map }) => {
const handleZoomIn = () => {
if (map) {
map.setZoom(map.getZoom() + 1);
}
};
const handleZoomOut = () => {
if (map) {
map.setZoom(map.getZoom() - 1);
}
};
return (
<CustomControl map={map} position={BMap.ControlPosition.TOP_LEFT}>
<div className="zoom-control">
<button onClick={handleZoomIn} className="zoom-btn">
+
</button>
<button onClick={handleZoomOut} className="zoom-btn">
-
</button>
</div>
</CustomControl>
);
};
// 定位控件
export const LocationControl: React.FC<{
map: BMap.Map | null;
onLocate?: (position: { lng: number; lat: number }) => void;
}> = ({ map, onLocate }) => {
const handleLocate = () => {
if (!map || !navigator.geolocation) return;
navigator.geolocation.getCurrentPosition(
(position) => {
const { longitude, latitude } = position.coords;
const point = new BMap.Point(longitude, latitude);
map.centerAndZoom(point, 15);
// 添加定位标记
const marker = new BMap.Marker(point);
map.addOverlay(marker);
if (onLocate) {
onLocate({ lng: longitude, lat: latitude });
}
},
(error) => {
console.error('定位失败:', error);
alert('定位失败,请检查浏览器权限设置');
},
{ enableHighAccuracy: true, timeout: 5000, maximumAge: 0 }
);
};
return (
<CustomControl map={map} position={BMap.ControlPosition.TOP_RIGHT}>
<button onClick={handleLocate} className="location-btn">
📍
</button>
</CustomControl>
);
};
// 图层切换控件
export const LayerControl: React.FC<{
map: BMap.Map | null;
BMap: typeof window.BMap | null;
}> = ({ map, BMap }) => {
const [currentLayer, setCurrentLayer] = React.useState<'normal' | 'satellite' | 'hybrid'>('normal');
const switchLayer = (layerType: 'normal' | 'satellite' | 'hybrid') => {
if (!map || !BMap) return;
const layerMap = {
normal: BMap.MapTypeId.NORMAL_MAP,
satellite: BMap.MapTypeId.SATELLITE_MAP,
hybrid: BMap.MapTypeId.HYBRID_MAP,
};
map.setMapType(layerMap[layerType]);
setCurrentLayer(layerType);
};
return (
<CustomControl map={map} position={BMap.ControlPosition.TOP_RIGHT}>
<div className="layer-control">
<button
onClick={() => switchLayer('normal')}
className={`layer-btn ${currentLayer === 'normal' ? 'active' : ''}`}
>
地图
</button>
<button
onClick={() => switchLayer('satellite')}
className={`layer-btn ${currentLayer === 'satellite' ? 'active' : ''}`}
>
卫星
</button>
<button
onClick={() => switchLayer('hybrid')}
className={`layer-btn ${currentLayer === 'hybrid' ? 'active' : ''}`}
>
混合
</button>
</div>
</CustomControl>
);
};
// 控件样式
const controlStyles = `
.custom-control {
background: white;
border-radius: 4px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
padding: 8px;
}
.zoom-control {
display: flex;
flex-direction: column;
gap: 4px;
}
.zoom-btn {
width: 32px;
height: 32px;
background: white;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 18px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.zoom-btn:hover {
background: #f5f5f5;
border-color: #1890ff;
}
.zoom-btn:active {
background: #e6f7ff;
}
.location-btn {
width: 40px;
height: 40px;
background: white;
border: 1px solid #d9d9d9;
border-radius: 50%;
font-size: 20px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.location-btn:hover {
background: #f5f5f5;
border-color: #1890ff;
transform: scale(1.1);
}
.layer-control {
display: flex;
gap: 4px;
}
.layer-btn {
padding: 6px 12px;
background: white;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: all 0.3s;
}
.layer-btn:hover {
background: #f5f5f5;
border-color: #1890ff;
}
.layer-btn.active {
background: #1890ff;
color: white;
border-color: #1890ff;
}
`;
// 注入样式
if (typeof document !== 'undefined') {
const styleElement = document.createElement('style');
styleElement.textContent = controlStyles;
document.head.appendChild(styleElement);
}
4.4 地图工具函数
typescript
/**
* 地图工具函数集合
* 提供常用的地图计算、坐标转换、地理编码等工具函数
*/
/**
* 计算两点之间的距离(单位:公里)
* 使用Haversine公式计算球面距离
*/
export const calculateDistance = (
point1: { lng: number; lat: number },
point2: { lng: number; lat: number }
): number => {
const rad = (d: number) => d * Math.PI / 180.0;
const lat1 = point1.lat;
const lng1 = point1.lng;
const lat2 = point2.lat;
const lng2 = point2.lng;
const radLat1 = rad(lat1);
const radLat2 = rad(lat2);
const a = radLat1 - radLat2;
const b = rad(lng1) - rad(lng2);
let s = 2 * Math.asin(Math.sqrt(
Math.pow(Math.sin(a / 2), 2) +
Math.cos(radLat1) * Math.cos(radLat2) * Math.pow(Math.sin(b / 2), 2)
));
s = s * 6378.137; // 地球半径(公里)
s = Math.round(s * 10000) / 10000;
return s;
};
/**
* 地理编码:地址转坐标
* @param address 详细地址
* @param city 城市名(可选)
* @returns 坐标点 { lng, lat }
*/
export const geocodeAddress = async (
address: string,
city?: string
): Promise<{ lng: number; lat: number } | null> => {
return new Promise((resolve, reject) => {
if (!window.BMap) {
reject(new Error('百度地图SDK未加载'));
return;
}
const myGeo = new BMap.Geocoder();
myGeo.getPoint(
address,
(point: BMap.Point | null) => {
if (point) {
resolve({ lng: point.lng, lat: point.lat });
} else {
resolve(null);
}
},
city
);
});
};
/**
* 反向地理编码:坐标转地址
* @param lng 经度
* @param lat 纬度
* @returns 地址信息
*/
export const reverseGeocode = async (
lng: number,
lat: number
): Promise<{
address: string;
province: string;
city: string;
district: string;
street: string;
streetNumber: string;
} | null> => {
return new Promise((resolve, reject) => {
if (!window.BMap) {
reject(new Error('百度地图SDK未加载'));
return;
}
const point = new BMap.Point(lng, lat);
const geocoder = new BMap.Geocoder();
geocoder.getLocation(point, (result: any) => {
if (result) {
resolve({
address: result.address,
province: result.addressComponents.province,
city: result.addressComponents.city,
district: result.addressComponents.district,
street: result.addressComponents.street,
streetNumber: result.addressComponents.streetNumber,
});
} else {
resolve(null);
}
});
});
};
/**
* 坐标转换:百度坐标(BD09)与其他坐标系互转
*/
export const coordinateConverter = {
/**
* 百度坐标(BD09)转火星坐标(GCJ02)
*/
bd09ToGcj02: (lng: number, lat: number): { lng: number; lat: number } => {
const x = lng - 0.0065;
const y = lat - 0.006;
const z = Math.sqrt(x * x + y * y) - 0.00002 * Math.sin(y * Math.PI);
const theta = Math.atan2(y, x) - 0.000003 * Math.cos(x * Math.PI);
return {
lng: z * Math.cos(theta),
lat: z * Math.sin(theta),
};
},
/**
* 火星坐标(GCJ02)转百度坐标(BD09)
*/
gcj02ToBd09: (lng: number, lat: number): { lng: number; lat: number } => {
const x = lng;
const y = lat;
const z = Math.sqrt(x * x + y * y) + 0.00002 * Math.sin(y * Math.PI);
const theta = Math.atan2(y, x) + 0.000003 * Math.cos(x * Math.PI);
return {
lng: z * Math.cos(theta) + 0.0065,
lat: z * Math.sin(theta) + 0.006,
};
},
/**
* 百度坐标(BD09)转WGS84坐标
*/
bd09ToWgs84: (lng: number, lat: number): { lng: number; lat: number } => {
// 先转GCJ02,再转WGS84
const gcj02 = coordinateConverter.bd09ToGcj02(lng, lat);
return coordinateConverter.gcj02ToWgs84(gcj02.lng, gcj02.lat);
},
/**
* 火星坐标(GCJ02)转WGS84坐标
*/
gcj02ToWgs84: (lng: number, lat: number): { lng: number; lat: number } => {
const a = 6378245.0;
const ee = 0.00669342162296594323;
const dLat = coordinateConverter.transformLat(lng - 105.0, lat - 35.0);
const dLng = coordinateConverter.transformLng(lng - 105.0, lat - 35.0);
const radLat = lat / 180.0 * Math.PI;
let magic = Math.sin(radLat);
magic = 1 - ee * magic * magic;
const sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * Math.PI);
dLng = (dLng * 180.0) / (a / sqrtMagic * Math.cos(radLat) * Math.PI);
return {
lng: lng - dLng,
lat: lat - dLat,
};
},
// 内部转换函数
transformLat: (lng: number, lat: number): number => {
let ret = -100.0 + 2.0 * lng + 3.0 * lat + 0.2 * lat * lat + 0.1 * lng * lat + 0.2 * Math.sqrt(Math.abs(lng));
ret += (20.0 * Math.sin(6.0 * lng * Math.PI) + 20.0 * Math.sin(2.0 * lng * Math.PI)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(lat * Math.PI) + 40.0 * Math.sin(lat / 3.0 * Math.PI)) * 2.0 / 3.0;
ret += (160.0 * Math.sin(lat / 12.0 * Math.PI) + 320 * Math.sin(lat * Math.PI / 30.0)) * 2.0 / 3.0;
return ret;
},
transformLng: (lng: number, lat: number): number => {
let ret = 300.0 + lng + 2.0 * lat + 0.1 * lng * lng + 0.1 * lng * lat + 0.1 * Math.sqrt(Math.abs(lng));
ret += (20.0 * Math.sin(6.0 * lng * Math.PI) + 20.0 * Math.sin(2.0 * lng * Math.PI)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(lng * Math.PI) + 40.0 * Math.sin(lng / 3.0 * Math.PI)) * 2.0 / 3.0;
ret += (150.0 * Math.sin(lng / 12.0 * Math.PI) + 300.0 * Math.sin(lng / 30.0 * Math.PI)) * 2.0 / 3.0;
return ret;
},
};
/**
* 计算多边形面积(单位:平方米)
* 使用鞋带公式(Shoelace formula)
*/
export const calculatePolygonArea = (points: Array<{ lng: number; lat: number }>): number => {
if (points.length < 3) return 0;
let area = 0;
const n = points.length;
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
area += points[i].lng * points[j].lat;
area -= points[j].lng * points[i].lat;
}
area = Math.abs(area) / 2;
// 将经纬度坐标转换为平面坐标的近似面积
// 这里使用简化计算,实际应用中可能需要更精确的球面面积计算
const earthRadius = 6371000; // 地球半径(米)
const latRad = (points[0].lat * Math.PI) / 180;
const scaleFactor = Math.cos(latRad);
return area * earthRadius * earthRadius * scaleFactor * Math.PI / 180 * Math.PI / 180;
};
/**
* 判断点是否在多边形内
* 使用射线法(Ray Casting Algorithm)
*/
export const isPointInPolygon = (
point: { lng: number; lat: number },
polygon: Array<{ lng: number; lat: number }>
): boolean => {
if (polygon.length < 3) return false;
let inside = false;
const x = point.lng;
const y = point.lat;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].lng;
const yi = polygon[i].lat;
const xj = polygon[j].lng;
const yj = polygon[j].lat;
const intersect = ((yi > y) !== (yj > y)) &&
(x < (xj - xi) * (y - yi) / (yj - yi) + xi);
if (intersect) inside = !inside;
}
return inside;
};
/**
* 计算路径长度(单位:米)
*/
export const calculatePathLength = (path: Array<{ lng: number; lat: number }>): number => {
if (path.length < 2) return 0;
let totalDistance = 0;
for (let i = 0; i < path.length - 1; i++) {
const point1 = path[i];
const point2 = path[i + 1];
const distance = calculateDistance(point1, point2) * 1000; // 公里转米
totalDistance += distance;
}
return totalDistance;
};
/**
* 获取两点之间的中间点
*/
export const getMidpoint = (
point1: { lng: number; lat: number },
point2: { lng: number; lat: number }
): { lng: number; lat: number } => {
return {
lng: (point1.lng + point2.lng) / 2,
lat: (point1.lat + point2.lat) / 2,
};
};
/**
* 格式化坐标显示
*/
export const formatCoordinate = (
lng: number,
lat: number,
format: 'decimal' | 'dms' = 'decimal'
): string => {
if (format === 'decimal') {
return `${lng.toFixed(6)}, ${lat.toFixed(6)}`;
}
// 度分秒格式
const lngDeg = Math.floor(Math.abs(lng));
const lngMin = Math.floor((Math.abs(lng) - lngDeg) * 60);
const lngSec = ((Math.abs(lng) - lngDeg - lngMin / 60) * 3600).toFixed(2);
const lngDir = lng >= 0 ? 'E' : 'W';
const latDeg = Math.floor(Math.abs(lat));
const latMin = Math.floor((Math.abs(lat) - latDeg) * 60);
const latSec = ((Math.abs(lat) - latDeg - latMin / 60) * 3600).toFixed(2);
const latDir = lat >= 0 ? 'N' : 'S';
return `${lngDeg}°${lngMin}'${lngSec}"${lngDir}, ${latDeg}°${latMin}'${latSec}"${latDir}`;
};
/**
* 地图工具函数Hook封装
*/
export const useMapTools = () => {
return {
calculateDistance,
geocodeAddress,
reverseGeocode,
coordinateConverter,
calculatePolygonArea,
isPointInPolygon,
calculatePathLength,
getMidpoint,
formatCoordinate,
};
};
/**
* 使用示例:
*
* ```typescript
* import { useMapTools } from './utils/mapTools';
*
* const MapComponent = () => {
* const tools = useMapTools();
*
* // 计算两点距离
* const distance = tools.calculateDistance(
* { lng: 116.404, lat: 39.915 },
* { lng: 116.414, lat: 39.925 }
* );
* console.log(`距离:${distance}公里`);
*
* // 地址解析
* const getCoordinate = async () => {
* const point = await tools.geocodeAddress('北京市天安门');
* if (point) {
* console.log('坐标:', point);
* }
* };
*
* // 坐标转换
* const bdPoint = { lng: 116.404, lat: 39.915 };
* const gcjPoint = tools.coordinateConverter.bd09ToGcj02(bdPoint.lng, bdPoint.lat);
* console.log('转换后坐标:', gcjPoint);
*
* return <div>地图组件</div>;
* };
* ```
*/
转载自:https://blog.csdn.net/u014727709/article/details/163345035
欢迎 👍点赞✍评论⭐收藏,欢迎指正