前言
axios
是一个非常小巧的 HTTP
库,但是其中蕴含了许多值得学习的编程思想。今天就让我们一起来学透它。
本文将分为以下部分循序渐进地学习 axios
库:
axios
简介axios
工程搭建axios
源码编写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"
,看看输出效果:
可以看到项目会进行 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 方法发送请求,可以想象它还有 POST,PUT,DELETE 等 HTTP 协议支持的方法
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'}
});
- 调用
axios
的get
方法发送请求,不难想象它还有POST
,PUT
,DELETE
等HTTP
协议支持的方法。 - 调用
axios
的request
方法发送请求。 - 可以通过向
axios
传递相关配置来创建请求。 - 可以使用自定义配置新建一个
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
:
axios = createInstance(defaults);
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
在浏览器端向后台发送请求,可以使用 fetch
或 XMLHttpRequest
。由于 fetch
羽翼尚未丰满,它甚至不支持取消请求的操作。因此我们在浏览器端选择 XMLHttpRequest
。
XMLHttpRequest
XMLHttpRequest(XHR
)对象用于与服务器交互。通过 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/ 这个网站提供了很多公共接口供我们使用,因此本文将使用它来模拟接口请求。
打开浏览器控制台,可以看到请求已经发送成功。
解析 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
字符串。
实现:
// 该函数是对 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
请求的 param
、body
、header
。但是客户端并不能获取到后台响应的数据。接下来我们就来实现基于 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/*")
,为什么这里又使用了 CommonJS
的 require
呢?
是的,作者把这里 xhr
与 http
的导出改为了 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);
到此,你会发现其实拦截器非常像 redux
、koa2
的中间件。它会按照你的添加顺序,先去执行request
拦截器,然后执行真实请求,最后再执行 response
拦截器。
拦截器实现
创建拦截器管理类:
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.promise
为 resolve
状态。
3、在 xhr.js
中,通过 cancelToken.promise.then(reason => {request.abort()})
,监听了 promise
的状态,当发生改变时,就对该请求进行取消。
withCredentials
在同域情况下,发送请求会默认携带当前域下的 cookie
,但是跨域的情况下,默认是不会携带请求域下的 cookie
,如果想携带,只需要设置请求的 xhr
对象的 withCredentials
为 true
即可。
这个实现起来非常简单,只需要在 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
攻击有着如下的流程:
- 受害者登录
a.com
,并保留了登录凭证(Cookie
); - 攻击者引诱受害者访问了
b.com
; b.com
向a.com
发送了一个请求:a.com/act=xx
。浏览器会默认携带a.com
的Cookie
;a.com
接收到请求后,对请求进行验证,并确认是受害者的凭证,误以为是受害者自己发送的请求;a.com
以受害者的名义执行了act=xx
;- 攻击完成,攻击者在受害者不知情的情况下,冒充受害者,让
a.com
执行了自己定义的操作。
如果对于 CSRF
防御不熟悉的同学也可以阅读作者的另外一篇文章:前端安全(同源策略、XSS攻击、CSRF攻击)
axios 是如何防御 CSRF
采用验证 Token
的方式来防御 CSRF
攻击,它的防御过程如下:
- 在浏览器向服务器发起请求时,服务器生成一个
CSRF Token
。并通过set-cookie
的方式种到客户端。 - 客户端发送请求时,从
cookie
对应字段中读取出token
,然后添加到请求headers
中。 - 服务器解析请求
headers
,并验证token
。
由于这个 token
是很难伪造的,所以就能区分这个请求是否是用户正常发起的。
因此我们的 axios
库要自动把这几件事情做了,每次发送请求的时候,从 cookie
中读取对应的 token
值,然后添加到请求 headers
中,我们允许用户配置 xsrfCookieName
和 xsrfHeaderName
,其中 xsrfCookieName
表示存储 token
的 cookie
名称,xsrfHeaderName
表示请求 headers
中 token
对应的 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
发布
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);
});
总结
- 通过
axios
简介,使我们对axios
有一个初步的理解,这里还是建议大家去过一遍官方文档。 - 通过搭建
axios
项目工程,可以学习到如何搭建一个企业级库的工程项目。 - 通过一步一步实现
axios
源码,可以学习到很多精妙的编程思想,这也是实现一个开源库的重要目的。 - 最后我们把实现好的库,打包发布到
npm
线上,完成了一个开源库的完整生命周期。
如果公司需要你手动搭建一个开源库,是不是已经胸有成竹了。
如果学习本文给您带来了帮助,那就点个赞把~ ~。