人机交互爱好者的随笔

0%

React组件设计模式(2)

上文中提到了一种基于 mobx 的 React 组件设计方案:它将状态与逻辑剥离到 mobx 中,并通过工厂函数将它们与组件进行关联、注入,通过分层解决了前端组件基于UI进行测试的脆弱性组件生命周期被自动调用影响测试结果的问题。但这种设计模式同样产生了一些新的问题,在本篇中,我们将逐一尝试解决。

优化

闭包导致的 api 不可测试

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
function createCount(initialCount) {
const countStore = new CountStore(initialCount);
const loadingStore = new LoadingStore();

const onClick = () => {
CountPresenter.setCount(countStore, countStore.count + 1);
};

const didMount = async () => {
LoadingPresenter.setLoading(loadingStore, true);
const newCount = await Promise.resolve(10);
CountPresenter.setCount(countStore, newCount);
LoadingPresenter.setLoading(loadingStore, false);
};

return observer(() => {
useEffect(() => {
didMount();
}, []);

return (
<CountUI
count={countStore.count}
loading={loadingStore.loading}
onClick={onClick}
/>
);
});
}
const Count = createCount();

对于该工程函数,它将承担了状态与逻辑的聚合、组件的创建等任务,但由于闭包导致onClickdidMount等聚合后产生的 api 无法被测试。解决的办法其实很简单——OOP,让我们将工厂函数转化为工厂类:

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
class CountFactory {
constructor(
private countStore: CountStore,
private loadingStore: LoadingStore
) {}

onClick() {
CountPresenter.setCount(this.countStore, this.countStore.count + 1);
}

didMount = async () => {
LoadingPresenter.setLoading(loadingStore, true);
const newCount = await Promise.resolve(10);
CountPresenter.setCount(countStore, newCount);
LoadingPresenter.setLoading(loadingStore, false);
};

create() {
return observer(() => {
useEffect(() => {
didMount();
}, []);
return (
<CountUI
loading={this.loadingStore.loading}
count={this.countStore.count}
onClick={this.onClick}
/>
);
});
}
}

const countFactory = new CountFactory(new CountStore(), new LoadingStore());
const Count = countFactory.create();
  • 我们将countStoreloadingStore转化为私有属性实现依赖的解耦(同时借助 ts 对 class 的优化,省去了手动赋值的过程)
  • 我们将onClickdidMount转变为类的成员方法,使得其能对外暴露,方便测试
  • 作为牺牲,我们暂时移除为countStore等被依赖的 store 提供初始化参数的功能(一般在 redux 中,初始值都是固定的,这点牺牲可以忍受)

接收全局 Store、Service

在我们将工厂函数转变为工厂类后,失去了通过函数参数像工厂提供全局Store和Service的功能,该功能在组件间通信、发送接口请求时非常重要。为此,我们将创建一个工厂基类:

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
import { ComponentType } from "react";

abstract class BaseFactory<St, Sv> {
protected globalStores: St;
protected services: Sv;
receive({ globalStores, services }: BaseFactoryParams<St, Sv>) {
this.globalStores = globalStores;
this.services = services;
return this;
}
abstract create(): ComponentType;
}

interface BaseFactoryParams<St, Sv> {
globalStores?: St;
services?: Sv;
}

const globalStores = {};
const services = {};
type GlobalStores = typeof globalStores;
type Services = typeof services;

class CountFactory extends BaseFactory<GlobalStores, Services> {
// ...
}
const countFactory = new CountFactory(new CountStore(), new LoadingStore());
countFactory.receive({ globalStores, services });
const Count = countFactory.create();
  • BaseFactory类具有 2 个成员属性globalStoresservices,前者将保存用户基本信息、loading 等全局状态,后者与发送接口请求相关。
  • BaseFactory类具有receive公有方法,用于外部将需要的数据传递进来并赋值。
  • 后续的工厂类将继承CountFactory,并在实例化后调用receive方法

使用 Reflect-Metadata 实现自动依赖注入

在完成第一步后,我们虽然实现了依赖解耦,但也面临一个新的问题:每次创建工厂实例都需要手动传递依赖类的实例,这个过程若人工完成则非常容易犯错。为此,我们将通过metadata提供的元编程能力,将依赖注入自动化

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
import "reflect-metadata";

type Constructor<T = any> = new (...args: any[]) => T;

const Injectable = (): ClassDecorator => target => {};

const Factory = <T extends object, K extends keyof T>(
target: Constructor<T>
): T => {
// 获取所有注入的服务
const providers = Reflect.getMetadata("design:paramtypes", target) || [];
const args = providers.map((provider: Constructor) => {
// return new provider(new)
return Factory(provider);
});
return new target(...args);
};

@Injectable()
class CountFactory extends BaseFactory<GlobalStores, Services> {
// ...
}
const Count = Factory(CountFactory)
.receive({ globalStores, services })
.create();
  • 借助强大的 metadata 元编程,ts 会在经过Injectable装饰器描述的CountFactory类上,将构造函数参数的类型CountStoreLoadingStore记录在CountFactory
  • Factory将会读取传递给它的CountFactory上的元数据,得到[CountStore, LoadingStore]这一数组(数组的成员均为实体类)
  • Factory将这些实体类实例化并作为参数用于CountFactory的实例化,这样自动依赖注入的过程就完成了

可以看到,到这一步为止,将工厂函数改造为工厂类带来的大多数问题都已经被解决,而两个组件之间的差异也仅在于传递给Factory的工厂类名称而已。

类似 dva-loading 的内置 loading

在将工厂函数的原有问题解决完之后,我们会发现一个之前所忽略的一个小问题——需要人工设置 loading 状态——也在引入成员装饰器后有了解决的希望。这就开始着手处理:

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
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
import { observable, action } from "mobx";

interface CounterActions {
[name: string]: number;
}

interface Actions {
[name: string]: boolean;
}

interface Counter {
actions: Actions;
}

const counter: Counter = {
actions: {}
};

function actionCount(action: string, state: boolean) {
if (typeof counter.actions[action] === "undefined") {
counter.actions[action] = 0;
}

if (state === true) {
counter.actions[action] += 1;
} else if (state === false) {
counter.actions[action] = Math.max(counter.actions[action] - 1, 0);
}

return counter.actions[action] > 0;
}

class LoadingStore {
// load status of each action
@observable
actions: Actions = {};

// change load status
@action
change = (model: string, action: string, state: boolean) => {
if (action) {
this.actions[action] = actionCount(action, state);
}
};
}

const loadingStore = new LoadingStore();

function loading(): MethodDecorator {
return function(target, key, descriptor) {
const model = target.constructor.name;
const action = `${model}/${String(key)}`;
return {
value: funcWithLoading([model, action], descriptor.value),
enumerable: false,
configurable: true,
writable: true
};
};
}

function funcWithLoading(names: [string, string], func: any, scope?: object) {
return function(...args: any[]) {
const [model, action] = names;
loadingStore.change(model, action, true);
const promise = func.apply(scope || this, args);
if (typeof promise === "object" && typeof promise.finally === "function") {
promise.finally(() => {
loadingStore.change(model, action, false);
});
} else {
loadingStore.change(model, action, false);
}

return promise;
} as any;
}
const globalStores = { loadingStore };

@Injectable()
class CountFactory extends BaseFactory<GlobalStores, Services> {
constructor(private countStore: CountStore) {
super();
this.didMount = this.didMount.bind(this);
}

onClick() {
CountPresenter.setCount(this.countStore, this.countStore.count + 1);
}

@loading()
async didMount() {
const newCount: number = await new Promise((res, rej) => {
setTimeout(() => {
res(10);
}, 2000);
});
CountPresenter.setCount(this.countStore, newCount);
}

create = () => {
return observer(() => (
<CountView
loading={
this.globalStores.loadingStore.actions["CountFactory/didMount"]
}
count={this.countStore.count}
onClick={this.onClick}
didMount={this.didMount}
/>
));
};
}

const Count = Factory(CountFactory)
.receive({ globalStores, services })
.create();
  • 我们通过 mobx 定义了一个LoadingStore类和对应的loading装饰器,前者保存了所有的被注册过的异步 api 的 loading 状态,后者通过 descriptor 对 loadingStore 进行操作。
  • globalStores获取了loadingStore,并通过BaseFactoryreceive方法将 loadingStore 注入到工厂类中。
  • CountFactory工厂类对异步操作didMount附加装饰器,并在 create 中通过类名/方法名的形式取得这一 api 的 loading 状态。

这种方法相关便捷的实现了 loading 的内置效果,但是也存在一些问题:

  1. loadingStore 是一个独立的全局 Store,若某个工厂类被创建了 2 次,并且他们同时存在,那么 loading 便会互相干扰。
  2. 由于只有MethodDecorator能通过第三个参数取得原本属性的 descriptor,而PropertyDecorator不能,且箭头函数定义方式会将函数以成员属性而非原型方法的形式创建,因此 loading 装饰器无法与箭头函数共存,需要在构造函数中人为绑定 this(是不是很熟悉?)

总结

至此,我们已经对原本的工厂函数进行大刀阔斧的改造,解决了依赖耦合、难以测试的问题,通过提供了自动依赖注入、内置 loading 的功能,大大提升了该模式的可用性。
到这里我们可以看到,在模式下的组件,整体上分为 4 层:

  1. 基于类的持久层:Store 与 Presenter
  2. 基于类的工厂:Factory
  3. 基于类的 Api 等:Service
  4. 函数式的组件:View

组件的函数化与逻辑的 oop 化是一个明显的特征。由于组件不再承担大量业务逻辑,函数式的写法可读性更高;而其他层为了使结构更清晰、易于测试,选择了 OOP 的模式

参考:

  1. Reflect.metadata
  2. mobx-loading
  3. Demo repo
  4. Demo 演示