看板组件的设计与优化

摘要

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

Gitee 移动端项目分离总结

与上次的移动端项目拆分方案相比,做了以下改动:

  • 分离了部分移动端 css 文件
  • 改用 git+https 方式从 git 代码库中安装移动端的构建产物,省去了发版、上传 npm 包和刷新淘宝源的麻烦

分离后,主仓库在生产环境下的全量构建耗时减少约 11.65%,具体测试结果如下:

版本 切换版本后第一次 第二次 第三次 第四次 后三次平均
分离后 178.40s 78.47s 77.44s 76.57s 77.49s
分离前 233.72s 89.76s 84.73s 88.64s 87.71s

现在的移动端项目代码并未完全与主仓库分离,主仓库中还存在一些移动端的代码,主要原因如下:

  • css 文件依赖关系复杂,拆分难度大
  • 部分页面的 js 和 css 文件是由 Rails 的 Asset Pipeline 管理的,它们只能在主仓库中

对于这些问题,在开发初期如果有良好的模块化意识和开发规范约束话都是可以避免的,例如:

  • 统一使用 Webpack 打包 css 和 js 文件,尽量不依赖 Rails 环境的工具和资源
  • js 和 css 代码应该根据功能、作用范围划分文件和目录,以便区分和查找

Gitee 企业版页面性能优化

需求

红薯抱怨企业版页面没有那种梭一下就刷完的流畅度,加载完页面后还要等一段时间才完全呈现出内容。

分析

浏览器开发者工具统计的网络性能如下图所示:

从图中可以看出:

  • 仅 dashboard 页面就需要等 437ms 才能下载完成,由于该页面涉及到后端的模板渲染和相关代码,暂不优化它
  • 最大文件的是 app.js,有 700KB,而且是经 gzip 压缩后的大小
  • echarts.common.min.js 比 vendors_lib.js 小,但耗时却比它多

解决方案

优化 app.js 大小

app.js 作为企业版所有页面公用的基础 js 文件,包含了 jQuery、组件库、编辑器、图表、highlight.js、moment.js 等依赖库,其中编辑器、highlight.js、moment.js 这类体积大的依赖库在大部分页面中都用不到,可见有很大的优化空间。

在审查 app.js 的依赖后,得出的初步优化方法如下:

  • 移除无用的图表库依赖
  • 对于只在个别页面中使用的依赖库,改为按需引入
  • 移除编辑器依赖,改为用 Webpack 打包,由相关组件使用 import() 方法按需异步加载

采用前两个方法进行优化后,app.js 大小变为 596KB,比优化前减少 104KB。

优化 echarts.js 的传输性能

echarts.js 相比 enterprise_dashboard.js,下载速度慢了大约一倍,原因如下:

  • assets.gitee.com 域名下的 enterprise_dashboard.js 有 CDN 加速,传输性能不会受到 gitee.com 主站性能影响
  • enterprise_dashboard.js 有对应的 .gz 文件,服务器可直接使用,无需在每次请求时耗费时间对文件进行 gzip 压缩

webpack 打包的资源都托管在 assets.gitee.com 域名下,那么优化手段就是改用 webpack 打包 echarts。

经过优化后,echarts.js 的下载耗时已经减少到 vendors_lib.js 的一半:

Your browser is out-of-date!

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

×