TypeScript 重构 Axios 经验分享

拒绝做一个只会用 API 的文档工程师,本文将会让你从重复造轮子的过程中掌握 web 开发相关的基本知识,特别是 XMLHttpRequest。

又是一篇关于 TypeScript 的分享,年底了,请允许我沉淀一下。上次用 TypeScript 重构 Vconsole 的项目 埋下了对 Axios 源码解析的梗。于是,这次分享的主题就是 如何从零用 TypeScript 重构 Axios 以及为什么我要这么做

笔者在用 TypeScript 重复造轮子的时候目的还是很明确的,不仅是为了用 TypeScript 养成一种好的开发习惯,更重要的是了解工具库关联的基础知识。 只有更多地注重基础知识,才能早日摆脱文档工程师的困扰。(Ps: 用 TypeScript,也是为了摆脱前端查文档的宿命!)

本次分享包括以下内容:

  • 工程简介 & 开发技巧
  • API 实现
  • XHR,XHR,XHR
  • HTTP,HTTP,HTTP
  • 单元测试

项目源码,分享可能会错过某些细节实现,需要的可以看源码,测试用例基本跑通了。想想,5w star 的库,就这样自己实现了一遍。

工程简介

Axios 是什么?

Promise based HTTP client for the browser and node.js

axios 是基于 Promise 用于浏览器和 nodejs 的 HTTP 客户端,它本身具有以下特性 ( √ 表示本项目具备该特性 ):

  • √ 从浏览器创建 XMLHttpRequest => XHR 实现
  • √ 支持 Promise API => XHR 实现
  • √ 拦截请求和响应 => 请求拦截
  • √ 转换请求和响应数据 => 对应项目目录 /src/core/dispatchRequest.ts
  • √ 取消请求 取消请求
  • √ 自动转换 JSON 数据 => 对应项目目录 /src/core/dispatchRequest.ts
  • √ 客户端支持防止 CSRF/XSRF => CSRF
  • × 从 node.js 发出 http 请求

这里主要讲解浏览器端的 XHR 实现,限于篇幅不会涉及 node 下的 http 。如果你愿意一层一层了解它,你会发现实现 axios 还是很简单的,来一起探索吧!

目录说明

首先来看下目录。

目录与 Axios 基本保持一致,core 是 Axios 类的核心代码。adapters 是 XHR 核心实现,Cancel 是与 取消请求相关的代码。helpers 用于放常用的工具函数。Karma.conf.js 及 test 目录与单元测试相关。.travis.yml 用于配置 在线持续集成,另外可在 github 的 README 文件配置构建情况。

Parcel 集成

打包工具选用的是 Parcel,目的是零配置编译 TypeScript 。入口文件为 src 目录下的 index.html,只需在 入口文件里引入 index.ts 即可完成热更新,TypeScript 编译等配置:

<body>
  <script src="index.ts"></script>
</body>
复制代码

Parcel 相关:

# 全局安装
yarn global add parcel-bundler

# 启动服务
parcel ./src/index.html

# 打包
parcel build ./src/index.ts
复制代码

vscode 调试

运行完 parcel 命令会启动一个本地服务器,可以通过 .vscode 目录下的 launch.json 配置 Vscode 调试工具。

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "chrome",
      "request": "launch",
      "name": "Lanzar Chrome contra localhost",
      "url": "http://localhost:1234",
      "webRoot": "${workspaceRoot}",
      "sourceMaps": true,
      "breakOnLoad": true,
      "sourceMapPathOverrides": {
        "../*": "${webRoot}/*"
      }
    }
  ]
}
复制代码

配置完成后,可断点调试,按 F5 即可开始调试。

TypeScript 配置

TypeScript 整体配置和规范检测参考如下:

强烈建议开启 tslint ,安装 vscode tslint 插件 并在 .vscode 目录下的 .setting 配置如下格式:

{
  "editor.tabSize": 2,
  "editor.rulers": [120],
  "files.trimTrailingWhitespace": true,
  "files.insertFinalNewline": true,
  "files.exclude": {
    "**/.git": true,
    "**/.DS_Store": true
  },
  "eslint.enable": false,
  "tslint.autoFixOnSave": true,
  "typescript.format.enable": true,
  "typescript.tsdk": "node_modules/typescript/lib"
}
复制代码

如果有安装 Prettier需注意两者风格冲突,无论格式化代码的插件是什么,我们的目的只有一个,就是 保证代码格式化风格统一。( 最好遵循 lint 规范 )。

ps:.vscode 目录可随 git 跟踪进版本管理,这样可以让 clone 仓库的使用者更友好。

另外可以通过,vscode 的 控制面板中的问题 tab 迅速查看当前项目问题所在。

TypeScript 代码片段测试

我们时常会有想要编辑某段测试代码,又不想在项目里编写的需求(比如用 TypeScript 写一个 deepCopy 函数),不想脱离 vscode 编辑器的话,推荐使用 quokka,一款可立即执行脚本的插件。

接着像这样

({
  plugins: 'jsdom-quokka-plugin',
  jsdom: { html: `<div id="test">Hello</div>` }
});

const testDiv = document.getElementById('test');

console.log(testDiv.innerHTML);
复制代码

API 概览

重构的思路首先是看文档提供的 API,或者 index.d.ts 声明文件。 优秀一点的源码可以看它的测试用例,一般会提供 API 相关的测试,如 Axios API 测试用例 ,本次分享实现 API 如下:

总得下来就是五类 API,比葫芦娃还少。有信心了吧,我们来一个个"送人头"。

Axios 类

这些 API 可以统称为实例方法,有实例,就肯定有类。所以在讲 API 实现之前,先让我们来看一下 Axios 类。

两个属性(defaults,interceptors),一个通用方法( request ,其余的方法如,get、post、等都是基于 request,只是参数不同 )真的不能再简单了。

export default class Axios {
  defaults: AxiosRequestConfig;
  interceptors: {
    request: InterceptorManager;
    response: InterceptorManager;
  };
  request(config: AxiosRequestConfig = {}) {
    // 请求相关
  }
  // 由 request 延伸出 get 、post 等
}
复制代码

axios 实例

Axios 库默认导出的是 Axios 的一个实例 axios,而不是 Axios 类本身。但是,这里并没有直接返回 Axios 的实例,而是将 Axios 实例方法 request 的上下文设置为了 Axios。 所以 axios 的类型是 function,不是 object。但由于 function 也是 Object 所以可以设置属性和方法。于是 axios 既可以表现的像实例,又可以直接函数调用 axios(config)。具体实现如下:

const createInstance = (defaultConfig: AxiosRequestConfig) => {
  const context = new Axios(defaultConfig);
  const instance = Axios.prototype.request.bind(context);
  extend(instance, Axios.prototype, context);
  extend(instance, context);
  return instance;
};

axios.create = (instanceConfig: AxiosRequestConfig) => {
  return createInstance(mergeConfig(axios.defaults, instanceConfig));
};

const axios: AxiosExport = createInstance(defaults);

axios.Axios = Axios;

export default axios;
复制代码

axios 还提供了一个 Axios 类的属性,可供别的类继承。另外暴露了一个工厂函数,接收一个配置项参数,方便使用者创建多个不同配置的请求实例。

Axios 默认配置

如果不看源码,我们用一个类,最关心的应该是构造函数,默认设置了什么属性,以及我们可以修改哪些属性。体现在 Axios 就是,请求的默认配置。

下面我们来看下默认配置:

const defaults: AxiosRequestConfig = {
  headers: headers(), // 请求头
  adapter: getDefaultAdapter(), // XMLHttpRequest 发送请求的具体实现
  transformRequest: transformRequest(), // 自定义处理请求相关数据,默认有提供一个修改根据请求的 data 修改 content-type 的方法。
  transformResponse: transformResponse(), // 自定义处理响应相关数据,默认提供了一个将 respone 数据转换为 JSON格式的方法
  timeout: 0,
  xsrfCookieName: 'XSRF-TOKEN',
  xsrfHeaderName: 'X-XSRF-TOKEN',
  validateStatus(status: number) {
    return status >= 200 && status < 300;
  }
};
复制代码

也就是说,如果你用 Axios ,你应该知道它有哪些默认设置。

Axios 传入配置

先来看下 axios 接受的请求参数都有哪些属性,以下参数属性均是可选的。使用 TypeScript 事先定义了这些参数的类型,接下来传参的时候就可以检验传参的类型是否正确。

export interface AxiosRequestConfig {
  url?: string; // 请求链接
  method?: string; // 请求方法
  baseURL?: string; // 请求的基础链接
  xsrfCookieName?: string; // CSRF 相关
  xsrfHeaderName?: string; // CSRF 相关
  headers?: any; // 请求头设置
  params?: any; // 请求参数
  data?: any; // 请求体
  timeout?: number; // 超时设置
  withCredentials?: boolean; // CSRF 相关
  responseType?: XMLHttpRequestResponseType; // 响应类型
  paramsSerializer?: (params: any) => string; // url query 参数格式化方法
  onUploadProgress?: (progressEvent: any) => void; // 上传处理函数
  onDownloadProgress?: (progressEvent: any) => void; // 下载处理函数
  validateStatus?: (status: number) => boolean;
  adapter?: AxiosAdapter;
  auth?: any;
  transformRequest?: AxiosTransformer | AxiosTransformer[];
  transformResponse?: AxiosTransformer | AxiosTransformer[];
  cancelToken?: CancelToken;
}
复制代码

请求配置

  • url
  • method
  • baseURL
export interface AxiosRequestConfig {
  url?: string; // 请求链接
  method?: string; // 请求方法
  baseURL?: string; // 请求的基础链接
}
复制代码

先来看下相关知识:

url,method 作为 XMLHttpRequestopen 方法的参数。

open 语法: xhrReq.open(method, url, async, user, password);

url 是一个 DOMString,表示发送请求的 URL。

注意:将 null | undefined 传递给接受 DOMString 的方法或参数时通常会把其 stringifies 为 “null” | “undefined”

用原生的 open 方法传递如下参数,实际请求 URL 如下:

let xhr = new XMLHttpRequest();

// 假设当前 window.location.host 为 http://localhost:1234

xhr.open('get', ''); // http://localhost:1234/
xhr.open('get', '/'); // href http://localhost:1234/
xhr.open('get', null); // http://localhost:1234/null
xhr.open('get', undefined); // http://localhost:1234/undefined
复制代码

可以看到默认 baseURL 为 window.location.host 类似 http://localhost:1234/undefined 这种 URL 请求成功的情况是存在的。当前端动态传递 url 参数时,参数是有可能为 nullundefined ,如果不是通过 response 的状态码来响应操作,此时得到的结果就跟预想的不一样。这让我想起了,JavaScript 隐式转换的坑,比比皆是。(此处安利 TypeScript 和 '===' 操作符)

对于这种情况,使用 TypeScript 可以在开发阶段规避这些问题。但如果是动态赋值(比如请求返回的结果作为 url 参数时),需要给值判断下类型,必要时可抛出错误或转换为其他想要的值。

接着来看下 axios url 相关,主要提供了 baseURL 的支持,可以通过 axios.defaults.baseURLaxios({baseURL:'...'})

const isAbsoluteURL = (url: string): boolean => {
  // 1、判断是否为协议形式比如 http://
  return /^([a-z][a-z\d\+\-\.]*:)?\/\//i.test(url);
};
const combineURLs = (baseURL: string, relativeURL: string): string => {
  return relativeURL
    ? baseURL.replace(/\/+$/, '') + '/' + relativeURL.replace(/^\/+/, '')
    : baseURL;
};
const suportBaseURL = () => {
  // 2、baseURL 处理
  return baseURL && !isAbsoluteURL(url) ? combineURLs(baseURL, url) : url;
};
复制代码

params 与 data

在 axios 中 发送请求时 params 和 data 的区别在于:

  • params 是添加到 url 的请求字符串中的,用于 get 请求。

  • data 是添加到请求体(body)中的, 用于 post 请求。

params

axios 对 params 的处理分为赋值和序列化(用户可自定义 paramsSerializer 函数)

helpers 目录下的 buildURL 文件主要生成完整的 URL 请求地址。

data

XMLHttpRequest 是通过 send 方法把 data 添加到请求体的。

语法如下:

send();
send(ArrayBuffer data);
send(ArrayBufferView data);
send(Blob data);
send(Document data);
send(DOMString? data);
send(FormData data);
复制代码

可以看到 data 有这几种类型:

  • ArrayBuffer
  • ArrayBufferView
  • Blob
  • Document
  • DOMString
  • FormData

希望了解 data 有哪些类型的可以看这篇

实际使用:

var xhr = new XMLHttpRequest();
xhr.open('GET', '/server', true);

xhr.onload = function() {
  // 请求结束后,在此处写处理代码
};

xhr.send(null);
// xhr.send('string');
// xhr.send(new Blob());
// xhr.send(new Int8Array());
// xhr.send({ form: 'data' });
// xhr.send(document);
复制代码

另外,在发送请求即调用 send()方法之前应该根据 data 类型使用 setRequestHeader() 方法设置 Content-Type 头部来指定数据流的 MIME 类型。

Axios 在 transformRequest 配置项里有个默认的方法用于修改请求( 可自定义 )。

const transformRequest = () => {
  return [
    (data: any, headers: any) => {
      // ...根据 data 类型修改对应 headers
    }
  ];
};
复制代码

HTTP 相关

HTTP 请求方法

axios 提供配置 HTTP 请求的方法:

export interface AxiosRequestConfig {
  method?: string;
}
复制代码

可选配置如下:

  • GET:请求一个指定资源的表示形式. 使用 GET 的请求应该只被用于获取数据.
  • HEAD:HEAD 方法请求一个与 GET 请求的响应相同的响应,但没有响应体.
  • POST:用于将实体(data)提交到指定的资源,通常导致状态或服务器上的副作用的更改.
  • PUT:用请求有效载荷替换目标资源的所有当前表示。
  • DELETE:删除指定的资源。
  • OPTIONS:用于描述目标资源的通信选项。
  • PATCH:用于对资源应用部分修改。

接着了解下 HTTP 请求

HTTP 定义了一组请求方法, 以表明要对给定资源执行的操作。指示针对给定资源要执行的期望动作. 虽然他们也可以是名词, 但这些请求方法有时被称为 HTTP 动词. 每一个请求方法都实现了不同的语义, 但一些共同的特征由一组共享:: 例如一个请求方法可以是 safe, idempotent, 或 cacheable.

  • safe:说一个 HTTP 方法是安全的,是说这是个不会修改服务器的数据的方法。也就是说,这是一个对服务器只读操作的方法。这些方法是安全的:GET,HEAD 和 OPTIONS。有些不安全的方法如 PUT 和 DELETE 则不是。

  • idempotent:一个 HTTP 方法是幂等的,指的是同样的请求被执行一次与连续执行多次的效果是一样的,服务器的状态也是一样的。换句话说就是,幂等方法不应该具有副作用(统计用途除外)。在正确实现的条件下,GET,HEAD,PUT 和 DELETE 等方法都是幂等的,而 POST 方法不是。所有的 safe 方法也都是幂等的。

  • cacheable:可缓存的,响应是可被缓存的 HTTP 响应,它被存储以供稍后检索和使用,从而将新的请求保存在服务器。

篇幅有限,看 MDN

HTTP 请求头

axios 提供配置 HTTP 请求头的方法:

export interface AxiosRequestConfig {
  headers?: any;
}
复制代码

一个请求头由名称(不区分大小写)后跟一个冒号“:”,冒号后跟具体的值(不带换行符)组成。该值前面的引导空白会被忽略。

请求头可以被定义为:被用于 http 请求中并且和请求主体无关的那一类 HTTP header。某些请求头如 Accept, Accept-*, If-*``允许执行条件请求。某些请求头如:Cookie, User-AgentReferer 描述了请求本身以确保服务端能返回正确的响应。

并非所有出现在请求中的 http 首部都属于请求头,例如在 POST 请求中经常出现的 Content-Length 实际上是一个代表请求主体大小的 entity header,虽然你也可以把它叫做请求头。

消息头列表

axios 根据请求方法 设置了不同的 Content-TypeAccpect 请求头。

设置请求头

XMLHttpRequest 对象提供的 XMLHttpRequest对象提供的.setRequestHeader() 方法为开发者提供了一个操作这两种头部信息的方法,并允许开发者自定义请求头的头部信息。

XMLHttpRequest.setRequestHeader() 是设置 HTTP 请求头部的方法。此方法必须在 open() 方法和 send() 之间调用。如果多次对同一个请求头赋值,只会生成一个合并了多个值的请求头。

如果没有设置 Accept 属性,则此发送出 send() 的值为此属性的默认值/ 。**

安全起见,有些请求头的值只能由 user agent 设置:forbidden header names 和 forbidden response header names.

默认情况下,当发送 AJAX 请求时,会附带以下头部信息:

axios 设置代码如下:

// 在 adapters 目录下的 xhr.ts 文件中:
if ('setRequestHeader' in requestHeaders) {
  // 通过 XHR 的 setRequestHeader 方法设置请求头信息
  for (const key in requestHeaders) {
    if (requestHeaders.hasOwnProperty(key)) {
      const val = requestHeaders[key];
      if (
        typeof requestData === 'undefined' &&
        key.toLowerCase() === 'content-type'
      ) {
        delete requestHeaders[key];
      } else {
        request.setRequestHeader(key, val);
      }
    }
  }
}
复制代码

至于能不能修改 http header,我的建议是当然不能随便修改任何字段。

  • 有一些字段是绝对不能修改的,比如最重要的 host 字段,如果没有 host 值,http1.1 协议会认为这是一个不规范的请求从而直接丢弃。同样的如果随便修改这个值,那目的网站也返回不了正确的内容

  • user-agent 也不建议随便修改,有很多网站是根据这个字段做内容适配的,比如 PC 和手机肯定是不一样的内容。

  • 有一些字段能够修改,比如 connectioncache-control等。不会影响你的正常访问,但有可能会慢一点。

  • 还有一些字段可以删除,比如你不希望网站记录你的访问行为或者历史信息,你可以删除 cookie,referfer 等字段。

  • 当然你也可以自定义构造任意你想要的字段,一般没什么影响,除非 header 太长导致内容截断。通常自定义的字段都建议 X-开头。比如 X-test: lance。

HTTP 小结

只要是用户主动输入网址访问时发送的 http 请求,那这些头部字段都是浏览器自动生成的,比如 host,cookie,user-agent, Accept-Encoding 等。JS 能够控制浏览器发起请求,也能在这里增加一些 header,但是考虑到安全和性能的原因,对 JS 控制 header 的能力做了一些限制,比如 host 和 cookie, user-agent 等这些字段,JS 是无法干预的禁止修改的消息首部。关于 HTTP 的知识实在多,这里简单谈到相关联的知识。这里埋下伏笔,后续若有更适合讲 HTTP 的例子,再延伸。

接下来的 CSRF,就会修改 headers。

CSRF

与 CSRF 相关的配置属性有这三个:

export interface AxiosRequestConfig {
  xsrfCookieName?: string
  xsrfHeaderName?: string
  withCredentials?: boolean;
}

// 默认配置为
{
  xsrfCookieName: 'XSRF-TOKEN',
  xsrfHeaderName: 'X-XSRF-TOKEN',
  withCredentials: false
}
复制代码

那么,先来简单了解 CSRF

跨站请求伪造(英语:Cross-site request forgery),也被称为 one-click attack 或者 session riding,通常缩写为 CSRF 或者 XSRF, 是一种挟制用户在当前已登录的 Web 应用程序上执行非本意的操作的攻击方法。跟跨网站脚本(XSS)相比,XSS 利用的是用户对指定网站的信任,CSRF 利用的是网站对用户网页浏览器的信任。

什么是 CSRF 攻击?

你这可以这么理解 CSRF 攻击:攻击者盗用了你的身份,以你的名义发送恶意请求。CSRF 能够做的事情包括:以你名义发送邮件,发消息,盗取你的账号,甚至于购买商品,虚拟货币转账。造成的问题包括:个人隐私泄露以及财产安全。

CSRF 原理

在他们的钓鱼站点,攻击者可以通过创建一个 AJAX 按钮或者表单来针对你的网站创建一个请求:

<form action="https://my.site.com/me/something-destructive" method="POST">
  <button type="submit">Click here for free money!</button>
</form>
复制代码

要完成一次 CSRF 攻击,受害者必须依次完成两个步骤:

1.登录受信任网站 A,并在本地生成 Cookie。

2.在不登出 A 的情况下,访问危险网站 B。

如果减轻 CSRF 攻击?

只使用 JSON api

使用 JavaScript 发起 AJAX 请求是限制跨域的。 不能通过一个简单的 <form> 来发送 JSON, 所以,通过只接收 JSON,你可以降低发生上面那种情况的可能性。

禁用 CORS

第一种减轻 CSRF 攻击的方法是禁用 cross-origin requests(跨域请求)。如果你希望允许跨域请求,那么请只允许 OPTIONS, HEAD, GET 方法,因为他们没有副作用。不幸的是,这不会阻止上面的请求由于它没有使用 JavaScript(因此 CORS 不适用)。

检查 Referer 字段

HTTP 头中有一个 Referer 字段,这个字段用以标明请求来源于哪个地址。在处理敏感数据请求时,通常来说,Referer 字段应和请求的地址位于同一域名下。这种办法简单易行,工作量低,仅需要在关键访问处增加一步校验。但这种办法也有其局限性,因其完全依赖浏览器发送正确的 Referer 字段。虽然 http 协议对此字段的内容有明确的规定,但并无法保证来访的浏览器的具体实现,亦无法保证浏览器没有安全漏洞影响到此字段。并且也存在攻击者攻击某些浏览器,篡改其 Referer 字段的可能。(PS:可见遵循 web 标准多么重要)

CSRF Tokens

最终的解决办法是使用 CSRF tokens。 CSRF tokens 是如何工作的呢?

  1. 服务器发送给客户端一个 token。
  2. 客户端提交的表单中带着这个 token。
  3. 如果这个 token 不合法,那么服务器拒绝这个请求。

攻击者需要通过某种手段获取你站点的 CSRF token, 他们只能使用 JavaScript 来做。 所以,如果你的站点不支持 CORS, 那么他们就没有办法来获取 CSRF token, 降低了威胁。

确保 CSRF token 不能通过 AJAX 访问到!

不要创建一个/CSRF路由来获取一个 token, 尤其不要在这个路由上支持 CORS!

token 需要是不容易被猜到的, 让它很难被攻击者尝试几次得到。 它不需要是密码安全的。 攻击来自从一个未知的用户的一次或者两次的点击, 而不是来自一台服务器的暴力攻击。

axios 中的 CSRF Tokens

这里有个 withCredentials ,先来了解下。

XMLHttpRequest.withCredentials 属性是一个 Boolean 类型,它指示了是否该使用类似 cookies,authorization headers(头部授权)或者 TLS 客户端证书这一类资格证书来创建一个跨站点访问控制(cross-site Access-Control)请求。在同一个站点下使用 withCredentials 属性是无效的。

如果在发送来自其他域的 XMLHttpRequest 请求之前,未设置 withCredentials 为 true,那么就不能为它自己的域设置 cookie 值。而通过设置 withCredentials 为 true 获得的第三方 cookies,将会依旧享受同源策略,因此不能被通过 document.cookie 或者从头部相应请求的脚本等访问。

// 在标准浏览器环境下 (非 web worker 或者 react-native) 则添加 xsrf 头
if (isStandardBrowserEnv()) {
  // 必须在 withCredentials 或 同源的情况,才设置 xsrfHeader 头
  const xsrfValue =
    (withCredentials || isURLSameOrigin(url)) && xsrfCookieName
      ? cookies.read(xsrfCookieName)
      : undefined;
  if (xsrfValue && xsrfHeaderName) {
    requestHeaders[xsrfHeaderName] = xsrfValue;
  }
}
复制代码

CSRF 小结

对于 CSRF,需要让后端同学,敏感的请求不要使用类似 get 这种幂等的,但是由于 Form 表单发起的 POST 请求并不受 CORS 的限制,因此可以任意地使用其他域的 Cookie 向其他域发送 POST 请求,形成 CSRF 攻击。

这时,如果有涉及敏感信息的请求,需要跟后端同学配合,进行 XSRF-Token 认证。此时,我们用 axios 请求的时候,就可以通过设置 XMLHttpRequest.withCredentials=true 以及设置 axios({xsrfCookieName:'',xsrfHeaderName:''}),不使用则会用默认的 XSRF-TOKENX-XSRF-TOKEN(拿这个跟后端配合即可)。

所以,axios 特性中,客户端支持防止 CSRF/XSRF。只是方便设置 CORF-TOKEN ,关键还是要后端同学的接口支持。(PS:前后端相亲相爱多重要,所以作为前端的我们还是尽可能多了解这方面的知识

XHR 实现

axios 通过适配器模式,提供了支持 node.js 的 http 以及客户端的 XMLHttpRequest 的两张实现,本文主要讲解 XHR 实现。

大概的实现逻辑如下:

const xhrAdapter = (config: AxiosRequestConfig): AxiosPromise => {
  return new Promise((resolve, reject) => {
    let request: XMLHttpRequest | null = new XMLHttpRequest();
    setHeaders();
    openXHR();
    setXHR();
    sendXHR();
  });
};
复制代码

如果逐行讲解,不如录个教程视频,建议大家直接看 adapters 目录下的 xhr.ts ,在关键地方都有注释!

  1. xhrAdapter 接受 config 参数 ( 由默认参数和用户实例化时传入参数的合并值,axios 对合并值由做特殊处理。 )
  2. 设置请求头,比如根据传入的参数 dataauth,xsrfHeaderName 设置对应的 headers
  3. setXHR 主要是在 request.readyState === 4 的时候对响应数据作处理以及错误处理
  4. 最后执行 XMLHttpRequest.send 方法

返回的是一个 Promise 对象,所以支持 Promise 的所有特性。

请求拦截

请求拦截在 axios 应该算是一个比较骚的操作,实现非常简单。有点像一系列按顺序执行的 Promise。

直接看代码实现:

  // interceptors 分为 request 和 response。

  interface interceptors {
    request: InterceptorManager;
    response: InterceptorManager;
  }

  request (config: AxiosRequestConfig = {}) {
    const { method } = config
    const newConfig: AxiosRequestConfig = {
      ...this.defaults,
      ...config,
      method: method ? method.toLowerCase() : 'get'
    }

    // 拦截器原理:[请求拦截器,发送请求,响应拦截器] 顺序执行

    // 1、建立一个存放 [ resolve , reject ] 的数组,
    // 这里如果没有拦截器,则执行发送请求的操作。
    // 由于之后都是 resolve 和 reject 的组合,所以这里默认 undefined。真是骚操作!

    const chain = [ dispatchRequest, undefined ]

    // 2、Promise 成功后会往下传递参数,于是这里先传入合并后的参数,供之后的拦截器使用 (如果有的话)。
    let promise: any = Promise.resolve(newConfig)

    // 3、又是一波骚操作,完美的运用了数组的方法。咋不用 reduce 实现 promise 顺序执行呢 ?
    // request 请求拦截器肯定需要 `dispatchRequest` 在前面,于是 [interceptor.fulfilled, interceptor.rejected, dispatchRequest, undefined]
    this.interceptors.request.forEach((interceptor: Interceptor) => {
      chain.unshift(interceptor.fulfilled, interceptor.rejected)
    })
    // response 响应拦截器肯定需要在 `dispatchRequest` 后面,于是 [dispatchRequest, undefined,interceptor.fulfilled, interceptor.rejected]
    this.interceptors.response.forEach((interceptor: Interceptor) => {
      chain.push(interceptor.fulfilled, interceptor.rejected)
    })

    // 4、依次执行 Promise( fulfilled,rejected )
    while (chain.length) {
      promise = promise.then(chain.shift(), chain.shift())
    }

    return promise
  }
复制代码

又是对基础知识的完美运用,无论是 Promise 还是数组的变异方法都算巧妙运用。

当然,Promise 的顺序执行还可以这样:

function sequenceTasks(tasks) {
  function recordValue(results, value) {
    results.push(value);
    return results;
  }
  var pushValue = recordValue.bind(null, []);
  return tasks.reduce(function(promise, task) {
    return promise.then(task).then(pushValue);
  }, Promise.resolve());
}
复制代码

取消请求

如果不知道 XMLHttpRequest 有 absort 方法,肯定会觉得取消请求这种秀操作的怎么可能呢!( PS:基础知识多重要 )

const { cancelToken } = config;
const request = new XMLHttpRequest();

if (cancelToken) {
  cancelToken.promise
    .then(cancel => {
      if (!request) {
        return;
      }
      request.abort();
      reject(cancel);
      request = null;
    })
    .catch(err => {
      console.error(err);
    });
}
复制代码

至于 CancelToken 就不讲了,好奇怪的实现。没有感悟到原作者的设计真谛!

单元测试

最后到了单元测试的环节,先来看下相关依赖。

用的是 karma,配置如下:

执行命令:

yarn test
复制代码

本项目是基于 jasmine 来写测试用例,还是比较简单的。

karma 会跑 test 目录下的所有测试用例,感觉测试用例用 TypeScript 来写,有点难受。因为测试本来就是要让参数多样化,然而 TypeScript 事先规定了数据类型。虽然可以使用泛型来解决,但是总觉得有点变扭。

不过,整个测试用例跑下来,代码强壮了很多。对于这种库来说,还是很有必要的。如果需要二次重构,基于 TypeScript 和 有覆盖大部分函数的单元测试支持,应该会容易很多。

总结

感谢能看到这里的朋友,想必也是 TypeScript 或 Axios 的粉丝,不妨相互认识一下。

还是那句话,TypeScript 确实好用。短时间内就能将 Axios 大致重构了一遍,感兴趣的可以跟着一起。老规矩,在分享中不会具体讲库怎么用 (想必,如果自己撸完这么一个项目,应该不用去看 API 了吧。) ,更多的是从广度拓展大家的知识点。如果对某个关键词比较陌生,这就是进步的时候了。比如笔者接下来要去深入涉略 HTTP 了。虽然,感觉目前 TypeScript 的热度好像好不是很高。好东西,总是那些不容易变的。哈,别到时候打脸了。

我变强了吗? 不扯了,听杨宗纬的 "我变了,我没变" 了。

切记,没有什么是看源码解决不了的 bug。

参考

猜你喜欢

转载自juejin.im/post/5bf7f1c0e51d455ed74f625c