基于TypeScript从零重构axios(一)
# 基于TypeScript从零重构axios(一)
从零开始重构一个功能完整的JS库,并进行单元测试与部署发布。
[TOC]
# 一、项目构建
# 1.1 需求分析
- 在浏览器端使用 XMLHttpRequest 对象通讯
- 支持 Promise API
- 支持请求和响应的拦截器
- 支持请求数据和响应数据的转换
- 支持请求的取消
- JSON 数据的自动转换
- 客户端防止 XSRF
# 1.2 初始化项目
# 1.2.1 TypeScript library starter
- 开源的 TypeScript 开发基础库的脚手架工具,可以帮助我们快速初始化一个 TypeScript 项目。
- 官网地址 (opens new window)
# 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
2
3
4
5
6
7
8
9
10
11
12
13
# 1.2.1.2 优秀工具集成
使用 TypeScript library starter
创建的项目集成了很多优秀的开源工具:
- 使用 RollupJS (opens new window) 帮助我们打包。
- 使用 Prettier (opens new window) 和 TSLint (opens new window) 帮助我们格式化代码以及保证代码风格一致性。
- 使用 TypeDoc (opens new window) 帮助我们自动生成文档并部署到 GitHub pages。
- 使用 Jest (opens new window)帮助我们做单元测试。
- 使用 Commitizen (opens new window)帮助我们生成规范化的提交注释。
- 使用 Semantic release (opens new window)帮助我们管理版本和发布。
- 使用 husky (opens new window)帮助我们更简单地使用 git hooks。
- 使用 Conventional changelog (opens new window)帮助我们通过代码提交信息自动生成 change log。
# 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
2
3
4
5
6
7
npm run lint
: 使用 TSLint 工具检查src
和test
目录下 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
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
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
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
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
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
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
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
3
4
5
6
7
8
9
10
11
12
13
14
15
# 2.2 处理请求body数据
# 2.2.1 需求分析
XMLHttpRequest
对象的send方法的参数Document
和BodyInit
类型。BodyInit
包括了Blob
,BufferSource
,FormData
,URLSearchParams
,ReadableStream
、USVString
。- 当没有数据的时候,可传入
null
。
axios({
method: 'post',
url: '/base/post',
data: {
a: 1,
b: 2
}
})
1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
这里 data
是不能直接传给 send
函数的,需要把它转换成 JSON 字符串。
# 2.2.2 transformRequest 函数实现
由于FormData
、ArrayBuffer
这些类型,用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
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
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
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
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.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
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
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
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
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
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
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
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
3
4
5
# 2.4.2.2 实现获取响应数据逻辑
- 在
xhr
函数添加onreadystatechange
(opens new window) 事件处理函数,并且让xhr
函数返回的是AxiosPromise
类型。
// 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
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
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
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
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
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
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
2
3
4
5
6
7
8
9
10
11
12