这是《从零到开源》系列的第 5 篇。
前 4 篇搭好了前后端框架、数据库、认证权限,今天终于进入最"可视化"的一步:充电站地图监控 。
项目完全开源,链接在文末。
🎯 本篇目标
实现一个类似"美团充电宝地图"但更偏后台运营风格的地图页面:
- 展示所有充电站的地理位置(经纬度)
- 不同状态用不同颜色标记(空闲/占用/离线)
- 点击标记弹出详情面板
- 左侧列表与地图双向联动
- 支持暗色主题,适配夜间监控
🧱 技术栈
- React 19
- react-leaflet(v4)
- leaflet(v1.9)
- Tailwind CSS(暗色瓦片自定义)
1. 为什么选 React-Leaflet 而不是其他地图库?
我在初期对比过:
| 方案 | 优点 | 缺点 |
|---|---|---|
| Google Maps API | 数据最全,样式精美 | 付费墙、国内不稳定、React 封装弱 |
| 高德/百度地图 | 国内好用,POI 丰富 | 商用授权不清晰,暗色主题难调 |
| Mapbox GL | 自定义能力最强 | 学习曲线陡峭,需要 token |
| React-Leaflet | 开源免费,轻量,React 化好 | 部分高级功能需自己封装 |
因为本项目是开源非盈利,且 Leaflet 的瓦片可以换用 OpenStreetMap 或自己部署的暗色瓦片,所以选了 React-Leaflet。
2. 安装与基础配置
bash
npm install leaflet react-leaflet
# 样式文件需要手动引入
在 main.jsx 或组件中引入 Leaflet 的 CSS:
jsx
import "leaflet/dist/leaflet.css";
⚠️ 注意:Leaflet 默认的 marker 图标图片会找不到,需要手动配置:
jsx
import L from "leaflet";
import icon from "leaflet/dist/images/marker-icon.png";
import iconShadow from "leaflet/dist/images/marker-shadow.png";
let DefaultIcon = L.icon({
iconUrl: icon,
shadowUrl: iconShadow,
iconSize: [25, 41],
iconAnchor: [12, 41],
});
L.Marker.prototype.options.icon = DefaultIcon;
- 暗色地图瓦片(夜间模式)
免费的 OpenStreetMap 默认瓦片是亮色的,不适合大屏监控。我找到了一个稳定、免费、暗色的瓦片源:
jsx
<TileLayer
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
attribution='© <a href=" ">OSM</a > © CartoDB'
/>
效果:深灰色背景,道路和标签用淡青色,非常适合叠加霓虹色的标记。
- 充电站状态 + 自定义标记图标
我们有三类状态:
· 空闲 🟢:绿色标记 · 占用 🔴:红色标记 · 离线 ⚫:灰色标记
动态生成图标:
jsx
const getMarkerIcon = (status) => {
const color =
status === "available" ? "#22c55e" :
status === "occupied" ? "#ef4444" : "#6b7280";
return L.divIcon({
html: `<div style="background-color: ${color}; width: 12px; height: 12px; border-radius: 50%; border: 2px solid white; box-shadow: 0 0 4px ${color};"></div>`,
className: "custom-marker",
iconSize: [12, 12],
iconAnchor: [6, 6],
});
};
你也可以换成 SVG 图标或 Font Awesome,但圆点方案最轻量,且性能好。
- 地图组件核心代码(精简版)
jsx
import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
function StationMap({ stations, selectedStation, onSelectStation }) {
return (
<MapContainer center={[39.9042, 116.4074]} zoom={10} className="h-full w-full">
<TileLayer url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" />
{stations.map((station) => (
<Marker
key={station.id}
position={[station.latitude, station.longitude]}
icon={getMarkerIcon(station.status)}
eventHandlers={{
click: () => onSelectStation(station),
}}
>
<Popup>
<div className="text-sm">
<strong>{station.name}</strong><br />
空闲桩:{station.available_ports}/{station.total_ports}<br />
状态:{station.status === "available" ? "空闲" : "占用"}
</div>
</Popup>
</Marker>
))}
</MapContainer>
);
}
- 列表与地图联动(双向)
左侧用 Tailwind 做滚动列表,点击某一站点时:
- 地图 flyTo 定位到该站点
- 打开对应的 Popup
- 高亮列表项
关键代码(使用 useRef 获取地图实例):
jsx
const mapRef = useRef();
const handleSelectStation = (station) => {
setSelectedStation(station);
mapRef.current?.flyTo([station.latitude, station.longitude], 14);
// 手动打开 popup 需要配合 markerRef,简化版可以在 popup 中自动展示
};
列表项高亮用 Tailwind 的条件类即可。
- 响应式布局技巧
地图容器高度在 PC 上用 h-[calc(100vh-64px)],手机上改为 h-96。配合 Tailwind 的响应式前缀:
jsx
<div className="h-96 md:h-[calc(100vh-4rem)]">
<MapContainer ... />
</div>
- 踩坑总结(减少你掉坑)
坑点 解决方案 Marker 图标不显示 手动设置 DefaultIcon 地图在弹窗/抽屉中渲染错位 触发 map.invalidateSize() 瓦片加载慢 换用更快的 CDN,或预加载 移动端手势冲突 监听 touchstart 判断目标元素,临时禁用地图拖动(代码略长,见 GitHub)
🔗 系列链接
· 上一篇(第4篇):JWT 认证 + 角色权限体系 · 下一篇(第6篇):30 个 API 接口怎么设计?RESTful API 实战
🌐 项目地址
· GitHub:github.com/Rhi637/ev-c... · 在线演示:rhi637.github.io/ev-charging...
💬 互动
如果你也在做地图相关的前端项目,欢迎评论区交流。前 10 条评论我都会回复。 觉得有用的话,点赞 + 收藏 + 转发,支持下这个从零到开源的小系列 🙏