引言
此文章通过 gantt (甘特) 图性能问题展开详细剖析,从 0 到 1 完整解决性能问题方案,帮助各位小伙伴打开分析性能问题视角。
gantt 常用于项目管理与资源排期场景,其在我们公司产品内也有多处应用 ,具体功能表现为:
左侧可以新建任务,进行资源排期,估时等,右侧可以进行日历查看,拖拽,关联等。
多数客户对于 gantt 图的使用也有较高的频率,目前也有一批客户反馈 gantt 图性能不佳,所以 gantt 的性能也是我们需要关注和追求的。
以下是对 gantt 模块进行方案整理和优化过程中的经验,将记录下来一起分享。
词语说明
以下是后续文章词语的解释说明
dhtmlx-gantt —— 是国外 dhtmlx 公司研制的甘特图基础库,只对个人项目开源,商用是要经过付费才被允许
ones-ai/gantt —— 基于 dhtmlx-gantt 库封装的业务甘特图库,直接提供给业务使用
gantt ——甘特图
item ——甘特图中某一个任务,即一行内容
link ——甘特图右侧任务的关系连线
devtool performance —— 浏览器 F12 开发者工具 performance 性能分析工具
概要
我们在项目中使用的 gantt 图库为 dhtmlx-gantt , 版本为 v6.3,最新版本为 v7.1 (截至2022年12月12日)
在我们项目中前端使用的是 React 框架,在 dhtmlx-gantt v6 版本中是基于原生 js 实现的能力,是不支持 React 的能力的,比如 React 虚拟 dom、对比props按需更新等等。
所以我们基于 dhtmlx-gantt 二次封装了 ones-ai/gantt 供业务使用:
ones-ai/gantt 二次封装所做的事概括为:
- 提供一个 React 容器组件
- 容器组件 接受的 props 参数,经过一些转化成 gantt 需要的配置参数
- 用配置参数初始化 dhtmlx-gantt 的 gantt 实例
- 将 gantt 实例挂在容器组件 render 函数的 div 上
- 渲染逻辑完全由 gantt 内部接管
-
通过 ref 转发 gantt 的 api 给外部调用
使用 gantt 业务场景举例
- 项目管理-项目计划
在项目计划中,用户在甘特图中增删改计划与任务、以及定义里程碑等
如何发现问题
比如在使用项目计划 gantt 过程中,对其 item 进行增删改后,响应都比较慢。
先是针对不同的操作都进行了 devtool performance 的性能录制(此为浏览器原生工具的教程,具体教程可以看: performance 教程
我们从一个问题例子出发,分析 gantt 操作带来的性能消耗在什么地方。
比如我们针对一个 item 对其进行 “修改计划日期” ,将“11月16日“修改为 “11月 18日“,进行性能录制分析。
此操作 performance 录制内容如下,这个过程大致分为 5 步:
- 鼠标触发点击事件,选中新的日期
- 日期选择组件关闭、销毁
- 调用 update 接口,告诉服务器更新此 item 日期
- 更新成功后调用 gantt data 接口,获取最新的 gantt 数据
- 根据 gantt data,重新绘制 gantt 图,页面得到最终态
整个 “修改计划日期”过程,持续了 2-3s 才得到反馈,对于用户来说是比较不能接受的。
我们基于上述的初步分析之后,我们基本上断定有 3 大类问题:
- 前端有渲染长任务(火焰图紫色部分)、以及 js 长任务(火焰图黄色部分)
- 后端接口请求响应慢
- 更新成功后,交付没有及时写回反馈,让用户等到最后一步才能感知反馈
经过上述初步分析已经有大体的优化方向,我们再结合具体火焰图以及具体代码,进一步分析问题即可。
另外如果你在编写 React 程序, 你也可以使用 React 官方的开发者工具 —— Profiler
Profiler 工具也是是类似于 devtool performance 采用录制的方式
可以通过此工具查看 你的 React 组件编写的性能这么样、有没有重复渲染、是什么导致组件重复 render 等等
我想你们在编写业务的时候,应该也想知道自己的代码性能表现如何,那么也可以参照上述分析思路来分析性能,这里就不再详细展开讲了。
另外后端接口性能部分,也有接口压测、sql 慢日志,以及火焰图分析工具 pprof 等手段,有兴趣小伙伴自己去看吧。
存在哪些问题
上述我们讲述了怎么分析问题的过程,由于问题比较多,我们在这一章节具体展开说问题都有哪些,然后怎么优化。
问题1 gantt 每次都是全量更新
我们发现每次修改 gantt 后重新获取 gantt 数据, 当 gantt data 发生变化时,gantt 将执行一次整体绘制。
虽然 gantt 图库内置实现了 Virtual List 虚拟滚动相关功能,但是重新绘制还是要消耗不少性能,比如在 Windows 低配机器上,渲染要在1s左右。
我们知道,不管是 React 还是 Vue 框架,因为有 虚拟 Dom 的存在,即便是数据发生变化了,能做到不需要更新的 Dom 元素可以不更新。
而我们使用的 gantt v6 版本,是原生 js 实现并不具备这种能力。
(在 gantt v7 版本的 提供了基于 React 编写的版本,但是升级需要钱,且 api 也有一些不兼容的问题,所以升级看来并不是一件易事)
所以我们接下来应该探讨,怎么让 gantt v6 也有按需更新能力。
以前的更新 gantt 步骤为:
修改 gantt 成功 -> 查询整个 gantt data -> refresh gantt data -> gantt 重新绘制 -> 展示修改后最终效果
我们这时候有个疑问:
“我们修改 gantt 一个 item 成功后, 只更新那个 item 不就好了?最开始为什么不这么设计呢?”
带着疑问,我们查阅 gantt 库相关文档 ,其实存在内置按需更新 gantt 的 api :
updateItem(更新指定id的item)、deleteItem(删除指定id的item)、addItem (添加item)。
你可以指明要更改哪个 item,可以达到按需修改的效果。
但是,我们发现在修改 gantt 某一个 item 时,会存在其他关联 item 也会跟着变更的场景(比如你修改子 item 的进度,那么父 item 进度也会变更)
那我们问题转换为:
“我们修改 gantt 一个 item 成功后, 我们如果知道有哪些 item 都受影响了,只更新那批 item 不就好了?”
所以我们把重点放在怎么知道有哪些 item 发生变化,有两种方案:
- 我们调用 update 接口后,后端接口直接返回有哪些 item 变化了,更新那部分 item 即可。
- 我们调用 update 接口后,查询整个 gantt data, 前端对新老两份 gantt data 做 diff,找出那部分变化的 item,更新那部分 item 即可。
由于在后端方面 update 接口要检查变化的 item 都有哪些,逻辑也比较重,所以我们最后决定采取方案 2,前端对数据做 diff 就好了。
因此我们要实现一个 diff 算法,我们思考的是输入输出是什么,时间复杂度该如何?
- 输入:新旧 gantt data
- 输出:变更的 item 、新增的 item 、删除的 item
- 时间复杂度: O(n)
我们来实现一下简单版本的 diff 函数,大体是这样 :
/**
* @method 计算gantt图的差异,返回新增、修改、删除的 items
* @param {item[]} newGanttData 新的 gantt 数组
* @param {Map} lastDataMap 上一次 oldGanttData 的 Map 表
* @param {function} compareItemFn 自定义对比函数,默认为 lodash 深对比
* */
const ganttDataDiff = (newGanttData, lastDataMap = this.getLastDataMap(), compareItemFn = _l.equal) => {
let addedTasks = []; // 存放新增的item
const deletedTasks = []; // 存放删除的item
const modifiedTasks = []; // 存放修改的item
const currentDataMap = new Map(); // 记录新的 gantt 数据映射
newGanttData.forEach((item) => {
currentDataMap.set(item.id, item);
if (lastDataMap.has(item.id)) {
const currentItem = lastDataMap.get(item.id);
const isSameItem = compareItemFn(currentItem, item);
if (!isSameItem) {
modifiedTasks.push(item);
}
} else {
addedTasks.push(item);
}
});
lastDataMap.forEach((item, id) => {
if (!currentDataMap.has(id)) {
deletedTasks.push(item);
}
});
this.setLastDataMap('replace', currentDataMap);
if (process.env.NODE_ENV === 'development') {
if (addedTasks.length || modifiedTasks.length || deletedTasks.length) {
console.log('--- 检测到 gantt 数据变动 ---');
addedTasks.length && console.log('新增 items: ', addedTasks);
modifiedTasks.length && console.log('修改 items: ', modifiedTasks);
deletedTasks.length && console.log('删除 items: ', deletedTasks);
}
}
return {
addedTasks,
deletedTasks,
modifiedTasks,
};
}
有了对比 diff 算法,结合 gantt 内置的 updateItem(更新指定id的item)、deleteItem(删除指定id的item)、addItem (添加item)。
那我们得到最终的修改步骤为:
修改 gantt 成功 -> 查询整个 gantt data -> 对新 gantt data 与 老 gantt data 做 diff 算出差异-> gantt 更新差异 items -> 展示修改后最终效果
最终优化后的效果对比:
优化前: 800ms
优化后: 60ms
问题2 冗余的事件监听
我们在分析性能录制时候发现,gantt 在渲染 item 逻辑时(下图render_items方法),火焰图的调用栈里面都有 addEventListener 方法。
看了业务的功能里面,每一行每一列确实都有点击功能,大胆猜测,这是给每一个子元素都绑定事件监听了。
比如下面进度,点击之后会出现修改进度的小窗,也就是说,当可以操作列越来越多,事件监听器就越多,
去翻了业务每一列子元素的 renderFn 写法, 确实都给子元素绑上了 onClick 事件。
说到这里,该怎么优化,不用我多说了吧。
没错,这里应该要用 “事件委托” ,不熟悉概念的可以复习一下。
万幸的是,gantt 早就给你准备好了事件委托 delegate api, 我们改造成 delegate api,所有事件监听都可以只委托在 gantt 上就完事了。
这样来就减少了一堆的冗余事件监听,内存也少了,执行也变快了,看下图对比
优化前,render_items 需要 85ms
优化后,render_items 需要 26ms。(这里变成26ms,其实还有其他优化点,下面接着说)
问题3 React 组件无效转化为 Web Component
我们在定义 gantt 每一列应该展示什么内容时,肯定要为每一列自定义一个 render 函数,比如:
日期列-需要展示时间格式,负责人列-需要带上头像图片等等。
像负责人列配置,你可能会这么写:
const assignColumn = {
label: '负责人',
key: 'assign',
renderFn: (itemData) => { // 定义展示的元素
return `<div class="item-assign-col">
<img class="item-assign-col-img" src="${itemData.img}"/>
<div class="item-assign-col-name">${itemData.name}</div>
</div>`;
}
};
但是我们项目中使用的是 React 框架,renderFn 想要返回一个 React 组件,不幸的是 gantt v6 不能直接支持直接返回 React 组件的写法,比如:
const assignColumn = {
label: '负责人',
key: 'assign',
renderFn: (itemData) => { // 定义展示的元素
return <AssignComponent head={itemData.img} name={itemData.name}/>; // 直接返回 React 组件
}
};
查阅 gantt 源代码,是直接 appendChild 字符串的,这样会导致 React Element 无法正常工作。
为了让浏览器直接认识这个 React 组件,前人引入了可以将 React 组件 转为 Web Component 的库 reactive-elements 。
这个库竟然无人维护了,最后一次提交 4年前,还停留在 alpha 版本
先不说这个库是不是出正式版本,就性能而言,经过一层转换之后的性能也是有损耗的。
所以我们的问题转化为:
”怎么可以使用 React 组件能力同时,也不转化为 Web Component ,能让 gantt 渲染出正确的 Dom 元素?“
答案就是:
renderToString 和 renderToStaticMarkup
这两个 api 是 React 内置的用于服务器渲染的,可以将 React 元素转化为 String,反手这么一写:
const assignColumn = {
label: '负责人',
key: 'assign',
renderFn: (itemData) => { // 定义展示的元素
return ReactDOMServer.renderToString(<AssignComponent head={itemData.img} name={itemData.name}/>);
}
};
看似这么简单解决的肯定有诈。
renderToString 是有缺陷的,组件一些生命周期像 componentDidMount 不会执行以外,还会把声明的事件丢掉, 比如 onClick 这些...
目前我们要渲染的列,都是比较简单的,基本都可以优化成 renderToString 写法,然后 onClick 事件我们可以通过上述事件委托解决了,其他复杂的组件,我们保持原样。
等后续升级 gantt v7,可以完全渲染 React 组件,不过目前性能又再提升一个 level !!!
问题4 组件性能不佳
我们精通各种框架的使用、熟练 API 的调用。
所以我们潜意识会觉得,开源库与公共库的代码就是比较好的。我们往往不需要太关注。
但是开源库也有性能不合理的地方。
举一个例子:
我们发现某些表格页面渲染性能很差,经过排查发现:
名称 采用了 antd- design 的 Typography.Text 排版组件,可以为名称提供超长的名称显示 三个点 ... ,且悬浮出现 tooltip 气泡提醒
里面的判断文字超长逻辑比较重,导致列表用了很多这个组件,性能会相当差~~~ 当时组件库为此还专门写了一个新的 Text 组件,让性能好一些;
当然 Typography.Text 这个组件 antd 官方在后续的版本也对问题进行了修复。
回到我们 gantt 场景,我们发现 gantt 使用的部分组件性能也不是很好,比如:
Tooltip 组件——悬浮提示组件,在 gantt 中每个按钮配了一个
当 gantt 渲染时, 即便不悬浮在 icon buttion 上,Tooltip 元素也存在与页面上, 这部分也会导致 gantt 性能下降。
这个问题我们有 3 种方案:
1、不使用 Tooltip, 采用原生 title
2、懒加载,悬浮再渲染 Tooltip
3、采用单例 ToolTip, 鼠标悬浮在哪就定位在哪
最后方案我们与产品确认了,可以接受 title 效果,所以简单采用了方案 1。
经过上述调整,我们渲染页面速度又进一步提升。
问题5 后端接口性能
我们查看更新 gantt 图的动作中,涉及的接口有 2 个:
- update 更新 gantt 图 接口
- gantt data 查询更新后的 gantt 数据
上图中,查询 gantt 数据接口性能表现更差,我们得对其初步分析的思路步骤有:
1、我们可以对接口查询的字段,进行一一阉割,看看哪个字段去掉后,对接口查询时间影响最关键
2、查询 mySQL 慢日志,有没有存在慢查询语句
3、通过压测接口,通过相关火焰图监控工具(pprof),看看后端再执行哪部分逻辑比较耗时
步骤 1 进行字段阉割后,发现对接口查询速度提升不明显,我们只保留查询一些基础字段,比如 id、name等信息,发现接口还是很慢。
步骤2 慢查询也没有记录
“既然不是某些字段导致的慢,也不是慢查询,那么是不是基础模块设计存在问题?”
我们执行 步骤 3,用接口压测 + pprof 火焰图分析后, 确实能给我们提供一些信息。
我们发现耗时最严重的部分,主要耗时集中红色圈的2部分:
- taskDelegate 有大量的map写入,内存申请操作
- gc 垃圾回收操作
“这个信息很关键,间接反映了接口处理逻辑中,极有可能存在处理大数据量的操作。”
我们对接口逻辑进一步发现问题:
- 每个 item 查询,都会查询一次全部 item 层级信息放入map(从堆上申请内存)导致申请了大量的堆内存和map写入动作
- 请求结束后 item 回收,map出现大量无用map key,golang进行重hash(hash key数量不变、map桶重hash),gc频繁
可以简单理解为:
在 for 循环里面重复做一个耗时、耗内存的操作。
那我们优化的方式也很简单,将重复且耗时操作,放在循环外面执行。
自此,我们就能解释上面为什么减少字段,接口查询耗时并不会减少,因为耗时不在item自己的字段查询上。
经过接口逻辑调整后,查询操作性能提升 50% +
问题6 交互反馈不佳
多尔蒂门槛原理:系统需要在 400ms 内对使用者的操作做出响应,这样才能够让使用者保持专注,并提高生产效率。
在技术上假设我们能优化的都优化了,招都打完了,如果你还发现不如意,可以通过一些非技术手段优化。
- 用户操作后直接给予反馈,不需要等修改接口、详情接口等成功,等最终结果回来后,再次确认最终状态。
- 比如一个开关按钮,当用户点击时,直接更改状态,等接口成功后,再次确认状态即可
gantt 图也同理,在用户修改时,直接对数据进行回写,达到快速响应。然后数据回来后,在进行一次diff确认最终状态即可。
- 同时也要考虑到网络不佳、数据量大,加载比较慢的情况,及时给予合适的响应动画,来缓解用户等待的焦虑。
总结
经过上述优化,gantt 操作和渲染性能有了明显的提升,当然还有很多小的优化点就不记录了。
我们对 gantt 性能问题现象、分析以及解决的过程都进行了阐述。下面来总结一下:
- 我们遇到性能问题,需要善于借助工具分析,比如: devtool 工具、框架提供的工具、监控日志分析工具等等
-
我们对性能问题场景可以多维度分析,可以从前端、后端同时入手,除了上述优化思路外,我们常见手段还有:
-
按需加载资源
-
优化业务功能逻辑、算法逻辑
-
对长列表实现虚拟列表
-
减少重复渲染
-
关注基础组件性能
-
分页
-
接口拆解加载(1个接口拆2接口,先加载主体数据,再获取耗时的数据)
-
减少请求
-
串行接口 改成 并行接口
-
接口逻辑优化
-
慢 sql 优化
-
减小静态资源体积
-
即时反馈、加载中间态
-
-
在开发的时候多关注自己的代码,寻求最优解,写出一份质量和性能较好的代码。
“ 你也许觉得经你着手优化过的代码,会有一份自豪感。”