最近刚做了个和地图相关的需求,涉及到「海量点标记 + 海量标注」。当数据量达到三千以上的时候,「海量标注」会明显拖慢页面的加载/响应速度,非常影响用户体验,因此我对其进行了优化。感觉还挺有挑战性的,在这里总结一下,关键性代码(Vue3)已开源至 GitHub:https://github.com/yingjieweb/amap-optimization-demo,如果能给遇到同样问题的你一点帮助的话,麻烦帮忙点个 star~ 😉。
🚩 如果你所做的需求仅涉及到「海量点标记」,那你不需要看这篇博客,因为高德地图的「海量点标记」已经优化的很好了,它可以为数量在万级以上的点标记提供良好的解决方案,这里有高德地图海量点标记的「官方文档」和「实例」可以参考。
🚩 本篇博客针对数据量较大时,「海量标注」会拖慢页面的加载/响应速度的问题做了分析和优化实践,其中心思想分为以下几点:
通过将 海量标注 延迟加载(懒加载)的方式,加快页面首屏渲染速度
检测 海量标注 中的数据项,判断其坐标是否在浏览器视口区域,进行分片渲染
监听地图缩放及移动事件,先删除原标注图层,再根据第二步渲染新图层
对 海量标注 中的公共部分进行提取,通过 海量点标记 的方式渲染,减少 DOM 节点数
针对点击后高亮被选中的标注,单独添加图层进行叠加,减少第三步带来的延迟
1、适用场景说明
这里大致描述一下我所做的需求,大家可以类比一下,进而判断是否是适用于你 🤔️
我的需求是这样的:用户进入页面时先加载地图,并在地图上展示一些点标记。当地图被放大到一定倍数的时候,隐藏点标记图层,展示标注图层。标注图层里的每个标注有一个小的图点标识图和气泡组成,气泡内含有一些数据信息。当用户点击某个气泡时,需要高亮当前气泡,并将其展示在最上层。
效果图大致如下:
2、地图及海量点标记
下面就从地图搭建开始,一步步实现上面的功能,然后对页面卡顿问题进行优化。因为几乎都是调用高德地图提供的 API,所以我在这里只粘贴部分代码,算是提供个思路吧 🤓️
关于初始化地图和设置「海量点标记」的主要是思路如下:
geocoder = new AMap.Geocoder({ city: cityId // 支持传入城市名、adcode 和 citycode }) geocoder.getLocation(cityName.value, function(status, result) { if (status === 'complete' && result.info === 'OK') { map = new AMap.Map('map', { resizeEnable: true, //是否监控地图容器尺寸变化 zoom: 10, //初始化地图层级 center: [result.geocodes[0].location.lng, result.geocodes[0].location.lat], //初始化地图中心点 }); // 展示地图 map setMassMarks() // 设置海量点标记 marker } })
设置「海量点标记」的方法如下:
setMassMarks() { const lowPriceIconStyle = { url: priceIconMap.low, size: new AMap.Size(30, 30), anchor: 'center' } const normalIconStyle = { url: priceIconMap.normal, size: new AMap.Size(30, 30), anchor: 'center' } const highPriceIconStyle = { url: priceIconMap.high, size: new AMap.Size(30, 30), anchor: 'center' } const styleObjectArr = [lowPriceIconStyle, normalIconStyle, highPriceIconStyle] const markerList = houseList.map(item => ({ lnglat: item.location.split(','), name: item.name, id: item.id, style: item.level - 1 })) massMarks = new AMap.MassMarks(markerList, { zIndex: 500, // 海量点图层叠加的顺序 zooms: [3, 14], // 在指定地图缩放级别范围内展示海量点图层 style: styleObjectArr // 设置样式数组 }); massMarks.setMap(map); }
上面 ⬆️ 两段代码主要是先初始化地图,然后用 cityName 通过 AMap.Geocoder (高德地图的编码转换器) 获取城市的具体坐标,从而设置地图的中心点坐标。然后通过高德的 AMap.MassMarks 方法设置「海量点标记」展示在 3-14 zoom 层级,再通过 style 参数做点标记的样式区分。
3、海量标注
页面一进入的「海量点标记」图层已经实现了,下面是设置「海量标注」:
setLabelsLayer() { labels = [] houseList.map(item => { const normalMarker = new AMap.Marker({ zooms: [14, 20], offset: new AMap.Pixel(0, -15), extData: item }); normalMarker.setContent( `<div class="amap-info-window"> <div class="amap-info-title">${item.name}</div> <div class="amap-info-price">${item.location}</div> <img class="amap-info-dot" src="https://t1...jpg"> </div>`); normalMarker.setPosition(item.location.split(',')) normalMarker.on('click', function(e){ setNormalMarkerSelected(e.target.getExtData().id) }); labels.push(normalMarker) }) map.add(labels) }
关于「海量标注」,高德地图的官方解释是:当需要在地图添加千级以上的点标记时,LabelMarker 是代替 Marker 的更好选择。不同于 MassMarks ,LabelMarker 不仅可以绘制图标,还可以为图标添加文字信息,且万级以上数据也具有较好性能,配置也更加灵活。
但是其实性能并没有那么好,当数据量达到三千级以上的时候,LabelMarker 的画面流畅程度大大缩减了,基本上是用户所不能接受的地步,因此我们需要对其进行优化。
ps:不知道是不是我打开方式有问题,有没有人使用过「海量标注」,并像官方所说的那样:万级以上数据也具有较好性能?我标记了三千多个标注的时候页面的响应速度就会超级卡,如果大家有更好的思路的话请指教 😍
4、卡顿优化实践
先说一下我们上面的思路吧:进入页面加载地图,并设置「海量点标记」和 「海量标注」。
⬆️ 这算是一个最简易版本,虽然我们已经采用了高德地图所提倡的「海量点标记」和 「海量标注」,但是无论是页面的首屏加载速度还是用户交互的响应速度都很慢,为了增强用户体验,我们需要在上面思路的基础上进行优化!
1、针对首屏加载速度的优化:其实用户一进入到页面看到的是「海量点标记」的图层,「海量标注」图层是隐藏在后面的,当用户放大地图的时候才会展示出来。因此,我们可以先把「海量标注」的加载放到后面,这样可以加快页面首屏渲染速度。
既然要把「海量标注」放在后面加载,那我们就需要监听地图的缩放事件,当地图放大到一定级别时就去加载「海量标注」图层:
setMapListener() { map.on('zoomend', () => { // 监听地图缩放结束后的等级 zoom = map.getZoom() if (zoom >= 14) { setLabelsLayer() // 设置海量标注 具体代码在上面 } }) }
把「海量标注」图层的加载时机放在后面虽然可以加快首屏的渲染速度,但是当数据量较大的时候,一次性加载超多「海量标注」的 DOM 元素会让浏览器的渲染引擎吃不消的。既然这样我们可不可以不要一次性加载那么多?先把视口区域的标注加载出来?
2、针对海量标注的分片加载优化:检测「海量标注」中的数据项,判断其坐标是否在浏览器视口区域,从而进行分片渲染:
executeConditionRender() { let screenCoordinateRange = map.getBounds() let northEast = [screenCoordinateRange.northEast.lng, screenCoordinateRange.northEast.lat] let southEast = [screenCoordinateRange.southWest.lng, screenCoordinateRange.northEast.lat] let southWest = [screenCoordinateRange.southWest.lng, screenCoordinateRange.southWest.lat] let northWest = [screenCoordinateRange.northEast.lng, screenCoordinateRange.southWest.lat] screenHouseList = houseList.filter(item => { return AMap.GeometryUtil.isPointInRing(item.location.split(','), [northEast, southEast, southWest, northWest]) }) setLabelsLayer() // 设置海量标注 具体代码在上面,用 screenHouseList 替换 houseList }
上述代码中,我们可以通过高德地图提供的 getBounds 方法,拿到当前视口中地图的东北角和西南角的坐标,进而可以算出视口的四个点坐标,然后通过 AMap.GeometryUtil.isPointInRing 方法,判断海量标注图层中的数据是否在视口范围内,进行分片加载。
除此之外我们还需要监听用户的滑动事件,当用户滑动地图之后,重新执行如上逻辑:
const setMapListener = () => { map.on('zoomend', () => { // 监听地图缩放结束后的等级 zoom = map.getZoom() if (zoom >= 14) { executeConditionRender() } }) map.on('moveend', () => { // 监听地图中心点的位置变化 if (zoom >= 14) { executeConditionRender() } }) }
我们上述的编码思想是:当用户放大或移动视口时,先判断当前 zoom 是否在海量标注展示的级别,如果是的话就加载当前视口内的标注。但是这样会产生一个问题,标注图层的数量会随着用户的操作次数的增加而堆积,这样也是会影响页面流畅程度的,因此我们需要对其进行优化。
3、针对海量标注图层堆积问题的优化:在下一次加载视口区域内的标注图层之前,我们需要先把之前的图层删除掉,这样就可以避免图层堆积的问题:
executeConditionRender() { let screenCoordinateRange = map.getBounds() let northEast = [screenCoordinateRange.northEast.lng, screenCoordinateRange.northEast.lat] let southEast = [screenCoordinateRange.southWest.lng, screenCoordinateRange.northEast.lat] let southWest = [screenCoordinateRange.southWest.lng, screenCoordinateRange.southWest.lat] let northWest = [screenCoordinateRange.northEast.lng, screenCoordinateRange.southWest.lat] screenHouseList = houseList.filter(item => { return AMap.GeometryUtil.isPointInRing(item.location.split(','), [northEast, southEast, southWest, northWest]) }) map.remove(labels) // 删除原来气泡层的海量标注 setLabelsLayer() // 设置海量标注 具体代码在上面,用 screenHouseList 替换 houseList }
到这里,我们主要的优化工作已经做的差不多了,页面已经是比较流畅的了。上面说的「中心思想」中的第四步(提取公共标识图)和第五步(高亮选中的标注)相对来说比较简单了,这里就简单说一下吧:
4、针对屏内标注 DOM 元素过多的优化:第四步(提取公共标识图)是因为每个标注都带一个小的圆点图片,每个小圆点图片都是一个 DOM 元素,当海量标注比较密集的时候,一屏内也能有成百上千个标注,也就会有成百上千个小圆点图片的 DOM 元素,这会给页面绘制速度增加负担的,这里也是可以优化一下的。
对 海量标注 中的公共部分进行提取,通过 海量点标记 的方式渲染,减少 DOM 元素,相当于是借用了高德地图的海量点标记来帮我们做的一次优化!具体代码比较简单,这里就不贴了,只是新加了一个圆点标记图层。
5、针对高亮标注数据处理延迟的优化:我们之前的第三步优化是针对海量标注图层堆积的问题处理,它其实做了两件事:先删除原来的图层,然后再遍历一下原视口中的标注点,将被点击的标注进行高亮样式处理,没被点击的标注进行重新绘制。
上述这种处理方法其实是有优化空间的,我的想法是通过单独添加高亮标注图层来节省原图层的重新计算和渲染,这样就相当于节省了第三步中删除图层和重新绘制的成本,可能不是很形象,我们画一下大致流程就很好明白了:
// 优化前: 用户点击标注 ➡️ 删除旧图层 ➡️ 重新计算(点击的标注设置高亮,普通标注重新绘制) ➡️ 页面添加新的标注图层 // 优化后: 用户点击标注 ➡️ 删除旧的高亮图层 ➡️ 重新设置高亮图层 ➡️ 页面添加新的标注图层
这样看起来直观很多,其实就是相当于节省了一部分「重新计算」的成本。
5、优化效果对比
在数据量较大的情况下(我的数据量在3500+),通过上述的五个步骤的优化之后,页面的各方面性能都有了明显的提升,下面我们做一下优化前后的性能对比,一起感受一下优化之后的丝滑吧!😏
1、首屏加载时间对比:这也是对上述「1、针对首屏加载速度的优化」的优化验证,通过将「海量标注」延迟加载的方式,大大加快了页面首屏的加载速度,对比图如下:
没有将「海量标注」做推迟加载处理时,页面首屏加载地图展示出来的时间在 12s 左右,而且还是不完全展示(只展示了海量点标记,地图没真正展示出来)。优化之后,地图和「海量点标记」完全加载出来只用了 3s,这个时间虽然不能说很快,但是 3s 的时间普通用户是完全可以接受的。优化之后,首屏的加载时间可以说提升了 4 倍!amazing!😲
2、帧率统计对比:这也是对上述「2、针对海量标注的分片加载优化」的优化验证,通过只展示屏内的标注,可以大大缩减海量标注的数量,进而在用户和页面发生交互行为时,页面的响应速度会更快、帧率更高,对比图如下:
在没做「海量标注」分片加载时, 一次性将大量的标注信息渲染出来,不仅需要很长的加载时间,而且页面上的标注节点太多,会导致用户和页面发生交互行为之后,页面的响应速度很慢,平均帧率只有不到 10 fps。优化之后,平均帧率可以接近 30 fps。要知道,人眼在 24帧/s 的情况下就会产生暂留效果,30 fps 可以说是很流畅了!wonderful!😲
3、DOM 节点数对比:这是对上述「2、针对海量标注的分片加载优化 + 3、针对海量标注图层堆积问题的优化 + 4、针对屏内标注 DOM 元素过多的优化」的优化验证,因为上述优化点都有对减少 DOM 节点直接或间接产生贡献,对比图如下:
没有做上述「2 / 3 / 4」步骤点优化时,页面的 DOM 节点数量在 40000+,由于页面的 DOM 节点较多,所以当用户和页面发生交互行为时,CPU的占用率在 90% 以上,JS 的内存体积也在 250M+,进而导致页面卡顿明显。优化之后,可以将页面上的 DOM 节点数控制在 6000 以下,此时当用户和页面交互时,CPU 的占用率在 50% 左右,JS 的内存体积在 100M 以下,因此页面交互会流畅很多!😈
4、整体性能统计对比:通过上述五个步骤的优化,页面的各方面性能都有了一个明显的提升,我们看一下整体的 Summary 看板的统计指标,对比图如下:
通过 Summary 的各项指标以及真实的使用体验可以看出,上述所做的五个优化步骤效果还是很明显的,无论是页面首屏加载速度,还是交互响应速度,以及帧率、DOM 节点数...,各方面性能都有一个明显的提升。What a wonderful world!🎉
6、写在最后
这是第一次做和地图强相关的需求,一开始以为没什么难度,就是照着高德 API 抄一抄就行了,没想到数据量较大时还是有很多可以钻研和学习的地方,真是 surprise 🤒️
做这个需求几天里,心里一直被这个事情占据着,觉都没睡好,做梦都是考试找不到考场 😭。不过最后还是搞定了,心里不由的开心和兴奋,感觉自己又进步了!
一开始遇到海量标注卡顿的问题,我先请教公司前辈,但是没人用过海量标注,进而也就没遇到过卡顿的问题。但是同事们也给了些思路,我也静下心来慢慢做了分析,也从网上找到了点优化思路,感觉各种优化的方法还是有迹可循的。
毕业工作快一年了,这个需求也算是我在无人指导的情况下独自完成的,完成之后非常有成就感的,我也把它视为一个我成长的里程碑。Keep running!
说实话,工作之后自己的时间却是少了,主动学习写博客的次数也越来越少,想想毕业时给自己定的目标「踏踏实实学点前端,以后做一名专业的前端开发工程师」,着实感觉有点惭愧,反思一下自己,还是得持续学习啊!
ps:写这篇博客时,感觉好像回到了写毕业论文的时候 😂