ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

百度地图自定义点标记与文本标注:选型、实现及海量性能优化

百度地图自定义点标记与文本标注:选型、实现及海量性能优化 1. 百度地图标记体系全拆解与选型思路第一次在页面上把百度地图跑起来、往上面扔一个红色气泡绝大多数人都会觉得这不就几行代码的事。等真正进入项目需求变成每个门店要用自己的logo当图标标注旁边要浮一段文字说明同屏要放下三千个点位还不能卡就会发现光往地图上贴东西这件事百度地图就给了你好几套不同的工具选错了后期改起来能把人折磨到怀疑人生。这篇内容就是把我自己在做门店地图、设备分布图、物流轨迹这几类项目里关于百度地图自定义点标记和文本标注的踩坑经验完整捋一遍从选型、实现到性能优化和跨端接入尽量让刚上手的人少走弯路也希望对已经做过一轮的开发者有点新启发。先明确一下讨论范围百度地图 JS API 里能往地图上贴东西的核心对象有这么几类——点标记 Marker、文本标注 Label、信息窗口 InfoWindow、矢量图形Polygon/Polyline/Circle、自定义覆盖物 Overlay以及用来处理海量点的点聚合 MarkerClusterer。这几样东西看起来功能差不多实际定位差别很大用错场景轻则代码臃肿重则性能直接崩盘。下面逐一拆开来说。1.1 点标记、文本标注、覆盖物到底该怎么选很多人一上来就用 Marker 解决所有问题包括那些其实应该用 Label 的地方。Marker 的本质是带图标的地理点位它适合表达这里有一个实体——一个门店、一个设备、一个用户位置。Label 的本质是挂在某个坐标上的一段文字它不带图标适合做地名说明、数值展示、状态文字。而自定义覆盖物 Overlay 则是最灵活的一层你可以往里面塞任意 HTML 和 CSS做卡片、做图片轮播、做带动画的自定义 UI 都行。我整理了一张对照表方便快速判断该用哪个类型核心用途能否放HTML性能表现适用数据量级Marker 点标记表达具体点位否只能换图标/矢量符号中等几十到几百Label 文本标注展示文字说明否纯文本/富文本有限较好几百InfoWindow 信息窗口点击后的详情气泡是同时只建议开一个单点触发自定义 Overlay完全自由的样式是较差十几个以内MarkerClusterer 点聚合海量点位聚合展示否优秀上千到上万判断逻辑很简单如果需求是这个点长什么样优先 Marker如果需求是这个点旁边要显示什么字用 Label 或者 Marker 自带的 setLabel如果需求是点开之后有一堆内容要展示用 InfoWindow只有当以上都满足不了你的样式需求时才上自定义 Overlay。我见过一个反面案例有人为了给每个门店做带阴影、带圆角、带文字的卡片标记用了自定义 Overlay结果八十个点在地图上滑一下就掉帧。后来改成 Marker 自定义图标 Label 组合同样的视觉效果性能直接回来了。Overlay 的代价就是你放弃了地图对它的批量优化能力每个覆盖物都是独立的 DOM 节点数量一多浏览器就扛不住。1.2 坐标系与初始化那些不写进文档但会让你踩坑的前置条件在动手写标记之前有几个前置条件必须先搞定不然后面全是坑。第一是坐标系。百度地图用的是自己的一套坐标BD09而我们从 GPS 设备、第三方接口拿到的通常是 WGS84从某些地图开放平台拿到的是 GCJ02。如果不做转换直接扔上去点位会整体偏移几百米。转换靠百度提供的坐标转换服务或者用现成的转换库本地算。我的习惯是数据入库时就统一转成 BD09 存起来前端只管渲染不在前端做转换一是省性能二是避免到处转导致混乱。第二是初始化时的容器尺寸。地图容器如果初始高度是 0比如藏在 tab 里、弹窗里初始化出来的地图会是一片空白或者只显示一角。解决办法是等容器可见之后再调用map.centerAndZoom()或者在显示后手动触发一次map.resize()。这个坑我在做后台管理系统的时候踩过无数次特别是那种默认隐藏、点击才展开的侧边栏地图。第三是 AK 申请和域名白名单。百度地图的密钥分浏览器端和服务端前端用的 AK 要配置 Referer 白名单否则本地能跑、上线就报错。开发阶段可以用*临时放开上线前一定要收紧。初始化地图的典型代码如下注释里标了几个关键点// 异步加载完 API 后callback 里再初始化 function initMap() { const map new BMap.Map(container, { enableMapClick: false // 关闭地图底图点击避免和标记点击冲突 }); // 中心点和缩放级别建议先用默认值占位拿到真实数据后再 setViewport const point new BMap.Point(116.404, 39.915); map.centerAndZoom(point, 12); map.enableScrollWheelZoom(true); // 按需开启滚轮缩放 // 开启惯性拖拽体验更顺滑 map.enableInertialDragging(); return map; }注意enableMapClick: false这个配置很关键。默认情况下点击地图空白处会触发地图的点击事件如果你给地图绑了点击添加标记的逻辑再配上标记自身的点击事件很容易出现点标记结果又加了一个标记的尴尬情况建议手动关掉。另外提一句版本选择老项目大量用的是 BMapJS API v2/v3新项目可以考虑 BMapGLGL 版GL 版在渲染海量数据和 3D 效果上有优势API 命名空间从BMap变成BMapGL但常用对象基本一一对应。选哪个取决于你的项目是否已经深度绑定旧版新起项目我个人倾向 GL 版。2. 自定义点标记的完整实现路径选型定了之后重头戏就是怎么把点标记做得好看、好用。百度地图的 Marker 提供了从最省事到最自由的几个层级我按实现在项目里的出场频率从高到低排一下换图标 用矢量符号 自定义覆盖物。2.1 从默认图标到自定义图标Marker 的定制层级最基础的用法就是扔一个默认水滴图标上去const marker new BMap.Marker(new BMap.Point(116.404, 39.915)); map.addOverlay(marker);但实际项目里几乎没人用默认图标因为每个点位要表达的东西都不一样。换图标靠的是BMap.Icon三个参数图片地址、尺寸、可选的偏移和锚点配置。const myIcon new BMap.Icon(marker.png, new BMap.Size(32, 32), { // 图片中每个图标的实际区域雪碧图场景用 imageSize: new BMap.Size(32, 32), // 锚点默认是图标中心想让底部尖角对准坐标就往下移 anchor: new BMap.Size(16, 32) }); const marker new BMap.Marker(point, { icon: myIcon });这里有个高频踩坑点——锚点anchor。默认锚点是图标正中心如果你的图标是水滴形、气球形底部有个尖角用来指位置那必须把 anchor 设成(宽/2, 高)否则点位会整体偏上。我第一次做门店地图时没注意视觉上点位全飘到了图标中间跟实际位置差了半个图标高度被产品追着改了半天。如果你的图标是纯色图形还可以用矢量符号 Symbol来画不用切图改颜色改形状都是代码层面的事const symbol new BMap.Symbol(BMap_Symbol_SHAPE_CIRCLE, { scale: 2, // 放大倍数 fillColor: #409eff, // 填充色 fillOpacity: 0.9, strokeColor: #fff, strokeWeight: 2 }); const marker new BMap.Marker(point, { icon: symbol });矢量符号的好处是不用管理图片资源动态改颜色特别方便。比如设备在线是绿色、离线是灰色用 Symbol 直接改fillColor就行用图标则要准备两套图来回切。缺点是形状有限复杂图形还是得切图。2.2 用自定义覆盖物实现完全自由的标记样式当需求超出图标和符号的能力范围比如标记要带文字、带状态小圆点、带旋转动画就得上自定义覆盖物了。核心是继承BMap.Overlay实现initialize和draw两个方法。function CustomMarker(point, html) { this._point point; this._html html; } CustomMarker.prototype new BMap.Overlay(); CustomMarker.prototype.initialize function (map) { this._map map; const div document.createElement(div); div.style.position absolute; div.style.zIndex 100; div.innerHTML this._html; // 这里可以塞任意 HTML // 绑定事件、加动画都在这层做 div.onclick () { /* ... */ }; map.getPanes().markerPane.appendChild(div); this._div div; return div; }; CustomMarker.prototype.draw function () { const position this._map.pointToOverlayPixel(this._point); this._div.style.left position.x - this._div.offsetWidth / 2 px; this._div.style.top position.y - this._div.offsetHeight px; };draw方法在每次地图状态变化拖动、缩放时都会被调用所以里面的定位计算要尽量轻。pointToOverlayPixel负责把经纬度转成屏幕像素再根据自定义内容的实际宽高做偏移校正这一步是保证覆盖物钉在地理坐标上的关键。实操心得自定义覆盖物里的 DOM 越简单越好。我做过一个带渐变、阴影、伪元素、内部小图标的卡片标记二十个点就明显感觉到拖动不如原生 Marker 跟手。后来把阴影改成一张预渲染的 PNG 背景图性能立刻好了一截。能用 CSS 解决的别用多层嵌套 DOM能用图片的别用实时渲染。还有一个容易忽略的点自定义覆盖物不会自动响应地图的清除操作map.clearOverlays()是清不掉通过getPanes()手动挂上去的节点的。要么自己维护一个数组统一管理要么老老实实用标准 Overlay 的方式注册进地图。这也是我建议能用 Marker 就别自己造轮子的原因——省心。顺带说下文本标注和 Marker 的结合玩法。如果只是想在图标旁边挂一段字根本不用自定义覆盖物Marker 自带setLabelconst label new BMap.Label(望京SOHO, { offset: new BMap.Size(24, 4) // 相对图标的位置微调 }); label.setStyle({ color: #333, fontSize: 12px, border: none, background: transparent, padding: 2px 4px }); marker.setLabel(label);注意setStyle会整体覆盖已有样式不是合并。如果你先设了字体又想改颜色得把之前的样式一起带上否则字体设置就丢了。这个默认覆盖的行为坑过不少人包括我在内。3. 文本标注与信息窗口的实操细节Label 和 InfoWindow 看似简单实际藏着不少细节。Label 是常年挂着的文字InfoWindow 是点击后才弹出来的气泡两者的使用场景完全不同但经常被混用。3.1 Label 文本标注的定位、偏移与样式控制Label 独立使用的时候需要一个坐标点const label new BMap.Label(设备A正常运行, { position: new BMap.Point(116.404, 39.915), offset: new BMap.Size(0, -20) // 相对坐标点往上抬 20px }); map.addOverlay(label);offset是 Label 最容易让人困惑的参数。它的原点是坐标点对应的像素位置然后按你给的 Size 做平移。想让文字显示在点位上方offset 的 y 给负值想显示在右侧x 给正值。由于 Label 的文本框有内边距实际显示位置往往和你想的差几个像素建议先大致设一个值然后在浏览器里肉眼微调别指望着一次算准。Label 的样式控制靠setStyle支持大部分 CSS 属性但是属性名要用驼峰fontSize而不是font-size。这一点和写普通 CSS 不一样复制样式过来的时候要手动改。另外 Label 默认自带一个白底灰边的样式不想要的话得手动清掉label.setStyle({ border: 0, background: none, color: #333 });经验提醒Label 的渲染方式是地图自己画的不是普通 DOM所以复杂样式比如圆角、阴影、渐变支持有限。真要做花哨的文字气泡还是老实走 InfoWindow 或自定义覆盖物。关于 Label 的显示策略还有个实用技巧当地图缩小到一定级别时如果 Label 太多会糊成一片建议监听zoomend事件根据当前 zoom 值批量控制 Label 的显示隐藏。比如 zoom 小于 13 时只显示重要标注大于等于 13 才全部显示。这个逻辑在门店地图里特别有用能让不同缩放级别下的地图都保持清爽。map.addEventListener(zoomend, () { const zoom map.getZoom(); labelList.forEach(item { // 自己给每个 label 预设一个 importance 等级 if (zoom item.minZoom) { map.addOverlay(item.label); } else { map.removeOverlay(item.label); } }); });3.2 信息窗口 InfoWindow 的内容组织与交互陷阱InfoWindow 是点击标记后弹出的详情气泡用起来很顺手const infoWindow new BMap.InfoWindow(div门店详情.../div, { width: 240, height: 120, offset: new BMap.Size(0, -20) }); marker.addEventListener(click, () { map.openInfoWindow(infoWindow, marker.getPosition()); });它支持传入 HTML 字符串所以内容可以做得相当丰富。但有三个坑一定要知道。第一InfoWindow 会响应内容里的链接和按钮点击。如果你在 HTML 里放了a或者button事件默认能正常触发但要注意别让点击穿透到地图上必要时在内容容器上加stopPropagation。第二同时打开多个 InfoWindow 需要自己管理。地图本身不会自动关闭上一个如果每个标记点击都openInfoWindow屏幕上会堆一排气泡。正确做法是全局维护一个 InfoWindow 实例点击新标记时先closeInfoWindow再打开或者复用同一个实例只改内容// 复用同一个信息窗口点击不同标记只换位置和内容 marker.addEventListener(click, () { map.openInfoWindow(infoWindow, marker.getPosition()); });第三InfoWindow 里的异步内容是坑。如果详情数据要点击时才去请求接口而 InfoWindow 已经用固定高度打开了内容加载完可能会撑破容器或者被裁掉。我的做法是先打开一个带 loading 的默认尺寸窗口数据回来后再setContent更新同时更新高度。InfoWindow 提供setContent方法可以动态替换内容。关于文本内容的展示还有一个我踩过的坑HTML 内容里的特殊字符要转义。门店名称里如果有或者引号直接拼进字符串会导致结构错乱甚至 XSS 风险。养成习惯所有动态内容过一遍转义函数再塞进 InfoWindow。4. 同图层大量数据标记的性能优化当标记数量从几十涨到几千性能问题就藏不住了。这时候靠单个 Marker 硬堆是行不通的必须换思路。这也是很多做过地图项目的人最头疼的一环——同一图层大量数据标记效果。4.1 点聚合 MarkerClusterer 的使用与参数调优点聚合的核心思想是距离近的点合并成一个带数字的大圆放大到一定级别再拆开。百度提供了 MarkerClusterer 库需要单独引入MarkerClusterer_min.js之类的文件。基本用法是先把所有点准备好然后交给聚合器// 先构造所有 marker const markers points.map(p new BMap.Marker(p)); // 交给聚合器管理 const clusterer new BMapLib.MarkerClusterer(map, { markers: markers, minClusterSize: 2, // 少于几个点不聚合 gridSize: 60, // 聚合的网格大小值越大聚合越猛 maxZoom: 18, // 超过这个级别不再聚合 isAverageCenter: true // 聚合中心用平均坐标 });几个参数直接影响观感。gridSize是聚合半径调大了几个相邻的街区会被合成一个圈调小了圈特别碎。我一般在 40 到 80 之间试具体看点位密度。minClusterSize是聚合成团的最小点数设成 2 意味着两个点靠得近也会合并设成 3 则两个点保持独立。maxZoom控制展开的时机别设太小否则放大地图还是密密麻麻的圈用户会以为点丢了。聚合器还支持自定义聚合的样式默认是个蓝色圆圈带数字想改的话可以覆盖它的渲染逻辑或者退一步用styles配置数组按点数分级设置不同的圆圈大小和颜色。这个在表达热力分布的时候特别好用点越多圈越大越红一眼就能看出哪里密集。避坑提示聚合器的markers数组是一次性传入的如果你后续要动态增删点位得用addMarkers/removeMarkers/clearMarkers这些方法直接改原数组不会生效。这个和很多人直觉中的传引用不一样我当初就因为改数组没用而排查了半天。4.2 海量数据下的图层管理与渲染策略聚合不是万能的。如果需求不是聚合而是每个点都要露出来只是要快那得从另外几个角度优化。缩小可视化范围。用户真的需要看全国所有点吗很多时候按当前视野范围bound去请求数据就够了。监听moveend和zoomend用map.getBounds()拿到当前矩形区域只拉这个范围内的点。数据量瞬间从几万变成几百。map.addEventListener(moveend, () { const bounds map.getBounds(); // 把 bounds 传给后端只取视野内的点 fetchPoints(bounds.getSouthWest(), bounds.getNorthEast()); });合并渲染。如果点位是密集的小点可以用一个 Canvas 图层或者自定义 Overlay 一次性画出来而不是每个点一个 Marker。百度地图本身有限制同时渲染的 Marker 数量多到一定程度会有性能天花板。用 Canvas 手动画的话几万个点也能保持流畅代价是事件交互要自己实现监听 Canvas 点击反算最近的点。分级加载。数据分层低 zoom 级别只显示重点标记高 zoom 级别再显示全量。配合前面说的视野过滤能覆盖绝大多数场景。避免频繁的 addOverlay / removeOverlay。每次增删都涉及 DOM 操作和重新计算布局批量操作时能一次性做完就别循环调用。有个小技巧先map.disableMassClear()或者在批量操作前把地图暂停更新做完再恢复能减少中间态的重复渲染。性能优化没有一个银弹我一般按这个顺序排查数据量是不是可以按视野裁剪 → 能不能用聚合 → 能不能换 Canvas 渲染 → 单个标记本身是不是太重。大部分项目走到前两步问题就解决了真到需要 Canvas 的通常是上万点位的监控大屏场景。5. 跨端接入的差异与常见踩坑记录百度地图不止能在网页里用微信小程序、Qt 桌面应用、WordPress 站点都有接入方式但每种的坑不太一样。这一节把几个常见场景的差异和排查思路捋一捋。5.1 微信小程序、Qt、WordPress 场景下的接入差异微信小程序接入百度地图一般是通过 web-view 组件嵌网页版或者用小程序原生的地图组件再对接百度地图的服务端接口逆地理编码、路径规划等。web-view 里跑 JS API 和网页基本一致但要注意小程序的域名白名单和 web-view 的通信限制。原生地图组件则用的是另一套坐标系和 API和百度 JS API 不能混用得提前想清楚用哪条路线。Qt 桌面端接入百度地图主流做法是内嵌一个 WebEngine浏览器控件加载一个本地或远程的 HTML 页面页面里跑的还是百度地图 JS API。数据交互靠 Qt 和 JavaScript 互调。这种做法开发快但要注意 WebEngine 的版本差异导致的地图渲染问题以及如何在不联网的客户端上缓存地图切片这个通常需要额外的地图服务支持。WordPress 站点接入百度地图通常是用地图插件或者自己在主题里引入 JS API。插件的坑在于版本老旧、API 不兼容或者用了过期的密钥机制。自己写的话反而可控用短代码shortcode输出一个地图容器和初始化脚本把文章里配置的坐标、标题通过短代码属性传进来。要点是脚本加载顺序——地图初始化必须在 API 异步加载完成之后callback参数别忘了配。不管哪种端坐标系和密钥白名单这两件事是通用的雷区。小程序和桌面端从设备拿到的坐标大概率不是 BD09白名单在非浏览器环境下的判断规则也和网页不同上线前都要实测。5.2 常见问题速查表与排查思路把我在项目里遇到的高频问题整理成一张表方便对着排查。现象可能原因排查/解决思路地图一片空白容器高度为 0 或初始化时机不对确认容器有宽高显示后再初始化或 resize标记整体偏移坐标系未转换统一转成 BD09 再渲染图标位置偏上/偏下锚点未设置按图标形态设置 anchor本地正常上线报错AK 域名白名单未配置后台配置 Referer 白名单点击标记又新增了标记地图点击事件未关闭enableMapClick: false文本样式不生效setStyle 整体覆盖每次传入完整样式对象聚合不更新直接改数组未调方法用 addMarkers/removeMarkers自定义覆盖物拖动卡顿DOM 太重简化结构用图片代替多重渲染InfoWindow 内容错乱动态内容未转义拼接前做 HTML 转义独家避坑技巧善用浏览器控制台的地图实例。把map实例挂到window上window.map map调试时可以直接在控制台调用map.getOverlays()看当前有哪些覆盖物、手动addOverlay测试效果、map.getZoom()看当前级别。这比来回改代码刷新页面快得多我在调坐标和样式的时候几乎全靠它。另外提醒一句百度地图的 API 行为在不同版本之间会有细微差异尤其是事件触发的时机和顺序。遇到诡异问题时先确认一下你引入的 API 版本script标签里的v参数或 GL 版的配置然后对照该版本的官方文档。很多网上搜到的解法失效根源就是版本对不上。最后分享一个我在做设备分布图时的小技巧给每个标记加一个自定义属性存业务 ID。百度地图的 Marker 对象本身不存业务数据但你可以直接往上挂const marker new BMap.Marker(point); marker.bizId item.id; marker.addEventListener(click, (e) { console.log(e.target.bizId); // 直接拿到业务 ID });这招对聚合后的点击处理也管用聚合展开后的每个小标记都保留了原始属性点击时能直接定位到是哪条数据省去了一堆坐标反查的麻烦。做好坐标转换和分场景选型标记和标注这一块基本就稳了剩下的就是把单点性能和批量渲染这两个坎迈过去。
返回列表