HomeGithub

使用 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

上图为: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 呢? 个人认为是下面这些优势:

  1. Learn once, use everywhere

Axios 既可以在浏览器也可以在 Node.js 环境中使用并且语法一致。这相当于抹平了不同运行时的语法差异,对于全栈开发者或者来说降低了学习和维护难度。

  1. Axios 允许创建多个实例(instance)

fetch 是典型的单例模式,在任何时候只有一个实例。Axios 也可以作为单例使用,但是它允许创建多个实例以应对复杂场景。比如一个前端项目可能会对接多个不相干的后端服务并且它们对请求格式的要求不同,这时可以创建多个 Axios 实例分别对应这些后端服务。

  1. 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'
  1. Axios 自带拦截器(interceptor)

拦截器可以拦截 request/response 并做一些通用处理,这在某些情况下非常有用(后文会举例说明)。

  1. 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);
  });
  1. 其他功能:比如自带 timeout, 更简单易用的请求取消机制等

Axios 封装示例

不同项目对请求层的要求几乎都不同,假设项目中对于 HTTP 请求层的需求如下:

有了具体的需求就可以着手封装了。首先定义一些类型(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);
});

参考资料

  1. Wikipedia: AJAX
  2. Wikipedia: JSON Web Token