Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

前端领域如何实现请求中断 #13

Open
lengyue0909 opened this issue Dec 23, 2020 · 0 comments
Open

前端领域如何实现请求中断 #13

lengyue0909 opened this issue Dec 23, 2020 · 0 comments

Comments

@lengyue0909
Copy link
Owner

引言

几乎在所有面向用户或企业的应用程序中,所呈现出来的信息都不是一成不变的,即数据都是动态的,由某个或者多个后台服务所提供。那么就不可避免地会涉及到网络请求,而对于不同企业肯定有不同的业务场景。在一个功能完善的应用程序呈现给用户之前,前后端开发人员必须先根据产品经理提供的业务需求文档协商建立起格式良好的接口契约,然后再经过开发联调测试验证部署上线等一系列流程之后才具有可用性,才能展现在用户面前供用户使用。

但是可能并不是在任何场景下,我们都需要关心网络请求的响应结果,或者说在某些场景下,我们只需要关心最新的有效的网络请求,对于老旧的失效的网络请求,我们甚至可以忽略它的存在。我们知道,从浏览器发起一次网络请求,到建立TCP链接(对于HTTPS协议还需要建立额外的TLS连接)以及DNS域名解析,再到发送请求数据报文,最终服务器处理请求并响应数据,期间会不停占用客户端和服务器资源。如果该网络请求对于我们而言已经无效,那么我们就可以通过手动中断请求,来提前释放被占用的资源,减少不必要的资源开销。

例如考虑以下场景:

  • VueReact单页应用中,组件A挂载完毕之后向后台服务发起请求拉取数据,但是由于加载过慢,用户可能期间发生路由跳转或回退,导致组件A卸载,但是组件内部的网络请求并没有立即停止下来,此时的响应数据对于已卸载的组件A而言已经无效。若刚好此时请求响应错误,就可能导致前端实现的兜底弹窗出现在跳转后的页面中,造成视觉干扰;
  • 页面存在定时轮询业务,即固定间隔一段时间再次发起请求,这样就可能存在多个请求间的竞争关系,如果上一个请求的响应速度比最近一次请求的响应速度慢,则前者就会覆盖后者,从而导致数据错乱;
  • 类似于关键字搜索或模糊查询等需要频繁发起网络请求的相关业务,可能在一定程度上为了优化程序的执行性能,减少冗余的网络IO,我们会使用防抖(debounce)函数来对请求逻辑进行包装,减少查询次数以降低服务器压力,但是依旧避免不了由于加载耗时过长导致新老请求数据错乱的问题;
  • 针对前端大文件上传等上传服务,需要实现上传进度的暂停恢复,即断点续传

还有很多其他没有列出的应用场景,针对每种应用场景,虽然我们都能给出对应的方案来解决实际问题,但是笔者认为最理想的方案还是尽量减少无用请求,减少客户端和服务器之间的无效传输,鉴于此也就引入了本文中将要讲到的中断请求的方式。

在前端领域,个人觉得有几种比较常见的网络请求方案:浏览器原生支持的XMLHttpRequest对象同时兼容浏览器端和NodeJS服务端的第三方HTTP库Axios大部分浏览器最新实现的Fetch API。本文主要基于以上三种请求方案讲解一下各自中断请求的方式,文中若有错误,还请指正。

1、XMLHttpRequest

浏览器原生实现的XMLHttpRequest(以下简称XHR)构造函数对于我们来说已经是再熟悉不过了,但是在实际应用中,大部分场景下可能我们并不需要去主动实例化XHR构造函数,毕竟实例化之后还需要通过调用opensend等一系列的官方API才能实现与服务器的数据交互,操作细节稍微繁琐。

相反我们一般会推荐使用社区实现的第三方库来方便我们简化操作流程,提升开发效率,例如下一节将要讲述的Axios。但即便是Axios,在浏览器端其底层依旧是通过XHR构造函数来实现网络IO的,因此这一小节有必要对XHR的相关知识点进行回顾和讲解。

首先抛出一个基础示例:

/**
 * @description: 基于 XHR 封装的网络请求工具函数
 * @param {String} url 请求接口地址
 * @param {Document | XMLHttpRequestBodyInit | null} body 请求体
 * @param {Object} requestHeader 请求头
 * @param {String} method 请求方法
 * @param {String} responseType 设置响应内容的解析格式
 * @param {Boolean} async 请求是否异步
 * @param {Number} timeout 设置请求超时时间(单位:毫秒)
 * @param {Boolean} withCredentials 设置跨域请求是否允许携带 cookies 或 Authorization header 等授权信息
 * @return {Promise} 可包含响应内容的 Promise 实例
*/
function request({
  url,
  body = null,
  requestHeader = {'Content-Type': 'application/x-www-form-urlencoded'},
  method = 'GET',
  responseType = 'text',
  async = true,
  timeout = 30000,
  withCredentials = false,
} = {}) {
  return new Promise((resolve, reject) => {
    if (!url) {
      return reject(new TypeError('the required parameter [url] is missing.'));
    }
    
    if (method.toLowerCase() === 'get' && body) {
      url += `?${request.serialize(body)}`;
      body = null;
    }

    const xhr = new XMLHttpRequest();
    xhr.open(method, url, async);

    if (async) {
      xhr.responseType = responseType;
      xhr.timeout = timeout;
    }
    xhr.withCredentials = withCredentials;

    if (requestHeader && typeof requestHeader === 'object') {
      Object.keys(requestHeader).forEach(key => xhr.setRequestHeader(key, requestHeader[key]));
    }

    xhr.onreadystatechange = function onReadyStateChange() {
      if (xhr.readyState === XMLHttpRequest.DONE) {
        if (xhr.status >= 200 && xhr.status < 300 || xhr.status === 304) {
          resolve(xhr.response);
        }
      }
    };

    xhr.onerror = function onError(error) {
      console.log(error);
      reject({ message: '请求出错,请稍后重试' });
    };

    xhr.ontimeout = function onTimeout() {
      reject({ message: '接口超时,请稍后重试' });
    };

    xhr.send(body ? JSON.stringify(body) : null);
  });
}

以上示例对XHR请求操作流程进行了一下简单的封装,并未涉及到太多的细节和兼容处理。一个简单的调用方式如下:

request({
  url: 'http://www.some-domain.com/path/to/example',
  method: 'POST',
  requestHeader: {'Content-Type': 'application/json; charset=UTF-8'},
  body: {key: value}
}).then(response => console.log(response));

基于以上操作便完成了一次客户端和服务器的数据交互请求,接下来在此基础上继续完善请求中断的相关逻辑。

我们知道,在XHR实例上为我们提供了一个abort方法用于终止该请求,并且当一个请求被终止的时候,该请求所对应的XHR实例的readyState属性将会被设置为XMLHttpRequest.UNSET(0),同时status属性会被重置为0,因此在本示例中我们同样使用abort方法来实现请求中断。

// 参考以上示例
function request({
  // 省略入参
  ...
} = {}) {
  return new Promise((resolve, reject) => {
    // 省略代码
    ...
  });
}

// 存储请求接口地址以及请求体和 XHR 实例的映射关系
request.cache = {};

/**
 * @description: 根据提供的键名中断对应的请求 
 * @param {String} key 存储在 request.cache 属性中的键名,若未提供则中断全部请求 
 * @return {void}
 */
request.clearCache = (key) => {
  if (key) {
    const instance = request.cache[key];
    if (instance) {
      instance.abort();
      delete request.cache[key];
    }

    return;
  }

  Object.keys(request.cache).forEach(cacheKey => {
    const instance = request.cache[cacheKey];
    instance.abort();
    delete request.cache[cacheKey];
  });
};

在以上示例中,我们通过request.cache来临时存储请求接口地址以及请求体和XHR实例的映射关系,因为在同一页面中一般可能会涉及到多个接口地址不同的请求,或者同一个请求对应不同的请求体,因此这里考虑加上了请求体以做区分。当然为了作为request.cache中的唯一键名,我们还需要对请求体进行序列化操作,因此简单封装一个序列化工具函数。

/**
 * @description: 将请求体序列化为字符串
 * @param {Document | XMLHttpRequestBodyInit | null} data 请求体
 * @return {String} 序列化后的字符串
 */
request.serialize = (data) => {
  if (data && typeof data === 'object') {
    const result = [];

    Object.keys(data).forEach(key => {
      result.push(`${key}=${JSON.stringify(data[key])}`);
    });

    return result.join('&');
  }

  return data;
}

完成以上的基础代码之后,接下来我们将其应用到request函数中:

function request({
  url,
  body = null,
  // 省略部分入参
  ...
} = {}) {
  return new Promise((resolve, reject) => {
    if (!url) {
      return reject(new TypeError('the required parameter [url] is missing.'));
    }
    
    // 省略部分代码
    ...

    const xhr = new XMLHttpRequest();

    // 将请求接口地址以及请求体和 XHR 实例存入 cache 中
    let cacheKey = url;
    if (body) {
      cacheKey += `_${request.serialize(body)}`;
    }

    // 每次发送请求之前将上一个未完成的相同请求进行中断
    request.cache[cacheKey] && request.clearCache(cacheKey);
    request.cache[cacheKey] = xhr;
    
    // 省略部分代码
    ...

    xhr.onreadystatechange = function onReadyStateChange() {
      if (xhr.readyState === XMLHttpRequest.DONE) {
        if (xhr.status >= 200 && xhr.status < 300 || xhr.status === 304) {
          // 请求完成之后清除缓存
          request.clearCache(cacheKey);
          resolve(xhr.response);
        }
      }
    };

    xhr.onerror = function onError(error) {
      console.log(error);
      // 请求报错之后清除缓存
      request.clearCache(cacheKey);
      reject({ message: '请求出错,请稍后重试' });
    };

    xhr.ontimeout = function onTimeout() {
      // 请求超时之后清除缓存
      request.clearCache(cacheKey);
      reject({ message: '接口超时,请稍后重试' });
    };

    xhr.send(body ? JSON.stringify(body) : null);
  });
}

这样便简单实现了一个自包含的请求中断的处理逻辑,每次发送请求之前自动判定未完成的多余请求并将其清除,从而避免性能上的开销。当然,不仅如此,这里同样可以通过request.clearCache函数来在组件卸载或路由跳转的时候手动清除未完成的请求,因为这部分请求对于卸载后的组件而言没有太多实质意义,例如以下示例:

// 网页卸载前清除缓存
window.addEventListener('beforeunload', () => request.clearCache(), false);

// Vue 中路由跳转前清除缓存
router.beforeEach((to, from, next) => { request.clearCache(); next(); });

// React 中路由跳转时清除缓存
import { Component } from 'react';
import { withRouter } from 'react-router-dom';
class App extends Component {
  componentDidMount() {
    // 监听路由变化
    this.props.history.listen(location => {
      // 通过比较 location.pathname 来判定路由是否发生变化
      if (this.props.location.pathname !== location.pathname) {
        // 若路由发生变化,则清除缓存
        request.clearCache();
      }
    });
  }
}

export default withRouter(App);

2、Axios

Axios想必是我们使用最多的一个第三方开源免费的HTTP库,其本身基于Promise的特性使得我们可以很方便地写出更加优雅且易维护的代码,从而避免函数多层嵌套所带来的一系列问题。

当然,它最大的特点在于可以同时兼容浏览器端和NodeJS服务端。底层通过判定不同的运行环境来自动提供不同的适配器,在浏览器端通过原生的XHR对象来发送请求,而在NodeJS服务端则通过内置的http模块来发送请求。不仅如此,在其底层的Promise管道链中还为我们暴露了称之为拦截器的入口,使得我们可以参与到一个请求的生命周期中,在请求发送之前和响应接收之后能够自定义实现数据的装配和转换操作。带来的如此之多的人性化操作,使得我们没有理由不去用它,这也奠定了其长久以来依旧如此火爆的基础。

言归正传,在Axios中同样为我们提供了请求中断的相关API。首先抛出一个基础示例:

// 安装 axios
npm install --save axios

// 导入 axios
import axios from 'axios';
// 创建 axios 实例
const instance = axios.create({
  baseURL: 'https://www.some-domain.com/path/to/example',
  timeout: 30000,
  headers: {
    'Content-Type': 'application/x-www-form-urlencoded',
  },
});
// 设置 axios 实例默认配置
instance.defaults.headers.common['Authorization'] = '';
instance.defaults.headers.post['Content-Type'] = 'application/json; charset=UTF-8';

// 自定义请求拦截器
instance.interceptors.request.use(config => {
  const token = window.localStorage.getItem('token');
  token && (config.headers['Authorization'] = token);
  return config;
}, error => Promise.reject(error));

// 自定义响应拦截器
instance.interceptors.response.use(response => {
  if (response.status === 200) {
    return Promise.resolve(response.data);
  }
  
  return Promise.reject(response);
}, error => Promise.reject(error));

接下来我们结合Axios提供的CancelToken构造函数来创建一个简单的post请求:

const CancelToken = axios.CancelToken;
let cancel;

instance.post('/api/user/123', {
  name: 'new name',
  phone: 'new phone',
}, {
  // CancelToken 构造函数接收一个 executor 函数参数,并且该函数接收一个取消函数 c 用于取消该次请求
  cancelToken: new CancelToken(function executor(c) {
    // 将取消函数赋值到外部变量,方便从外部取消请求
    cancel = c;
  }),
});

// 手动取消请求
cancel();

针对需要同时取消多个请求以及自动取消的应用场景,上面的示例显然不能满足我们的需求。这里我们同样可以利用上一小节的思路来维护一个请求接口地址以及请求体和取消函数c之间的映射关系。同时为了避免在每个请求中都需要手动去实例化CancelToken,我们可以巧妙利用request拦截器来整合这部分的逻辑,实现逻辑复用。首先我们将缓存逻辑拆分到一个单独的文件中:

// cacheUtils.js
export const CacheUtils = {
  // 存储请求接口地址以及请求体和取消函数之间的映射关系
  cache: {},
  
  // 根据提供的键名 key 取消对应的请求,若未提供则取消全部请求
  clearCache: function (key) {
    if (key) {
      const cancel = this.cache[key];
      if (cancel && typeof cancel === 'function') {
        cancel();
        delete this.cache[key];
      }

      return;
    }

    Object.keys(this.cache).forEach(cacheKey => {
      const cancel = this.cache[cacheKey];
      cancel();
      delete this.cache[cacheKey];
    });
  },
};

接下来我们将其应用到请求拦截器和响应拦截器中:

import qs from 'qs';
import { CacheUtils } from './cacheUtils.js';

// 自定义请求拦截器
instance.interceptors.request.use(config => {
  let cacheKey = config.url;
  
  const token = window.localStorage.getItem('token');
  token && (config.headers['Authorization'] = token);
  
  const method = config.method.toLowerCase();
  if (method === 'get' && config.params && typeof config.params === 'object') {
    cacheKey += qs.stringify(config.params, { addQueryPrefix: true });
  }
  
  if (['post', 'put', 'patch'].includes(method) && config.data && typeof config.data === 'object') {
    config.data = qs.stringify(config.data);
    cacheKey += `_${qs.stringify(config.data, { arrayFormat: 'brackets' })}`;
  }
  
  // 每次发送请求之前将上一个未完成的相同请求进行中断
  CacheUtils.cache[cacheKey] && CacheUtils.clearCache(cacheKey);
  
  // 将当前请求所对应的取消函数存入缓存
  config.cancelToken = new axios.CancelToken(function executor(c) {
    CacheUtils.cache[cacheKey] = c;
  });
  
  // 临时保存 cacheKey,用于在响应拦截器中清除缓存
  config.cacheKey = cacheKey;
  
  return config;
}, error => Promise.reject(error));

// 自定义响应拦截器
instance.interceptors.response.use(response => {
  // 响应接收之后清除缓存
  const cacheKey = response.config.cacheKey;
  delete CacheUtils.cache[cacheKey];
  
  if (response.status === 200) {
    return Promise.resolve(response.data);
  }
  
  return Promise.reject(response);
}, error => {
  // 响应异常清除缓存
  if (error.config) {
    const cacheKey = error.config.cacheKey;
    delete CacheUtils.cache[cacheKey];
  }
  
  return Promise.reject(error);
});

这里我们同样提供CacheUtils.clearCache函数来应对需要手动清除未完成请求的应用场景,使用方式与上一小节思路相同,这里就不再重复多讲。

3、Fetch API

作为浏览器原生提供的XHR构造函数的理想替代方案,新增的Fetch API为我们提供了RequestResponse(以及其他与网络请求有关的)对象的通用定义,一个Request对象表示一个资源请求,通常包含一些初始数据和正文内容,例如资源请求路径、请求方式、请求主体等,而一个Response对象则表示对一次请求的响应数据。

同时Fetch API还为我们提供了一个全局的fetch方法,通过该方法我们可以更加简单合理地跨网络异步获取资源。fetch方法不仅原生支持Promise的链式操作,同时还支持直接传入Request对象来发送请求,增加了很强的灵活性。

到目前为止,Fetch API的支持程度如下图:

不难看出IE浏览器下的兼容性不容乐观,但是作为一名有追求的前端开发人员,当然不会止步于此。一番探索之后,发现可以通过isomorphic-fetch或者whatwg-fetch这两个第三方依赖来解决兼容性问题:

// 安装依赖
npm install --save whatwg-fetch

// 引入依赖
import {fetch as fetchPolyfill} from 'whatwg-fetch';

接下来同样先抛出一个基础示例:

const url = 'http://www.some-domain.com/path/to/example';
const initData = {
  method: 'POST',
  body: JSON.stringify({key: value}),
  headers: {
    'Content-Type': 'application/json; charset=UTF-8',
  },
  cache: 'no-cache',
  credentials: 'same-origin',
  mode: 'cors',
  redirect: 'follow',
  referrer: 'no-referrer',
};
fetch(url, initData).then(response => response.json()).then(data => console.log(data));
  
// 也可以直接通过 Request 构造函数来初始化请求数据
// Request 构造函数接收两个参数
// 第一个参数表示需要获取的资源 URL 路径或者另一个嵌套的 Request 实例
// 第二个可选参数表示需要被包含到请求中的各种自定义选项
const request = new Request(url, initData);
fetch(request).then(response => response.json()).then(data => console.log(data));

可以看到,相比于传统的XHR方式而言,fetch函数的使用方式更加简洁友好,易用性更强,同时还为我们提供了多种入参的形式使得程序功能变得更加的灵活可扩展。

那么回到本文的主题,上文中提到,在XHR实例中可以通过abort方法来取消请求,在Axios中可以通过CancelToken构造函数的参数来获得取消函数,从而通过取消函数来取消请求。但是很遗憾的是,在Fetch API中,并没有自带的取消请求的API供我们调用。不过令人愉悦的是,除了IE浏览器外,其他浏览器已经为Abort API添加了实验性支持,Abort API允许对XHRfetch这样的请求操作在未完成时进行终止,那么接下来对Abort API做一下简要的介绍。

Abort API的相关概念中主要包含了AbortControllerAbortSignal两大接口:

  • AbortController:表示一个控制器对象,该对象拥有一个只读属性signal和一个方法abortsignal属性表示一个AbortSignal实例,当我们需要取消某一个请求时,需要将该signal属性所对应的AbortSignal实例与请求进行关联,然后通过控制器对象提供的abort方法来取消请求;
  • AbortSignal:表示一个信号对象,作为控制器对象和请求之间通信的桥梁,允许我们通过控制器对象来对请求进行取消操作。该对象拥有一个只读属性aborted和一个方法onabortaborted属性体现为一个布尔值,表示与之通信的请求是否已经被终止,而onabort方法会在控制器对象终止该请求时调用。

通过以上两个接口,我们尝试封装一个简单加强版的可取消的fetch工具函数:

const abortableFetch = (url, initData) => {
  // 实例化控制器对象
  const abortController = new AbortController();
  
  // 获取信号对象
  const signal = abortController.signal;
  
  return {
    // 注意这里需要将 signal 信号对象与请求进行关联,关联之后才能通过 abortController.abort 方法取消请求
    ready: fetch(url, {...initData, signal}).then(response => response.json()),
    // 暴露 cancel 方法,用于在外层手动取消请求
    cancel: () => abortController.abort(),
  };
};

并将其应用到之前的基础示例中:

const url = 'http://www.some-domain.com/path/to/example';
const initData = {
  method: 'POST',
  body: JSON.stringify({key: value}),
  headers: {
    'Content-Type': 'application/json; charset=UTF-8',
  },
  cache: 'no-cache',
  credentials: 'same-origin',
  mode: 'cors',
  redirect: 'follow',
  referrer: 'no-referrer',
};

const {ready, cancel} = abortableFetch(url, initData);
ready
  .then(response => console.log(response))
  .catch(err => {
    if (err.name === 'AbortError') {
      console.log('请求已被终止');
    }
  });

// 手动取消请求
cancel();

至此我们便成功完成了基于Abort API的请求中断逻辑,当然如果针对需要同时取消多个请求以及自动取消的应用场景,在abortableFetch函数中我们已经对外暴露了cancel方法,是不是想起来在第二小节介绍Axios的过程中,同样出现过cancel方法, 所以这里完全可以借助上文中的思路,构建出请求路径与请求体以及cancel取消函数之间的映射关系,对缓存进行集中管理并对外提供清空缓存的工具方法,由于实现思路与上文中的大同小异,这里就不再展开细讲,感兴趣的小伙伴儿可以自己尝试下。

总结

这里我们再次回顾一下本文主要讲解的内容,本文主要是基于目前前端领域使用的几种比较常见的网络请求方案,讲解了一下在代码层面各自实现请求中断的处理方式。在浏览器原生提供的XHR对象中,我们通过实例上的abort方法来终止请求。在Axios库中,我们借助于其提供的CancelToken构造函数同样实现了请求中断。最后,我们通过fetch函数和Abort API的相互配合,实现了在现代主流浏览器的Fetch API中请求中断的方式。通过这些优化操作可以提前释放被占用的资源,一定程度上减少了不必要的资源开销。

感谢阅读

如果你觉得这篇文章的内容对你有帮助,能否帮个忙关注一下笔者的公众号[前端之境],每周都会努力原创一些前端技术干货,关注公众号后可以邀你加入前端技术交流群,我们可以一起互相交流,共同进步。

文章已同步更新至Github博客,若觉文章尚可,欢迎前往star!

你的一个点赞,值得让我付出更多的努力!

逆境中成长,只有不断地学习,才能成为更好的自己,与君共勉!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant