Vue 3 中集成天地图 JavaScript API 的完整实践指南

本文详细介绍如何在 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";

/**
* 动态加载天地图 JavaScript SDK
* SDK 加载完成后 window.T 全局对象可用
*/
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([]); // [[lng, lat], [lng, lat], ...]

// 地图实例 + 覆盖物引用
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));

// 至少3个点才绘制多边形
if (points.length >= 3) {
polygon = new T.Polygon(points, {
color: "#409eff", // 边框颜色
weight: 3, // 边框宽度(px)
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
/**
* 生成带文字的圆形标记 SVG Data URI
* @param {string} fillColor 填充色
* @param {string} strokeColor 描边色
* @param {string} text 文字
*/
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;

// onUnmounted / 弹窗关闭时清理
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)事件监听clickmouseovermouseout
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;
}
// 手动清空容器 DOM(天地图可能残留节点)
const container = document.getElementById("mapContainer");
if (container) container.innerHTML = "";
};

6.3 经纬度顺序陷阱

问题:天地图 API 中 LngLat 的构造函数参数顺序是 (经度, 纬度),即 (lng, lat),而非更常见的 (lat, lng)。如果搞反会导致标记点飞到错误位置。

正确写法

1
new T.LngLat(lng, lat); // ✅ 经度在前,纬度在后

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 [];

// 格式1: [[lng, lat], [lng, lat]]
if (Array.isArray(data[0])) {
return data.map(([lng, lat]) => ({ lng, lat }));
}

// 格式2: [{lng, lat}] 或 [{longitude, latitude}]
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
// composables/useTdtMap.js
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 项目中落地天地图,核心在于以下几点:

  1. 按需加载:通过动态注入 <script> 标签实现 SDK 按需加载,避免首屏性能损耗
  2. 生命周期对齐:弹窗 @opened 初始化、@closed 销毁,避免地图渲染异常
  3. 数据驱动视图:以状态数组为单一数据源,通过重绘函数统一渲染覆盖物,撤销/清空操作简洁可靠
  4. 适度抽象:当使用场景超过 2 个时,建议抽取 Composable 统一管理地图生命周期和操作

本文覆盖了选点定位、多边形绘制/编辑、路径轨迹展示三个典型场景,掌握了这些模式后,扩展到热力图、聚合点等高级功能也会更加得心应手。

本文基于天地图 JavaScript API v4.0 编写。