人机交互爱好者的随笔

0%

React组件设计模式

React 是目前最流行的 Web 前端 UI 框架之一,借助其创造的 JSX 语法,可以将前端项目以组件为单位进行构建。而蓬勃发展的社区也对如何设计 React 下的组件进行了大量的讨论与实践,甚至还有专门的参考书。今天,笔者将列举自己接触到的 React 组件设计模式。

主流的 React 组件设计模式

在 React v16.7 之前,大多数 React 组件都是基于 Class 的。典型的 React Class 组件结构如下:

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
class Count extends React.Component {
constructor(props) {
super(props);
this.state = {
count: props.count || 1,
loading: false
};
}

componentDidMount() {
this.fetchCount();
}

fetchCount = async () => {
this.setState({
loading: true
});
const count = await Promise.resolve(10);
this.setState({
loading: false,
count
});
};

handleClick = () => {
this.setState(({ count }) => ({
count: count + 1
}));
};

render() {
const { count, loading } = this.state;
return loading ? (
<p>loading...</p>
) : (
<div>
<p>count: {count}</p>
<button onClick={this.handleClick}>click</button>
</div>
);
}
}

可以看到,典型的 React 组件主要包含 5 个部分:

  • 外部 props:父组件传入的属性
  • 内部 state:组件内部状态
  • 生命周期钩子函数:由 React 引擎在组件到达对应时间点时自动调用
  • 事件回调函数:用于响应鼠标、键盘等用户交互行为产生的事件
  • UI:这是实际要渲染的内容,一般是根据设计稿、原型图的要求进行设计,并将以上四部分填充进对应位置

一般来说,基于 class 的组件推荐将UI与逻辑分离,即将上例中的render部分单独拆出去,并组织成无状态组件,由负责逻辑的组件控制交互,改造后的结果如下

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
class Count extends React.Component {
constructor(props) {
super(props);
this.state = {
count: props.initialCount || 1,
loading: false
};
}

componentDidMount() {
this.fetchCount();
}

fetchCount = async () => {
this.setState({
loading: true
});
const count = await Promise.resolve(10);
this.setState({
loading: false,
count
});
};

handleClick = () => {
this.setState(({ count }) => ({
count: count + 1
}));
};

render() {
const { count, loading } = this.state;
return (
<CountUI count={count} loading={loading} onClick={this.handleClick} />
);
}
}

function CountUI(props) {
const { count, loading, onClick } = props;
return loading ? (
<p>loading...</p>
) : (
<div>
<p>count: {count}</p>
<button onClick={onClick}>click</button>
</div>
);
}

这样的组件设计模式已经很完善了,但它依然有以下问题:

  • 常见的类继承不适合用于 React 的逻辑组件,因为作为被继承类的React.ComponentReact.PureComponent并不是抽象类——js 中也没有这个概念——而逻辑组件往往包含具体行为的生命周期函数
  • 基于 Class 的逻辑组件,在修改内部状态时,是通过调用this.setState这个特殊 api 来实现的,这是基于 immutable 数据模型的声明式编程的写法。而this参与之后便很难将组件的方法拆离出去
  • 组件的外部依赖——props——发生变化时,默认情况下会触发组件的 reRender,因此也不方便对逻辑组件使用依赖注入的方式进行解耦

由于上述原因,当两个不同的逻辑组件具有类似的行为,或者一个逻辑组件的行为由其他两个或者多个逻辑组件的行为组合合成时,无法实现方便的逻辑复用。
因此,高阶组件render props等能实现状态逻辑复用的方案被大规模使用了起来,类似的还有 vue 中mixin。但这些方案都只能解决某一类型的问题,而且只共享数据处理逻辑,不会共享数据本身。于是,React 在其16.7.0-alpha版本中推出了React Hook这一新特性。

React Hook 下的组件设计模式

在 ReactHook 推出后,逻辑组件的设计有了全新的模式,将上例改造后可以看到如下形式:

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
function Count(props) {
const [loading, setLoading] = React.useState(false);
const { count, setCount, onClick } = useCount(props.initialCount);
useDidMount(setLoading, setCount);
return <CountUI count={count} loading={loading} onClick={onClick} />;
}

function useCount(initialCount = 1) {
const [count, setCount] = React.useState(initialCount);

const handleClick = React.useCallback(() => {
setCount(prevCount => prevCount + 1);
}, [setCount]);

return {
count,
setCount,
onClick: handleClick
};
}

function useDidMount(setLoading, setCount) {
const fetchCount = React.useCallback(async () => {
setLoading(true);
const newCount = await Promise.resolve(10);
setCount(newCount);
setLoading(false);
}, [setCount, setLoading]);

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

function CountUI(props) {
const { count, loading, onClick } = props;
return loading ? (
<p>loading...</p>
) : (
<div>
<p>count: {count}</p>
<button onClick={onClick}>click</button>
</div>
);
}

这是一种基于函数式编程(FP)的组件设计模式:

  • Count组件聚合层,用于组装各钩子、状态
  • useCount钩子中定义了handleClick这一函数用于响应鼠标点击事件,并且 count 相关的逻辑更内聚。当修改 count 状态的逻辑变得复杂时,还可以使用useReducer来替代useState
  • useDidMount钩子中实现了类似componentDidMount的功能,并且由于依赖注入更易于维护和逻辑复用

可以看到,在使用了 ReactHook 之后,组件的逻辑又被再一次分割、解耦,变得更易于维护。

但是,这种方案并不是完美的:

  • useEffect 这一官方钩子并不等同于生命周期,它只是可以将行为模拟成类似的效果,存在相对较高的学习和适应成本,React 核心维护者也对此专门写过一篇博客来描述它的运作机制,初学者可能会长期受困于组件无法正常reRenderreRender死循环两种异常场景中
  • 哪怕不使用 useEffect 的自定义钩子,由于 hooks 的底层对开发者而言更接近一个类似数组机制的黑盒,因此不光在开发阶段要进行思维调整以适应这一机制,在遇到 BUG 时更要放弃之前的经验
  • 由于 Hook 这一特性正式推出(v16.8,2018 年 2 月)至今还不足一年,很多第三方测试库都还未针对它做出调整,目前暂时只能通过react-hooks-testing-library来对自定义 hook 进行测试,这对于在现有项目上进行重构的团队来说是一个问题

而除了 ReactHook 外,配合 Mobx 这一状态管理器,还可以带来第三种 React 下的组件设计模式

基于 Mobx 的 React 组件设计模式

Mobx 是一个可以独立使用的函数响应式(FRP)状态管理库,一般来说它都是和 React 配合使用(关于 Mobx 的使用可以参考这里

但是,这一次我们对 mobx 的使用有别于以往通过provider/inject这一基于 context 的机制,不将其作为类似 Redux 的外部全局状态,而是以组件内部状态的方式使用。具体改造结果如下:

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
class CountStore {
@observable count: number;

constructor(initialCount: number) {
runInAction(() => {
this.count = initialCount || 1;
});
}
}

class CountPresenter {
@action
static setCount(store: CountStore, count: number) {
store.count = count;
}
}

class LoadingStore {
@observable loading: boolean = false;
}

class LoadingPresenter {
@action
static setLoading(store: LoadingStore, loading: boolean) {
store.loading = loading;
}
}

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}
/>
);
});
}

interface CountProps {
count: number;
loading: boolean;
onClick: () => void;
}

function CountUI(props: CountProps) {
const { count, loading, onClick } = props;

return loading ? (
<p>loading...</p>
) : (
<div>
<p>count: {count}</p>
<button onClick={onClick}>click</button>
</div>
);
}

相比于基于 Class 和基于 Hook 的组件设计模式,这种模式乍一看在代码上有了明显的提升(从 40+行增加到了 70+行)。但它在很大程度上回归了 OOP 的写法,避免了初学 ReactHook 时面对种种 magic 行为的迷惑与随之而来的 bug。同时,近乎完全解耦的写法也使得这种模式下设计出来的组件,更易于编写细粒度的单元测试以提高可维护性。

但这种模式也带来的新的问题:

  • onClickdidMount这种直接与组件交互相关的函数因为被createCount这一工厂函数形成的闭包包裹,无法直接测试,需要提升到集成测试环境
  • 每一次工厂函数调用会产生一个新组件,它们才是可以被其他组件调用、渲染的单元,而不是工厂函数本身
  • 由于闭包的关系,新组件在其 UI 被销毁后,状态并不会清空重置,因此行为与全局状态类型,需要设置用于清空数据的销毁钩子
  • 当需要以列表的形式渲染多个具有内部状态的组件时,这种模式略显纠结