[React.js]元件解除安裝如何自動取消非同步請求

Cryptolalia發表於2019-03-02

背景介紹

某次路過同事的工位,剛好看到同事在寫面試評價,看到裡面有一個問題:元件解除安裝時自動取消非同步請求問題,不及格。

我:???

現在fetch已經支援手動abort請求了嗎?

於是上網去查各種資料:how to abort fetch http request when component umounts

然後得到的各種各樣的資料裡面,看起來比較靠譜的是這樣一種:

componentDidMount(){
  this.mounted = true;

  this.props.fetchData().then((response) => {
    if(this.mounted) {
      this.setState({ data: response })
    }
  })
}

componentWillUnmount(){
  this.mounted = false;
}
複製程式碼

我:????

就這樣嗎?

然而這個寫法並沒有真的abortfetch請求,只是不去響應fetch成功之後的結果而已,這完全沒有達到取消非同步請求的目的。

於是我去問了問同事,如何真正abort掉一個已經傳送出去的fetch請求。

同事跟我說:現在瀏覽器還不支援abortfetch請求。

我:……

同事繼續:不過我們可以通過Promise.race([cancellation, fetch()])的方式,在fetch真正結束之前先呼叫cancellation方法來返回一個reject,直接結束這個Promise,這樣就可以看似做到abort掉一個正在傳送的fetch,至於真正的fetch結果是怎麼怎樣的我們就不需要管了,因為我們已經得到了一個reject結果。

我:那麼有具體實現方法的wiki嗎?

同事:我們程式碼裡面就有,你去看看就行。

我:……(我竟然不知道!)

於是我就連讀帶問,認真研讀了一下元件解除安裝自動取消非同步請求的程式碼。

實現

整個程式碼的核心部分確實是剛才同事提到的那一行程式碼:return Promise.race([cancellation, window.fetch(input, init)]);

不過這裡的cancellation其實是另一個Promise,這個Promise負責註冊一個abort事件,當我們元件解除安裝的時候,主動觸發這個abort事件,這樣最後如果元件解除安裝之前,fetch請求已經響應完畢,就走正常邏輯,否則就因為我們觸發了abort事件返回了一個reject的響應結果。


const realFetch = window.fetch;
const abortableFetch = (input, init) => {
    // Turn an event into a promise, reject it once `abort` is dispatched
    const cancellation = new Promise((_, reject) => {
        init.signal.addEventListener(
            `abort`,
            () => {
                reject(abortError);
            },
            { once: true }
        );
        });
     // Return the fastest promise (don`t need to wait for request to finish)
    return Promise.race([cancellation, realFetch(input, init)]);
};
複製程式碼

那麼我們什麼如果觸發這個abort事件呢,又根據什麼去找到對應的fetch請求呢?

首先為了繫結和觸發我們自定義的事件,我們需要自己實現一套類似node裡面的Emitter類,這個類只需要包含註冊事件,繫結事件以及觸發事件是哪個方法即可。

emitter.js
export default class Emitter {
  constructor() {
    this.listeners = {};
  }
  dispatchEvent = (type, params) => {
    const handlers = this.listeners[type] || [];
    for(const handler of handlers) {
      handler(params);
    }
  }
  addEventListener = (type, handler) => {
    const handlers = this.listeners[type] || (this.listeners[type] = []);
    handlers.push(handler);
  }
  removeEventListener = (type, handler) => {
    const handlers = this.listeners[type] || [];
    const idx = handlers.indexOf(handler);
    if(idx !== -1) {
      handlers.splice(idx, 1);
    }
    if(handlers.length === 0) {
      delete this.listeners[type];
    }
  }
}
複製程式碼

根據Emitter類我們可以衍生出一個Signal類用作標記fetch的類,然後一個SignalController類作為Signal類的控制器。

abort-controller.js
class AbortSignal extends Emitter {
  constructor() {
    super();
    this.aborted = false;
  }
  toString() {
    return `[AbortSignal]`;
  }
}

class AbortController {
  constructor() {
    super();
    this.signal = new AbortSignal();
  }
  abort() {
    this.signal.aborted = true;
    this.signal.dispatchEvent(`abort`);
  };
  toString() {
    return `[AbortController]`;
  }
}
複製程式碼

有了這兩個類之後,我們就可以去完善一下剛才的abortableFetch函式了。

abortable-fetch.js
if (typeof Symbol !== `undefined` && Symbol.toStringTag) {
  // These are necessary to make sure that we get correct output for:
  // Object.prototype.toString.call(new AbortController())
  AbortController.prototype[Symbol.toStringTag] = `AbortController`;
  AbortSignal.prototype[Symbol.toStringTag] = `AbortSignal`;
}

const realFetch = window.fetch;
const abortableFetch = (input, init) => {
  if (init && init.signal) {
    const abortError = new Error(`Aborted`);
    abortError.name = `AbortError`;
    abortError.isAborted = true;

    // Return early if already aborted, thus avoiding making an HTTP request
    if (init.signal.aborted) {
      return Promise.reject(abortError);
    }
    // Turn an event into a promise, reject it once `abort` is dispatched
    const cancellation = new Promise((_, reject) => {
      init.signal.addEventListener(
        `abort`,
        () => {
          reject(abortError);
        },
        { once: true }
      );
    });

    delete init.signal;

    // Return the fastest promise (don`t need to wait for request to finish)
    return Promise.race([cancellation, realFetch(input, init)]);
  }

  return realFetch(input, init);
};
複製程式碼

我們在傳入的引數中加入加入一個signal欄位標識該fetch請求是可以被取消的,這個signal標識就是一個Signal類的例項。

然後當我們元件解除安裝的時候自動觸發AbortControllerabort方法,就可以了。

最後我們改造一下Component元件,給每一個元件都內建繫結signal的方法,當元件解除安裝是自動觸發abort方法。

enhance-component.js
import React from `react`;
import { AbortController } from `lib/abort-controller`;

/**
 * 用於元件解除安裝時自動cancel所有註冊的promise
 */
export default class EnhanceComponent extends React.Component {
  constructor(props) {
    super(props);
    this.abortControllers = [];
  }
  componentWillUnmount() {
    this.abortControl();
  }

  /**
   * 取消signal對應的Promise的請求
   * @param {*} signal
   */
  abortControl(signal) {
    if(signal !== undefined) {
      const idx = this._findControl(signal);
      if(idx !== -1) {
        const control = this.abortControllers[idx];
        control.abort();
        this.abortControllers.splice(idx, 1);
      }
    } else {
      this.abortControllers.forEach(control => {
        control.abort();
      });
      this.abortControllers = [];
    }
  }

  /**
   * 註冊control
   */
  bindControl = () => {
    const controller = new AbortController();
    this.abortControllers.push(controller);
    return controller.signal;
  }
  _findControl(signal) {
    const idx = this.abortControllers.findIndex(controller => controller.signal === signal);
    return idx;
  }
}
複製程式碼

這樣,我們所有繼承自EnhanceComponent的元件都會自帶一個bindControllerabort方法,我們將bindController生成的signal傳入fetch的引數就可以完成元件解除安裝是自動取消非同步請求了。

xxxComponent.js
import EnhanceComponent from `components/enhance-component`;
export default class Demo extends EnhanceComponent {
    // ...
    fetchData() {
        util.fetch(UPLOAD_IMAGE, {
            method: `POST`,
            data: {},
            signal: this.bindControl(),
        })
    }
    // ...
}
複製程式碼

相關文章