现状
需要在地图上通过 圆点 展示业务信息,其中圆点的出现、在线、消失都有对应的动画,当数据量过大,圆点过多时, 存在以下问题:
- 当一次性渲染大量 圆点 同时展示出现动画,导致页面十分卡顿。
- 在渲染 圆点 完成后,拖动缩放地图也十分卡顿不流畅。
现在业务数据最少也是 2000 + ,平时正常也有 5000 +,这次优化的目的最少要支持 100000 + 数据点渲染。
分析
页面卡顿不流畅,主要的问题是 浏览器在处理一些耗时操作,没有办法及时响应事件,我们需要做的是 找到这些耗时的点,想办法解决它
首先 通过代码来看一下这块的实现方式:
- 圆点是通过 Leaflet Marker 创建绘制到 地图的,圆点是一个渐变色的 svg 图标。
- 动画是通过 CSS Animation 创建的。
然后 我们一次性创建 500 点,通过 chrome 的 Performance 分析一下问题所在:
从使用的体验来看,卡顿点主要在两个地方:首次渲染数据时 和 缩放地图时,因此我们使用 Chrome Performance 着重这两个场景分析。
首次渲染数据 过程
从上图中可以看出:
- Main 主线程操作中,有较多 标红的 Task,说明在这期间 当前页面无法及时响应用户操作,参考 Long Task
- 占用主线程的耗时操作主要集中在 Scripting(JS 脚本的执行) 和 Painting(重排)两块。
进一步分析 Scripting
从上图中可以看出:
- Scripting 内占比最大的方法是
addMarkerToMap方法,该方法的功能是 在地图上添加 Marker。 addMarkerToMap耗时原因主要是由realCords(坐标转换) 和addLayer组成。
首先要分析一下占比最大的 addLayer 方法,代码中使用 L.marker 方式创建 Marker,其中 Marker 的 Icon 是通过 L.divIcon 创建的,因此 addLayer 方法最耗时部分 createIcon 的具体实现在此 :DivIcon.js#L45。createIcon 内主要是 创建 dom 节点、添加样式。
由此可见,如果有 10000+ Marker,就要创建 10000+ 的 dom 节点,这些节点的创建 不仅耗时,而且占用了大量内存。
缩放地图 过程
从图中看出主要耗时在 Rendering 这块,主要是哪些部分在 Rendering 通过 Performace 并不能很直观的看出,我们可以通过 Rendering 分析器来查看一下。
我们看到界面上每个 Marker 都有一个黄色的边框,说明每个 Marker 都在一个合成层上,这样的好处在于 修改 transform \ 透明度 等属性时,会得到硬件加速支持,并且不会引起重排。但是过多的合成层将增大内存和管理的开销。
由此可见,通过平移地图并不会影响 Marker 的位置,但是 缩放地图时 会导致重新计算 Marker 位置,并且通过 transform: translate3D(); 来改变 Marker 位置,虽然是在 合成层 上进行的,但是 海量的 Marker 依旧会导致 大量的内存开销 和 CPU 占用。
详情文档:简化绘制的复杂度、减小绘制区域 和 坚持仅合成器的属性和管理层计数
Marker Paint
通过打开控制台中 Rendering 分析器内的 Paint flashing 选项,可以看到页面内元素的 Paint 过程。
从视频中,可以看出 Marker 相关的元素 Paint 了三次(中间一次是不断Paint):
- 第一次 创建 Marker dom 节点
- 第二次 不断扩大的重排 Paint,主要是由于 水波 动画导致的。
- 第三次 水波动画 伪元素 定位导致的
由此可见,一个 Marker 的初始化将导致这 三次 Paint,如果同时出现 5000+ 甚至 10000+ Marker,仅仅第二次的水波动画 都无法正常展示。
结论
综上所述,导致页面操作卡顿甚至假死、动画不流畅的 主要原因如下:
- 水波动画 导致元素不断重排,在 Marker 数量过多时 动画效果大打折扣。
- 通过 L.Marker 需要创建大量的 Dom 节点,并且每个都是单独的合成层 增加了内存开销。
解决
解决 水波动画 Paint
针对上述分析两个问题,我们先来看一下 动画的问题。
首先代码中水波动画的实现代码如下:
@keyframes wave {
0% {
left: 0;
top: 0;
opacity: 0.3;
width: 12px;
height: 12px;
}
100% {
left: -30px;
top: -30px;
opacity: 0;
width: 70px;
height: 70px;
}
}
.icon-show:after {
content: ' ';
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
animation: wave 2s ease-out forwards;
}
从代码中看出 wave 的效果 是通过改变伪类的 width 和 height 实现的,显而易见,width 和 height 的改变会导致 Paint,因此最好通过 transform 来实现。
优化后的代码如下:
@keyframes wave {
0% {
opacity: 0.3;
}
100% {
opacity: 0;
transform: scale(5);
}
}
.icon-show:after {
left: 0;
top: 0;
content: ' ';
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
animation: wave 2s ease-out forwards;
}
最后再来看一下 Paint 的次数
从视频中看出,水波动画过程中 不会再导致 Paint 了,动画效果也流畅了许多。
解决 Dom 节点过多
其实 leaflet 中通过 L.Marker 创建的 Marker 无法避免的会创建 div,尤其是在 Marker 越来越多的情况下 就开销就会越来越大,性能自然越来越差。
leaflet 也提供了 canvas 方式进行绘制,可以使用 L.circleMarker 的方式创建 圆点,等于所有的 Marker 都创建到了一个 canvas 上,这样在每次初始化或者缩放地图时,只会 Paint canvas 节点,大大提高性能。
由于很多时候业务要求,circlemarker 并不能满足样式,因此可以 继承重新 leaflet 内 Render 类方法来实现,有些复杂的渲染,也可以自定义 Render 实现。
这里 只用让 circlemarker 支持绘制 渐变颜色即可,因此我们走入 leaflet 源码,通过一系列的追踪(leaflet 源码看起来还是相对轻松的,每个 class 都很清新),定位到 画圆的方法在这里 Canvas _updateCircle,因此重写该方法即可,代码如下:
export const CustomCanvas = L.Canvas.extend({
/**
* 重写 绘制圆形 方法
*/
_updateCircle(layer) {
// 源码部分 start
if (!this._drawing || layer._empty()) { return }
const p = layer._point
const ctx = this._ctx
const r = Math.max(Math.round(layer._radius), 1)
const s = (Math.max(Math.round(layer._radiusY), 1) || r) / r
if (s !== 1) {
ctx.save()
ctx.scale(1, s)
}
ctx.beginPath()
ctx.arc(p.x, p.y / s, r, 0, Math.PI * 2, false)
if (s !== 1) {
ctx.restore()
}
this._fillStroke(ctx, layer)
// 源码部分 end
// 增加渐变颜色圆配置
const { fillGradient } = layer.options
if (fillGradient) {
const g = ctx.createLinearGradient(
p.x + fillGradient.position[0],
p.y / s + fillGradient.position[1],
p.x + fillGradient.position[2],
p.y / s + fillGradient.position[3]
)
fillGradient.colorStops.forEach((agrs) => {
g.addColorStop(...agrs)
})
ctx.fillStyle = g
ctx.fill()
}
}
})
export function DroneMarkerCanvas(options) {
return new CustomCanvas(options)
}
我们尽量不要改动 源码中的实现,尽量扩展方法的功能,因此我在这里仅增加了 fillGradient 属性,等于在 L.CircleMarker Options 内增加了一个 fillGradient 配置。
使用时代码如下:
import { DroneMarkerCanvas } from './leafletExt/DroneMarkerCanvas'
// leaflet 自定义 Marker render 类
const droneMarkerRender = DroneMarkerCanvas({ padding: 0.5 })
L.circleMarker([50.5, 30.5], {
fill: false,
stroke: false,
// 自定义 渐变颜色配置
fillGradient: {
// 渐变的起始位置
position: [-3.5, -3.5, 3.5, 3.5],
// 切分阶段
colorStops: [
[0, DRONE_GRADIENT_COLOR[type][0]],
[1, DRONE_GRADIENT_COLOR[type][1]]
]
},
radius: 7,
renderer: droneMarkerRender
}).addTo(map)
因为是通过 canvas 绘制,整体的响应速度很快,承载 100000+ 的 Marker 也不是问题。
但是由于使用了 canvas 绘制,之前那种通过 css3 实现的动画将无法使用,只能通过 canvas 来实现。
总结
- 通过 css3 实现动画是,尽量使用 transform 来实现动画效果 而不是使用会导致元素 paint 的 width、height、top、left 等。
- 减少 Dom 元素,正常页面的DOM元素数量一般不应该超过1000,DOM元素过多会使DOM元素查询效率,样式表匹配效率降低,是页面性能最主要的瓶颈之一。
- 通过 canvas 实现需要大量绘制的操作,在 leaflet 地图中有效学习使用 Leaflet Plugins ,能极高的提升 地图可视化功能和性能。
推荐一个挺不错的 leaflet canvas 绘制相关扩展,通过学习源码 会很快上手 Leaflet Plugins,github.com/linghuam/Le…
TODO
- canvas 动画