[React.js]组件卸载如何自动取消异步请求

5,641 阅读4分钟

背景介绍

某次路过同事的工位,刚好看到同事在写面试评价,看到里面有一个问题:组件卸载时自动取消异步请求问题,不及格。

我:???

现在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(),
        })
    }
    // ...
}