TypeScript重构axios库

需求分析

  • 在浏览器端使用 XMLHttpRequest 对象通讯
  • 支持 Promise API
  • 支持请求和响应的拦截器
  • 支持请求数据和响应数据的转换
  • 支持请求的取消
  • JSON 数据的自动转换
  • 客户端防止 XSRF

此外,还会有一些 axios 库支持的一些其它的 feature。

初始化项目

使用TypeScript library starter脚手架工具

先通过 git clone 把项目代码拉到 ts-axios 目录,然后运行 npm install 安装依赖,并且给项目命名。

生成的目录结构如下:

├── CONTRIBUTING.md
├── LICENSE 
├── README.md
├── code-of-conduct.md
├── node_modules
├── package-lock.json
├── package.json
├── rollup.config.ts // rollup 配置文件
├── src // 源码目录
├── test // 测试目录
├── tools // 发布到 GitHup pages 以及 发布到 npm 的一些配置脚本工具
├── tsconfig.json // TypeScript 编译配置文件
└── tslint.json // TypeScript lint 文件

集成了很多优秀的开源工具:

  • 使用 RollupJS 帮助我们打包。
  • 使用 PrettierTSLint 帮助我们格式化代码以及保证代码风格一致性。
  • 使用 TypeDoc 帮助我们自动生成文档并部署到 GitHub pages。
  • 使用 Jest 帮助我们做单元测试。
  • 使用 Commitizen 帮助我们生成规范化的提交注释。
  • 使用 Semantic release 帮助我们管理版本和发布。
  • 使用 husky 帮助我们更简单地使用 git hooks。
  • 使用 Conventional changelog 帮助我们通过代码提交信息自动生成 change log。

demo编写

利用 Node.js 的 express 库去运行我们的 demo,利用 webpack 来作为 demo 的构建工具。

先安装编写 demo 需要的依赖包,如下:

"webpack": "^4.28.4",
"webpack-dev-middleware": "^3.5.0",
"webpack-hot-middleware": "^2.24.3",
"ts-loader": "^5.3.3",
"tslint-loader": "^3.5.4",
"express": "^4.16.4",
"body-parser": "^1.18.3"

其中,webpack 是打包构建工具,webpack-dev-middlewarewebpack-hot-middleware 是 2 个 expresswebpack 中间件,ts-loadertslint-loaderwebpack 需要的 TypeScript 相关 loader,express 是 Node.js 的服务端框架,body-parserexpress 的一个中间件,解析 body 数据用的。

然后在 examples 目录下创建 webpack 配置文件 webpack.config.js

const fs = require("fs");
const path = require("path");
const webpack = require("webpack");

module.exports = {
    
    
  mode: "development",

  /**
   * 我们会在 examples 目录下建多个子目录
   * 我们会把不同章节的 demo 放到不同的子目录中
   * 每个子目录的下会创建一个 app.ts
   * app.ts 作为 webpack 构建的入口文件
   * entries 收集了多目录个入口文件,并且每个入口还引入了一个用于热更新的文件
   * entries 是一个对象,key 为目录名
   */
  entry: fs.readdirSync(__dirname).reduce((entries, dir) => {
    
    
    const fullDir = path.join(__dirname, dir);
    const entry = path.join(fullDir, "app.ts");
    if (fs.statSync(fullDir).isDirectory() && fs.existsSync(entry)) {
    
    
      entries[dir] = ["webpack-hot-middleware/client", entry];
    }

    return entries;
  }, {
    
    }),

  /**
   * 根据不同的目录名称,打包生成目标 js,名称和目录名一致
   */
  output: {
    
    
    path: path.join(__dirname, "__build__"),
    filename: "[name].js",
    publicPath: "/__build__/",
  },

  module: {
    
    
    rules: [
      {
    
    
        test: /\.ts$/,
        enforce: "pre",
        use: [
          {
    
    
            loader: "tslint-loader",
          },
        ],
      },
      {
    
    
        test: /\.tsx?$/,
        use: [
          {
    
    
            loader: "ts-loader",
            options: {
    
    
              transpileOnly: true,
            },
          },
        ],
      },
    ],
  },

  resolve: {
    
    
    extensions: [".ts", ".tsx", ".js"],
  },

  plugins: [
    new webpack.HotModuleReplacementPlugin(),
    new webpack.NoEmitOnErrorsPlugin(),
  ],
};

examples 目录下创建 server.js 文件:

const express = require("express");
const bodyParser = require("body-parser");
const webpack = require("webpack");
const webpackDevMiddleware = require("webpack-dev-middleware");
const webpackHotMiddleware = require("webpack-hot-middleware");
const WebpackConfig = require("./webpack.config");

const app = express();
const compiler = webpack(WebpackConfig);

app.use(
  webpackDevMiddleware(compiler, {
    
    
    publicPath: "/__build__/",
    stats: {
    
    
      colors: true,
      chunks: false,
    },
  })
);

app.use(webpackHotMiddleware(compiler));

app.use(express.static(__dirname));

app.use(bodyParser.json());
app.use(bodyParser.urlencoded({
    
     extended: true }));

const port = process.env.PORT || 8080;
module.exports = app.listen(port, () => {
    
    
  console.log(`Server listening on http://localhost:${
      
      port}, Ctrl+C to stop`);
});

然后在 examples 目录下创建对应功能实现的目录,在每个目录下再对应创建 index.htmlapp.ts 文件来编写各自demo。

基础功能实现

基础请求发送

src 目录下创建一个 index.ts 文件,作为整个库的入口文件,然后定义一个 axios 方法,并把它导出。

// function axios(config) {}
// export default axios;

import {
    
     AxiosRequestConfig } from "./types";
function axios(config: AxiosRequestConfig) {
    
    }
export default axios;

接下来,我们需要给 config 参数定义一种接口类型。我们创建一个 types 目录,在下面创建一个 index.ts 文件,作为我们项目中公用的类型定义文件。

接下来定义 AxiosRequestConfig 接口类型:

export interface AxiosRequestConfig {
    
    
  url: string;
  method?: Method;
  data?: any;
  params?: any;
}

其中,url 为请求的地址,必选属性;而其余属性都是可选属性。method 是请求的 HTTP 方法;datapostpatch 等类型请求的数据,放到 request body 中的;paramsgethead 等类型请求的数据,拼接到 urlquery string 中的。

为了让 method 只能传入合法的字符串,定义一种字符串字面量类型 Method

export type Method =
  | "get"
  | "GET"
  | "delete"
  | "Delete"
  | "head"
  | "HEAD"
  | "options"
  | "OPTIONS"
  | "post"
  | "POST"
  | "put"
  | "PUT"
  | "patch"
  | "PATCH";

我们不在 index.ts 中去实现发送请求的逻辑,利用模块化的编程思想,把这个功能拆分到一个单独的模块中。

src 目录下创建一个 xhr.ts 文件,我们导出一个 xhr 方法,它接受一个 config 参数,类型也是 AxiosRequestConfig 类型

import {
    
     AxiosRequestConfig } from "./types";

export default function xhr(config: AxiosRequestConfig): void {
    
    
  
  const {
    
     data = null, url, method = "get" } = config;

  const request = new XMLHttpRequest();

  request.open(method.toUpperCase(), url, true);

  request.send(data);
}

我们首先通过解构赋值的语法从 config 中拿到对应的属性值赋值给变量,并且还定义了一些默认值,因为在 AxiosRequestConfig 接口的定义中,有些属性是可选的。

接着我们实例化了一个 XMLHttpRequest 对象,然后调用了它的 open 方法,传入了对应的一些参数,最后调用 send 方法发送请求。

编写好了 xhr 模块,我们就需要在 index.ts 中去引入这个模块。

import {
    
     AxiosRequestConfig } from "./types";
import xhr from "./xhr";

function axios(config: AxiosRequestConfig): void {
    
    
  xhr(config);
}

export default axios;

至此,基本的发送请求代码就编写完毕。

处理请求 url 参数

需求分析:接下来需要处理把 params 对象的 key 和 value 拼接到 url

处理情况包括:参数值为数组、参数值为对象、参数值为 Date 类型、特殊字符支持、空值忽略、丢弃 url 中的哈希标记、保留 url 中已存在的参数。

根据需求分析,我们要实现一个工具函数,把 params 拼接到 url 上。我们希望把项目中的一些工具函数、辅助方法独立管理,于是我们创建一个 helpers 目录,在这个目录下创建 url.ts 文件,未来会把处理 url 相关的工具函数都放在该文件中。

// helpers/url.ts

import {
    
     isDate, isObject } from './util'

function encode (val: string): string {
    
    
  return encodeURIComponent(val)
    .replace(/%40/g, '@')
    .replace(/%3A/gi, ':')
    .replace(/%24/g, '$')
    .replace(/%2C/gi, ',')
    .replace(/%20/g, '+')
    .replace(/%5B/gi, '[')
    .replace(/%5D/gi, ']')
}

export function bulidURL (url: string, params?: any) {
    
    
  if (!params) {
    
    
    return url
  }

  const parts: string[] = []

  Object.keys(params).forEach((key) => {
    
    
    let val = params[key]
    if (val === null || typeof val === 'undefined') {
    
    
      return
    }
    // /base/get?foo[]=bar&foo[]=baz
    let values: string[]
    if (Array.isArray(val)) {
    
    
      values = val
      key += '[]'
    } else {
    
    
      values = [val]
    }
    // /base/get?date=2019-04-01T05:55:39.030Z
    values.forEach((val) => {
    
    
      if (isDate(val)) {
    
    
        val = val.toISOString()
      } else if (isObject(val)) {
    
    
        val = JSON.stringify(val)
      }
      parts.push(`${
      
      encode(key)}=${
      
      encode(val)}`)
    })
  })

  let serializedParams = parts.join('&')

  // 判断不是一个空数组
  if (serializedParams) {
    
    
    const markIndex = url.indexOf('#')   // 判断是否有hash,如果有就把后面的都删除掉
    if (markIndex !== -1) {
    
    
      url = url.slice(0, markIndex)
    }
    // 有问号证明有参数了,直接加&,没问号就加个问号
    url += (url.indexOf('?') === -1 ? '?' : '&') + serializedParams
  }
  return url
}
// helpers/util.ts

const toString = Object.prototype.toString
// 判断类型的方法 Object.prototype.toString.call()

// 类型保护 val is Date
export function isDate (val: any): val is Date {
    
    
  return toString.call(val) === '[object Date]'
}

export function isObject (val: any): val is Object {
    
    
  return val !== null && typeof val === 'object'
}

我们已经实现了 buildURL 函数,接下来我们来利用它实现 url 参数的处理逻辑

function axios (config: AxiosRequestConfig): void {
    
    
  processConfig(config)
  xhr(config)
}

function processConfig (config: AxiosRequestConfig): void {
    
    
  config.url = transformUrl(config)
}

function transformUrl (config: AxiosRequestConfig): string {
    
    
  const {
    
     url, params } = config
  return bulidURL(url, params)
}

在执行 xhr 函数前,先执行 processConfig 方法,对 config 中的数据做处理,除了对 urlparams 处理之外,未来还会处理其它属性。在 processConfig 函数内部,通过执行 transformUrl 函数修改了 config.url,该函数内部调用了 buildURL

至此,对 url 参数处理逻辑就实现完了。

处理请求 body 数据

需求分析:我们通过执行 XMLHttpRequest 对象实例的 send 方法来发送请求,并通过该方法的参数设置请求 body 数据。 send 方法的参数支持 DocumentBodyInit 类型,BodyInit 包括了 Blob, BufferSource, FormData, URLSearchParams, ReadableStreamUSVString,当没有数据的时候,我们还可以传入 null。但是我们最常用的场景还是传一个普通对象给服务端,这个时候 data 是不能直接传给 send 函数的,我们需要把它转换成 JSON 字符串。

根据需求分析,我们要实现一个工具函数,对 request 中的 data 做一层转换。我们在 helpers 目录新建 data.ts 文件。

import {
    
     isPlainObject } from './util'

export function transformRequest (data: any): any {
    
    
  if (isPlainObject(data)) {
    
    
    return JSON.stringify(data)
  }
  return data
}
// helpers/util.js

export function isPlainObject (val: any): val is Object {
    
    
  return toString.call(val) === '[object Object]'
}

这里为什么要使用 isPlainObject 函数判断,而不用之前的 isObject 函数呢,因为 isObject 的判断方式,对于 FormDataArrayBuffer 这些类型,isObject 判断也为 true,但是这些类型的数据我们是不需要做处理的,而 isPlainObject 的判断方式,只有我们定义的普通 JSON 对象才能满足。

// index.ts

import {
    
     transformRequest } from './helpers/data'

function processConfig (config: AxiosRequestConfig): void {
    
    
  config.url = transformURL(config)
  config.data = transformRequestData(config)
}

function transformRequestData (config: AxiosRequestConfig): any {
    
    
  return transformRequest(config.data)
}

定义 transformRequestData 函数,去转换请求 body 的数据,内部调用了 transformRequest 方法。

然后我们在 processConfig 内部添加了这段逻辑,在处理完 url 后接着对 config 中的 data 做处理。

接下来编写demo,但是 base/post 请求的 response 里却返回的是一个空对象。

实际上是因为我们虽然执行 send 方法的时候把普通对象 data 转换成一个 JSON 字符串,但是我们请求 headerContent-Typetext/plain;charset=UTF-8,导致了服务端接受到请求并不能正确解析请求 body 的数据。

处理请求 header

需求分析:我们做了请求数据的处理,把 data 转换成了 JSON 字符串,但是数据发送到服务端的时候,服务端并不能正常解析我们发送的数据,因为并没有给请求 header 设置正确的 Content-Type。所以首先我们要支持发送请求的时候,可以支持配置 headers 属性。并且在当我们传入的 data 为普通对象的时候,headers 如果没有配置 Content-Type 属性,需要自动设置 Content-Type 字段为:application/json;charset=utf-8

根据需求分析,我们要实现一个工具函数,对 request 中的 headers 做一层加工。我们在 helpers 目录新建 headers.ts 文件。

import {
    
     isPlainObject } from './util'

// 因为请求 header 属性是大小写不敏感的,比如我们之前的例子传入 header 的属性名 content-type 就是全小写的,所以我们先要把一些 header 属性名规范化
function normalizeHeaderName (headers: any, normalizedName: string): void {
    
    
  if (!headers) {
    
    
    return
  }
  Object.keys(headers).forEach(name => {
    
    
    if (name !== normalizedName && name.toUpperCase() === normalizedName.toUpperCase()) {
    
    
      headers[normalizedName] = headers[name]
      delete headers[name]
    }
  })
}

export function processHeaders (headers: any, data: any): any {
    
    
  normalizeHeaderName(headers, 'Content-Type')
  
  if (isPlainObject(data)) {
    
    
    if (headers && !headers['Content-Type']) {
    
    
      headers['Content-Type'] = 'application/json;charset=utf-8'
    }
  }
  return headers
}

接下来实现请求 header 处理逻辑。

先修改一下 AxiosRequestConfig 接口类型的定义,添加 headers 这个可选属性。

// types/index.ts
export interface AxiosRequestConfig {
    
    
  url: string
  method?: Method
  data?: any
  params?: any
  headers?: any
}

//index.ts
function processConfig (config: AxiosRequestConfig): void {
    
    
  config.url = transformURL(config)
  config.headers = transformHeaders(config)
  config.data = transformRequestData(config)
}
function transformHeaders (config: AxiosRequestConfig) {
    
    
  const {
    
     headers = {
    
    }, data } = config
  return processHeaders(headers, data)
}

因为我们处理 header 的时候依赖了 data,所以要在处理请求 body 数据之前处理请求 header

// xhr.ts

export default function xhr (config: AxiosRequestConfig): void {
    
    
  const {
    
     data = null, url, method = 'get', headers } = config

  const request = new XMLHttpRequest()

  request.open(method.toUpperCase(), url, true)

  Object.keys(headers).forEach((name) => {
    
    
    // 当我们传入的 data 为空的时候,请求 header 配置 Content-Type 是没有意义的,于是我们把它删除。
    if (data === null && name.toLowerCase() === 'content-type') {
    
    
      delete headers[name]
    } else {
    
    
      request.setRequestHeader(name, headers[name])
    }
  })

  request.send(data)
}

获取响应数据

需求分析:我们发送的请求可以从网络层面接收到服务端返回的数据,但是代码层面并没有做任何关于返回数据的处理。我们希望能处理服务端响应的数据,并支持 Promise 链式调用的方式。

axios({
    
    
  method: 'post',
  url: '/base/post',
  data: {
    
    
    a: 1,
    b: 2
  }
}).then((res) => {
    
    
  console.log(res)
})

我们可以拿到 res 对象,并且我们希望该对象包括:服务端返回的数据 data,HTTP 状态码 status,状态消息 statusText,响应头 headers、请求配置对象 config 以及请求的 XMLHttpRequest 对象实例 request

根据需求,我们可以定义一个 AxiosResponse 接口类型

export interface AxiosResponse {
    
    
  data: any
  status: number
  statusText: string
  headers: any
  config: AxiosRequestConfig
  request: any
}

另外,axios 函数返回的是一个 Promise 对象,我们可以定义一个 AxiosPromise 接口,它继承于 Promise<AxiosResponse> 这个泛型接口

// 当 axios 返回的是 AxiosPromise 类型,那么 resolve 函数中的参数就是一个 AxiosResponse 类型
export interface AxiosPromise extends Promise<AxiosResponse> {
    
    
}

对于一个 AJAX 请求的 response,我们是可以指定它的响应的数据类型的,通过设置 XMLHttpRequest 对象的 responseType 属性,于是我们可以给 AxiosRequestConfig 类型添加一个可选属性

export interface AxiosRequestConfig {
    
    
  // ...
  responseType?: XMLHttpRequestResponseType
}

responseType 的类型是一个 XMLHttpRequestResponseType 类型,它的定义是 "" | "arraybuffer" | "blob" | "document" | "json" | "text" 字符串字面量类型。

接下来实现获取响应数据逻辑。首先我们要在 xhr 函数添加 onreadystatechange 事件处理函数,并且让 xhr 函数返回的是 AxiosPromise 类型。

// 实现 axios 函数的 Promise 化
export default function xhr(config: AxiosRequestConfig): AxiosPromise {
    
    
  return new Promise((resolve) => {
    
    
    const {
    
     data = null, url, method = 'get', headers, responseType } = config

    const request = new XMLHttpRequest() // 创建一个 request 实例

    // 这里判断了如果config中配置了responseType,把它设置到request.responseType中。
    if (responseType) {
    
    
      request.responseType = responseType
    }

    request.open(method.toUpperCase(), url, true)

    request.onreadystatechange = function handleLoad() {
    
    
      if (request.readyState !== 4) {
    
    
        return
      }

      const responseHeaders = request.getAllResponseHeaders()
      const responseData = responseType && responseType !== 'text' ? request.response : request.responseText
      const response: AxiosResponse = {
    
    
        data: responseData,
        status: request.status,
        statusText: request.statusText,
        headers: responseHeaders,
        config,
        request
      }
      resolve(response)
    }

    Object.keys(headers).forEach((name) => {
    
    
      // 当传入的 data 为空,请求 header 配置 Content-Type 是没有意义的,所以把它删除
      if (data === null && name.toLowerCase() === 'content-type') {
    
    
        delete headers[name]
      } else {
    
    
        request.setRequestHeader(name, headers[name])
      }
    })

    request.send(data)
  })
}

这样我们就实现了 axios 函数的 Promise 化。

处理响应 header

需求分析:我们通过 XMLHttpRequest 对象的 getAllResponseHeaders 方法获取到的值是如下一段字符串

date: Fri, 05 Apr 2019 12:40:49 GMT
etag: W/"d-Ssxx4FRxEutDLwo2+xkkxKc4y0k"
connection: keep-alive
x-powered-by: Express
content-length: 13
content-type: application/json; charset=utf-8

每一行都是以回车符和换行符 \r\n 结束,它们是每个 header 属性的分隔符。对于上面这串字符串,我们希望最终解析成一个对象结构:

{
    
    
  date: 'Fri, 05 Apr 2019 12:40:49 GMT'
  etag: 'W/"d-Ssxx4FRxEutDLwo2+xkkxKc4y0k"',
  connection: 'keep-alive',
  'x-powered-by': 'Express',
  'content-length': '13'
  'content-type': 'application/json; charset=utf-8'
}

根据需求分析,我们要实现一个 parseHeaders 工具函数。

// helpers/headers.ts

export function parseHeaders(headers: string): any {
    
    
  let parsed = Object.create(null)
  if (!headers) {
    
    
    return parsed
  }
  // split()方法用于把一个字符串分割成字符串数组
  headers.split('\r\n').forEach(line => {
    
    
    let [key, ...vals] = line.split(':')
    key = key.trim().toLowerCase()
    if (!key) {
    
    
      return
    }
    let val = vals.join(':').trim()
    parsed[key] = val
  })
  return parsed
}

然后我们在xhr.ts中使用这个工具函数

const responseHeaders = parseHeaders(request.getAllResponseHeaders())

此时发现响应的 headers 字段从字符串解析成对象结构了,接下来解决对响应 data 字段的处理。

处理响应 data

需求分析:在不设置 responseType 的情况下,当服务端返回的数据是字符串类型,把它转换成 JSON 对象。

根据需求分析,我们要实现一个 transformResponse 工具函数。

// helpers/data.ts

export function transformResponse(data: any): any {
    
    
  if (typeof data === 'string') {
    
    
    try {
    
    
      data = JSON.parse(data)
    } catch (e) {
    
    
      // do nothing
    }
  }
  return data
}

然后在 index.ts 里去使用该方法

function axios(config: AxiosRequestConfig): AxiosPromise {
    
    
  processConfig(config)
  return xhr(config).then((res) => {
    
    
    return transformResponseData(res)
  })
}

function transformResponseData(res: AxiosResponse): AxiosResponse {
    
    
  res.data = transformResponse(res.data)
  return res
}

此时响应的 data 字段从字符串解析成 JSON 对象结构了。

至此, ts-axios 的基础功能已经实现完毕。不过到目前为止,我们都仅仅实现的是正常情况的逻辑,接下来我们要处理各种异常情况的逻辑。

异常情况处理

我们希望程序能捕获到错误,做进一步的处理。

如果在请求的过程中发生任何错误,我们都可以在 reject 回调函数中捕获到。

处理网络异常错误

当网络出现异常(比如不通)的时候发送请求会触发 XMLHttpRequest 对象实例的 error 事件,于是我们可以在 onerror 的事件回调函数中捕获此类错误。

我们在 xhr 函数中添加如下代码:

request.onerror = function handleError() {
    
    
  reject(new Error('Network Error'))
}

处理超时错误

我们可以设置某个请求的超时时间 timeout,也就是当请求发送后超过某个时间后仍然没收到响应,则请求自动终止,并触发 timeout 事件。

请求默认的超时时间是 0,即永不超时。所以我们首先需要允许程序可以配置超时时间:

export interface AxiosRequestConfig {
    
    
  // ...
  timeout?: number
}

接着在 xhr 函数中添加如下代码:

const {
    
     /*...*/ timeout } = config

if (timeout) {
    
    
  request.timeout = timeout
}

request.ontimeout = function handleTimeout() {
    
    
  reject(new Error(`Timeout of ${
      
      timeout} ms exceeded`))
}

处理非 200 状态码

对于一个正常的请求,往往会返回 200-300 之间的 HTTP 状态码,对于不在这个区间的状态码,我们也把它们认为是一种错误的情况做处理。

request.onreadystatechange = function handleLoad() {
    
    
  if (request.readyState !== 4) {
    
    
    return
  }

  if (request.status === 0) {
    
    
    return
  }

  const responseHeaders = parseHeaders(request.getAllResponseHeaders())
  const responseData =
    responseType && responseType !== 'text' ? request.response : request.responseText
  const response: AxiosResponse = {
    
    
    data: responseData,
    status: request.status,
    statusText: request.statusText,
    headers: responseHeaders,
    config,
    request
  }
  handleResponse(response)
}

function handleResponse(response: AxiosResponse) {
    
    
  if (response.status >= 200 && response.status < 300) {
    
    
    resolve(response)
  } else {
    
    
    reject(new Error(`Request failed with status code ${
      
      response.status}`))
  }
}

我们在 onreadystatechange 的回调函数中,添加了对 request.status 的判断,因为当出现网络错误或者超时错误的时候,该值都为 0。

接着我们在 handleResponse 函数中对 request.status 的值再次判断,如果是 2xx 的状态码,则认为是一个正常的请求,否则抛错。

至此对各种错误都做了处理,并把它们抛给了程序应用方,让他们对错误可以做进一步的处理。

但是这里的错误都仅仅是简单的 Error 实例,只有错误文本信息,并不包含是哪个请求、请求的配置、响应对象等其它信息。那么接下来我们会对错误信息做增强。

错误信息增强

目前错误信息提供的非常有限,我们希望对外提供的信息不仅仅包含错误文本信息,还包括了请求对象配置 config,错误代码 codeXMLHttpRequest 对象实例 request 以及自定义响应对象 response

axios({
    
    
  method: 'get',
  url: '/error/timeout',
  timeout: 2000
}).then((res) => {
    
    
  console.log(res)
}).catch((e: AxiosError) => {
    
    
  console.log(e.message)
  console.log(e.request)
  console.log(e.code)
})

首先先来定义 AxiosError 类型接口,用于外部使用。

// types/index.ts

export interface AxiosError extends Error {
    
    
  config: AxiosRequestConfig
  code?: string
  request?: any
  response?: AxiosResponse
  isAxiosError: boolean
}

接着我们创建 error.ts 文件,然后实现 AxiosError 类,它是继承于 Error 类。

import {
    
     AxiosRequestConfig, AxiosResponse } from '../types'

export class AxiosError extends Error {
    
    
  isAxiosError: boolean
  config: AxiosRequestConfig
  code?: string | null
  request?: any
  response?: AxiosResponse

  constructor(
    message: string,
    config: AxiosRequestConfig,
    code?: string | null,
    request?: any,
    response?: AxiosResponse
  ) {
    
    
    super(message)
    this.config = config
    this.code = code
    this.request = request
    this.response = response
    this.isAxiosError = true
      
    // 这行代码是官方文档的推荐,目的是为了解决 TypeScript 继承一些内置对象的时候的坑
    Object.setPrototypeOf(this, AxiosError.prototype)
  }
}

// 为了方便使用,对外暴露了一个 createError 的工厂方法
export function createError(
  message: string,
  config: AxiosRequestConfig,
  code?: string | null,
  request?: any,
  response?: AxiosResponse
): AxiosError {
    
    
  const error = new AxiosError(message, config, code, request, response)
  return error
}

接下来修改关于错误对象创建部分的逻辑

// xhr.ts

import {
    
     createError } from './helpers/error'

request.onerror = function handleError() {
    
    
  reject(createError(
    'Network Error',
    config,
    null, // code
    request
  ))
}

request.ontimeout = function handleTimeout() {
    
    
  reject(createError(
    `Timeout of ${
      
      config.timeout} ms exceeded`,
    config,
    'ECONNABORTED', // code:software caused connection abort 软件引起的连接中止
    request
  ))
}

function handleResponse(response: AxiosResponse) {
    
    
  if (response.status >= 200 && response.status < 300) {
    
    
    resolve(response)
  } else {
    
    
    reject(createError(
      `Request failed with status code ${
      
      response.status}`,
      config,
      null,
      request,
      response
    ))
  }
}

TypeScript 并不能把 e 参数推断为 AxiosError 类型,于是我们需要手动指明类型,为了让外部应用能引入 AxiosError 类型,我们也需要把它们导出。

我们创建 axios.ts 文件,把之前的 index.ts 的代码拷贝过去,然后修改 index.ts 的代码。

import axios from './axios'

export * from './types'

export default axios

这样我们在 demo 中就可以引入 AxiosError 类型了。

至此,关于 ts-axios 的异常处理逻辑就告一段落。下面我们会对 ts-axios 的接口做扩展,让它提供更多好用和方便的 API。

接口扩展

为了用户更加方便地使用 axios 发送请求,我们可以为所有支持请求方法扩展一些接口:

  • axios.request(config)
  • axios.get(url[, config])
  • axios.delete(url[, config])
  • axios.head(url[, config])
  • axios.options(url[, config])
  • axios.post(url[, data[, config]])
  • axios.put(url[, data[, config]])
  • axios.patch(url[, data[, config]])

如果使用了这些方法,我们就不必在 config 中指定 urlmethoddata 这些属性了。

从需求上来看,axios 不再单单是一个方法,更像是一个混合对象,本身是一个方法,又有很多方法属性,接下来我们就来实现这个混合对象。

根据需求分析,混合对象 axios 本身是一个函数,我们再实现一个包括它属性方法的类,然后把这个类的原型属性和自身属性再拷贝到 axios 上。先来给 axios 混合对象定义接口:

// types/index.ts

export interface Axios {
    
    
  request(config: AxiosRequestConfig): AxiosPromise;

  get(url: string, config?: AxiosRequestConfig): AxiosPromise;

  delete(url: string, config?: AxiosRequestConfig): AxiosPromise;

  head(url: string, config?: AxiosRequestConfig): AxiosPromise;

  options(url: string, config?: AxiosRequestConfig): AxiosPromise;

  post(url: string, data?: any, config?: AxiosRequestConfig): AxiosPromise;

  put(url: string, data?: any, config?: AxiosRequestConfig): AxiosPromise;

  patch(url: string, data?: any, config?: AxiosRequestConfig): AxiosPromise;
}

export interface AxiosInstance extends Axios {
    
    
  (config: AxiosRequestConfig): AxiosPromise;
}

export interface AxiosRequestConfig {
    
    
  url?: string;
  // ...
}

首先定义一个 Axios 类型接口,它描述了 Axios 类中的公共方法,接着定义了 AxiosInstance 接口继承 Axios,它就是一个混合类型的接口。

另外 AxiosRequestConfig 类型接口中的 url 属性变成了可选属性。

然后创建一个 Axios 类,来实现接口定义的公共方法。

我们创建一个 core 目录,用来存放发送请求核心流程的代码,在 core 目录下创建 Axios.ts 文件。

import {
    
     AxiosRequestConfig, AxiosPromise, Method } from "../types";
import dispatchRequest from "./dispatchRequest";

export default class Axios {
    
    
  request(config: AxiosRequestConfig): AxiosPromise {
    
    
    return dispatchRequest(config);
  }

  get(url: string, config?: AxiosRequestConfig): AxiosPromise {
    
    
    return this._requestMethodWithoutData("get", url, config);
  }

  delete(url: string, config?: AxiosRequestConfig): AxiosPromise {
    
    
    return this._requestMethodWithoutData("delete", url, config);
  }

  head(url: string, config?: AxiosRequestConfig): AxiosPromise {
    
    
    return this._requestMethodWithoutData("head", url, config);
  }

  options(url: string, config?: AxiosRequestConfig): AxiosPromise {
    
    
    return this._requestMethodWithoutData("options", url, config);
  }

  post(url: string, data?: any, config?: AxiosRequestConfig): AxiosPromise {
    
    
    return this._requestMethodWithData("post", url, data, config);
  }

  put(url: string, data?: any, config?: AxiosRequestConfig): AxiosPromise {
    
    
    return this._requestMethodWithData("put", url, data, config);
  }

  patch(url: string, data?: any, config?: AxiosRequestConfig): AxiosPromise {
    
    
    return this._requestMethodWithData("patch", url, data, config);
  }

  _requestMethodWithoutData(
    method: Method,
    url: string,
    config?: AxiosRequestConfig
  ) {
    
    
    return this.request(
      // Object.assign () 是对象的静态方法,可以用来复制对象的可枚举属性到目标对象,利用这个特性可以实现对象属性的合并。 Object.assign(target, sources)。如果只是想将两个或多个对象的属性合并到一起,不改变原有对象的属性,可以用一个空的对象作为 target 对象:Object.assign({},target,source)
      Object.assign(config || {
    
    }, {
    
    
        method,
        url,
      })
    );
  }

  _requestMethodWithData(
    method: Method,
    url: string,
    data?: any,
    config?: AxiosRequestConfig
  ) {
    
    
    return this.request(
      Object.assign(config || {
    
    }, {
    
    
        method,
        url,
        data,
      })
    );
  }
}

其中 request 方法的功能和我们之前的 axios 函数功能是一致。axios 函数的功能就是发送请求,基于模块化编程的思想,我们把这部分功能抽出一个单独的模块,在 core 目录下创建 dispatchRequest 方法,把之前 axios.ts 的相关代码拷贝过去。另外我们把 xhr.ts 文件也迁移到 core 目录下。

// core/dispatchRequest.ts

import {
    
     AxiosPromise, AxiosRequestConfig, AxiosResponse } from "../types";
import xhr from "./xhr";
import {
    
     buildURL } from "../helpers/url";
import {
    
     transformRequest, transformResponse } from "../helpers/data";
import {
    
     processHeaders } from "../helpers/headers";

export default function dispatchRequest(
  config: AxiosRequestConfig
): AxiosPromise {
    
    
  processConfig(config);
  return xhr(config).then(
    res => {
    
    
    return transformResponseData(res)
  },
  // 当我们发送请求失败后,也能把响应数据转换成 JSON 格式
  e => {
    
    
    if (e && e.response) {
    
    
      e.response = transformResponseData(e.response)
    }
    return Promise.reject(e)
  }
  )
}

function processConfig(config: AxiosRequestConfig): void {
    
    
  config.url = transformURL(config);
  config.headers = transformHeaders(config);
  config.data = transformRequestData(config);
}

function transformURL(config: AxiosRequestConfig): string {
    
    
  const {
    
     url, params } = config;
  return buildURL(url, params);
}

function transformRequestData(config: AxiosRequestConfig): any {
    
    
  return transformRequest(config.data);
}

function transformHeaders(config: AxiosRequestConfig) {
    
    
  const {
    
     headers = {
    
    }, data } = config;
  return processHeaders(headers, data);
}

function transformResponseData(res: AxiosResponse): AxiosResponse {
    
    
  res.data = transformResponse(res.data);
  return res;
}

回到 Axios.ts 文件,对于 getdeleteheadoptionspostpatchput 这些方法,都是对外提供的语法糖,内部都是通过调用 request 方法实现发送请求,只不过在调用之前对 config 做了一层合并处理。

接下来实现混合对象。首先这个对象是一个函数,其次这个对象要包括 Axios 类的所有原型属性和实例属性,我们首先来实现一个辅助函数 extend

// helpers/util.ts

export function extend<T, U>(to: T, from: U): T & U {
  for (const key in from) {
    (to as T & U)[key] = from[key] as any;
  }
  return to as T & U;
}

extend 方法的实现用到了交叉类型,并且用到了类型断言。

extend 的最终目的是把 from 里的属性都扩展到 to 中,包括原型上的属性。

接下来对 axios.ts 文件做修改,我们用工厂模式去创建一个 axios 混合对象。

import { AxiosInstance } from "./types";
import Axios from "./core/Axios";
import { extend } from "./helpers/util";

function createInstance(): AxiosInstance {
  const context = new Axios(); // 实例化了 Axios 实例 context
  const instance = Axios.prototype.request.bind(context); // 创建 instance 指向 Axios.prototype.request 方法,因为用了 this,所以绑定了上下文 context

  extend(instance, context); // 通过 extend 方法把 context 中的原型方法和实例方法全部拷贝到 instance 上
  // 这样就实现了一个混合对象: instance 本身是一个函数,又拥有了 Axios 类的所有原型和实例属性,最终把这个 instance 返回

  return instance as AxiosInstance; // 不能正确推断 instance 的类型,把它断言成 AxiosInstance 类型
}

const axios = createInstance();

export default axios;

这样我们就可以通过 createInstance 工厂函数创建了 axios,当直接调用 axios 方法就相当于执行了 Axios 类的 request 方法发送请求,当然我们也可以调用 axios.getaxios.post 等方法。

axios 函数重载

需求分析:目前我们的 axios 函数只支持传入 1 个参数,如下:

axios({
    
    
  url: "/extend/post",
  method: "post",
  data: {
    
    
    msg: "hi",
  },
});

我们希望该函数也能支持传入 2 个参数,如下:

axios("/extend/post", {
    
    
  method: "post",
  data: {
    
    
    msg: "hello",
  },
});

第一个参数是 url,第二个参数是 config,这个函数有点类似 axios.get 方法支持的参数类型,不同的是如果我们想要指定 HTTP 方法类型,仍然需要在 config 传入 method

首先我们要修改 AxiosInstance 的类型定义。

types/index.ts

export interface AxiosInstance extends Axios {
    
    
  (config: AxiosRequestConfig): AxiosPromise;

  (url: string, config?: AxiosRequestConfig): AxiosPromise;
}

我们增加一种函数的定义,它支持 2 个参数,其中 url 是必选参数,config 是可选参数。

由于 axios 函数实际上指向的是 request 函数,所以我们来修改 request 函数的实现。

core/Axios.ts

  // 函数重载
  request(url: any, config?: any): AxiosPromise {
    
    
    if (typeof url === 'string') {
    
    
      if (!config) {
    
    
        config = {
    
    }  // 可能不传,如果为空则构造一个空对象
      }
      config.url = url
    } else {
    
    
      config = url // 如果 url 不是字符串类型,则说明传入的就是单个参数,且 url 就是 config
    }
    return dispatchRequest(config)
  }

我们把 request 函数的参数改成 2 个,urlconfig 都是 any 类型,config 还是可选参数。

接着在函数体我们判断 url 是否为字符串类型,一旦它为字符串类型,则继续对 config 判断,因为它可能不传,如果为空则构造一个空对象,然后把 url 添加到 config.url 中。如果 url 不是字符串类型,则说明我们传入的就是单个参数,且 url 就是 config,因此把 url 赋值给 config

这里要注意的是,我们虽然修改了 request 的实现,支持了 2 种参数,但是我们对外提供的 request 接口仍然不变,可以理解为这仅仅是内部的实现的修改,与对外接口不必一致,只要保留实现兼容接口即可

响应数据支持泛型

需求分析:通常情况下,我们会把后端返回数据格式单独放入一个接口中:

// 请求接口数据
export interface ResponseData<T = any> {
    
    
  /**
   * 状态码
   * @type { number }
   */
  code: number

  /**
   * 数据
   * @type { T }
   */
  result: T

  /**
   * 消息
   * @type { string }
   */
  message: string
}

我们可以把 API 抽离成单独的模块:

import {
    
     ResponseData } from './interface.ts';

export function getUser<T>() {
    
    
  return axios.get<ResponseData<T>>('/somepath')
    .then(res => res.data)
    .catch(err => console.error(err))
}

接着我们写入返回的数据类型 User,这可以让 TypeScript 顺利推断出我们想要的类型:

interface User {
    
    
  name: string
  age: number
}

async function test() {
    
    
  // user 被推断出为
  // {
    
    
  //  code: number,
  //  result: { name: string, age: number },
  //  message: string
  // }
  const user = await getUser<User>()
}

根据需求分析,我们需要给相关的接口定义添加泛型参数。

types/index.ts

export interface AxiosResponse<T = any> {
    
    
  data: T
  status: number
  statusText: string
  headers: any
  config: AxiosRequestConfig
  request: any
}

export interface AxiosPromise<T = any> extends Promise<AxiosResponse<T>> {
    
    
}

export interface Axios {
    
    
  request<T = any>(config: AxiosRequestConfig): AxiosPromise<T>

  get<T = any>(url: string, config?: AxiosRequestConfig): AxiosPromise<T>

  delete<T = any>(url: string, config?: AxiosRequestConfig): AxiosPromise<T>

  head<T = any>(url: string, config?: AxiosRequestConfig): AxiosPromise<T>

  options<T = any>(url: string, config?: AxiosRequestConfig): AxiosPromise<T>

  post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): AxiosPromise<T>

  put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): AxiosPromise<T>

  patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): AxiosPromise<T>
}

export interface AxiosInstance extends Axios {
    
    
  <T = any>(config: AxiosRequestConfig): AxiosPromise<T>

  <T = any>(url: string, config?: AxiosRequestConfig): AxiosPromise<T>
}

这里我们先给 AxiosResponse 接口添加了泛型参数 TT=any 表示泛型的类型参数默认值为 any

接着我们为 AxiosPromiseAxios 以及 AxiosInstance 接口都加上了泛型参数。我们可以看到这些请求的返回类型都变成了 AxiosPromise<T>,也就是 Promise<AxiosResponse<T>>,这样我们就可以从响应中拿到了类型 T 了。

拦截器设计与实现

需求分析:我们希望能对请求的发送和响应做拦截,也就是在发送请求之前和接收到响应之后做一些额外逻辑。

我们希望设计的拦截器的使用方式如下:

// 添加一个请求拦截器
axios.interceptors.request.use(function (config) {
    
    
  // 在发送请求之前可以做一些事情
  return config;
}, function (error) {
    
    
  // 处理请求错误
  return Promise.reject(error);
});
// 添加一个响应拦截器
axios.interceptors.response.use(function (response) {
    
    
  // 处理响应数据
  return response;
}, function (error) {
    
    
  // 处理响应错误
  return Promise.reject(error);
});

axios 对象上有一个 interceptors 对象属性,该属性又有 requestresponse 2 个属性,它们都有一个 use 方法,use 方法支持 2 个参数,第一个参数类似 Promise 的 resolve 函数,第二个参数类似 Promise 的 reject 函数。我们可以在 resolve 函数和 reject 函数中执行同步代码或者是异步代码逻辑。

并且我们是可以添加多个拦截器的,拦截器的执行顺序是链式依次执行的方式。对于 request 拦截器,后添加的拦截器会在请求前的过程中先执行;对于 response 拦截器,先添加的拦截器会在响应后先执行。

axios.interceptors.request.use(config => {
    
    
  config.headers.test += '1'
  return config
})
axios.interceptors.request.use(config => {
    
    
  config.headers.test += '2'
  return config
})

此外,我们也可以支持删除某个拦截器,如下:

const myInterceptor = axios.interceptors.request.use(function () {
    
    /*...*/})
axios.interceptors.request.eject(myInterceptor)

先了解一下拦截器工作流程,整个过程是一个链式调用的方式,每个拦截器都可以支持同步和异步处理。

因此我们自然而然地就联想到使用 Promise 链的方式来实现整个调用过程。

在这个 Promise 链的执行过程中,请求拦截器 resolve 函数处理的是 config 对象,而相应拦截器 resolve 函数处理的是 response 对象。

在了解了拦截器工作流程后,我们先要创建一个拦截器管理类,允许我们去添加 删除和遍历拦截器。

根据需求,axios 拥有一个 interceptors 对象属性,该属性又有 requestresponse 2 个属性,它们对外提供一个 use 方法来添加拦截器,我们可以把这俩属性看做是一个拦截器管理对象。

use 方法支持 2 个参数,第一个是 resolve 函数,第二个是 reject 函数,对于 resolve 函数的参数,请求拦截器是 AxiosRequestConfig 类型的,而响应拦截器是 AxiosResponse 类型的;而对于 reject 函数的参数类型则是 any 类型的。

根据上述分析,我们先来定义一下拦截器管理对象对外的接口。

// types/index.ts

export interface AxiosInterceptorManager<T> {
  use(resolved: ResolvedFn<T>, rejected?: RejectedFn): number

  eject(id: number): void
}

export interface ResolvedFn<T=any> {
  (val: T): T | Promise<T>
}

export interface RejectedFn {
  (error: any): any
}

这里我们定义了 AxiosInterceptorManager 泛型接口,因为对于 resolve 函数的参数,请求拦截器和响应拦截器是不同的。

import {
    
     ResolvedFn, RejectedFn } from '../types'

interface Interceptor<T> {
    
    
  resolved: ResolvedFn<T>
  rejected?: RejectedFn
}

export default class InterceptorManager<T> {
    
    
  // 内部维护一个私有属性 interceptors,它是一个数组,用来存储拦截器
  private interceptors: Array<Interceptor<T> | null>

  constructor() {
    
    
    this.interceptors = []
  }

  // use 接口就是添加拦截器到 interceptors 中,并返回一个 id 用于删除
  use(resolved: ResolvedFn<T>, rejected?: RejectedFn): number {
    
    
    this.interceptors.push({
    
    
      resolved,
      rejected
    })
    return this.interceptors.length - 1
  }

  // forEach 接口就是遍历 interceptors 用的,它支持传入一个函数,遍历过程中会调用该函数,并把每一个 interceptor 作为该函数的参数传入
  forEach(fn: (interceptor: Interceptor<T>) => void): void {
    
    
    this.interceptors.forEach(interceptor => {
    
    
      if (interceptor !== null) {
    
    
        fn(interceptor)
      }
    })
  }

  // eject 就是删除一个拦截器,通过传入拦截器的 id 删除
  eject(id: number): void {
    
    
    if (this.interceptors[id]) {
    
    
      this.interceptors[id] = null // 不能用数组删除,长度会乱
    }
  }
}

我们定义了一个 InterceptorManager 泛型类,内部维护了一个私有属性 interceptors,它是一个数组,用来存储拦截器。该类还对外提供了 3 个方法,其中 use 接口就是添加拦截器到 interceptors 中,并返回一个 id 用于删除;forEach 接口就是遍历 interceptors 用的,它支持传入一个函数,遍历过程中会调用该函数,并把每一个 interceptor 作为该函数的参数传入;eject 就是删除一个拦截器,通过传入拦截器的 id 删除。

当我们实现好拦截器管理类,接下来就是在 Axios 中定义一个 interceptors 属性,它的类型如下:

interface Interceptors {
    
    
  request: InterceptorManager<AxiosRequestConfig>
  response: InterceptorManager<AxiosResponse>
}

export default class Axios {
    
    
  interceptors: Interceptors

  constructor() {
    
    
    this.interceptors = {
    
    
      request: new InterceptorManager<AxiosRequestConfig>(),
      response: new InterceptorManager<AxiosResponse>()
    }
  }
}

Interceptors 类型拥有 2 个属性,一个请求拦截器管理类实例,一个是响应拦截器管理类实例。我们在实例化 Axios 类的时候,在它的构造器去初始化这个 interceptors 实例属性。

接下来,我们修改 request 方法的逻辑,添加拦截器链式调用的逻辑:

core/Axios.ts

interface PromiseChain {
    
    
  resolved: ResolvedFn | ((config: AxiosRequestConfig) => AxiosPromise)
  rejected?: RejectedFn
}

request(url: any, config?: any): AxiosPromise {
    
    
  if (typeof url === 'string') {
    
    
    if (!config) {
    
    
      config = {
    
    }
    }
    config.url = url
  } else {
    
    
    config = url
  }

  // 构造 PromiseChain 类型的数组 chain,并把 dispatchRequest 函数赋值给 resolved 属性
  const chain: PromiseChain[] = [{
    
    
    resolved: dispatchRequest,
    rejected: undefined
  }]

  // 注意拦截器的执行顺序,对于请求拦截器,先执行后添加的,再执行先添加的;
  // 而对于响应拦截器,先执行先添加的,后执行后添加的。

  // 先遍历请求拦截器插入到 chain 的前面
  this.interceptors.request.forEach(interceptor => {
    
    
    chain.unshift(interceptor)
  })
  
  // 再遍历响应拦截器插入到 chain 后面
  this.interceptors.response.forEach(interceptor => {
    
    
    chain.push(interceptor)
  })
  
  // 定义一个已经 resolve 的 promise
  let promise = Promise.resolve(config)

  // 循环这个 chain,拿到每个拦截器对象,把它们的 resolved 函数和 rejected 函数添加到 promise.then 的参数中,这样就相当于通过 Promise 的链式调用方式,实现了拦截器一层层的链式调用的效果
  while (chain.length) {
    
    
    const {
    
     resolved, rejected } = chain.shift()!
    promise = promise.then(resolved, rejected)
  }

  return promise
}

首先,构造一个 PromiseChain 类型的数组 chain,并把 dispatchRequest 函数赋值给 resolved 属性;接着先遍历请求拦截器插入到 chain 的前面;然后再遍历响应拦截器插入到 chain 后面。

接下来定义一个已经 resolve 的 promise,循环这个 chain,拿到每个拦截器对象,把它们的 resolved 函数和 rejected 函数添加到 promise.then 的参数中,这样就相当于通过 Promise 的链式调用方式,实现了拦截器一层层的链式调用的效果。

注意我们拦截器的执行顺序,对于请求拦截器,先执行后添加的,再执行先添加的;而对于响应拦截器,先执行先添加的,后执行后添加的。

至此,我们给 ts-axios 实现了拦截器功能,它是一个非常实用的功能,在实际工作中我们可以利用它做一些需求如登录权限认证。

我们目前通过 axios 发送请求,往往会传入一堆配置,但是我们也希望 ts-axios 本身也会有一些默认配置,我们可以把用户传入的自定义配置和默认配置做一层合并。

配置化实现

合并配置的设计与实现

在之前了解到,在发送请求的时候可以传入一个配置,来决定请求的不同行为。我们也希望 ts-axios 可以有默认配置,定义一些默认的行为。这样在发送每个请求,用户传递的配置可以和默认配置做一层合并。

和官网 axios 库保持一致,给 axios 对象添加一个 defaults 属性,表示默认配置,可以直接修改默认配置:

axios.defaults.headers.common['test'] = 123
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded'
axios.defaults.timeout = 2000

其中对于 headers 的默认配置支持 common 和一些请求 method 字段,common 表示对于任何类型的请求都要添加该属性,而 method 表示只有该类型请求方法才会添加对应的属性。

在上述例子中,我们会默认为所有请求的 header 添加 test 属性,会默认为 post 请求的 header 添加 Content-Type 属性。

接下来,先实现默认配置定义

// defaults.ts

import { AxiosRequestConfig } from './types'

// 定义 defaults 常量,包含默认请求的方法、超时时间,以及 headers 配置
const defaults: AxiosRequestConfig = {
  method: 'get',
  timeout: 0,
  headers: {
    common: {
      Accept: 'application/json, text/plain, */*'
    }
  }
}

const methodsNoData = ['delete', 'get', 'head', 'options']

methodsNoData.forEach(method => {
  defaults.headers[method] = {}
})

const methodsWithData = ['post', 'put', 'patch']

methodsWithData.forEach(method => {
  defaults.headers[method] = {
    'Content-Type': 'application/x-www-form-urlencoded'
  }
})

export default defaults

然后要给 axios 对象添加一个 defaults 属性,表示默认配置

export default class Axios {
    
    
  defaults: AxiosRequestConfig
  interceptors: Interceptors

  constructor(initConfig: AxiosRequestConfig) {
    
    
    this.defaults = initConfig
    this.interceptors = {
    
    
      request: new InterceptorManager<AxiosRequestConfig>(),
      response: new InterceptorManager<AxiosResponse>()
    }
  }
  // ...
}  

我们给 Axios 类添加一个 defaults 成员属性,并且让 Axios 的构造函数接受一个 initConfig 对象,把 initConfig 赋值给 this.defaults

接着修改 createInstance 方法,支持传入 config 对象。

import defaults from './defaults'

function createInstance(config: AxiosRequestConfig): AxiosStatic {
    
    
  const context = new Axios(config)
  const instance = Axios.prototype.request.bind(context)

  // extend(instance, Axios.prototype, context)

  extend(instance, context)

  return instance as AxiosStatic
}

const axios = createInstance(defaults)

这样我们就可以在执行 createInstance 创建 axios 对象的时候,把默认配置传入了。

定义了默认配置后,我们发送每个请求的时候需要把自定义配置和默认配置做合并,它并不是简单的 2 个普通对象的合并,对于不同的字段合并,会有不同的合并策略。

我们在 core/mergeConfig.ts 中实现合并方法,在 mergeField 方法中,我们会针对不同的属性使用不同的合并策略。

// 合并方法的整体思路就是对 config1 和 config2 中的属性遍历,执行 mergeField 方法做合并,这里 config1 代表默认配置,config2 代表自定义配置
// 策 略 模 式

import {
    
     AxiosRequestConfig } from "../types";
import {
    
     isPlainObject,deepMerge } from "../helpers/util";

const strats = Object.create(null)

// 这是大部分属性的合并策略,它很简单,如果有 val2 则返回 val2,否则返回 val1,也就是如果自定义配置中定义了某个属性,就采用自定义的,否则就用默认配置。
function defaultStrat(val1: any,val2: any): any{
    
    
  return typeof val2 !== 'undefined' ? val2 : val1
}

// 这是对于一些属性如 url、params、data的合并策略
function formVal2Strat(val1: any,val2: any): any{
    
    
  if(typeof val2 !== 'undefined'){
    
    
    return val2
  }
}

// 因为对于 url、params、data 这些属性,默认配置显然是没有意义的,它们是和每个请求强相关的,所以我们只从自定义配置中获取
const stratKeysFromVal2 = ['url','params','data']

stratKeysFromVal2.forEach(key => {
    
    
  strats[key] = formVal2Strat
})

// 对于 headers 这类的复杂对象属性,合并策略选用深拷贝,同时也处理了其它一些情况,因为它们也可能是一个非对象的普通值。
function deepMergeStrat(val1: any, val2: any): any {
    
    
  if (isPlainObject(val2)) {
    
    
    return deepMerge(val1, val2)
  } else if (typeof val2 !== 'undefined') {
    
    
    return val2
  } else if (isPlainObject(val1)) {
    
    
    return deepMerge(val1)
  } else {
    
    
    return val1
  }
}

// const stratKeysDeepMerge = ['headers']
// 修改合并规则,因为 auth 也是一个对象格式,所以它的合并规则是 deepMergeStrat
const stratKeysDeepMerge = ['headers', 'auth']

stratKeysDeepMerge.forEach(key => {
    
    
  strats[key] = deepMergeStrat
})

export default function mergeConfig(
  config1: AxiosRequestConfig,
  config2?: AxiosRequestConfig
): AxiosRequestConfig {
    
    
  if (!config2) {
    
    
    config2 = {
    
    }
  }

  const config = Object.create(null)

  for (let key in config2) {
    
    
    mergeField(key)
  }

  for (let key in config1) {
    
    
    if (!config2[key]) {
    
     // config2中没有
      mergeField(key)
    }
  }

  function mergeField(key: string): void {
    
    
    const strat = strats[key] || defaultStrat
    config[key] = strat(config1[key], config2![key])
  }

  return config
}

遍历过程中,我们会通过 config2[key] 这种索引的方式访问,所以需要给 AxiosRequestConfig 的接口定义添加一个字符串索引签名:

export interface AxiosRequestConfig {
    
    
  // ...

  [propName: string]: any
}

helpers/util.ts

// 合并配置深拷贝
export function deepMerge(...objs: any[]): any {
    
    
  const result = Object.create(null)
  objs.forEach(obj => {
    
    
    if (obj) {
    
    
      Object.keys(obj).forEach(key => {
    
    
        const val = obj[key]
        if (isPlainObject(val)) {
    
    
          if (isPlainObject(result[key])) {
    
    
            result[key] = deepMerge(result[key], val)
          } else {
    
    
            result[key] = deepMerge({
    
    }, val)
          }
        } else {
    
    
          result[key] = val
        }
      })
    }
  })
  return result
}

最后我们在 request 方法里添加合并配置的逻辑:

config = mergeConfig(this.defaults, config)

flatten headers

经过合并后的配置中的 headers 是一个复杂对象,多了 commonpostget 等属性,而这些属性中的值才是我们要真正添加到请求 header 中的。

举个例子:

headers: {
    
    
  common: {
    
    
    Accept: 'application/json, text/plain, */*'
  },
  post: {
    
    
    'Content-Type':'application/x-www-form-urlencoded'
  }
}

我们需要把它压成一级的,如下:

headers: {
    
    
  Accept: 'application/json, text/plain, */*',
 'Content-Type':'application/x-www-form-urlencoded'
}

这里要注意的是,对于 common 中定义的 header 字段,我们都要提取,而对于 postget 这类提取,需要和该次请求的方法对应。

接下来实现 flattenHeaders 方法。

helpers/header.ts

export function flattenHeaders(headers: any, method: Method): any {
    
    
  if (!headers) {
    
    
    return headers
  }
  headers = deepMerge(headers.common || {
    
    }, headers[method] || {
    
    }, headers)

  const methodsToDelete = ['delete', 'get', 'head', 'options', 'post', 'put', 'patch', 'common']

  methodsToDelete.forEach(method => {
    
    
    delete headers[method]
  })

  return headers
}

我们可以通过 deepMerge 的方式把 commonpost 的属性拷贝到 headers 这一级,然后再把 commonpost 这些属性删掉。

然后我们在真正发送请求前执行这个逻辑。

core/dispatchRequest.ts

function processConfig(config: AxiosRequestConfig): void {
    
    
  config.url = transformURL(config)
  config.headers = transformHeaders(config)
  config.data = transformRequestData(config)
  config.headers = flattenHeaders(config.headers, config.method!)
}

这样确保了我们配置中的 headers 是可以正确添加到请求 header 中的。

请求和响应配置化

官方的 axios 库 给默认配置添加了 transformRequesttransformResponse 两个字段,它们的值是一个数组或者是一个函数。

其中 transformRequest 允许你在将请求数据发送到服务器之前对其进行修改,这只适用于请求方法 putpostpatch,如果值是数组,则数组中的最后一个函数必须返回一个字符串或 FormDataURLSearchParamsBlob 等类型作为 xhr.send 方法的参数,而且在 transform 过程中可以修改 headers 对象。

transformResponse 允许你在把响应数据传递给 then 或者 catch 之前对它们进行修改。

当值为数组的时候,数组的每一个函数都是一个转换函数,数组中的函数就像管道一样依次执行,前者的输出作为后者的输入。

举个例子:

axios({
    
    
  transformRequest: [
    function(data) {
    
    
      return qs.stringify(data);
    },
    ...axios.defaults.transformRequest,
  ],
  transformResponse: [
    axios.defaults.transformResponse,
    function(data) {
    
    
      if (typeof data === "object") {
    
    
        data.b = 2;
      }
      return data;
    },
  ],
  url: "/config/post",
  method: "post",
  data: {
    
    
    a: 1,
  },
});

先修改 AxiosRequestConfig 的类型定义,添加 transformRequesttransformResponse 俩个可选属性。

types/index.ts

export interface AxiosRequestConfig {
    
    
  // ...
  transformRequest?: AxiosTransformer | AxiosTransformer[];
  transformResponse?: AxiosTransformer | AxiosTransformer[];
}

export interface AxiosTransformer {
    
    
  (data: any, headers?: any): any;
}

接着修改默认配置,如下:

defaults.ts

import {
    
     processHeaders } from "./helpers/headers";
import {
    
     transformRequest, transformResponse } from "./helpers/data";

const defaults: AxiosRequestConfig = {
    
    
  // ...
  transformRequest: [
    function(data: any, headers: any): any {
    
    
      processHeaders(headers, data);
      return transformRequest(data);
    },
  ],

  transformResponse: [
    function(data: any): any {
    
    
      return transformResponse(data);
    },
  ],
};

我们把之前对请求数据和响应数据的处理逻辑,放到了默认配置中,也就是默认处理逻辑。

接下来,我们就要重构之前写的对请求数据和响应数据的处理逻辑了。由于我们可能会编写多个转换函数,我们先定义一个 transform 函数来处理这些转换函数的调用逻辑。

import {
    
     AxiosTransformer } from "../types";

export default function transform(
  data: any,
  headers: any,
  fns?: AxiosTransformer | AxiosTransformer[]
): any {
    
    
  if (!fns) {
    
    
    return data;
  }
  if (!Array.isArray(fns)) {
    
    
    fns = [fns];
  }
  fns.forEach((fn) => {
    
    
    data = fn(data, headers);
  });
  return data;
}

transform 函数中接收 dataheadersfns 3 个参数,其中 fns 代表一个或者多个转换函数,内部逻辑很简单,遍历 fns,执行这些转换函数,并且把 dataheaders 作为参数传入,每个转换函数返回的 data 会作为下一个转换函数的参数 data 传入。

接下来修改对请求数据和响应数据的处理逻辑。

dispatchRequest.ts

import transform from "./transform";

function processConfig(config: AxiosRequestConfig): void {
    
    
  config.url = transformURL(config);
  config.data = transform(config.data, config.headers, config.transformRequest);
  config.headers = flattenHeaders(config.headers, config.method!);
}

function transformResponseData(res: AxiosResponse): AxiosResponse {
    
    
  res.data = transform(res.data, res.headers, res.config.transformResponse);
  return res;
}

我们把对请求数据的处理和对响应数据的处理改成使用 transform 函数实现,并把配置中的 transformRequesttransformResponse 分别传入。

至此,我们就实现了请求和响应的配置化。

到目前为止 axios 都是一个单例,一旦我们修改了 axios 的默认配置,会影响所有的请求。官网提供了一个 axios.create 的工厂方法允许我们创建一个新的 axios 实例,同时允许我们传入新的配置和默认配置合并,并做为新的默认配置。

扩展 axios.create 静态接口

由于 axios 扩展了一个静态接口,因此我们先来修改接口类型定义。

types/index.ts

export interface AxiosStatic extends AxiosInstance {
    
    
  create(config?: AxiosRequestConfig): AxiosInstance;
}

create 函数可以接受一个 AxiosRequestConfig 类型的配置,作为默认配置的扩展,也可以接受不传参数。

接着我们来实现 axios.create 静态方法。

axios.ts

function createInstance(config: AxiosRequestConfig): AxiosStatic {
    
    
  const context = new Axios(config);
  const instance = Axios.prototype.request.bind(context);

  extend(instance, context);

  return instance as AxiosStatic;
}
axios.create = function create(config) {
    
    
  return createInstance(mergeConfig(defaults, config));
};

内部调用了 createInstance 函数,并且把参数 configdefaults 合并,作为新的默认配置。注意这里我们需要 createInstance 函数的返回值类型为 AxiosStatic

至此我们实现了 axios.create 静态接口的扩展,整个 ts-axios 的配置化也告一段落。

官方 axios 库还支持了对请求取消的能力,在发送请求前以及请求发送出去未响应前都可以取消该请求。

取消功能的设计与实现

有些场景下,我们希望能主动取消请求,比如常见的搜索框案例,在用户输入过程中,搜索框的内容也在不断变化,正常情况每次变化我们都应该向服务端发送一次请求。但是当用户输入过快的时候,我们不希望每次变化请求都发出去,通常一个解决方案是前端用 debounce 的方案,比如延时 200ms 发送请求。这样当用户连续输入的字符,只要输入间隔小于 200ms,前面输入的字符都不会发请求。

但是还有一种极端情况是后端接口很慢,比如超过 1s 才能响应,这个时候即使做了 200ms 的 debounce,但是在我慢慢输入(每个输入间隔超过 200ms)的情况下,在前面的请求没有响应前,也有可能发出去多个请求。因为接口的响应时长是不定的,如果先发出去的请求响应时长比后发出去的请求要久一些,后请求的响应先回来,先请求的响应后回来,就会出现前面请求响应结果覆盖后面请求响应结果的情况,那么就乱了。因此在这个场景下,我们除了做 debounce,还希望后面的请求发出去的时候,如果前面的请求还没有响应,我们可以把前面的请求取消。

从 axios 的取消接口设计层面,我们希望做如下的设计:

const CancelToken = axios.CancelToken;
const source = CancelToken.source();

axios.get('/user/12345', {
    
    
  cancelToken: source.token
}).catch(function (e) {
    
    
  if (axios.isCancel(e)) {
    
    
    console.log('Request canceled', e.message);
  } else {
    
    
    // 处理错误
  }
});

// 取消请求 (请求原因是可选的)
source.cancel('Operation canceled by the user.');

我们给 axios 添加一个 CancelToken 的对象,它有一个 source 方法可以返回一个 source 对象,source.token 是在每次请求的时候传给配置对象中的 cancelToken 属性,然后在请求发出去之后,我们可以通过 source.cancel 方法取消请求。

我们还支持另一种方式的调用:

const CancelToken = axios.CancelToken;
let cancel;

axios.get('/user/12345', {
    
    
  cancelToken: new CancelToken(function executor(c) {
    
    
    cancel = c;
  })
});

// 取消请求
cancel();

axios.CancelToken 是一个类,我们直接把它实例化的对象传给请求配置中的 cancelToken 属性,CancelToken 的构造函数参数支持传入一个 executor 方法,该方法的参数是一个取消函数 c,我们可以在 executor 方法执行的内部拿到这个取消函数 c,赋值给我们外部定义的 cancel 变量,之后我们可以通过调用这个 cancel 方法来取消请求。

通过需求分析,我们知道想要实现取消某次请求,我们需要为该请求配置一个 cancelToken,然后在外部调用一个 cancel 方法。

请求的发送是一个异步过程,最终会执行 xhr.send 方法,xhr 对象提供了 abort 方法,可以把请求取消。因为我们在外部是碰不到 xhr 对象的,所以我们想在执行 cancel 的时候,去执行 xhr.abort 方法。

现在就相当于我们在 xhr 异步请求过程中,插入一段代码,当我们在外部执行 cancel 函数的时候,会驱动这段代码的执行,然后执行 xhr.abort 方法取消请求。

我们可以利用 Promise 实现异步分离,也就是在 cancelToken 中保存一个 pending 状态的 Promise 对象,然后当我们执行 cancel 方法的时候,能够访问到这个 Promise 对象,把它从 pending 状态变成 resolved 状态,这样我们就可以在 then 函数中去实现取消请求的逻辑。类似如下的代码:

if (cancelToken) {
    
    
  cancelToken.promise
    .then(reason => {
    
    
      request.abort()
      reject(reason)
    })
}

接下来,我们就来实现这个 CancelToken 类,先来看一下接口定义:

export interface AxiosRequestConfig {
    
    
  // ...
  cancelToken?: CancelToken
}

export interface CancelToken {
    
    
  promise: Promise<string>
  reason?: string
}

export interface Canceler {
    
    
  (message?: string): void
}

export interface CancelExecutor {
    
    
  (cancel: Canceler): void
}

其中 CancelToken 是实例类型的接口定义,Canceler 是取消方法的接口定义,CancelExecutorCancelToken 类构造函数参数的接口定义。

我们单独创建 cancel 目录来管理取消相关的代码,在 cancel 目录下创建 CancelToken.ts 文件:

import {
    
     CancelExecutor } from '../types'

interface ResolvePromise {
    
    
  (reason?: string): void
}

export default class CancelToken {
    
    
  promise: Promise<string>
  reason?: string

  constructor(executor: CancelExecutor) {
    
    
    let resolvePromise: ResolvePromise
    this.promise = new Promise<string>(resolve => {
    
    
      resolvePromise = resolve
    })

    executor(message => {
    
    
      if (this.reason) {
    
    
        return
      }
      this.reason = message
      resolvePromise(this.reason)
    })
  }
}

CancelToken 构造函数内部,实例化一个 pending 状态的 Promise 对象,然后用一个 resolvePromise 变量指向 resolve 函数。接着执行 executor 函数,传入一个 cancel 函数,在 cancel 函数内部,会调用 resolvePromise 把 Promise 对象从 pending 状态变为 resolved 状态。

接着我们在 xhr.ts 中插入一段取消请求的逻辑。

core/xhr.ts

const {
    
     /*....*/ cancelToken } = config

if (cancelToken) {
    
    
  cancelToken.promise.then(reason => {
    
    
    request.abort()
    reject(reason)
  })
}

这样就满足了第二种使用方式,接着我们要实现第一种使用方式,给 CancelToken 扩展静态接口。

export interface CancelTokenSource {
    
    
  token: CancelToken
  cancel: Canceler
}

export interface CancelTokenStatic {
    
    
  new(executor: CancelExecutor): CancelToken

  source(): CancelTokenSource
}

其中 CancelTokenSource 作为 CancelToken 类静态方法 source 函数的返回值类型,CancelTokenStatic 则作为 CancelToken 类的类类型。

cancel/CancelToken.ts

export default class CancelToken {
    
    
  // ...

  static source(): CancelTokenSource {
    
    
    let cancel!: Canceler
    const token = new CancelToken(c => {
    
    
      cancel = c
    })
    return {
    
    
      cancel,
      token
    }
  }
}

source 的静态方法很简单,定义一个 cancel 变量实例化一个 CancelToken 类型的对象,然后在 executor 函数中,把 cancel 指向参数 c 这个取消函数。

这样就满足了我们第一种使用方式,但是在第一种使用方式的例子中,我们在捕获请求的时候,通过 axios.isCancel 来判断这个错误参数 e 是不是一次取消请求导致的错误,接下来我们对取消错误的原因做一层包装,并且把给 axios 扩展静态方法

export interface Cancel {
    
    
  message?: string
}

export interface CancelStatic {
    
    
  new(message?: string): Cancel
}

export interface AxiosStatic extends AxiosInstance {
    
    
  create(config?: AxiosRequestConfig): AxiosInstance

  CancelToken: CancelTokenStatic
  Cancel: CancelStatic
  isCancel: (value: any) => boolean
}

其中 Cancel 是实例类型的接口定义,CancelStatic 是类类型的接口定义,并且我们给 axios 扩展了多个静态方法。

cancel 目录下创建 Cancel.ts 文件。

export default class Cancel {
    
    
  message?: string

  constructor(message?: string) {
    
    
    this.message = message
  }
}

export function isCancel(value: any): boolean {
    
    
  return value instanceof Cancel
}

Cancel 类非常简单,拥有一个 message 的公共属性。isCancel 方法也非常简单,通过 instanceof 来判断传入的值是不是一个 Cancel 对象。

接着我们对 CancelToken 类中的 reason 类型做修改,把它变成一个 Cancel 类型的实例。

先修改定义部分。

types/index.ts

export interface CancelToken {
    
    
  promise: Promise<Cancel>
  reason?: Cancel
}

再修改实现部分:

import Cancel from './Cancel'

interface ResolvePromise {
    
    
  (reason?: Cancel): void
}

export default class CancelToken {
    
    
  promise: Promise<Cancel>
  reason?: Cancel

  constructor(executor: CancelExecutor) {
    
    
    let resolvePromise: ResolvePromise
    this.promise = new Promise<Cancel>(resolve => {
    
    
      resolvePromise = resolve
    })

    executor(message => {
    
    
      if (this.reason) {
    
    
        return
      }
      this.reason = new Cancel(message)
      resolvePromise(this.reason)
    })
  }
}

接下来我们给 axios 扩展一些静态方法,供用户使用。

axios.ts

import CancelToken from './cancel/CancelToken'
import Cancel, {
    
     isCancel } from './cancel/Cancel'

axios.CancelToken = CancelToken
axios.Cancel = Cancel
axios.isCancel = isCancel

除此之外,我们还需要实现一些额外逻辑,比如当一个请求携带的 cancelToken 已经被使用过,那么我们甚至都可以不发送这个请求,只需要抛一个异常即可,并且抛异常的信息就是我们取消的原因,所以我们需要给 CancelToken 扩展一个方法。

先修改定义部分。

types/index.ts

export interface CancelToken {
    
    
  promise: Promise<Cancel>
  reason?: Cancel

  throwIfRequested(): void
}

添加一个 throwIfRequested 方法,接下来实现它:

cancel/CancelToken.ts

export default class CancelToken {
    
    
  // ...

  throwIfRequested(): void {
    
    
    if (this.reason) {
    
    
      throw this.reason
    }
  }
}

判断如果存在 this.reason,说明这个 token 已经被使用过了,直接抛错。

接下来在发送请求前增加一段逻辑。

core/dispatchRequest.ts

export default function dispatchRequest(config: AxiosRequestConfig): AxiosPromise {
    
    
  throwIfCancellationRequested(config)
  processConfig(config)

  // ...
}

function throwIfCancellationRequested(config: AxiosRequestConfig): void {
    
    
  if (config.cancelToken) {
    
    
    config.cancelToken.throwIfRequested()
  }
}

发送请求前检查一下配置的 cancelToken 是否已经使用过了,如果已经被用过则不用法请求,直接抛异常。

withCredentials

需求分析:有些时候我们会发一些跨域请求,比如 http://domain-a.com 站点发送一个 http://api.domain-b.com/get 的请求,默认情况下,浏览器会根据同源策略限制这种跨域请求,但是可以通过 CORS 技术解决跨域问题。

在同域的情况下,我们发送请求会默认携带当前域下的 cookie,但是在跨域的情况下,默认是不会携带请求域下的 cookie 的,比如 http://domain-a.com 站点发送一个 http://api.domain-b.com/get 的请求,默认是不会携带 api.domain-b.com 域下的 cookie,如果我们想携带(很多情况下是需要的),只需要设置请求的 xhr 对象的 withCredentials 为 true 即可。

先修改 AxiosRequestConfig 的类型定义。

types/index.ts

export interface AxiosRequestConfig {
    
    
  // ...
  withCredentials?: boolean;
}

然后修改请求发送前的逻辑。

core/xhr.ts

const {
    
     /*...*/ withCredentials } = config;

if (withCredentials) {
    
    
  request.withCredentials = true;
}

XSRF 防御

XSRF 又名 CSRF,跨站请求伪造,它是前端常见的一种攻击方式。

CSRF 的防御手段有很多,比如验证请求的 referer,但是 referer 也是可以伪造的,所以杜绝此类攻击的一种方式是服务器端要求每次请求都包含一个 token,这个 token 不在前端生成,而是在我们每次访问站点的时候生成,并通过 set-cookie 的方式种到客户端,然后客户端发送请求的时候,从 cookie 中对应的字段读取出 token,然后添加到请求 headers 中。这样服务端就可以从请求 headers 中读取这个 token 并验证,由于这个 token 是很难伪造的,所以就能区分这个请求是否是用户正常发起的。

对于我们的 ts-axios 库,我们要自动把这几件事做了,每次发送请求的时候,从 cookie 中读取对应的 token 值,然后添加到请求 headers 中。我们允许用户配置 xsrfCookieNamexsrfHeaderName,其中 xsrfCookieName 表示存储 tokencookie 名称,xsrfHeaderName 表示请求 headerstoken 对应的 header 名称。

axios.get('/more/get',{
    
    
  xsrfCookieName: 'XSRF-TOKEN', // default
  xsrfHeaderName: 'X-XSRF-TOKEN' // default
}).then(res => {
    
    
  console.log(res)
})

我们提供 xsrfCookieNamexsrfHeaderName 的默认值,当然用户也可以根据自己的需求在请求中去配置 xsrfCookieNamexsrfHeaderName

先修改 AxiosRequestConfig 的类型定义。

types/index.ts

export interface AxiosRequestConfig {
    
    
  // ...
  xsrfCookieName?: string
  xsrfHeaderName?: string
}

然后修改默认配置。

defaults.ts

const defaults: AxiosRequestConfig = {
    
    
  // ...
  xsrfCookieName: 'XSRF-TOKEN',

  xsrfHeaderName: 'X-XSRF-TOKEN',
}

接下来我们要做三件事:

  • 首先判断如果是配置 withCredentialstrue 或者是同域请求,我们才会请求 headers 添加 xsrf 相关的字段。
  • 如果判断成功,尝试从 cookie 中读取 xsrftoken 值。
  • 如果能读到,则把它添加到请求 headersxsrf 相关字段中。

我们先来实现同域请求的判断。

helpers/url.ts

interface URLOrigin {
    
    
  protocol: string
  host: string
}


export function isURLSameOrigin(requestURL: string): boolean {
    
    
  const parsedOrigin = resolveURL(requestURL)
  return (
    parsedOrigin.protocol === currentOrigin.protocol && parsedOrigin.host === currentOrigin.host
  )
}

const urlParsingNode = document.createElement('a')
const currentOrigin = resolveURL(window.location.href)

function resolveURL(url: string): URLOrigin {
    
    
  urlParsingNode.setAttribute('href', url)
  const {
    
     protocol, host } = urlParsingNode

  return {
    
    
    protocol,
    host
  }
}

同域名的判断主要利用了一个技巧,创建一个 a 标签的 DOM,然后设置 href 属性为我们传入的 url,然后可以获取该 DOM 的 protocolhost。当前页面的 url 和请求的 url 都通过这种方式获取,然后对比它们的 protocolhost 是否相同即可。

接着实现 cookie 的读取。

helpers/cookie.ts

const cookie = {
    
    
  read(name: string): string | null {
    
    
    const match = document.cookie.match(new RegExp('(^|;\\s*)(' + name + ')=([^;]*)'))
    return match ? decodeURIComponent(match[3]) : null
  }
}

export default cookie

cookie 的读取逻辑很简单,利用了正则表达式可以解析到 name 对应的值。

最后实现完整的逻辑。

core/xhr.ts

const {
    
    
  /*...*/
  xsrfCookieName,
  xsrfHeaderName
} = config

if ((withCredentials || isURLSameOrigin(url!)) && xsrfCookieName){
    
    
  const xsrfValue = cookie.read(xsrfCookieName)
  if (xsrfValue) {
    
    
    headers[xsrfHeaderName!] = xsrfValue
  }
}

上传和下载的进度监控

有些时候,当我们上传文件或者是请求一个大体积数据的时候,希望知道实时的进度,甚至可以基于此做一个进度条的展示。

我们希望给 axios 的请求配置提供 onDownloadProgressonUploadProgress 2 个函数属性,用户可以通过这俩函数实现对下载进度和上传进度的监控。

xhr 对象提供了一个 progress 事件,我们可以监听此事件对数据的下载进度做监控;另外,xhr.uplaod 对象也提供了 progress 事件,我们可以基于此对上传进度做监控。

首先修改一下类型定义。

types/index.ts

export interface AxiosRequestConfig {
    
    
  // ...
  onDownloadProgress?: (e: ProgressEvent) => void
  onUploadProgress?: (e: ProgressEvent) => void
}

接着在发送请求前,给 xhr 对象添加属性。

core/xhr.ts

const {
    
    
  /*...*/
  onDownloadProgress,
  onUploadProgress
} = config

if (onDownloadProgress) {
    
    
  request.onprogress = onDownloadProgress
}

if (onUploadProgress) {
    
    
  request.upload.onprogress = onUploadProgress
}

另外,如果请求的数据是 FormData 类型,我们应该主动删除请求 headers 中的 Content-Type 字段,让浏览器自动根据请求数据设置 Content-Type。比如当我们通过 FormData 上传文件的时候,浏览器会把请求 headers 中的 Content-Type 设置为 multipart/form-data

我们先添加一个判断 FormData 的方法。

helpers/util.ts

export function isFormData(val: any): boolean {
    
    
  return typeof val !== 'undefined' && val instanceof FormData
}

然后再添加相关逻辑。

core/xhr.ts

if (isFormData(data)) {
    
    
  delete headers['Content-Type']
}

xhr 函数内部随着需求越来越多,代码也越来越臃肿,我们可以把逻辑梳理一下,把内部代码做一层封装优化。

我们把整个流程分为 7 步:

  • 创建一个 request 实例。
  • 执行 request.open 方法初始化。
  • 执行 configureRequest 配置 request 对象。
  • 执行 addEventsrequest 添加事件处理函数。
  • 执行 processHeaders 处理请求 headers
  • 执行 processCancel 处理请求取消逻辑。
  • 执行 request.send 方法发送请求。

这样拆分后整个流程就会显得非常清晰,未来我们再去新增需求的时候代码也不会显得越来越臃肿

HTTP 授权

HTTP 协议中的 Authorization 请求 header 会包含服务器用于验证用户代理身份的凭证,通常会在服务器返回 401 Unauthorized 状态码以及 WWW-Authenticate 消息头之后在后续请求中发送此消息头。

axios 库也允许在请求配置中配置 auth 属性,auth 是一个对象结构,包含 usernamepassword 2 个属性。一旦用户在请求的时候配置这俩属性,我们就会自动往 HTTP 的 请求 header 中添加 Authorization 属性,它的值为 Basic 加密串。 这里的加密串是 username:password base64 加密后的结果。

首先修改一下类型定义。

types/index.ts

export interface AxiosRequestConfig {
    
    
  // ...
  auth?: AxiosBasicCredentials
}

export interface AxiosBasicCredentials {
    
    
  username: string
  password: string
}

接着修改合并规则,因为 auth 也是一个对象格式,所以它的合并规则是 deepMergeStrat

core/mergeConfig.ts

const stratKeysDeepMerge = ['headers', 'auth']

然后修改发送请求前的逻辑。

core/xhr.ts

const {
    
    
  /*...*/
  auth
} = config

if (auth) {
    
    
  headers['Authorization'] = 'Basic ' + btoa(auth.username + ':' + auth.password)
}

自定义合法状态码

之前 ts-axios 在处理响应结果的时候,认为 HTTP status在 200 和 300 之间是一个合法值,在这个区间之外则创建一个错误。有些时候我们想自定义这个规则,比如认为 304 也是一个合法的状态码,所以我们希望 ts-axios 能提供一个配置,允许我们自定义合法状态码规则。

首先修改一下类型定义。

types/index.ts

export interface AxiosRequestConfig {
    
    
  // ...
  validateStatus?: (status: number) => boolean
}

然后我们来修改默认配置规则。

defaults.ts

validateStatus(status: number): boolean {
    
    
  return status >= 200 && status < 300
}

添加默认合法状态码的校验规则。然后再请求后对响应数据的处理逻辑。

core/xhr.ts

const {
    
    
  /*...*/
  validateStatus
} = config

function handleResponse(response: AxiosResponse): void {
    
    
  if (!validateStatus || validateStatus(response.status)) {
    
    
    resolve(response)
  } else {
    
    
    reject(
      createError(
        `Request failed with status code ${
      
      response.status}`,
        config,
        null,
        request,
        response
      )
    )
  }
}

如果没有配置 validateStatus 以及 validateStatus 函数返回的值为 true 的时候,都认为是合法的,正常 resolve(response),否则都创建一个错误

自定义参数序列化

在之前我们对请求的 url 参数做了处理,我们会解析传入的 params 对象,根据一定的规则把它解析成字符串,然后添加在 url 后面。在解析的过程中,我们会对字符串 encode,但是对于一些特殊字符比如 @+ 等却不转义,这是 axios 库的默认解析规则。

当然,我们也希望自己定义解析规则,于是我们希望 ts-axios 能在请求配置中允许我们配置一个 paramsSerializer 函数来自定义参数的解析规则,该函数接受 params 参数,返回值作为解析后的结果。

首先修改一下类型定义。

types/index.ts

export interface AxiosRequestConfig {
    
    
  // ...
  paramsSerializer?: (params: any) => string
}

然后修改 buildURL 函数的实现。

helpers/url.ts

export function buildURL(
  url: string,
  params?: any,
  paramsSerializer?: (params: any) => string
): string {
    
    
  if (!params) {
    
    
    return url
  }

  let serializedParams

  if (paramsSerializer) {
    
    
    serializedParams = paramsSerializer(params)
  } else if (isURLSearchParams(params)) {
    
    
    serializedParams = params.toString()
  } else {
    
    
    const parts: string[] = []

    Object.keys(params).forEach(key => {
    
    
      const val = params[key]
      if (val === null || typeof val === 'undefined') {
    
    
        return
      }
      let values = []
      if (Array.isArray(val)) {
    
    
        values = val
        key += '[]'
      } else {
    
    
        values = [val]
      }
      values.forEach(val => {
    
    
        if (isDate(val)) {
    
    
          val = val.toISOString()
        } else if (isPlainObject(val)) {
    
    
          val = JSON.stringify(val)
        }
        parts.push(`${
      
      encode(key)}=${
      
      encode(val)}`)
      })
    })

    serializedParams = parts.join('&')
  }

  if (serializedParams) {
    
    
    const markIndex = url.indexOf('#')
    if (markIndex !== -1) {
    
    
      url = url.slice(0, markIndex)
    }

    url += (url.indexOf('?') === -1 ? '?' : '&') + serializedParams
  }

  return url
}

这里我们给 buildURL 函数新增了 paramsSerializer 可选参数,另外我们还新增了对 params 类型判断,如果它是一个 URLSearchParams 对象实例的话,我们直接返回它 toString 后的结果。

helpers/util.ts

export function isURLSearchParams(val: any): val is URLSearchParams {
    
    
  return typeof val !== 'undefined' && val instanceof URLSearchParams
}

最后我们要修改 buildURL 调用的逻辑。

core/dispatchRequest.ts

function transformURL(config: AxiosRequestConfig): string {
    
    
  const {
    
     url, params, paramsSerializer } = config
  return buildURL(url!, params, paramsSerializer)
}

baseURL

有些时候,我们会请求某个域名下的多个接口,我们不希望每次发送请求都填写完整的 url,希望可以配置一个 baseURL,之后都可以传相对路径。

我们一旦配置了 baseURL,之后请求传入的 url 都会和我们的 baseURL 拼接成完整的绝对地址,除非请求传入的 url 已经是绝对地址。

首先修改一下类型定义。

types/index.ts

export interface AxiosRequestConfig {
    
    
  // ...
  baseURL?: string
}

接下来实现 2 个辅助函数。

helpers/url.ts

export function isAbsoluteURL(url: string): boolean {
    
    
  return /^([a-z][a-z\d\+\-\.]*:)?\/\//i.test(url)
}

export function combineURL(baseURL: string, relativeURL?: string): string {
    
    
  return relativeURL ? baseURL.replace(/\/+$/, '') + '/' + relativeURL.replace(/^\/+/, '') : baseURL
}

最后我们来调用这俩个辅助函数。

core/dispatchRequest.ts

function transformURL(config: AxiosRequestConfig): string {
    
    
  let {
    
     url, params, paramsSerializer, baseURL } = config
  if (baseURL && !isAbsoluteURL(url!)) {
    
    
    url = combineURL(baseURL, url)
  }
  return buildURL(url!, params, paramsSerializer)
}

静态方法扩展

官方 axios 库实现了 axios.allaxios.spread 等方法。

实际上,axios.all 就是 Promise.all 的封装,它返回的是一个 Promise 数组,then 函数的参数本应是一个参数为 Promise resolves(数组)的函数,在这里使用了 axios.spread 方法。所以 axios.spread 方法是接收一个函数,返回一个新的函数,新函数的结构满足 then 函数的参数结构。

为了保持与官网 axios API 一致,也在 ts-axios 库中实现这俩方法。

官方 axios 库也通过 axios.Axios 对外暴露了 Axios 类 (感觉也没有啥使用场景)。

另外对于 axios 实例,官网还提供了 getUri 方法在不发送请求的前提下根据传入的配置返回一个 url

首先修改类型定义。

types/index.ts

export interface AxiosClassStatic {
    
    
  new (config: AxiosRequestConfig): Axios;
}

export interface AxiosStatic extends AxiosInstance {
    
    
  // ...

  all<T>(promises: Array<T | Promise<T>>): Promise<T[]>;

  spread<T, R>(callback: (...args: T[]) => R): (arr: T[]) => R;

  Axios: AxiosClassStatic;
}

export interface Axios {
    
    
  // ...

  getUri(config?: AxiosRequestConfig): string;
}

然后我们去实现这几个静态方法。

axios.ts

axios.all = function all(promises) {
    
    
  return Promise.all(promises);
};

axios.spread = function spread(callback) {
    
    
  return function wrap(arr) {
    
    
    return callback.apply(null, arr);
  };
};

axios.Axios = Axios;

最后我们去给 Axios 添加实例方法 getUri

core/Axios.ts

getUri(config?: AxiosRequestConfig): string {
    
    
  config = mergeConfig(this.defaults, config)
  return transformURL(config)
}

先和默认配置合并,然后再通过 dispatchRequest 中实现的 transformURL 返回一个新的 url

至此,ts-axios 就实现了官网 axios 库在浏览器端的所有需求。

单元测试

单元测试是前端一个很重要的方向,鉴别一个开源库是否靠谱的一个标准是它的单元测试是否完善。

有了完整的单元测试,未来去重构现有代码或者是增加新的需求都会有十足的把握不出现 regression bug。

在前面已经编写完成 ts-axios 库的代码,并通过 demo 的形式简单地对一些功能做了验证,但是 demo 可以走到的代码分支,覆盖的场景都是极其有限的。

为了用更科学的手段保证我们代码的可靠性,我们去编写单元测试,并尽可能达到 99% 以上的测试覆盖率。

我们使用开源测试框架 Jest,它是 Facebook 出品的一个测试框架,相对其他测试框架,它的一大特点就是内置了常用的测试工具,比如自带断言、测试覆盖率工具,实现了开箱即用。

单元测试部分不是本文重点,在此不做介绍,本项目最后测试覆盖率达到了100%。

猜你喜欢

转载自blog.csdn.net/weixin_46232841/article/details/124417526