地图业务在uniapp里做到中后期,十有八九会碰到同一个坎:点位太多,地图扛不住。我第一次接到门店分布需求的时候,后台一次性返回了800多个坐标点,直接全量塞进map组件的markers数组里。结果小程序端还能勉强拖拽,H5端在电脑浏览器上已经明显掉帧,低端安卓机上直接卡成PPT。后来调研了一圈,发现解决方案就是标题里这三个字——聚合点。
聚合点的核心价值,是把视野范围内距离相近的多个标记合并成一个带数字的圆点,用户缩放地图时再动态拆分。它不改变你的数据源,只是改变了渲染策略。这篇博文我会把这套东西讲透:微信小程序端的官方聚合怎么开、H5和App端怎么用高德插件补位、不引SDK时自己写个网格聚合算法要怎么做,以及我在实际项目中踩过的样式和交互层面的坑。
1. 一次渲染几百个marker为什么会卡:聚合点的原理与触发时机
1.1 卡顿的根源:地图组件不是为千级DOM准备的
先想清楚一个问题:地图上的marker到底是什么?
map组件底层的标记点,不是普通网页里的div,它在各个端的实现完全不一样。微信小程序端的map是原生组件覆盖在webview之上,App端的map同样走的是原生地图SDK的图层;H5端则是通过WebGL或者Canvas在绘制。虽然底层各有不同,但有一个共同点:每个marker都是一个独立的渲染实体,数量上来之后,CPU和GPU都会不堪重负。
我自己做过一次粗测,在小程序开发者工具里渲染100个marker时帧率几乎没有波动,到300个时开始出现偶发卡顿,到800个时拖动地图有明显延迟,放到真机上更惨。而且marker数量越大,地图的触摸响应、缩放动画、regionchange事件回调都会跟着变慢,原因很简单——每次视野变化,地图引擎都要对所有marker做一次投影和碰撞计算。
聚合点能解决的,正是这个性能瓶颈。它的思路是:把视口内距离相近的点合并成一个聚合标记,地图上实际渲染的DOM/图层数量从上千降到几十。这样地图的拖动、缩放都只针对少量标记计算,帧率自然就回来了。
1.2 聚合点的工作机制和缩放级别的换算
聚合不是把点“永久合在一起”,它是跟随缩放级别动态变化的。放大到一定级别,聚合点会散开成单个标记;缩小到一定级别,单个标记又会重新聚拢。
无论是官方的聚合能力,还是高德的MarkerClusterer,底层的聚合判定逻辑都有一个共同参数:聚合半径。这个半径的单位是“像素”,不是经纬度。当地图上两个标记点的像素距离小于这个值时,就会被并成一个聚合点。
为什么要强调像素?因为同样的经纬度距离,在不同缩放级别下对应的像素距离完全不同。缩放级别越大(地图越放大),同样1公里在地图上占的像素越多;缩放级别越小(地图越缩小),占的像素越少。所以聚合算法必须动态计算:zoom小的时候聚合范围大,zoom大的时候聚合范围小,直到某个zoom值以上,所有点都散开。
这套机制决定了你在开发时要关注两件事:
scale/zoom属性的初始值,因为初始缩放级别直接决定用户第一眼看到的是聚合状态还是单体状态。- 聚合点的
marker-change回调,因为聚合状态变化是异步触发的,你需要在这个回调里更新UI。
提示:聚合半径不能设置得太离谱。一般官方默认值在60px左右比较合适,太大了会出现“跨街区聚合”的错觉,用户点开一看聚合了十几个毫不相关的点;太小了聚合效果不明显。
2. 微信小程序端最省事的做法:直接开官方聚合开关
2.1 一分钟接入:enable-aggregation开启聚合
如果你的目标平台只有微信小程序,那恭喜你,uni-app官方已经帮你封装好了最省事的方案。从HBuilderX 3.5.3+开始,map组件支持了聚合点能力,底层依赖微信基础库2.22.0以上的enable-aggregation属性。
直接看代码,这是最小可用实现:
<template> <view> <map id="storeMap" :markers="markers" :enable-aggregation="true" :scale="scale" :min-scale="3" :max-scale="20" bindmarkertap="onMarkerTap" bindregionchange="onRegionChange" @markerchange="onMarkerChange" /> </view> </template> <script> export default { data() { return { scale: 10, markers: [], }; }, onLoad() { this.loadStores(); }, methods: { async loadStores() { const result = await this.fetchStoreList(); this.markers = result.map((item, index) => ({ id: item.id || index, latitude: item.latitude, longitude: item.longitude, iconPath: "/static/marker.png", width: 32, height: 32, title: item.name, joinCluster: true, // 这个字段很关键:只有为true才会参与聚合 })); }, onMarkerChange(e) { console.log("聚合状态变化", e.detail); }, }, }; </script>有两个细节必须提醒:
enable-aggregation要写成:enable-aggregation="true",不是enable-aggregation,后者在vue模板里会被当作字符串"true",部分平台解析会出问题。joinCluster字段必须为true,否则即使开了聚合开关,该标记也不会参与聚合,而是直接显示在地图上。
这也是官方文档容易忽略的坑。我见过不少人在社区里问“为什么我的enable-aggregation开了没效果”,九成都是因为marker对象里少了joinCluster。
2.2 聚合状态怎么感知:marker-change事件里的门道
地图上的聚合点合并、拆分是异步发生的,你要在UI层感知到这种变化,靠的是markerchange事件。
onMarkerChange(e) { // e.detail 的结构因端而异,但一般会包含聚合后的标记信息 const detail = e.detail || {}; const markers = detail.markers || detail.Markers || []; console.log("当前聚合标记数量", markers.length); }这个事件的触发时机是:当用户拖动或缩放地图,导致聚合状态发生变化时。注意它不是每帧触发,而是“变化后触发一次”,所以用它来更新自定义UI(比如显示当前点位/聚合数量)是没问题的。
但同样要提醒:不同平台对这个事件的支持并不一致。我在实测中发现,某些版本的微信基础库对markerchange的事件回调字段解析有差异,有的叫markers,有的叫causedBy,有的根本不传任何数据。所以生产环境里不要过度依赖这个事件里的具体字段,用它来做日志上报、或者做个节流后刷新状态还可以,但别用它来驱动核心业务逻辑。
2.3 官方方案的局限:样式、跨端覆盖面和数据量上限
官方聚合方案最大的优点是接入简单,但它不是万能的。我在项目里用过一段时间后,总结了三个明确的局限:
样式控制非常有限。聚合点长什么样,微信端有一套自己的默认UI——一个蓝色/红色的圆,中间显示数字。你没法自定义聚合点的背景色、字体颜色、尺寸、边框,想要和业务品牌色一致就彻底没戏。我当时的做法是直接把聚合点当“过渡态”,用户一缩放就散了,样式丑一点也就忍了。
跨端支持参差不齐。我试过把这套代码跑在H5端,
enable-aggregation直接不生效;App端部分版本支持,但行为和小程序端不完全一致。所以如果你的项目要同时发布到小程序、H5、App,官方聚合只能当小程序端的专属优化,其他端还得另想办法。数据量超过3000时,官方聚合自己也会卡。因为聚合计算的逻辑是内置在地图引擎里的,数据量特别大的时候,聚合本身的算法开销也会成为瓶颈。当时我压测过4000个点,开启聚合后缩放到最小级别,依然有明显卡顿。
所以结论很清楚:官方聚合适合中小数据量、只发小程序端的场景;如果你要覆盖多端,或者数据量动不动上万,还是得看后面的方案。
3. H5和App端的曲线救国方案:高德MarkerClusterer接入实录
3.1 为什么H5端不能直接照搬官方聚合
uni-app的map组件在H5端的底层实现是基于各家地图JS API的,并没有把聚合能力暴露给开发者。所以你在web端想实现聚合,最直接的办法就是绕过map组件,直接用高德/百度地图的JS API,把地图实例握在自己手里。
有人说“那我用uni-app的map组件,手动计算聚合点,再动态更新markers不就行了?”——理论上当然可以,这种方案也不用引额外SDK,后面第4章我会展开讲。但如果你不想自己维护一套聚合算法,又对地图能力有更多要求(比如绘制热力图、轨迹动画、海量点图层),直接上高德JS API更省心。
我在项目里采用的策略是:条件编译,区分端处理。小程序端保留uni-app的官方聚合,H5端走独立的高德地图页面。
// #ifdef H5 import AMapLoader from "@amap/amap-jsapi-loader"; // #endif3.2 MarkerClusterer的接入步骤和关键参数
高德JS API 2.0版本提供了AMap.MarkerClusterer插件,专门做标记聚合。先引入依赖:
<!-- 或者直接在index.html里引入 --> <script src="https://webapi.amap.com/maps?v=2.0&key=YOUR_KEY&plugin=AMap.MarkerClusterer"></script>然后在页面里初始化地图和聚合器:
// #ifdef H5 import AMapLoader from "@amap/amap-jsapi-loader"; export default { data() { return { map: null, clusterer: null, }; }, mounted() { this.initMap(); }, methods: { async initMap() { const AMap = await AMapLoader.load({ key: "YOUR_AMAP_KEY", version: "2.0", plugins: ["AMap.MarkerClusterer"], }); this.map = new AMap.Map("mapContainer", { zoom: 11, center: [116.397428, 39.90923], viewMode: "2D", }); // 准备点位数据 const markers = this.storeData.map((item) => { return new AMap.Marker({ position: [item.longitude, item.latitude], title: item.name, content: `<div class="custom-marker">${item.name}</div>`, }); }); // 创建聚合器 this.clusterer = new AMap.MarkerClusterer(this.map, markers, { gridSize: 60, // 聚合像素范围 maxZoom: 18, // 超过这个缩放级别不再聚合 minClusterSize: 2, // 至少几个点才聚合 styles: this.getClusterStyles(), // 自定义聚合样式 }); }, }, }; // #endif参数里最值得调的是gridSize和maxZoom。
gridSize:聚合判断的像素距离,单位是px。我实测下来60是一个比较均衡的值,间距超过60px的点不会被聚合。如果你想要更激进的聚合,可以调到80甚至100,但太小不行,否则聚合点数量还是很多。maxZoom:当缩放级别大于这个值时,所有聚合点都会散开成单个marker。我建议设在16~18之间。如果设得太小(比如14),用户双击地图想放大看细节时,发现聚合点半天不散,体验很怪。
3.3 聚合后的事件处理与自定义样式
MarkerClusterer的聚合点默认是带数字的圆点,但你可以通过styles参数完全自定义:
getClusterStyles() { const size = 44; return [ { size: new AMap.Size(size, size), offset: new AMap.Pixel(-size / 2, -size / 2), textColor: "#fff", backgroundColor: "#e74c3c", borderColor: "#fff", borderWidth: 2, fontWeight: "bold", }, // 可以针对不同聚合数量设置不同样式 { size: new AMap.Size(56, 56), offset: new AMap.Pixel(-28, -28), textColor: "#fff", backgroundColor: "#d35400", borderWidth: 2, }, ]; }聚合事件分两层:聚合点被点击、单体marker被点击。前者要监听clusterer上的事件,后者直接监听marker的click。
this.clusterer.on("click", (e) => { const cluster = e.cluster; // 通过cluster.getMarkers()可以拿到聚合点里的所有原始marker console.log("聚合点包含的店铺", cluster.getMarkers().length); }); markers.forEach((marker) => { marker.on("click", () => { console.log("点击了单体", marker.getTitle()); }); });用高德方案时有一个隐藏的坑:AMap.MarkerClusterer内部会接管所有marker的显示/隐藏,如果你在聚合创建后又想单独操作某个marker(比如高亮、加动画),通常需要先把marker从聚合器里移除再操作,否则你的操作会被聚合器的重绘覆盖掉。
注意:高德JS API的MarkerClusterer在点位超过1万时,性能也会下降。大数据量时建议配合后端按经纬度网格预聚合,前端只拿聚合结果渲染。
4. 不引SDK也能聚合:一套轻量网格聚合算法
4.1 网格聚合的数学思路
如果不想引高德SDK,也不想受限于官方聚合的样式,自己写一套轻量聚合算法完全可行。这里我分享的是一套“网格聚合”方案,核心思路很简单:
把地图视口按照经纬度划分成一个个网格,落在同一个网格里的点算一个聚合点。
网格大小不是固定的,它要随缩放级别动态变化。zoom小的时候网格大一些,多合并一点;zoom大的时候网格变小,让点散开。这里的关键是换算公式。我用的经验值是:在zoom=12时,网格大约对应0.01个纬度(约1.1公里);每增加一级缩放,网格边长减半;每减少一级,边长翻倍。
function getGridSize(zoom) { // zoom越大,网格越小 return 0.01 / Math.pow(2, zoom - 12); }当然这个系数不是绝对的,你得根据自己的业务数据密度来调。数据密集的城市商圈,网格系数可以设大一点;农村、郊区的稀疏点位,系数要设小一点。
4.2 完整代码实现
下面是完整的网格聚合逻辑,数据源是后台返回的经纬度数组:
function gridCluster(points, zoom) { const gridSize = 0.01 / Math.pow(2, zoom - 12); const clusterMap = {}; points.forEach((point) => { const latIndex = Math.floor(point.latitude / gridSize); const lngIndex = Math.floor(point.longitude / gridSize); const key = `${latIndex}_${lngIndex}`; if (!clusterMap[key]) { clusterMap[key] = { latitude: point.latitude, longitude: point.longitude, count: 1, points: [point], }; } else { clusterMap[key].count += 1; clusterMap[key].points.push(point); // 用平均值动态更新聚合点的中心坐标 clusterMap[key].latitude = clusterMap[key].latitude + (point.latitude - clusterMap[key].latitude) / clusterMap[key].count; clusterMap[key].longitude = clusterMap[key].longitude + (point.longitude - clusterMap[key].longitude) / clusterMap[key].count; } }); return Object.values(clusterMap).map((cluster) => ({ latitude: cluster.latitude, longitude: cluster.longitude, count: cluster.count, points: cluster.points, })); }这段代码的说明:
- 每个点先根据zoom算出它所在的网格id(latIndex_lngIndex),同一个网格的合并成一个聚合点。
- 聚合点的坐标用所有点的平均值动态更新,这样聚合点的位置不会偏向网格左下角,而是相对居中。
- 返回结构里保留
points数组,方便点击聚合点后获取原始数据。
然后把它接到uni-app的marker渲染上:
const clusterData = gridCluster(this.storeData, this.scale); this.markers = clusterData.map((cluster) => { if (cluster.count > 1) { // 聚合marker:用自定义图标显示数量 return { id: `cluster_${cluster.latitude}_${cluster.longitude}`, latitude: cluster.latitude, longitude: cluster.longitude, iconPath: "/static/cluster-marker.png", width: 36, height: 36, label: { content: `${cluster.count}`, color: "#fff", fontSize: 12, anchorX: -8, anchorY: -26, }, joinCluster: true, clusterInfo: cluster, // 挂到marker上,点击时可以取回 }; } // 单个点 return { id: cluster.points[0].id, latitude: cluster.points[0].latitude, longitude: cluster.points[0].longitude, iconPath: "/static/marker.png", width: 32, height: 32, joinCluster: true, rawData: cluster.points[0], }; });自己写算法的最大好处是:聚合点的样式完全由你控制,图标、数字、背景色全都自定义。不用忍受官方聚合那一套固定的蓝色圆点。
但这个方案也有代价。地图每次拖动、缩放都要重新计算一次聚合,如果数据量上万,前端遍历的耗时也会可观。解决办法是加个阈值:只在regionchange触发时重新聚合,而不是连续执行;或者用requestAnimationFrame做节流,把聚合计算推迟到下一帧,避免阻塞主线程。
onRegionChange(e) { if (e.type === "end") { // 地图移动结束后触发重新聚合 this.updateCluster(e.detail.scale || this.scale); } }4.3 边界条件与几个实测数据
网格聚合算法看着简单,实际用起来有四个边界条件需要处理:
zoom值很大时,网格边长趋向0,每个点都单独成格,聚合退化成普通marker渲染。此时
gridSize不能无限变小,我设了最小值0.001,避免经纬度误差导致同一个点反复横跳动。聚合点的id不能重复。因为re-render时map组件靠id做diff,如果id冲突会导致标记不刷新。上面代码里id用了
cluster_纬度_经度,经纬度精度要保留到5位以上,否则两个相邻聚合点的id可能一样。个别点会“跳动”。因为聚合中心是平均值,当用户缩小地图时,中心点会随新增入网格的点而变化,视觉上聚合点会“漂移”。这是网格聚合的通病,无解,只能把系数调小,让变化幅度不那么夸张。
性能分层很明显。我压测过,1000个点,网格聚合计算一次大约2~4ms;5000个点,大约15~20ms;1万个点,50ms以上。50ms虽然不算久,但如果连续触发,地图交互就会明显卡顿。所以数据过万时,我强烈建议后端做SQL级别的网格分组,直接返回聚合结果,而不是全量下发前端算。
5. 聚合状态的交互优化与前端分帧渲染
5.1 聚合点点击后下钻和单体点弹窗的交互逻辑
聚合点不只是“好看”,它还要承担交互引导的作用。用户看到聚合点上有数字,第一反应就是点击它,期望能放大到能看到具体门店。我在项目里的处理逻辑是:
onMarkerTap(e) { const markerId = e.detail.markerId; const marker = this.markers.find((m) => m.id === markerId); if (!marker) return; if (marker.clusterInfo && marker.clusterInfo.count > 1) { // 聚合点:放大一级视野,让聚合点散开 const newScale = (this.scale || 10) + 2; this.scale = Math.min(newScale, 18); // 可选:把视野中心移到聚合点位置 this.center = { latitude: marker.latitude, longitude: marker.longitude, }; } else { // 单体点:弹出详情 this.currentStore = marker.rawData; this.showDetailPopup = true; } }这一段逻辑很关键,因为用户在移动端的手指点击精度本身就不高,如果点击聚合点没有视觉反馈,用户会以为地图卡了。加了放大下钻后,用户每次点击都能看到地图在响应。
单体点弹窗我用的是uni-popup组件,里面展示门店名称、地址、电话,再加一个“导航”按钮。导航调用高德的路线规划,这里要注意不同端的协议差异。小程序端用wx.openLocation;H5端用https://uri.amap.com/navigation拼接参数;App端可以用uni.openLocation。
5.2 大数据量的懒分批处理
数据量特别大的时候,即使有聚合算法,一次性把数据塞进前端也够呛。我遇到过后台返回5万条记录,光JSON解析就花了2秒多,直接卡住首屏。
后来我改成“按视野范围请求”的策略:
- 首次进入地图,只请求当前视口范围内的数据接口,后端根据
bounds参数(左下角和右上角经纬度)做筛选。 - 地图拖动结束后,判断新的视口是否超出了已请求的数据范围,如果是,再增量请求新范围内的数据。
- 已经请求过的区域做缓存,下次拖回去不重复请求。
这个方案对后端友好,前端也不会一次性接太多数据。配合聚合点使用,理论上无论数据总量多大,单次渲染的点位都能控制在几百以内。
对应uniapp代码上,就是监听regionchange的end回调,然后比较可视范围和当前数据范围。
onRegionChange(e) { if (e.type === "end") { const mapCtx = uni.createMapContext("storeMap", this); mapCtx.getRegion({ success: (res) => { // res包含西南角和东北角经纬度 const northeast = res.northeast; const southwest = res.southwest; // 判断当前数据是否覆盖该范围,若否则加载新数据 if (!this.isRangeLoaded(northeast, southwest)) { this.loadStoresInRange(northeast, southwest); } }, }); } }5.3 我踩过的一些细节坑
写到最后,把我在uniapp地图聚合项目里踩过的几个印象最深的坑列出来,希望能帮读者省点排查时间:
坑一:ios端聚合点图标不居中。在App端使用label显示聚合数字时,anchorX和anchorY的偏移量在各端表现不一致。解决方法是给聚合点用一张包含数字的整图,直接用iconPath渲染,不要依赖label定位。
坑二:和cover-view冲突。在小程序端,如果聚合点是原生的marker,上面叠加的弹窗、按钮必须用cover-view才不会被原生层盖住。但cover-view层级和map原生组件的遮挡关系在iOS上偶尔会抽风,表现是弹窗点不到。稳妥做法是聚合点的弹窗直接用map的callout,或者干脆做成分离的全屏半透明遮罩,放在map组件外层,通过v-if控制显示。
坑三:聚合点数量变化时,marker图标闪烁。这个主要是uni-app的markers是响应式数据,每次重新赋值都会全量重新渲染。我实际测试后,把更新方式从“直接this.markers = newMarkers”改成了“先清空再赋值仍然会闪”,最后只有一种办法彻底解决——只在data里保存聚合所需的原始点数据,聚合结果用this.$set更新具体字段,而不是整体替换数组。如果聚合点数量少(<100),整体替换问题不大;聚合点多了,闪烁问题会很明显,需要做diff更新。
这里我再给一个不算技巧的小技巧:一切地图聚合方案都建议加个降级开关。当用户手机性能太差或者数据量过大时,允许用户手动关闭聚合、查看全量marker列表,用列表的方式浏览点位。聚合是体验优化,不是业务功能本身,别让它成为卡顿的另一个源头。
我从这个项目里最大的体会是:地图聚合的本质不是某个API的调用,而是“用计算换渲染”。官方聚合帮你做了计算,省心但样式受限;自己写算法灵活可控,但要处理边界和性能细节。把地图的数据流、渲染流分开想清楚,聚合点怎么做都不会跑偏。希望这篇博文能帮你少走点弯路。