看板组件的设计与优化
摘要
看板是 Gitee 企业版中的一种任务展示模式,在该模式下可以很直观的查看和管理各个任务状态。本文将以新企业版中的看板组件为对象,简单的介绍了它的功能需求、实现方案以及开发过程中遇到的问题和解决方法,最后总结了看板组件用到的性能优化手段。
功能需求
- 横向滚动时按需触发加载可见区域内的板块
- 在板块内滚动卡片列表时自动加载下一页卡片
- 拖拽任务卡片到另一个板块中可更改任务属性
- 任务更新失败后,将卡片移回原来的位置
- 当打开任务后,突出显示看板中的对应任务卡片
- 用户在外部组件(例如:任务详情)中操作更新任务后,看板内对应的任务也应该有变化
实现方案
- 用 useReducer() 管理所有板块的状态,包括其任务列表
- 所有板块共用同一任务数据源,以便直接更新任务并让对应的板块重新渲染任务卡片
- 在板块和卡片的 DOM 节点上添加 data-id 属性
- 添加 onDragStart、onDragOver、onDrop 等事件处理器,实现卡片拖拽功能
- 在 onDragStart 回调函数中,根据元素的 data-id 属性获取 columnId 和 itemId
- 在 onDrop 回调函数中,调用 dispatch 方法更新相关板块中的任务列表
- 为容器添加 scroll 事件监听,实现横向滚动加载
- 为每个板块添加 scroll 事件监听,实现纵向滚动加载下一页任务
性能优化
第一个版本在实际测试时有明显的卡顿现象,每次滚动触发加载数据时都会卡住一段时间,拖拽移动卡片时也是如此,开发者工具的性能分析结果如下:
从分析结果来看,可能是因为 React 的事件处理和相关状态影响了性能,于是就做了如下优化:
- 改用原生 addEventListener() 添加事件绑定
- 改用 useRef() 存储滚动加载和拖拽操作相关数据
- 移除板块组件的 viewport 参数,改为在响应 scroll 事件时从 DOM 对象获取
然而并没有多大效果,由此可知这点复杂度的状态数据并不会对渲染性能产生明显的影响。
改用 react-beautiful-dnd
既然使用这些优化手段无法解决性能问题,那么改用流行且成熟的 react-beautiful-dnd 来实现拖拽功能会有怎样的效果?经实践证明,这种方案也不能解决性能问题,从开始拖拽到卡片移动的间隔时间为 276ms,脚本和渲染的耗费时间过长。
结合 react-beautiful-dnd 的示例代码以及开发者工具中的元素属性变化情况可以看出,react-beautiful-dnd 的拖动动画效果是基于 fixed 定位 + transform 实现的,容器中的 placeholder 元素用于撑开插入位置。另外,虽然 react-beautiful-dnd 在嵌套的滚动容器中的拖拽效果正常,但它在控制台中给的警告却表明它暂不支持嵌套的滚动容器:
1 | Droppable: unsupported nested scroll container detected. |
由于性能问题并未解决,还增加了一些依赖包以及这个警告的问题,所以取消使用 react-beautiful-dnd。
使用 React.memo 优化
React.memo 是高阶组件,作为一种性能优化方式,它可以让组件在 props 未变更的情况下跳过渲染组件的操作直接复用最近一次渲染的结果。
之前尝试过的优化手段都是以“React 虚拟 DOM 渲染性能很高”为前提的,毕竟页面内的元素数量看起来不多,再结合 JavaScript 的性能表现来看,很容易让人认为 React 渲染包含几千个组件的虚拟 DOM 树的效率很高,耗时应该不会超过 1ms。然而实际性能表现却不得不让人怀疑 React 的性能无法应付现在的场景了。
首先看上图的性能分析结果,从耗时最高的 Event:drop
持续向下展开直到组件的函数调用为止,可以知道看板中的各个组件渲染耗时都很高。
对看板的子组件使用 React.memo 后,拖拽操作的流畅度有显著的提升,详细的性能分析结果如下:
优化状态管理和组件通信
上文中测试的看板所包含的板块数量都不足 20,优化后的性能表现勉强能接受,但在成员看板开发完后这个卡顿问题就变得很严重了,因为成员看板加载的成员有 100 个,每次更新任务数据时这 100 个板块都会重新渲染,再加上部分板块已经加载的任务列表,渲染量还是比较大的。
现在采用的实现方案是集中管理所有板块数据和任务数据,这会导致每当有任务添加和更新时都会触发所有板块的重新渲染,那么接下来的优化目标就是数据源和组件通行了。
新的优化方案如下:
- 取消全局的板块数据源,改为让每个板块维护各自的数据
- 取消全局的任务数据源,改用 useRef() 保存,示例:
1
const storeRef = useRef();
- 代理板块的 fetcher 回调函数,将它返回的任务数据保存到 storeRef 中,示例:
1
2
3
4
5const columnFecher = useCallback((...args) => {
const item = await fetcher(...args);
storeRef.current[item.id] = item;
return item;
}, []);1
2- <KanbanColumn fetcher={fecher} ... />
+ <KanbanColumn fetcher={columnFecher} ... /> - 改用发布订阅模式实现跨板块的任务移动、选中、更新等操作,也就是在全局创建一个事件总线供其它组件使用
优化后,拖拽操作耗时降到 20ms 左右。
总结
看板组件用到的性能优化手段可分为以下几类:
- 使用缓存:useMemo()、useCallback()、React.memo()
- 简化状态:减少全局状态、细粒度的拆分组件状态、使用 Context 减少冗余的 props 传递、使用 useRef() 保存与渲染无关的数据
- 优化组件通信:用事件总线实现任务数据在多个板块中的增、删、改和移动操作
- 减少渲染节点:按需创建性能开销较大的组件(例如:Dropdown、Popup、DatePicker),仅在 hover 或 click 时创建它们
React 文档中的避免调停章节有提到,React 会在组件的 props 或 state 变更后重新渲染组件,重新渲染会花费一些时间,这个“一些时间”可能比你想象中的要多,尤其是在组件数量很多且内部状态复杂的情况下,实际耗时可能会达到数百毫秒,所以,在开发组件的时候就应该考虑性能优化问题,不要过度高估 React 的渲染性能。