一、技术选型
本文通过 RenderJS,将Leaflet地图完美集成到UniApp中,解决了跨端地图渲染的问题。
那么问题来了,为什么选择这个方案?
- UniApp自带map组件功能限制;
- Leaflet 在 UniApp 中只能在 H5 使用,如果APP使用,引入Leaflet的WebView,页面容易卡顿;
- 复杂地图交互(自定义图层、轨迹绘制)难以实现;
RenderJS + Leaflet优势
✅ 接近原生的性能;
✅ Leaflet完整的生态功能;
✅ 跨端一致性体验;
二、基础集成:从零开始搭建
1、环境准备,依赖安装
bash
npm install leaflet
npm install leaflet.chinatmsproviders
javascript
{
"dependencies": {
"leaflet": "^1.9.4",
"leaflet.chinatmsproviders": "^3.0.6",
},
}
2、地图模板
html
<template>
<view class="app-container">
<view id="mapContainer"></view>
</view>
</template>
3、RenderJS模块定义
javascript
<script module="leaflet" lang="renderjs">
import L from 'leaflet';
import "leaflet.chinatmsproviders";
// 或者
import L from '@/static/js/leaflet/leaflet.js'; // 需要把依赖放在本地,我是通过此方法引入的
import '@/static/js/leaflet/leaflet.css';
import '@/static/js/leaflet/leaflet.ChineseTmsProviders.js';
import '@/static/js/leaflet/leaflet.mapCorrection.js'; // 地图纠偏文件根据自己路径引入
export default {
data() {
return {
centerPoint: [31.533705, 120.278157],
map: null,
ownerInstance: null, // 逻辑层实例引用
}
},
mounted() {
this.initMap();
},
methods: {
// 初始化地图
initMap() {
this.map = L.map('mapContainer', {
attributionControl: false,
zoomControl: false,
detectRetina: true,
tap: false
}).setView(this.centerPoint, 18);
L.tileLayer.chinaProvider('GaoDe.Satellite.Map', {
maxZoom: 18
}).addTo(this.map);
},
}
}
</script>
4、地图容器样式
css
<style lang="scss" scoped>
.map-container {
width: 100%;
height: 100vh;
#mapContainer {
width: 100%;
height: 100%;
}
}
</style>
三、 核心技术:Vue与RenderJS双向通信
地图所在标签元素,添加 :prop="renderAction"属性,变量改变触发handleAction方法
html
<template>
<view class="app-container">
<view id="mapContainer" :prop="renderAction" :change:prop="leaflet.handleAction"></view>
<view @click="handleClickBtn">中心位置</view>
<view @click="handleDrawing">绘制</view>
</view>
</template>
1、Renderjs
javascript
// 绘制
handleDrawing() {
// 其他逻辑
this.notifyLogicLayer('updateStatus', true);
},
// 定位到中心位置
handleCenterLocation(params = null) {
let centerPoint = params && params.centerPoint;
let zoomLevel = this.map.getZoom();
// 平滑移动flyTo
this.map.flyTo(centerPoint, zoomLevel, {
duration: 1, // 动画持续时间(秒)
easeLinearity: 0.25
});
// 立即定位setView
// this.map.setView(centerPoint, zoomLevel);
},
// 接收逻辑层消息:改变renderAction触发,根据参数执行对应的视图层方法
handleAction(newValue, ownerInstance) {
console.log("handleAction", newValue);
this.ownerInstance = ownerInstance;
if (!newValue || !newValue.action) {
console.warn('RenderJS接收指令格式错误:缺少action属性');
return;
}
const method = this[newValue.action];
if (!method || typeof method !== 'function') {
console.error(`RenderJS中未找到方法:${newValue.action}`);
return;
}
try {
method.call(this, newValue.params);
} catch (err) {
console.error(`RenderJS执行方法${newValue.action}失败:`, err);
}
},
// 通知Vue逻辑层
notifyLogicLayer(methodName, params) {
if (this.ownerInstance) {
this.ownerInstance.callMethod(methodName, params);
}
}
2、Vue逻辑层
javascript
<script>
export default {
data() {
return {
// 传递给RenderJS的指令
renderAction: {
action: '', // RenderJS中要执行的方法名
params: null // 传递的参数
},
centerPoint: [31.233705, 120.238157]
}
},
methods: {
// 定位到中心位置
handleClickBtn() {
this.sendActionToRender('handleCenterLocation', {
centerPoint: this.centerPoint,
});
},
// 接收RenderJS通知
updateStatus(status) {
// 执行逻辑
},
// 发送消息到RenderJS
sendActionToRender(action, params = null) {
this.renderAction = {
action,
params,
timestamp: Date.now() // 添加时间戳确保每次都触发
};
},
}
}
</script>
四、 实战案例:轨迹回放系统
以下代码是我自己实现的功能,可能不适用其他,不过逻辑大致如此,可自行修改
功能点1:时间轴改变,绘制设备轨迹以及最新点标记,并判断是否跟随;
功能点2:实时监听我的位置变化,并绘制轨迹
RenderJS中实现
javascript
// ----------- 绘制设备标记以及轨迹,视角跟随 ----------
// 更新设备轨迹线,并且清除之前的
updateFlyLineWithClear({
points,
shouldFollowDeviceId = null
}) {
// 按设备ID分组
const pointsByDevice = {};
points.forEach(point => {
if (!pointsByDevice[point.id]) {
pointsByDevice[point.id] = [];
}
pointsByDevice[point.id].push(point);
});
Object.keys(pointsByDevice).forEach(deviceItemId => { // 为每个设备清除并重新绘制轨迹
this.removeLayerByName(deviceItemId + '_droneLine'); // 清除旧轨迹线
const devicePoints = pointsByDevice[deviceItemId].sort((a, b) => a.time - b.time); // 排序
devicePoints.forEach((point, index) => { // 绘制该设备的完整轨迹
if (index > 0) {
const oldPoint = devicePoints[index - 1]; // 上一个点,每两点绘制轨迹
this.drawSinglePath(
point.id,
point.latDiff,
point.lonDiff,
oldPoint.latDiff,
oldPoint.lonDiff,
point.weixian
);
}
});
// 更新设备标记(只显示最后一个点)
if (devicePoints.length > 0) {
const lastPoint = devicePoints[devicePoints.length - 1];
const shouldFollow = shouldFollowDeviceId === deviceItemId; // 判断是否需要跟随当前设备
this.updateDroneMarker(lastPoint, shouldFollow);
}
});
},
// 绘制单条轨迹线段
drawSinglePath(id, latDiff, lonDiff, oldlatDiff, oldlonDiff, weixian) {
const flyLine = L.polyline([
[oldlatDiff, oldlonDiff],
[latDiff, lonDiff]
], {
name: id + '_droneLine',
color: weixian < 3 ? '#3BC25B' : '#c23b3b',
weight: 3,
opacity: 0.8,
zIndexOffset: 1000,
}).addTo(this.map);
},
// 更新设备标记,添加跟随判断
updateDroneMarker(point, shouldFollow = false) {
const {
id,
latDiff,
lonDiff,
weixian,
} = point;
this.removeLayerByName(id + '_droneMarker'); // 移除旧标记
const drone1 = require('@/static/images/xxx.png');
const drone2 = require('@/static/images/xxx.png');
const icon = L.icon({
iconUrl: weixian < 3 ? drone1 : drone2,
iconSize: [36, 36],
iconAnchor: [18, 18],
className: 'drone-icon'
});
const droneMarker = L.marker([latDiff, lonDiff], {
name: id + '_droneMarker',
icon,
zIndexOffset: 1000,
popupAnchor: [0, -50]
}).addTo(this.map);
// 弹窗,自定义内容
droneMarker.openPopup(`
<div class='device-txtarea'>
<div class="info-item">
<div>${latDiff}</div>
<div>${lonDiff}</div>
</div>
</div>
`, {
autoClose: false,
closeOnClick: false,
className: weixian < 3 ? 'safety-popup' : 'danger-popup' // 自定义弹窗样式
});
droneMarker.openPopup(); // 根据需求控制显示隐藏
// 更新无人机列表
this.droneList = this.droneList.filter(drone =>
!drone.options.name.includes(id + '_droneMarker')
);
this.droneList.push(droneMarker);
// 如果需要跟随,移动视角到无人机位置
if (shouldFollow) {
this.sightFollowFly({
deviceId: id
});
}
},
// 跟随设备视角
sightFollowFly(params) {
if (!params || !params.deviceId) {
console.error('视角跟随缺少设备ID');
return;
}
const deviceId = params.deviceId
// 查找对应的设备标记
const droneMarker = this.droneList.find(drone =>
drone.options.name && drone.options.name.includes(deviceId + '_droneMarker')
);
if (droneMarker) {
// 获取设备当前位置
const dronePosition = droneMarker.getLatLng();
// 平滑移动到设备位置
this.map.flyTo(dronePosition, 18, {
duration: 0.5, // 动画持续时间(秒)
easeLinearity: 0.25
});
} else {
console.error(`未找到无人机 ${deviceId} 的标记`);
}
},
// ----------- 实时监听我的位置,绘制轨迹 ----------
// 更新我的位置和轨迹
updateLocationAndTrack(params) {
this.updateMyLocationMarker(params);
this.drawMyLocationTrack(params);
},
// 更新我的位置标记
updateMyLocationMarker(params) {
if (!params.point) return;
this.removeLayerByName('myLocationMark'); // 先清除旧标记
const myLocationMarker = L.circleMarker(params.point, { // 创建新的位置标记
name: 'myLocationMark',
radius: 8,
color: '#fff',
weight: 2,
fillColor: '#3281F4',
fillOpacity: 1,
zIndex: 1500,
zIndexOffset: 1500,
}).addTo(this.map);
},
// 绘制我的位置轨迹线
drawMyLocationTrack(params) {
if (!params.points || params.points.length <= 1) return;
this.removeLayerByName('myLocationTrack'); // 先清除旧的轨迹线
const trackLine = L.polyline(params.points, { // 创建轨迹线
name: 'myLocationTrack',
color: '#3281F4',
weight: 3,
opacity: 0.8,
zIndex: 1000,
zIndexOffset: 1000,
}).addTo(this.map);
},
// 通过名称删除
removeLayerByName(name) {
let layersToRemove = this.findLayerByName(name);
if (layersToRemove.length > 0) {
layersToRemove.forEach((layer) => {
this.map.removeLayer(layer);
});
return true;
}
return false;
},
// 通过名称查找图层
findLayerByName(name) {
let foundLayer = [];
this.map.eachLayer(function(layer) {
if (layer._name === name || layer.options?.name === name) {
foundLayer.push(layer);
}
});
return foundLayer;
},
Vue实现逻辑
javascript
// ----------- 绘制设备标记以及轨迹,视角跟随 -----------
// 时间改变
changeTime(val) {
this.currentTime = val;
this.drawPreviousFlyPath();
// 如果正在跟随,确保视角跟随当前设备
if (this.isFollowing && this.followingDeviceId) {
this.sendActionToRender('sightFollowFly', {
deviceId: this.followingDeviceId
});
}
},
// 绘制当前时间之前的飞行轨迹
drawPreviousFlyPath() {
// 筛选出当前时间及之前的所有点
const previousPoints = pointsList.filter(item =>
(item.id === 'xxx' || item.id === 'xxx') &&
item.time <= this.currentTime
)
// 按时间排序
previousPoints.sort((a, b) => a.time - b.time);
// 发送到RenderJS进行绘制
this.sendActionToRender('updateFlyLineWithClear', {
points: previousPoints,
shouldFollowDeviceId: this.isFollowing ? this.followingDeviceId : null
});
},
// ----------- 实时监听我的位置,并绘制轨迹 ------------
// 开始监听位置变化
async startLocationTracking() {
try {
// 开始持续监听位置变化
this.locationWatcher = await startLocationWatch({
success: this.handleLocationChange,
fail: (error) => {
console.error('位置监听失败:', error);
uni.showToast({
title: '位置监听失败',
icon: 'none'
});
}
});
this.isTracking = true;
console.log('位置监听已启动');
} catch (error) {
console.error('启动位置监听失败:', error);
uni.showToast({
title: '启动位置监听失败',
icon: 'none'
});
}
},
// 处理位置变化
handleLocationChange(location) {
const newPoint = [location.latitude, location.longitude];
// 检查是否与上一个点相同,位置去重
if (this.isSameAsLastPoint(newPoint)) {
return;
}
this.myCurrentGps = newPoint; // 更新当前位置
this.trackPoints.push(newPoint); // 添加到轨迹点数组
this.updateMyLocationMarkerAndTrack(newPoint); // 更新我的位置标记并绘制轨迹线
},
// 更新我的位置标记并绘制轨迹线
updateMyLocationMarkerAndTrack(point) {
const points = JSON.parse(JSON.stringify(this.trackPoints));
this.sendActionToRender('updateLocationAndTrack', {
point: point,
points: points
});
},
// 检查是否与上一个点相同
isSameAsLastPoint(newPoint) {
if (newPoint && this.trackPoints.length < 1) return;
const lastPoint = this.trackPoints[this.trackPoints.length - 1];
const [lastLat, lastLng] = lastPoint;
const [newLat, newLng] = newPoint;
const precision = 0.00001; // 设置精度阈值(约1米精度)
return (
Math.abs(lastLat - newLat) < precision &&
Math.abs(lastLng - newLng) < precision
);
},
// 停止位置监听,页面退出之前需要执行
stopLocationTracking() {
if (this.locationWatcher) {
stopLocationWatch(this.locationWatcher);
this.locationWatcher = null;
}
this.isTracking = false;
},
样式:
css
::v-deep {
.safety-popup {
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: rgba(59, 194, 91, 0.75) !important;
}
}
.danger-popup {
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: rgba(194, 59, 59, 0.75) !important;
}
}
.leaflet-popup-content-wrapper {
box-shadow: none;
.leaflet-popup-content {
margin: 0;
}
}
...
}
五、 深度踩坑与解决方案
❌ 问题1:地图容器高度异常
现象 :地图显示为灰色,高度为0
解决方案:
css
#mapContainer {
height: 100vh !important;
min-height: 500px;
}
❌ 问题2:地图显示异常
现象:地图有灰色方块,图层显示不全
解决方案:我当时遇到的问题是没有引入leaflet.css
javascript
import '@/static/js/leaflet/leaflet.css'
大家如有更好方案,评论区可留言