在React Native中函数式的控制Loading Component

阅读前你需要知道的#

  • React Context是什么
  • 如何自定义Hooks

为什么需要函数式的控制Loading Component#

在APP的开发中,我们经常会遇到一些异步的操作,比如通过网络请求获取数据。在用户进行这类操作的时候,一般需要弹出一些提示的组件,用来表示这次网络请求所处的状态。 最简单的实现方法是维护一个内部State,在网络请求的状态发生变化的时候,按照业务需求同步更新这个内部状态,并且根据这个状态来决定Loading Component是否渲染,代码示例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const TestLoading = () => {
const [isError, setIsError] = useState(false)
const handleRequest = async () => {
try {
await requestAPI()
setIsError(false)
}catch(e){
setIsError(ture)
}
}
return (
<>
<Button title='request' onPress={handleRequest} />
<Modal visible={isError}>
<ActivityIndicator size="large"/>
</Modal>
</>
)
}

从上面的代码我们不难看出,如果使用和这种简单的实现方式,需要在每个需要展示Loading Component的页面,手动添加它,并且还需要增加一个内部状态来控制它的展示。这不免显得有些冗余,如果过我们可以使用如下的函数式的调用方式来控制Loading Component,将大量减少冗余代码

1
2
3
4
5
6
7
8
9
10
11
12
const TestLoading = () => {
//...
const handleRequest = async () => {
showLoading()
await requestAPI()
closeLoading()
}
//...
return (
<Button title='request' onPress={handleRequest} />
)
}

要解决的问题#

  1. 和web不同的是,在Mobile的开发中,我们无法直接调用类似UIView这类的原生方法来增加新的Element
  2. 保持最大的兼容性,尽量不依赖Mobile平台提供的特殊API

实现#

设计思路#

对于这样的Loading Component在整个APP中可以只存在一个这样的组件,并将它在APP的最外层进行挂载,然后在通过React Context共享它的控制渲染的函数。这样,我们就可以在任意一个组件或者Hook中控制Loading Component了。

代码#

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
60
61
// GlobalLoaidngProvider.tsx
export const GlobalLoadingContext = React.createContext({
show: console.log
close: console.log
});

export const GlobalLoadingProvider: React.FC<React.PropsWithChildren> = ({
children
}) => {
const [isShow, setIsShow] = useState(false);
const [message, setMessage] = useState('');
const handleShow = (message?: string) => {
setIsShow(true);
message && setMessage(message);
}
const handleClose = () => {
setIsShow(false)
}
return (
<GlobalLoadingContext.Provider value={{
show: handleShow,
}}>
{children}
<Modal
animationType="slide"
transparent={true}
visible={modalVisible}
>

<ActivityIndicator size='large' />
<Text>{message}</Text>
</Modal>
</GlobalLoadingContext.Provider>
)
}

// App.tsx
// ...
const App = () => {
//...
return (
<GlobalLoadingProvider>
{//... you component}
</GlobalLoadingProvider>
)
}

//RequestAPIButton.tsx

const RequestAPI = () => {
const {show, close} = useContext(GlobalLoadingContext)
const handleRequest = async () => {
show("data lading");
await requestAPI();
close();
};

return(
<Button onPress={handleRequest} title="requestAPI" />
)
}

React “动态”表单设计(一)

React “动态”表单设计(一)#

在本节中,我会介绍动态表单的定义,实现一个这样的表单会遇到的问题,和给出它的基础数据结构和生成函数。 在下一节中,我会更详细的介绍,这个表单中字段的值的存储方式。和如何实现这些功能和组件

动态表单是什么#

在常见B/S架构的项目下,我们有的时候会遇到,需要根据不同用户的权限和需求,为他们提供包含不同字段的表单。

例如,一个工单系统,我们假设有三个角色。可以接受工单的人(A和B),审核工单进度的人(C)。对于A,他可能需要填写工单处理的开始和结束时间,确认什么时候可以处理工单。如果A接受了工单,但发现在约定时间无法处理完成,他可以将该工单分享给B。那么B看到的工单内容,应该是不可以编辑,并且可以选择接受或不接受。 当A B完成了这个工单后,C应该收到工单完成审核。C可以看到工单的所有信息,并且选择是否关闭这个工单。

对于上述业务逻辑,我们可以看到一个表单的大部分内容被复用了3次(工单号,处理人等字段),而对于A,B,C他们又有一些特别的字段和操作,并且他们这些区别的可以见性控制完全来自于Server端下发的数据。那么对于这种场景我们是否可以使用被复用的字段和每个人的特殊操作来组成最终展现的表单么?动态表单就可以解决这个问题。

当然对于上面这段简单描述来讲,直觉上我们可能会偏向于下面这种方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const userInfo = getUserInfo();
const {isResolverSelf, isJudger} = userInfo;
const renderForm = () => {
if(isResolverSelf){
return <FullEditableForm/>;
}
if(isJudger){
return <CloseActionForm />;
}
if(!isResolverSelf){
return <ReciveSharedForm />;
}
}
return renderForm();
但随着角色的增加和对权限精细管控的需求,renderForm会快速膨胀导致维护成本的提高。 所以,当你的系统会出现可以预见的需求范围的扩展,请考虑使用动态表单这样的实现方式。

要解决的问题#

为了实现这样的表单,我们需要处理以下问题: - 提供表单中可能出现的组件(可控组件),并统一组件的onChange,value,label等props。 - 设计状态管理中,数据的存储方式。方便组件间进行交互,联动和最终的表单提交。 - 设计一个UI渲染引擎,能通过接收配置文件的方式,正确渲染UI。 - 提供统一Actions,包括onChange onInValid onSubmit onCancel等Form中常见的Actions。 - (可选)当Form的State受Form外的操作出现频繁变更时的性能优化。

基础知识#

阅读下面的内容时,需要的基础知识: - typescript - React - React的常见hooks,如useContext useReducer useState 接下来内容中出现的代码,将会使用到它们。

概要模块设计#

对于这样动态表单系统,大致由这4个模块来组成: 1. UI 渲染引擎 2. 表单字段组件。 3. 状态管理 4. 表单操作函数 接下来,我们来逐一了解如何设计这三个模块。

UI render#

对于UI渲染引擎,我们一般有两种数据结构可以选择。 一是一维的数据结构,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
enum ComponentTypeEnum {
text = 0,
select = 1,
checkbox = 2,
container = 3
}

interface FormFieldUIProps {
value: string | number | boolean;
label: string | ReactNode;
componentType: ComponentTypeEnum;
name: string; // unique
parentField: string; // it will save other field name
// The componentsProps should from field component. e.x the select component should has options;
required?: boolean;
componentsProps: Props
};
type FormUIStruct = FormFieldUIProps[];
另一个是嵌套的数据结构,如:
1
2
3
4
5
6
7
8
9
10
interface FormFieldUIProps {
value: string | number | boolean;
label: string | ReactNode;
componentType: ComponentTypeEnum;
name: string;
required?: boolean;
components: Props;
children: FormFieldUIProps[];
};
type FormUIStruct = FormFieldUIProps[];
这里我们先对比下这两种数据结构特性:

一维数据结构 嵌套数据结构
渲染时 需要转换为嵌套结构渲染 可以直接渲染
查找表单项时 直接通过name进行查找 需要使用DFS进行查找
可读性 和DOMTree完全不同,很难看出从属关系 和DOMTree中的从属关系高度近似
复用性 需要考虑name的唯一性 多层嵌套存在天然的隔离,name不需要唯一

这两种数据结构没有明显的优劣,它们在不同的场景有各自的用处。 比如,根据一维数据结构的特点,如果我们的表单经常出现对于表单项的CRDU操作,那么它的检索速度快这个特点就很适合这个场景。 如果我们的表单配置需要出现大量的作为children复用,那么嵌套结构很适合这个场景。 所以,基于上述情况,我们应该结合具体的使用场景,来选择要使用的数据结构。

PS:为了更直观的描述这个系统的构成,笔者将使用嵌套数据结构做为UI渲染的数据结构。

State management#

PS:为了更好的表述系统的设计,笔者将尽量减少引入三方依赖,因此,状态管理将使用React的Context和hooks来实现。 如上所述,状态管理需要收集表单项的值的变更,并且通知和其关联的组件做出更新。在表单完成填写后,能支持提交和取消更改的操作。 基于此,我们需要如下的代码:

1
2
3
4
5
6
7
8
interface FieldValueCollection {
[key in string]: FormFieldUIProps['value'];
};
interface FieldActionsCollection {
submit: (fieldValues: FieldValueCollection) => void;
reset: () => void;
handleValueChange: (cachePath: string, value: FormFieldUIProps['value']) => void;
}

Action Design#

1
2
3
4
5
6
7
type validForm = () => {
[key in keyof FieldValueCollection]: message;
} | undefined
type submitForm = () => void;
type resetForm = () => void;
// we want change run some actions when the value updated in path
type getValueByPath = (path: keyof FieldValueCollection) => FormFieldUIProps['value'];

使用DSL描述表单#

现在我定义了描述表单的数据结构。现在我们将这个数据结构做为DSL解释器函数的输入来生成一个完整的表单,这个函数实现如下:

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
const renderForm = (conf: FormUIStruct) => {
const travelConf = (conf?: FormUIStruct, combinePath: string = '', listIndex: number =NaN) => {
conf?.forEach((item, index) => {
if (!item) {
return null;
}
const { children, componentType, name } = item;
if (componentType === ComponentTypeEnum.container) {
const newCombinePath = isNaN(listIndex) ? `${combinePath}.${name}` : `${combinePath}.${listIndex}.${name}`;
return (
<FieldContainer key= { combinePath } path = { combinePath }>
{ travelConf(children, newCombinePath)}
</FieldContainer>
)
}else if (componentType === ComponentTypeEnum.list){
const newCombinePath = `${ combinePath }.${ name } `;
return (
<FieldListContainer key= { combinePath } path = { combinePath } >
{ travelConf(children, newCombinePath, index)}
</FieldListContainer>
)
}
else{
const newCombinePath = `${ combinePath }.${ name } `;
return (
<FieldComponent { ...item } key = { combinePath } path = { newCombinePath } />
)
}
})
}
return travelConf(conf);
}

从CRA将react项目迁移成微前端项目-1

从CRA将react项目迁移成微前端项目 —— 目录结构的确定和构建方式的更改#

基础知识#

阅读之前你需要知道的知识包括

为什么需要迁移至微前端?#

随着项目不断迭代,原有的项目体积在不断增大。伴随而来的是功能和依赖数量的快速增长。这使得整体项目将越来越难以维护。 并且如果在一个基础旧的功能上进行更新,我们又希望能做到最小代价的开发、测试和构建的话,那么将原有的单体架构拆分成更小的单元,这将是势在必行的。 本篇文章将会以这个项目的迁移为例,讲解整个迁移过程中的思考和实现。

拆分前功能分析#

现有目录结构(点击收起/展开)
├── src
│   ├── assets
│   │   ├── fonts
│   │   ├── lib # 需要改动开源包
│   │   │   ├── redux-undo
│   │   │   └── ruler
│   │   └── style
│   │       └── datePicker
│   ├── components
│   │   ├── base 
│   │   ├── common # 系统中的通用组件,包括alert,dialog等
│   │   ├── comps # 系统中的展示数据的组件
│   │   │   ├── codeFragment
│   │   │   ├── commonTitle
│   │   │   ├── datasource
│   │   │   ├── echarts
│   │   │   ├── group
│   │   │   └── _template_
│   │   ├── form # 系统中的表单组件
│   │   └── recursion # 系统中的表单组件生成器
│   │       ├── echarts
│   │       └── widget
│   ├── configurableComponents # 配置化的组件,通用的表单和系统UI主题
│   │   ├── form
│   │   └── theme
│   │       └── overrides
│   ├── helpers # 工具函数,数据解析、后端交互
│   ├── page # 页面结构
│   │   ├── canvas
│   │   └── editor
│   │       ├── Header
│   │       ├── LeftPanel
│   │       ├── RightPanel
│   │       └── User
│   ├── service # 数据模型及处理
│   ├── store # 前端状态管理及本地数据持久化
│   │   ├── DB
│   │   ├── features
│   │   │   └── appSlice
│   │   └── reducers
│   ├── __test__ # 单元测试
│   │   ├── components
│   │   │   └── recursion
│   │   └── utils
│   │       └── MockData
│   ├── @types # 公共类型及包类型overwrite
│   └── utils # 工具函数
  

从目录中看,需要抽离的功能包含:

  • 部分工具函数
    • 一部分同时提供给多个,或只提供给抽离后的项目使用。
  • 系统中的展示数据的组件
    • 期待这些组件可以拥有自己的版本控制
    • 并且可以在线热更新
  • 系统中的表单组件生成器
    • 这里的类型需要提供给数据展示组件
    • 抽离后的项目可能使用,比如鉴权模块
  • 部分系统中的通用组件
    • alert这类组件会在所有组件被引用
    • 原有通用组件功能增加,需要关注的问题减少
  • 配置化的组件

基于monorepo重建目录结构#

为什么选择monorepo?#

从以下角度出发:

  • 为拆分后的功能提供独立的版本控制和依赖管理。
  • 使得CI可以按照目录维度进行独立的构建,减少构建的时间。
  • 独立构建后产生功能维度的预发版本,可以更加充分的进行测试。
  • 可以简单为抽离后的项目提供统一版本的公共依赖
  • 可以结合 pnpm workspace 减少的坏境创建的link,对本地全局环境的污染

抽离的原则#

这里以系统中Notice组件为例。

Notice组件,顾名思义,这是用来处理系统中所有弹出式通知的组件。只要用户的操作行为,在业务上被定义为需要告知给用户的,都会使用它对消息内容进行展示。它被系统大部分功能依赖,例如现有目录中的:

  • 表单组件生成器

  • 数据模型及处理

  • 前端状态管理

  • 部分工具函数

    如果对该组件进行抽离,可以预见的是会产生大量的文件修改和测试部分重写。即使花费如此高昂的代价,也要对这类组件进行抽离,对这种行为,我一般遵循这几个原则:

  1. 该功能需要被其他抽离的组件调用。
  2. 该功能未来可能会产生可预见较为频繁的修改。
  3. 需要提供在线的热更新。

这里着重说一2和3。

2中提到的 功能可预见较为频繁的修改 我们可以从notice组件的迭代得到答案。

例如,目前系统提供的notice只是提供了单纯的消息展示,并且它的消失时机是几个可选择的常量。如果说后续出现一个消失时机来自不同组件的hook或其他事件的需求。这些碎片化的需求,可能就会使得notice频繁进行发版。

进行拆分之后,我们不必因为某个小功能,对整体系统重新构建。而只需要构建单个功能。

同时结合 3之后,我们对此类功能,抛弃传统的webpack将所有依赖打包成同一个bundle(这里先不讨论 async import),无论是使用script + ESM还是cjs + new Function的模式,我们都能在不进行大规模系统构建的前提下,完成对某个功能更新

为每个子项目添加自己的 package.json 和 tsconfig.json#

这是为了让抽离后的项目获得下面的特性:

  • 独立的配置管理,如 alias path
  • 独立的依赖,如A组件依赖了a包,但是系统中其他组件没有依赖
  • 独立的版本控制,这里是指 package.json中的version字段

重建后的目录结构#

整体项目目录结构
.
├── common # 通用组件
│   ├── codeEditor
│   ├── dynamicImport
│   ├── notice
│   ├── recursion
│   └── theme
├── core # 项目主入口
│   ├── config
│   ├── public
│   ├── scripts
│   └── src
├── dataComp # 需要热更新和运行时存在多版本的组件
│   ├── codeFragment
│   ├── commonTitle
│   ├── datasource
│   ├── echarts
│   └── group
└── workspace
    └── devServer # 本地开发命令集
    
common下项目的一般结构
├── package.json
├── pnpm-lock.yaml
├── README.md
├── src
│   ├── index.tsx
│   └── lib.d.ts
└── tsconfig.json    
    
dataComp下项目的一般结构
├── package.json
├── pnpm-lock.yaml
├── src
├── tsconfig.json
└── webpack.config.js # 可能用到的独特的配置,将会被merge到运行的webpackconfig中
    

如何构建抽离后的项目#

当目录重构后,我们需要对原有的构建流程进行改造。由于项目原本是通过create-react-app这个命令创建的,但是后续的修改,不可避免的会对webpack的配置产生大量的修改,所以第一步我们需要运行 eject命令,使得我们可以续改项目的构建配置。 ### 构建模式的选择 —— 单独打包 or 统一打包 运行eject后,我们需要确定对于抽离后的项目,是选择每个项目都配置单独的构建流程,还是使用一个通用的构建方式。

这里出于下面点考虑,我选择了使用通用构建方式:

  • 开发成本低,不必为每个抽离后的项目,开发单独的webpack配置

  • 原本是从单体项目中抽离的,所以构建流程大体一致

  • 更容易控制依赖的版本

    同时,我将 /core目录称之为主项目,它将提供所有项目的构建配置。

在主项目中引用抽离的项目#

  • dev环境的package(重建后目录中common部分):使用pnpm link创建抽离项目的软连接并引入
  • dev环境的component(重建后目录中dataComp部分):调用主项目的webpack进行构建后,通过本地的dev服务发送静态资源文件(bundle.js),在主项目中使用 new Function 的方式引入
  • prod环境的package:在主项目中运行 pnpm install, 以module的方式引入。
  • prod环境的component: CI中在每个项目目录中运行构建命令,通过nginx的location,以目录名称为路由地址,提供静态资源文件(bundle.js),在主项目中使用 new Function 的方式引入

如何在dev环境维持抽离后的module局部热更新#

确定构建模式后,为了提供良好的开发体验。我们仍然期望,抽离后的代码在开发环境出现更新时,项目仍能提供局部热更新的能力。

为了实现这个需求,我们需要:

  1. 扩展主项目webpack的构建范围。
  2. 使用link命令,创建一个基于软连接的本地依赖。

实现1,需要完成向webpack添加:

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
60
61
62
63
64
65
66
67
68

/**
* @returns {string[]}
*/
const resolveAppsRoot = () => {
const commonPath = path.resolve('../common');
const deps = fs.readdirSync(path.resolve('../common'));
return deps.map((pathVal) => {
return path.join(commonPath, pathVal);
});
};

/**
*
* @param {string[]} mainModulesPath
* @returns {string[]}
*/
const getValidCompilerModulesPath = (mainModulesPath) => {
const appsRoot = resolveAppsRoot();
return mainModulesPath.concat(
appsRoot.reduce((curr, rootPath) => {
return [...curr, path.join(rootPath, 'src'), path.join(rootPath, 'node_modules')];
}, [])
);
};

/**
* @param {string} appSrc
* @returns {string[]}
*/
const resolveAppsSrc = (appSrc) => {
const commonPath = path.resolve('../common');
const deps = fs.readdirSync(path.resolve('../common'));
return [
appSrc,
...deps.map((pathVal) => {
return path.join(commonPath, pathVal, 'src');
}),
];
};

// webpack config modify

{
//...
resolve: {
//...
modules: getValidCompilerModulesPath(
['node_modules', paths.appNodeModules].concat(modules.additionalModulePaths || [])
),
//...
},
//...
module:{
//...
rules: [
//...
{
test: /\.(js|mjs|jsx|ts|tsx)$/,
include: isEnvDevelopment ? resolveAppsSrc(pathsappSrc) : paths.appSrc,
loader: require.resolve('babel-loader'),
//...
}
],
//...
}
//...
}
这里解释一下这些改动的意义:

  • module.rules 的修改, 扩展了webpack的构建范围,可以使用主项目中的babel(支持typescript),编译抽离后的项目。
  • resolve.modules 的修改,是让主项目webpack可以解析抽离后项目的独立依赖。
  • resolveAppsSrc 函数处理抽离后项目的需要编译的路径
  • getValidCompilerModulesPath 数处理抽离后项目的依赖需要编译的路径

控制所有项目中react的版本&单一实例控制#

step1: 在项目的根目录下创建pnpm-workspace.yamlpackage.json

step2: 然后在根目录运行如下命令:

1
pnpm add -w react react-dom
step3: 在抽离后的项目(如common/recursion)目录中运行

1
pnpm add --save-peer react react-dom
step4: webpack的修改如下
1
2
3
4
5
6
7
8
9
10
11
12
{
//...
resolve: {
//...
alias: {
//...
react: path.resolve('../node_modules/react'),
'react-dom': path.resolve('../node_modules/react-dom'),
//...
},
}
}

逐条解释它们的作用

  • step1: 提供一个 pnpm 的 workspace。使得抽离后的项目中的依赖可以从工作区共享。减少抽离后的项目的整体体积。
  • step2:将react添加到工作区中
  • step3:将抽离后项目的react依赖,从生产环境构建时剔除
  • step4:为抽离后的项目提供了单一实例的react和react-dom,这在固定了react版本的同时,解决了react hook要求项目只能包含一个react实例的问题。但这里没有完全解决,context共享问题,在多个项目共享一个带有useContext的依赖时,会出现undefined的问题

多项目context共享#

以formik为例,需要修改主项目的webpack

1
2
3
4
5
6
7
8
9
10
11
{
//...
resolve: {
//...
alias: {
//...
formik: path.resolve('../node_modules/formik'),
//...
},
}
}
一些使用新版 CRA 的项目还需要修改ModuleScopePlugin,来放开对于依赖范围的检测

提供alias功能#

我们在开发中,经常会遇到引用一些公共函数的需求,但是,如果引用的层级太深,难免会出现形如 import moduleFunc from '../../../../../utils/getData'的路径。这样的路径,可读性差,并且如果出现整体目录迁移并且引用该功能的文件非常,会使得这些文件都出现修改。

所以一般我们都会使用形如import moduleFunc from @utils/getData;的方式进行优化。

但如果在抽离后的项目使用这样的特性,需要对主项目的webpack再做一些更改,这是因为由于构建命令的执行目录在主项目下,它们的相对路径,并没有对应到抽离后的项目目录。需要对webpack做出如下修改:

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
//webpack config
{
//...
resolve: {
//...
alias: {
//...
...(getValidCompilerPaths(modules.webpackAliases) || {}),
//...
},
}
}
/**
* 获取alias真实的编译路径
* @param {Record<string,string>} alias
* @returns {Record<string,string>}
*/
const getValidCompilerPaths = (mainAlias) => {
const appsRoot = resolveAppsRoot();
return appsRoot.reduce((curr, next) => {
const alias = handleTSAlias(next);
return {
...alias,
...curr,
};
}, mainAlias);
};
/**
*
* @typedef {object} Options
* @property {string} options.baseUrl
* @property {{
* [key: string]: string[]
* }} options.paths
* 获取webpack使用的alias的绝对路径
*
* @param {Options} options
* @param {{
* rootPath: string;
* }} extension
*/
function getWebpackAliases(options = {}, extension = {}) {
const { baseUrl, paths: aliasPath } = options;
const { rootPath } = extension;

const appPath = rootPath ? rootPath : paths.appPath;
const appSrc = rootPath ? path.join(rootPath, baseUrl) : paths.appSrc;

if (!baseUrl) {
return {};
}

const baseUrlResolved = path.resolve(appPath, baseUrl);

if (path.relative(appPath, baseUrlResolved) === '') {
return {
src: appSrc,
};
}
if (isEmpty(aliasPath)) {
return {};
}
const aliasPathKeys = Object.keys(aliasPath);
const result = aliasPathKeys.reduce((prev, curr) => {
const aliasPathArr = aliasPath[curr];
return {
...prev,
[curr.replace('/*', '')]: path.resolve(appSrc, aliasPathArr[0].replace('/*', '')),
};
}, {});
console.log(result);
return result;
}

/**
* 获取对应项目的tsconfig
* @param {string} rootPath
* @returns {Record<string, string[]>}
*/
const getTSOption = (rootPath) => {
const appTsConfig = path.join(rootPath, 'tsconfig.json');
const hasTsConfig = fs.existsSync(appTsConfig);
if (!hasTsConfig) {
throw new Error('sub-project tsconfig is not exist');
}
const ts = require(resolve.sync('typescript', {
basedir: paths.appNodeModules,
}));
const config = ts.readConfigFile(appTsConfig, ts.sys.readFile).config;
return config.compilerOptions || {};
};

/**
* 处理TS中声明的alias,使得tsconfig中的alias能与webpack对应上
* @param {string} rootPath
* @returns {string}
*/
const handleTSAlias = (rootPath) => {
return getWebpackAliases(getTSOption(rootPath), { rootPath });
};

整体项目typecheck#

在上一个小节中,完成对alias的解析,但是,会出现ts类型错误,如:无法找到模块@utils。这是因为上面我们只解决了,webpack的编译打包流程,但类型检查仍没有提供抽离后的项目和其目录的对应关系。

这里我们需要将类型检测的范围扩展到整体项目范围,并提供对应的文件路径关系

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
// webpack config 
{
//...
plugins: {
//...
new ForkTsCheckerWebpackPlugin({
//...
typescript: {
//...
configOverwrite: {
//...
compilerOptions: {
references: getTypeCheckPaths(),
}
//...
}
//...
}
//...
}),
}
}

const getTypeCheckPaths = () => {
const appsRoot = resolveAppsRoot();
const formatPaths = (aliasPath, appRoot) => {
return Object.keys(aliasPath).reduce((curr, next) => {
const item = aliasPath[next];
return {
...curr,
[next]: path.join(appRoot, item[0]),
};
}, {});
};

const result = appsRoot.reduce((curr, next) => {
//getTSOption的实现参考上面
const { paths: aliasPath } = getTSOption(next);
if (isEmpty(aliasPath)) {
return curr;
} else {
return { ...curr, ...formatPaths(aliasPath, next) };
}
}, {});
return result;
};

后续计划#

dev环境提供构建特定modules的命令#

上面的一系列改动,解决了抽离后整体项目的构建问题,但目前的构建方式仍然是全量进行构建,预期是通过一个可交互的command,让用户可以选择构建那些抽离后的项目。没被选中的,使用pnpm add <package-names>,从公共仓库安装到主项目中。

实现部分组件的在线热更新#

dataComp中的项目均需要提供在线热更新。

dev环境将提供一个dev server提供编译后的bundle.js

prod环境将使用nginx为不同项目的编译产物提供静态服务。

主项目都将使用 fetch + new Fucntion的方式引入此类组件。

允许组件在运行时同时存在多版本#

dataComp中的项目对于不同的用户,可能同时需要存在不同的版本。这需要在它的URL信息中加入版本信息,用来加载不同版本的编译产物。

JS开发中函数式编程的一些经验

JS开发中函数式编程的一些经验#

基础知识#

阅读之前你需要知道的知识包括

  • 什么是副作用

  • 什么是高阶过程 ## 减少副作用 以下是wiki对于副作用的定义。 > 在计算机科学中,函数副作用指当调用函数时,除了返回可能的函数值之外,还对主调用函数产生附加的影响。例如修改全局变量(函数外的变量),修改参数,向主调方的终端、管道输出字符或改变外部存储信息等。
    >在某些情况下函数副作用会给程序设计带来不必要的麻烦,给程序带来十分难以查找的错误,并降低程序的可读性与可移植性。严格的函数式语言要求函数必须无任何副作用,但功能性静态函数本身的目的正是产生某些副作用。在生命科学中,副作用往往带有贬义,但在计算机科学中,副作用有时正是“主要作用”。 ### 为什么要减少副作用 在数学中我们会遇到这样的函数y=x+1,一旦我们确定了x的值,那么无论我们在什么时候使用这个函数,得到的y的值始终不会发生变化。
    即使,更为复杂的数学公式,比如 \[ \begin{align*} \frac{n(n-1)}{2n} \end{align*} \] 也符合上述规律。 在程序开发中,我们也会定义一些函数,但这里面的一些函数会随着不同时间和上下文调用出现变化。一个简单的例子:

    1
    2
    3
    4
    5
    6
    7
    let variable = 1;
    const printVariable () => {
    variable++;
    console.log(variable)
    }
    printVariable() //输出: 2
    printVariable() //输出: 3
    一旦这样的函数被大量使用,尤其是作为公共函数在多人开发使用的时候时,会存在一些隐患。用下面的代码来说明这个问题:
    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
    // util.ts
    type User = {
    root: 'normal' | 'admin' | 'guest'
    }
    export const users:User[] = [{
    root: 'guest'
    },{
    root: 'admin'
    },{
    root: 'normal'
    },];
    export const converAllUsersTonNormal = () =>{
    users.forEach((item)=>{
    if(item.root !== cacheUser.root){
    item.root === 'normal'
    }
    })
    }
    // developerA.ts
    import {users} from '@path/util'
    const isAllUsersNormal = () =>{
    return users.every(item=>{
    if(user.root !== 'normal'){
    return true;
    }
    return false;
    })
    }
    // developerA.ts
    isAllUsersNormal() // converAllUsersTonNormal没有在任何地方调用,输出 false
    isAllUsersNormal() // converAllUsersTonNormal被调用过,输出 true
    显而易见的是,在上面的例子中,如果无法确定两个开发者提供的函数调用次数,那么最终我们得到的结果将是无法确定的。
    所以,减少副作用就可以减少上述的情况出现,尽可能的降低bug的出现。以下我列举了两个减少副作用的方式。

使用函数替代一些简单的赋值#

比如下面的代码

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
type response = {
code: number;
}
const getResponseMsg = () => {
let result = '';
if(respose.code === 200){
result = 'success'
}else{
result = 'error: '
if(respose.code === 404){
result += 'url not found'
}
if(respose.code === 500){
result += 'server has error'
}
result += 'unknown error'
}
return result;
}
// 用函数替代后
const getResponseMsgFunctional = () => {
if(respose.code === 200){
return 'success'
}else{
const getErrorMsg =(msg:string)=> 'error: '+ msg;
if(respose.code === 404){
return getErrorMsg('url not found');
}
if(respose.code === 500){
return getErrorMsg('server has error');
}
return getErrorMsg('unknown error');
}
return result;
}
这里可以看到 getResponseMsgFunctionalcode !== 200时的处理,用getErrorMsg替换了原本,对result重新赋值的操作。再消除了副作用的同时,也增强了代码的可拓展性和内聚程度,因为一旦之后的有新的需求,可能对errorMsg的前缀产生影响, 那么后续的更改,可以完全在getErrorMsg中进行。

对于引用类型的修改#

在JS中,引用类型的修改从来都是非常容易出现BUG的操作之一。比如,一个引用类型的变量暴露给多个开发者使用。 这里推荐用函数替代对于引用的直接修改。比较成熟的方案如redux
虽然我们用诸如redux的方案解决了直接修改引用类型,带来的不确定性问题。但同时,这样的设计也存在一些性能问题。
主流的前端框架中,如果一个组件的props是一个引用类型,那么确定该组件是否需要更新,一般都是进行引用的直接对比。这时,如果一个深层redux对象被共享给了多个组件,那么某一层的更新,可能会引起其他组件的不必要更新。为了解决这个问题,我们可能需要做很多额外的 工作,来确定该组件是否真的需要更新。

高阶过程,自上而下的设计#

在开发过程中,我们不可避免的会遇到一些非常复杂的需求。可能是需要重构一个关联了很多其他模块的函数,可能是深度遍历一个复杂对象并根据每层对象的一些属性调用一些其他的函数。 遇到这些复杂的情况,我们可以用高阶过程去解决这类问题。 一个简单的例子,用递归便利数组。

1
2
3
4
5
6
7
8
9
10
11
12
const arrayIterator = (
arr: any[],
condition: (...arg?: any[])=> boolean,
action: (...arg?: any[])=>void,
) =>{
if(condition()){
action()
return arrayIterator(arr.slice(1),condition,action)
}else{
return;
}
}
事实上我们可以这样看待上面的代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

const dataOperation = (data: any) =>{
// do something for generate `newData`
return newData
}

const arrayIterator = (
data: any,
condition: (...arg?: any[])=> boolean,
recursionAction?: (...arg?: any[])=>any,
recursionEndAction?: (...arg?: any[])=>any,
) =>{
if(condition()){
recursionAction()
return arrayIterator(dataOperation(data),condition,action)
}else{
return recursionEndAction()
}
}
这意味着,大部分递归都可以用这样的方式进行拆分。拆分后的递归将拥有很强的拓展性。而且维护每个部分的心智负担将降低,修改某个部分只需要关注函数内部逻辑,而不用整体的考虑。

增量运行E2E测试

基础知识#

阅读之前你需要知道的知识包括

要解决的问题#

随着项目不断的迭代,新的功能在增加,旧的功能也逐渐被更新。这导致E2E测试的体量在不断的增大。 但是,我们的每次修改不一定会涉及所有的E2E测试。所以,我们是否可以做到只运行本次commit影响的E2E?

问题拆分 & 分析#

我将问题拆分为下面几个子问题:

  1. 如何界定作为对比的基础commit
  2. 如何获取这个commit的id
  3. 如何获取本次commit和基础commit之间的更新
  4. 如何只运行更新的文件并打印运行过程中的日志消息

问题分析#

  • 对于问题1:
    • 通常情况:一般我们需要对比的是本次commit和当前分支刚被创建出时的commit,这是由于,我们的分支一般会从最新的master分支获取,而master分支中的E2E在大部分情况下,都是已经被验证过的。
    • 和特定commit对比:一些需要和特定commit做对比以排查特定更改,引起的bug时。这里特定的commitID,需要我们在commit message中加入特定的内容,进行标记。
    • 运行全部的E2E:项目正式更新至线上时,还是期待完整的E2E测试,能为我们带来高的可用性。这里运行全部的E2E,需要我们在commit message中加入特定的内容,进行标记。
  • 对于问题2和3: 使用nodejschild_process模块调度Git命令,并使用正则的方式获取需要的内容。
  • 对于问题4: 使用nodejschild_process模块调度Cypress命令,并将输出使用Steam进行实时输出

解决方案#

获取用于对比的基础commitID#

  • 对于通常情况使用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    const getBranchFirstCommitID = () => {
    return new Promise<string>((res, rej) => {
    // compare with master
    exec('git cherry -v master', (err, data) => {
    if (err) rej(err);
    const dataArr = data.split('\n') as string[];
    const commitContent = dataArr[0].split(' ');

    res(commitContent[1]);
    });
    });
    };

  • 对于和特定commit对比的情况使用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    const getCurrentCommit = () => {
    return new Promise<string>((res, rej) =>
    exec(`git log -1`, (err, stdout, stderr) => {
    if (err) {
    rej(stderr);
    }
    res(stdout);
    })
    );
    };
    const getCompareCommitID = async () => {
    const commitContent = await getCurrentCommit();
    const regx = /\[Compare: (.+?)\]/;
    if (!regx.test(commitContent)) {
    return await getBranchFirstCommitID();
    }
    const resultArray = commitContent.match(regx) as RegExpMatchArray;
    return resultArray[1];
    };
    这里如果运行getCompareCommitID后,得到返回值为'ALL'的时候,就会运行所有的E2E测试了

获取两个Commit之间的更新#

这里我们约定,所有的E2E文件的路径中都会携带cypress这个字符

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

const getDiffFlies = (baseCommitID: string, commitID: string) => {
return new Promise<string>((res, rej) =>
exec(`git diff --name-only ${baseCommitID} ${commitID}`, (err, stdout, stderr) => {
if (err) {
rej(stderr);
}
res(stdout);
})
);
};

const getCommitID = (index: number) => {
return new Promise<string>((res, rej) =>
exec(`git show HEAD~${index} --pretty=format:"%h" --no-patch`, (err, stdout) => {
if (err) {
rej(err);
}
res(stdout);
})
);
};

//一些特殊的项目结构下,项目中可能存在多个子系统,所以这里需要匹配特定的路径
const getProjectDiffFiles = async () => {
try {
const compareCommitID = await getCompareCommitID();
const currentCommitID = await getCommitID(0);
if (compareCommitID === 'ALL') {
// it will run all tests
return [];
}

const diffFiles = (await getDiffFlies(currentCommitID, compareCommitID)).split('\n');
const currentProjectDiffFile = diffFiles
.filter((file) => {
const {dir} = parse(file);
const currentDirArr = __dirname.split(sep);
const currentDir = currentDirArr[currentDirArr.length - 2] as string;
if (dir.includes(currentDir) && dir.includes('cypress')) {
return true;
}
return false;
})
.map((file) => {
return join(...file.split(sep).slice(2)) as string;
});
return currentProjectDiffFile;
} catch (error) {
console.log(error); // eslint-disable-line
throw new Error('get commit id is fail');
}
};

只运行更新的文件并打印运行过程中的日志消息#

nodejs的child_process模块提供了多种方式运行命令,一般情况下使用exec,但由于exec的输出将会在命令完全运行后才输出,并且一般的CI中,都会设置每一步的响应时间。如果使用exec,有可能会由于E2E时间过长,导致超时。 所以,这里我使用spawn,它将会实时的输出命令的运行日志。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const testRunner = (specPath: string[]) => {
console.log('It will run test', specPath.join(',')); // eslint-disable-line
const runner = spawn('cypress', ['run', '--headless', '--spec', specPath.join(',')]);
runner.stderr.on('data', (data) => {
if (data) console.log(data.toString()); // eslint-disable-line
});
runner.stdout.on('data', (data) => {
console.log(data.toString()); // eslint-disable-line
});
};

const main = async () => {
const testPath = await getProjectDiffFiles();
testRunner(testPath);
};

至此就完成了增量的运行E2E测试。

monorepo结构下启动react项目局部热更新

基础知识#

阅读之前你需要知道的知识包括

遇到的问题#

  1. 多子项目单独开发时,如何确定react版本?
  2. 如何保证主项目加载子项目作为组件时,二者公用同一个react实例?
  3. 开发时,如何处理互相存在依赖子系统?
  4. 外置化加载react&react-dom时,hot-reload失效

问题原因&分析#

  • 为什么会引入问题[1]:这是因为使用用monorepo这个结构时,我们希望所有的子系统和主系统(系统的入口),使用同一个react版本,以便系统整体的核心依赖的控制,并且减少多版本兼容带来的额外的bug。
  • 为什么会引入问题[2]:
    • hooks的使用要求二者必须使用的是一个react实例
    • 对于context,它的使用也依赖于同一react实例
  • 为什么会引入问题[3]:当我们同时满足问题[1]和[2]时,react和react-dom将不能被打包到一个bandle包中,所以在webpack层面,我们需要使用external,排除react。与此同时,由于主流的用于处理react热更新的插件,react-refresh-webpack-plugin对于外置加载的react core有严格的顺序要求,所以导致了热更新的失效

如何解决问题?#

多子项目单独开发时,如何确定react版本,以及如何保证主项目加载子项目作为组件时,二者公用同一个react实例#

我们期待所有这个系统中,所有项目的react依赖都指向同一个react core文件,这样在系统所有项目都构建完成后,react的版本就确定了下来,所以我们可以修改webpack.config.js和项目的HTML模版来达到这个目标: 对于webpack.config.js增加:

1
2
3
4
5
{
//...
external: ['react','react-dom']
//...
}
对于HTML模版(这里的具体语法,需要根据具体使用的模版解析器语法来):

1
2
<script data-tn="react-bundle" type="text/javascript" src="{{reactBundlePath | safe}}"><script>
<script data-tn="react-dom-bundle" type="text/javascript" src="{{reactDomBundlePath | safe"></script>

这里的 reactBundlePathreactDomBundlePath是react单独编译后的结果,对应的是两个js文件。

至此react core就完成外置化,所有的子项目在单独开发时,都可以修改自己的HTML模版,以使用公共的react core,并且主系统和子系统的react实例始终一直,在

开发时,如何处理互相存在依赖子系统#

在webpack5之前,可以在webpack中增加:

1
2
3
4
5
6
7
8
9
10
{
//...
resolve:{
//...
alias:{
'<module_name>': 'project/root/path/provide/modules'
}
}
//...
}
这样就可以轻松的通过 import * as MouduleName from 'module_name'的方式使用另一个子项目暴露的功能了。

外置化加载react&react-dom时,hot-reload失效#

由于react-refresh-webpack-plugin对于外置加载的react core有严格的顺序要求,所以我们需要修改项目打包的输出,由原来的单入口,改为多入口。并且在HTML模版中控制它们的加载顺序。

对于webpack.config.js需要修改:

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
{
//...
entry: isDevelopment
? {
whm: 'webpack-hot-middleware/client?quiet=true&reload=true&path=/<default>/<route>/__webpack_hmr&timeout=2000', //启用webpack-hot-middleware作为热更新服务时需要增加的
reactRefreshEntry: '@pmmmwh/react-refresh-webpack-plugin/client/ReactRefreshEntry.js', //让react产生局部更新
main: clientEntry, //项目本身的入口文件
}
: clientEntry,//项目本身的入口文件
//...
module: {
rules: [
{
oneOf: [
{
test: /\.(js|ts|tsx)$/,
exclude: /node_modules/,
loader: 'babel-loader',
options: {
plugins: ['lodash', isDev && require.resolve('react-refresh/babel')].filter(
Boolean
),
presets: [['@babel/env']],
},
},
],
},
],
},
//...
plugins: defultPlugins.concat(isDevelopment ? [
new webpack.HotModuleReplacementPlugin(),
new webpack.NoEmitOnErrorsPlugin(),

new ReactRefreshWebpackPlugin({
overlay: {
sockIntegration: 'whm',
},
}),
]:[])
}

对于HTML模版(这里的具体语法,需要根据具体使用的模版解析器语法来):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{% if isDev%}
<script type="text/javascript" src="{{ bundlePath | safe }}/client.bundle.js"></script>
<script type="text/javascript" src="{{ bundlePath | safe }}/vendors.client.bundle.js"></script>
<script type="text/javascript" src="{{ bundlePath | safe }}/whm.client.bundle.js"></script>
<script type="text/javascript" src="{{ bundlePath | safe }}/reactRefreshEntry.client.bundle.js"></script>
{% endif %}
<!-- ..... -->
{% if isDev%}
<script type="text/javascript" src="{{ bundlePath | safe }}/main.client.bundle.js"></script>
{% else %}
{% for url in clientBundle %}
<script type="text/javascript" src="{{ bundlePath }}/{{ url | safe }}"></script>
{% endfor %}
{% endif %}

至此就完成在monorepo下,react项目的局部热更新。

High performance grouped list design

Overall goal#

  1. nested relationships exist in the grouping and there is no theoretical upper limit to the depth
  2. Drag and drop elements out of the grouped list to create grouping relationships
  3. ungrouped elements can be dragged into the group to create new grouping relationships
  4. When ungrouped list items are moved, they will automatically cross over the group and its subcomponents
  5. When ungrouped list items are grouped, the relative order before grouping should be maintained
  6. when grouped list items are ungrouped, the relative order before grouping should be maintained
  7. the above operation should also be valid for the direct operation of grouping (here the grouping is also operated as a list item) ## Analysis
  • Due to Objectives 1&5, the data structure should be kept in a one-dimensional structure, i.e. in the form of an array of objects. Such a data structure provides the base order of list items and facilitates maintaining the relative order of list items when creating groupings.
  • For objectives 2&3&5&6, the relative position of the grouped list items within the group should be recorded when calculating whether to create/update/delete grouping relationships for drag and drop items, to facilitate sorting the position of the list when the grouping relationships change
  • For Objective 7, grouping should be included as one of the list items. Provide the "type" field as a distinction between grouped list items and other lists, for possible expansion of the grouping expand/collapse function.
  • Render the list with a multi-dimensional structure to facilitate recursive rendering of the list, friendly to jsx syntax. ## Data structure design

List item data structure

1
2
3
4
interface ListItem {
code: string;
groupCode: string;
}

List data structure

1
type List = ListImte[]

Auxiliary data structure when updating grouping

1
2
3
4
5
type GroupStack = {
groupCode: string;
index: number; // the real subscript of the group
offsetNumber: number // the length of the group, for recording the relative position of the list items in the group
}[]

The data structure used for react rendering

1
2
3
4
5
interface AssistStruct {
code: string;
children?: AssistStruct[];
parentGroupCode?: string; //pop stack flag
}

Algorithm selection#

One-dimensional object arrays into nested structures Design.#

Detect group closure,The algorithm is a variant of the bracket closure algorithm.

If the group-code field in the current list item is not equal to the code at the top of the stack, the group is closed and the current stack top element is popped.

Specific implementation#

One-dimensional array of objects converted to a nested structure implements.#

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
/**
* Convert a one-dimensional array to a multi-layer structure
* @param compCodes The code of all components
* @param compDatas the data of all components
* @returns returns the nested structure associated with code, the
*/
const subList = (compCodes: string[], compDatas: JDV.State['compDatas']): AssistStruct[] => {
let groupStack: GroupStack[] = [];
const resultData: AssistStruct[] = [];

const stackPop = (groupCode?: string) => {
let len = groupStack.length - 1;
while (len >= 0) {
if (groupStack[len].groupCode ! == groupCode) {
groupStack.pop();
} else {
break;
}
len--;
}
};

const setResult = (result: AssistStruct[], groupStack: GroupStack[], groupCode: string, value: AssistStruct) => {
groupStack.forEach((item, index) => {
if (!result) {
return null;
}
if (!result[item.index]) {
return;
}
if (result[item.index].code ! == groupCode) {
// If the current component's group is not equal to the key in the result, search down
return setResult(result[item.index].children as AssistStruct[], groupStack.slice(index + 1), groupCode, value);
} else {
if (result[item.index].children) {
(result[item.index].children as AssistStruct[]).push(value);
item.offsetNumber += 1;
} else {
result[item.index].children = [value];
}
}
});
};

compCodes.forEach((item, index) => {
const hasGroup = compDatas[item] ? compDatas[item].config.groupCode : undefined;
stackPop(hasGroup);
if (compDatas[item].compCode === 'group') {
if (hasGroup) {
// If the current component's parent is at the top of the stack, update the result tree
setResult(resultData, groupStack.slice(0), hasGroup, {
code: item,
children: [],
});

// if the current group has a parent group, the group stack must not be empty, and the group index is the parent group length-1
// debugger;
groupStack.push({
groupCode: item,
index: groupStack.length ? groupStack[groupStack.length - 1].offsetNumber - 1 : index,
offsetNumber: 0,
});
} else {
groupStack = []; //no group, empty stack
resultData.push({
code: item,
children: [],
});
//If the current group has no parent group, the group stack must be empty and the group index is the result length
groupStack.push({
groupCode: item,
index: resultData.length - 1,
offsetNumber: 0,
});
}
} else {
if (hasGroup) {
// If the current component's parent is at the top of the stack, update the result tree
setResult(resultData, groupStack.slice(0), hasGroup, {
code: item,
});
} else {
groupStack = []; //no group, empty stack
resultData.push({
code: item,
});
}
}
});
return resultData;

Translated with www.DeepL.com/Translator (free version)

Recording web pages to video at a specified time through a server

Why is there such a need?#

I recently work in the field of front-end data visualization, and the need for some monitoring of long-running front-end pages comes up. In the past, my solution was to record through some existing platform on my personal PC via browser, or an earlier approach was to record through some screen recording tools.

In such an approach, the following problems were often encountered.

  • Insufficient resolution to restore
  • The recorded log format is difficult to parse
  • Need to open the personal computer for a long time
  • ** What is recorded through the platform is often not a video, but a DOM-Mirror recording. Such logs are difficult to share with others for troubleshooting**
  • DOM-Mirror recordings for playback lack value for rendering real-time data returned by the backend (because the point in time has been missed, and playback cannot play back the service state of the backend at that time)
  • The number of concurrent recordings is limited by the performance of personal computers
  • Recorded files are not well managed

My goal#

So, based on the above needs, we need to achieve the following requirements.

  • Record at the native resolution required by the web page
  • Be able to record on the server side and not on the PC
  • The ability to record generic video and log files that can be easily shared with others
  • Ability to make concurrent recordings
  • Video frame rate should be smooth enough (at least at 4K)
  • Provide access to static resources for recorded files

Choice of technology stack#

  • Base language and framework - js & nodejs
  • For running tasks at specified times -- cron job
  • For opening web pages -- puppeteer
  • For video recording the following options are available
    • Use the browser api getDisplayMedia for recording
    • Use puppeteer to take a screenshot by frame, then compress the image with ffmpeg
    • Use xvfb to record the video stream from the virtual desktop directly by encoding it with ffmpeg
  • For recording logs -- puppeteer provides devtools related events
  • For concurrent processing -- introduce weighted calculations
  • For video processing -- ffmpeg

The specific implementation#

I. Current solution#

The main problems that this solution circumvents to solve are.#

  • The use of getDisplayMedia is limited by the browser's protocol. This api is only available when the access protocol is https, and the recording of audio depends on other api.
  • The performance of getDisplayMedia has little room for optimization when recording multiple pages concurrently, and the most fatal problem is that the performance overhead of the recording process is borne by the browser. This means that if the page itself is more performance sensitive, it is basically impossible to record the page running properly using this api.
  • puppeteer's frame-by-frame screenshots are limited by chrome-devtools itself, resulting in only 10+ images being cut out a second. In a data visualization scenario, a large amount of real-time data rendering is obviously unacceptable as well.

Core Processes#

Key points.#

  1. use node call xvfb, create virtual desktops: open source library node-xvfb has some problems, the virtual desktops created, seem to share the same stream buffer, in the case of concurrent recording, there will be a situation of preemption, resulting in accelerated video content, so the need to encapsulate a new node call xvfb
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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
import * as process from 'child_process';
class XvfbMap {
private xvfb: {
[key: string]: {
process: process.ChildProcessWithoutNullStreams;
display: number;
execPath?: string;
};
} = {};

setXvfb = (key: string, display: number, process: process.ChildProcessWithoutNullStreams, execPath?: string) => {
this.xvfb[key] = {
display,
process,
execPath,
};
};

getSpecXvfb = (key: string) => {
return this.xvfb[key];
};

getXvfb = () => this.xvfb;
}

const xvfbIns = new XvfbMap();

/**
* 检测虚拟桌面是否运行
* @param num 虚拟桌面窗口编号
* @param execPath 内存缓冲文件映射路径
* @returns Promise<boolean>
*/
const checkoutDisplay = (num: number, execPath?: string) => {
const path = execPath || '/dev/null';
return new Promise<boolean>((res, rej) => {
const xdpyinfo = process.spawn('xdpyinfo', [
'-display',
`:${num}>${path}`,
'2>&1',
'&&',
'echo',
'inUse',
'||',
'echo',
'free',
]);
xdpyinfo.stdout.on('data', (data) => res(data.toString() === 'inUse'));
xdpyinfo.stderr.on('data', (data) => rej(data.toString()));
});
};

const getRunnableNumber = async (execPath?: string): Promise<number> => {
const num = Math.floor(62396 * Math.random());
const isValid = await checkoutDisplay(num, execPath);
if (isValid) {
return num;
} else {
return getRunnableNumber(execPath);
}
};

export const xvfbStart = async (
key: string,
option: { width: number; height: number; depth: 15 | 16 | 24 },
execPath?: string
) => {
const randomNum = Math.floor(62396 * Math.random());
const { width, height, depth } = option;
try {
const xvfb = process.spawn('Xvfb', [
`:${randomNum}`,
'-screen',
'0',
`${width}x${height}x${depth}`,
'-ac',
'-noreset',
]);

xvfbIns.setXvfb(key, randomNum, xvfb, execPath);
return randomNum;
} catch (error) {
console.log(error);
return 99;
}
};

export const xvfbStop = (key: string) => {
const xvfb = xvfbIns.getSpecXvfb(key);
return xvfb.process.kill();
};

export default xvfbIns;

  1. Load balancing during concurrent server recording. This feature is to solve the problem of high server CPU load when recording video encoding concurrently. So to maximize the number of concurrent recordings, I record the number of tasks being and will be performed by each server, mark this number as the weight of the service, and when a new recording task is created, first check the weight of the current server, then create the recording task on the server with the lowest weight, and lower the weight when the recording is completed and the task is manually terminated.

    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
    import { CronJob } from 'cron';

    interface CacheType {
    [key: string]: CronJob;
    }

    class CronCache {
    private cache: CacheType = {};
    private cacheCount = 0;
    setCache = (key: string, value: CronJob) => {
    this.cache[key] = value;
    this.cacheCount++;
    return;
    };

    getCache = (key: string) => {
    return this.cache[key];
    };

    deleteCache = (key: string) => {
    if (this.cache[key]) {
    delete this.cache[key];
    }

    this.cacheCount = this.cacheCount > 0 ? this.cacheCount - 1 : 0;
    };

    getCacheCount = () => this.cacheCount;
    getCacheMap = () => this.cache;
    }

    export default new CronCache();

  2. When starting puppeteer, you need to provide parameters

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    const browser = await puppeteer.launch({
    headless: false,
    executablePath: '/usr/bin/google-chrome',
    defaultViewport: null,
    args: [
    '--enable-usermedia-screen-capturing',
    '--allow-http-screen-capture',
    '--ignore-certificate-errors',
    '--enable-experimental-web-platform-features',
    '--allow-http-screen-capture',
    '--disable-infobars',
    '--no-sandbox',
    '--disable-setuid-sandbox',//关闭沙箱
    '--start-fullscreen',
    '--display=:' + display,
    '-–disable-dev-shm-usage',
    '-–no-first-run', //没有设置首页。
    '–-single-process', //单进程运行
    '--disable-gpu', //GPU硬件加速
    `--window-size=${width},${height}`,//窗口尺寸
    ],
    });

solution performance (in docker)#

  • Standard 1k resolution: dual-core CPU 2.3Ghz; 10 concurrent at 4G ram
  • Standard 2k resolution: dual-core CPU 2.3Ghz; 4 concurrent under 4G ram

II. Tried and tested solutions#

getDisplayMedia mode#

Key points#
  1. The api call causes chrome to pop up an interactive window to choose which specific web page to record. Closing this window requires the following parameters to be enabled when starting puppeteer

    1
    2
    3
    4
    5
    6
    7
    8
    9
    '--enable-usermedia-screen-capturing',
    `-auto-select-desktop-capture-source=recorder-page`,
    '--allow-http-screen-capture',
    '--ignore-certificate-errors',
    '--enable-experimental-web-platform-features',
    '--allow-http-screen-capture',
    '--disable-infobars',
    '--no-sandbox',
    '--disable-setuid-sandbox',
  2. To execute the recording, you need to inject the function via puppeteer page.exposeFunction.

Q & A#

Q: Why do I need to introduce xvfb?

A: In the tried and tested solution, getDisplayMedia requires the runtime environment to provide a desktop environment. In the current solution, it is necessary to push the video stream from xvfb directly into ffmpeg

Q: Why are there certain memory requirements?

A: To provide the minimum running memory for chrome

Project address#

https://github.com/sadofriod/time-recorder

Draw smooth cubic Bessel curves

Basics#

What you need to know before reading includes

  • The coordinate system of the canvas
  • The formula for the midpoint of two points in Cartesian coordinates
  • The formula for the distance between two points in Cartesian coordinates
  • Basic trigonometric functions
  • projection basics
  • canvas drawing Bezier curves

Problems faced#

1. choosing a quadratic Bezier curve or a cubic Bezier curve#

2. Calculate control points for Bezier curves#

Problem analysis#

Problem 1.#

Since the quadratic Bézier curve will have only one bend after drawing, it will render poorly when multiple nodes are connected. And at 45°, 135°, 225°, 315°, special treatment is needed, otherwise the curve obtained is too large in radian.

Question 2.#

After deciding to use the cubic Bezier curve, we need to calculate the two control points C1,C2 when drawing the curve, and then draw it by CanvasRenderingContext2D.bezierCurveTo.

Since we need two control points, we will divide the line S-E between the starting point SP(start point) and the end point EP(end point) into 4 parts. The following points are obtained.

\[ \begin{align*} Split_{m} = (\frac{(X_{SP}+X_{EP})}2,\frac{(Y_{SP}+Y_{EP})}2)\\ \end{align*} \] The formula L(x) for S-E is obtained as \[ L(x) = \frac{X_{Split_{m}}}{Y_{Slit_{m}}}x \] From L(x) we know that the slope of S-E satisfies \[ \tan \theta = \frac{X_{Split_{m}}}{Y_{Slit_{m}}} \]

Then, using \[Split_{m}\] as the origin of the coordinate system and establishing the right angle coordinate system, we get

\[ \begin{align*} len = \sqrt{(X_{Split_{m}}-X_{SP})^{2}+(Y_{Split_{m}}-Y_{SP})^{2}}\ \\\ \theta = \arctan \frac{X_{Split_{m}}}{Y_{Slit_{m}}}\ \\\\ Y_{offset} = len-\cos \theta \\\\ \\\\ C1=(X_{Split_{m}},Y_{Split_{m}}-len)\\\ C2=(X_{Split_{m}},Y_{Split_{m}}+len) \end{align*} \]

Code section#

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
/**
* @param props
* @typeof props {
start: number[];
end: number[];
canvas: CanvasRenderingContext2D;
}
*/
export const drawLine = (props: Common.LineProps) => {
const { start, end, canvas: ctx, color } = props;

const getMidCoord = (c1: number, c2: number) => {
if (c1 === c2) {
return c1;
}
return (c1 + c2) / 2;
};

const [x1, y1] = start;
const [x2, y2] = end;
const [midX, midY] = [getMidCoord(x1, x2), getMidCoord(y1, y2)];
const drawMirror = (y1: number, y2: number) => {
if (y1 > y2) {
return ctx.bezierCurveTo(control2[0], control2[1], control1[0], control1[1], end[0], end[1]);
} else {
return ctx.bezierCurveTo(control1[0], control1[1], control2[0], control2[1], end[0], end[1]);
}
};
const degCos = Math.cos(Math.atan((x1 - midX) / (y1 - midY)));

const lineLen = Math.sqrt(Math.pow(y1 - midY, 2) + Math.pow(x1 - midX, 2)) * 2;

const control1 = [midX, midY - degCos * (lineLen / 2)];
const control2 = [midX, midY + degCos * (lineLen / 2)];

ctx.beginPath();
ctx.moveTo(start[0], start[1]);
drawMirror(y1, y2);
ctx.lineWidth = 2;
ctx.strokeStyle = color ? color : "#000";
ctx.stroke();
ctx.closePath();
};

绘制平滑的三次贝塞尔曲线

基础知识#

阅读之前你需要知道的知识包括

  • canvas的坐标系
  • 直角坐标中两点的中点公式
  • 直角坐标中两点距离公式
  • 基础的三角函数
  • 投影基础知识
  • canvas绘制贝塞尔曲线

面临的问题#

1. 选择二次贝塞尔曲线 or 三次贝塞尔曲线#

2. 贝塞尔曲线控制点计算#

问题分析#

问题1:#

由于二次贝塞尔曲线绘制后,将只有一处弯曲,在多节点连接时,呈现效果很差。并且在45°,135°,225°,315°时,需要做特殊的处理,否侧得到的曲线的弧度过大。

问题2:#

在确定使用三次贝塞尔曲线后,需要通过计算得出曲线绘制时的两个控制点C1,C2。然后通过CanvasRenderingContext2D.bezierCurveTo进行绘制。

由于我们需要两个控制点,所以,我们将会把起点SP(start point)和终点EP(end point)间的连线S-E均分为4份。得到如下点: \[ \begin{align*} Split_{m} = (\frac{(X_{SP}+X_{EP})}2,\frac{(Y_{SP}+Y_{EP})}2)\\ \end{align*} \] 得到S-E的公式L(x)为 \[ L(x) = \frac{X_{Split_{m}}}{Y_{Slit_{m}}}x \] 根据L(x)可知S-E的斜率满足 \[ \tan \theta = \frac{X_{Split_{m}}}{Y_{Slit_{m}}} \] 然后将\(Split_{m}\)作为坐标系的原点,建立直角坐标系,得到 \[ \begin{align*} len = \sqrt{(X_{Split_{m}}-X_{SP})^{2}+(Y_{Split_{m}}-Y_{SP})^{2}}\\ \\ \theta = \arctan \frac{X_{Split_{m}}}{Y_{Slit_{m}}}\\ \\ Y_{offset} = len·\cos \theta \\ \\ C1=(X_{Split_{m}},Y_{Split_{m}}-len)\\ C2=(X_{Split_{m}},Y_{Split_{m}}+len) \end{align*} \]

代码部分#

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
/**
* @param props
* @typeof props {
start: number[];
end: number[];
canvas: CanvasRenderingContext2D;
}
*/
export const drawLine = (props: Common.LineProps) => {
const { start, end, canvas: ctx, color } = props;

const getMidCoord = (c1: number, c2: number) => {
if (c1 === c2) {
return c1;
}
return (c1 + c2) / 2;
};

const [x1, y1] = start;
const [x2, y2] = end;
const [midX, midY] = [getMidCoord(x1, x2), getMidCoord(y1, y2)];
const drawMirror = (y1: number, y2: number) => {
if (y1 > y2) {
return ctx.bezierCurveTo(control2[0], control2[1], control1[0], control1[1], end[0], end[1]);
} else {
return ctx.bezierCurveTo(control1[0], control1[1], control2[0], control2[1], end[0], end[1]);
}
};
const degCos = Math.cos(Math.atan((x1 - midX) / (y1 - midY)));

const lineLen = Math.sqrt(Math.pow(y1 - midY, 2) + Math.pow(x1 - midX, 2)) * 2;

const control1 = [midX, midY - degCos * (lineLen / 2)];
const control2 = [midX, midY + degCos * (lineLen / 2)];

ctx.beginPath();
ctx.moveTo(start[0], start[1]);
drawMirror(y1, y2);
ctx.lineWidth = 2;
ctx.strokeStyle = color ? color : "#000";
ctx.stroke();
ctx.closePath();
};