In Search of Better Loading and Error-handling in Angular

栏目: IT技术 · 发布时间: 3年前

内容简介:For many, Reactive programming seems like a conceptually elegant approach that falls apart the moment you try to do any serious programming. When adding essential error handling, refreshable state, etc. into an application, many folks see their codebases m

For many, Reactive programming seems like a conceptually elegant approach that falls apart the moment you try to do any serious programming. When adding essential error handling, refreshable state, etc. into an application, many folks see their codebases move further from the promise of clean, elegant reactive transforms.

It doesn’t have to be this way. While I’ve argued before for cleaner display of refreshable data by using AsyncPipe adopting better patterns for data refresh , this advice on its own does not provide an end-to-end pattern of displaying data from the moment it is loading all the way to error handling and refresh.

In this post, we’ll experiment with various ways to handle loading and errors when fetching dynamic data.

Our Starting Point

In my Use AsyncPipe article , I contended that rendering an Observable solely through a template and AsyncPipe was ideal. A basic “loading” example was presented, where the template looks something like this:

<ng-container *ngIf="(page$ | async) as page; else loading">
    <h1>{{page.title}}</h1>
    <p>{{page.paragraph}}</p>
</ng-container>
<ng-template #loading>
    <mat-spinner></mat-spinner>
</ng-template>

The main problem here is that the spinner will appear indefinitely if the observable encounters an error.

Option A: Catch-and-Present

One promising approach is to just catch that error and make page$ contain either a page or an error message/state:

readonly page$ = this.pageService.loadPage().pipe(
    catchError(e => of({error: e.message}))
);

In this case page$ is now Observable<Page|{error: string}> . We can render this as such:

<ng-container *ngIf="(page$ | async) as page; else loading">
    <ng-container *ngIf="!!page.error; else error">
        <h1>{{page.title}}</h1>
        <p>{{page.paragraph}}</p>
    </ng-container>
    <ng-template #error>
        Failed to load page: {{page.error}}.
    </ng-template>
</ng-container>
<ng-template #loading>Loading…</ng-template>

One drawback of this is that it isn’t clear how a result being reloaded can ever go back to the “Loading” state.

Option B: Modeling Result State

One approach that looks more elegant on a first glance is modeling our observable state in a more defined way:

enum ContentState {
    LOADING,
    LOADED,
    ERROR,
}

type Loading = { state: ContentState.LOADING };
type Error = { state: ContentState.ERROR; error: string };
type Loaded<T> = { state: ContentState.LOADED; item: T };

type Rendered<T> = Loading | Error | Loaded<T>;

Given an Observable<Rendered<T>> , a template can look like this:

<!-- Use *ngIf as a mechanism to save the variable -->
<ng-container *ngIf="(page$ | async) as result;" [ngSwitch]="result.state">
    <ng-container *ngSwitchCase="ContentState.LOADING">
        <mat-spinner></mat-spinner>
    </ng-container>
    <ng-container *ngSwitchCase="ContentState.ERROR">
        Failed to load page: {{result.error}}.
    </ng-container>
    <ng-container *ngSwitchCase="ContentState.LOADED">
        <h1>{{result.item.title}}</h1>
        <p>{{result.item.paragraph}}</p>
    </ng-container>
</ng-container>

For example, for the simple use-case:

@Component()
class PageComponent {
    readonly ContentState = ContentState;

    readonly page$ = this.pageService.loadPage().pipe(
        map((item) => ({ state: ContentState.LOADED, item })),
        startWith({ state: ContentState.LOADING }),
        catchError((e) => of({ state: ContentState.ERROR, error: e.message }))
    );
}

The nice thing about this approach is we can always emit a ContentState.LOADING event when the content is being reloaded.

For example, when content is periodically refreshed :

@Component()
class PageComponent {
    readonly ContentState = ContentState;

    readonly page$ = timer(0, 60 * 1000).pipe(
        switchMap(() =>
            this.pageService.loadPage().pipe(
                map((item) => ({
                    state: ContentState.LOADED,
                    item,
                })),
                startWith({ state: ContentState.LOADING }),
                catchError((e) =>
                    of({
                        state: ContentState.ERROR,
                        error: e.message,
                    })
                )
            )
        )
    );
}

or when new filters are requested:

@Component()
class PageComponent {
    readonly ContentState = ContentState;

    readonly requestedFilter$ = new BehaviorSubject<Filter>({});
    readonly page$ = this.requestedFilter$.pipe(
        switchMap((filter) =>
            this.pageService.loadPage(filter).pipe(
                map((item) => ({
                    state: ContentState.LOADED,
                    item,
                })),
                startWith({ state: ContentState.LOADING }),
                catchError((e) =>
                    of({
                        state: ContentState.ERROR,
                        error: e.message,
                    })
                )
            )
        )
    );
}

The main drawback here is that the existing content will disappear as soon as we attempt to fetch new content.

You can also see how progressively more complicated this mode becomes the moment we run into refreshing patterns.

Option C: Modeling State with Multiple Observables

Rather than try to encompass the entire state in one rich observable, we can take advantage of Observable caching and sharing to define a content$ , isLoading$ , and error$ Observables, all driven by the same events or data.

This way, we can reason about each of these observables separately and in terms of the others.

Given the three observables, we can render out content as such:

<mat-progress-bar *ngIf="(isLoading$ | async)" mode="indeterminate">
</mat-progress-bar>
<div *ngIf="(error$ | async) as error">Error: {{error.message}</div>
<ng-container *ngIf="(content$ | async) as page">
    <h1>{{page.title}}</h1>
    <p>{{page.paragraph}}</p>
</ng-container>

This way, we have control to decide what content to display independently from whether a loading spinner or an error is displayed.

In the simple case, our component looks like this:

@Component()
class PageComponent {
    readonly content$ = this.pageService
        .loadPage()
        .pipe(publishReplay(1), refCount());

    readonly isLoading$ = this.content$.pipe(mapTo(false), startWith(true));

    readonly error$ = this.content$.pipe(
        mapTo(false),
        catchError((e) => of(e))
    );
}

This perhaps is too complicated to see the benefit. Once we start having more complex uses, however, you’ll see that reasoning about isLoading$ and error$ separately is very helpful:

For example, when content is periodically refreshed :

@Component()
class PageComponent {
    private readonly trigger$ = timer(0, 60 * 1000).pipe(
        publishReplay(1),
        refCount()
    );

    readonly content$ = this.trigger$.pipe(
        switchMap(() => this.pageService.loadPage()),
        publishReplay(1),
        refCount()
    );

    readonly isLoading$ = merge(
        this.trigger$.pipe(mapTo(true)),
        this.content$.pipe(
            // Even errors indicate we're not
            // still loading.
            catchError(() => of(undefined)),
            mapTo(false)
        )
    );

    readonly error$ = this.content$.pipe(
        mapTo(false),
        catchError((e) => of(e))
    );
}

or when new filters are requested:

@Component()
class PageComponent {
    readonly requestedFilter$ = new BehaviorSubject<Filter>({});

    readonly content$ = this.requestedFilter$.pipe(
        switchMap((f) => this.pageService.loadPage(f)),
        publishReplay(1),
        refCount()
    );

    readonly isLoading$ = merge(
        this.requestedFilter$.pipe(mapTo(true)),
        this.content$.pipe(
            // Even errors indicate we're not
            // still loading.
            catchError(() => of(undefined)),
            mapTo(false)
        )
    );

    readonly error$ = this.content$.pipe(
        mapTo(false),
        catchError((e) => of(e))
    );
}

These last two examples illustrate what is powerful about this approach: given trigger$ (cause of refetching data) and content$ (result of fetching data) we can define isLoading$ and error$ very elegantly:

function isLoading(
    trigger$: Observable<unknown>,
    content$: Observable<unknown>
): Observable<boolean> {
    return merge(
        trigger$.pipe(mapTo(true)),
        content$.pipe(
            catchError(() => of(undefined)),
            mapTo(false)
        )
    );
}

function isError(content$): Observable<false | Error> {
    return content$.pipe(
        mapTo(false),
        catchError((e) => of(e))
    );
}

// or, alternatively: To clear error once a new fetch is triggered:
function isError(trigger$, content$): Observable<false | Error> {
    return merge(trigger$, content$).pipe(
        mapTo(false),
        catchError((e) => of(e))
    );
}

With this, we can take our examples from the Data and Page Content Refresh patterns in Angular article and give it elegant loading and error indicators:

@Component()
export class TaskComponent {
    constructor(
        private readonly http: HttpClient,
        private readonly route: ActivatedRoute
    ) {}

    private readonly autoRefresh$ = timer(0, TASK_REFRESH_INTERVAL_MS);
    private readonly refreshToken$ = new BehaviorSubject(undefined);

    markAsComplete() {
        this.route.params
            .pipe(
                map(([params]) => params["task_id"]),
                switchMap((taskId) =>
                    this.http.post(`/api/tasks/${taskId}`, {
                        state: State.Done,
                    })
                )
            )
            .subscribe(() => this.refreshToken$.next(undefined));
    }

    private readonly trigger$ = combineLatest(
        this.route.params,
        this.autoRefresh$,
        this.refreshToken$
    ).pipe(publishReplay(1), refCount());

    readonly task$ = this.trigger$.pipe(
        switchMap(([params]) =>
            this.http.get(`/api/tasks/${params["task_id"]}`)
        ),
        publishReplay(1),
        refCount()
    );

    readonly isLoading$ = isLoading(this.trigger$, this.task$);
    readonly error$ = isError(this.trigger$, this.task$);
}

Closing Thoughts

It seems like there’s some merit to look at various stateful constructs ( isLoading , error , etc.) as individual Observable “views” of some common streams that drive this data.

Deriving multiple values from other underlying observables does have a downside: we now need to worry about one of the uglier parts of Observables: sharing, caching, and reference counting.


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

驯服烂代码

驯服烂代码

伍斌 / 机械工业出版社 / 2014-11 / 69.00

Kent Beck、Martin Fowler、Michael C. Feathers、Robert C. Martin、Joshua Kerievsky、Gerard Meszaros等大师们的传世著作为如何提升编程技艺和代码质量提供了思想和原则上的指导,本书则为实践和融合这些思想、原则提供了过程和方法上指导。本书通过编程操练的方式讲述了如何用TDD(测试驱动开发)的方法来驯服烂代码,通过结对编......一起来看看 《驯服烂代码》 这本书的介绍吧!

在线进制转换器
在线进制转换器

各进制数互转换器

图片转BASE64编码
图片转BASE64编码

在线图片转Base64编码工具

HTML 编码/解码
HTML 编码/解码

HTML 编码/解码