在打车、共享单车、门店查询等业务场景中,地图上的标记点经常成百上千,如果不做处理直接铺开,不仅视觉杂乱,点击目标也很难找准。点聚合就是为解决这个问题而生的:地图缩放级别较小时,将附近的点合并成一个带数字的聚合点;用户点击或放大地图后,聚合点再展开成独立的标记点。微信小程序的map组件本身不支持点聚合,官方也没有提供聚合展开的动画能力,所以大部分实现要么用第三方SDK,要么自己写一套聚合逻辑。本文重点讲后者,并且把展开过程的动画做细——让子标记点从聚合点位置带着缩放和渐显效果依次浮现出来,而不是生硬地一下子全部出现。

一、整体实现思路:markers数组驱动的伪动画
小程序的map组件没有提供单个marker的动画API,marker只能通过markers属性整体更新。但这也意味着动画的核心手段其实很朴素:高频更新markers数组,每次更新时微调每个子标记点的位置偏移和透明度,多帧连续播放就形成了动画。
具体来说,需要准备三组数据:一是聚合点的位置(展开的起点),二是子标记点的真实经纬度(展开的终点),三是一组插值中间态。点击聚合点时,记录动画起始时间,然后通过setData定时更新每个子标记点从起点到终点的插值位置,同时把alpha从0逐渐调到1,width和height从小图标尺寸放大到正常尺寸。透明度和尺寸缩放这两个属性恰好是markers项的原生支持字段,不需要借助cover-view去模拟。
经纬度插值有一个需要留意的点:短距离内经纬度可以按线性插值处理,误差可以忽略。如果聚合点与子点距离较大(跨越多个城市级别),线性插值会出现路线穿过建筑之类的观感问题,但点聚合展开一般发生在几百米到几公里范围,线性插值完全够用。
二、核心代码:插值函数与动画驱动
先封装一个缓动函数和插值计算,让动画有先快后慢的节奏感。这里用easeOutCubic,效果比较自然:
// 缓动函数:easeOutCubic
function easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3);
}
// 经纬度线性插值
function lerp(start, end, t) {
return start + (end - start) * t;
}
// 计算某一帧子标记点的状态
// cluster: 聚合点 {latitude, longitude}
// item: 子标记点真实坐标
// progress: 0~1 的动画进度
function calcFrameState(cluster, item, progress) {
const eased = easeOutCubic(progress);
return {
latitude: lerp(cluster.latitude, item.latitude, eased),
longitude: lerp(cluster.longitude, item.longitude, eased),
alpha: eased, // 渐显:透明度 0 到 1
width: Math.round(24 + 16 * eased), // 缩放:24px 放大到 40px
height: Math.round(32 + 21 * eased) // 高度 32px 放大到 53px
};
}接下来是动画驱动部分。小程序没有requestAnimationFrame,常用的替代方案是用setInterval或setTimeout递归。帧率控制在每秒25帧到30帧就够了,间隔约33毫秒,既流畅又不至于让setData压力过大:
Page({
data: {
latitude: 39.90,
longitude: 116.40,
scale: 12,
markers: []
},
expandCluster(e) {
const clusterId = e.detail.markerId;
const cluster = this.findClusterById(clusterId);
const children = cluster.children; // 该聚合包含的子点
const DURATION = 400; // 动画总时长 400ms
const FRAME = 33; // 每帧间隔 33ms
const totalFrames = Math.round(DURATION / FRAME);
let frame = 0;
// 先生成子标记点,初始状态:位于聚合点中心、完全透明、小尺寸
const newMarkers = children.map((item, idx) => ({
id: item.id,
iconPath: item.iconPath,
latitude: cluster.latitude,
longitude: cluster.longitude,
alpha: 0,
width: 24,
height: 32,
callout: item.callout
}));
// 移除聚合点,加入子点
this.setData({ markers: this.data.markers.filter(m => m.id !== clusterId).concat(newMarkers) });
// 停掉上一次未完成的动画
if (this._expandTimer) clearInterval(this._expandTimer);
this._expandTimer = setInterval(() => {
frame++;
const progress = Math.min(frame / totalFrames, 1);
this.setData({
markers: this.data.markers.map(m => {
const item = children.find(c => c.id === m.id);
if (!item) return m; // 非本次展开的点不动
const state = calcFrameState(cluster, item, progress);
return Object.assign({}, m, state);
})
});
if (progress >= 1) clearInterval(this._expandTimer);
}, FRAME);
}
});这段代码的关键点有两个。第一,初始帧先让所有子点以透明状态出现在聚合点中心,这样用户点击后立即有反馈,不会出现空白等待;第二,动画过程中只更新本次展开的那批marker,其他标记点保持引用不变,可以减少diff开销。
三、错开节奏:依次弹出的细节处理
所有子点同时展开虽然也能看,但如果给每个子点加一点延迟,让它们依次弹出,层次感会明显增强。实现方式很简单:给每个子点计算progress时减去一个基于索引的延迟:
// stagger: 每个子点的延迟间隔,取总时长的 15% 左右
function calcStaggerProgress(index, total, progress) {
const stagger = 0.15;
const span = 1 - stagger;
const offset = (index / Math.max(total, 1)) * stagger;
return Math.max(0, Math.min(1, (progress - offset) / span));
}把calcFrameState里的progress换成calcStaggerProgress计算出的值即可。这样第一个子点立即开始动画,最后一个子点略微延后,整体像水波一样扩散开。子点数量特别多时(比如超过20个),建议把延迟比例调小或者干脆不用错开,否则最后几个点会出现明显的等待感。
收起动画则是反着来:把progress从1递减到0,动画结束后再从markers中移除子点、恢复聚合点。注意收起时要在动画的最后一帧才做移除操作,提前移除会出现子点突然消失的跳变。
四、性能优化与常见问题
动画卡顿的原因绝大多数出在setData上。markers整体更新的成本与标记点总数成正比,当地图上同时存在几百个marker时,每帧33毫秒的间隔可能被渲染耗时吃掉,表现为动画掉帧甚至地图整体卡死。几个有效的优化手段:
- 限制参与动画的marker数量:单个聚合点展开的子点建议控制在20个以内,超过时可以只展示部分并提示还有更多。
- 用数据路径更新:与其整体setData markers,不如用
this.setData({ ['markers[' + i + '].alpha']: v })这种路径写法,只更新变化字段,减少序列化开销。 - 降帧处理:低端机上把帧间隔放宽到50毫秒(20帧),动画时长适当延长,观感依然可以接受。
- 动画期间暂停聚合计算:地图缩放触发的重新聚合并不会感知到动画进行中,如果动画播放期间用户缩放地图,需要先结束动画再做聚合,否则markers会被聚合计算结果覆盖,出现闪跳。
另外有一个容易踩的坑:alpha字段在部分安卓机型上对自定义图标支持不完整,如果发现渐显无效,可以改用cover-view配合CSS的opacity动画来做子标记点,用markers的label或自定义覆盖物承载视觉,坐标仍由marker驱动。这是兼容性与实现成本的权衡,建议先在真机上验证alpha的表现再决定方案。
最后,聚合算法本身推荐在数据加载完成时基于网格法或距离法预先算好,点击展开只消费计算结果,不要在点击回调里临时遍历全量坐标,否则数据量大时点击响应会明显延迟。整套方案在子点规模50以内、聚合点10个左右的常见业务场景下,动画帧率可以稳定在25帧以上,体验流畅。