看板组件的分离工作记录

背景

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

原计划

  • 将看板组件分离到独立的代码库中维护,解除与业务代码的耦合。
  • 重写看板组件的代码,遵循推荐的 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/

Pull Request 的多行评论功能开发日志

Pull Request 的多行评论功能允许用户选择指定范围内的代码行进行评论,使得评论上下文更准确,这个功能在之前的赶进度期间由于开发成本较高而被暂时搁置,一直推迟到最近才开始开发。

实现多行评论功能所需要解决的问题主要是鼠标交互、多行选中效果以及如何修改旧代码,本文章将介绍这些问题细节和解决方法。

高亮选中行

在按住 + 按钮拖动的时候和 hover 多行评论的时候需要高亮选中行,react-diff-view 的 Diff 组件本身有支持该功能,向 Diff 组件传入 selectedChanges 参数即可实现,至于如何生成 selectedChanges,解决方法如下:

  • 监听文件内的 mousedown、mouseup、mouseenter 事件。在 mousedown 时记录起始行;在 mouseenter 时记录结束行,然后在 diff 数据中查找匹配的行数据,将其转换成 changeKey 数组存入 selectedChanges;在 mouseup 时结束选中操作,触发显示评论框。
  • 监听评论的 mouseenter 和 mouseleave 事件。在 mouseenter 时根据评论数据中的起始和结束行号,从 diff 数据中筛选出匹配的行数据,将其转换成 changeKey 数组存入 selectedChanges;在 mouseleave 时,清空 selectedChanges。

在这两个解决方案中稍微复杂点的功能是行数据的筛选,筛选条件有两种:起始行数据 + 行数、起始行的新旧行号 + 结束行的新旧行号,核心操作都是遍历每个 hunk 中的每个 change,判断 change 中的 oldLineNumber 和 newLineNumber 这两个属性是否符合条件。

选中效果是有了,但 Diff 组件只是简单的给选中行加上 diff-code-selected 类,无法区分起始行和结束行,要给起始行和结束行加上边界框样式的话,还需要手动编码实现。现在采用的方法是用 useEffect() + 原生 DOM 操作,在每次渲染后获取所有代码行的 DOM 对象,给起始行和结束行加上对应的 className。

重构

在旧代码中,文件差异视图组件包含了评论、按需加载、展开更多等功能的代码,这杂乱的代码增加了新功能的开发成本,光是理清评论功能和 Diff 组件交互代码都得花点时间去上下滚动浏览代码。为解决这问题,提升多行评论功能的开发体验,有必要对旧代码进行重构,重构方案如下:

  • 将评论功能的状态管理和组件相关代码都移动到 useCommentWidgets.jsx 中,主组件中调用 useCommentWidgets() 并传入响应参数即可启动代码评论。
  • 将展开更多功能的代码移动到 ExpandableHunks.jsx,在主组件中渲染 <ExpandableHunks> 组件即可启用该功能。

重构后,源文件内的代码量减少了一半,多行评论功能也基于这种思路将代码拆分到了两个文件:

  • useMultilineSelectState.js: 多行选择功能的状态管理,包含 selectedChanges 状态和用于标记选中行边界的 Effect。
  • useMultilineSelectHandler.js: 多行选择功能的处理器,包含鼠标交互和筛选选中行的实现。

采用这种思路的好处是,每块功能的代码都放在独立的文件中,在修改和添加新功能的时候可以更专注于某块功能的代码,不用再受到其它功能代码的影响而分散注意力。

企业工作台模块化布局方案

需求

  • 可自由拖动模块的位置、设置模块的宽度
  • 可添加/移除模块
  • 每一行的模块高度相等
  • 部分模块有最大和最小宽度限制
  • 当前用户无配置时使用默认模块布局

选型

使用 react-grid-layout 管理模块布局。

实现方案

栅格化布局

将内容区域宽度划分为 12 格,高度以 100px 为一格,模块的宽高以格为单位,假设内容区宽度为 1080px,那么 4x4 尺寸的模块在 react-grid-layout 中的效果就是 360px x 400px。

行内元素等高

由于 react-grid-layout 中的布局是基于绝对定位(position: absolute)和变换(trasnform: translate(x, y))实现的,无法使用 block 和 flex 布局特性,若基于它实现行内模块等高的效果的话会比较复杂。

为了简化 react-grid-layout 相关代码的复杂度,目前可行的解决方法是将页面内容布局模式分为普通和编辑:

  • 只读模式: 读取模块化配置,根据各个模块的几何属性(x、y、w、h)进行排版然后渲染到页面中,利用 flex 布局实现行内模块等高特性
  • 编辑模式: 转换模块化配置然后交给 react-grid-layout 处理

这两种模式的布局代码可以划分到 DashboardFlowLayout 和 DashboardGridLayout 组件中,在切换到编辑模式时,获取 DashboardFlowLayout 组件中所有模块的实际高度并传给 DashboardGridLayout 组件,每当模块布局变化时,DashboardGridLayout 组件根据模块的实际高度重新排版,然后将行内模块最大高度作为行高并应用到所有模块。

排版的效果与浏览器渲染机制中的 reflow (回流) 相似,为了方便理解和引用相关资料,在实际代码中实现排版这一操作的函数已命名为 reflow,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function reflow(elements, maxWidth) {
let row = null;
const rows = [];

elements
.slice()
.sort((a, b) => (a.y * maxWidth + a.x) - (b.y * maxWidth + b.x))
.forEach((el) => {
if (!row || el.y >= row.y + row.height || row.width + el.width > maxWidth) {
row = {
y: row ? row.y + row.height : 0,
width: el.width,
height: el.height,
children: [el]
};
rows.push(row);
return;
}
row.height = Math.max(el.y - row.y + el.height, row.height);
row.children.push(el);
});

return rows;
}

只读模式下的布局还原

编辑模式下的布局是基于 react-grid-layout 实现的,能够用 xy 灵活的控制模块位置,那么在基于 flex 布局的只读模式下如何还原模块的位置?

较为稳妥的做法是用空 div 元素填充模块之间的空白,空白 div 的宽度等于当前模块的 x 与上个模块的 x + width 的差值,即:width = el.x - (prevEl.x + prevEl.width)

看板组件的设计与优化

摘要

看板是 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 人节活动动画开发日志

需求

红薯在愚人节前一天突发奇想,想在 Gitee 上也整一个活动,活动内容就是让仓库页面有个破碎的动画然后显示“愚人节快乐!”。

实现

第一次搞动画有点慌,没用过 Canvas API,不知道一天内能不能搞出来。一开始是想用类似于 Google 在灭霸搜索结果页面加的彩蛋,点击无限手套就会触发灰飞烟灭效果,但仔细想了一下,直接照搬网上现成的动画的话会拉低用户对 Gitee 的评价,于是决定用其它效果。

碎片化

页面变成碎片的效果比较容易实现,大致思路是根据屏幕宽高计算 x 和 y 轴上的碎片数量,然后计算每个碎片的坐标,伪代码如下:

1
2
3
4
5
6
7
8
const size = 32;

for (let x = 0; x < Math.ceil(canvas.width / size); ++x) {
for (let y = 0; x < Math.ceil(canvas.height / size); ++y) {
// 四个参数对应 x, y, width, height
new Piece(x * size, y * size, size, size)
}
}

随机分布

碎片该在什么时候掉落?短时间内让全部碎片掉落的话,对渲染性能要求较高,动画会卡顿。按顺序逐个开始掉落的话,观感过于普通,得搞点不一样的效果。

最终决定采用如下方式:

  1. 在生成碎片列表后,用随机算法将它打乱,参考:如何将一个 JavaScript 数组打乱顺序? - 知乎
  2. 在渲染时分批激活掉落的碎片,并计时
  3. 在碎片激活后的一段时间内,只给碎片渲染边框,表示这个碎片将要掉落
  4. 开始掉落

掉落

碎片要有个掉落动画,每个碎片的掉落路线应该随机,掉到底下后会弹起。掉落路线的计算方法需要花时间查资料和写代码,就目前的情况而言,可没那么多时间能够静下心来自己动手实现,那么,只能找现成的代码来用了。

花了点时间,找到了这个动画:HTML5 Canvas实现会跳舞的时间动画,数字变化后会变成一堆小球掉落,挺符合需求的,但下载源码包后发现要关注这个网站的微信公众号才给解压密码,这种拿别人的开源项目不注明出处还加上解压密码的行为挺让人反感的。

这个问题本质是如何实现自由落体动画,用相关关键词可以搜索到这个动画:HTML5/Canvas 物理学自由落体动画 - 踏得网,代码比较简单,可以直接拿来用。

颜色渐变

动画内容只有碎片掉落还不够,可以给碎片加上颜色渐变效果,在碎片动画播放一段时间后开始加入颜色渐变效果,大致流程是:

  1. 创建碎片,随机分配一个颜色
  2. 在碎片激活后一段时间,开始渲染渐变色

填充渐变色可以用 fillRect(),在 Canvas API 首页就有它的示例代码。

调整渲染顺序

当掉落的碎片增加到一定量时,未掉落的碎片会遮住掉落中的碎片,碎片看上去像是向屏幕后面掉落,观感不太好。由于掉落的碎片是在未掉落的碎片之前绘制的,先绘制的内容会被后绘制的内容覆盖,为解决这问题,需要再加一个数组,按碎片的生成顺序倒序,然后在渲染时遍历该数组即可。

处理图表和表格的打印样式问题

新开发的统计页面需要有个打印功能,但在预览打印内容时出现了以下问题:

  1. 样式缺失,内容排版混乱
  2. ECharts 图表宽度溢出,导致文档中有滚动条
  3. Element UI 的表格组件宽度未 100% 拉伸

对于第一个问题,原因是 page_specific_style_bundle_tag 方法输出的 link 标签的 media 属性默认值是 screen,这使得 link 标签引入的 css 代码仅在 screen 上有效。解决方法是手动将 media 属性设为 all,然后添加 @media print 查询,隐藏无关内容。

第二个问题,原因是在媒介切换为 print 时 ECharts 的图表尺寸仍然是用 screen 媒介下的尺寸,虽然有尝试过在 window 触发 beforeprint 事件时主动更新图表尺寸,但并没有什么用,后来根据搜索结果得知,可在打印前将图表转换为图片,并让 img 元素的宽度为 100% 以使图表宽度自适应。核心代码如下:

1
2
3
4
5
6
7
8
9
function print() {
const img = new Image()

img.onload = () => {
window.print()
}
img.src = this.chart.getDataURL()
this.imgSrc = img.src
}

第三个问题,Element UI 的表格组件分别用三个 table 元素实现表头、表身和表尾,并使用 colgroup 元素来控制每一列的宽度,这些宽度是由 JavaScript 计算的,显然,在媒介切换为 print 时它并不会重新计算。解决方法是将表格组件中的三个 table 元素合并为一个,然后去除 colgroup 元素,让浏览器自动调整 table 布局。核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export default {
mounted() {
$(window).on('beforeprint', () => {
const $box = $(this.$el)
const $parent = $box.parent()
const $table = $($parent.find('.el-table__body-wrapper table')[0].outerHTML)

$table.prepend($parent.find('.el-table__header-wrapper thead')[0].outerHTML)
$table.append(`<tfoot>${$parent.find('.el-table__footer-wrapper tr')[0].outerHTML}</tfoot>`)
$table.find('colgroup').remove()
$box.empty().append($table)
})
}
}
Your browser is out-of-date!

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

×