看板组件的分离工作记录

背景

现有的看板组件与业务代码存在耦合,而且由于初期考虑不够全面,还存在一些设计不合理的地方,因此应该对其进行改进并分离到业务组件库中。

原计划

  • 将看板组件分离到独立的代码库中维护,解除与业务代码的耦合。
  • 重写看板组件的代码,遵循推荐的 eslint 规则,提升代码质量。
  • 采用 react-beautiful-dnd 实现拖拽操作,提升交互体验。
  • 采用 react-window 实现虚拟滚动加载任务卡片,提升交互体验和性能。

遇到的问题

在完成看板组件的基本功能和一个简单的示例后,出现了以下问题:

  • 性能变差。 当板块和卡片数量变多后,拖拽卡片会卡顿现象,滚动触发加载下一页任务列表时也有卡顿现象。
  • 数据通信成本变高。 如果像官方示例那样,所有板块都共用父组件传入的数据源,那么在拖拽卡片时会很容易触发重新渲染,导致性能变差。
  • 布局实现难度变高。 react-window 要求容器高度固定,这导致看板不能直接利用 css 布局特性实现自动拉伸高度。

实际改动内容

考虑到现阶段不适合投入太多时间成本去解决这些问题,所以我们只对现有代码做简单的重构和优化,不做大改动,实际改动内容大致如下:

  • 将看板组件代码移动到业务组件库的代码库中。
  • 改进看板现有的基于事件的数据通信方式。
  • 使用 prop-types 添加属性类型检查。
  • 改进滚动加载功能。
  • 升级 react-dnd。

实现细节

滚动加载

在窗口尺寸变化、页面内任意元素滚动的时候,检测卡片列表是否在可见区域内、是否已经滚动到接近底部,然后决定是否加载下一页数据,这两个检测的核心代码如下:

1
2
3
const viewPortWidth = Math.min(document.body.offsetWidth, parent.offsetWidth);
const visible = el.offsetLeft < parent.offsetLeft + parent.scrollLeft + viewPortWidth;
const loadable = el.scrollTop + el.clientHeight >= el.scrollHeight - el.clientHeight / 5;

数据通信

每个板块维护自己的卡片列表数据,利用事件实现跨板块的数据操作,能减少状态变化的影响范围和重新渲染次数。

事件监听和触发由 EventHub 对象实现,Board 组件和 Column 组件共享这个对象,我们可以使用 <KanbanEventHubProvider>useKanbanEventHub() 拿到它,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
function KanbanIssueEventHandler({ children }) {
const kanbanEventHub = useKanbanEventHub();

// ...
// 在合适的时机向 example 板块插入新项目
kanbanEventHub.emit('insertItem', { columnId: 'example', item });
// ...

return children;
}

function KanbanBoardWrapper({ children }) {
return (
<KanbanEventHubProvider>
<KanbanIssueEventHandler>
{children}
</KanbanIssueEventHandler>
</KanbanEventHubProvider>
);
}

function Example() {
// ...
return (
<KanbanBoardWrapper>
<kanbanBoard
columns={...}
renderCard={...}
onSort={...}
/>
</KanbanBoardWrapper>
);
}

效果演示

http://gitee-frontend.gitee.io/gitee-ent-react/gitee-kanban-react/

看板组件的设计与优化

摘要

看板是 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 的渲染性能。

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×