前言
做了一个小程序, 里面涉及到用户授权以及登录的情况, 初次登录需要获取用户信息, 然后再走登录流程, 后续就不需要用户授权了, 就可以直接走登录流程了
同时有的数据需要登录之后才能获取, 如果未登录则返回 401, 此时需要先登录, 然后再去获取数据
也就是说需要做静默登录的操作: 请求数据, 登录了则正常获取数据, 而如果未登录则需要先登录然后才能获取数据
当页面只有一个数据来源的时候, 只需要正常发起请求, 遇到 401 就去登录, 然后再执行这个获取数据的函数即可, 但当页面上有多个, 比如两个数据来源的时候, 遇到 401 然后重新请求数据, 此时就可能发生这么一个情况: 没登录, 两个接口都返回 401 然后都去登录, 就会登录两遍, 虽然登录两遍也只是除了第一次之外又刷新了一次登录的有效期
模拟获取数据的函数
不得不说, 后端返回的数据的格式各家公司有各家的规范, 有的公司在处理异常的时候会返回相应的 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();
发现问题了: 死循环
当调用 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 回调的, 不然这句也不会输出:
数据获取失败:
这里没有考虑到外部 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 代码块中的代码
后续更新
发现了一个可以优化的点: 需要请求多个接口, 当知道需要重新登录的时候这几个请求已经都发出去了, 能不能当第一个接口告诉超时, 就跳过后续所有请求直接去登录, 登录完之后再请求所有全部接口呢?
当然是可以的, 于是优化了一下代码, 具体如下:
const my401PromiseAll = async (promiseReqList) => {
const reservedPromiseReqList = promiseReqList;
const isMoreThanOne = promiseReqList.length > 1;
let res = null;
try {
if (isMoreThanOne) {
res = await Promise.all([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, 否则登录之后再次请求
这个优化有点空间换时间的意味, 毕竟多个并发请求, 拆成了先一个, 再其他, 改成了继发的请求了, 私以为这个优化在用户量比较少的时候不明显, 但用户量一多, 那服务器的压力就可想而知了, 优化的效果就相对明显了