摘要
看板是 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 2 3 4 5 6 7 Droppable: unsupported nested scroll container detected. A Droppable can only have one scroll parent (which can be itself) Nested scroll containers are currently not supported. We hope to support nested scroll containers soon: https://github.com/atlassian/react-beautiful-dnd/issues/131 👷 This is a development only message. It will be removed in production builds.
由于性能问题并未解决,还增加了一些依赖包以及这个警告的问题,所以取消使用 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 5 const 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 的渲染性能。