使用 Axios 封装 HTTP 请求层
Axios 是当前 JavaScript 生态中最流行的 HTTP 请求库,即便 2017 年左右主流浏览器就都支持了原生的 Fetch API,但 Axios 的语法优势和内置的便捷功能让它在今天仍然是多数开发者的首选。
Axios 的历史和原理
早期(2005年之前)网站发送 HTTP 请求到服务器,服务器会返回新的 HTML,之后浏览器需要解析 HTML 重新构建 DOM 并渲染整个页面。很快人们发现这样的交互体验不尽人意,于是发明了 AJAX 这个模式来实现局部刷新。它使用 XMLHttpRequest(简称XHR) 在后台发起 HTTP 请求,收到服务器返回后通过 JavaScript 操纵 DOM 以局部更新页面。
Axios 正是基于 XHR 的 HTTP 请求库(实际上 Axios 即支持浏览器环境又支持 Node.js 环境,在不同的环境中依靠不同的原生工具来发出 HTTP 请求,在浏览器中使用 XHR 而在 Node.js 中使用内置的 http 模块)。
上图为:Axios 源代码中使用了 XHR
在使用了 AJAX 后用户体验得到改善,但开发者却对 XHR 的语法不太满意,它有一堆事件和状态,大体使用方法如下:
var request = new XMLHttpRequest();
request.onreadystatechange = function () {
if (xhr.readyState === 4) { // 4 means request is completed
if (xhr.status === 200) {
console.log(xhr.responseText);
} else {
console.log('Error: ' + xhr.status);
}
}
};
request.open("GET", "hello.txt");
request.send();
随着时代发展,2015 年 Promise 被引入语言标准(ES6)用于处理异步操作。相比纯回调的方式,Promise 通过链式调用的方式避免了回调地狱所以很快受到开发者的青睐(ES2017中引入了 async/await 关键词,不过这只是语法糖,本质还是 Promise)。
HTTP 请求天生就有异步属性,开发者希望能在做 HTTP 请求时使用 Promise 但是 XHR 并没有提供基于 Promise 的 API,于是 Axios 项目正式启动,它的定位是:
Promise based HTTP client for the browser and node.js
Axios 的优势(相对于 fetch)
虽然 fetch 也基于 Promise,并且还是原生的 Web API(这意味着你不需要引入额外的第三方代码),但为什么开发者仍然会选择 axios 呢? 个人认为是下面这些优势:
- Learn once, use everywhere
Axios 既可以在浏览器也可以在 Node.js 环境中使用并且语法一致。这相当于抹平了不同运行时的语法差异,对于全栈开发者或者来说降低了学习和维护难度。
- Axios 允许创建多个实例(instance)
fetch 是典型的单例模式,在任何时候只有一个实例。Axios 也可以作为单例使用,但是它允许创建多个实例以应对复杂场景。比如一个前端项目可能会对接多个不相干的后端服务并且它们对请求格式的要求不同,这时可以创建多个 Axios 实例分别对应这些后端服务。
- Axios 可以设定默认值
在配置 Axios 的时候可以设定一些默认值,比如 baseURL, headers 之类:
axios.defaults.baseURL = 'https://api.example.com';
axios.defaults.headers.common['Authorization'] = 'XXX';
axios.defaults.headers.post['Content-Type'] = 'application/json'
- Axios 自带拦截器(interceptor)
拦截器可以拦截 request/response 并做一些通用处理,这在某些情况下非常有用(后文会举例说明)。
- Axios 简化了和 JSON 相关的操作
const url = 'https://jsonplaceholder.typicode.com/posts'
const data = { a: 10, b: 20 };
const headers = {
Accept: "application/json",
"Content-Type": "application/json;charset=UTF-8",
};
// axios
axios
.post(url, data, { headers }) // 自带 post 等方法;传参时无须手动序列化
.then(({ data }) => { // 自动 JSON 解析
console.log(data);
})
// fetch
const options = {
method: "POST",
headers: headers,
body: JSON.stringify(data) // 需要手动序列化以作为 http body
};
fetch(url, options)
.then((response) => response.json()) // 需要手动做 JSON 解析
.then((data) => {
console.log(data);
});
- 其他功能:比如自带 timeout, 更简单易用的请求取消机制等
Axios 封装示例
不同项目对请求层的要求几乎都不同,假设项目中对于 HTTP 请求层的需求如下:
-
所有的 API 都在一个站点下,而且对请求格式的要求相同 (无需使用多个 Axios 实例)
-
大多数 API 需要认证和授权才能访问(需要设置 Authorization 请求头),但有少量的 API 是公开的(无需认证和授权)
-
API 的风格是 RESTful
-
关于请求方法的一些约定:
- 请求方法可能会是: GET, POST, PUT, PATCH, DELETE
- GET 请求只用于获取资源,不应该生成或者修改资源
- POST 的主要用处是 create 新资源,有时获取资源需要传结构化数据给 API 也可以考虑使用 POST
- PUT 用于全量更新(自动生成的字段比如 id 除外), 相当于先删除再创建
- PATCH 用于部分更新,只会更新 payload 里面有的字段
- DELETE 用于删除资源
-
关于 request payload 的一些约定:
- request 的 payload 可能存在于两个地方:URL 和 HTTP Body
- URL 里的 payload 分为两种:URL Path & URL Query Params
- GET 请求一般不会有 HTTP Body
- 如果 request payload 是 HTTP Body 的形式那么大部分情况 Content-Type 请求头是 application/json 但不排除特殊情况
-
关于 response 中 Content-Type 返回头的一些约定:
- Content-Type 返回头一般是 application/json 但不排除会有其他情况比如 text/plain 之类
- 如果是文件下载服务 Content-Type 会尽量是文件类型对应的 MIME 类型(比如 application/pdf, application/zip 之类),对于未知文件类型会返回 application/octet-stream;客户端拿到文件后可能需要展示文件内容也可能需要直接下载并保存
有了具体的需求就可以着手封装了。首先定义一些类型(types.ts):
// 这不包括所有的 HTTP 方法(比如 HEAD 和 OPTIONS 方法),但覆盖了大部分场景
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"
export interface IRequestOption {
method?: HttpMethod
params?: Record<string, any> // url query params
body?: any
headers?: Record<string, string>
skipAuth?: boolean // whether to include auth header
requestBinaryData?: {
saveAsFile: boolean; // whether to save response data as file
}
}
再定义一些 utils 函数来处理文件下载(utils.ts):
export function getFilenameFromResponse(contentDisposition: string, fallback = "data"): string {
// content disposition example: attachment; filename="abc.pdf"
if (!contentDisposition) {
console.warn("Content-Disposition header missing in response")
return fallback
}
const filename = contentDisposition
.split(";")
.find(it => it.trim().startsWith("filename"))
?.split("=")[1]
?.trim()
?.replace(/^['"]|['"]$/g, "");
if (!filename) {
console.warn(`Failed to parse Content-Disposition header ${contentDisposition}, fallback to ${fallback}`)
return fallback
}
return filename
}
export function downloadAsFile(
blob: Blob,
filename: string // important: filename with suffix
) {
const url = window.URL.createObjectURL(blob);
// create an invisible link
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.style.display = "none";
document.body.appendChild(link);
link.click();
// release memory
window.URL.revokeObjectURL(url)
}
现在正式开始封装 axios 以构建 request 函数 (request.ts):
import axios from 'axios'
import { IRequestOption } from './types.ts'
import { getFilenameFromResponse, downloadAsFile } from './utils'
export const LOCAL_STORAGE_KEY = {
ACCESS_TOKEN = "access_token",
REFRESH_TOKEN = "refresh_token"
}
// set up base url (if FE & backend happen to be under the same host, skip this)
axios.defaults.baseURL = 'https://api.example.com';
// request wrapper based on axios
export async function request<T = any>(url: string, option: IRequestOption = {}): Promise<T> {
const { method = 'GET', params, body, headers = {}, skipAuth = false, requestBinaryData } = option;
// make api call
const accessToken = localStorage.getItem(LOCAL_STORAGE_KEY.ACCESS_TOKEN);
const response = await axios.request<T>({
method: method.toLowerCase(), // axios uses lowercase for method
url: url,
headers: skipAuth ? : headers : {
...headers,
['Authorization']: `Bearer ${accessToken}`
},
params: params,
data: body,
responseType: requestBinaryData ? 'blob' : 'json',
})
/* save response data as file if saveAsFile is true */
if (requestBinaryData?.saveAsFile) {
const contentDisposition = response.headers['content-disposition']
const filename = getFilenameFromResponse(contentDisposition)
downloadAsFile((response.data as unknown) as Blob, filename)
}
// return data
return response.data
}
最后可以把 method 提取出来作为单独的函数 (index.ts):
import { HttpMethod, IRequestOption } from './interface'
import { request } from './request'
type IShortRequestOption = Omit<IRequestOption, 'method' | 'data' | 'params'>
function constructHandyRequest<T = any>(
request: (url: string, options: IRequestOption) => Promise<T>,
method: HttpMethod
) {
if (method === 'GET') {
return (url: string, params?: Record<string, any>, options?: IShortRequestOption) => {
return request(url, { ...options, method, params })
}
} else {
return (url: string, body?: any, options?: IShortRequestOption) => {
return request(url, { ...options, method, body })
}
}
}
export HttpRequest = {
Request: request,
Get: constructHandyRequest(request, 'Get'),
Post: constructHandyRequest(request, 'Post'),
Put: constructHandyRequest(request, 'Put'),
Patch: constructHandyRequest(request, 'Patch'),
Delete: constructHandyRequest(request, 'Delete'),
}
一些具体的例子:
// list todos
HttpRequest.Get("/todos")
// get details of an todo item(whose id is 1)
HttpRequest.Get("/todos/1")
// get list of unfinished todos
HttpRequest.Get("/todos?done=false")
HttpRequest.Get("/todos", { done: false })
// create todo item
HttpRequest.Post("/todos", { content: "Walk the dog" })
// change status of a todo item(whose id is 1)
HttpRequest.Patch("/todos/1", { done: true })
// replace a todo item(whose id is 1)
HttpRequest.Put("/todos/1", { conent: "Walk the cow", done: true })
// delete a todo item(whose id is 1)
HttpRequest.Delete("/todos/1")
// delete a list of todos by id
HttpRequest.Delete("/todos", ["1", "2", "3"])
拦截器用法示例 - Refresh Access
很多项目在用户登录后会返回一个 access token,在后续需要鉴权的 API 请求中,客户端要将其放在 Authorization 请求头中。
常见的 access token 的格式是 JWT 下面是我用 JWT Builder 生成的一个 JWT:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2NTc1OTcxOTEsImV4cCI6MTY1NzY4MzU5MSwiYXVkIjoid3d3LmV4YW1wbGUuY29tIiwic3ViIjoianJvY2tldEBleGFtcGxlLmNvbSIsIkdpdmVuTmFtZSI6IkpvaG5ueSIsIlN1cm5hbWUiOiJSb2NrZXQiLCJFbWFpbCI6Impyb2NrZXRAZXhhbXBsZS5jb20iLCJSb2xlIjpbIk1hbmFnZXIiLCJQcm9qZWN0IEFkbWluaXN0cmF0b3IiXX0.f5Q-NMUITKSAQ7I_JpKhkC48s45tBvR-lx5qMKqgRJc
如果 Auth Scheme 是 Bearer 那么对应的 Authorization 请求头就是:
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2NTc1OTcxOTEsImV4cCI6MTY1NzY4MzU5MSwiYXVkIjoid3d3LmV4YW1wbGUuY29tIiwic3ViIjoianJvY2tldEBleGFtcGxlLmNvbSIsIkdpdmVuTmFtZSI6IkpvaG5ueSIsIlN1cm5hbWUiOiJSb2NrZXQiLCJFbWFpbCI6Impyb2NrZXRAZXhhbXBsZS5jb20iLCJSb2xlIjpbIk1hbmFnZXIiLCJQcm9qZWN0IEFkbWluaXN0cmF0b3IiXX0.f5Q-NMUITKSAQ7I_JpKhkC48s45tBvR-lx5qMKqgRJc
仔细观察下生成的 JWT, 可以看到 JWT 被两个 . 分成了 3 部分(分别对应 header, payload 和 verify signature),我们可以使用 jwt.io 对其进行解析,下面是解析出来的 payload 部分:
{
"iss": "Online JWT Builder",
"iat": 1657597191, // Tue Jul 12 2022 11:39:51 GMT+0800 (中国标准时间)
"exp": 1657683591, // Wed Jul 13 2022 11:39:51 GMT+0800 (中国标准时间)
"aud": "www.example.com",
"sub": "jrocket@example.com",
"GivenName": "Johnny",
"Surname": "Rocket",
"Email": "jrocket@example.com",
"Role": [
"Manager",
"Project Administrator"
]
}
请注意其中的 iat 和 exp 字段,它们分别代表 token 的颁发(issued at)和失效(expire)时间。那么失效后该怎么办呢? 一种简单粗暴的办法是直接 redirect 到登录页面让用户重新登录,这样就能获取一个新的 access token。但这并不是好的用户体验,因为它可能打断用户的 flow。试想如下场景:用户想连续做两个操作,先将一个待办事项标记为已完成再新建一个待办事项。碰巧的是第一个操作刚做完 access token 就失效了,这时再执行第二个操作就会失败并被 redirect 到登录页面。
很明显我们要有一种自动续签的机制,如果请求因为 token expire 失败那么就自动为用户申请一个新的 access token 并用新的 token 重试请求。自动续签需要有一个 refresh token,一般也可以在用户登录后拿到。Refresh token 通常也是 jwt 的格式且比 access token 的有效时间要长得多。和 access token 一样,refresh token 也需要被缓存起来,一般使用 localStorage,先为其定义 key:
export const LOCAL_STORAGE_KEY = {
ACCESS_TOKEN = "access_token",
REFRESH_TOKEN = "refresh_token"
}
假设下面函数可以 refresh access token:
export async function refreshAccess() {
const { data: { access_token } } = await axios.post<{ access_token: string }>("/access/refresh",{
refresh_token: localStorage.getItem(LOCAL_STORAGE_KEY.REFRESH_TOKEN)
});
localStorage.setItem(LOCAL_STORAGE_KEY.ACCESS_TOKEN, access_token);
return access_token
}
下面是用 Axios 拦截器实现自动续签的一个基础版本(假设 token expired 那么 API 的 reponse code 是 401,response body 中返回的 reason 是 expired):
axios.interceptors.response.use((response) => response, (error) => {
const originalRequest = error.config;
const errorCode = error.response?.status
// handle 401: Unauthorized
if (errorCode === 401) {
const reason = error.response?.data?.["reason"]
if (reason !== "expired" || originalRequest._retry) {
redirectToLogin()
return Promise.reject(error)
}
// token expired & not yet retried
originalRequest._retry = true;
// refresh access token & retry
return refreshAccess().then(token => {
return axios({
...originalRequest,
headers: {
...originalRequest?.headers,
Authorization: `Bearer ${token}`,
}
});
});
}
// nromal cases
return Promise.reject(error);
});
以上代码看起来没什么问题,但如果 expire 之时存在多个并发 API 请求(一个典型的例子是由卡片组成的 Dashboard,每个卡片都可能对应一个单独的 HTTP 请求),每一个请求都会被 retry,就会导致 refresh token 在短时间内被调用多次。虽然一些后端平台允许并能够承受这样的操作,但前端还是应该做优化尽量避免这样的情况,下面是我的一个解决方案:
const refreshStatus: {
isRefreshing: boolean,
pendingerExecutors: Array<(type: 'retry' | 'reject') => void>
} = {
isRefreshing: false,
pendingerExecutors: []
};
axios.interceptors.response.use((response) => response, (error) => {
const originalRequest = error.config;
const errorCode = error.response?.status
// handle 401: Unauthorized
if (errorCode === 401) {
const reason = error.response?.data?.["reason"]
if (reason !== "expired") {
redirectToLogin()
return Promise.reject(error)
}
if (!refreshStatus.isRefreshing) {
refreshStatus.isRefreshing = true
// token expired, try refresh token
refreshAccess()
.then(() => {
refreshStatus.pendingerExecutors.forEach(fn => fn('retry'))
})
.catch(err => {
// this usually means refresh token itself is expired
console.error(err)
refreshStatus.pendingerExecutors.forEach(fn => fn('reject'))
redirectToLogin()
})
.finally(() => {
refreshStatus.isRefreshing = false
refreshStatus.pendingerExecutors = []
})
}
// return a promise that will not resolve by itself
return new Promise((resolve, reject) => {
const curExecutor = (type: 'retry' | 'reject') => {
if (type === 'reject') {
reject(error)
} else {
resolve(
axios({
...originalRequest,
headers: {
...originalRequest?.headers,
Authorization: `Bearer ${retryToken}`,
},
})
)
}
}
refreshStatus.pendingerExecutors.push(curExecutor)
})
}
// normal cases
return Promise.reject(error);
});