JavaScript 实战:用Haversine公式计算附近5公里内的商家(附完整代码)
当你在开发一个本地生活服务应用时,如何快速找到用户当前位置5公里范围内的商家?这个问题看似简单,但背后涉及到地理空间计算的精妙算法。今天我们就来深入探讨如何用JavaScript实现这一功能。
1. 理解Haversine公式的核心原理
Haversine公式是计算球面上两点之间距离的经典算法,特别适用于地球表面的距离计算。与简单的平面距离计算不同,它考虑了地球的曲率,因此计算结果更加准确。
公式的核心思想是将经纬度转换为弧度,然后利用三角函数的性质计算两点之间的中心角,最后乘以地球半径得到实际距离。具体来说:
a = sin²(Δφ/2) + cos φ1 ⋅ cos φ2 ⋅ sin²(Δλ/2) c = 2 ⋅ atan2(√a, √(1−a)) d = R ⋅ c其中:
- φ1, φ2:两点的纬度(弧度)
- Δφ:纬度差(弧度)
- Δλ:经度差(弧度)
- R:地球半径(平均值约为6371公里)
提示:地球并非完美球体,Haversine公式假设地球为完美球体,实际应用中误差通常在0.5%以内,对于大多数商业应用已经足够精确。
2. JavaScript实现基础距离计算
让我们先实现一个基础版的Haversine公式计算函数:
/** * 计算两个经纬度之间的球面距离(单位:米) * @param {number} lat1 - 第一个点的纬度(十进制度) * @param {number} lon1 - 第一个点的经度(十进制度) * @param {number} lat2 - 第二个点的纬度(十进制度) * @param {number} lon2 - 第二个点的经度(十进制度) * @returns {number} 距离(米) */ function getDistance(lat1, lon1, lat2, lon2) { const toRad = angle => angle * Math.PI / 180; const R = 6371000; // 地球半径,单位:米 const φ1 = toRad(lat1); const φ2 = toRad(lat2); const Δφ = toRad(lat2 - lat1); const Δλ = toRad(lon2 - lon1); const a = Math.sin(Δφ/2) * Math.sin(Δφ/2) + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ/2) * Math.sin(Δλ/2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); return R * c; }使用示例:
// 计算北京天安门和上海外滩的距离 const distance = getDistance(39.9087, 116.3975, 31.2400, 121.4900); console.log(`两地距离约为 ${(distance/1000).toFixed(2)} 公里`); // 输出:两地距离约为 1068.01 公里3. 优化性能:批量计算附近商家
在实际应用中,我们通常需要从大量商家中筛选出附近5公里内的商家。直接逐个计算距离显然效率不高。我们可以采用以下优化策略:
3.1 预筛选优化
在应用Haversine公式前,先进行粗略筛选:
function findNearbyBusinesses(userLat, userLon, businesses, maxDistance = 5000) { // 粗略筛选:纬度1度约111公里,经度1度在赤道约111公里 const latRange = maxDistance / 111000; const lonRange = maxDistance / (111000 * Math.cos(userLat * Math.PI / 180)); return businesses.filter(business => { // 先进行粗略筛选 if (Math.abs(business.lat - userLat) > latRange || Math.abs(business.lon - userLon) > lonRange) { return false; } // 精确计算 const distance = getDistance(userLat, userLon, business.lat, business.lon); return distance <= maxDistance; }); }3.2 使用空间索引
对于更大规模的数据,可以考虑使用空间索引技术:
| 索引类型 | 适用场景 | JavaScript实现 |
|---|---|---|
| 网格索引 | 中小规模数据 | 简单二维数组划分 |
| R树 | 大规模数据 | 使用rbush等库 |
| GeoHash | 分布式系统 | 使用geohash-js |
// 使用rbush实现R树索引 import RBush from 'rbush'; const tree = new RBush(); businesses.forEach(business => { tree.insert({ minX: business.lon, minY: business.lat, maxX: business.lon, maxY: business.lat, business: business }); }); // 查询附近商家 const nearby = tree.search({ minX: userLon - 0.1, // 适当扩大范围 minY: userLat - 0.1, maxX: userLon + 0.1, maxY: userLat + 0.1 }).map(item => item.business);4. 完整实战:外卖配送场景实现
让我们实现一个完整的外卖配送场景解决方案:
class NearbyService { constructor(businesses = []) { this.businesses = businesses; this.tree = this._buildIndex(businesses); } _buildIndex(businesses) { const tree = new RBush(); businesses.forEach(b => { tree.insert({ minX: b.lon - 0.01, minY: b.lat - 0.01, maxX: b.lon + 0.01, maxY: b.lat + 0.01, business: b }); }); return tree; } getNearby(userLat, userLon, radius = 5000) { // 1. 粗略筛选 const latRange = radius / 111000; const lonRange = radius / (111000 * Math.cos(userLat * Math.PI / 180)); const candidates = this.tree.search({ minX: userLon - lonRange, minY: userLat - latRange, maxX: userLon + lonRange, maxY: userLat + latRange }).map(item => item.business); // 2. 精确计算 return candidates.filter(business => { const distance = getDistance(userLat, userLon, business.lat, business.lon); business.distance = distance; // 添加距离信息 return distance <= radius; }).sort((a, b) => a.distance - b.distance); // 按距离排序 } } // 使用示例 const businesses = [ {id: 1, name: "餐厅A", lat: 39.9087, lon: 116.3975}, {id: 2, name: "餐厅B", lat: 39.9187, lon: 116.4075}, // 更多商家数据... ]; const service = new NearbyService(businesses); const nearby = service.getNearby(39.9087, 116.3975, 5000); console.log(nearby);5. 高级优化与注意事项
5.1 缓存计算结果
对于静态商家数据,可以预先计算并缓存距离:
const cache = new Map(); function getCachedDistance(lat1, lon1, lat2, lon2) { const key = `${lat1},${lon1},${lat2},${lon2}`; if (!cache.has(key)) { cache.set(key, getDistance(lat1, lon1, lat2, lon2)); } return cache.get(key); }5.2 Web Worker并行计算
对于大量计算,可以使用Web Worker避免阻塞主线程:
// worker.js self.onmessage = function(e) { const {userLat, userLon, businesses} = e.data; const results = businesses.map(b => ({ ...b, distance: getDistance(userLat, userLon, b.lat, b.lon) })).filter(b => b.distance <= 5000); self.postMessage(results); }; // 主线程 const worker = new Worker('worker.js'); worker.postMessage({userLat, userLon, businesses}); worker.onmessage = e => { console.log('附近商家:', e.data); };5.3 常见问题与解决方案
- 精度问题:Haversine公式的误差通常在0.5%以内,对于更高精度需求,可以考虑Vincenty公式
- 性能瓶颈:对于超过10万条数据,建议使用专业的地理空间数据库如PostGIS
- 移动端优化:考虑使用原生模块或WebAssembly提升计算性能
在实际项目中,我们还需要考虑用户位置的实时更新、商家营业状态等因素,这些都会影响最终的筛选结果。