前言
最近刚做了一个小程序, 里面涉及到用户授权以及登录的情况, 初次登录需要获取用户信息, 然后再走登录流程, 后续就不需要用户授权了, 就可以直接走登录流程了
同时有的数据需要登录之后才能获取, 如果未登录则返回401, 此时我需要先登录, 然后再去获取数据
也就是说我需要做静默登录的操作: 请求数据, 登录了则正常获取数据, 而如果未登录则需要先登录然后才能获取数据
当页面只有一个数据来源的时候, 我只需要正常发起请求, 遇到401就去登录, 然后再执行这个获取数据的函数即可, 但当页面上有多个, 比如两个数据来源的时候, 遇到401然后重新请求数据, 此时就可能发生这么一个情况: 没登录, 两个接口都返回401然后都去登录, 就会登录两遍, 虽然登录两遍也只是除了第一次之外又刷新了一次登录的有效期, 但作为一个有(想)追(装)求(X)的程序员, 这样的事我无(很)法(想)容(装)忍(X), 于是就有了这篇文章, 那么接下来, 我们就一起来看看这个问题怎么处理吧
模拟获取数据的函数
不得不说, 后端返回的数据的格式各家公司有各家的规范, 有的公司在处理异常的时候会返回相应的status code, 而有的则是全部按200处理然后通过响应体中的code字段来标识, code为0表示成功, 为其他数字表示异常, 同时这些code和除了200之外的其他status code是一一对应的, 比如401表示未授权这样的
我这边遇到的就是后者
首先我们需要创建一个函数, 模拟请求发送, 登录了返回相应的数据, 未登录则返回401和失败原因这些信息, 同时, 由于我们需要控制请求的失败与否来测试我们的代码, 因此还需要在这个函数的外部定义一个变量, 让我们的这个函数可以根据这个外部变量来修改自己的返回结果, 为了便于测试, 这样的函数我们写两个:
let isFooSuccess = false;
let isFoo2Success = true;
//请求foo数据的函数
const foo = ({ delay }) => (
new Promise(
(resolve, reject) => {
console.log('foo被调用, isFooSuccess', isFooSuccess);
const res = {
code: isFooSuccess ? 0 : 401,
msg: isFooSuccess ? 'foo成功' : 'foo失败, 需要登录',
data: isFooSuccess
? [
{
a: 1
}
]
: []
}
setTimeout(
() => {
isFooSuccess ? resolve(res) : reject(res);
},
delay
)
}
)
)
//请求foo2数据的函数
const foo2 = ({ delay }) => (
new Promise(
(resolve, reject) => {
console.log('foo2被调用, isFoo2Success', isFoo2Success);
const res = {
code: isFoo2Success ? 0 : 401,
msg: isFoo2Success ? 'foo2成功' : 'foo2失败, 需要登录',
data: isFooSuccess
? [
{
a: 1
}
]
: []
}
setTimeout(
() => {
isFoo2Success ? resolve(res) : reject(res);
},
delay
)
}
)
)
两个函数都需要返回一个promise, 参数的话是考虑到实际中会有多个参数的情况, 所以传递的是一个object, 为了方便调试也添加了输出语句, 两个函数返回的结果的结构是一样的, 其实也就是一个封装过的request, 它请求后端接口, 然后返回一个promise
模拟登录的函数
接下来就是模拟登录的函数了:
//登录
const login = (isLoginSuccess, delay) => (
new Promise(
(resolve, reject) => {
console.log('login被调用, isLoginSuccess', isLoginSuccess);
const res = {
msg: isLoginSuccess ? 'success' : 'fail',
code: isLoginSuccess ? 0 : 123,
isLoginSuccess
};
isFooSuccess = isLoginSuccess;
isFoo2Success = isLoginSuccess;
setTimeout(
() => {
isLoginSuccess ? resolve(res) : reject(res);
},
delay
);
}
)
)
登录之后需要将上面我们提到的外部变量做一个修改, 从而当我们再次请求的时候, '后端接口'才知道我们的登录情况
处理接口401的函数
同时我们还需要一个处理401的函数, 同时也是这一整个需求的关键代码: 当响应体中的code的值为401的时候就做错误处理, 然后登录, 接着再再次请求数据, 以及如果遇到其他异常, 那么我们也要用reject抛出
也就是: 遇到请求返回的结果中code为401, 则返回一个reject的promise, 否则resolve:
//遇到请求返回的结果中code为401, 则返回一个reject的promise, 否则resolve
const handlePromise401Reject = (promiseReq, promiseReqParams) => {
const finalPromise = promiseReqParams ? promiseReq(promiseReqParams) : promiseReq();
return new Promise(
(resolve, reject) => {
finalPromise
.then(
res => {
const { code } = res;
code === 401 ? reject(res) : resolve(res);
}
)
.catch(error => {
reject(error);
});
}
)
}
封装一个处理401的函数, 这个函数返回一个promise, 同时它也接收一个返回promise的请求函数, 以及这个请求函数所需要的参数, 当然也要考虑没有参数的情况
有了请求函数, 也有了我们封装的处理401的函数, 现在我们使用一下:
handlePromise401Reject(foo, { delay: 1000 })
.then(res => {
console.log('成功', res);
})
.catch(error => {
console.log('失败', error);
})
我们可以看到, 接口返回401, 此时promise的状态为reject, 最终结果进到了catch回调中, 符合预期
结合起来
接着, 我们需要把上面提到的部分都结合起来, 也就是: 获取数据, 登录未过期则返回数据, 登录过期就先登录然后再去获取数据:
let isFooSuccess = false;
let isFoo2Success = true;
//请求foo数据的函数
const foo = ({ delay }) => (
new Promise(
(resolve, reject) => {
console.log('foo被调用, isFooSuccess', isFooSuccess);
const res = {
code: isFooSuccess ? 0 : 401,
msg: isFooSuccess ? 'foo成功' : 'foo失败, 需要登录',
data: isFooSuccess
? [
{
a: 1
}
]
: []
}
setTimeout(
() => {
isFooSuccess ? resolve(res) : reject(res);
},
delay
)
}
)
)
//请求foo2数据的函数
const foo2 = ({ delay }) => (
new Promise(
(resolve, reject) => {
console.log('foo2被调用, isFoo2Success', isFoo2Success);
const res = {
code: isFoo2Success ? 0 : 401,
msg: isFoo2Success ? 'foo2成功' : 'foo2失败, 需要登录',
data: isFooSuccess
? [
{
a: 1
}
]
: []
}
setTimeout(
() => {
isFoo2Success ? resolve(res) : reject(res);
},
delay
)
}
)
)
//登录
const login = (isLoginSuccess, delay) => (
new Promise(
(resolve, reject) => {
console.log('login被调用, isLoginSuccess', isLoginSuccess);
const res = {
msg: isLoginSuccess ? 'success' : 'fail',
code: isLoginSuccess ? 0 : 123,
isLoginSuccess
};
isFooSuccess = isLoginSuccess;
isFoo2Success = isLoginSuccess;
setTimeout(
() => {
isLoginSuccess ? resolve(res) : reject(res);
},
delay
);
}
)
)
//遇到请求返回的结果中code为401, 则返回一个reject的promise, 否则resolve
const handlePromise401Reject = (promiseReq, promiseReqParams) => {
const finalPromise = promiseReqParams ? promiseReq(promiseReqParams) : promiseReq();
return new Promise(
(resolve, reject) => {
finalPromise
.then(
res => {
const { code } = res;
code === 401 ? reject(res) : resolve(res);
}
)
.catch(error => {
reject(error);
});
}
)
}
//请求数据的函数
const getData = () => {
handlePromise401Reject(foo, { delay: 1000 })
.then(res => {
console.log('成功', res);
})
.catch(error => {
console.log('失败', error);
login(true, 1000)
.then(res => {
console.log('登录成功', res);
getData();
})
.catch(error => {
console.log('登录失败', error);
});
});
};
getData();
请求数据, 首先请求foo数据, 返回401, 此时我们登录, 登录成功之后再次请求, 请求成功, 没问题
多个请求
但实际情况是我们经常会需要处理多个请求的情形, 此时怎么办呢?
比如此时同时请求foo数据和foo2数据, 遇到任意一个返回401, 那么就去登录, 登录成功再次做请求, 也就是: 多个promise, 只要其中一个reject了, 那么最终的结果就是reject, 这个场景是不是似曾相识? 对的, 这就是Promise.all的处理逻辑, 所以刚才提到的这个情形, 我们可以用Promise.all来处理
其余代码不变, 修改我们请求数据的函数getData如下:
//请求数据的函数
const getData = () => {
Promise.all([
handlePromise401Reject(foo, { delay: 1000 }),
handlePromise401Reject(foo2, { delay: 1000 })
])
.then(res => {
console.log('成功', res);
})
.catch(error => {
console.log('失败', error);
login(true, 1000)
.then(res => {
console.log('登录成功', res);
getData();
})
.catch(error => {
console.log('登录失败', error);
});
});
};
getData();
这样我们就解决了这个需求, 但仔细一看还是不够完美, 为什么呢? 比如我们会将handlePromise401Reject用作一个公共的utils, 然后使用的时候import, 但这里还需要登录, 们还要导入login, 毕竟401了需要做登录的操作, 也就是说我们在使用的时候需要导入handlePromise401Reject和login, 这里我们能不能封装一个函数, 直接将login放进去, 我们用这个函数去请求数据, 401了登录, 登录完毕之后将请求到的数据给我们resolve出来, 大概像这样:
const getData = () => {
my401PromiseAll([
handlePromise401Reject(foo, { delay: 1000 }),
handlePromise401Reject(foo2, { delay: 1000 })
])
.then(res => {
console.log('数据获取成功:', res);
})
.catch(error => {
console.log('数据获取失败:', error);
})
}
getData();
说干就干
更完美的方案
这里还是我们熟悉的Pormise.all, 只要有一个reject了那么它的结果就reject, 符合我的需求, 此时我去登录就好了, 然后就resolve了, 这里我们写一个这样的函数:
const my401PromiseAll = promiseReqList => {
return new Promise(
(resolve, reject) => {
console.log('my401PromiseAll被调用');
Promise.all(promiseReqList)
.then(
res => {
console.log('my401PromiseAll resolve', res);
resolve(res);
}
)
.catch(error => {
console.log('my401PromiseAll reject', error);
login(true, 1000)
.then(res => {
console.log('登录成功', res);
my401PromiseAll(promiseReqList);
})
.catch(error => {
console.log('登录失败', error);
});
reject(error);
})
}
)
}
const getData = () => {
my401PromiseAll([
handlePromise401Reject(foo, { delay: 1000 }),
handlePromise401Reject(foo2, { delay: 1000 })
])
.then(res => {
console.log('数据获取成功:', res);
})
.catch(error => {
console.log('数据获取失败:', error);
})
}
getData();
到这里可能有的朋友已经发现问题了: 死循环
是的, 是这样的, 输出真的是最好的学习函数, 我一直以为promise我会了, 直到自己开始做输出, 写技术文章的时候才发现, 是会了, 但没全会
当我们调用getData函数之后, 这段代码的执行是这样:
foo函数被调用foo2函数被调用my401PromiseAll函数被调用fooreject,my401PromiseAll也reject- 进登录流程中,
login被调用 - 同时外部
getData进到了catch中, 因为my401PromiseAllreject了 - 登录成功, 递归调用
my401PromiseAll函数
此时就进入死循环了, 因为my401PromiseAll返回的promise的状态已经改变了, my401PromiseAll内部再次修改它的promise状态将不起作用, foo reject导致my401PromiseAll也reject, 此时内部尝试修改promise的状态为resolve(通过登录)则是徒劳:
图中有个Uncaught (in promise) {code: 401, msg: 'foo失败, 需要登录', data: Array(0)}, 推测是因为外部promise的状态已经改变了, 而我在内部尝试再次修改外部promise状态导致的, 毕竟getData中是写了catch回调的, 不然这句也不会输出:
数据获取失败: {code: 401, msg: 'foo失败, 需要登录', data: Array(0)}
这里没有考虑到外部promise状态改变之后, 内部无法再次变更外部promise的状态, 那怎么办呢?
我们希望foo和foo2中任意一个或者两个都401就去登录, 登录成功再次执行foo和foo2然后再把结果返回给我们, 这个能力Promise.all能提供, 但是不够完美, 因此解决方案就是我们手动实现一个符合我们自己需求的Promise.all即可
最终方案: 自定义Promise.all
这里我们自定义的Promise.all和原生的Promise.all有一些不同, 原生的它状态改变之后无法再次变更, 这里我们需要使得它的状态变更一次之后再次发生变更, 那么就是说在第一次变更之前, 我们需要保存一下请求操作, 流程大概如下:
- 保存请求
- 发送请求,
401, 此时内部状态第一次变更, 但不改变外部状态 - 登录, 登录成功之后再次发送请求, 此时需要发送保存的请求, 因为第一次的状态已经变更了, 我们无法再次变更, 只能产生新的状态
- 第二次发送请求, 请求成功, 此时再改变外部状态
这里的关键在于等待第二次发送的请求, 只有在第二次请求结束之后我们才能改变外部的状态, 而第一次请求发送完成的时候是不能, 相当于让一个任务挂起, 完成之后再继续执行后面的代码
而让任务挂起的操作, 我想到了await, 在await语句没有执行完成的时候后面的代码是不会执行的, 要用await自然少不了async, async函数返回一个promise, 我们只需要在这个async函数里面做请求, 当第二次请求结束之后将结果return出去即可
使用async/await随之而来就是遇到error的时候没法catch了, 此时我们需要用到try catch语句, 从而能让我们可以catch到抛出的error, 具体代码如下:
const my401PromiseAll = async promiseReqList => {
const reservedPromiseReqList = promiseReqList;
let res = null;
console.log('my401PromiseAll被调用');
try{
res = await Promise.all(promiseReqList.map(promiseReq => promiseReq()));
console.log('my401PromiseAll resolve', res);
}catch(error) {
console.log('my401PromiseAll reject', error);
try{
const loginRes = await login(true, 1000);
console.log('登录成功', loginRes);
res = await Promise.all(reservedPromiseReqList.map(promiseReq => promiseReq()));
}catch(error2) {
console.log('登录失败', error2);
res = error2;
}
}
return res;
}
同时使用的时候需要这样:
const getData = () => {
my401PromiseAll([
() => handlePromise401Reject(foo, { delay: 1000 }),
() => handlePromise401Reject(foo2, { delay: 1000 })
])
.then(res => {
console.log('数据获取成功:', res);
})
.catch(error => {
console.log('数据获取失败:', error);
})
}
getData();
由于需要保存请求, 那么就不能像原生的Promise.all那样传递promise实例, 而是传递函数, 这样我们才可以保存之后再次进行调用
同时需要注意的是try catch语句中try代码块部分, 当遇到第一个抛出错误的代码之后, try中抛出错误的代码之后的代码就不会运行了, 会进到catch代码块中, 比如:
let res = null;
try{
res = await foo({ delay: 1000 });
res = await foo2({ delay: 1000 });
console.log('try成功', res);
}catch(error) {
console.log('try没成功', error);
}
当foo抛出错误了, 那么它后面的foo2就不会执行了, foo2后面的输出语句也不会执行, 会进到catch代码块中执行catch代码块中的代码
至此, 便完成了我们小程序静默登录的需求
结语
到这, 可能有的小伙伴会觉得第一种方式, 就是在外部使用login的方式也可以, 确实是这样的, 但我想直接把login也一起封装进去, 毕竟小程序静默登录, 遇到401了肯定要重新登录之后再做接下来的操作, 我觉得它们业务逻辑联系比较紧密, 因此就放到一起了, 再一个我也比较好奇, 好奇如果将login放进去了, 那么我要如何处理, 于是便有了后面的最终方案, 也可以理解为是一种不满足, 一种探索吧
以上就是这篇文章的全部内容了, 如果你觉得这篇文章写得还不错, 别忘了给我点个赞, 如果你觉得对你有帮助, 可以点个收藏, 以备不时之需
参考文献:
后续更新
在文章发布了3个小时之后, 我发现了一个可以优化的点: 我们需要请求多个接口, 当我们知道需要重新登录的时候这几个请求已经都发出去了, 能不能当第一个接口告诉我们超时, 我们就跳过后续所有请求直接去登录, 登录完之后再请求所有全部接口呢?
当然是可以的, 于是我优化了一下代码, 具体如下:
const my401PromiseAll = async promiseReqList => {
const reservedPromiseReqList = promiseReqList;
const isMoreThanOne = promiseReqList.length > 1;
let res = null;
try{
if(isMoreThanOne) {
res = await promiseReqList[0]();
const restReq = promiseReqList.filter((_, idx) => idx !== 0);
res = [
...res,
...await Promise.all(restReq.map(promiseReq => promiseReq()))
]
}else{
res = await Promise.all(promiseReqList.map(promiseReq => promiseReq()));
}
}catch(error) {
try{
await login(true, 1000);
res = await Promise.all(reservedPromiseReqList.map(promiseReq => promiseReq()));
}catch(error2) {
res = error2;
}
}
return res;
}
需要请求的接口数量如果大于1个, 那么我们先请求第一个:
第一个接口请求成功, 那么我们继续请求后续的接口
第一个接口请求失败, 那么我们跳过后续剩余请求直接登录, 登录完再次请求全部所有接口
倘若接口数量就只有1个, 那就走正常的请求逻辑, 成功则resolve, 否则登录之后再次请求
这个优化有点空间换时间的意味, 毕竟多个并发请求, 我拆成了先一个, 再其他, 改成了继发的请求了, 私以为这个优化在用户量比较少的时候不明显, 但用户量一多, 那服务器的压力就可想而知了, 优化的效果就相对明显了
当然了, 这个也是见仁见智的, 欢迎大家跟我留言探讨~