本篇会藉由设计「取消重複请求机制」来解析 axios 的原始码,篇幅较长请耐心阅读。
其实要实践取消请求的功能并不会很难,官方也有一目了然的 教学,不过我自己在实作后一直对于 cancelToken
的原理耿耿于怀,就去研究了一下原始码,所以在实际撰写之前,想先分享一下我的理解。
接下来我们会直接看打包过的档案: axios/dist/axios.js
,所有 axios 的程式码都在这。你可以一边看 github 一边看文章。
为什么需要取消请求
cancelToken
可以为我们取消多余或不必要的 http请求
,虽然在一般情况下可能感觉不到有取消请求的必要,不过在一些特殊情况中没有好好处理的话,可能会导致一些问题发生。像是...
Pending
时间较久的 API
若短时间内重複请求,会有旧盖新的情况。重复的 post
请求,有可能导致多次的资料操作,例如表单发送两次。发送请求与拦截器
# Class Axios
先从最主要的 Axios类别
看起,每一个 axios 应用都会创建一个 Axios类别
,而当中最核心的就是 request
方法,不过我们先暂时跳过。
后面两段则是在类别上又新增了好几个方法,让我们可以发起不同的http请求: axios.get()
、axios.post()
。
不过仔细一看会发现,最终我们呼叫的还是 request
,所以才会说 request
是 axios 的核心。
function Axios(instanceConfig) { this.defaults = instanceConfig; this.interceptors = { request: new InterceptorManager(), response: new InterceptorManager() };}Axios.prototype.request = function request(config) { // ...先跳过};// 帮不同的请求方法创建别名,最终都是呼叫requestutils.forEach(['delete', 'get', 'head', 'options'], function forEachMethodNoData(method) { Axios.prototype[method] = function(url, config) { return this.request(utils.merge(config || {}, { method: method, url: url })); };});utils.forEach(['post', 'put', 'patch'], function forEachMethodWithData(method) { Axios.prototype[method] = function(url, data, config) { return this.request(utils.merge(config || {}, { method: method, url: url, data: data })); };});
# Class InterceptorManager
在前面我们有看到,Axios类别
中有个 interceptors
属性,其值为物件,并且有 request
和 response
的属性。
这两个属性都是 InterceptorManager类别
,而这个类别是用来管理拦截器的,我之也写过 一篇 在介绍拦截器是什么,不晓得的人可以去看一下。
而今天我们就是要用Axios的拦截器来达到取消重複请求的功能,所以来看看 InterceptorManager
吧。
function InterceptorManager() { // 储存拦截器的方法,未来阵列里会放入物件,每个物件会有两个属性分别对应成功和失败后的函式 this.handlers = [];}// 在拦截器里新增一组函式,我们在上一篇有用过InterceptorManager.prototype.use = function use(fulfilled, rejected) { this.handlers.push({ fulfilled: fulfilled, rejected: rejected }); return this.handlers.length - 1;};// 注销拦截器里的某一组函式InterceptorManager.prototype.eject = function eject(id) { if (this.handlers[id]) { this.handlers[id] = null; }};// 原码的写法我觉得很容易看不懂,所以我改写了一下// 简单来说就是拿handlers跑迴圈,把里面的物件当作参数来给fn执行InterceptorManager.prototype.forEach = function(fn) { this.handlers.forEach(obj => { fn(h); });};
基本上这个类别还蛮单纯的,主要就是三个操作 handlers
的方法,我们之前就是透过 axios.interceptors.request.use
和 axios.interceptors.response.use
来添加拦截器的。
但现在我们要再更深入了解Axios是怎么在请求前后透过拦截器处理 request
和 response
的,这时候就要回去看 Axios.prototype.request
了。
# Axios.prototype.request
可以发现,每当我们发送请求 Axios.prototype.request
会宣告一个阵列以及一个Promise物件。
并且利用 InterceptorManager.prototype.forEach
把我们拦截器中新增的函式一一放进 chain
中。
至于 dispatchRequest
就是Axios主要发送 XMLHttpRequest
的函式,我们等等会提到。
当所有函式都放进 chain
后再两两一组拿出来作为 promise.then()
的参数,而且利用Promise的链式呼叫来串接。
最后我们的请求就可以依照 request拦截器 -> dispatchRequest -> response拦截器
的顺序进行处理。
Axios.prototype.request = function request(config) { //..省略 var chain = [dispatchRequest, undefined]; // 定义一个状态是resolve的Promise; config是发出请求时带的设定 var promise = Promise.resolve(config); // InterceptorManager.prototype.forEach,把request拦截器的每一组函式「往前」加进chain里 this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) { chain.unshift(interceptor.fulfilled, interceptor.rejected); }); // InterceptorManager.prototype.forEach,把response拦截器的每一组函式「往后」加进chain里 this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) { chain.push(interceptor.fulfilled, interceptor.rejected); }); // 全部加进去后,chain会长的像是这样: [ // request.handlers[0].fulfilled, request.handlers[0].rejected, ..., // dispatchRequest, undefined, // response.handlers[0].fulfilled, response.handlers[0].rejected, ..., // ] // 只要chain里还有项目,就继续执行 while (chain.length) { promise = promise.then(chain.shift(), chain.shift()); } return promise;};
最后把所有的函数串接起来后,promise
会像是下面这样,并且 Axios.prototype.request
会把这个 promise
返回出来,所以我们才可以在呼叫 axios.get()
之后直接用 then()
。
Promise.resolve(config) .then(requestFulfilled, requestRejected) .then(dispatchRequest, undefined) .then(responseFulfilled, responseRejected)
这个 Promise
已经是 resolve
状态,所以请求拦截器会拿到 config
来做前置处理。官方文件有规定,添加请求拦截器的时候,fulfilled函式最后要返回 config
,所以 dispatchRequest
才能拿到 config
来发送请求。dispatchRequest
在完成 XMLHttpRequest
后会返回请求的 response
给回应拦截器。官方文件一样有规定回应拦截器的fulfilled函式最后要返回 response
,所以你最后才可以拿到API资料。# Function dispatchRequest
现在知道了拦截器是如何串接的了,那 dispatchRequest
是如何发送http请求的呢?
我们只看重点部分,当中 adapter
会根据发送请求的环境对应到不同的适配器(建立请求的函式),而 dispatchRequest
会再以 then()
串接,由http请求的成功或失败来决定要进入回应拦截器的 fulfilled
函式或 rejected
函式。
module.exports = function dispatchRequest(config) { // 检查请求是否被取消的函式 throwIfCancellationRequested(config); // axios会使用预设的http请求适配器,除非你有特别设定 // 以浏览器发送请求会使用xhrAdapter,node环境则使用httpAdapter var adapter = config.adapter || defaults.adapter; // 适配器会把http请求包装成Promise并返回,dispatchRequest再以then()串接 return adapter(config).then( // 若请求成功dispatchRequest会返回response给回应拦截器的fulfilled函式 function onAdapterResolution(response) { throwIfCancellationRequested(config); return response; }, // 反之则将错误抛给回应拦截器的rejected函式 function onAdapterRejection(reason) { if (!isCancel(reason)) throwIfCancellationRequested(config); return Promise.reject(reason); } );}
另外可以看到 throwIfCancellationRequested
不断的出现,这个函式会检查请求是否已经被「要求」取消,等我们进入到 CancelToken 时会再提到它。
# Function xhrAdapter
由于我们是以浏览器发送请求,所以这边以 xhrAdapter
适配器为主。(完整程式码)xhrAdapter
整段很长,但如果只看重点,其实就是在发送 XMLHttpRequest
,并在过程中做一些判断来决定要 resolve
或 reject
这个 Promise
。
module.exports = function xhrAdapter(config) { return new Promise(function dispatchXhrRequest(resolve, reject) { // 建立一个新的XMLHttpRequest var request = new XMLHttpRequest(); // 监听readyState的变化 request.onreadystatechange = function handleLoad() { // readyState === 4 代表请求完成 if (!request || request.readyState !== 4) return; // 若请求完成,準备好回应的response var responseHeaders = 'getAllResponseHeaders' in request ? parseHeaders(request.getAllResponseHeaders()) : null; var responseData = !config.responseType || config.responseType === 'text' ? request.responseText : request.response; var response = { data: responseData, status: request.status, statusText: request.statusText, headers: responseHeaders, config: config, request: request }; // settle内部会做一些验证,成功则resolve(response),反之reject(error) settle(resolve, reject, response); request = null; }; // 发送XMLHttpRequest request.send(requestData); });};
到目前为止我们已经知道 axios 处理请求的流程,接下来就进入本文的重点 - CancelToken。
我把整个架构图像化,希望对各位有帮助。
CancelToken
# 基本用法
在看原始码前,我们先看看 CancelToken
是怎么使用的。
这段程式做了什么可以先不管,我们只要知道,如果要使用 CancelToken
就必须在 request
的 config
中新增一个 cancelToken
属性。
let cancelaxios.get('/user/12345', { cancelToken: new axios.CancelToken(c => { cancel = c; })});cancel()
# Class CancelToken
再来就该看看我们在 cancelToken
属性中建构的 CancelToken类别
是什么。
CancelToken
都会建立一个 Promise
,并且将 resolve
主动权给拿了出来,定义给resolvePromise
。再者,当我们要建构一个 CancelToken
的时候必须传入一个 function
,它会直接被呼叫并且得到一个名为 cancel
的函式作为参数。当要取消请求就是呼叫 cancel
,而它做了两件事情: 1. 赋值给属性 reason
2. 将属性 promise
给 resolve
function CancelToken(executor) { // 判断executor是否为function if (typeof executor !== 'function') { throw new TypeError('executor must be a function.'); } // 建立一个新的Promise物件,并将其resolve函式赋予给变数resolvePromise // 此时Promise会是pending状态,还未被resolve var resolvePromise; this.promise = new Promise(function promiseExecutor(resolve) { resolvePromise = resolve; }); // 执行executor,并以函式「cancel」作为参数带入 var token = this; executor(function cancel(message) { // 确认reason是否存在,若存在代表cancel已被执行过 if (token.reason) return; // 将reason赋值为一个Cancel类别 token.reason = new Cancel(message); // resolve Promise resolvePromise(token.reason); });}// 确认reason是否存在,若存在代表此CancelToken的cancel已被执行过,便抛出错误CancelToken.prototype.throwIfRequested = function throwIfRequested() { if (this.reason) throw this.reason;};
所以 axios 只要根据这两个属性,就能判断此次请求是否已经被取消,而 throwIfRequested
就是利用 reason
来判断是否要抛出错误。
# throwIfCancellationRequested
还记得我们在 dispatchRequest
里有看到 throwIfCancellationRequested
不断的被呼叫吗?(请看章节 #Function-dispatchRequest)
它的作用就是判断 config
是否有被加上 cancelToken
属性,有的话就会呼叫 CancelToken.prototype.throwIfRequested
,以此来判断请求是否已被取消。
function throwIfCancellationRequested(config) { if (config.cancelToken) config.cancelToken.throwIfRequested();}
# Function xhrAdapter
没错,又再次看到了 xhrAdapter
,因为在前面我暂时省略了 xhrAdapter
内部的一个判断。
当它发现 config.cancelToken
存在,便会为 CancelToken.promise
接上一个 then()
,意味着当 promise
被 resolve
的那一刻,请求就会被 abort
。
module.exports = function xhrAdapter(config) { return new Promise(function dispatchXhrRequest(resolve, reject) { var request = new XMLHttpRequest(); // ...省略.... if (config.cancelToken) { // cancelToken.promise要被resolve才会执行then // onCanceled(cancel)中的cancel会是cancelToken.reason config.cancelToken.promise.then(function onCanceled(cancel) { if (!request) return; // 取消XMLHttpRequest request.abort(); reject(cancel); request = null; }); } request.send(requestData); });};
# 重点整理
首先我们可以知道 CancelToken 的原理就是在 request config
中加上一个 CancelToken类别
,并且利用其类别属性来判断 cancel
函式是否被呼叫执行,若已执行代表该请求被「要求」取消。
另外可以发现 axios 在以下三个时机点都有检查请求的取消与否:
请求发送前 - [dispatchRequest开头]请求发送中 - [xhrAdapterq]请求发送后 - [dispatchRequest.then]实际运用
了解整个 axios 架构以及 CancelToken 后,终于可以来实践取消请求的功能了,先来釐清我们的需求。
每次发送请求要判断是否已经存在相同的请求,若存在就取消前一次请求,只保留最新的
根据这样的需求我们归纳出几个必要的关键,然后準备以下程式码
为了要能取消请求,必须设定config.cancelToken
为了要判断重複的请求,要把每次请求记录在暂存中在请求完成或被取消时从暂存中移除// 暂存:纪录执行中的请求const pending = new Map();const addPending = config => { // 利用method和url来当作这次请求的key,一样的请求就会有相同的key const key = [config.method, config.url].join("&"); // 为config添加cancelToken属性 config.cancelToken = new axios.CancelToken(cancel => { // 确认暂存中没有相同的key后,把这次请求的cancel函式存起来 if (!pending.has(key)) pending.set(key, cancel); });};const removePending = config => { // 利用method和url来当作这次请求的key,一样的请求就会有相同的key const key = [config.method, config.url].join("&"); // 如果暂存中有相同的key,把先前存起来的cancel函式拿出来执行,并且从暂存中移除 if (pending.has(key)) { const cancel = pending.get(key); cancel(key); pending.delete(key); }};
準备就绪后,只要在请求拦截与回应拦截器中呼叫它们即可...
// request 拦截器instance.interceptors.request.use( config => { // 先判断是否有重複的请求要取消 removePending(config); // 把这次请求加入暂存 addPending(config); return config; }, error => { return Promise.reject(error); });// response 拦截器instance.interceptors.response.use( response => { // 请求被完成,从暂存中移除 removePending(response); return response; }, error => { return Promise.reject(error); });
从此我们不必再担心 API 在回应前被重複触发导致错误,因为我们永远只会保留最新一次的请求。