本文详细介绍如何在 Vue 3 + Element Plus 项目中集成天地图 JavaScript API v4.0,涵盖 SDK 加载、地图选点定位、多边形绘制/编辑、轨迹展示等常见场景的完整实现方案。
一、为什么选择天地图
天地图(TianDiTu)是国家地理信息公共服务平台,提供标准的 JavaScript API,相较于国外地图服务具有以下优势:
- 国内访问稳定,加载速度快
- 提供卫星影像、混合地图等多种图层
- API 覆盖标记、折线、多边形、信息窗、比例尺等常用功能
- 完全免费,无需注册信用卡
二、SDK 动态加载方案
天地图 JS API 通过 CDN 加载,建议在组件内按需动态引入,避免首屏加载不必要的资源。
2.1 加载函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| const TIANDITU_TK = "your_api_key_here";
const loadTdtScript = () => { return new Promise((resolve, reject) => { if (window.T) return resolve();
const script = document.createElement("script"); script.src = `https://api.tianditu.gov.cn/api?v=4.0&tk=${TIANDITU_TK}`; script.onload = () => resolve(); script.onerror = () => reject(new Error("天地图加载失败")); document.head.appendChild(script); }); };
|
2.2 关键设计点
| 设计 | 说明 |
|---|
| Promise 化 | 将异步加载封装为 Promise,便于 async/await 调用 |
| 幂等性 | 加载前检查 window.T,多次调用不会重复创建 <script> 标签 |
| 按需加载 | 仅在打开地图弹窗时触发,首页不加载地图 SDK |
| 错误处理 | onerror 回调 reject,调用方可以捕获加载失败并给出友好提示 |
三、核心场景实现
3.1 场景一:地图选点定位
在地图弹窗中点击任意位置,自动获取经纬度坐标。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| const initMap = async () => { await loadTdtScript(); const T = window.T;
const map = new T.Map("mapContainer"); map.centerAndZoom(new T.LngLat(116.40769, 39.89945), 12); map.setMapType(TMAP_SATELLITE_MAP);
map.addEventListener("click", e => { const { lng, lat } = e.lnglat; setMarker(map, lng, lat); }); };
let marker = null; const setMarker = (map, lng, lat) => { const T = window.T; if (marker) map.removeOverLay(marker);
const icon = new T.Icon({ iconUrl: markerIconUrl, iconSize: new T.Point(32, 32), iconAnchor: new T.Point(16, 32), });
marker = new T.Marker(new T.LngLat(lng, lat), { icon }); map.addOverLay(marker); };
|
关键点:
- 使用
TMAP_SATELLITE_MAP 卫星图层,方便辨认实际地物 - 标记的
iconAnchor 设为图标底部中心,定位更准确 - 每次点击先移除旧标记再创建新标记,保证地图上只有一个标记点
3.2 场景二:多边形区域绘制
用户在地图上依次点击顶点,绘制多边形区域,支持撤销和清空操作。
3.2.1 数据结构
1 2 3 4 5 6 7
| const mapPoints = ref([]);
let map = null; let polygon = null; let pointMarkers = [];
|
3.2.2 点击绘制顶点
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| map.addEventListener("click", e => { const { lng, lat } = e.lnglat; mapPoints.value.push([lng, lat]); redrawPolygon(map); });
const redrawPolygon = map => { const T = window.T;
if (polygon) map.removeOverLay(polygon); pointMarkers.forEach(m => map.removeOverLay(m)); pointMarkers = [];
const points = mapPoints.value.map(([lng, lat]) => new T.LngLat(lng, lat));
pointMarkers = points.map((p, i) => { return new T.Marker(p, { title: `顶点${i + 1}`, icon: new T.Icon({ iconUrl: smallCircleIcon, iconSize: new T.Point(12, 12), iconAnchor: new T.Point(6, 6), }), }); }); pointMarkers.forEach(m => map.addOverLay(m));
if (points.length >= 3) { polygon = new T.Polygon(points, { color: "#409eff", weight: 3, opacity: 0.8, fillColor: "#79bbff", fillOpacity: 0.3, }); map.addOverLay(polygon); } };
|
3.2.3 撤销与清空
1 2 3 4 5 6 7 8 9 10 11
| const undoLastPoint = () => { mapPoints.value.pop(); redrawPolygon(map); };
const clearPolygon = () => { mapPoints.value = []; redrawPolygon(map); };
|
3.2.4 自动计算中心点
1 2 3 4 5 6 7 8 9 10 11 12
|
const calcCenter = points => { const len = points.length; if (len === 0) return [0, 0];
const sumLng = points.reduce((s, [lng]) => s + lng, 0); const sumLat = points.reduce((s, [, lat]) => s + lat, 0);
return [sumLng / len, sumLat / len]; };
|
3.2.5 已有多边形回显展示
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| const initDetailMap = async boundaryData => { await loadTdtScript(); const T = window.T;
const map = new T.Map("detailMap"); const points = parseBoundary(boundaryData);
const polygon = new T.Polygon( points.map(p => new T.LngLat(p.lng, p.lat)), { color: "#409eff", weight: 3, opacity: 0.8, fillColor: "#79bbff", fillOpacity: 0.3 }, ); map.addOverLay(polygon);
map.setViewport(polygon.getBounds());
const [centerLng, centerLat] = calcCenter(boundaryData); const centerMarker = new T.Marker(new T.LngLat(centerLng, centerLat), { icon: new T.Icon({ iconUrl: centerPinIcon, iconSize: new T.Point(28, 28), iconAnchor: new T.Point(14, 28) }) }); map.addOverLay(centerMarker);
const infoWin = new T.InfoWindow(""); centerMarker.addEventListener("mouseover", () => { infoWin.setContent(`<div>经度: ${centerLng.toFixed(6)}</div><div>纬度: ${centerLat.toFixed(6)}</div>`); map.openInfoWindow(infoWin, centerMarker.getLngLat()); }); centerMarker.addEventListener("mouseout", () => map.closeInfoWindow()); };
|
关键点:
setViewport 自动计算最优缩放级别和中心点,使多边形完整显示- 中心点使用自定义 SVG 图标,视觉上更突出
- InfoWindow 用于悬浮展示详细信息,交互体验好
- 数据驱动视图:先维护状态数组,再统一调用
redrawPolygon() 重绘,撤销/清空操作简洁可靠
3.3 场景三:路径轨迹展示
展示从起点到终点的完整路径,使用折线 + 起终点标记。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| const initTrackMap = async trajectory => { await loadTdtScript(); const T = window.T;
const map = new T.Map("trackMap");
const points = trajectory.map(p => new T.LngLat(p.lng, p.lat)); const polyline = new T.Polyline(points, { color: "#26B165", weight: 4, opacity: 0.8, }); map.addOverLay(polyline);
const startIcon = new T.Icon({ iconUrl: generateMarkerSvg("#FFFFFF", "#26B165", "起"), iconSize: new T.Point(32, 32), iconAnchor: new T.Point(16, 32), }); map.addOverLay(new T.Marker(points[0], { icon: startIcon }));
const endIcon = new T.Icon({ iconUrl: generateMarkerSvg("#FFFFFF", "#FF4C4C", "终"), iconSize: new T.Point(32, 32), iconAnchor: new T.Point(16, 32), }); map.addOverLay(new T.Marker(points[points.length - 1], { icon: endIcon }));
map.setViewport(polyline.getBounds());
map.addControl(new T.Control.Scale()); };
|
起/终点 SVG 图标生成函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
const generateMarkerSvg = (fillColor, strokeColor, text) => { const svg = ` <svg xmlns="http://www.w3.org/2000/svg" width="32" height="40" viewBox="0 0 32 40"> <circle cx="16" cy="14" r="12" fill="${fillColor}" stroke="${strokeColor}" stroke-width="2.5"/> <text x="16" y="19" text-anchor="middle" fill="${strokeColor}" font-size="14" font-weight="bold">${text}</text> <polygon points="10,26 16,38 22,26" fill="${strokeColor}"/> </svg>`; return "data:image/svg+xml," + encodeURIComponent(svg); };
|
关键点:
- 使用内联 SVG Data URI 生成自定义标记图标,无需额外静态资源
setViewport 配合 getBounds() 自动适配,确保整条轨迹完整可见- 比例尺控件帮助用户直观感知距离
四、地图实例生命周期管理
每个使用地图的组件都应维护独立的实例引用,确保组件销毁时正确清理:
1 2 3 4 5 6 7 8 9 10 11 12
| let mapInstance = null; let markerInstance = null;
const destroyMap = () => { if (mapInstance) { mapInstance.clearOverLays(); mapInstance = null; } markerInstance = null; };
|
生命周期对照表:
| 事件 | 操作 |
|---|
弹窗 @opened | 调用 initMap() 创建地图 |
弹窗 @closed | 调用 destroyMap() 销毁实例 |
组件 onUnmounted | 清理可能残留的地图实例 |
五、API 能力速查
| API | 用途 | 关键参数 |
|---|
T.Map(containerId) | 创建地图实例 | DOM 容器 id |
map.centerAndZoom(center, zoom) | 设置中心点和缩放 | T.LngLat, 缩放级别 1-18 |
map.setMapType(type) | 设置图层 | TMAP_NORMAL_MAP / TMAP_SATELLITE_MAP / TMAP_HYBRID_MAP |
T.Marker(lngLat, opts) | 添加标记点 | 坐标、T.Icon 图标配置 |
T.Polyline(points, style) | 绘制折线 | 坐标数组、颜色、线宽 |
T.Polygon(points, style) | 绘制多边形 | 坐标数组、边框/填充样式 |
T.InfoWindow(content) | 信息窗 | HTML 内容字符串 |
map.setViewport(bounds) | 自适应视野 | polyline.getBounds() / polygon.getBounds() |
T.Control.Scale() | 比例尺控件 | 添加到 map.addControl() |
map.addEventListener(event, fn) | 事件监听 | click、mouseover、mouseout 等 |
map.clearOverLays() | 清除所有覆盖物 | 无 |
六、踩坑记录与最佳实践
6.1 弹窗中地图初始化时机
问题:在 el-dialog 中使用天地图,如果在 onMounted 中初始化,此时弹窗 DOM 可能还未渲染,地图容器 div 的实际宽高为 0,导致地图无法正常显示。
解决:监听弹窗的 @opened 事件,此时 DOM 已完全渲染,再初始化地图:
1 2 3
| <el-dialog @opened="initMap" @closed="destroyMap"> <div id="mapContainer" style="width:100%;height:480px"></div> </el-dialog>
|
6.2 多次打开弹窗地图未重绘
问题:关闭弹窗后再次打开,地图容器中残留上次的 DOM 节点,两次初始化互相干扰。
解决:关闭弹窗时彻底销毁地图实例和 DOM:
1 2 3 4 5 6 7 8 9
| const destroyMap = () => { if (map) { map.clearOverLays(); map = null; } const container = document.getElementById("mapContainer"); if (container) container.innerHTML = ""; };
|
6.3 经纬度顺序陷阱
问题:天地图 API 中 LngLat 的构造函数参数顺序是 (经度, 纬度),即 (lng, lat),而非更常见的 (lat, lng)。如果搞反会导致标记点飞到错误位置。
正确写法:
6.4 多边形数据格式兼容
问题:后端返回的多边形数据格式可能不统一,有的是 [[lng, lat]] 二维数组,有的是 [{lng, lat}] 对象数组。
解决:编写兼容解析函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| const parseBoundary = data => { if (!data || !data.length) return [];
if (Array.isArray(data[0])) { return data.map(([lng, lat]) => ({ lng, lat })); }
return data.map(p => ({ lng: p.lng ?? p.longitude, lat: p.lat ?? p.latitude, })); };
|
6.5 API Key 安全防护
注意:天地图 API Key 不应直接硬编码在前端代码中。建议方案:
- 将 Key 存储在
.env 环境变量中:VITE_TIANDITU_TK=your_key - 使用
import.meta.env.VITE_TIANDITU_TK 读取 - 生产环境可通过后端接口动态获取,进一步降低泄漏风险
七、进阶封装建议
当项目中有多个页面使用天地图时,建议将通用逻辑抽取为 Composable:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74
| export function useTdtMap(containerId, options = {}) { const map = ref(null); const markers = ref([]); const polyline = ref(null); const polygon = ref(null);
const init = async () => { await loadTdtScript(); const T = window.T; map.value = new T.Map(containerId); map.value.centerAndZoom(new T.LngLat(options.center?.[0] ?? 116.4, options.center?.[1] ?? 39.9), options.zoom ?? 12); if (options.mapType) map.value.setMapType(options.mapType); };
const addMarker = (lng, lat, iconUrl) => { const T = window.T; const m = new T.Marker(new T.LngLat(lng, lat), { icon: iconUrl ? new T.Icon({ iconUrl, iconSize: new T.Point(32, 32), iconAnchor: new T.Point(16, 32) }) : undefined, }); map.value.addOverLay(m); markers.value.push(m); return m; };
const drawPolyline = (points, style = {}) => { const T = window.T; polyline.value = new T.Polyline( points.map(p => new T.LngLat(p[0], p[1])), { color: "#26B165", weight: 4, opacity: 0.8, ...style }, ); map.value.addOverLay(polyline.value); map.value.setViewport(polyline.value.getBounds()); };
const drawPolygon = (points, style = {}) => { const T = window.T; polygon.value = new T.Polygon( points.map(p => new T.LngLat(p[0], p[1])), { color: "#409eff", weight: 3, opacity: 0.8, fillColor: "#79bbff", fillOpacity: 0.3, ...style }, ); map.value.addOverLay(polygon.value); map.value.setViewport(polygon.value.getBounds()); };
const clearAll = () => { if (map.value) map.value.clearOverLays(); markers.value = []; polyline.value = null; polygon.value = null; };
const destroy = () => { clearAll(); map.value = null; const el = document.getElementById(containerId); if (el) el.innerHTML = ""; };
onUnmounted(destroy);
return { map, markers, polyline, polygon, init, addMarker, drawPolyline, drawPolygon, clearAll, destroy, }; }
|
使用示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <template> <el-dialog v-model="visible" @opened="map.init" @closed="map.destroy"> <div id="myMap" style="height:500px"></div> </el-dialog> </template>
<script setup> import { useTdtMap } from "@/composables/useTdtMap";
const map = useTdtMap("myMap", { center: [116.4, 39.9], zoom: 12, mapType: TMAP_SATELLITE_MAP, }); </script>
|
八、总结
在 Vue 3 项目中落地天地图,核心在于以下几点:
- 按需加载:通过动态注入
<script> 标签实现 SDK 按需加载,避免首屏性能损耗 - 生命周期对齐:弹窗
@opened 初始化、@closed 销毁,避免地图渲染异常 - 数据驱动视图:以状态数组为单一数据源,通过重绘函数统一渲染覆盖物,撤销/清空操作简洁可靠
- 适度抽象:当使用场景超过 2 个时,建议抽取 Composable 统一管理地图生命周期和操作
本文覆盖了选点定位、多边形绘制/编辑、路径轨迹展示三个典型场景,掌握了这些模式后,扩展到热力图、聚合点等高级功能也会更加得心应手。
本文基于天地图 JavaScript API v4.0 编写。