如何构建通用 api 中间层

43次阅读

共计 6457 个字符,预计需要花费 17 分钟才能阅读完成。

零、问题的由来
开门见山地说,这篇文章是一篇安利软文~,安利的对象就是最近搞的 tua-api。
顾名思义,这就是一款辅助获取接口数据的工具。
发请求相关的工具辣么多,那我为啥要用你呢?
理想状态下,项目中应该有一个 api 中间层。各种接口在这里定义,业务侧不应该手动编写接口地址,而应该调用接口层导出的函数。
import {fooApi} from ‘@/apis/’

fooApi
.bar({a: ‘1’, b: ‘2’}) // 发起请求,a、b 是请求参数
.then(console.log) // 收到响应
.catch(console.error) // 处理错误
那么如何组织实现这个 api 中间层呢?这里涉及两方面:

如何发请求,即“武器”部分
如何组织管理 api 地址

让我们先回顾一下有关发请求的历史。
一、如何发请求
1.1. 原生 XHR (XMLHttpRequest)
说到发请求,最经典的方式莫过于调用浏览器原生的 XHR。在此不赘述,有兴趣可以看看 MDN 上的文档。
var xhr = window.XMLHttpRequest
? new XMLHttpRequest()
// 在万恶的 IE 上可能还没有 XMLHttpRequest 这对象
: new ActiveXObject(‘Microsoft.XMLHTTP’)

xhr.open(‘GET’, ‘some url’)
xhr.responseType = ‘json’

// 传统使用 onreadystatechange
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200) {
console.log(xhr.responseText)
}
}

// 或者直接使用 onload 事件
xhr.onload = function () {
console.log(xhr.response)
}

// 处理出错
xhr.onerror = console.error

xhr.send()
这代码都不用看,想想就头皮发麻 …
1.2.jQuery 封装的 ajax
由于原生 XHR 写起来太繁琐,再加上当时 jQuery 如日中天。日常开发中用的比较多的还是 jQuery 提供的 ajax 方法。jQuery ajax 文档点这里
var params = {
url: ‘some url’,
data: {name: ‘Steve’, location: ‘Beijing’},
}

$.ajax(params)
.done(console.log)
.fail(console.error)
jQuery 不仅封装了 XHR,还十分贴心地提供跨域的 jsonp 功能。
$.ajax({
url: ‘some url’,
data: {name: ‘Steve’, location: ‘Beijing’},
dataType: ‘jsonp’,
success: console.log,
error: console.error,
})
讲道理,jQuery 的 ajax 已经很好用了。然而随着 Vue、React、Angular 的兴起,连 jQuery 本身都被革命了。新项目为了发个请求还引入巨大的 jQuery 肯定不合理,当然后面这些替代方案也功不可没 …
1.3. 现代浏览器的原生 fetch
XHR 是一个设计粗糙的 API。记得当年笔试某部门的实习生的时候就有手写 XHR 的题目,我反正记不住 api,并没有写出来 …
fetch api 基于 Promise 设计,调用起来比 XHR 方便多了。
fetch(url)
.then(res => res.json())
.then(console.log)
.catch(console.error)
async/await 自然也能使用
try {
const data = await fetch(url).then(res => res.json())
console.log(data)
} catch (e) {
console.error(e)
}
当然 fetch 也有不少的问题

兼容性问题
使用繁琐,详见参考文献之 fetch 没有你想象的那么美

不支持 jsonp(虽然理论上不应该支持,但实际上日常还是需要使用的)
只对网络请求报错,对 400,500 都当做成功的请求,需要二次封装
默认不会带 cookie,需要添加配置项
不支持 abort,不支持超时控制,使用 setTimeout 及 Promise.race 的实现的超时控制并不能阻止请求过程继续在后台运行,造成了流量的浪费
没有办法原生监测请求的进度,而 XHR 可以

1.4. 基于 Promise 的 axios
axios 算是请求框架中的明星项目了。目前 github 5w+ 的 star…
先来看看有什么特性吧~

同时支持浏览器端和服务端的请求。(XMLHttpRequests、http)
支持 Promise
支持请求和和数据返回的拦截
转换请求返回数据,自动转换 JSON 数据
支持取消请求
客户端防止 xsrf 攻击

嗯,看起来确实是居家旅行全栈开发必备好库,但是 axios 并不支持 jsonp…
1.5. 不得不用的 jsonp
在服务器端不方便配置跨域头的情况下,采用 jsonp 的方式发起跨域请求是一种常规操作。
在此不探究具体的实现,原理上来说就是

由于 script 标签可以设置跨域的来源,所以首先动态插入一个 script,将 src 设置为目标地址
服务端收到请求后,根据回调函数名(可自己约定,或作为参数传递)将 json 数据填入(即 json padding,所以叫 jsonp…)。例如 callback({“foo”: “bar”})。
浏览器端收到响应后自然会执行该 script 即调用该函数,那么回调函数就收到了服务端填入的 json 数据了。

上面讲到新项目一般都弃用 jQuery 了,那么跨域请求还是得发呀。所以可能你还需要一个发送 jsonp 的库。(实践中选了 fetch-jsonp,当然其他库也可以)
综上,日常开发在框架的使用上以 axios 为主,实在不得不发 jsonp 请求时,就用 fetch-jsonp。这就是我们中间层的基础,即“武器”部分。
1.6. 小程序场景
在小程序场景没得选,只能使用官方的 wx.request 函数 …
二、构建接口层基础功能
对于简单的页面,直接裸写请求地址也没毛病。但是一旦项目变大,页面数量也上去了,直接在页面,或是组件中裸写接口的话,会带来以下问题

代码冗余:很多接口请求都是类似的代码,有许多相同的逻辑
不同的库和场景下的接口写法不同(ajax、jsonp、小程序 …)
不方便切换测试域名
不方便编写接口注释
没法实现统一拦截器、甚至中间件功能

如何封装这些接口呢?
2.1. 接口地址划分
首先我们来分析一下接口地址的组成

https://example-base.com/foo/create
https://example-base.com/foo/modify
https://example-base.com/foo/delete

对于以上地址,在 tua-api 中一般将其分为 3 部分

host: ‘https://example-base.com/’

prefix: ‘foo’

pathList: [‘create’, ‘modify’, ‘delete’]

2.2. 文件结构
apis/ 一般是这样的文件结构:
.
└── apis
├── prefix-1.js
├── prefix-2.js
├── foo.js // <– 以上的 api 地址会放在这里
└── index.js
index.js 作为接口层的入口,会导入并生成各个 api 然后再导出。
2.3. 基础配置内容
所以以上的示例接口地址可以这么写
// src/apis/foo.js

export default {
// 请求的公用服务器地址。
host: ‘http://example-base.com/’,

// 请求的中间路径,建议与文件同名,以便后期维护。
prefix: ‘foo’,

// 接口地址数组
pathList: [
{path: ‘create’},
{path: ‘modify’},
{path: ‘delete’},
],
}
这时如果想修改服务器地址,只需要修改 host 即可。甚至还能这么玩
// src/apis/foo.js

// 某个获取页面地址参数的函数
const getUrlParams = () => {…}

export default {
// 根据 NODE_ENV 采用不同的服务器
host: process.env.NODE_ENV === ‘test’
? ‘http://example-test.com/’
: ‘http://example-base.com/’,

// 根据页面参数采用不同的服务器,即页面地址带 ?test=1 则走测试地址
host: getUrlParams().test
? ‘http://example-test.com/’
: ‘http://example-base.com/’,

// …
}
2.4. 配置导出
下面来看一下 apis/index.js 该怎么写:
import TuaApi from ‘tua-api’
// 小程序端这样导入
// import TuaApi from ‘tua-api/dist/mp’

// 初始化
const tuaApi = new TuaApi({…})

// 导出
export const fooApi = tuaApi.getApi(require(‘./foo’).default)
这样我们就把接口地址封装了起来,业务侧不需要关心接口的逻辑,而后期接口的修改和升级时只需要修改这里的配置即可。
2.5. 接口参数与接口类型
示例的接口地址太理想化了,如果有参数如何传递?
假设以上接口添加 id、from 和 foo 参数。并且增加以下逻辑:

foo 参数默认填 bar

from 参数默认填 index-page

delete 接口使用 jsonp 的方式,from 参数默认填 delete-page

modify 接口使用 post 的方式,from 参数不需要填

哎~,别急着死,暂且看看怎么用 tua-api 来抽象这些逻辑?
// src/apis/foo.js

export default {
// …

// 公共参数,将会合并到后面的各个接口参数中
commonParams: {
foo: ‘bar’,
from: ‘index-page’,
},

pathList: [
{
path: ‘create’,
params: {
// 类似 Vue 中 props 的类型检查
id: {required: true},
},
},
{
path: ‘modify’,
// 使用 post 的方式
type: ‘post’,
params: {
// 写成 isRequired 也行
id: {isRequired: true},
// 接口不合并公共参数,即不传 from 参数
commonParams: null,
},
},
{
path: ‘delete’,
// 使用 jsonp 的方式(不填则默认使用 axios)
reqType: ‘jsonp’,
params: {
id: {required: true},
// 这里填写的 from 会覆盖 commonParams 中的同名属性
from: ‘delete-page’,
},
},
],
}
现在来看看业务侧代码有什么变化。
import {fooApi} from ‘@/apis/’

// 直接调用将会报错,因为没有传递 id 参数
await fooApi.create()

// 请求参数使用传入的 from:id=1&foo=bar&from=foo-page
await fooApi.create({id: 1, from: ‘foo-page’})

// 请求参数将只有 id:id=1
await fooApi.modify({id: 1})

// 请求参数将使用自身的 from:id=1&foo=bar&from=delete-page
await fooApi.delete({id: 1})
2.6. 接口重命名
假设现在后台又添加了以下两个新接口,咱们该怎么写配置呢?

remove/all
add-array

首先,把后台同学砍死 …2333

这什么鬼接口地址,直接填的话会业务侧就会写成这样。
fooApi[‘remove/all’]
fooApi[‘add-array’]
这代码简直无法直视 … 让我们用 name 属性,将接口重命名一下。
// src/apis/foo.js

export default {
// …

pathList: [
// …

{path: ‘remove/all’, name: ‘removeAll’},
{path: ‘add-array’, name: ‘addArray’},
],
}
更多配置请点击这里查看
三、高级功能
一个接口层仅仅只能发 api 请求是远远不够的,在日常使用中往往还有以下需求

发起请求时展示 loading,收到响应后隐藏
出错时展示错误信息,例如弹一个 toast
接口上报:包括性能和错误
添加特技:如接口参数加密、校验

3.1. 小程序端的 loading 展示
小程序端由于原生自带 UI 组件,所以框架内置了该功能。主要包括以下参数

isShowLoading
showLoadingFn
hideLoadingFn

顾名思义,就是开关和具体的显示、隐藏的方法,详情参阅这里
3.2. 基础钩子函数
最简单的钩子函数就是 beforeFn/afterFn 这俩函数了。
beforeFn 是在请求发起前执行的函数(例如小程序可以通过返回 header 传递 cookie),因为是通过 beforeFn().then(…) 调用,所以注意要返回 Promise。
afterFn 是在收到响应后执行的函数,可以不用返回 Promise。
注意接收的参数是一个【数组】[res.data, ctx] 所以默认值是 const afterFn = ([x]) => x,即返回接口数据到业务侧

第一个参数是接口返回的数据对象 {code, data, msg}

第二个参数是请求相关参数的对象,例如有请求的 host、type、params、fullPath、reqTime、startTime、endTime 等等

3.3.middleware 中间件
钩子函数有时不太够用,并且代码一长不太好维护。所以 tua-api 还引入了中间件功能,用法上和 koa 的中间件很像(其实底层直接用了 koa-compose)。
export default {
middleware: [fn1, fn2, fn3],
}
首先说下中间件执行顺序,koa 中间件的执行顺序和 redux 的正好相反,例如以上写法会以以下顺序执行:
请求参数 -> fn1 -> fn2 -> fn3 -> 响应数据 -> fn3 -> fn2 -> fn1
接口说下中间件的写法,分为两种

普通函数:注意一定要 return next() 否则 Promise 链就断了!
async 函数:注意一定要 await next()!

// 普通函数,注意一定要 return next()
function (ctx, next) {
ctx.req // 请求的各种配置
ctx.res // 响应,但这时还未发起请求,所以是 undefined!
ctx.startTime // 发起请求的时间

// 传递控制权给下一个中间件
return next().then(() => {
// 注意这里才有响应!
ctx.res // 响应对象
ctx.res.data // 响应的数据
ctx.reqTime // 请求花费的时间
ctx.endTime // 收到响应的时间
})
}

// async/await
async function (ctx, next) {
ctx.req // 请求的各种配置

// 传递控制权给下一个中间件
await next()

// 注意这里才有响应响应!
ctx.res // 响应对象
}
其他参数参阅这里
四、小结
这篇安利文,先是从前端发请求的历史出发。一步步介绍了如何构建以及使用 api 中间层,来统一管理接口地址,最后还介绍了下中间件等高级功能。话说回来,这么好用的 tua-api 各位开发者老爷们不来了解一下么?

参考文献

Jquery ajax, Axios, Fetch 区别之我见
传统 Ajax 已死,Fetch 永生
fetch 没有你想象的那么美
fetch 使用的常见问题及解决方法

正文完
 0