Gitee 企业版前端技术优化 - Part 1

Gitee 企业版前端项目在开发初期由于如期交付压力较大,各方面设计考虑得并不全面,因此遗留了一批技术债务。随着项目的迭代更新,这些技术债务对新功能开发的负面影响逐渐明显,有时还不得不基于糟糕的设计方案继续做糟糕的改动,致使技术债务越积越多。

为了改善项目的开发效率和体验,我们在之前一段时间里针对部分技术债务制定了优化方案,涉及构建工具、NPE 错误处理、API 库维护以及模块拆分解耦等方面。

优化构建性能

构建性能是影响开发效率的常见原因之一,在优化前,webpack-dev-server 不仅构建速度慢,CPU 和内存占用高,而且热更新机制只会整页刷新,这导致我们在进行开发的时候需要花费很多的时间在等待 webpack-dev-server 启动和热更新上。

专门花时间优化 webpack 的成本较高,所以换个构建工具是最好的选择。首先,鉴于项目内的文件非常多,构建时间大部分都花费在模块的加载和转译上,我们可以排除掉和 webpack 性能相差不大的工具。其次,鉴于我们在开发环境中调试用的浏览器都很新,无需处理 JavaScript 新标准的兼容问题,那么我们可以选择打包工序更简单的构建工具。

经调研后我们选择了 Vite 作为开发环境下的构建工具,原有的 webpack 仍然保留。

减少 NPE 错误

NPE(Null Pointer Exception)就是我们常常看到的 Cannot read properties of undefinedxxx is not a functionCannot set properties of null 这类 TypeError,频繁出现这类错误会对产品质量和用户体验造成不好的影响,因此我们有必要减少这类错误。

出现 NPE 的主要原因有:

  1. 后端返回的数据格式与约定的不一致
  2. API 的函数没有写 return 关键字返回 Promise 对象
    1
    2
    3
    function fetchData(params) {
    request(params);
    }
  3. 调用 API 的函数时没有写 await 关键字获取返回值
    1
    2
    3
    4
    const st = fetchData();
    if (!st.isFail) {
    st.data.data ......
    }
  4. 没有处理请求失败的情况就直接使用数据
    1
    2
    3
    4
    5
    6
    7
    8
    let list = data.list;
    if (!list) {
    const st = await fetchData();
    if (st.isSuccess) {
    list = st.data.data;
    }
    }
    data.map(); // TypeError: Cannot read properties of undefined (reading 'map')
  5. 组件内部数据的结构和流向不够明确,数据容易被改成错误的格式

这些原因本质上是源于数据类型不明确,采用 TypeScript 可以解决,对于老代码,我们可以使用 JSDoc + @ts-check 注释。

优化 API 库

现有的 API 客户端库维护成本很高,每次新增接口都需要人工编写代码,比较费时间,因此我们需要一个能自动生成 API 客户端库且 TypeScript 友好的工具来解决这个问题。

经过简单的调研后,我们决定采用 pont。pont 在初始配置下生成的 API 代码命名太长,不易于使用,为此我们还做了一些定制,包括:简化命名、补全数据类型、转换后端数据类型名等。

简化模块间的依赖关系

在企业版前端项目中有个 app.js 模块,它集成了全局状态管理、API、数据采集、自动更新、通知、主题管理等职责,是被依赖得最多的模块,大部分模块和组件都会引入它,其中的部分模块还有循环依赖的问题。像这样一种内部设计复杂、依赖关系混乱且职责不单一的模块,显然是需要尽早处理掉的,而处理方案就是将各种职责的代码分离出去成为较独立的模块。

解耦 API 模块

app.ent 提供绑定了当前企业 id 参数的 API 请求方法,它依赖 app.localData.enterpriseId,存在的意义就是简化传参代码。由此可看出只要解除对 app.localData.enterpriseId 依赖就能将 API 分离出来,大致的解耦方法是:移除 app.ent,改用新的 api 模块,原有的 app.localData.enterpriseId 依赖项改成由 App 对象初在始化时调用 api 模块的方法传入。

解耦后,依赖关系就由之前的:

1
模块 -> app.js -> app.ent -> new API(app.localData.enterpriseId)

变成了:

1
2
3
4
5
6
                 app.js
|
传入 enterpriseId
|

模块 -> api.js -> API

解耦全局状态管理模块

app.localData 提供全局状态管理,对其进行解耦的话有三种候选方案:

  • mobx + 全局变量
  • mobx + Context
  • useState + Context

参考资料:https://mobx.js.org/react-integration.html#using-external-state-in-observer-components

mobx + 全局变量

将 app 中的状态拆分到独立的 js 文件中,每个 js 文件创建一个全局状态实例,其它组件按需 import 使用。

优点:

  • 修改成本小,现有的基于 mobx 状态管理的组件可以继续使用。
  • 能够从 import 的模块路径看出组件依赖哪些全局状态,并快速跳转到状态定义。

缺点: 所有组件共用同一个全局状态实例,在进行单元测试时如果想创建多个全局状态实例来测试组件的话,可能会变得复杂。

mobx + Context

将 app 中的状态拆分到独立的 js 文件中,每个文件提供状态的定义和 Context 对象,由入口 js 文件实例化这些全局状态并通过 Context 共享。

优点:

  • 修改成本小,现有的基于 mobx 状态管理的组件可以继续使用。
  • 能够从 import 的模块路径看出组件依赖哪些全局状态,并快速跳转到状态定义。
  • 便于单元测试,可按需创建多个状态实例对组件进行测试。

缺点: 在 React devtools 中会看到嵌套了很多层的 Context.Provider,看着可能会比较难受。

useState + Context

mobx 适用于状态较多但组件只用到其中部分状态的场景,它可以有效减少因状态变化而重新渲染的组件数量。对于企业版前端现而言,全局状态中的企业、用户、主题、颜色等数据的变更频率很低,即便它们发生了变化,所产生影响也可以忽略不计,而且 mobx 要求所有用到 observable 状态的组件都用 observer() 包裹,也就是将组件与 mobx 强绑定在一起,这对组件的侵入性很强,因此,我们可以选择简化成用 Context 实现全局状态管理。

将全局状态拆分成多个状态分配给多个 Context 实现共享,例如:

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
function AppStoreProvider({ children }) {
const ent = useState();
const user = useState();
const ui = useState();
return (
<EnterpriseInfoContext.Provider value={ent}>
<UserInfoContext.Provider value={user}>
<UIContext.Provider value={ui}>
{children}
</UIContext.Provider>
</UserInfoContext.Provider>
</EnterpriseInfoContext.Provider>
);
);

// 在其它组件中
import EnterpriseInfoContext from '@web/context/EnterpriseInfo';
import UserInfoContext from '@web/context/UserInfo';
import UIContext from '@web/context/UI';

function Example() {
const ent = useContext(EnterpriseInfoContext);
const user = useContext(UserInfoContext);
const ui = useContext(UIContext);
}

优点: 少了 mobx 依赖,不用再调用 observer()makeObservable() 了。

缺点:

  • 修改成本大,需要对现有的基于 mobx 状态管理的组件进行修改。
  • 在 React devtools 中会看到嵌套了很多层的 Context.Provider,看着可能会比较难受。

通过对比上述三种方案的优缺点,我们决定采用 mobx + Context 方式管理状态,在 src/stores 目录中存放所有状态源码,由入口 js 文件实例化它们并通过 Context 共享。

Store 示例写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/stores/EnterpriseStore.ts

import { createContext } from 'react';

export class EnterpriseStore {
list: EnterpriseInfo[];
current: EnterpriseInfo;

...
}

export const Context = createContext<EnterpriseStore>();

export default EnterpriseStore;

解耦 UI 状态

app.ui 提供主题、颜色表、提示框、弹窗等 UI 相关的状态管理,解耦方案是拆分 toast、popStacks、themeContext 相关代码。考虑到 app.ui.showPop()、app.ui.confirm()、app.ui.toastFail() 等函数已在大量文件中使用,修改成本很高,故应该让拆分出来的 js 模块继续提供相同功能的函数。

解耦主题管理模块

app.ui.useTheme()app.ui.themeContextapp.ui.colorCodes 等的相关代码拆分到 src/theme.js 中,提供 ThemeProvideruseTheme(),示例:

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
// components/ThemeProvider/index.js
const ThemeContext = createContext();

function createTheme(themeColor) {
return ...;
}

export function useTheme() {
return useContext(ThemeContext);
}

export default function ThemeProvider({ children }) {
const [themeColor, setThemeColor] = useState('blue');
const theme = useMemo(() => ({
...createTheme(themeColor),
theme: themeColor,
setThemeColor
}), [themeColor]);

return (
<ThemeContext.Provider value={theme}>
{children}
</ThemeContext.Provider>
);
}
1
2
3
4
5
6
7
8
9
10
11
// 入口文件

import ThemeProvider from '@web/components/ThemeProvider';

function App({ children }) {
return (
<ThemeProvider>
{children}
</ThemeProvider>
);
}
1
2
3
4
5
6
7
// 示例组件
import { useTheme } from '@web/components/ThemeProvider';

function Example() {
const { theme, isDark, mainColor } = useTheme();
...
}

解耦弹窗组件和管理模块

将 app.ui 里的 showPop()confirm() 等 popLayer 相关方法都拆分到 src/components/PopLayer/index.js 中,在内部创建全局的 observable 状态,提供 PopLayerProvider,由它负责根据全局状态渲染弹框。

示例用法:

1
2
3
4
5
6
7
import PopLayer from '@web/components/PopLayer';

PopLayer.show(() => <Dialog ... />);
PopLayer.confirm({
title: '标题'
content: '内容'
});
1
2
3
4
5
6
7
8
9
10
11
// 根组件

import { PopLayerProvider } from '@web/components/PopLayer';

function App() {
return (
<PopLayerProvider>
...
</PopLayerProvider>
);
}

改进对话框组件

现有的 Dialog 组件是用 app.ui.showPop() 来控制显示的,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function Example() {
const [state, setState] = useState();

function submit() {
console.log(state);
}

function showDialog() {
app.ui.showPop(() => <MyDialog state={state} setState={setState} onSubmit={submit} />);
}

return (
<div>
{state}
<button onClick={showDialog}>show</button>
</div>
);
}

这种用法会导致显示后的弹框与 Example 组件状态失去同步,因为 MyDialog 并没有挂载在 Example 组件内部,不受 Example 组件渲染过程影响,state 变化后 MyDialog 组件函数不会再次调用,导致 MyDialog 使用的 state 和 onSubmit 一直是调用 showPop() 时的值。

在不改变现有写法的情况下,这个问题的解决方法有两个:让 MyDialog 的回调函数不读当前状态、用 useRef() 保存最新状态供回调函数使用。但按照 React 的编程风格,最合适的解决方法是改成声明式写法:

1
return <MyDialog visible={visible} state={state} setState={setState} onSubmit={submit} />;

state 变化后 MyDialog 重新渲染,onSubmit 被更新为最新的 submit。

改进通知组件

将 app 里的 toast 相关方法都拆分到 src/components/Toast/index.js 中,内部创建全局的 observable 状态,提供 Toast 和 ToastProvider,ToastProvider 负责根据全局状态渲染 Toast 组件。

示例用法:

1
2
3
4
import Toast from '@web/components/Toast';

Toast.error('失败!');
Toast.success('成功!');
1
2
3
4
5
6
7
8
9
10
11
// 根组件

import { ToastProvider } from '@web/components/Toast';

function App() {
return (
<ToastProvider>
...
</ToastProvider>
);
}

参考资料:https://ant.design/components/notification-cn/

解耦数据采集模块

app.sensor 并不依赖 app 其它资源,所以可以直接删掉 app.sensor,其它用到 sensor 对象的模块按需引入 sensor。原代码是引入预先下载的 js sdk 模块,现改成从 node_modules 引入 js sdk 包,方便管理版本,减少代码库中的第三方代码。

解耦自动更新器模块

app.autoUpdater 只用于向导航菜单提供 hasNewVersion 状态,解耦方法是改成 Hook + Context.Provider 方式提供 hasNewVersion 状态,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// components/AutoUpdaterProvider/index.tsx
const AutoUpdaterContext = createContext(false);

export function useAutoUpdater() {
return useContext(AutoUpdaterContext);
}

export function AutoUpdaterProvider({ children }) {
const [hasNewVersion, setHasNewVersion] = useState(false);

useEffect(() => {
...
}, []);

return (
<AutoUpdaterContext.Provider value={hasNewVersion}>
{children}
</AutoUpdaterContext.Provider>
);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// 入口文件

import AutoUpdaterProvider from '@web/components/AutoUpdaterProvider';

function AppStoreProvider({ children }) {
...

return (
<AutoUpdaterProvider>
{children}
</AutoUpdaterProvider>
);
}
1
2
3
4
5
6
7
8
9
10
11
// 示例组件
import { useAutoUpdater } from '@web/components/AutoUpdaterProvider';

function Example() {
const hasNewVersion = useAutoUpdater();

if (hasNewVersion) {
...
}
...
}

总结

这一期的技术优化方案包括以下优化项:

  • 使用 vite 提升开发服务器的构建速度和热更新体验
  • 使用 TypeScript 编写新代码,减少 NPM 错误
  • 使用 pont 生成 API 客户端库,减少人工维护成本,提升 API 使用体验
  • 拆分 app.js 模块,降低其复杂度以便于单元测试

其中,vite 已经投入在开发环境中使用,pont 工具相关的配置、模板和新生成的 API 库已经提交到代码库中,新代码将会使用新 API 库,而旧代码仅在修改到它时顺便迁移到新 API。对于 app.js 模块的拆分解耦,目前已经实施的只有数据采集、自动更新器和主题管理这几个模块的拆分方案,剩下的方案将会在以后实施。

在开发环境中使用 vite 代替 webpack

需求

Gitee 企业版前端项目是采用 webpack 构建的,它存在以下问题:

  • 构建速度慢: 全量构建耗时很长,虽然可以通过配置缓存来提升构建速度,但这缓存会影响到 npm link 的私有包的调试,导致构建产出的代码一直用上个版本的包,必须禁掉缓存才会生效。
  • 内存占用过大: 构建时的内存占用会增长到 1 GB 以上,webpack-dev-server 运行时间长了后能占用 2 GB 以上的内存。
  • CPU 占用过高: 构建时的 CPU 几乎满载,在 Ubuntu 系统中甚至能让系统 UI 出现卡顿问题。
  • 热更新机制存在问题: 更新速度慢,修改代码后是直接刷新页面而不是仅更新相关组件,影响开发效率,而且还存在内存占用过高的问题,浏览器标签页的内存占用会随着热更新次数的增加而增加,占用 1GB 以上的内存很常见。

分析

通过简单分析这些问题,我们可以很容易得出其主要原因在于模块的加载和转译,对于开发人员而言,在开发环境中调试用的浏览器都很新,无需考虑 JavaScript 新标准的兼容问题,那么我们可以将模块转译和 sourcemap 生成等流程的耗时作为主要指标,调研其它速度比 webpack 快的工具。

调研

首先排除 snowpack、parcel 和 rollup,它们在性能方面的提升有限且有一定的学习成本,剩下的就是
esbuild 和基于 esbuild 的 vite。esbuild 的文档中给出的性能对比图如下:

可以看出 esbuild 的性能已经满足需求,但考虑到文档、配置复杂度、dev-server、sass-loader 等的问题,选择 vite 是最为合适的。

实施

此次的 vite 引入工作内容主要包括:

  • 安装 vite 及相关依赖。
  • 添加 vite.config.js 文件。
  • 拆分 webpack 相关配置代码,以在 vite 配置中复用。
  • 弃用 vite 不支持的装饰器语法,改成普通的函数调用。
  • 升级 mobx,更新 mobx 用法,解决在 vite 构建模式下的 observer 组件不会响应 observe 状态更新的问题。
  • 解决 vite:css 的警告。

由于引入 vite 主要用于解决开发环境下的性能问题,生产环境仍然用 webpack 构建,因此测试工作量较小。

问题记录

node_modules 中的部分模块的 js 文件包含 JSX 语法

添加插件,将目标模块的 js 文件的 loader 改成 jsx,参考:

https://github.com/vitejs/vite/discussions/3448#discussioncomment-749919

Identifier ‘React’ has already been declared

添加 esbuild 配置,给每个 jsx 文件注入 react 的引入代码,参考:

https://github.com/vitejs/vite/issues/2369#issuecomment-904689947

环境变量注入

使用 define 配置实现原 Webpack DefinePlugin 相同的效果。

路径别名

将原 alias: { name: path } 配置格式改为 alias: [{ find: name, replacement: path }] 格式。

在 scss 中引入 node_modules 目录中的样式

alias 配置中添加以下替换规则以支持用 @import "~xxxxx" 将路径定向到 node_modules 目录内。

1
2
3
4
{
find: /~(.+)/,
replacement: path.join(__dirname, 'node_modules/$1'),
}

文件复制

暂未找到合适的文件复制插件,所以手写 JavaScript 代码在每次加载 vite.config.js 时复制一次文件。

@import must precede all other statements (besides @charset)

.styl 文件中使用 @import 引入 css 文件会报这个警告,编译结果是每个 css 都被输出成单个文件,解决方法是新建个 vendor.scss 然后将 css 文件都集中在里面 @import

效果

启动速度很快。 vite 将打包程序的部分工作交给了浏览器,仅在浏览器请求源码时进行转换并按需提供源码,这使得开发服务器在启动时的工作量非常少,不用像 webpack 那样要耗费大量时间等待打包完成才能使用开发服务器。

性能和内存开销降低。 初次访问开发服务器资源时的性能和内存开销比较大,node 和 esbuild 进程共占用约 1.4 GB 内存,但在构建完后会降低至 500MB 以下。

热更新很快。 修改代码后,相关组件会迅速更新为修改后的效果,不用再像之前那样浪费时间等待页面刷新了。

总结

vite 解决了开发环境下的构建性能开销大和耗时长的问题,使得项目成员有更多的时间用于开发上,提升了开发效率。目前暂未替换 webpack,仅作为另一个可选的构建方式。

Select 组件的更换方案

需求

原 Select 组件存在的主要问题是代码理解成本较高,组件内的各种功能的代码都混合在一起,如果要修改某个功能还得找出相关的代码块,然后理清与其它代码块的依赖关系,再找出相关的状态和副作用,最后理解实现原理和工作流程。

这个问题会引起其它问题:

  • 修改和及添加新功能的难度较大。
  • bug 的修复难度较大,修完一个 bug 可能会引出新 bug。

因此,我们需要一个新的 Select 组件来解决这些问题。

解决方案

基于 rc-select 组件定制,理由如下:

  • 用开源组件可减少组件的学习成本和维护成本。 ant-design 的 select 组件是基于 rc-select 实现的,ant-design 作为一个流行的企业级设计语言,已经经过众多项目的考验,它的 select 组件应该能应对大部分使用场景,而且经过这么久的迭代,出 bug 的几率应该很小。
  • 易于定制 CSS 样式。 rc-select 只提供最基本的 css 样式,支持自定义 class 前缀,我们可以很容易的根据项目的视觉规范来定制样式。
  • 支持虚拟列表。 减少渲染长列表时的性能开销。

新功能

为了满足业务需求,我们添加了以下属性:

  • noBorder: 是否使用无边框样式
  • autoWidth: 是否自适应内容宽度
  • autoLoadSelectedOption: 是否加载已选中项
  • remote: 是否启用滚动加载远程选项列表
  • remoteSearch: 是否使用远程搜索功能
  • onFetchMore: 加载更多选项时的回调函数
  • optionRender: 选项的渲染函数
  • searchInMenu: 是否在下拉菜单中显示搜索框
  • extraOptions: 额外的选项
  • options[].SingleSelectionOption: 是否为单选选项

遇到的问题

  • 性能问题。打开/关闭下拉菜单时都会触发回流,由于 CSS 代码量很大、CSS 规则复杂,导致回流耗时很长。

  • 使用动画会导致拉菜单无法向上展开。

  • 不能自定义触发器。选择框和下拉菜单是绑定在一起的,无法自定义选择框,主要应用场景有:

    • 将选择框内容自定义成 文本:[选项1][选项2] 这种格式。
    • 点击自定义组件时展开下拉菜单。

    这场景看上去更适合用 Dropdown 组件实现,但如果用 Dropdown 的话,就要重新实现一遍已经给 Select 组件中做过的样式和功能定制,成本有点高。

实现细节

在前几个版本中,虽然各个 prop 的功能实现代码已经被划分成若干个 Hook 函数,但存在以下问题:

  • 多个 Hook 函数依赖同一个 prop。
  • 一个 Hook 函数依赖另一个 Hook 函数创建的状态。
  • options 的处理流程不清晰。searchInMenuextraOptionsoptionRender 都依赖 options,且都有各自的逻辑处理 options,当启用 remote 时,还得让它们能够处理由 remote 的函数管理的 options

这些问题本质上是代码耦合问题,要解决它首先得理清这些代码关系,然后进行拆分和简化,大致的优化点如下:

  • 这些新增的 prop 都是基于 Select 组件原有的 props 实现的,那么 prop 的 Hook 函数的工作模式可以改为从参数接收 props,返回编辑后的 props。
  • remote 的 Hook 函数只需要管理 options 状态,然后将 optionsonSearch 等属性插入到返回的 props 中。
  • searchInMenu 在下拉菜单中添加了自定义的搜索框以代替 rc-select 原有的搜索框,将 onSearch 插入到 props 中实现了对搜索功能的代理。这种做法恰好让 remote 的代码也能响应到搜索变化,从而重新加载远程选项。
  • optionRender 只管在 optionRender 函数有效时转换 optionschildren

优化后的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
function BaseSelect(props) {
let selectProps = useSearchInMenu(props);
selectProps = useExtraOptions(selectProps);
selectProps = useSingleSelectionOptions(selectProps);
selectProps = useOptionRender(selectProps);
return createElement(RcSelect, selectProps);
}

function RemoteSelect(props) {
const selectProps = useRemote(props);
return createElement(BaseSelect, selectProps);
}

从这段代码中我们可以看出以下特点:

  • hook 函数命名与 prop 名相同,便于查找相关代码。
  • 所有 hook 函数都只需要修改 props,无复杂的返回值和参数依赖。

效果演示

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

看板组件的分离工作记录

背景

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

原计划

  • 将看板组件分离到独立的代码库中维护,解除与业务代码的耦合。
  • 重写看板组件的代码,遵循推荐的 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: 多行选择功能的处理器,包含鼠标交互和筛选选中行的实现。

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

企业版多语言支持方案改进

需求

现有的多语言支持方案存在以下问题:

  • 翻译配置中的 key 采用中文原文命名,与旧企业版和社区版的 key 命名风格不同,不符合团队成员的习惯
  • 不支持复数形式的翻译,例如:1 error, 2 errors
  • 不支持对文本 + 组件混合内容的翻译,例如:There are <strong>123<strong> tasks
  • 代码健壮性未知,可能会增加维护成本
  • 缺少文档,不易上手

解决方案

新的方案:

  • 采用 react-i18next 实现多语言支持
  • 采用 i18next-http-backend 实现翻译文件的加载
  • 采用 i18next-browser-languageetector 侦测合适的语言
  • 在 vscode 中安装 i18n-ally 插件,方便查看和编辑翻译文本
  • 编写 build-1i8n.js 脚本合并多个翻译配置文件,以便按功能模块划分配置文件分配给各个团队成员进行翻译,避免产生过多的冲突

约定

语言代号:

  • en:英文
  • zh-CN: 简体中文
  • zh-TW: 繁体中文

翻译配置源文件存放位置:config/locales

一级目录以语言代号命名,目录内的配置文件采用 json 格式,文件名与模块对应,例如:issues.json,它对应任务模块中用到的翻译配置。

翻译配置文件内的 key 命名采用小写+下划线风格,例如:issues.filters.created_by_me

按模块拆分翻译文件只是为了方便维护,让前端直接加载这些文件的话只会增加网络请求数和等待时间,因此需要跑以下命令将它们合并成一个文件:

1
npm run build-i18n

为了有效的利用浏览器的缓存策略,前端在请求翻译文件时采用强缓存策略,并且请求参数中会带上版本号,该版本号来自 config/locales 文件夹的 commit id,由 DefinePlugin 插件在编译时将它作为 APP_I18N_VERSION 常量注入到源码中。

参考资料

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

需求

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

选型

使用 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)

基于 React Router 的面包屑组件设计与实现

需求

在新企业版的设计中,导航栏中的面包屑需要具备以下功能:

  • 显示当前页面的路径
  • 面包屑节点支持显示为下拉菜单
  • 面包屑节点内容可动态更改

调研

由于项目时间比较紧,调研对象仅限于几个容易找到的同类方案,最终确定的候选设计方案有两个:

  • ant-design/breadcrumb: 传入 routes 路由配置对象和 itemRender 函数,根据当前路径从 routes 中匹配面包屑列表,然后用 itemRender 渲染每个面包屑节点。
  • react-breadcrumbs-dynamic: 在页面中主动引入 <BreadcrumbsItem> 组件设置面包屑节点内容,然后由 组件组合这些面包屑。

ant-design/breadcrumb

ant-design 的面包屑组件依赖 routes 路由配置对象,但新企业版项目并不是采用路由配置的形式集中管理路由的,如果要采用这种设计方案,那么就需要重构现有的路由代码,成本比较高。

react-breadcrumbs-dynamic

使用它需要多引入 react-through 依赖项,还得给每个页面添加 <BreadcrumbsItem> 组件。

设计

结合上述方案以及 Vue Router 的使用经验,可以设计出如下组件:

  • <RouterView>:功能类似于 Vue Router 的同名组件,基于当前的路由信息渲染对应的组件
  • <RouterBreadcrumb>:基于当前路由信息生成面包屑
  • <RouterBreadcrumbItem>:动态设置面包屑结点内容
  • <RouterStoreProvider>: 用于实现 <RouterBreadcrumbItem><RouterBreadcrumb> 之间的数据通信

<RouterView> 接受的路由配置格式为:

1
2
3
4
5
6
7
[
{
text: '页面标题',
path: 'path/to/page',
routes: []
}
]

以下是完整的示例用法:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import {
RouterView,
RouterBreadcrumb,
RouterBreadcrumbItem,
RouterStoreProvider
} from 'gitee-router-react';

function UserList() {
return (
<div>
<Link to='/users/root'>root</Link>
<Link to='/users/admin'>admin</Link>
</div>
);
}

function UserDetail({ username }) {
return (
<div>
<RouterBreadcrumbItem>{username}</RouterBreadcrumbItem>
Detail of {username}
</div>
);
}

const routes = [
{
text: '用户',
path: 'users',
routes: [
{
text: '列表',
path: '',
exact: true,
component: UserList
},
{
text: '详情',
path: ':username',
exact: true,
component: UserDetail,
props({ params }) {
return params;
}
}
]
}
]

function App() {
return (
<div className='app'>
<RouterStoreProvider>
<RouterBreadcrumb routes={routes} className='app-breadcrumb' />
<RouterView routes={routes} />
</RouterStoreProvider>
</div>
);
}

这段示例代码实现了一个用户列表页面,点击列表中的 root 用户链接后,面包屑会更新为:用户 / root

实现

需要实现的主要功能有:

  • 路由匹配
  • 路由配置中的路径转换
  • 动态更新面包屑结点

路由匹配

react-router 的源码目录中有个预览版的 react-router-config 包已经实现了静态路由配置的渲染支持和路由匹配,可以直接参考它的源码实现。

路由配置中的路径转换

这个功能很简单,大致的流程是递归遍历路由配置列表,然后转换每个路由配置的路径为绝对路径。

动态更新面包屑结点

<RouterStoreProvider> 组件维护一个映射表,以路由配置中的 path 为索引键,记录包括面包屑结点内容在内的状态,然后使用 React.Context 实现 <RouterBreadcrumbItem> 组件与 <RouterBreadcrumb> 组件的状态共享。

开发

新的路由方案实现起来比较简单,主要的工作量都集中在旧路由改造上,改造可以分为以下几个部分:

  • 将原有的 <Switch> + <Route> 使用方式改为 <RouterView>
  • 将分散在各个导航菜单、页面组件中的 <Route> 组件参数都集中到 routes 目录中,以新的路由配置表示
  • 给项目和仓库页面添加 <RouterBreadcrumbItem> 组件,向面包屑注册切换菜单
  • 给 PullRequest、里程碑等页面添加 <RouterBreadcrumbItem> 组件,动态更新面包屑内容

总结

由于新企业版项目采用的是全新的 React 技术栈,整个团队在 React 方面也没有积累多少开发经验,因此,在没有路由管理最佳实践方案可供使用的情况下,团队成员都是按照自己的风格管理页面的路由,随着新页面的增加以及各种新需求的实现,路由管理问题变得越来越明显,直到这次的面包屑导航组件为止,除了对它进行改造外已经没有别的办法了,因为考虑其它方案太费时间,大都会增加代码复杂度,导致代码会越改越烂。

现在采用的路由配置方案参考自 Vue Router,虽称不上是 React 路由管理的最佳实践,但也算是一个经历了众多 Vue 项目考验的成熟的方案,而且对于新企业版项目而言有以下好处:

  • 路由配置的格式简单且都集中在一个目录中,易于理解和维护。
  • <RouterView> 组件的功能以及路由配置格式与 Vue Router 相似,有过 Vue Router 使用经验的人很容易上手。
  • 能作为参数传给面包屑组件使用,无需编写复杂的代码手动控制面包屑内容,以后的动态标题也能基于路由配置和匹配结果来实现。

看板组件的设计与优化

摘要

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

仓库代码页开发总结

需求

  • 在导航栏中点击“新建文件夹”和“重命名文件”后,文件列表中会显示相应的表单
  • 在导航栏中点击查找按钮并输入框关键词后,文件列表应该切换为搜索结果列表,按上下方向键切换上下文件,esc 键退出查找模式,enter 键进入文件详情页

组件化

以下是大致的组件结构:

  • ProjectCode: 主页
    • ProjectHeader: 头部
      • ProjectSocialButtons: star、watch、fork 社交按钮组
    • ProjectDescription: 描述
    • ProjectSummary: 概要统计信息
    • ProjectNavbar: 导航栏
      • RefDropdown: 分支下拉框
      • ProjectFinderInput: 查找器的输入框
      • ProjectBreadcrumb: 面包屑
      • ProjectCloneWayPopup: 克隆/下载
    • Switch: react-router 的 Switch 组件
      • Route -> ProjectTree: 文件列表
        • ProjectFinderFiles: 查找器的文件列表
        • ProjectRecentCommit: 最近提交信息
        • ProjectTreeItemFolderNewForm: 文件夹新建表单
        • ProjectTreeItem: 文件列表项
          • ProjectTreeItemRenameForm: 文件重命名表单
        • ProjectReadmeView: 自述文件预览
      • Route -> ProjectBlob: 文件详情
  • ProjectComments: 评论列表

数据模型

分析需求后可知新建文件夹、重命名文件和查找文件的相关状态在多个组件中共享,因此可以将数据源划分为两个:ProjectTreeOperation 和 ProjectFinder。

ProjectTreeOperation 保存文件列表视图的操作状态:

1
2
3
4
5
{
findFiles: false,
newFilder: false,
renameTarget: null
}

ProjectFinder 保存文件查找状态:

1
2
3
4
5
{
search: '',
files: [],
selectedFileIndex: 0
}

由于文件查找视图的文件上下切换选中功能需要多写几行代码判断下标的有效性,所以采用 useReducer() 将这些逻辑写进 reducer() 函数中,最终创建的 dispatch 方法支持的操作有三个:setFiles、prevFile、nextFile。

组件通信

对于新建文件夹和重命名文件操作,使用 ProjectTreeOperationStateContext 和 ProjectTreeOperationSetterContext 实现导航栏组件和文件列表组件的通信。对于文件查找操作,使用 ProjectFinderStateContext 和 ProjectFinderDispatchContext 实现输入框和文件列表组件的通信。

Your browser is out-of-date!

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

×