上个月我自己的一个门店分布页被打回重做,原因是两百多个 Marker 一梭子全打在地图上,鼠标滚轮一滚,整页直接掉帧到个位数,地图缩放卡成幻灯片。后来换成点聚合加上事件回调,两千多个点位在同一屏里缩放拖动都很跟手。这件事之后我把高德 JS API 2.0 的点聚合(MarkerCluster)和点标记(Marker)事件这套组合反复折腾了好几遍,踩过的坑基本都记下来了。这篇就把这套东西从选型、参数调优、事件绑定到性能兜底完整捋一遍,适合正在做地图点位展示的前端同学,也适合刚接触 2.0 版本、还在纠结旧版 MarkerClusterer 怎么迁移的人。
1. 需求拆解:这套组合到底在解决什么问题
1.1 从一次卡顿的地图页说起
地图上撒点这件事,最开始总是很朴素:拿到一个坐标数组,循环new AMap.Marker(),然后map.add()一挂,页面就成了。点位在几十个的时候完全没问题,上百个开始有感觉,上千个基本就是灾难。原因很直接,2.0 里的 Marker 默认是 DOM 结构,每个点都是一个真实的 HTML 节点挂在地图容器里,浏览器要同时维护几百上千个绝对定位元素的层级、变换、事件监听,缩放时还要逐个重算屏幕坐标,性能瓶颈根本不在 JS 逻辑,而在渲染层。
点聚合的思路是反过来的:不追求把每个点都画出来,而是先按当前缩放级别把距离近的点归成一簇,只渲染一个代表节点,节点上标一个数量。用户放大地图时聚合自动拆开,缩小地图时又自动合并。这样任何时刻真正存在于 DOM 里的标记数量都控制在一个很小的量级,性能自然就稳了。这个逻辑不复杂,但要把它和"点击某个点弹出详情"这类交互结合好,麻烦的地方在于:聚合之后点对象的生命周期变了,你不能再用自己手里的那份marker引用去绑事件了。
1.2 2.0 的 MarkerCluster 和旧版的差别在哪
1.4 时代大家用的是AMap.MarkerClusterer,2.0 换成了AMap.MarkerCluster,名字少了个 er,用法和参数结构都变了。最明显的差异是构造方式:新版是new AMap.MarkerCluster(map, data, options),直接把地图、数据、配置一次性传进去,插件内部会根据数据自己创建 Marker;旧版是先new MarkerClusterer(map, markers, opts),你得自己把 Marker 数组造好再交给它。这个变化看着只是写法不同,实际上决定了事件绑定的位置——旧版 marker 是你自己 new 出来的,事件可以在传进去之前就绑好;新版 marker 是插件内部生成的,你只能在renderMarker回调里去拿到它、绑事件。
另一个变化是数据更新。旧版常用clearMarkers()加addMarkers()的组合,新版直接setData(data),内部会做一次整体重算。这个改法更干净,但也带来一个新问题:setData之后所有 marker 都是重新渲染的,之前绑在旧 marker 上的事件自然就没了,所以事件绑定必须放在会重复执行的回调里,而不是放在初始化那一次。
1.3 什么情况下不该用点聚合
点聚合不是万能钥匙,我在项目里至少遇到过三种情况它不合适。第一种是点位数量很少,比如十几个、二三十个,这个量级下聚合不但没收益,反而会让用户困惑——明明就几个点,非要合并成一坨数字,体验是负的,直接用普通 Marker 就好。第二种是点位需要精确表达地理位置,比如配送员的实时位置、车辆的当前位置,这类点合并了就没意义了,用户要看的就是那一个具体坐标。第三种是点位之间有强业务关联,比如一条线路上的所有站点,合并起来会破坏线路上升序阅读的逻辑,这种更适合用自定义覆盖物或者线路绘制来处理。
我的经验分界线大概是:点位数量在一百以内,直接 Marker 加图标尺寸控制;一百到三千,用 MarkerCluster 最合适;超过三万,MarkerCluster 的渲染压力也开始显现,得考虑用 Canvas 图层或者数据抽稀了,这部分后面第 5 节会展开说。
2. 环境准备:把地图和聚合插件跑起来
2.1 Loader 加载与插件声明
2.0 版本推荐用官方 Loader 异步加载,而不是在 HTML 里挂 script 标签。Loader 的好处是插件按需声明,不用把整个 SDK 全量拉下来。这一段代码几乎是所有项目的起点:
import AMapLoader from '@amap/amap-jsapi-loader'; AMapLoader.load({ key: '你的Key', version: '2.0', plugins: ['AMap.MarkerCluster', 'AMap.InfoWindow', 'AMap.Icon'] }).then((AMap) => { initMap(AMap); }).catch((e) => { console.error('地图资源加载失败', e); });这里最容易出错的地方是plugins数组。AMap.MarkerCluster不是核心包自带的,不写进 plugins 里,控制台会报一个很直接的错:AMap.MarkerCluster is not a constructor。InfoWindow 和 Icon 这两个虽然很多时候能在核心包里拿到,但显式声明一遍不吃亏,尤其是你在做按需拆包的时候,少一个声明就是运行时白屏。
提示:Loader 的
load()返回的是 Promise,同一个 key 和 plugin 组合重复调用时会命中内部缓存,不会重复请求。所以不用担心在多个组件里各调一次会浪费流量,但要注意别在不同地方传了不同的 version 或者 plugins,那样缓存就走不上了。
2.2 数据结构为什么必须是 lnglat
MarkerCluster 接收的数据不是 Marker 数组,而是普通对象数组,而且每个对象必须有一个名为lnglat的属性,值是[经度, 纬度]的数组。这个字段名是硬约定,你写成lng/lat、longitude/latitude、position都不行,插件内部靠它来计算聚合网格。数据示例:
const points = [ { lnglat: [116.397428, 39.90923], id: 1001, name: '一号门店', level: 'A' }, { lnglat: [116.407428, 39.91923], id: 1002, name: '二号门店', level: 'B' } ];除了lnglat,其他字段你想带什么就带什么,它们会被原样保留,在renderMarker回调里通过context.data拿到。这一点很关键,因为实际业务里每个点往往要带一堆字段,比如门店 ID、营业状态、库存、负责人,全都塞进lnglat之外的自定义属性里就行,不用另起一个 Map 去做 id 到数据的映射。
清洗数据这一步别偷懒。接口返回的经纬度经常是字符串、null、undefined,甚至出现过[0, 0]这种默认值。带非法经纬度的点传进聚合里,要么直接被丢弃导致数量对不上,要么被算到某个奇怪的位置,聚合出来的中心点飘到地图外面。我现在的固定动作是先过一遍过滤:
const validPoints = rawList.filter(item => { const pos = item && item.lnglat; return Array.isArray(pos) && pos.length === 2 && Number.isFinite(pos[0]) && Number.isFinite(pos[1]) && Math.abs(pos[0]) <= 180 && Math.abs(pos[1]) <= 90; });2.3 最小可运行骨架
把地图和聚合串起来,能跑起来的最小版本大概是这样,我把它当作所有项目的模板:
function initMap(AMap) { const map = new AMap.Map('map-container', { zoom: 11, center: [116.397428, 39.90923], viewMode: '2D', resizeEnable: true }); const cluster = new AMap.MarkerCluster(map, points, { gridSize: 60, maxZoom: 15, minClusterSize: 2, renderClusterMarker: (context) => { const count = context.count; const size = count > 200 ? 56 : count > 50 ? 46 : 36; context.marker.setContent(` <div class="cluster-dot" style="width:${size}px;height:${size}px;"> <span>${count}</span> </div> `); context.marker.setOffset(new AMap.Pixel(-size / 2, -size / 2)); }, renderMarker: (context) => { const marker = context.marker; marker.setAnchor('bottom-center'); marker.on('click', (e) => { e.originEvent && e.originEvent.stopPropagation(); showDetail(context.data); }); } }); map.on('click', () => closeDetail()); }这段代码里有两处细节值得单独说。setOffset那一行不是可选项,自定义 content 的聚合点如果不设偏移,默认是左上角对齐坐标点,视觉上整个圆会偏右下,越放大越明显。renderMarker里绑事件时加了stopPropagation,是为了阻断点击标记同时触发地图空白点击的问题,第 4 节会详细讲。至于setAnchor('bottom-center'),是因为默认图标是个水滴形,锚点在底部尖角上,换成自定义图片后如果没改锚点,标记会整体往上飘一截。
3. 聚合参数调优:gridSize、maxZoom 与渲染函数
3.1 三个核心参数的实际效果
聚合插件暴露的参数不少,但真正决定视觉效果的只有三个:gridSize、maxZoom、minClusterSize。这三个值我调过很多遍,总结下来它们的实际影响是这样的。
gridSize是聚合计算用的网格边长,单位是像素,默认值 60。它的含义是:地图在屏幕上按 60 像素见方的格子切分,落在同一个格子里的点就被聚成一簇。所以这个值调大,聚合更激进,簇更少更大;调小,聚合更保守,簇更多更碎。我一般按照图标尺寸来反推,如果单个点的图标是 28 像素宽,那gridSize至少要给到 40 以上,不然两个图标会视觉重叠却不聚合,看起来像布局错乱。做门店分布这类密集场景,我常用 60 到 80;做设备点位这种稀疏场景,45 到 55 更合适。
maxZoom是聚合的终止层级,超过这个缩放级别就不再聚合,所有点全部展开。默认值比较大(官方文档里是 18 左右),意味着你要放到很大才会看到单个点。这个值我一般设成 15 到 17 之间,因为大多数业务场景用户放到 16 级已经能看清街区了,再往下放大意义不大,反而每次缩放都要重算聚合,白费性能。
minClusterSize是最小聚合数量,小于这个数量的点不聚合。默认 2,也就是说两个点挨着也会变成一个小小的数字 2。这个体验其实不好,用户点了半天以为有内容,展开一看就两个点。我通常设成 3 或者 5,让聚合点只代表"确实密集"的区域,视觉噪声少很多。
| 参数 | 默认值 | 我的常用取值 | 调大的效果 | 调小的效果 |
|---|---|---|---|---|
| gridSize | 60 | 45 ~ 80 | 簇更少更大,聚合更激进 | 簇更多更碎,聚合更保守 |
| maxZoom | 18 | 15 ~ 17 | 更晚才展开单点 | 更早展开单点 |
| minClusterSize | 2 | 3 ~ 5 | 少量聚集不合并 | 两个点也合并 |
这三个参数没有标准答案,必须结合你的数据密度和图标尺寸实调。我习惯的做法是先按经验值配一版,然后在地图上找一个人口最密的区域,把gridSize逐次加减 10,看哪个值下簇的大小和数量看起来最舒服,定下来之后再去看稀疏区域有没有出现"两个点叠在一起"的情况。
3.2 renderClusterMarker 定制聚合点样式
renderClusterMarker是聚合点的渲染钩子,每次聚合结果变化都会对每个簇调用一次。回调参数里通常包含count(该簇的点数量)、marker(这个簇对应的 Marker 实例)、markers(簇内所有成员的原始数据)这几个字段,具体字段名不同小版本可能略有差异,我在新项目里第一件事就是把它console.log打出来看一眼,别直接抄旧代码。
定制样式的核心是marker.setContent(htmlString)。这里可以传 HTML 字符串,也可以传 DOM 元素。传字符串的好处是写起来快,坏处是每次都会重新解析一遍,簇数量多的时候有性能开销;传 DOM 元素的好处是可以复用、可以提前把事件绑好,坏处是你得自己管理生命周期。我的选择是:簇数量在几百以内用字符串,几百以上用 DOM 复用。
样式上有个细节很容易翻车:setContent之后一定要跟一个setOffset,把内容居中到坐标点上。原因是自定义内容的定位原点默认在左上角,而聚合点应该是以坐标为中心向外扩散的圆形。偏移量的算法很简单,宽度和高度各取负的一半:
const size = 40; context.marker.setContent(`<div class="cluster" style="width:${size}px;height:${size}px;line-height:${size}px;">${count}</div>`); context.marker.setOffset(new AMap.Pixel(-size / 2, -size / 2));还有一个进阶玩法是按数量分级变色。我做门店项目时用的是三档:10 个以下浅色,10 到 100 中等,100 以上深色加白字。分级的好处是用户扫一眼地图就能看出哪片区域密度高,不用逐个点开。但档位别超过四档,太多颜色反而分不清主次。
3.3 renderMarker 定制单点样式与初始状态
renderMarker是单个点的渲染钩子,参数里能拿到marker和它对应的原始数据data。这个回调很关键,因为它决定了三件事:单点长什么样、点击时干什么、初始状态是什么样。
先说样式。单点样式有两种改法,一种是用marker.setIcon(new AMap.Icon({...}))换成图片图标,一种是用marker.setContent(html)换成自定义 DOM。我倾向图片图标,因为 Icon 走的是图片资源,浏览器渲染成本比 DOM 低,几百个点同时展开时差距明显。但如果要在点上加角标、加状态点、加动画,那就只能上 Content,取舍看你更在意性能还是表现力。
renderMarker: (context) => { const { marker, data } = context; const icon = new AMap.Icon({ image: data.level === 'A' ? iconA : iconB, size: new AMap.Size(28, 34), imageSize: new AMap.Size(28, 34) }); marker.setIcon(icon); marker.setAnchor('bottom-center'); marker.setzIndex(data.level === 'A' ? 100 : 10); }setzIndex这一行值得单独提。当地图上同时展开的点比较多、图标又比较大的时候,图标会互相压盖,压盖顺序按 marker 添加顺序来,默认情况下后加的盖住先加的。如果你的业务里"重要点位"必须显示在最上层,就得手动设 zIndex,把重要级别的值调高。这个参数是我踩过坑之后才记住的——之前有个项目里重点门店老是被普通门店盖住半边,排查了半天以为是图标尺寸问题,最后发现是层级没设。
3.4 setData 更新数据的正确姿势
数据变更时的正确做法是cluster.setData(newData),而不是销毁重建。销毁重建(cluster.setMap(null)再 new 一个)虽然也能工作,但会带来两个问题:一是地图会闪一下,视觉上不平滑;二是旧的实例如果没彻底释放,DOM 节点会残留,反复几次之后内存就上去了。
setData的行为是整体替换,内部会重新做聚合计算并重新调用所有渲染钩子。这意味着两件事你要接受:所有 marker 都是新的,之前绑在旧 marker 上的引用全部失效;所有自定义状态(比如某个点的"已选中"高亮)都会丢,需要在 renderMarker 里根据数据本身的字段重新设置。所以我在设计数据结构的时候会刻意把 UI 状态也放进数据里,比如selected: true,这样重绘之后状态能自动恢复,不用再手动找回来。
// 更新数据并保持选中态 const next = rawList.map(item => ({ ...item, lnglat: [item.lng, item.lat], selected: item.id === currentSelectedId })); cluster.setData(next);还有一个容易被忽略的点:setData传入空数组时,插件会清空所有点,这是正常行为,不用特殊处理。但如果你传的是undefined或者null,不同版本表现不一致,有的会报错有的会静默忽略,所以调用前加一层if (!Array.isArray(next)) return;是稳妥的。
4. 点标记事件:绑定位置决定成败
4.1 为什么事件要绑在 renderMarker 里
这是整篇文章里最关键的一条:用 MarkerCluster 的时候,marker.on('click', ...)必须写在renderMarker回调内部,不能写在外面。原因前面提过但值得再强调一次——marker 不是你创建的,是插件根据数据自动生成的,你在初始化的时候手里根本没有 marker 引用。而且每次setData、每次聚合重算、每次renderMarker被重新调用,marker 都可能是新实例,绑在旧实例上的监听器随实例一起被回收,你在外面绑的那一次只能覆盖到"某一个历史时刻"的 marker。
绑定位置对了之后还要解决重复绑定。renderMarker会被多次调用,如果简单粗暴地每次都marker.on('click', handler),虽然对同一个 marker 实例重复绑同一个函数通常不会导致重复触发(事件系统内部一般会去重),但如果你传的是匿名箭头函数,每次都是新引用,那就真的会绑多个。我的稳妥写法是:
renderMarker: (context) => { const marker = context.marker; marker.off('click'); marker.on('click', (e) => { e.originEvent && e.originEvent.stopPropagation(); openInfo(context.data, marker); }); }先off再on,比任何标记位都可靠,也不会因为逻辑分支漏掉清理。
4.2 聚合点点击事件的两种拿法
点击聚合出来的那坨数字,业务上一般有两种诉求:一种是"展开我",也就是把地图放大到能把这簇拆开的级别;另一种是"给我看这簇里都有什么",弹一个列表出来。
展开这个诉求最简单,聚合插件默认就带点击放大的行为,只要把zoomOnClick保持开启(默认就是开的),点一下簇,地图会自动setZoomAndCenter到合适层级。如果你不想要这个行为,比如想自己控制展开动画,把它设成false,然后自己监听:
cluster.on('click', (event) => { const clusterData = event.clusterData || {}; const markers = clusterData.markers || []; const center = clusterData.center; if (markers.length && center) { map.setZoomAndCenter(Math.min(map.getZoom() + 2, 17), center); } });第二种诉求就需要在cluster.on('click')里拿到簇内成员。回调参数里通常有clusterData,里面带count、markers、center、bounds这些字段,markers就是簇内所有点的原始数据数组。有了这个数组,弹一个底部抽屉、或者一个侧边列表,把这一片区的点位列出来让用户选,逻辑就通了。我做过的一个版本是点击簇之后弹一个卡片,卡片里按距离排序列出前五个点,超出部分显示"还有 N 个",点具体某项就map.setZoomAndCenter(17, 该点坐标)并高亮,用起来很顺。
注意:
clusterData的字段名在不同小版本里改过,我在两个项目里遇到过markers和points两种写法。所以新项目第一件事一定是console.log(JSON.stringify(event.clusterData))打一遍,别照着记忆写。
4.3 事件冒泡:点击标记时地图 click 也触发了
这个坑几乎每个人都会踩一次:你给 marker 绑了 click 弹窗,又给 map 绑了 click 关闭弹窗,结果点击标记的一瞬间,弹窗刚打开就被关掉了,看起来像"根本没响应"。
根因是 DOM 事件冒泡。Marker 在地图容器内部是子节点,点击它会一路冒泡到地图容器,地图组件的 click 监听器自然就被触发了。解决办法有两个,选一个就行。
第一个是在 marker 的点击处理里阻断冒泡:
marker.on('click', (e) => { if (e.originEvent && e.originEvent.stopPropagation) { e.originEvent.stopPropagation(); } openInfo(context.data); });第二个是在 map 的点击处理里判断事件源,只处理"点在地图上"的情况:
map.on('click', (e) => { if (e.target !== map) return; closeInfo(); });我个人更推荐第二种,因为它的语义更准确——我要处理的是"用户点了空白地图",而不是"所有冒泡上来的点击"。第一种在自定义覆盖物里偶尔会遇到originEvent拿不到的情况,兼容性稍微差一点。
4.4 InfoWindow 的定位、复用与关闭
信息窗这块的坑集中在定位偏移上。默认的 InfoWindow 尖角朝下,锚点在底部,如果不设offset,窗口会直接盖住标记图标。常规做法是设一个负的纵向偏移,把窗口往上推:
const infoWindow = new AMap.InfoWindow({ isCustom: false, offset: new AMap.Pixel(0, -36), anchor: 'bottom-center' }); infoWindow.open(map, [lng, lat]);那个 -36 不是固定值,取决于你的图标高度。图标高 34 像素,偏移就给 -36 左右,留 2 像素空隙看起来最舒服。偏移给小了窗口会压住图标,给大了中间会空一块,视觉上很别扭。我在项目里会把这个值做成常量,和图标尺寸放在一起维护,换图标的时候顺手改。
复用这件事也值得说。我见过不少代码每次点击都new AMap.InfoWindow(),点一百次就造一百个实例,而且旧的没关,叠在 DOM 里越积越多,时间长了页面明显变卡。正确做法是全局只维护一个实例,点击时先setContent再open,或者先close再open:
function openInfo(data, position) { if (!infoWindow) { infoWindow = new AMap.InfoWindow({ offset: new AMap.Pixel(0, -36) }); } infoWindow.setContent(renderCardHtml(data)); infoWindow.open(map, position); }5. 性能:从两千到两万点的分层策略
5.1 数据量分档与对应的技术方案
数据量决定了你该用什么方案,这一条我吃过亏之后才形成肌肉记忆。我的经验分档是这样的:
| 点位数量 | 推荐方案 | 关键动作 |
|---|---|---|
| 0 ~ 100 | 普通 Marker | 控制图标尺寸,复用 Icon 实例 |
| 100 ~ 3000 | MarkerCluster | 调好 gridSize 与 maxZoom |
| 3000 ~ 20000 | MarkerCluster + 数据抽稀 | 低层级只渲染重点数据 |
| 20000 以上 | Canvas 图层 / 专业可视化图层 | 放弃 DOM 标记 |
两千到三千这个区间是 MarkerCluster 最舒服的区间,只要参数调好了,缩放拖动基本感觉不到卡顿。三千往上就开始需要辅助手段了,最有效的一招是"分级渲染":在地图 zoom 小于某个阈值时,只把重要级别的数据传给聚合,普通数据先不传;等用户放大到街区级别,再把全量数据setData进去。用户在缩小看全景的时候本来也看不清每个点,少渲一些完全不影响体验,反而响应更快。
抽稀策略要慎用随机采样,因为随机会导致用户每次缩放看到的点位不一样,体验很怪。我的做法是按业务权重排序后取 top N,权重用门店等级、数据完整度、访问热度都行,保证每次拿到的都是同一批重点数据,结果稳定。
5.2 图标与 DOM 的复用技巧
renderMarker里不要在每次回调都new AMap.Icon()。同一个样式重复创建 Icon 实例,会产生大量重复的图片解码和内存占用。我的做法是在地图初始化之前就把需要用到的几种图标全部创建好,放进一个对象里按 key 取:
const icons = { A: new AMap.Icon({ image: urlA, size: new AMap.Size(28, 34), imageSize: new AMap.Size(28, 34) }), B: new AMap.Icon({ image: urlB, size: new AMap.Size(28, 34), imageSize: new AMap.Size(28, 34) }) }; renderMarker: (context) => { context.marker.setIcon(icons[context.data.level] || icons.B); }聚合点的 DOM 也是同理。用字符串模板的话,每次setContent都会触发一次 HTML 解析;簇数量上千的时候这个开销不小。改成 DOM 复用后,只需要在创建时改一下宽高和文字,性能差别肉眼可见。我用过的写法是维护一个 DOM 池,用不到的节点从地图上摘下来放回池子里,下次直接用,避免频繁的创建和销毁。
const pool = []; function getClusterDom() { return pool.pop() || document.createElement('div'); } function releaseClusterDom(node) { node.className = 'cluster'; node.style.display = 'none'; pool.push(node); }5.3 内存泄漏排查
地图页面最常见的泄漏来源是三处:没解绑的事件、没销毁的聚合实例、没清理的定时器。单页应用里切走再切回来,如果每次重建地图都没清理干净,内存会一轮一轮往上涨,用户用久了就明显卡。
判断有没有泄漏,最简单的办法是打开浏览器开发者工具的内存面板,拍两次快照做对比,看 AMap 相关的对象数量是不是随路由切换持续增长。如果增长,基本可以确定是地图实例或者聚合实例没销毁。清理的顺序是固定的:
function destroyMap() { if (infoWindow) { infoWindow.close(); infoWindow = null; } if (cluster) { cluster.setData([]); cluster.setMap(null); cluster = null; } if (map) { map.clearMap(); map.destroy(); map = null; } }顺序不能反,先清聚合再销毁地图,否则插件内部可能还在持有地图的引用。map.destroy()这一步一定要调,它在框架里对应的就是onUnmounted或者useEffect的清理函数,漏了这一步,前面所有清理都白做。
6. 常见问题速查与调试手法
6.1 症状对照表
把我在项目里实际遇到过的问题整理成表,遇到类似症状可以直接对照:
| 症状 | 大概率原因 | 处理方式 |
|---|---|---|
| 控制台报 MarkerCluster is not a constructor | plugins 里没声明 | 加上'AMap.MarkerCluster' |
| 聚合点整体偏右下 | 没设 setOffset | 按内容尺寸取负一半 |
| 聚合数量比数据总数少 | 数据里有非法经纬度 | 入库前过滤清洗 |
| 点击标记弹窗闪一下就消失 | map click 冒泡 | 判断e.target !== map |
| 点了标记没任何反应 | 事件绑在了初始化阶段 | 挪进 renderMarker |
| setData 后选中态丢了 | 重绘重建 marker | 状态存进数据字段 |
| 缩放时明显掉帧 | 单点用 DOM Content 渲染 | 改用 Icon 图片 |
| 图标互相压盖重要点被挡住 | zIndex 没设 | 按等级setzIndex |
| 信息窗盖住图标 | offset 太小 | 按图标高度取负值 |
| 切换路由后内存持续上涨 | 地图实例没 destroy | 补map.destroy() |
6.2 几个实用的调试动作
第一个动作,把renderClusterMarker和renderMarker的第一行都加上console.count('renderMarker'),跑一次缩放,看控制台这个数字涨了多少。如果一次缩放就涨了几千,说明你的数据量已经超过当前参数能承受的范围,该调 gridSize 或者抽稀了。
第二个动作,把clusterData完整打出来。聚合点击拿不到成员数据,九成是字段名对不上,打一次就清楚。
第三个动作,给聚合点加上临时边框。在自定义内容里写border: 2px solid red,你能直观看到每个簇的实际覆盖范围,判断 gridSize 是不是设大了——如果红色圆圈的边缘大量重叠,说明聚合还不够激进。
第四个动作,用map.getZoom()在缩放事件里打日志,对照 maxZoom 看什么时候开始展开单点,是否符合预期。
我现在的习惯是把这四个动作写成一段调试代码,用 URL 参数控制开关,上线前删掉开关对应的分支就行,比事后一个个加日志快得多。
7. 我个人在项目里的几个固定习惯
做了几个地图项目之后,有几件事变成了肌肉记忆。第一件是数据结构永远统一:无论接口返回什么,进聚合之前一定映射成带lnglat的标准结构,UI 状态也一并放进去,这样重绘自动恢复,不用额外维护状态表。第二件是图标和 InfoWindow 全部预先创建、全局单例,渲染回调里只做取值和赋值,不做任何 new 操作。第三件是事件处理一律"先 off 再 on",宁可多写一行,也不赌事件系统会不会去重。
还有一个小技巧分享给经常调参数的人:把gridSize、maxZoom、minClusterSize三个值做成页面上的控制面板,拖滑块实时setData重绘,比改代码刷新看效果快十倍。我带过的同学第一次见这个都觉得很值,调十分钟就能找到最舒服的组合,比反复猜快多了。这套面板不用上线,本地开发环境挂上去就行,纯调试工具,成本很低。