UniApp RenderJS中集成 Leaflet地图,突破APP跨端开发限制

一、技术选型

本文通过 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'

大家如有更好方案,评论区可留言

相关推荐
没头脑和不高兴y2 小时前
Element-Plus-X:基于Vue 3的AI交互组件库
前端·javascript
ErMao2 小时前
Proxy 与 Reflect:最硬核、最实用的解释
前端·javascript
N***73852 小时前
前端路由权限动态更新,Vue与React实现
前端·vue.js·react.js
k09332 小时前
在组件外(.js文件)中使用pinia的方法2--在http.js中使用pinia
开发语言·javascript·http
华仔啊2 小时前
Vue3图片放大镜从原理到实现,电商级细节展示方案
前端·vue.js·canvas
宇余2 小时前
Unibest开发避坑指南:20+常见问题与解决方案
前端·vue.js
Glommer2 小时前
AST 反混淆处理示例
javascript·爬虫
二川bro2 小时前
第44节:物理引擎进阶:Bullet.js集成与高级物理模拟
开发语言·javascript·ecmascript
越努力越幸运5082 小时前
JavaScript进阶篇垃圾回收、闭包、函数提升、剩余参数、展开运算符、对象解构
开发语言·javascript