React 组件中避免业务逻辑示例
文中所引用的源代码均托管在我的个人 GitHub 仓库里:
https://github.com/winkingzhang/react-business-refactor
用户故事
假设我们有一个卖书的业务,目前正在开发如下的功能:
作为一个购书用户,我希望能看到给定 ISBN 的书籍的详细信息, 通过检阅书籍标题、作者和以本地货币显示的折扣后价格(或者原价),以便于决定是否购买。
业务分析
站在 React 组件开发的视角,从以上的用户故事中,我们需要创建一个 React 组件来显示书籍的详细信息,主要包含以下几个部分:
- 输入是一个字符串,表示书籍的 ISBN 编号
- 应该有一个后台服务的调用,来检索带有 ISBN 的图书详细信息,其响应包括
- 书籍标题,不为空的字符串
- 书籍作者,不为空的字符串
- 书籍描述,字符串,可以为空
- 折扣价格或者原价,一个数字,表示货币单位的价格
- 由于 UI 外观不是本文的重点,我将省略所有样式部分,只关注业务逻辑的重构。
具体实现
所有这些演示代码都在 React 18+ 上运行,它也可以在 React 16+ 上运行,但可能需要多次渲染。
第一版:先让业务跑起来
让我们简化所有样式,我们可以轻松地用 Typescript
制作如下所示的 BookDetailView
组件
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
import React, { useState } from 'react';
interface BookDetailModel {
name: String,
author: String,
discountPrice?: number,
price?: number,
}
interface BookDetailViewProps {
isbn: String,
}
enum AsyncLoadingState {
Loading = 1,
Loaded = 0,
Error = -1,
}
const BookDetailView = ({isbn}: BookDetailViewProps) => {
const [loadingState, setLoadingState] =
useState<AsyncLoadingState>(AsyncLoadingState.Loading);
const [book, setBook] = useState<BookDetailViewModel | null>();
useEffect(() => {
const fetchBookDetailAsync = async () => {
const response = await fetch(`http://localhost:3000/books/${isbn}.json`);
return await response.json();
};
fetchBookDetailAsync()
.then((book) => {
setBook(book);
setLoadingState(AsyncLoadingState.Loaded);
})
.catch(() => setLoadingState(AsyncLoadingState.Error));
}, [isbn]);
if (loadingState == AsyncLoadingState.Loading) {
return <Text>Loading...</Text>
} else if (loadingState == AsyncLoadingState.Error) {
return <Text>Error</Text>
}
if (!book) {
return <Text>Not Found</Text>
}
const onSubmit = () => {
// ... submit logic here
};
return (
<Container>
<Name value={book.name}/>
<Author value={book.author}/>
<Price value={book.discountPrice || book.price}/>
<Button onClick={onSubmit}>Buy</Button>
</Container>
);
}
useState
这里我们用 hook 设置了两个状态
loadingState
/setLoadingState
,指示远程 API 调用的加载状态- 正在加载,初始状态,表示组件正在加载数据,会显示加载指示
- Loaded,成功状态,表示加载数据成功
- 错误,访问远程 API 时出现错误
book
/setBook
,标明书籍详情- 当它为空或未定义时,显示错误消息 “未找到”
- 否则,显示书籍详细信息
并且我们使用 useEffect
钩子来包装 http 请求并从远程 api 调用的响应中异步应用书籍详细信息。
看起来很简单,但是,这个 React UI 组件在第 25 到第 36 行之间混合了一些业务, 一般来说,视图不应该关心如何检索书籍详细信息,因此,这里需要小小重构一下。
顺便说一句,我确实包装了如下所示但上面从未提及过的常见组件。
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
const globalNumberFormat = Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
});
// @ts-ignore
const Container = ({children}) => (
<div>{children}</div>
);
// @ts-ignore
const Text = ({children, style}: { children: any, style?: any }) => (
<div style={style}>{children}</div>
);
// @ts-ignore
const Button = ({children, onClick}: { children: any, onClick?: any }) => (
<button onClick={onClick}>{children}</button>
);
const Name = ({value: name}: { value: String }) => (
<Text style={ {fontSize: 36, marginTop: 10} }>{name}</Text>
);
const Author = ({value: author}: { value: String }) => (
<Text style={ {fontSize: 20, marginTop: 15} }>{author}</Text>
);
const Price = ({value: price}: { value?: number }) => (
<Text style={ {fontSize: 24, marginTop: 10} }>
{globalNumberFormat.format(price || 0)}
</Text>
);
第二版,使用自定义钩子重构获取书籍详细信息
如题,这里让我们把获取书籍详细信息的逻辑从 BookDetailView
组件中提取出来,放到一个自定义钩子中,
让 isbn
作为唯一的参数,其他的加载状态和书籍详细模型放到内部。
正如你看到的,这里需要使用 useEffect
来封装 http 请求
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const useBookDetail = (isbn: String) => {
const [loadingState, setLoadingState] =
useState<AsyncLoadingState>(AsyncLoadingState.Loading);
const [book, setBook] = useState<BookDetailModel | null>();
useEffect(() => {
const fetchBookDetailAsync = async () => {
const response = await fetch(`http://localhost:3000/books/${isbn}.json`);
return await response.json();
};
fetchBookDetailAsync()
.then((book) => {
setBook(book);
setLoadingState(AsyncLoadingState.Loaded);
})
.catch(() => setLoadingState(AsyncLoadingState.Error));
}, [isbn]);
return {loadingState, book};
};
export default useBookDetail;
这样在 BookDetailView
中就可以移除很多代码,最后看起来是这样的(这里省略了重复部分):
1
2
3
4
5
6
7
8
9
10
11
const BookDetailView = ({isbn}: BookDetailViewProps) => {
const {loadingState, book} = useBookDetail(isbn);
/* omit all the same lines */
// ......
return (
<Container>
<Name value={book.name}/>
<Author value={book.author}/>
<Price value={book.discountPrice || book.price}/>
看起来好了很多,但是如果聚焦到第 11 行,那么这里计算 price
的逻辑还是业务,
它不应该放在 React UI 组件里,接下来我们继续重构。
第三版,彻底封装业务和远程调用
首先快速回顾第二版遇到的几处坏味道:
price
的计算逻辑应该放在业务逻辑中,而不是 React UI 组件中。 换言之, UI 只应该关心两件事 —— 显示界面元素,响应元素的交互并将控制权转交后台的业务逻辑。fetchBookDetailAsync
是硬编码在useBookDetail
钩子中的,实际项目不应该这么做,而是封装成一个api
之类的对象。
这就是我们需要做的事情,让我们继续重构。
分离 View Model 和 Data Model
如上,调用远程 api 返回书籍详情,这个模型可以称之为 Data Model(这里是相对前端而言,对后端 DTO 更为合适)。 要想在 UI 显示,则需要从 Data Model 转换一个纯粹的业务视图模型,它仅仅表示 UI 显示的数据。 这样,就有下面的这两个模型:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// data model is placed at 'src/models/data.ts'
export interface BookDetailModel {
name: String,
author: String,
discountPrice?: number,
price: number,
}
// view model is placed at 'src/models/view.ts'
export interface BookDetailViewModel {
name: String,
author: String,
price: number,
}
接下来是转换逻辑,就叫它 bookDetailHandler
吧,
它的作用是分析从 http 响应回来的 json 对象,即 Data Model,然后转换为 View Model(这里包含一个简单的单价计算):
1
2
3
4
5
6
7
const parseBookDetail = (bookDetail: BookDetailModel): BookDetailViewModel => {
return {
name: bookDetail.name,
author: bookDetail.author,
price: bookDetail.discountPrice || bookDetail.price,
}
};
封装远程调用
这里没有什么特别的,只是把 api 调用单独提取出来放到一个单独的文件里:
1
2
3
4
5
const getBookDetailByIsbn = async (isbn: String): Promise<BookDetailModel> => {
const response = await fetch(`http://localhost:3000/books/${isbn}.json`);
const bookDetail = await response.json();
return bookDetail as BookDetailModel;
};
连接 UI, 业务和 api 调用
接下来修改 useBookDetail
钩子,这里仅仅调用业务逻辑,而不是直接调用远程 api:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const useBookDetail = (isbn: String) => {
const [loadingState, setLoadingState] =
useState<AsyncLoadingState>(AsyncLoadingState.Loading);
const [book, setBook] = useState<BookDetailViewModel | null>();
useEffect(() => {
getBookDetailViewModelByIsbn(isbn)
.then((book) => {
setBook(book);
setLoadingState(AsyncLoadingState.Loaded);
})
.catch(() => setLoadingState(AsyncLoadingState.Error));
}, [isbn]);
return {loadingState, book};
};
最后,在 bookDetailHandler
里定义 getBookDetailViewModelByIsbn
函数,实现如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
const parseBookDetail = (bookDetail: BookDetailModel): BookDetailViewModel => {
// ... omit long line here ...
};
const getBookDetailViewModelByIsbn = async (
isbn: String
): Promise<BookDetailViewModel> => {
return parseBookDetail(await getBookDetailByIsbn(isbn));
}
export {
getBookDetailViewModelByIsbn,
};
这里 getBookDetailByIsbn
是从 api/bookDetail
里导入的对远端 api 的调用 (这里我用的 mock 数据)。
这样,我们就完成了对业务逻辑的封装,现在 BookDetailView
只关心 UI 的显示和交互,而不再关心业务逻辑。
总结
就是这么简单,让我们总结一下我们所做的事情,以及为什么这么做:
- 正如标题所示,避免将业务逻辑与 React 组件(UI)混合在一起
- 在单独的模块中抽象外部交互(如果喜欢轻量级解决方案,子文件夹也能满足基本要求)
- 分别定义数据模型和视图模型,并在业务处理程序中将它们桥接起来
- 与独立的业务处理程序协作,使其更加封装