手写 axios 库并发布至 npm 线上完整过程

10,836 阅读3分钟

前言

axios 是一个非常小巧的 HTTP 库,但是其中蕴含了许多值得学习的编程思想。今天就让我们一起来学透它。

本文将分为以下部分循序渐进地学习 axios 库:

  1. axios 简介
  2. axios 工程搭建
  3. axios 源码编写
  4. axios 打包发布

如学习本文对您有所帮助,请点个赞把~ ~ 。

axios 简介

axios 是一个基于 promise 的 HTTP 库,可以用在浏览器和 node.js 中。

特性

  • 从浏览器中创建 XMLHttpRequests ,从 node.js 创建 http 请求;
  • 支持 Promise API ;
  • 拦截请求和响应;
  • 转换请求数据和响应数据;
  • 取消请求;
  • 自动转换 JSON 数据;
  • 客户端支持防御 XSRF 。

axios 工程搭建

下面开始搭建自己的项目工程。

采用 ES6+ 语法以及 webpack 打包。

初始化项目:

1、git 上面创建项目 lion-axios
2、本地 git clone https://github.com/shiyou00/lion-axios.git
3、进入 cd lion-axios/
4、初始化项目 npm init -y  

配置 webpack

开发环境配置文件: webpack.dev.js 

const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');

module.exports = {
  mode: 'development', // 开发环境
  devtool: 'cheap-module-eval-source-map', // sourceMap用于错误调试
  devServer: {
    contentBase: './example', // 服务器启动根目录设置为example
    open: true, // 自动打开浏览器
    port: 8088, // 端口号
    hot: true // 开启热更新,同时要配置相应的插件HotModuleReplacementPlugin
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: 'public/index.html', // 使用模板文件,主要用于查看效果
      inject:'head' // 插入到 head 标签中
    }),
    new webpack.HotModuleReplacementPlugin() // 热更新插件
  ]
};

生产环境配置文件: webpack.pro.js 

module.exports = {
  mode: 'production' // 生产环境
};

公共配置文件: webpack.common.js 

const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const { merge } = require('webpack-merge');
const prodConfig = require("./webpack.pro"); // 引入生产环境配置
const devConfig = require("./webpack.dev"); // 引入开发环境配置

// 公共配置
const commonConfig = {
  entry: './src/axios.js',  // 打包入口文件
  output: {
    filename: 'axios.js', // 输出的文件名
    path: path.resolve(__dirname, 'dist'), // 输出的绝对路径
    library: 'axios', // 类库的命名空间,如果通过网页的方式引入,则可以通过window.axios访问它
    globalObject: 'this', // 定义全局变量,兼容node和浏览器运行,避免出现"window is not defined"的情况
    libraryTarget: "umd", // 定义打包方式Universal Module Definition,同时支持在CommonJS、AMD和全局变量使用
    libraryExport: 'default' // 对外暴露default属性,就可以直接调用default里的属性
  },
  module:{
    rules:[ // 配置 babel 的解析,同时在项目的跟目录下有.babelrc的babel配置文件
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loader: "babel-loader"
      }
    ]
  },
  plugins: [
    new CleanWebpackPlugin(), // 每次打包都先清理出口(dist)文件夹
  ]
};

module.exports = (env)=>{
  // 根据执行命令判断开发环境or生产环境,启用不同的配置文件
  if(env && env.production){
    return merge(commonConfig,prodConfig);
  }else{
    return merge(commonConfig,devConfig);
  }
}

.babelrc 配置:

{
  "plugins": [
    [
      "@babel/plugin-proposal-class-properties",
      {
        "loose": true
      }
    ],
    [
      "@babel/plugin-transform-runtime",
      {
        "absoluteRuntime": false,
        "corejs": 2,
        "helpers": true,
        "regenerator": true,
        "useESModules": false
      }
    ]
  ],
  "presets": [["@babel/preset-env"]]
}

package.json 增加 script 命令:

"scripts": {
  "dev": "webpack-dev-server --env.development --config webpack.common.js",
  "build": "webpack --env.production --config webpack.common.js"
},
  • 开发时执行 npm run dev 命令;
  • 打包时执行 npm run build 命令。

至此 axios 库工程的基础配置已经好了。

代码质量

以上配置能保证我们可以正确的把项目打包成类库了,但是企业级项目,代码质量管控也是非常重要的。

本项目将采用以下工具对代码质量进行管控:

  • editorConfig ,有助于维护跨多个编辑器和 IDE 从事同一项目的多个开发人员的一致编码风格。
  • ESLint ,可组装的 JavaScript 和 JSX 检查工具。
  • husky ,git 命令 hook 专用配置,它可以配置执行 git commit 等命令执行的钩子。
  • lint-staged ,可以在特定的 git 阶段执行特定的命令。
  • prettier ,代码统一格式化。
  • commitlint , git commit message 规范。

editorConfig

.editorconfig 配置:

root = true // 根级配置


[*] // 指定作用于所有文件
end_of_line = lf // 定义换行符 [lf | cr | crlf]
insert_final_newline = true // 文件是否以一个空白行结尾

[*.{js,html,css}] // 作用文件
charset = utf-8 // 编码格式

[*.js] // 作用文件
indent_style = space // 缩进类型
indent_size = 2 // 缩进大小

ESLint

# 安装
npm install eslint --D || yarn add eslint -D

# 初始化 eslint
./node_modules/.bin/eslint --init // 问答式一步一步操作即可

.eslintrc.js 配置:

module.exports = {
  env: { // env 关键字指定你想启用的环境
    browser: true,
    es2021: true,
    node: true,
  },
  parser: "babel-eslint", // 解析器
  extends: "eslint:recommended", // 继承的配置规则集
  parserOptions: {
    ecmaVersion: 12, // 指定你想要使用的 ECMAScript 版本
    sourceType: "module", // 启用 ESModule
  },
  rules: { // 规则 "off" = 关闭 "warn" = 警告 "error" = 报错
    strict: "off", // 严格模式,规则关闭
    "no-console": "off", // 禁用 console 对象方法,规则关闭
    "global-require": "off", // 要求 require() 出现在顶层模块作用域中,规则关闭
    "require-yield": "off", // 要求 generator 函数内有 yield,规则关闭
  },
};

husky + lint-staged + prettier

安装:

npm install husky lint-staged prettier --save-dev 
或
yarn add husky lint-staged prettier --dev

.huskyrc 配置:

{
  "hooks": {
    "pre-commit": "lint-staged"
  }
}

该配置表示执行 commit 之前,先执行 lint-staged 。

.lintstagedrc 配置:

{
  "*.{js,ts,jsx,tsx}": [
    "eslint --fix --quiet", // fix = 自动修复,quiet = ESLint报告错误
    "prettier --write" // 使用 prettier 进行格式化
  ],
  "*.css": "prettier --write",
  "*.html": "prettier --write",
  "*.md": "prettier --write",
  "*.json": "prettier --write"
}

该配置表示 lint-staged 命令集。

commitlint

安装:

npm install @commitlint/config-conventional @commitlint/cli --save-dev
或
yarn add @commitlint/config-conventional @commitlint/cli --dev

commitlint.config.js 配置:

module.exports = {
  extends: ['@commitlint/config-conventional']
};

.huskyrc 中增加配置:

{
  "hooks": {
+   "commit-msg": "commitlint -E HUSKY_GIT_PARAMS", // 表示对 commit message 进行 commitlint 检测
    "pre-commit": "lint-staged"
  }
}

全部配置好后,我们在 axios.js 中添加如下代码来测试下:

const a = 12;
console.log(a);

执行 git add . && git commit -m "test" ,看看输出效果:

QQ20201221-103629.gif

可以看到项目会进行 ESLint 以及 commitlint 检查,这里的 commit message 编写的有问题,我们修改正确就可以正常提交到 git 仓库了。

项目目录结构

├──README.md // 文档说明
├──node_modules // 依赖包文件夹
├──package.json
├──.babelrc // babel 配置文件
├──commitlint.config.js // commitlint 配置文件
├──.editorconfig // editorconfig 配置文件
├──.eslintrc.js // ESLint 配置文件
├──.huskyrc // husky 配置文件
├──.lintstagedrc // lint-staged 配置文件
├──webpack.common.js // webpack 公用配置
├──webpack.dev.js // webpack 开发环境配置
├──webpack.pro.js // webpack 生产环境配置
├──.gitignore // git 上传忽略配置
└──src
    └──axios.js // 库入口文件

点击查看本小结完整代码

axios 源码编写

axios 的很多设计思想值得我们借鉴引用,这也是我们手写 axios 库的主要学习目的之一。

axios 基础框架

编写 axios 的第一步,我们得先搭建一个简易的架子。然后一步一步完善其中的内容。

axios 库的调用方式:

# 调用 axios 的 get 方法发送请求,可以想象它还有 POSTPUTDELETEHTTP 协议支持的方法
axios.get('/user?ID=12345')
  .then(function (response) {
    console.log(response);
  })
  .catch(function (error) {
    console.log(error);
  });

# 调用 axios 的 request 方法发送请求
axios.request({
  url:"/user?ID=12345"
})

# 可以通过向 axios 传递相关配置来创建请求
axios({
  method: 'post',
  url: '/user/12345',
  data: {
    firstName: 'Fred',
    lastName: 'Flintstone'
  }
});

# 可以使用自定义配置新建一个 axios 实例
const instance = axios.create({
  baseURL: 'https://some-domain.com/api/',
  timeout: 1000,
  headers: {'X-Custom-Header': 'foobar'}
});
  1. 调用 axiosget 方法发送请求,不难想象它还有 POSTPUTDELETEHTTP 协议支持的方法。
  2. 调用 axiosrequest 方法发送请求。
  3. 可以通过向 axios 传递相关配置来创建请求。
  4. 可以使用自定义配置新建一个 axios 实例。

通过这些使用方式,我们首先搭建一个可以支持这样使用的 lion-axios 基础框架。

创建: src/axios.js  

1、创建 Axios 类

class Axios {
  // 用来存储配置信息。
  config = {};
  constructor(initConfig) {
  // 实例化时接收一个配置信息,并保存到config属性中。
    this.config = initConfig;
  }
  // 该类有一个 request 方法,它可以用来发送请求
  request(config){}
}

2、createInstance 方法

用于实例化 Axios 类。

function createInstance(initConfig) {
  // 创建 Axios 实例
  const context = new Axios(initConfig);
  // 变量 instance 保存了 Axios 类上的 request 方法,并使用上一步实例化的对象去接替该方法中的 this。
  const instance = Axios.prototype.request.bind(context);
  // 返回的其实是 request 方法
  return instance;
}

3、对外提供 axios 实例

// 默认参数对象,如果用户不传入 method,则默认为 git
const defaults = {
  method: 'get',
}

const axios = createInstance(defaults);

export default axios;

4、使用 axios

分析下,此时的 axios

  1. axios = createInstance(defaults);
  2. createInstance = Axios.prototype.request = request(config){} 

因此它支持 axios({...}); 这样调用了。

5、支持 axios.create 方法

axios.create = function (config) {
  // 合并默认配置与用户传入的配置,并作为 createInstance 的参数参入,
  // 目前合并config未实现。因此直接传了 default 去创建实例。
  // const initConfig = mergeConfig(config,defaults);
  return createInstance(defaults);
}

create 方法支持传入一个配置对象去创建一个新的 Axios 实例。

现在支持这样调用 axios 了:

const instance = axios.create({...});

6、支持 axios.request 与 axios.get | axios.post | axios.put 等形式调用

首先 Axios 类新增 get | post | delete 等方法

class Axios{
  config = {};
  constructor(initConfig) {
    this.config = initConfig;
  }
  request(config){}
  get(){}
  delete(){}
  head(){}
  options(){}
  post(){}
  put(){}
  patch(){}
}

instance 继承 Axios 类的方法及属性

实现 extend 方法继承 Axios 实例上的方法及属性:

function extend(to, from, ctx) {
  // 继承方法
  Object.getOwnPropertyNames( from ).forEach((key)=>{
    to[key] = from[key].bind(ctx);
  });
  // 继承 ctx 自身属性(不继承原型链上属性,因此需要 hasOwnProperty 进行判断)
  for(let val in ctx){
    if(ctx.hasOwnProperty(val)){
      to[val] = ctx[val];
    }
  }
  return to;
}

关于 class 实例方法继承有个小坑,详情查看ES6 Iterate over class methods

createInstance 中调用 extend 方法:

function createInstance(initConfig) {
  const context = new Axios(initConfig);

  const instance = Axios.prototype.request.bind(context);
  // 如果想 axios.request、axios.get 调用
  // 可以让 instance 继承 context 上的 request、get、post 等方法。
  extend(instance, context);	

  return instance;
}

这样一来 instance 就继承了 Axios 类上定义的属性及方法了。现在就可以直接这样调用了:

axios.get('/user?ID=12345')

axios.request({url:"/user?ID=12345"})

到此为止4种调用方式都已经支持了。并且 axios 库的基础框架也搭建好了。

点击查看本小结完整代码

发送真实请求

虽然我们已经可以通过各种方式调用 axios,但是那仅仅是调用它,并不能真正的向后台发送请求。

当我们使用 axios({...}) 与 axios.request({}) 调用时,实际都是在调用 Axios 类上的 request 方法。那么我们请求后台的任务就交给它来实现。

XHR

在浏览器端向后台发送请求,可以使用 fetchXMLHttpRequest。由于 fetch 羽翼尚未丰满,它甚至不支持取消请求的操作。因此我们在浏览器端选择 XMLHttpRequest

XMLHttpRequest

XMLHttpRequestXHR)对象用于与服务器交互。通过 XMLHttpRequest 可以在不刷新页面的情况下请求特定 URL,获取数据。这允许网页在不影响用户操作的情况下,更新页面的局部内容。XMLHttpRequest 在 AJAX 编程中被大量使用。

const xhr = (config)=>{
  // 解构config,data 如果不传默认为null,method 不传默认为 get方法,url是必传参数
  const {data = null,url,method = 'get'} = config;
  // 实例化 XMLHttpRequest
  const request = new XMLHttpRequest();
  // 初始化一个请求
  request.open(method.toUpperCase(),url,true);
  // 发送请求
  request.send(data);
}

class Axios {
  config = {};
  constructor(initConfig) {
    this.config = initConfig;
  }
  request(config) {
    xhr(config)
  }
}

通过简单实现 xhr 方法,并在 request 方法中调用它,我们就可以向后台发送真实的请求了。

axios({
  method: "post",
  url: "https://reqres.in/api/users",
  data: {
    "name": "frankshi",
    "job": "FE"
  },
});

reqres.in/ 这个网站提供了很多公共接口供我们使用,因此本文将使用它来模拟接口请求。

打开浏览器控制台,可以看到请求已经发送成功。

image.png

解析 param 参数

需求分析:

axios({
  method: 'get',
  url: '/base/get',
  params: {
    foo: ['bar', 'baz']
  }
})
参数为数组,最终请求的URL是 => /base?foo[]=bar&foo[]=baz

params: {
  foo: {
    bar: 'baz'
  }
}
参数为对象,最终请求的URL是 => /base?foo=%7B%22:%22baz%22%7D

params: {
  date:new Date()
}
参数为日期,最终请求的URL是 => /base?date=2020-12-24T08:00:00.000z

params: {
  foo: '@:$, '
}
参数为特殊字符,最终请求的URL是 => /base?foo=@:$+(空格转换成+)

params: {
  foo: 'bar',
  baz: null
}
参数为空值将忽略它,最终请求的URL是 => /base?foo=bar

axios({
  method: 'get',
  url: '/base/get#hash',
  params: {
    foo: 'bar'
  }
})
URL中包含hash值的,请求的时候将丢弃,最终请求的URL是 => /base?foo=bar

axios({
  method: 'get',
  url: '/base/get?foo=bar',
  params: {
    bar: 'baz'
  }
})
URL中已经包含参数的,请求的时候将拼接上去,最终请求的URL是 => /base?foo=bar&bar=baz

代码实现:

1、request 中增加一个方法来统一处理配置

request(config) {
  // 处理传入的配置
  processConfig(config);
  // 发送请求
  xhr(config)
}

2、在 processConfig 中转换 URL 参数

const buildURL = (url,params)=>{}

const transformURL = (config)=>{
  const { url, params } = config;
  return buildURL(url,params);
}

const processConfig = (config)=>{
  config.url = transformURL(config);
}

3、处理 params 的主体函数:


// 判断是否Date对象
function isDate(val) {
  return toString.call(val) === '[object Date]'
}
// 判断是否Object对象
function isPlainObject(val){
  return toString.call(val) === '[object Object]'
}
// 判断是否URLSearchParams对象实例
function isURLSearchParams(val) {
  return typeof val !== 'undefined' && val instanceof URLSearchParams
}

const buildURL = (url,params)=>{
  // 如果 params 参数为空,则直接返回原 URL
  if (!params) {
    return url
  }
  // 定义一个变量,用来保存最终拼接后的参数
  let serializedParams
  // 检测 params 是不是 URLSearchParams 对象类型
  if (isURLSearchParams(params)) {
    // 如果是(例如:new URLSearchParams(topic=api&foo=bar)),则 params 直接序列化输出
    serializedParams = params.toString()
  } else {
    // 如果不是则进入该主体运行
    // 定义一个数组
    const parts = [];
    // Object.keys 可以获取一个对象的所有key的数组,通过 forEach 进行遍历
    Object.keys(params).forEach(key => {
      // 获取每个key对象的val
      const val = params[key]
      // 如果 val 是 null,或者是 undefined 则终止这轮循环,进入下轮循环,这里就是忽略空值操作
      if (val === null || typeof val === 'undefined') {
        return
      }
      // 定义一个数组
      let values = []
      // 判断 val 是否是一个数组类型
      if (Array.isArray(val)) {
        // 是的话,values空数组赋值为 val,并且 key 拼接上[]
        values = val
        key += '[]'
      } else {
        // val 不是数组的话,也让它变为数组,抹平数据类型不同的差异,方便后面统一处理
        values = [val]
      }
      // 由于前面抹平差异,这里可以统一当做数组进行处理
      values.forEach(val => {
        // 如果 val 是日期对象,
        if (isDate(val)) {
          // toISOString返回Date对象的标准的日期时间字符串格式的字符串
          val = val.toISOString()
          // 如果 val 是对象类型的话,直接序列化
        } else if (isPlainObject(val)) {
          val = JSON.stringify(val)
        }
        // 处理结果推入数组
        parts.push(`${encode(key)}=${encode(val)}`)
      })
    })
    // 最后拼接数组
    serializedParams = parts.join('&')
  }

  if (serializedParams) {
    // 处理 hash 的情况
    const markIndex = url.indexOf('#')
    if (markIndex !== -1) {
      url = url.slice(0, markIndex)
    }
    // 处理,如果传入已经带有参数,则拼接在其后面,否则要手动添加上一个 ?
    url += (url.indexOf('?') === -1 ? '?' : '&') + serializedParams
  }
  // 输出完整的 URL
  return url

}

解析 body 数据

前面发送 body 数据是这样做的 request.send(data); 。而 data 正是我们传入的 data 数据:

axios({
  method: "post",
  url: baseURL,
  data: {
    "name": "frankshi",
    "job": "FE"
  },
});

直接传入的 data 是不能直接作为 send 函数的入参,需要把它转换成 JSON 字符串。

详见:MDN XMLHttpRequest send

实现:

// 该函数是对 data 进行序列化
const transformRequest = (data)=>{
  if (isPlainObject(data)) {
    return JSON.stringify(data)
  }
  return data
}

// 定义一个函数,它的职责是处理请求的数据
const transformRequestData = (config)=>{
  return transformRequest(config.data);
}
// processConfig 是前面定义的,用于处理传入的配置,它之前已经处理了URL,这里再增加处理data
const processConfig = (config)=>{
  config.url = transformURL(config);
  config.data = transformRequestData(config);
}

解析 header

一次 HTTP 请求中,header 扮演着非常重要的角色,它是客户端与服务端之间能互相理解对方的桥梁。因此我们的库也必须要保证可以正确传递并解析 header

默认添加上 Content-Type

axios({
  method: "post",
  url: baseURL,
  data: {
    "name": "frankshi",
    "job": "FE"
  },
});

当前端这样发送请求时,我们需要告诉服务器,我们发送的 data 的数据类型以便服务器可以正确解析。

因此需要如此配置:

axios({
  method: "post",
  url: baseURL,
  headers:{
   'content-type':"application/json;charset=utf-8"
  },
  data: {
    "name": "frankshi",
    "job": "FE"
  },
});

如果用户不添加 content-type ,我们需要为它自动添加上该配置。

接下来我们就来实现关于 headers 的逻辑:

const normalizeHeaderName = (headers, normalizedName)=> {
  if (!headers) {
    return
  }
  // 遍历所有 headers
  Object.keys(headers).forEach(name => {
    // 处理这种情况 如果 name 是 content-type,normalizedName 是 Content-Type,则统一使用 Content-Type
    // 并且删除 content-type。
    if (name !== normalizedName && name.toUpperCase() === normalizedName.toUpperCase()) {
      headers[normalizedName] = headers[name]
      delete headers[name]
    }
  })
}

 const processHeaders = (headers, data) => {
  normalizeHeaderName(headers, 'Content-Type')
  // 判断如果data数据是一个对象,则设置上'Content-Type'
  if (isPlainObject(data)) {
    if (headers && !headers['Content-Type']) {
      headers['Content-Type'] = 'application/json;charset=utf-8'
    }
  }
  return headers
}

const transformHeaders = (config)=>{
  const { headers = {}, data } = config;
  return processHeaders(headers,data);
}

const processConfig = (config)=>{
  config.url = transformURL(config);
  config.headers = transformHeaders(config);
  config.data = transformRequestData(config);
}

const xhr = (config)=>{
  let {data = null,url,method = 'get',headers={}} = config;
  const request = new XMLHttpRequest();
  request.open(method.toUpperCase(),url,true);
  // 遍历所有处理后的 headers
  Object.keys(headers).forEach(name => {
    // 如果 data 为空的话,则删除 content-type
    if (data === null && name.toLowerCase() === 'content-type') {
      delete headers[name]
    } else {
      // 给请求设置上 header
      request.setRequestHeader(name, headers[name])
    }
  })
  request.send(data);
}

响应请求

上面的请求,解析了 Request 请求的 parambodyheader。但是客户端并不能获取到后台响应的数据。接下来我们就来实现基于 Promise 的数据响应。

改造 xhr 函数

const xhr = (config)=>{
  // 所有的实现同构 new Promise 包裹起来
  return new Promise((resolve, reject)=>{
    let {data = null,url,method = 'get',headers={}, responseType} = config;
    
    const request = new XMLHttpRequest();
    request.open(method.toUpperCase(),url,true);
    // 判断用户是否设置了返回数据类型
    if (responseType) {
      request.responseType = responseType
    }
    // 监听 onreadystatechange 函数,接收后台返回数据
    request.onreadystatechange = () => {
      if (request.readyState !== 4) {
        return
      }

      if (request.status === 0) {
        return
      }
      // 返回的 header 是字符串类型,通过 parseHeaders 解析成对象类型
      const responseHeaders = parseHeaders(request.getAllResponseHeaders());
      const responseData =
        responseType && responseType !== 'text' ? request.response : request.responseText
      const response = {
        data: responseData,
        status: request.status,
        statusText: request.statusText,
        headers: responseHeaders,
        config,
        request
      }
      // 通过 resolve 返回数据
      resolve(response);
    }

    // 遍历所有处理后的 headers
    ... 省略了 headers 的处理
    request.send(data);
  })
}

这样写完之后,我们在提供给用户使用时,就可以写 Promise 语法了。

点击查看本小结完整代码

目录结构调整

现在有一个最大的问题就是,所有代码都集中在 axios.js 文件中,对于一个专业库来说,合理的文件目录划分是相当重要的。

优化后的目录结构图谱:

└──src
    └──axios.js // 入口文件
    └──core // 核心文件夹
        └──Axios.js // 存放 Axios 类
        └──dispatchRequest.js // 触发请求
    └──adapters // 适配器文件夹,axios 可以适配 node 中的 http,浏览器中的 xhr
        └──xhr.js // 浏览器 xhr 请求
        └──http.js // node http 请求,目前暂未实现
    └──helpers // 存放工具函数
    	└──data.js // 转换数据相关函数
    	└──headers.js // 处理 header 相关函数
    	└──url.js // 处理 url 相关函数
    	└──util.js // 通用工具函数

点击查看本小结完整代码

适配器

为了支持不同的环境,Axios 引入了适配器。在上小节整理目录的时候已经增加了 dispatchRequest.js 文件,它的主要职责是发送请求,适配器写在这里也最为合适。

dispatchRequest.js

// 默认适配器,判断是否有 XMLHttpRequest,来决定是否为浏览器环境,从而判断选择 xhr 还是 http 
const getDefaultAdapter = () => {
  let adapter;
  if (typeof XMLHttpRequest !== 'undefined') {
    // 浏览器
    adapter = require("../adapters/xhr");
  } else if (typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]') {
    // node.js
    adapter = require("../adapters/http");
  }
  return adapter;
}

const dispatchRequest = (config) => {
  // 如果用户传入了适配器则使用用户的,否则使用默认的适配器(后期会提取到默认配置文件)
  const adapter = config.adapter || getDefaultAdapter();

  // 处理传入的配置
  processConfig(config);
  // 发送请求
  return adapter(config).then((res) => transformResponseData(res));
};

适配器的原理不复杂,axios 就是通过这个函数抹平了浏览器和 node.js 环境的使用差异,做到用户无感知的。这里有个细节那就是 adapter = require("../adapters/*") ,为什么这里又使用了 CommonJSrequire 呢?

是的,作者把这里 xhrhttp的导出改为了 module.exports 的形式:

module.exports = function httpAdapter (config) {
  console.log("httpAdapter",config);
}

为什么使用 require 而不使用 import 呢?

是因为 CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。

点击查看本小结完整代码

完善 Axios 类方法

之前 Axios 类中定义了很多方法,但是都没有具体实现,现在 request 方法已经实现了,其它方法实现起来也就相对简单。

Axios.js

import dispatchRequest from "./dispatchRequest";

class Axios {
  config = {};

  constructor(initConfig) {
    this.config = initConfig;
  }

  request(config) {
    return dispatchRequest(config);
  }

  get(url,config) {
    return this._requestMethodWithoutData('get',url,config)
  }

  delete(url,config) {
    return this._requestMethodWithoutData('delete', url, config)
  }

  head(url,config) {
    return this._requestMethodWithoutData('head', url, config)
  }

  options(url,config) {
    return this._requestMethodWithoutData('head', url, config)
  }

  post(url,data,config) {
    return this._requestMethodWithData('post', url, data, config)
  }

  put(url,data,config) {
    return this._requestMethodWithData('put', url, data, config)
  }

  patch(url,data,config) {
    return this._requestMethodWithData('patch', url, data, config)
  }
  // 通用不带Data的调用方法,其本质就是调用 require 方法
  _requestMethodWithoutData(
    method,
    url,
    config
  ) {
    return this.request(
      Object.assign(config || {}, {
        method,
          url
      })
    )
  }
  // 通用带Data调用方法
  _requestMethodWithData(
    method,
    url,
    data,
    config
  ) {
    // 合并参数
    return this.request(
      Object.assign(config || {}, {
        method,
        url,
        data
      })
    )
  }
}

export default Axios;

如此完善后,我们就可以通过 axios.get()axios.post() 等方式调用了。

点击查看本小结完整代码

拦截器

拦截器简介

在请求或响应被 then 或 catch 处理前拦截它们。

// 添加请求拦截器
axios.interceptors.request.use(function (config) {
    // 在发送请求之前做些什么
    return config;
  }, function (error) {
    // 对请求错误做些什么
    return Promise.reject(error);
  });

// 添加响应拦截器
axios.interceptors.response.use(function (response) {
    // 对响应数据做点什么
    return response;
  }, function (error) {
    // 对响应错误做点什么
    return Promise.reject(error);
  });
  • 请求拦截器:它的作用是在请求发送前统一执行某些操作,比如在请求头中添加 token 字段。
  • 响应拦截器:它的作用是在接收到服务器响应后统一执行某些操作,比如发现响应状态码为 401 时,自动跳转到登录页。

此外我们还可以删除某个拦截器:

const myInterceptor = axios.interceptors.request.use(function () {/*...*/});
axios.interceptors.request.eject(myInterceptor);

到此,你会发现其实拦截器非常像 reduxkoa2 的中间件。它会按照你的添加顺序,先去执行request拦截器,然后执行真实请求,最后再执行 response 拦截器。

image.png

拦截器实现

创建拦截器管理类: core/InterceptorManager.js

export default class InterceptorManager{
  // 定义一个数组,用来存储拦截器
  interceptors = [];

  use(resolved, rejected) {
    // 向数组推入拦截器对象
    this.interceptors.push({
      resolved,
      rejected
    })
    // 返回拦截器在数组中索引
    return this.interceptors.length - 1
  }
  // 遍历数组
  forEach(fn) {
    this.interceptors.forEach(interceptor => {
      if (interceptor !== null) {
        fn(interceptor)
      }
    })
  }
  // 根据索引删除拦截器
  eject(id) {
    if (this.interceptors[id]) {
      this.interceptors[id] = null
    }
  }
}

这个拦截器类其实就是一个数组并且实现了 use、forEach、eject 3个方法去操作它。

接下来要实现拦截器的链式调用:core/Axios.js

class Axios {
  config = {};
  // 定义一个拦截器对象
  interceptors = {};

  constructor(initConfig) {
    this.config = initConfig;
    // 拦截器对象中包含:request拦截器以及response拦截器
    this.interceptors = {
      request: new InterceptorManager(),
      response: new InterceptorManager()
    }
  }

  request(config) {
    // 定义一个数组,数组中放入,会发送真实请求的对象,可以想象成它也是一个拦截器
    const chain = [
      {
        resolved: dispatchRequest,
        rejected: undefined
      }
    ]
    // 当用户使用 axios.interceptors.request.use(...) 推入了多个请求拦截器时
    // this.interceptors.request 这里面就有多个拦截器,通过遍历拦截器,插入 chain 数组的前面
    this.interceptors.request.forEach(interceptor => {
      chain.unshift(interceptor)
    })
    // 当用户使用 axios.interceptors.response.use(...) 推入多个响应拦截器时
    // this.interceptors.response 这里面就有多个拦截器,通过遍历拦截器,插入 chain 数组的后面
    this.interceptors.response.forEach(interceptor => {
      chain.push(interceptor)
    })
    
    // 此时的 chain 应该是这样的
    /*
    [
    	{
      	resolved:(config)=>{...}, // 用户自定义请求拦截器
        rejected:(config)=>{...}
      },
      ...
      {
        resolved: dispatchRequest,
        rejected: undefined
      },
      ...
      {
      	resolved:(res)=>{...}, // 用户自定义响应拦截器
        rejected:(res)=>{...}
      },
    ]
    */
		
    let promise = Promise.resolve(config)
    // 如果 chain 数组中有值就进入循环遍历
    while (chain.length) {
      // 每次取出数组的第一个元素,并从数组中删除
      const { resolved, rejected } = chain.shift();
      // promise 复制为下一次 promise.then,实现拦截器链式传递
      promise = promise.then(resolved, rejected)
    }
    // 最终全部执行完成之后,返回最后的执行结果
    return promise
  }
}

拦截器的实现思想非常值得借鉴,它的实现并不算复杂,巧妙的应用了 promise 不断传递,让 axios 具备了类似“中间件”的功能。

点击查看本小结完整代码

默认配置

默认配置使用介绍

全局的 axios 默认值

axios.defaults.baseURL = 'https://api.example.com';
axios.defaults.headers.common['Authorization'] = AUTH_TOKEN;
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded';

自定义实例默认值

// Set config defaults when creating the instance
const instance = axios.create({
  baseURL: 'https://api.example.com'
});
// Alter defaults after instance has been created
instance.defaults.headers.common['Authorization'] = AUTH_TOKEN;

默认配置实现

创建 defaults 配置文件

src/defaults.js

const defaults = {
  method: 'get', // 默认不传入 method 则给一个 GET 方法

  timeout: 0, // 默认不设置超时

  headers: {
    common: {
      Accept: 'application/json, text/plain, */*' // 默认给一个 Accept header
    }
  }
}
// 'delete', 'get', 'head', 'options' 这四种类型请求时默认 headers 为空
const methodsNoData = ['delete', 'get', 'head', 'options']

methodsNoData.forEach(method => {
  defaults.headers[method] = {}
})

// 'post', 'put', 'patch' 这三种类型请求时设置一个默认的 Content-Type
const methodsWithData = ['post', 'put', 'patch']

methodsWithData.forEach(method => {
  defaults.headers[method] = {
    'Content-Type': 'application/x-www-form-urlencoded'
  }
})

export default defaults

axios 中进行配置合并

Axios.js

...
import mergeConfig from "./mergeConfig";

class Axios {
  defaults = {};

  constructor(initConfig) {
    this.defaults = initConfig;
    ...
  }

  request(url, config) {
    if (typeof url === "string") {
      if (!config) {
        config = {};
      }
      config.url = url;
    } else {
      config = url;
    }
    // 合并默认配置与用户传进来的配置
    config = mergeConfig(this.defaults, config)

    ...
  }
}

假设有如下两个配置文件需要合并:

const defaults = {
  method: 'get',
  timeout: 0,
  headers: {
    common: {
      Accept: 'application/json, text/plain, */*';
    }
    post: {
      'Content-Type': 'application/x-www-form-urlencoded'
    }
  }
}

const customConfig = {
  url: "/post",
  method: "post",
  data:{a:1},
  headers:{
    test: "123"
  }
}

只要 customConfig 中有的配置优先使用用户自己的配置,否则才去使用默认配置。

合并配置的具体实现:src/core/mergeConfig.js

import { deepMerge, isPlainObject } from '../helpers/util'
// 创建策略对象
const strats = Object.create(null)
// 默认策略:val2即用户配置不为空则使用用户配置,否则使用val1默认配置
function defaultStrat(val1, val2) {
  return typeof val2 !== 'undefined' ? val2 : val1
}
// 'url', 'params', 'data' 这三种属性,只使用用户配置
function fromVal2Strat(val1, val2) {
  if (typeof val2 !== 'undefined') {
    return val2
  }
}
// 深度合并配置,适用于配置本身是对象的情况
// deepMerge 方法就是对象的深度拷贝的方法,这里就不再陈列赘述了
function deepMergeStrat(val1, val2) {
  if (isPlainObject(val2)) {
    return deepMerge(val1, val2)
  } else if (typeof val2 !== 'undefined') {
    return val2
  } else if (isPlainObject(val1)) {
    return deepMerge(val1)
  } else {
    return val1
  }
}
// 使用用户配置的三种属性
const stratKeysFromVal2 = ['url', 'params', 'data']

stratKeysFromVal2.forEach(key => {
  strats[key] = fromVal2Strat
})
// 需要深度合并的两种属性
const stratKeysDeepMerge = ['headers', 'auth']

stratKeysDeepMerge.forEach(key => {
  strats[key] = deepMergeStrat
})

export default function mergeConfig(
  config1,
  config2
) {
  if (!config2) {
    config2 = {}
  }
  // 创建一个空对象,用来存储最终合并好的配置文件
  const config = Object.create(null)
  // 遍历用户配置,mergeField方法就是根据属性选择到不同的策略,进行合并配置
  for (let key in config2) {
    mergeField(key)
  }
  // 遍历默认配置,并且该配置没有在用户配置中出现的
  for (let key in config1) {
    if (!config2[key]) {
      mergeField(key)
    }
  }
  // 这里采用的是设计模式中的“策略模式”,有效的剔除了代码中无限个 if else 的情况
  function mergeField(key) {
    const strat = strats[key] || defaultStrat
    config[key] = strat(config1[key], config2[key])
  }
  // 导出最终的config
  return config
}

这样一来我们就实现了 axios 的配置合并,这里的代码比较复杂,处理的情况较多,因此如果想要完全理解这块代码需要实际去运行代码,能理解这里的核心思想,使用策略模式消除了代码中的 if...else 是最为重要的。

点击查看本小结完整代码

取消功能

取消请求需求分析

使用 cancel token 取消请求,可以使用 CancelToken.source 工厂方法创建 cancel token,像这样:

const CancelToken = axios.CancelToken;
const source = CancelToken.source();

axios.get('/user/12345', {
  cancelToken: source.token
}).catch(function(thrown) {
  if (axios.isCancel(thrown)) {
    console.log('Request canceled', thrown.message);
  } else {
     // 处理错误
  }
});

axios.post('/user/12345', {
  name: 'new name'
}, {
  cancelToken: source.token
})

// 取消请求(message 参数是可选的)
source.cancel('Operation canceled by the user.');

还可以通过传递一个 executor 函数到 CancelToken 的构造函数来创建 cancel token

const CancelToken = axios.CancelToken;
let cancel;

axios.get('/user/12345', {
  cancelToken: new CancelToken(function executor(c) {
    // executor 函数接收一个 cancel 函数作为参数
    cancel = c;
  })
});

// cancel the request
cancel();

注意: 可以使用同一个 cancel token 取消多个请求。

取消请求实现

仔细看看它的使用方式:

const CancelToken = axios.CancelToken;
const source = CancelToken.source();

axios.post('/user/12345', {
  name: 'new name'
}, {
  cancelToken: source.token
})

source.cancel('Operation canceled by the user.');

执行 source.cancel('...') 时,就可以取消请求。实际实现取消请求可以通过如下代码:

const request = new XMLHttpRequest();
request.abort();

也就是 xhr.js 中通过执行 abort 方法取消请求,但是 request 对象并没有把它暴露出来。我们该如何实现联动呢?

通过阅读源码我们知道,通过 promise 来实现的联动。也就是说当执行 source.cancel('...') 时,实际上是去改变 promise 的状态。

xhr.js 中只要去监听该 promise 的状态即可:

if (cancelToken) {
  cancelToken.promise
    .then(reason => {
    request.abort()
    reject(reason)
  })
    .catch(() => {})
}

现在来实现它的核心类 src/cancel/CancelToken.js

class Cancel {
  message;

  constructor(message) {
    this.message = message
  }
}
// 判断抛出的错误是不是 Cancel 实例
function isCancel(value) {
  return value instanceof Cancel
}

export default class CancelToken {
  promise;// 定义 promise 变量,用来存储状态
  reason; // 定义错误原因变量

  constructor(executor) {
    // 定义一个空变量,用它来存储一个 promise 实例的 resolve 方法
    let resolvePromise;
    this.promise = new Promise(resolve => {
      resolvePromise = resolve
    })

    const paramFn = message => {
      if (this.reason) {
        return
      }
      this.reason = new Cancel(message)
      resolvePromise(this.reason)
    };
    // 执行实例化时传入的方法,并使用 paramFn 作为参数传入
    executor(paramFn)
  }

  throwIfRequested() {
    if (this.reason) {
      throw this.reason
    }
  }
  // 定义 source 静态方法,导出 CancelToken 实例以及取消方法 cancel
  static source() {
    let cancel;
    const token = new CancelToken(c => {
      cancel = c
    })
    return {
      cancel,
      token
    }
  }
}

最后在 axios.js 中添加相应的方法:

axios.CancelToken = CancelToken
axios.Cancel = Cancel
axios.isCancel = isCancel

最后我们来分析下调用过程:

1、执行 source.cancel('...'); 相当于执行 paramFn 方法。

2、执行 paramFn 方法时,通过 resolvePromise(this.reason) 改变 this.promiseresolve 状态。

3、在 xhr.js 中,通过 cancelToken.promise.then(reason => {request.abort()}),监听了 promise 的状态,当发生改变时,就对该请求进行取消。

点击查看本小结完整代码

withCredentials

在同域情况下,发送请求会默认携带当前域下的 cookie,但是跨域的情况下,默认是不会携带请求域下的 cookie,如果想携带,只需要设置请求的 xhr 对象的 withCredentialstrue 即可。

这个实现起来非常简单,只需要在 xhr.js 中添加这么一段代码:

module.exports = function xhrAdapter(config) {
  return new Promise((resolve, reject) => {
    let {
      ...
      withCredentials
    } = config;

    const request = new XMLHttpRequest();
    ...
    
    // 设置 xhr 对象的 withCredentials 属性即可
    if(withCredentials){
      request.withCredentials = withCredentials;
    }
    
    ...
    request.send(data);
  });
};

点击查看本小结完整代码

CSRF 防御

跨站请求伪造Cross-site request forgery),通常缩写为 CSRF 或者 XSRF, 是一种挟制用户在当前已登录的 Web 应用程序上执行非本意的操作的攻击方法。

一个典型的 CSRF 攻击有着如下的流程:

  1. 受害者登录 a.com,并保留了登录凭证(Cookie);
  2. 攻击者引诱受害者访问了 b.com
  3. b.coma.com 发送了一个请求:a.com/act=xx。浏览器会默认携带 a.comCookie
  4. a.com 接收到请求后,对请求进行验证,并确认是受害者的凭证,误以为是受害者自己发送的请求;
  5. a.com 以受害者的名义执行了 act=xx
  6. 攻击完成,攻击者在受害者不知情的情况下,冒充受害者,让 a.com 执行了自己定义的操作。

如果对于 CSRF 防御不熟悉的同学也可以阅读作者的另外一篇文章:前端安全(同源策略、XSS攻击、CSRF攻击)

axios 是如何防御 CSRF

采用验证 Token 的方式来防御 CSRF 攻击,它的防御过程如下:

  1. 在浏览器向服务器发起请求时,服务器生成一个 CSRF Token。并通过 set-cookie 的方式种到客户端。
  2. 客户端发送请求时,从 cookie 对应字段中读取出 token,然后添加到请求 headers 中。
  3. 服务器解析请求 headers,并验证 token

由于这个 token 是很难伪造的,所以就能区分这个请求是否是用户正常发起的。

因此我们的 axios 库要自动把这几件事情做了,每次发送请求的时候,从 cookie 中读取对应的 token 值,然后添加到请求 headers 中,我们允许用户配置 xsrfCookieNamexsrfHeaderName,其中 xsrfCookieName 表示存储 tokencookie 名称,xsrfHeaderName 表示请求 headerstoken 对应的 header 名称。

axios({
  url:"/",
  xsrfCookieName: 'XSRF-TOKEN', // 默认配置
  xsrfHeaderName: 'X-XSRF-TOKEN' // 默认配置
})

axios 实现 CSRF 防御

它的核心实现是在 xhr.js 中:

module.exports = function xhrAdapter(config) {
  return new Promise((resolve, reject) => {
    let {
      ...
      withCredentials,
      xsrfCookieName,
      xsrfHeaderName
    } = config;

    const request = new XMLHttpRequest();
    ...
    
    // 设置 xhr 对象的 withCredentials 属性即可
    if(withCredentials){
      request.withCredentials = withCredentials;
    }
    // 判断如果是配置 withCredentials 为 true,或者是同域请求并且 xsrfCookieName 存在
    // 才会在请求 headers 添加 xsrf 相关字段。
    if((withCredentials || isURLSameOrigin(url)) && xsrfCookieName ){
      // 通过cookie 去读取对应的 xsrfHeaderName 值
      const xsrfValue = cookie.read(xsrfCookieName);
      if(xsrfValue && xsrfHeaderName){
        headers[xsrfHeaderName] = xsrfValue;
      }
    }
    
    ...
    request.send(data);
  });
};

关于 CSRF 防御的实现并不复杂,但是通过实现它,可以让我们彻底掌握 CSRF 从产生原因到如何防御。

点击查看本小结完整代码

到此为止,关于手写 axios 的核心已经完成了,基本上涵盖了 axios 重要的知识点。相信您和作者一样可以收获良多,接下来我们把它打包发到 npm 线上把。

axios 打包发布

1、由于之前我们已经写好了 webpack 配置,因此只需要直接执行打包命令 npm run build 。 2、package.json 优化

{
  "name": "lion-axios", // 包名称
  "version": "1.0.0", // 版本
  "description": "lion-axios", // 描述
  "scripts": {
    "clean": "rimraf ./dist",
    "dev": "webpack-dev-server --env.development --config webpack.common.js",
    "build": "npm run clean && webpack --env.production --config webpack.common.js",
    "prepublishOnly": "npm run build" // 执行 npm publish 之前会自动执行打包
  },
  "repository": { // 代码托管信息
    "type": "git",
    "url": "git+https://github.com/shiyou00/lion-axios.git"
  },
  "files": [
    "dist" // 项目上传至npm服务器的文件,可以是单独的文件、整个文件夹,或者通配符匹配到的文件。
  ],
  "main": "dist/axios.js", // 入口文件
  "keywords": ["lion-axios"], // 关键词
  "author": "Lion", // 作者
  "license": "ISC", // 协议
  "bugs": {
    "url": "https://github.com/shiyou00/lion-axios/issues" // 提 bug 地址
  },
  "homepage": "https://github.com/shiyou00/lion-axios#readme", // 主页地址
}

3、登录注册 npm

# 查看是否登录
npm whoami

# 注册
npm adduser
Username:shiyou
Email:(xxx@xxx.com)

# 登录
npm login
然后填写注册信息即可

4、执行 npm publish 发布

查看 lion-axios 包线上地址

5、使用 lion-axios

# 安装
yarn add lion-axios 或 npm install lion-axios --save

# 引入
import axios from 'lion-axios';

# 使用
const baseURL = "https://reqres.in/api/users";
axios({
  method: "get",
  url: `${baseURL}?foo=bar`,
  params: {
    bar: "baz1",
  },
}).then((res) => {
  console.log(res);
});

总结

  1. 通过 axios 简介,使我们对 axios 有一个初步的理解,这里还是建议大家去过一遍官方文档。
  2. 通过搭建 axios 项目工程,可以学习到如何搭建一个企业级库的工程项目。
  3. 通过一步一步实现 axios 源码,可以学习到很多精妙的编程思想,这也是实现一个开源库的重要目的。
  4. 最后我们把实现好的库,打包发布到 npm 线上,完成了一个开源库的完整生命周期。

如果公司需要你手动搭建一个开源库,是不是已经胸有成竹了。

如果学习本文给您带来了帮助,那就点个赞把~ ~。