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 模块的拆分解耦,目前已经实施的只有数据采集、自动更新器和主题管理这几个模块的拆分方案,剩下的方案将会在以后实施。

Your browser is out-of-date!

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

×