前言
最近刚做了一个小程序, 里面涉及到用户授权以及登录的情况, 初次登录需要获取用户信息, 然后再走登录流程, 后续就不需要用户授权了, 就可以直接走登录流程了
同时有的数据需要登录之后才能获取, 如果未登录则返回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
函数被调用foo
reject
,my401PromiseAll
也reject
- 进登录流程中,
login
被调用 - 同时外部
getData
进到了catch
中, 因为my401PromiseAll
reject
了 - 登录成功, 递归调用
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
, 否则登录之后再次请求
这个优化有点空间换时间的意味, 毕竟多个并发
请求, 我拆成了先一个, 再其他, 改成了继发
的请求了, 私以为这个优化在用户量比较少的时候不明显, 但用户量一多, 那服务器的压力就可想而知了, 优化的效果就相对明显了
当然了, 这个也是见仁见智的, 欢迎大家跟我留言探讨~