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    }  }