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

936

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

前言

Bug在手,天下我有(bushi)⭐hello大家好呀,最近在系统学习TS,作为程序员只有理论知识的积累是不够的,苦于TS一堆枯燥乏味的知识点,想找个项目练练手锻炼一下实践能力?马上安排!

熟练使用axios在前端开发过程中已经是必备技能了,但是很多开发者对于axios的原理还不是很理解,接下来我将分几篇文章来带你用TypeScript从零构建一个axios,既能巩固自己的TS基本功,又能从源码层面剖析axios的实现,再也不用害怕面试被问axios的源码啦~

该项目TS知识点会涉及如下👇

TS.png

axios库的功能架构如下👇

axios.png

🆕你会收货什么?

  • 学会TS开发实际项目
  • 学会造轮子,并学会写单元测试
  • 学会使用先进的前端工具辅助开发
  • 完全掌握axios的实现原理

需求分析

重构之前,我们需要简单地做一些需求分析,看一下我们这次重构需要支持哪些 feature。

  • 在浏览器端使用 XMLHttpRequest 对象通讯
  • 支持 Promise API
  • 支持请求和响应的拦截器
  • 支持请求数据和响应数据的转换
  • 支持请求的取消
  • JSON 数据的自动转换
  • 客户端防止 CSRF

此外,我们还会支持一些 axios 库支持的一些其它的 feature。这里要注意的,我们这次重构不包括 axios 在 Node 中的实现,因为这部分我们在平时项目中应用的很少,还涉及到很多 Node.js 的知识,如果都讲的话,一是比较占用时间,另一个可能会喧宾夺主了。

那么接下来我们就开始初始化项目吧!

初始化项目

创建代码仓库

接下来,我们开始初始化项目,首先我们先去 GitHub 上创建一个仓库,填好仓库名称,以及写一下 README,对项目先做个简单的描述。

通常我们初始化一个项目,需要配置一大堆东西,比如 package.json.editorconfig.gitignore 等;还包括一些构建工具如 rollupwebpack 以及它们的配置。

当我们使用 TypeScript 去写一个项目的时候,还需要配置 TypeScript 的编译配置文件 tsconfig.json 以及 tslint.json 文件。

这些茫茫多的配置往往会让一个想从零开始写项目的同学望而却步,如果有一个脚手架工具帮我们生成好这些初始化文件该多好。好在确实有这样的工具,接下来我们的主角 TypeScript library starter 隆重登场。

TypeScript library starter

它是一个开源的 TypeScript 开发基础库的脚手架工具,可以帮助我们快速初始化一个 TypeScript 项目,我们可以去它的官网地址学习和使用它。

使用方式

git clone https://github.com/alexjoverm/typescript-library-starter.git ts-axios
cd ts-axios

npm install

先通过 git clone 把项目代码拉下来到我们的 ts-axios 目录,然后运行 npm install 安装依赖,并且给项目命名,我们仍然使用 ts-axios

安装好依赖后,我们先来预览一下这个项目的目录结构。

目录文件介绍

TypeScript library starter 生成的目录结构如下:

├── CONTRIBUTING.md
├── LICENSE 
├── README.md
├── code-of-conduct.md
├── node_modules
├── package-lock.json
├── package.json
├── rollup.config.ts // rollup 配置文件
├── src // 源码目录
├── test // 测试目录
├── tools // 发布到 GitHub pages 以及 发布到 npm 的一些配置脚本工具
├── tsconfig.json // TypeScript 编译配置文件
└── tslint.json // TypeScript lint 文件

优秀工具集成

使用 TypeScript library starter 创建的项目集成了很多优秀的开源工具:

  • 使用 RollupJS 帮助我们打包。
  • 使用 PrettierTSLint 帮助我们格式化代码以及保证代码风格一致性。
  • 使用 TypeDoc 帮助我们自动生成文档并部署到 GitHub pages。
  • 使用 Jest帮助我们做单元测试。
  • 使用 Commitizen帮助我们生成规范化的提交注释。
  • 使用 Semantic release帮助我们管理版本和发布。
  • 使用 husky帮助我们更简单地使用 git hooks。
  • 使用 Conventional changelog帮助我们通过代码提交信息自动生成 change log。

这里我们列举了很多工具,感兴趣的同学们可以点开他们的链接对这些工具做进一步学习。

NPM Scripts

TypeScript library starter 同样在 package.json 中帮我们配置了一些 npm scripts,接下来我们先列举一下我们开发中常用的 npm scripts,剩余的我们在之后学习中遇到的时候再来介绍。

  • npm run lint: 使用 TSLint 工具检查 srctest 目录下 TypeScript 代码的可读性、可维护性和功能性错误。
  • npm start: 观察者模式运行 rollup 工具打包代码。
  • npm test: 运行 jest 工具跑单元测试。
  • npm run commit: 运行 commitizen 工具提交格式化的 git commit 注释。
  • npm run build: 运行 rollup 编译打包 TypeScript 代码,并运行 typedoc 工具生成文档。

然后就是关联本地仓库。至此,我们项目已经初始化完毕,接下来我们就开始编写源码实现 axios 了。

编写基础请求代码

我们这节开始编写 ts-axios 库,我们的目标是实现简单的发送请求功能,即客户端通过 XMLHttpRequest 对象把请求发送到 server 端,server 端能收到请求并响应即可。

我们实现 axios 最基本的操作,通过传入一个对象发送请求,如下:

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

创建入口文件

我们删除 src 目录下的文件,先创建一个 index.ts 文件,作为整个库的入口文件,然后我们先定义一个 axios 方法,并把它导出,如下:

function axios(config) {

}
export default axios

这里 TypeScript 编译器会检查到错误,分别是 config 的声明上有隐含的 any 报错,以及代码块为空。代码块为空我们比较好理解,第一个错误的原因是因为我们给 TypeScript 编译配置的 strict 设置为 true 导致。

编译配置文件tsconfig.json

tsconfig.json 文件中指定了用来编译这个项目的根文件和编译选项,关于它的具体学习,可以去官网系统学习一下。

运行 tsc 命令去编译 TypeScript 文件,编译器会从当前目录开始去查找 tsconfig.json 文件,作为编译时的一些编译选项。

我们来看一下 tsconfig.json 文件,它包含了很多编译时的配置,其中我们把 strict 设置为 true,它相当于启用所有严格类型的检查选项。启用 --strict 相当于启用 --noImplicitAny,--noImplicitThis,--alwaysStrict--strictNullChecks--strictFunctionTypes--strictPropertyInitialization

定义AxiosRequestConfig接口类型

接下来,我们需要给 config 参数定义一种接口类型。我们创建一个 types 目录,在下面创建一个 index.ts 文件,作为我们项目中公用的类型定义文件。

接下来我们来定义 AxiosRequestConfig 接口类型:

export interface AxiosRequestConfig {
  url: string
  method?: string
  data?: any
  params?: any
}

其中,url 为请求的地址,必选属性;而其余属性都是可选属性。method 是请求的 HTTP 方法;datapostpatch 等类型请求的数据,放到 request body 中的;paramsgethead 等类型请求的数据,拼接到 urlquery string 中的。

为了让 method 只能传入合法的字符串,我们定义一种字符串字面量类型 Method

export type Method = 'get' | 'GET'
  | 'delete' | 'Delete'
  | 'head' | 'HEAD'
  | 'options' | 'OPTIONS'
  | 'post' | 'POST'
  | 'put' | 'PUT'
  | 'patch' | 'PATCH'

接着我们把 AxiosRequestConfig 中的 method 属性类型改成这种字符串字面量类型:

export interface AxiosRequestConfig {
  url: string
  method?: Method
  data?: any
  params?: any
}

然后回到 index.ts,我们引入 AxiosRequestConfig 类型,作为 config 的参数类型,如下:

import { AxiosRequestConfig } from './types'

function axios(config: AxiosRequestConfig) {
}
export default axios

那么接下来,我们就来实现这个函数体内部的逻辑——发送请求。

利用XMLHttpRequest发送请求

我们并不想在 index.ts 中去实现发送请求的逻辑,我们利用模块化的编程思想,把这个功能拆分到一个单独的模块中。

于是我们在 src 目录下创建一个 xhr.ts 文件,我们导出一个 xhr 方法,它接受一个 config 参数,类型也是 AxiosRequestConfig 类型。

import { AxiosRequestConfig } from './types'export default function xhr(config: AxiosRequestConfig) {
}

接下来,我们来实现这个函数体逻辑,如下:

export default function xhr(config: AxiosRequestConfig): void {
  const { data = null, url, method = 'get' } = config
​
  const request = new XMLHttpRequest()
​
  request.open(method.toUpperCase(), url, true)
​
  request.send(data)
}

我们首先通过解构赋值的语法从 config 中拿到对应的属性值赋值给我的变量,并且还定义了一些默认值,因为在 AxiosRequestConfig 接口的定义中,有些属性是可选的。

接着我们实例化了一个 XMLHttpRequest 对象,然后调用了它的 open 方法,传入了对应的一些参数,最后调用 send 方法发送请求。

对于 XMLHttpRequest 的学习,各位去 mdn 上系统地学习一下它的一些属性和方法,当做参考资料,因为在后续的开发中我们可能会反复查阅这些文档资料。

引入 xhr 模块

编写好了 xhr 模块,我们就需要在 index.ts 中去引入这个模块,如下:

import { AxiosRequestConfig } from './types'
import xhr from './xhr'

function axios(config: AxiosRequestConfig): void {
  xhr(config)
}
export default axios

那么至此,我们基本的发送请求代码就编写完毕了,接下来我们来写一个小 demo,来使用我们编写的 axios 库去发送请求。

demo编写

我们会利用 Node.js 的 express 库去运行我们的 demo,利用 webpack 来作为 demo 的构建工具。

依赖安装

我们先来安装一些编写 demo 需要的依赖包,如下:

"webpack": "^4.28.4",
"webpack-dev-middleware": "^3.5.0",
"webpack-hot-middleware": "^2.24.3",
"ts-loader": "^5.3.3",
"tslint-loader": "^3.5.4",
"express": "^4.16.4",
"body-parser": "^1.18.3"

其中,webpack 是打包构建工具,webpack-dev-middlewarewebpack-hot-middleware 是 2 个 expresswebpack 中间件,ts-loadertslint-loaderwebpack 需要的 TypeScript 相关 loader,express 是 Node.js 的服务端框架,body-parserexpress 的一个中间件,解析 body 数据用的。

编写 webpack 配置文件

examples 目录下创建 webpack 配置文件 webpack.config.js

const fs = require('fs')
const path = require('path')
const webpack = require('webpack')
​
module.exports = {
  mode: 'development',
​
  /**
   * 我们会在 examples 目录下建多个子目录
   * 我们会把不同章节的 demo 放到不同的子目录中
   * 每个子目录的下会创建一个 app.ts
   * app.ts 作为 webpack 构建的入口文件
   * entries 收集了多目录个入口文件,并且每个入口还引入了一个用于热更新的文件
   * entries 是一个对象,key 为目录名
   */
  entry: fs.readdirSync(__dirname).reduce((entries, dir) => {
    const fullDir = path.join(__dirname, dir)
    const entry = path.join(fullDir, 'app.ts')
    if (fs.statSync(fullDir).isDirectory() && fs.existsSync(entry)) {
      entries[dir] = ['webpack-hot-middleware/client', entry]
    }
​
    return entries
  }, {}),
​
  /**
   * 根据不同的目录名称,打包生成目标 js,名称和目录名一致
   */
  output: {
    path: path.join(__dirname, '__build__'),
    filename: '[name].js',
    publicPath: '/__build__/'
  },
​
  module: {
    rules: [
      {
        test: /.ts$/,
        enforce: 'pre',
        use: [
          {
            loader: 'tslint-loader'
          }
        ]
      },
      {
        test: /.tsx?$/,
        use: [
          {
            loader: 'ts-loader',
            options: {
              transpileOnly: true
            }
          }
        ]
      }
    ]
  },
​
  resolve: {
    extensions: ['.ts', '.tsx', '.js']
  },
​
  plugins: [
    new webpack.HotModuleReplacementPlugin(),
    new webpack.NoEmitOnErrorsPlugin()
  ]
}

编写 server 文件

examples 目录下创建 server.js 文件:

const express = require('express')
const bodyParser = require('body-parser')
const webpack = require('webpack')
const webpackDevMiddleware = require('webpack-dev-middleware')
const webpackHotMiddleware = require('webpack-hot-middleware')
const WebpackConfig = require('./webpack.config')
​
const app = express()
const compiler = webpack(WebpackConfig)
​
app.use(webpackDevMiddleware(compiler, {
  publicPath: '/__build__/',
  stats: {
    colors: true,
    chunks: false
  }
}))
​
app.use(webpackHotMiddleware(compiler))
​
app.use(express.static(__dirname))
​
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: true }))
​
const port = process.env.PORT || 8080
module.exports = app.listen(port, () => {
  console.log(`Server listening on http://localhost:${port}, Ctrl+C to stop`)
})

编写 demo 代码

首先在 examples 目录下创建 index.htmlglobal.css,作为所有 demo 的入口文件已全局样式文件。

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>ts-axios examples</title>
    <link rel="stylesheet" href="/global.css">
  </head>
  <body style="padding: 0 20px">
    <h1>ts-axios examples</h1>
    <ul>
      <li><a href="simple">Simple</a></li>
    </ul>
  </body>
</html>

global.css

html, body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
  color: #2c3e50;
}
​
ul {
  line-height: 1.5em;
  padding-left: 1.5em;
}
​
a {
  color: #7f8c8d;
  text-decoration: none;
}
​
a:hover {
  color: #4fc08d;
}

然后在 examples 目录下创建 simple 目录,作为本章节的 demo 目录,在该目录下再创建 index.htmlapp.ts 文件

index.html 文件如下:

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

app.ts 文件如下:

import axios from '../../src/index'axios({
  method: 'get',
  url: '/simple/get',
  params: {
    a: 1,
    b: 2
  }
})

因为我们这里通过 axios 发送了请求,那么我们的 server 端要实现对应的路由接口,我们来修改 server.js,添加如下代码:

const router = express.Router()
​
router.get('/simple/get', function(req, res) {
  res.json({
    msg: `hello world`
  })
})
​
app.use(router)

运行 demo

接着我们在 package.json 中去新增一个 npm script

"dev": "node examples/server.js"

然后我们去控制台执行命令

npm run dev

相当于执行了 node examples/server.js,会开启我们的 server。

接着我们打开 chrome 浏览器,访问 http://localhost:8080/ 即可访问我们的 demo 了,我们点到 Simple 目录下,通过开发者工具的 network 部分我们可以看到成功发送到了一条请求,并在 response 中看到了服务端返回的数据。

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