做地图类小程序时,测距功能几乎是绕不开的需求。无论是查看两个门店之间的直线距离,还是绘制路线时显示总长度,核心都离不开两个坐标点之间的距离计算。微信小程序的map组件虽然提供了丰富的地图展示能力,却没有内置测距工具,需要开发者自己实现。这篇文章会把整个实现过程拆开讲清楚:坐标距离怎么算、标记点怎么拖动、距离怎么实时刷新,以及屏幕像素和实际距离之间的比例尺换算怎么做。

一、两点间球面距离的计算原理与代码实现
首先要明确一个概念:经纬度坐标表示的是地球表面的位置,而地球近似是一个球体,所以两个经纬度点之间的距离不能简单地用平面直角坐标系的勾股定理来算。直接拿经纬度差值乘以一个固定系数,在低纬度地区误差还能接受,一旦跨纬度计算,误差会大到无法使用。
业界通用的做法是使用Haversine公式。这个公式通过球面三角函数关系,根据两点的经度和纬度差,计算出球面上大圆弧的长度。公式本身不需要死记硬背,理解它的输入输出就够了:输入是两个点的经纬度,输出是两点之间的球面距离,单位是米。下面是微信小程序中的实现代码:
function getDistance(lat1, lng1, lat2, lng2) {
const rad = function(d) {
// 角度转弧度
return d * Math.PI / 180;
};
const EARTH_RADIUS = 6378137; // 地球赤道半径,单位米
let radLat1 = rad(lat1);
let radLat2 = rad(lat2);
let radLng1 = rad(lng1);
let radLng2 = rad(lng2);
let a = radLat1 - radLat2;
let b = radLng1 - radLng2;
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 * EARTH_RADIUS;
// 保留两位小数
return Math.round(s * 100) / 100;
}
module.exports = {
getDistance: getDistance
};这段代码可以单独放在一个utils文件夹下的js文件里,比如叫distance.js,方便多处复用。计算结果单位是米,展示时可以做个简单处理:小于1000米直接显示米,超过1000米换算成公里并保留两位小数。这样做用户体验会好很多,用户看“852米”比看“0.852公里”直观得多。
二、map组件搭建与可拖动标记点实现
计算函数准备好之后,接下来搭建地图界面。微信小程序的map组件使用起来不复杂,关键的几个属性是:latitude和longitude控制地图中心点,markers放标记点数组,polyline画连接线。要实现测距,需要两个可以拖动的标记点,markers的每个元素要设置id、latitude、longitude,并且把draggable属性设为true。
页面WXML结构大致如下:
<view class="map-container">
<map id="myMap"
latitude="{{latitude}}"
longitude="{{longitude}}"
scale="14"
markers="{{markers}}"
polyline="{{polyline}}"
bindmarkertap="onMarkerTap"
bindregionchange="onRegionChange"
style="width: 100%; height: 100vh;">
</map>
<view class="distance-bar">
<text>两点距离:{{distanceText}}</text>
</view>
</view>这里有一个容易被忽视的坑:标记点拖动结束后触发的事件不是单独的bindmarkerdrag,而是要通过bindregionchange事件配合e.type === 'end'来捕获,或者使用bindmarkertap结合causedBy参数判断。实际开发中更稳妥的方式是在regionchange结束时,通过MapContext的getCenterLocation或者直接监听marker的拖拽回调重新读取坐标。小程序基础库2.4.0之后,markers支持了label和callout属性,可以顺手在标记点上显示A、B标签,让界面更清晰。
对应的JS逻辑中,初始化两组经纬度作为两个标记点,计算初始距离,然后在拖动事件里更新坐标并重新计算:
Page({
data: {
latitude: 39.908823,
longitude: 116.397470,
markers: [],
polyline: [],
distanceText: ''
},
onReady() {
this.initMarkers();
},
initMarkers() {
const pointA = { lat: 39.908823, lng: 116.397470 };
const pointB = { lat: 39.920233, lng: 116.410766 };
this.setData({
markers: [
this.buildMarker(1, pointA, 'A'),
this.buildMarker(2, pointB, 'B')
],
polyline: [{
points: [
{ latitude: pointA.lat, longitude: pointA.lng },
{ latitude: pointB.lat, longitude: pointB.lng }
],
color: '#1890FF',
width: 3,
dottedLine: false
}]
});
this.updateDistance(pointA, pointB);
},
buildMarker(id, point, label) {
return {
id: id,
latitude: point.lat,
longitude: point.lng,
draggable: true,
label: {
content: label,
color: '#ffffff',
bgColor: '#1890FF',
borderRadius: 8,
padding: 4
}
};
},
updateDistance(a, b) {
const { getDistance } = require('../../utils/distance');
const dist = getDistance(a.lat, a.lng, b.lat, b.lng);
let text = dist < 1000
? dist + ' 米'
: (dist / 1000).toFixed(2) + ' 公里';
this.setData({ distanceText: text });
}
});拖动标记点后实时刷新距离,需要在事件回调中拿到最新的marker坐标。建议用一个对象缓存当前A、B两点的坐标,任何一次拖动只更新其中一个点,然后调用updateDistance重算。这样即使用户来回拖拽多次,计算逻辑依然清晰,不会出现坐标错乱的问题。
三、比例尺换算:屏幕像素与实际距离的对应关系
有了两点距离,有些场景还需要知道比例尺信息,比如在地图底部显示一个比例尺控件,标注当前视野下1厘米代表多少米。这就涉及屏幕像素与地理距离的换算。map组件没有直接给出scale到实际距离的换算表,但可以通过MapContext的getRegion接口间接推算。
getRegion能拿到当前地图视野的西南角和东北角两个点的经纬度,再用前面写的Haversine函数算出这两个点的实际距离,就得到了视野宽度对应的地面长度。接下来需要知道地图控件的屏幕宽度,可以通过wx.createSelectorQuery获取map节点的boundingClientRect。两者一除,就得到了每像素对应的实际米数:
getScaleRatio() {
const mapCtx = wx.createMapContext('myMap', this);
mapCtx.getRegion({
success: (res) => {
// 计算视野东西方向的地面宽度
const westLng = res.southwest.longitude;
const eastLng = res.northeast.longitude;
const centerLat = (res.southwest.latitude + res.northeast.latitude) / 2;
const groundWidth = getDistance(centerLat, westLng, centerLat, eastLng);
// 获取地图控件像素宽度
wx.createSelectorQuery()
.select('#myMap')
.boundingClientRect((rect) => {
const meterPerPixel = groundWidth / rect.width;
// 生成一个整数化的比例尺长度,比如取100米
const niceValues = [50, 100, 200, 500, 1000, 2000];
let chosen = niceValues.find(v => v / meterPerPixel < 150) || 5000;
this.setData({
scaleText: chosen >= 1000 ? (chosen / 1000) + ' 公里' : chosen + ' 米',
scaleBarWidth: chosen / meterPerPixel
});
})
.exec();
}
});
}这段代码的思路是:先算出每像素代表多少米,再从一组整数化的候选值里挑一个合适的显示长度,让比例尺条在屏幕上不至于太长也不至于太短。这个函数要绑定在bindregionchange事件里,用户每次缩放或拖动地图后重新计算一次,比例尺就会跟着地图层级动态变化。
还有一个细节值得注意:纬度方向上经度对应的实际距离会随纬度变化,纬度越高同样的经度差代表的距离越短。所以计算视野宽度时要取南北纬度的中间值作为基准纬度,这样得到的比例尺在中纬度地区误差能控制在很小的范围内。如果应用场景集中在高纬度城市,这个近似已经足够精确;只有做测绘级别的应用时才需要考虑椭球模型的修正。
四、功能优化与常见问题排查
基础功能跑通之后,还有几个可以提升的点。第一是长按地图添加标记点,结合bindtap事件的e.detail可以拿到点击位置的经纬度,把固定两点测距改成任意点测距,交互会灵活很多。第二是支持多段折线测距,用一个数组保存所有途经点,循环计算相邻两段的距离再累加,就能实现类似地图App里的沿途测距功能。
常见问题方面,最频繁出现的是marker拖动后距离不更新。排查思路是先确认markers数据的id是否唯一,id重复会导致事件回调无法正确区分是哪个点被拖动。其次检查setData时是否更新了polyline数组,polyline不更新的话连线会停留在旧位置,视觉上就会显得功能失效。另外真机与开发工具的表现可能有差异,拖动事件在部分Android机型上触发频率较低,必要时可以在bindregionchange里做一次防抖处理,避免频繁setData带来的卡顿。
性能上,Haversine计算本身非常轻量,几十个点的折线测距完全不需要担心性能。真正要控制的是setData的调用频率,地图拖动过程中每一次都全量更新markers和polyline会明显掉帧,正确做法是只在拖动结束后更新一次数据,拖动过程中交给原生地图组件自己处理,这也是小程序地图开发中通用的优化原则。