原文链接:
Handle API call state NICELYblog.angularindepth.com
原文作者:
Siyang Kern Zhaoblog.angularindepth.com
译者:
dreamdevil00 - Overviewgithub.com
在本文中, 我将介绍一种处理 API 调用状态的方法, 该方法通过提取通用逻辑的方式减少了样板代码。 这种方法非常强力、整洁、不易出错。本文假定使用 NgRx 管理状态。
我敢打赌 API 调用是 web 开发中最常见的需求之一。很多应用程序都有大量的 API 调用。就用户体验而言,显示 API 调用的状态(例如加载时使用 spinner 显示
加载中
或出错时显示错误消息)始终是一种良好的实践。我见过很多对 API 调用状态建模的方法,并且发现了一个主要的痛点——
繁重的样板代码,这通常会导致更多的问题。
繁重的样板代码比如, 假设有下列业务要求:
- 发起 API 请求并获取今天的新闻列表
- 在加载时显示 spinner
- 当获取数据成功时, 显示加载的新闻列表
很多开发者会按下述方式设计状态模型(使用 2 种 actions,例如
LoadNews
和
LoadNewsSuccess
, 然后使用 2 个 reducer cases 改变
loading
和
entities
的状态)
export interface News {
loading: boolean;
entities: string[];
}
到目前为止, 我们还没有看到任何问题。 这种处理方式很中规中矩。
如果在应用中有 20(或者更多)次 API 请求, 问题来了:
- 样板代码太多 。 我们需要添加 API 状态(
) 20 次, 40 个 actions 以及实现 40 个 reducer cases。 代码中有很多逻辑是重复的。loading
- 命名不一致 。如果 20 次 API 调用是由 4 名开发者实现的。 他们可能会有不同的命名方式。 比如,
可能被命名为loading
,isLoading
,waiting
,isWaiting
等等。started
实际上,上述 API 状态模型只有一个状态
loading
。 然而, 一个完整的 API 状态应该有更多的状态(会在下节讨论), 这将使上述 2 个问题更加糟糕。
我们来优雅地解决这个问题。
什么是完整的状态集
完整的 API 调用过程可能有下述状态:
- API 调用尚未开始
- API 调用已开始但是还没有收到响应
- API 调用成功,收到调用成功的响应
- API 调用失败,收到错误响应
这样我们就可以设计一种通用的模型, 如下(称其为
Loadable
)
export interface Loadable {
loading: boolean;
success: boolean;
error: any;
}
很容易将 API 调用过程的 4 种状态映射到 上述模型中的 3 个字段。
我创建了 4 个 helper 函数来更新 loadable 的状态。可以看到它们都是纯函数, 都返回了新的 Loadable 对象:
export function createDefaultLoadable() {
return {
loading: false,
success: false,
error: null,
}
}
export function onLoadableLoad(loadable) {
return {
...loadable,
loading: true,
success: false,
error: null,
};
}
export function onLoadableSuccess(loadable) {
return {
...loadable,
loading: false,
success: true,
error: null,
};
}
export function onLoadableError(loadable, error) {
return {
...loadable,
loading: false,
success: false,
error: error,
};
将 loadable 添加到我们的加载新闻列表示例
模型
除了 loadable 的 3 个字段, 我们还需要一个状态来存储从 API 调用中获取到的新闻列表。我们可以添加下述模型:
export interface News extends Loadable {
news: string[];
}
export function createDefaultNews(): News {
return {
...createDefaultLoadable(),
entities: []
};
}
Actions
Actions 和
ngrx惯例保持一致。
export enum NewsActionsTypes {
Load = '[NEWS PAGE] LOAD NEWS',
LoadSuccess = '[NEWS PAGE] LOAD NEWS SUCCESS',
LoadError = '[NEWS PAGE] LOAD NEWS ERROR',
}
export class LoadNews implements Action {
readonly type = NewsActionsTypes.Load;
}
export class LoadNewsSuccess implements Action {
readonly type = NewsActionsTypes.LoadSuccess;
constructor(public payload: {entities: string[]}) {}
}
export class LoadNewsError implements Action {
readonly type = NewsActionsTypes.LoadError;
constructor(public error: any) {}
}
export type NewsActions = LoadNews | LoadNewsSuccess | LoadNewsError
Reducer(仍未完善)
根据 3 种不同的 actions, 我们使用一个 reducer 改变状态。
export function newsReducer(state: News = createDefaultNews(), action: NewsActions): News {
switch (action.type) {
case NewsActionsTypes.Load:
return onLoadableLoad(state);
case NewsActionsTypes.LoadSuccess:
return {
...onLoadableSuccess(state),
entities: action.payload.entities
};
case NewsActionsTypes.LoadError:
return onLoadableError(state, action.error);
default:
return state;
}
}
Effects
@Effect()
loadNews = this.actions$.pipe(
ofType(NewsActionsTypes.Load),
switchMap(action => {
return this.http.get('some url').pipe(
map((response: any) => new LoadNewsSuccess({entities: response.todaysNews})),
catchError(error => of(new LoadNewsError(error)))
);
}),
);
UI Component
@Component({
selector: 'app-news',
template: `
<button (click)="load()">Load News</button>
<!--loading spinner-->
<p *ngIf="(news$ | async).loading">loading...</p>
<p *ngFor="let item of (news$ | async).entities">{{item}}</p>
`
})
export class NewsComponent {
news$: Observable<News>;
constructor(private store: Store<{news: News}>) {
this.news$ = this.store.select(state => state.news);
}
load() {
this.store.dispatch(new LoadNews());
}
}
这就足够让它运行了。然而,这仅仅是通过扩展 loadable 来帮助实现一致的命名,并通过使用 helper 函数来帮助确保状态的正确更改。实际上样板代码并没有减少。想象一下,如果我们有 20 个 API调用,我们仍然需要处理这 20 个 reduce 中的所有 action(load、loadSuccess、loadError)。其中有 20 个共享相同的状态(即
loading
success
error
)改变逻辑。
从 reducer 中提取 API 状态改变逻辑
我们定义一个的高阶函数
withLoadable
, 该函数以 reducer、三种 action 类型字符串为参数, 并返回新的加强过的 reducer
export function withLoadable(baseReducer, {loadingActionType, successActionType, errorActionType}) {
return (state, action) => {
if (action.type === loadingActionType) {
state = onLoadableLoad(state);
}
if (action.type === successActionType) {
state = onLoadableSuccess(state);
}
if (action.type === errorActionType) {
state = onLoadableError(state, action.error);
}
return baseReducer(state, action);
};
}
这样, news reducer 就会变成这样子:
// base reducer should only update non-loadable states
function baseNewsReducer(state: News = createDefaultNews(), action: NewsActions): News {
switch (action.type) {
case NewsActionsTypes.LoadSuccess:
return {
...state,
entities: action.payload.entities
};
default:
return state;
}
}
// withLoadable enhances baseReducer to update loadable state
export function newsReducer(state: News, action: NewsActions): News {
return withLoadable(baseNewsReducer, {
loadingActionType: NewsActionsTypes.Load,
successActionType: NewsActionsTypes.LoadSuccess,
errorActionType: NewsActionsTypes.LoadError,
})(state, action);
}
baseNewsReducer
处理 loadable 之外的状态(即
entities
)
newsReducer
实际上会将
withLoadable
增强程序应用到
baseReducer
, 而
baseReducer
会
自动处理 loadable 状态的改变。
这样, 如果我们有 20 次 API 调用,并且想存储 20 * 3 = 60 个状态, 我们仅仅需要将
withLoadable
应用到 20 个 base reducers. 在这 20 个 base reducers 中, 我们并不关心应该如何更新 loadable 状态。 这样的话, 就省下了手动更新 API 状态的大量工作。
福利: 将 Loadable 和 UI 组件连接起来
Loadable 实际上提供了一个真正的一致性契约,这样它就可以与全局 UI 组件无缝连接。例如,我可以创建一个通用组件
loading-container
来全局地处理加载场景 UI,错误场景 UI。与外界的唯一契约仅仅是经
@Input
修饰的
Loadable
。
@Component({
selector: 'loading-container',
template: `
<div *ngIf="loadable.loading">This is loading spinner...</div>
<div *ngIf="loadable.error">{{loadable?.error?.message || 'Something went wrong'}}</div>
<ng-container *ngIf="loadable.success">
<ng-content></ng-content>
</ng-container>
`
})
export class LoadingContainerComponent {
@Input() loadable: Loadable;
}
只要像这样使用
loading-container
组件, 我们就可以在 API 调用过程中显示 spinner, 在 API 调用出错时显示错误信息, 同时, 也减少了大量样板代码。
<loading-container [loadable]="news$ | async">
<p *ngFor="let item of (news$ | async).entities">{{item}}</p>
</loading-container>
请阅读在 StackBlitz 或者 Github Repo 上的最终代码。这上面的代码和本文代码的唯一的区别是: 其类型更加严格。这是为了在现实生活中获得更好的编码体验。此外,它还使用 mock API 调用来获取新闻列表。
如果你想在你的项目中使用它, 我已经发布了一个 npm 包。 可以在这里查看。
Loading-example
zhaosiyang/loadable-example
loadable-state
编码快乐!