基于TypeScript从零重构axios(一)

# 基于TypeScript从零重构axios(一)

从零开始重构一个功能完整的JS库,并进行单元测试与部署发布。

[TOC]

# 一、项目构建

# 1.1 需求分析

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

# 1.2 初始化项目

# 1.2.1 TypeScript library starter

# 1.2.1.1 生成的目录文件
├── 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 文件
1
2
3
4
5
6
7
8
9
10
11
12
13
# 1.2.1.2 优秀工具集成

使用 TypeScript library starter 创建的项目集成了很多优秀的开源工具:

# 1.2.1.3 npm scripts
"scripts": {
    "lint": "tslint  --project tsconfig.json -t codeFrame 'src/**/*.ts' 'test/**/*.ts'",
    "build": "tsc --module commonjs && rollup -c rollup.config.ts && typedoc --out docs --target es6 --theme minimal --mode file src",
    "start": "rollup -c rollup.config.ts -w",
    "test": "jest --coverage",
    "commit": "git-cz",
}
1
2
3
4
5
6
7
  • npm run lint: 使用 TSLint 工具检查 srctest 目录下 TypeScript 代码的可读性、可维护性和功能性错误。
  • npm start: 观察者模式运行 rollup 工具打包代码。
  • npm test: 运行 jest 工具跑单元测试。
  • npm run commit: 运行 commitizen 工具提交格式化的 git commit 注释。
  • npm run build: 运行 rollup 编译打包 TypeScript 代码,并运行 typedoc 工具生成文档。

# 1.3 编写基础请求代码

  • 实现简单的发送请求功能,即客户端通过 XMLHttpRequest 对象把请求发送到 server 端,server 端能收到请求并响应即可。

# 1.3.1 创建入口文件

// src/index.ts
function axios(config) {
  //...
}

export default axios
1
2
3
4
5
6

# 1.3.2 利用XMLHttpRequest对象发送请求

// src/types/index.ts
/* 
* 公用的类型定义文件
*/

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

// 只能传入合法的字符串
export type Method = 'get' | 'GET'
  | 'delete' | 'Delete'
  | 'head' | 'HEAD'
  | 'options' | 'OPTIONS'
  | 'post' | 'POST'
  | 'put' | 'PUT'
  | 'patch' | 'PATCH'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// src/xhr.ts
// 发送请求模块
import { AxiosRequestConfig } from './types'

export default function xhr(config: AxiosRequestConfig): void {
  const { url, method = 'get', data =null } = config
  const request = new XMLHttpRequest()
  // 第三个参数:同步/异步(false/true),默认是异步也就是true,可以不用填写。
  request.open(method.toUpperCase(), url)
  request.send(data)
}
1
2
3
4
5
6
7
8
9
10
11
// src/index.ts
import { AxiosRequestConfig } from './types'
import xhr from './xhr'

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

export default axios
1
2
3
4
5
6
7
8
9

# 1.3.2 demo编写

server部分的实现,暂略。

# 二、基础功能实现

# 2.1 处理请求url参数

# 2.1.1 需求分析

axios({
  method: 'get',
  url: '/base/get',
  params: {
    //参数值
  }
})
1
2
3
4
5
6
7
  • 参数值为数字 a: 1, b: 2
    • 请求的 url/base/get?a=1&b=2
  • 参数值为数组foo: ['bar', 'baz']
    • 请求的 url/base/get?foo[]=bar&foo[]=baz
  • 参数值为对象foo: { bar: 'baz' }
    • 请求的 url/base/get?foo=%7B%22bar%22:%22baz%22%7D
    • foo 后面拼接的是 {"bar":"baz"} encode 后的结果。
  • 参数值为 Date 类型new Date()
    • 请求的 url/base/get?date=2019-04-01T05:55:39.030Z
    • date 后面拼接的是 date.toISOString() 的结果。
  • 特殊字符支持foo: '@:$, '
    • 请求的 url/base/get?foo=@:$+
    • 会把空格 转换成 +
    • 对于字符 @:$,[],是允许出现在 url 中的,不希望被 encode
  • 空值忽略foo: 'bar', baz: null
    • 请求的 url/base/get?foo=bar
    • 对于值为 null 或者 undefined 的属性,是不会添加到 url 参数中的
  • 丢弃 url 中的哈希标记url: '/base/get#hash'
    • 请求的 url/base/get
  • 保留 url 中已存在的参数url: '/base/get?foo=bar', params: { bar: 'baz' }
    • 请求的 url/base/get?foo=bar&bar=baz

# 2.1.2 buildURL 函数实现

创建一个helpers目录,将项目中的一些工具函数、辅助方法独立管理。

// src/helpers/util.ts
// 存放工具辅助方法

// 类型判断会常用到,可以考虑提取出来
const toString = Object.prototype.toString

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


export function isObject (val: any): val is Object {
  return val && typeof val === 'object'
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/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') {
      // forEach的return不会跳出forEach,而是跳到下一个循环
      return
    }
    let values: string[]
    if (Array.isArray(val)) {
      values = val
      key += '[]'
    } else {
      values = [val]
    }

    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) {
    // 去除url的hash值
    const markIndex = url.indexOf('#')
    if (markIndex !== -1) {
      url = url.slice(0, markIndex)
    }

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

  return url
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60

# 2.1.3 实现 url 参数处理逻辑

层层嵌套的写法,显得逻辑更清晰,也无需额外写注释。

// src/index.ts
import { buildURL } from './helpers/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)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 2.2 处理请求body数据

# 2.2.1 需求分析

  • XMLHttpRequest对象的send方法的参数 DocumentBodyInit 类型。
    • BodyInit 包括了 Blob, BufferSource, FormData, URLSearchParams, ReadableStreamUSVString
    • 当没有数据的时候,可传入 null
axios({
  method: 'post',
  url: '/base/post',
  data: { 
    a: 1,
    b: 2 
  }
})
1
2
3
4
5
6
7
8

这里 data是不能直接传给 send 函数的,需要把它转换成 JSON 字符串。

# 2.2.2 transformRequest 函数实现

由于FormDataArrayBuffer 这些类型,用typeof判断得到的也是对象类型,这里需要将它们与普通对象区分开来。

const toString = Object.prototype.toString

toString.call(undefined);	// [object Undefined]
toString.call(null); 		// [object Null]

let a = new FormData;
toString.call(a);			// [object FormData]
let b = new ArrayBuffer();
toString.call(b);			// [object ArrayBuffer]
1
2
3
4
5
6
7
8
9
// src/helpers/util.ts
// 判断普通对象
export function isPlainObject (val: any): val is Object {
  return toString.call(val) === '[object Object]'
}
1
2
3
4
5
// src/helpers/data.ts
import { isPlainObject } from './util'

export function transformRequest (data: any): any {
  if (isPlainObject(data)) {
    return JSON.stringify(data)
  }
  return data
}
1
2
3
4
5
6
7
8
9
// src/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)
}
1
2
3
4
5
6
7
8
9
10
11

# 2.3 处理请求header

# 2.3.1 需求分析

  • 如果没有给请求 header 设置正确的 Content-Type,当把 data 转换成了 JSON 字符串后,并将数据发送到服务端时, 服务端并不能正确解析接收的数据。
  • 对于JSON字符串,应配置
headers: {
    'content-type': 'application/json;charset=utf-8'
}
1
2
3

# 2.3.2 processHeaders 函数实现

// src/helpers/headers.ts
import { isPlainObject } from './util'

// 规范化header属性名
// 请求 header 属性是大小写不敏感的,故需统一
function normalizeHeaderName (headers: any, normalizedName: string): void {
    
  // 没有headers就不用处理了
  if (!headers) {
    return
  }
    
  Object.keys(headers).forEach(name => {
    if (name !== normalizedName && name.toUpperCase() === normalizedName.toUpperCase()) {
      headers[normalizedName] = headers[name]
      // delete操作符用于删除对象的某个属性
      // 如果没有指向这个属性的引用,那它最终会被释放
      delete headers[name]
    }
  })
}

export function processHeaders (headers: any, data: any): any {
  normalizeHeaderName(headers, 'Content-Type')
  
  if (isPlainObject(data)) {
    // headers为{}是true,故也会添加
    if (headers && !headers['Content-Type']) {
      headers['Content-Type'] = 'application/json;charset=utf-8'
    }
  }
  return headers
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

# 2.3.3 实现请求 header 处理逻辑

// src/types/index.ts
export interface AxiosRequestConfig {
  // 添加属性
  headers?: any
}
1
2
3
4
5
// src/index.ts
function processConfig (config: AxiosRequestConfig): void {
  config.url = transformURL(config)
  // 要在data被JSON字符串前,进行header处理
  config.headers = transformHeaders(config)
  config.data = transformRequestData(config)
}

function transformHeaders (config: AxiosRequestConfig) {
  // 避免headers为null的情况
  const { headers = {}, data } = config
  return processHeaders(headers, data)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// src/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为空,则无需给请求头配置Content-Type
    if (data === null && name === 'Content-Type') {
      delete headers[name]
    } else {
      request.setRequestHeader(name, headers[name])
    }
  })

  request.send(data)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 2.4 获取响应数据

# 2.4.1 需求分析

  • 能够处理服务端响应的数据,并支持 Promise 链式调用的方式。
axios({
  method: 'post',
  url: '/base/post',
  data: {
    a: 1,
    b: 2
  }
}).then((res) => {
  console.log(res)
})
1
2
3
4
5
6
7
8
9
10
  • res对象包括
    • 服务端返回的数据 data
    • HTTP 状态码status
    • 状态消息 statusText
    • 响应头 headers
    • 请求配置对象 config
    • 请求的 XMLHttpRequest 对象实例 request

# 2.4.2 实现

# 2.4.2.1 定义接口类型
  • 定义一个 AxiosResponse 接口类型。
// src/types/index.ts
export interface AxiosResponse {
  data: any
  status: number
  statusText: string
  headers: any
  config: AxiosRequestConfig
  request: any
}
1
2
3
4
5
6
7
8
9
  • axios 函数返回的是一个 Promise 对象,可以定义一个 AxiosPromise 接口,它继承于 Promise<AxiosResponse> 这个泛型接口。
    • axios 返回的是 AxiosPromise 类型,那么 resolve 函数中的参数就是一个 AxiosResponse 类型。
// src/types/index.ts
export interface AxiosPromise extends Promise<AxiosResponse> {
}
1
2
3
  • 可以指定reponse响应的数据类型。
    • responseType 的类型是一个 XMLHttpRequestResponseType 类型,是ts自己定义的。
    • type XMLHttpRequestResponseType = "" | "arraybuffer" | "blob" | "document" | "json" | "text"
// src/types/index.ts
export interface AxiosRequestConfig {
  // ...
  responseType?: XMLHttpRequestResponseType
}
1
2
3
4
5
# 2.4.2.2 实现获取响应数据逻辑
// src/xhr.ts
export default function xhr(config: AxiosRequestConfig): AxiosPromise {
  return new Promise((resolve) => {
    const { data = null, url, method = 'get', headers, responseType } = config

    const request = new XMLHttpRequest()

    if (responseType) {
      request.responseType = responseType
    }

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

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

      const responseHeaders = request.getAllResponseHeaders()
      // request.response 获取的就是 res.json()返回的内容
      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) => {
      if (data === null && name.toLowerCase() === 'content-type') {
        delete headers[name]
      } else {
        request.setRequestHeader(name, headers[name])
      }
    })

    request.send(data)
  })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
  • 修改axios函数的return值。
// src/index.ts
function axios(config: AxiosRequestConfig): AxiosPromise {
  processConfig(config)
  return xhr(config)
}
1
2
3
4
5

# 2.5 处理响应 header

# 2.5.1 需求分析

  • 通过 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
    
    1
    2
    3
    4
    5
    6
    • 每一行都是以回车符和换行符 \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'
}
1
2
3
4
5
6
7
8

# 2.5.2 parseHeaders 函数实现及应用

// src/helpers/heaer.ts
export function parseHeaders(headers: string): any {
    let parsed = Object.create(null)
    if (!headers) {
        return parsed
    }

    headers.split('\r\n').forEach(line => {
        let [key, val] = line.split(':')
        key = key.trim().toLowerCase()
        if (!key) {
            return
        }
        if (val) {
            val = val.trim()
        }
        parsed[key] = val
    })

    return parsed
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// src/xhr.ts
import { parseHeaders } from './helpers/headers'

const responseHeaders = parseHeaders(request.getAllResponseHeaders())
1
2
3
4

# 2.6 处理响应 data

# 2.6.1 需求分析

  • 在不去设置 responseType 的情况下,当服务端返回给我们的数据是字符串类型,尝试把它转换成一个 JSON 对象。
    • data: "{"a":1,"b":2}" => data: {a: 1, b: 2}

# 2.6.2 transformResponse 函数实现及应用

// src/helpers/data.ts
export function transformResponse(data: any): any {
  if (typeof data === 'string') {
    // data是字符串,但不一定是JSON字符串
    try {
      data = JSON.parse(data)
    } catch (e) {
      // do nothing
    }
  }
  return data
}
1
2
3
4
5
6
7
8
9
10
11
12
// src/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
}
1
2
3
4
5
6
7
8
9
10
11
12