图表性能优化方案梳理

背景知识

首先我们要知道前端页面上渲染引擎和JS执行引擎是在一个进程里边,分别有一个单独的线程,但这俩线程的特殊之处在于他们是互斥的,也就是说同一时间只能有其中一个线程在运行。至于为什么要这样设计,是因为JS线程是会直接修改页面上的元素的,总不能渲染线程在画元素的时候,突然JS线程把这个元素给删了吧。

然后我们也就很容易理解,如果想要页面帧率高,那么渲染线程就要一直工作,把每一帧都画出来;但如果反过来,是JS线程一直在工作,渲染线程被饿死,那页面上的画面一直都不会动,也就形成了"卡死"的现象。那又有小伙伴要问了,那如果渲染线程一直在工作,JS线程不就饿死了吗?是的,当然会饿死,但是我们其实并不需要渲染线程一直工作嘛,比如我们显示器只有60帧,那每一帧的时长就是16.7ms,渲染线程其实只需要最后的不到1ms的时间进行渲染就行,其他时间可以全留给JS线程去执行。

那我们优化的目标也就十分明确了,通过拆分JS长任务,使得单个JS任务尽可能能在一帧的时间内执行完,然后交给渲染线程去渲染,就能够保证页面的流畅性。

多图表

多图表的优化方案非常成熟。有了上面的背景知识,我们很容易想象到如果是一个页面几百张图同时去发请求,然后渲染,那么JS线程就会长时间占用,导致渲染线程一直不能执行,从而造成页面长时间卡死的现象。所以这里,我们就会有一些分治策略,来避免这种情况。

并发限制

那么最容易想到的,就是限制并发数。使用队列,拿到一个经验值作为队列的长度,限制当前任务的并发数,防止JS线程长时间阻塞渲染线程。有意思的是,这个会经常作为前端面试题出现:

kotlin 复制代码
class TaskController {
    constructor(maxConcurrent = 2) {
        this.maxConcurrent = maxConcurrent; // 最大并发数
        this.queue = [];                    // 任务队列
        this.running = 0;                   // 当前运行的任务数
        this.results = [];                  // 存储任务结果
    }

    // 添加任务到队列
    add(task) {
        return new Promise((resolve, reject) => {
            this.queue.push({
                task,
                resolve,
                reject
            });
            this.run();
        });
    }

    // 执行任务
    async run() {
        // 如果还有队列中的任务,且正在运行的任务数小于最大并发数
        while (this.queue.length && this.running < this.maxConcurrent) {
            const {task, resolve, reject} = this.queue.shift();
            this.running++;

            try {
                const result = await task();
                this.results.push(result);
                resolve(result);
            } catch (err) {
                reject(err);
            } finally {
                this.running--;
                this.run();
            }
        }
    }
}

懒加载

懒加载也是前端很常用的一个优化手段,核心思想就是只加载当前会用到的东西,用不到的就不加载,等需要用的时候再加载。在多图表的场景,我们就可以套用这个思路,只对当前可视区域内的图表进行加载,滚到哪儿就加载哪儿,可以极大程度地减小加载图表的数量,从而减少JS线程执行时间。

虚拟滚动

虚拟滚动也用到了懒加载的思想,但是在此之上加了一个逻辑。我们可以看到,懒加载只是延迟加载,如果我们滚动页面,让所有元素都加载一遍,那么此时页面上就还是有几百个图表实例的,首先是这几百个图表实例会占用大量的内存,其次是如果这些图表实例在初始化之后都响应一些事件,依然会造成长时间的JS执行,导致卡顿。所以虚拟滚动的核心思想会更进一步,从懒加载的"只加载"改成"只存在"当前可视区域内的图表,也就是说不在可视区域的图表我们可以直接销毁,也就不会再响应事件了;同时还会有一些别的优化,如果创建和销毁实例是个比较大的开销的话,我们可以对实例进行池化,重用这些实例。

但是吧但是,如无必要,勿增实体。也不能说虚拟滚动就是懒加载的上位替代,得看情况来,如果图表数量多的有限的情况下,比如服务监控,可能也就几十个,如果用虚拟滚动,反而涉及大量图表实例的创建和销毁,性能会更差,得不偿失,哪怕是复用图表实例,图表的清空、重绘也是一笔开销。所以这个东西,最好在真的遇到懒加载解决不了的性能问题时再考虑使用。

目前天问的多图表场景基本上都是采取懒加载这一策略进行了优化。

单图表单曲线多点

降采样

这里不赘述降采样的具体方案,比如什么等距降采样的avg、min、max、sum,或者LTTB啥的,也不提它们各自的优缺点,只针对降采样对单图表单曲线多点场景的优化效果。

假设我们原始数据是一秒钟一个点,看7天数据也就是 60 * 60 * 24 * 7 = 604800 个点,那不用想,直接卡死;可如果我们应用降采样策略,聚合成十分钟一个点,就只有 6 * 24 * 7 = 1008 个点,基本上无压力。这里的降采样是后端做的,降采样的数据存到单独的表里,大幅减少查询时的耗时以及传输时间,典型的用空间换时间的路子。

但是不止于此,除了后端降采样外,前端也可以在绘制的时候用上降采样。我们这样来看,单图表宽度一般在500px左右,每个点占据1px,满打满算一条曲线最多也就能绘制500个点,上边的降采样后的数据却有一千多个点,显然是多余了;这些多余的点,在图上看不出来,但却会参与计算,那我们不妨再应用一层降采样,只绘制必要的点好了,这里我们就用LTTB,尽可能保留曲线趋势。不过这里也得辩证地来看,虽然LTTB降采样让绘制的点少了,但是LTTB本身的性能开销是不小的,得权衡一下。

目前,天问是只有后端做了降采样。

单图表多曲线

首先我们可以对每条曲线应用上述提到的降采样策略,但是如果曲线过多,比如500条,那单图就有 1008 * 500 = 504000 个点,那不必想,肯定是卡的,有没有什么优化的路子呢?

限制曲线数量

曲线多,那么最容易想到的是,我让你少点呗。大部分场景下其实只关心Top或者Bottom的几条,撑死几十条,那我们大可以在产品上透出一个选择项,限制曲线条数,默认给它限制10条,他有需要自己去调整。

这个策略在天问大部分场景是靠谱的,但是有部分场景不能限制曲线数量,或者他可以手动选择不限制,就需要另外的策略了。

换成别的展示形式,如表格、热力图

那么就硬要展示几百上千条曲线呢,有没有办法呢?

。。。没有,除了一些比较通用的优化手段,能够稍微缓解一下卡顿情况,但最多也就是能做到从50条线不卡到80条线不卡,你要真有几千条线,咋优化都不顶用。

那我们换个角度想想,一个图几百上千条线真的是合理的吗?

不太合理,

这一坨东西真的能看出来什么有用的东西吗,也就看个最大值最小值吧,但也是看的总体,而不是具体到某条曲线上。我们不妨换一种展示形式,换成表格,看一些总结数据:

或者说,我们也可以用热力图,看区间内的分布情况:

通用优化

还有一些比较通用的优化,罗列一下好了:

  1. 通过Echarts series-lines.progressive 设置渐进式渲染,比较鸡肋,是针对单曲线优化的异步分片渲染,单曲线我们已经有降采样了。

  2. 通过Echarts series-lines.large 设置高性能渲染,也比较鸡肋,同样是针对单曲线的优化,缺点同上。

  3. 通过Echarts 服务端渲染,预先在服务端生成一张图片,页面加载时先展示图片,等到需要交互时再真正渲染一个图表,减少JS逻辑,可以提升首屏的加载和交互体验,但对后续多图表交互场景没有帮助,并且成本很高。

  4. 一些JS逻辑放到 requestIdleCallback 里,在CPU空闲时执行,防止阻塞渲染。

一些别的优化点

那么其实大盘场景,卡顿最重要的原因不是图表渲染,而是里边有太多太多前端的JS逻辑,阻塞了页面的渲染。这个就需要前后端一起优化,将一些比较重的逻辑看看能不能转移到后端去做,提升会非常明显。这不是说前端菜,写不好逻辑,也不是说前端懒,不愿意写这些逻辑,而是JS作为一门解释性语言,它就不是用来做重计算的,逻辑写多了就不渲染,不渲染就卡,希望前后端同学能在这一点上达成一致,然后慢慢优化。

案例分析(涉及内部产品的图片、链接、视频已移除)

好,那么就有小伙伴好奇,为什么案例分析是放在后边而不是开头?

结论先行,我先告诉你答案,你再回过头来看问题,自然就能get到问题产生的原因以及解法。

案例一 多图表

这个物理机监控页面是30+图表,典型的多图表场景,使用懒加载进行优化,整体渲染和交互非常流畅,可以看到帧率基本都在60左右,除了一开始的加载帧率稍低,后续各种操作场景也稳定在50帧以上。

案例二 单图表单曲线多点

这个大盘的图表,就是秒级数据,7天的量,如果没有降采样,会有60多w个点,但是开启LTTB降采样后,只返回了1200个点,渲染无压力,交互很流畅,也是只有刚开始帧率会低一点,后边都稳定在60帧。

案例三 单图表多曲线

这个大盘的图表,就是典型的单图表多曲线场景,单条曲线的点都不多,只有十几个,但是有120条曲线,

可以很明显地看到,除了刚开始会卡以外,后续的交互也非常卡顿,掉帧非常严重,tooltip不跟手,体验非常糟糕。

限制Top10之后会有非常大的改善

案例四 Grafana 单图表多曲线

这是Grafana上类似的单图表多曲线场景,单条曲线十几个点,但是有160条曲线,可以看到其实卡顿也比较明显了。

总结

可以看到多图表、单图表单曲线多点两种场景下天问的表现都很优异,在单图表多曲线的场景下很糟糕,但是对比竞品Grafana在同场景下的表现,其实也没太大差异,都很卡。主要原因还是在于这种场景就不应该选择折线图作为可视化,可参考Metric Graphs 101: Graphing Anti-Patterns | Datadog,目前天问上已有表格,热力图也在规划中,上线后可引导用户迁移单图表多曲线的图。另外天问之所以老让人觉得卡卡的,不流畅,其实不是因为图表加载性能差,而是因为页面上有太多不合理的JS逻辑,经常在hover图表或者滚动时去触发一些逻辑,导致渲染线程Hang住,造成卡顿。所以优化重点应该在如何对这些JS逻辑进行拆解、转移。

相关推荐
利刃之灵2 分钟前
03-HTML常见元素
前端·html
kidding7238 分钟前
gitee新的仓库,Vscode创建新的分支详细步骤
前端·gitee·在仓库创建新的分支
听风吹等浪起11 分钟前
基于html实现的课题随机点名
前端·html
leluckys17 分钟前
flutter 专题 六十三 Flutter入门与实战作者:xiangzhihong8Fluter 应用调试
前端·javascript·flutter
kidding72331 分钟前
微信小程序怎么分包步骤(包括怎么主包跳转到分包)
前端·微信小程序·前端开发·分包·wx.navigateto·subpackages
微学AI1 小时前
详细介绍:MCP(大模型上下文协议)的架构与组件,以及MCP的开发实践
前端·人工智能·深度学习·架构·llm·mcp
liangshanbo12151 小时前
CSS 包含块
前端·css
Mitchell_C1 小时前
语义化 HTML (Semantic HTML)
前端·html
倒霉男孩1 小时前
CSS文本属性
前端·css
shoa_top1 小时前
JavaScript 数组方法总结
javascript