小程序内置了wx.request
,用于向后端发送请求,我们先来看看它的文档:
wx.request(OBJECT)
发起网络请求。使用前请先阅读说明。
OBJECT参数说明:
参数名 | 类型 | 必填 | 默认值 | 说明 | 最低版本 |
---|---|---|---|---|---|
url | String | 是 | - | 开发者服务器接口地址 | - |
data | Object/String/ArrayBuffer | 否 | - | 请求的参数 | - |
header | Object | 否 | - | 设置请求的 header,header 中不能设置 Referer。 | - |
method | String | 否 | GET | (需大写)有效值:OPTIONS, GET, HEAD, POST, PUT, DELETE, TRACE, CONNECT | - |
dataType | String | 否 | json | 如果设为json,会尝试对返回的数据做一次 JSON.parse | - |
responseType | String | 否 | text | 设置响应的数据类型。合法值:text、arraybuffer | 1.7.0 |
success | Function | 否 | - | 收到开发者服务成功返回的回调函数 | - |
fail | Function | 否 | - | 接口调用失败的回调函数 | - |
complete | Function | 否 | - | 接口调用结束的回调函数(调用成功、失败都会执行) | - |
success返回参数说明:
参数 | 类型 | 说明 | 最低版本 |
---|---|---|---|
data | Object/String/ArrayBuffer | 开发者服务器返回的数据 | - |
statusCode | Number | 开发者服务器返回的 HTTP 状态码 | - |
header | Object | 开发者服务器返回的 HTTP Response Header | 1.2.0 |
这里我们主要看两点:
- 回调函数:success、fail、complete;
- success的返回参数:data、statusCode、header。
相对于通过入参传回调函数的方式,我更喜欢promise的链式,这是我期望的第一个点;success的返回参数,在实际开发过程中,我只关心data部分,这里可以做一下处理,这是第二点。
promisify
小程序默认支持promise,所以这一点改造还是很简单的:
/**
* promise请求
* 参数:参考wx.request
* 返回值:[promise]res
*/
function requestP(options = {}) {
const {
success,
fail,
} = options;
return new Promise((res, rej) => {
wx.request(Object.assign(
{},
options,
{
success: res,
fail: rej,
},
));
});
}
这样一来我们就可以使用这个函数来代替wx.request,并且愉快地使用promise链式:
requestP({
url: '/api',
data: {
name: 'Jack'
}
})
.then((res) => {
console.log(res);
})
.catch((err) => {
console.log(err);
});
注意,小程序的promise并没有实现finally,Promise.prototype.finally是undefined,所以complete不能用finally代替。
精简返回值
精简返回值也是很简单的事情,第一直觉是,当请求返回并丢给我一大堆数据时,我直接resolve我要的那一部分数据就好了嘛:
return new Promise((res, rej) => {
wx.request(Object.assign(
{},
options,
{
success(r) {
res(r.data); // 这里只取data
},
fail: rej,
},
));
});
but!这里需要注意,我们仅仅取data部分,这时候默认所有success都是成功的,其实不然,wx.request是一个基础的api,fail只发生在系统和网络层面的失败情况,比如网络丢包、域名解析失败等等,而类似404、500之类的接口状态,依旧是调用success,并体现在statusCode
上。
从业务上讲,我只想处理json的内容,并对json当中的相关状态进行处理;如果一个接口返回的不是约定好的json,而是类似404、500之类的接口异常,我统一当成接口/网络错误来处理,就像jquery的ajax那样。
也就是说,如果我不对statusCode
进行区分,那么包括404、500在内的所有请求结果都会走requestP().then
,而不是requestP().catch
。这显然不是我们熟悉的使用方式。
于是我从jquery的ajax那里抄来了一段代码。。。
/**
* 判断请求状态是否成功
* 参数:http状态码
* 返回值:[Boolen]
*/
function isHttpSuccess(status) {
return status >= 200 && status < 300 || status === 304;
}
isHttpSuccess
用来决定一个http状态码是否判为成功,于是结合requestP
,我们可以这么来用:
return new Promise((res, rej) => {
wx.request(Object.assign(
{},
options,
{
success(r) {
const isSuccess = isHttpSuccess(r.statusCode);
if (isSuccess) { // 成功的请求状态
res(r.data);
} else {
rej({
msg: `网络错误:${r.statusCode}`,
detail: r
});
}
},
fail: rej,
},
));
});
这样我们就可以直接resolve返回结果中的data,而对于非成功的http状态码,我们则直接reject一个自定义的error对象,这样就是我们所熟悉的ajax用法了。
登录
我们经常需要识别发起请求的当前用户,在web中这通常是通过请求中携带的cookie实现的,而且对于前端开发者是无感知的;小程序中没有cookie,所以需要主动地去补充相关信息。
首先要做的是:登录。
通过wx.login
接口我们可以得到一个code
,调用后端登录接口将code传给后端,后端再用code去调用微信的登录接口,换取sessionKey
,最后生成一个sessionId
返回给前端,这就完成了登录。
具体参考微信官方文档:wx.login
const apiUrl = 'https://jack-lo.github.io';
let sessionId = '';
/**
* 登录
* 参数:undefined
* 返回值:[promise]res
*/
function login() {
return new Promise((res, rej) => {
// 微信登录
wx.login({
success(r1) {
if (r1.code) {
// 获取sessionId
requestP({
url: `${apiUrl}/api/login`,
data: {
code: r1.code,
},
method: 'POST'
})
.then((r2) => {
if (r2.rcode === 0) {
const { sessionId } = r2.data;
// 保存sessionId
sessionId = sessionId;
res(r2);
} else {
rej({
msg: '获取sessionId失败',
detail: r2
});
}
})
.catch((err) => {
rej(err);
});
} else {
rej({
msg: '获取code失败',
detail: r1
});
}
},
fail: rej,
});
});
}
好的,我们做好了登录并且顺利获取到了sessionId,接下来是考虑怎么把sessionId通过请求带上去。
sessionId
为了将状态与数据区分开来,我们决定不通过data,而是通过header的方式来携带sessionId,我们对原本的requestP稍稍进行修改,使得它每次调用都自动在header里携带sessionId:
function requestP(options = {}) {
const {
success,
fail,
} = options;
// 统一注入约定的header
let header = Object.assign({
sessionId: sessionId
}, options.header);
return new Promise((res, rej) => {
...
});
}
好的,现在请求会自动带上sessionId了;
但是,革命尚未完成:
- 我们什么时候去登录呢?或者说,我们什么时候去获取sessionId?
- 假如还没登录就发起请求了怎么办呢?
- 登录过期了怎么办呢?
我设想有这样一个逻辑:
- 当我发起一个请求的时候,如果这个请求不需要sessionId,则直接发出;
- 如果这个请求需要携带sessionId,就去检查现在是否有sessionId,有的话直接携带,发起请求;
- 如果没有,自动去走登录的流程,登录成功,拿到sessionId,再去发送这个请求;
- 如果有,但是最后请求返回结果是sessionId过期了,那么程序自动走登录的流程,然后再发起一遍。
其实上面的那么多逻辑,中心思想只有一个:都是为了拿到sessionId!
我们需要对请求做一层更高级的封装。
首先我们需要一个函数专门去获取sessionId,它将解决上面提到的2、3点:
/**
* 获取sessionId
* 参数:undefined
* 返回值:[promise]sessionId
*/
function getSessionId() {
return new Promise((res, rej) => {
// 本地sessionId缺失,重新登录
if (!sessionId) {
login()
.then((r1) => {
res(r1.data.sessionId);
})
.catch(rej);
}
} else {
res(sessionId);
}
});
}
好的,接下来我们解决第1、4点,我们先假定:sessionId过期的时候,接口会返回code=401
。
整合了getSessionId,得到一个更高级的request方法:
/**
* ajax高级封装
* 参数:[Object]option = {},参考wx.request;
* [Boolen]keepLogin = false
* 返回值:[promise]res
*/
function request(options = {}, keepLogin = true) {
if (keepLogin) {
return new Promise((res, rej) => {
getSessionId()
.then((r1) => {
// 获取sessionId成功之后,发起请求
requestP(options)
.then((r2) => {
if (r2.rcode === 401) {
// 登录状态无效,则重新走一遍登录流程
// 销毁本地已失效的sessionId
sessionId = '';
getSessionId()
.then((r3) => {
requestP(options)
.then(res)
.catch(rej);
});
} else {
res(r2);
}
})
.catch(rej);
})
.catch(rej);
});
} else {
// 不需要sessionId,直接发起请求
return requestP(options);
}
}
留意req的第二参数keepLogin,是为了适配有些接口不需要sessionId,但因为我的业务里大部分接口都需要登录状态,所以我默认值为true。
这差不多就是我们封装request的最终形态了。
并发处理
这里其实我们还需要考虑一个问题,那就是并发。
试想一下,当我们的小程序刚打开的时候,假设页面会同时发出5个请求,而此时没有sessionId,那么,这5个请求按照上面的逻辑,都会先去调用login去登录,于是乎,我们就会发现,登录接口被同步调用了5次!并且后面的调用将导致前面的登录返回的sessionId过期~
这bug是很严重的,理论上来说,登录我们只需要调用一次,然后一直到过期为止,我们都不需要再去登录一遍了。
——那么也就是说,同一时间里的所有接口其实只需要登录一次就可以了。
——也就是说,当有登录的请求发出的时候,其他那些也需要登录状态的接口,不需要再去走登录的流程,而是等待这次登录回来即可,他们共享一次登录操作就可以了!
解决这个问题,我们需要用到队列。
我们修改一下getSessionId这里的逻辑:
const loginQueue = [];
let isLoginning = false;
/**
* 获取sessionId
* 参数:undefined
* 返回值:[promise]sessionId
*/
function getSessionId() {
return new Promise((res, rej) => {
// 本地sessionId缺失,重新登录
if (!sessionId) {
loginQueue.push({ res, rej });
if (!isLoginning) {
isLoginning = true;
login()
.then((r1) => {
isLoginning = false;
while (loginQueue.length) {
loginQueue.shift().res(r1);
}
})
.catch((err) => {
isLoginning = false;
while (loginQueue.length) {
loginQueue.shift().rej(err);
}
});
}
} else {
res(sessionId);
}
});
}
使用了isLoginning这个变量来充当锁的角色,锁的目的就是当登录正在进行中的时候,告诉程序“我已经在登录了,你先把回调都加队列里去吧”,当登录结束之后,回来将锁解开,把回调全部执行并清空队列。
这样我们就解决了问题,同时提高了性能。
封装
在做完以上工作以后,我们都很清楚的封装结果就是request
,所以我们把request暴露出去就好了:
function request() {
...
}
module.exports = request;
这般如此之后,我们使用起来就可以这样子:
const request = require('request.js');
Page({
ready() {
// 获取热门列表
request({
url: 'https://jack-lo.github.io/api/hotList',
data: {
page: 1
}
})
.then((res) => {
console.log(res);
})
.catch((err) => {
console.log(err);
});
// 获取最新信息
request({
url: 'https://jack-lo.github.io/api/latestList',
data: {
page: 1
}
})
.then((res) => {
console.log(res);
})
.catch((err) => {
console.log(err);
});
},
});
是不是很方便,可以用promise的方式,又不必关心登录的问题。
然而可达鸭眉头一皱,发现事情并不简单,一个接口有可能在多个地方被多次调用,每次我们都去手写这么一串url
参数,并不那么方便,有时候还不好找,并且容易出错。
如果能有个地方专门记录这些url就好了;如果每次调用接口,都能像调用一个函数那么简单就好了。
基于这个想法,我们还可以再做一层封装,我们可以把所有的后端接口,都封装成一个方法,调用接口就相对应调用这个方法:
const apiUrl = 'https://jack-lo.github.io';
const req = {
// 获取热门列表
getHotList(data) {
const url = `${apiUrl}/api/hotList`
return request({ url, data });
},
// 获取最新列表
getLatestList(data) {
const url = `${apiUrl}/api/latestList`
return request({ url, data });
}
}
module.exports = req; // 注意这里暴露的已经不是request,而是req
那么我们的调用方式就变成了:
const req = require('request.js');
Page({
ready() {
// 获取热门列表
req.getHotList({
page: 1
})
.then((res) => {
console.log(res);
})
.catch((err) => {
console.log(err);
});
// 获取最新信息
req.getLatestList({
page: 1
})
.then((res) => {
console.log(res);
})
.catch((err) => {
console.log(err);
});
}
});
这样一来就方便了很多,而且有一个很大的好处,那就是当某个接口的地址需要统一修改的时候,我们只需要对request.js
进行修改,其他调用的地方都不需要动了。
错误信息的提炼
最后的最后,我们再补充一个可轻可重的点,那就是错误信息的提炼。
当我们在封装这么一个req
对象的时候,我们的promise曾经reject过很多的错误信息,这些错误信息有可能来自:
wx.request
的fail;- 不符合
isHttpSuccess
的网络错误; - getSessionId失败;
…
等等的一切可能。
这就导致了我们在提炼错误信息的时候陷入困境,到底catch到的会是哪种error
对象?
这么看你可能不觉得有问题,我们来看看下面的例子:
req.getHotList({
page: 1
})
.then((res) => {
console.log(res);
})
.catch((err) => {
console.log(err);
});
假如上面的例子中,我想要的不仅仅是console.log(err)
,而是想将对应的错误信息弹窗出来,我应该怎么做?
我们只能将所有可能出现的错误都检查一遍:
req.getHotList({
page: 1
})
.then((res) => {
if (res.code !== 0) {
// 后端接口报错格式
wx.showModal({
content: res.msg
});
}
})
.catch((err) => {
let msg = '未知错误';
// 文本信息直接使用
if (typeof err === 'string') {
msg = err;
}
// 小程序接口报错
if (err.errMsg) {
msg = err.errMsg;
}
// 自定义接口的报错,比如网络错误
if (err.detail && err.detail.errMsg) {
msg = err.detail.errMsg;
}
// 未知错误
wx.showModal({
content: msg
});
});
这就有点尴尬了,提炼错误信息的代码量都比业务还多几倍,而且还是每个接口调用都要写一遍~
为了解决这个问题,我们需要封装一个方法来专门做提炼的工作:
/**
* 提炼错误信息
* 参数:err
* 返回值:[string]errMsg
*/
function errPicker(err) {
if (typeof err === 'string') {
return err;
}
return err.msg || err.errMsg || (err.detail && err.detail.errMsg) || '未知错误';
}
那么过程会变成:
req.getHotList({
page: 1
})
.then((res) => {
console.log(res);
})
.catch((err) => {
const msg = req.errPicker(err);
// 未知错误
wx.showModal({
content: msg
});
});
好吧,我们再偷懒一下,把wx.showModal也省去了:
/**
* 错误弹窗
*/
function showErr(err) {
const msg = errPicker(err);
console.log(err);
wx.showModal({
showCancel: false,
content: msg
});
}
最后就变成了:
req.getHotList({
page: 1
})
.then((res) => {
console.log(res);
})
.catch(req.showErr);
至此,一个简单的wx.request封装过程便完成了,封装过的req
比起原来,使用上更加方便,扩展性和可维护性也更好。
结尾
以上内容其实是简化版的mp-req
,介绍了mp-req
这一工具的实现初衷以及思路,使用mp-req
来管理接口会更加的便捷,同时mp-req
也提供了更加丰富的功能,比如插件机制、接口的缓存,以及接口分类等,欢迎大家关注mp-req了解更多内容。
以上最终代码可以在这里获取:req.js。
请教, 在小程序启动的时候wx.login一下,实现放到本地存储起来,设置好过期时间,跟您的使用队列方法对比,有什么坏处?
请问大佬,如果接口因为超时请求失败,如何进行重连才是比较合理的呢?
分情况吧,如果只是普通的超时请求,第一方案应该是提高后端的处理速度吧?如果是属于某一个接口失败后需要重连的情况,那就在超时失败以后再发起一次请求就可以了;如果是要在全局上做,也就是说对于所有的网络请求都要实现超时失败后重连的逻辑,那建议在底层做统一处理,比如对wx.request进行封装,实现一个重连的机制,然后业务当中统一使用封装之后的方法。但是建议还是从提高接口的响应速度方面去做优化吧,如果实在是比较耗时的请求,可以使用轮询。
目前是wx.request里进行封装,重连次数限制为5次,可是后面发现如果某页的接口进行重连时,如果此时跳转到另一页,之前重连的接口还在进行请求,直至达到限制的次数5次,这种情况有什么办法解决吗?
也许RequestTask.abort()是你所需要的?
多写大哥的思路,回头自己也造个轮子玩,[滑稽]
并发这一块,处理逻辑过于复杂,不至于在每个接口调用之前去login一下。只要保证应用启动的时候,login 事先调用了。有时候当代码实在无法精简的时候,应该试着从业务逻辑入手。把逻辑简单化。
并发只是使用了队列而已,并没有复杂,这里并没有在每个接口调用前都去login,当有login在进行的时候,其他接口即便发起了也处于挂起状态,只有等该login完成之后才会发出去,所以一直只会有一个login在进行。
嗦嘎,学习了。感谢分享
非常好的分享,感谢