造轮子:基于TS从零构建axios(二)

1,335 阅读8分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第5天,点击查看活动详情

前言

上一篇我们实现了一个简单的请求发送,并编写了相关的 demo。但是现在存在一些问题:我们传入的 params 数据并没有用,也没有拼接到 url 上;我们对 request body 的数据格式、请求头 headers 也没有做处理;另外我们虽然从网络层面收到了响应的数据,但是我们代码层面也并没有对响应的数据做处理。那么这一章,我们就来解决这些问题。

处理请求url参数

需求分析

还记得我们上节遗留了一个问题,再来看这个例子:

axios({
  method: 'get',
  url: '/base/get',
  params: {
    a: 1,
    b: 2
  }
})

我们希望最终请求的 url/base/get?a=1&b=2,这样服务端就可以通过请求的 url 解析到我们传来的参数数据了。实际上就是把 params 对象的 key 和 value 拼接到 url 上。

再来看几个更复杂的例子。

参数值为数组

axios({
  method: 'get',
  url: '/base/get',
  params: {
    foo: ['bar', 'baz']
  }
})

最终请求的 url/base/get?foo[]=bar&foo[]=baz'

参数值为对象

axios({
  method: 'get',
  url: '/base/get',
  params: {
    foo: {
      bar: 'baz'
    }
  }
})

最终请求的 url/base/get?foo=%7B%22bar%22:%22baz%22%7Dfoo 后面拼接的是 {"bar":"baz"} encode 后的结果。

参数值为 Date 类型

const date = new Date()
​
axios({
  method: 'get',
  url: '/base/get',
  params: {
    date
  }
})

最终请求的 url/base/get?date=2019-04-01T05:55:39.030Zdate 后面拼接的是 date.toISOString() 的结果。

特殊字符支持

对于字符 @:$,、``、[],我们是允许出现在 url 中的,不希望被 encode。

axios({
  method: 'get',
  url: '/base/get',
  params: {
    foo: '@:$, '
  }
})

最终请求的 url/base/get?foo=@:$+,注意,我们会把空格 ``转换成 +

空值忽略

对于值为 null 或者 undefined 的属性,我们是不会添加到 url 参数中的。

axios({
  method: 'get',
  url: '/base/get',
  params: {
    foo: 'bar',
    baz: null
  }
})

最终请求的 url/base/get?foo=bar

丢弃 url 中的哈希标记

axios({
  method: 'get',
  url: '/base/get#hash',
  params: {
    foo: 'bar'
  }
})

最终请求的 url/base/get?foo=bar

保留 url 中已存在的参数

axios({
  method: 'get',
  url: '/base/get?foo=bar',
  params: {
    bar: 'baz'
  }
})

最终请求的 url/base/get?foo=bar&bar=baz

buildURL 函数实现

根据我们之前的需求分析,我们要实现一个工具函数,把 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
    }
    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) {
    const markIndex = url.indexOf('#')
    if (markIndex !== -1) {
      url = url.slice(0, markIndex)
    }
​
    url += (url.indexOf('?') === -1 ? '?' : '&') + serializedParams
  }
​
  return url
}

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 !== null && typeof val === 'object'
}
​

实现 url 参数处理逻辑

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

index.ts 文件中添加如下代码:

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 参数处理逻辑就实现完了,接下来我们就开始编写 demo 了。

demo 编写

examples 目录下创建 base 目录,在 base 目录下创建 index.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Base example</title>
  </head>
  <body>
    <script src="/__build__/base.js"></script>
  </body>
</html>

接着创建 app.ts 作为入口文件:

import axios from '../../src/index'axios({
  method: 'get',
  url: '/base/get',
  params: {
    foo: ['bar', 'baz']
  }
})
​
axios({
  method: 'get',
  url: '/base/get',
  params: {
    foo: {
      bar: 'baz'
    }
  }
})
​
const date = new Date()
​
axios({
  method: 'get',
  url: '/base/get',
  params: {
    date
  }
})
​
axios({
  method: 'get',
  url: '/base/get',
  params: {
    foo: '@:$, '
  }
})
​
axios({
  method: 'get',
  url: '/base/get',
  params: {
    foo: 'bar',
    baz: null
  }
})
​
axios({
  method: 'get',
  url: '/base/get#hash',
  params: {
    foo: 'bar'
  }
})
​
axios({
  method: 'get',
  url: '/base/get?foo=bar',
  params: {
    bar: 'baz'
  }
})

接着在 server.js 添加新的接口路由:

router.get('/base/get', function(req, res) {
  res.json(req.query)
})

然后在命令行运行 npm run dev,接着打开 chrome 浏览器,访问 http://localhost:8080/ 即可访问我们的 demo 了,我们点到 Base 目录下,通过开发者工具的 network 部分我们可以看到成功发送的多条请求,并可以观察它们最终请求的 url,已经如期添加了请求参数。

那么至此我们的请求 url 参数处理编写完了,下一小节我们会对 request body 数据做处理。

处理请求 body 数据

需求分析

我们通过执行 XMLHttpRequest 对象实例的 send 方法来发送请求,并通过该方法的参数设置请求 body 数据,我们可以去 mdn 查阅该方法支持的参数类型。

我们发现 send 方法的参数支持 DocumentBodyInit 类型,BodyInit 包括了 Blob, BufferSource, FormData, URLSearchParams, ReadableStreamUSVString,当没有数据的时候,我们还可以传入 null

但是我们最常用的场景还是传一个普通对象给服务端,例如:

axios({
  method: 'post',
  url: '/base/post',
  data: { 
    a: 1,
    b: 2 
  }
})

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

transformRequest 函数实现

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

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 对象才能满足。

helpers/url.ts

if (isDate(val)) {
  val = val.toISOString()
} else if (isPlainObject(val)) {
  val = JSON.stringify(val)
}

对于上节课我们对请求参数值的判断,我们也应该用 isPlainObject 才更加合理。

helpers/util.js

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

既然现在 isObject 方法不再使用,我们先将其注释。

实现请求 body 处理逻辑

index.ts

import { transformRequest } from './helpers/data'
​
```typescript
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

axios({
  method: 'post',
  url: '/base/post',
  data: {
    a: 1,
    b: 2
  }
})
​
const arr = new Int32Array([21, 31])
​
axios({
  method: 'post',
  url: '/base/buffer',
  data: arr
})

我们在 examples/base/app.ts 添加 2 段代码,第一个 post 请求的 data 是一个普通对象,第二个请求的 data 是一个 Int32Array 类型的数据,它是可以直接传给 XMLHttpRequest 对象的 send 方法的。

router.post('/base/post', function(req, res) {
  res.json(req.body)
})
​
router.post('/base/buffer', function(req, res) {
  let msg = []
  req.on('data', (chunk) => {
    if (chunk) {
      msg.push(chunk)
    }
  })
  req.on('end', () => {
    let buf = Buffer.concat(msg)
    res.json(buf.toJSON())
  })
})

我们接着在 examples/server.js 中添加 2 个路由,分别针对这俩种请求,返回请求传入的数据。

然后我们打开浏览器运行 demo,看一下结果,我们发现 /base/buffer 的请求是可以拿到数据,但是 base/post 请求的 response 里却返回的是一个空对象,这是什么原因呢?

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

知道这个问题后,下面一节课我们来实现对请求 header 的处理。

处理请求 header

需求分析

我们上节遗留了一个问题:

axios({
  method: 'post',
  url: '/base/post',
  data: {
    a: 1,
    b: 2
  }
})

我们做了请求数据的处理,把 data 转换成了 JSON 字符串,但是数据发送到服务端的时候,服务端并不能正常解析我们发送的数据,因为我们并没有给请求 header 设置正确的 Content-Type

所以首先我们要支持发送请求的时候,可以支持配置 headers 属性,如下:

axios({
  method: 'post',
  url: '/base/post',
  headers: {
    'content-type': 'application/json;charset=utf-8'
  },
  data: {
    a: 1,
    b: 2
  }
})

并且在当我们传入的 data 为普通对象的时候,headers 如果没有配置 Content-Type 属性,需要自动设置请求 headerContent-Type 字段为:application/json;charset=utf-8

processHeaders 函数实现

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

helpers/headers.ts

import { isPlainObject } from './util'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 属性是大小写不敏感的,比如我们之前的例子传入 header 的属性名 content-type 就是全小写的,所以我们先要把一些 header 属性名规范化。

实现请求 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) => {
    if (data === null && name.toLowerCase() === 'content-type') {
      delete headers[name]
    } else {
      request.setRequestHeader(name, headers[name])
    }
  })
​
  request.send(data)
}

这里要额外判断一个逻辑,当我们传入的 data 为空的时候,请求 header 配置 Content-Type 是没有意义的,于是我们把它删除。

demo 编写

axios({
  method: 'post',
  url: '/base/post',
  data: {
    a: 1,
    b: 2
  }
})
​
axios({
  method: 'post',
  url: '/base/post',
  headers: {
    'content-type': 'application/json;'
  },
  data: {
    a: 1,
    b: 2
  }
})
​
const paramsString = 'q=URLUtils.searchParams&topic=api'
const searchParams = new URLSearchParams(paramsString)
​
axios({
  method: 'post',
  url: '/base/post',
  data: searchParams
})

通过 demo 我们可以看到,当我们请求的数据是普通对象并且没有配置 headers 的时候,会自动为其添加 Content-Type:application/json;charset=utf-8;同时我们发现当 data 是某些类型如 URLSearchParams 的时候,浏览器会自动为请求 header加上合适的 Content-Type

至此我们对于请求的处理逻辑暂时告一段落。目前我们的请求从网络层面是可以收到服务端的响应的,下一章我们就从代码层面来处理服务端响应,并且让调用方可以拿到从服务端返回的数据。