Angular 實踐:如何優雅地發起和處理請求
- 2020 年 2 月 26 日
- 筆記

Tips: 本文實現重度依賴 ObservableInput,靈感來自同事 @Mengqi Zhang 實現的 asyncData 指令,但之前沒有 ObservableInput 的裝飾器,處理響應 Input 變更相對麻煩一些,所以這裡使用 ObservableInput 重新實現。
What And Why
大部分情況下處理請求有如下幾個過程:

看着很複雜的樣子,既要 Loading,又要 Reload,還要 Retry,如果用命令式寫法可能會很蛋疼,要處理各種分支,而今天要講的 rxAsync 指令就是用來優雅地解決這個問題的。
How
我們來思考下如果解決這個問題,至少有如下四個點需要考慮。
1.發起請求有如下三種情況:
- 第一次渲染主動加載
- 用戶點擊重新加載
- 加載出錯自動重試
2.渲染的過程中需要根據請求的三種狀態 —— loading, success, error (類似 Promise 的 pending, resolved, rejected) —— 動態渲染不同的內容
3.輸入的參數發生變化時我們需要根據最新參數重新發起請求,但是當用戶輸入的重試次數變化時應該忽略,因為重試次數隻影響 Error 狀態
4.用戶點擊重新加載可能在我們的指令內部,也可能在指令外部
Show Me the Code
話不多說,上代碼:
@Directive({ selector: '[rxAsync]', }) export class AsyncDirective<T, P, E = HttpErrorResponse> implements OnInit, OnDestroy { @ObservableInput() @Input('rxAsyncContext') private context$!: Observable<any> // 自定義 fetcher 調用時的 this 上下文,還可以通過箭頭函數、fetcher.bind(this) 等方式解決 @ObservableInput() @Input('rxAsyncFetcher') private fetcher$!: Observable<Callback<[P], Observable<T>>> // 自動發起請求的回調函數,參數是下面的 params,應該返回 Observable @ObservableInput() @Input('rxAsyncParams') private params$!: Observable<P> // fetcher 調用時傳入的參數 @Input('rxAsyncRefetch') private refetch$$ = new Subject<void>() // 支持用戶在指令外部重新發起請求,用戶可能不需要,所以設置一個默認值 @ObservableInput() @Input('rxAsyncRetryTimes') private retryTimes$!: Observable<number> // 發送 Error 時自動重試的次數,默認不重試 private destroy$$ = new Subject<void>() private reload$$ = new Subject<void>() private context = { reload: this.reload.bind(this), // 將 reload 綁定到 template 上下文中,方便用戶在指令內重新發起請求 } as IAsyncDirectiveContext<T, E> private viewRef: Nullable<ViewRef> private sub: Nullable<Subscription> constructor( private templateRef: TemplateRef<any>, private viewContainerRef: ViewContainerRef, ) {} reload() { this.reload$$.next() } ngOnInit() { // 得益於 ObservableInput ,我們可以一次性響應所有參數的變化 combineLatest([ this.context$, this.fetcher$, this.params$, this.refetch$$.pipe(startWith(null)), // 需要 startWith(null) 觸發第一次請求 this.reload$$.pipe(startWith(null)), // 同上 ]) .pipe( takeUntil(this.destroy$$), withLatestFrom(this.retryTimes$), // 忽略 retryTimes 的變更,我們只需要取得它的最新值即可 ) .subscribe(([[context, fetcher, params], retryTimes]) => { // 如果參數變化且上次請求還沒有完成時,自動取消請求忽略掉 this.disposeSub() // 每次發起請求前都重置 loading 和 error 的狀態 Object.assign(this.context, { loading: true, error: null, }) this.sub = fetcher .call(context, params) .pipe( retry(retryTimes), // 錯誤時重試 finalize(() => { // 無論是成功還是失敗,都取消 loading,並重新觸發渲染 this.context.loading = false if (this.viewRef) { this.viewRef.detectChanges() } }), ) .subscribe( data => (this.context.$implicit = data), error => (this.context.error = error), ) if (this.viewRef) { return this.viewRef.markForCheck() } this.viewRef = this.viewContainerRef.createEmbeddedView( this.templateRef, this.context, ) }) } ngOnDestroy() { this.disposeSub() this.destroy$$.next() this.destroy$$.complete() if (this.viewRef) { this.viewRef.destroy() this.viewRef = null } } disposeSub() { if (this.sub) { this.sub.unsubscribe() this.sub = null } } }
Usage
總共 100 多行的源碼,說是很優雅,那到底使用的時候優不優雅呢?來個實例看看:
@Component({ selector: 'rx-async-directive-demo', template: ` <button (click)="refetch$$.next()">Refetch (Outside rxAsync)</button> <div *rxAsync=" let todo; let loading = loading; let error = error; let reload = reload; context: context; fetcher: fetchTodo; params: todoId; refetch: refetch$$; retryTimes: retryTimes " > <button (click)="reload()">Reload</button> loading: {{ loading }} error: {{ error | json }} <br /> todo: {{ todo | json }} </div> `, preserveWhitespaces: false, changeDetection: ChangeDetectionStrategy.OnPush, }) class AsyncDirectiveComponent { context = this @Input() todoId = 1 @Input() retryTimes = 0 refetch$$ = new Subject<void>() constructor(private http: HttpClient) {} fetchTodo(todoId: string) { return typeof todoId === 'number' ? this.http.get('//jsonplaceholder.typicode.com/todos/' + todoId) : EMPTY } }