很多人提到前端性能优化的时候,大多都会对算法方面进行了有意的忽略,认为计算这种工作就应该交给后端去处理,我们只需把数据展示出来就可以了。但是有没有想过,如果有一天后端表示数据量太大了,而且数据需要频繁更新,这时候该怎么办?
根据惯例先说一下需求背景。在项目中,我们引用了一个叫做热力图的组件,这个组件需要用到的数据有每个点在图片上的位置,以及各个点固定半径R范围内的点个数。这样热力图就可以根据各个点范围内的数据量多少来决定这个区域的颜色深浅程度,从而判断用户对这个图片所感兴趣的区域。后端收集返回的数据只有用户所点击的位置,因此各个点范围内的点个数还得由前端去计算组装。
问题发现以及临时解决方案
就在某天下班后,刚刚回到宿舍的我就收到了大群里产品怒问:为毛A页面一打开就卡死了,这东西能用?不给我一个说法有你好果汁吃的?
怀着陈杂五味的心情,我打开了他发来的链接。一顿操作下来发现原来卡死的原因主要在于这个热力图计算的过程,当数据量达到5000个的时候计算大概需要6-7s左右。
根据平常所学的前端八股文知识,我们可以把这个计算交给webWorker去做,这样就不会导致UI线程卡死了。
于是乎,二话不说。把webWorker给安排上,同时加上一个loading表示热力图数据正在加载中,待webWorker把计算结果返回后再把数据给渲染出来。完美,打开页面没有卡顿现象了,代码提交上线。
计算耗时分析
页面是不卡了,但是这尼玛7-8s(为什么时间还多了,这个后面我再做解释)也太久了吧。所以究竟是发生什么事了?
第二天早上,我再次打开了热力图组件,看了一遍它整个的计算过程。好家伙,一看代码发现这个计算用了经典的forEach双循环,通过各个之间交叉计算去得到各个点范围内的点个数
const SPOT_DIAMETER = 12
spotList.forEach(prevSpot => {
spotList.forEach(nextSpot => {
const centerDistance = Math.hypot(
+prevSpot.x - +nextSpot.x,
+prevSpot.y - +nextSpot.y
)
if (centerDistance < SPOT_DIAMETER && prevSpot !== nextSpot) {
prevSpot.num++
}
})
})
作为刷过leetcode的人(其实只会看题解),我肯定是看不惯这种事的,先来个简单的改造吧。如果我知道A点与B点距离小于R,那么我就可以知道B点与A点距离也是小于R,也就是说计算量可以减少一半,因此可以得到以下改进
for (let i = 0; i < spotList.length - 1; i++) {
for (let j = i + 1; j < spotList.length; j++) {
const prevSpot = spotList[i]
const nextSpot = spotList[j]
const centerDistance = Math.hypot(
+prevSpot.x - +nextSpot.x,
+prevSpot.y - +nextSpot.y
)
if (centerDistance < SPOT_DIAMETER && prevSpot !== nextSpot) {
prevSpot.num++
nextSpot.num++
}
}
}
经过简单的改进,可以发现当前计算的时间一下子就从7 - 8s减少到了3 - 4s。似乎还是太久了,有没有更好的改进方案呢,有!
更好的解决方案
假设一下,我们把这个图片当做一个二维表格,我知道这个表格内各个位置数据的个数以及数据在表格上的位置。那么我们只需要把这个点半径范围内的位置上的数据个数给加起来就可以得知每个点范围内的数据总数了。根据这个原理,可以得到计算的流程大概如下。
一、遍历一遍原始数据,记录二维数组上各个点的个数
二、再遍历一遍数据,收集半径范围内点的个数
比如下图红圈中心的1只需要遍历圈内数组的个数然后相加就可以得到点数为6了
const SPOT_DIAMETER = 12
// 圆数组,用于判断各个数据是否在圆上
const circleList = (() => {
const arr = []
for (let i = 0; i < SPOT_DIAMETER * 2 + 1; i++) {
for (let j = 0; j < SPOT_DIAMETER * 2 + 1; j++) {
if (Math.hypot(SPOT_DIAMETER - i, SPOT_DIAMETER - j) <= SPOT_DIAMETER) {
arr[i] = arr[i] || (arr[i] = [])
arr[i][j] = 1
}
}
}
return arr
})()
// 记录二维数组上各个位置数据的个数
const spotMap = spotList.reduce((map, spot) => {
const [i, j] = [Math.round(spot.x), Math.round(spot.y)]
map[i] = map[i] || (map[i] = [])
map[i][j] = (map[i][j] || 0) + 1
return map
}, [])
// 再遍历一遍可以得知各个点周围的数据个数
spotList.forEach((spot, sIdx) => {
const [posX, posY] = [Math.round(spot.x), Math.round(spot.y)]
// 圆数组起点位置X,Y
const [cX, cY] = [posX - SPOT_DIAMETER, posY - SPOT_DIAMETER]
const [startX, endX] = [Math.max(posX - SPOT_DIAMETER, 0), posX + SPOT_DIAMETER]
const [startY, endY] = [Math.max(posY - SPOT_DIAMETER, 0), posY + SPOT_DIAMETER]
let num = 0
for (let i = startX; i < endX; i++) {
for (let j = startY; j < endY; j++) {
const [circleI, circleJ] = [i - cX, j - cY] // 相减可得对应的圆数组的位置
if (circleList[circleI]?.[circleJ] && spotMap[i]?.[j]) { // 判断是否在圆数组上并且当前位置上点个数是否大于0
num += spotMap[i][j]
}
}
}
spot.num = num
})
已知项目中所用到的半径范围R是12,在5000个数据的情况下,我们可以得到分别在代码一和代码三的计算次数如下
代码一:5000 * 5000
代码三:5000 * 25 * 25 (ps:R + 1 + R)
好家伙,8倍之多(代码三还通过map减少了Math.hypot的调用,实际优化的时间更多),而且这个差距还会随着数据量增大而差距越来越大,因为前者是O(n^2),而后者是O(n)。
优化结果得到5000条数据的情况下只需要不到1s。
假设spotList数据如下
const spotList = Array(6000).fill(0).map(() => ({
x: Math.random() * 100,
y: Math.random() * 100,
num: 0
}))
// 通过console.time我们可以得到以下数据
// 代码一:2.5s
// 代码三:73ms
这时候还需要用webWorker吗
webWorker单独启动一个线程会消耗额外的内存,并且它还会传输过程也会存在一定的时间花费,这就是为什么用webWorker后反而计算时间要更久了。最终我把时Worker去掉后发现计算时长只需要 0.3s左右,同时还节省了部分内存,可喜可贺。
可扩展的解决方案
当数据超过5000个或者小于625个的时候我们还可以采用不同的策略(较多时结合webWorker,较少时不要二维数组直接计算),但是鉴于目前的数据都在于5000个左右,所以从代码的可维护性角度来说,暂时就不对这两种情况做特殊处理了。