前端性能优化:劫持Promise一、使用场景 API 请求优化: 当多个地方需要请求相同的数据时,可以使用 promis
阅读建议:
一、二、三、四节推荐所有人阅读,五、六节可以选择性阅读
一、使用场景
-
API 请求优化: 当多个地方需要请求相同的数据时,可以使用
promiseHijack
来避免重复的API请求。例如,在单页应用(SPA)中,如果多个组件需要相同的数据,可以防止多个相同的请求同时发送。 -
缓存异步操作: 例如,查询数据库或读取文件系统,如果同一查询在短时间内被多次请求,可以利用这个函数进行优化,避免重复的查询或读取操作。
-
并发控制: 在高并发场景下,可以减少对外部资源的压力,如减少对数据库或外部API的重复调用。
二、实现原理
当第一个
`promise`
没有完成时(没有从`pendding`
转变为`fulfilled`
或`rejected`
),后续异步操作都将等待首个promise的完成并共享它的执行结果
三、实现
promiseHijack 的作用专注于减少
短时间
内的冗余操作,不做缓存或其他操作.短时间:时间的长短不固定,取决于首个promise的执行(发起至完成)时长
例:
// 模拟耗时的promise操作 const delay = () => new Promise(resolve => setTimeout(() => resolve(), 1000)) const delayHijack = promiseHijack(delay) console.time('hijack1') delayHijack().then(() => console.timeEnd('hijack1')) // hijack1: 1000.6240234375 ms console.time('hijack2') delayHijack().then(() => console.timeEnd('hijack2')) // hijack2: 1000.8291015625 ms
该例子中,delay利用setTimeout使得promise在约1000ms后执行完成,结果便是所有被劫持(hijack)的完成时间都接近与“首个promise的执行时长”(1200ms)
四、用例
模拟耗时的promise操作
const delay = ms => new Promise(resolve => setTimeout(() => resolve(), ms)); let count = 1; const mockApi = async (name) => { console.log("👻 ~ mockApi ~ running"); await delay(1000); return `${name}-timestamp-${count++}` }
1、被劫持的情况
不使用promiseHijack
count = 1;
mockApi().then((data) => console.log("mockApi_1:", data));
mockApi().then((data) => console.log("mockApi_2:", data));
mockApi().then((data) => console.log("mockApi_3:", data));
// 👻 ~ mockApi ~ running
// 👻 ~ mockApi ~ running
// 👻 ~ mockApi ~ running
// mockApi_1: undefined-timestamp-1
// mockApi_2: undefined-timestamp-2
// mockApi_3: undefined-timestamp-3
使用promiseHijack
count = 1;
const hijackedMockApi = promiseHijack(mockApi);
hijackedMockApi().then((data) => console.log("hijackedMockApi_1:", data));
hijackedMockApi().then((data) => console.log("hijackedMockApi_2:", data));
hijackedMockApi().then((data) => console.log("hijackedMockApi_3:", data));
// 👻 ~ mockApi ~ running
// hijackedMockApi_1: undefined-timestamp-1
// hijackedMockApi_2: undefined-timestamp-1
// hijackedMockApi_3: undefined-timestamp-1
分析:
-
未使用
promiseHijack
时,mockApi被执行了三次,三次promise
请求的结果都不相同 -
使用
promiseHijack
后,mockApi仅被执行了一次,三次promise
请求共用了首个promise
的结果
2、不被劫持的情况(async await)
count = 1;
const hijackedMockApi = promiseHijack(mockApi);
await hijackedMockApi().then((data) => console.log("hijackedMockApi_1:", data));
await hijackedMockApi().then((data) => console.log("hijackedMockApi_2:", data));
await hijackedMockApi().then((data) => console.log("hijackedMockApi_3:", data));
// 👻 ~ mockApi ~ running
// hijackedMockApi_1: undefined-timestamp-1
// 👻 ~ mockApi ~ running
// hijackedMockApi_2: undefined-timestamp-2
// 👻 ~ mockApi ~ running
// hijackedMockApi_3: undefined-timestamp-3
分析:
这里使用async await来使得各个hijackedMockApi
之间变为串行同步调用,也就导致在hijackedMockApi_2
执行时hijackedMockApi_1
已经执行完毕,则这种情况下他们之间将不共用结果
3、不被劫持的情况(setTimeout)
count = 1;
const hijackedMockApi = promiseHijack(mockApi);
hijackedMockApi().then((data) => console.log("hijackedMockApi_1:", data));
setTimeout(() => {
hijackedMockApi().then((data) => console.log("hijackedMockApi_2:", data));
},500)
setTimeout(() => {
hijackedMockApi().then((data) => console.log("hijackedMockApi_3:", data));
},2000)
setTimeout(() => {
hijackedMockApi().then((data) => console.log("hijackedMockApi_4:", data));
},2500)
// 👻 ~ mockApi ~ running
// hijackedMockApi_1: undefined-timestamp-1
// hijackedMockApi_2: undefined-timestamp-1
// 👻 ~ mockApi ~ running
// hijackedMockApi_3: undefined-timestamp-2
// hijackedMockApi_4: undefined-timestamp-2
分析:
已知mockApi
完成延迟为1000ms, 当有新的hijackedMockApi
在1000ms内发起都会共用首个promise的结果,而超过该时间将不再共用,而由新的hijack来代理
- 0ms:
hijackedMockApi_1
执行,预计在1000ms执行完成 - 500ms:
hijackedMockApi_2
执行,此时hijackedMockApi_1
未完成,则他们共用同一结果 - 1000ms:
hijackedMockApi_1
执行完成,将结果响应给hijackedMockApi_1
和hijackedMockApi_2
的请求方 - 2000ms:
hijackedMockApi_3
执行,预计在3000ms执行完成,此时并无未完成promise,则由新的hijack代理 - 2500ms:
hijackedMockApi_4
执行,此时hijackedMockApi_3
未完成,则hijackedMockApi_4
与hijackedMockApi_3
共用同一结果 - 3000ms:
hijackedMockApi_3
执行完成,将结果返回给hijackedMockApi_3
和hijackedMockApi_4
的请求方
例子中我们的mockApi使用的delay由setTimeout实现,也就是非JavaScript主线程来实现的延迟,若使用其他方式,例如
queueMicrotask
实现的延迟则执行效果会不一样,由于我们讨论的异步场景为网络请求、文件读取等此类非JavaScript主线程的异步操作,若要了解其他使用场景请移步 第六节《多想亿点》
4、不被劫持的情况(arguments)
const fn = async () => {}
const hijackedFn = promiseHijack(fn);
hijackedFn(...arguments_1)
hijackedFn(...arguments_2)
promiseHijack
中使用 lodash/isEqual 来比较arguments的相同(当然你可以通过传递自定义的isEqual来改变默认的比较行为),只有arguments比较相同的promise操作才会共用同一个结果,如下:
count = 1;
const hijackedMockApi = promiseHijack(mockApi);
// 相同的一般类型
hijackedMockApi(1).then((data) => console.log("hijackedMockApi_1:", data));
hijackedMockApi(1).then((data) => console.log("hijackedMockApi_2:", data));
// 相同的引用类型
hijackedMockApi({x: "y"}, {m: "n"}).then((data) => console.log("hijackedMockApi_3:", data));
hijackedMockApi({x: "y"}, {m: "n"}).then((data) => console.log("hijackedMockApi_4:", data));
/* console log */
// 👻 ~ mockApi ~ running
// 👻 ~ mockApi ~ running
// hijackedMockApi_1: 1-timestamp-1
// hijackedMockApi_2: 1-timestamp-1
// hijackedMockApi_3: [object Object]-timestamp-2
// hijackedMockApi_4: [object Object]-timestamp-2
例子中参数[1]
与[1]
相同,则他们共享同一个结果;[{x: "y"}, {m: "n"}]
与[{x: "y"}, {m: "n"}]
相同,则他们共享同一个结果;而[1]
与[{x: "y"}, {m: "n"}]
不同则不共享同一结果。
五、实战
1、不同组件内的异步请求
日常开发中总会遇到不同组件之间同时请求同一个接口的情况(如首次渲染)
模拟一个get请求
async function getRainbow() {
console.log('🌈 is coming');
await delay(200);
return { data: '🌈' };
}
export const getRainbowHijack = promiseHijack(getRainbow);
组件A/组件B
export default function ComponentA() {
const [data, setData] = useState(null);
useEffect(() => {
getRainbowHijack().then(res => {
setData(res.data);
});
}, []);
return (
<div>
<h1>ComponentA</h1>
<div>GET: {data}</div>
</div>
);
}
开始运行
import A from './ComponentA';
import B from './ComponentB';
export default function App() {
return (
<>
<A />
<B />
</>
);
}
最终渲染
控制台打印
// 🌈 is coming
可以观察到getRainbow
只执行了一次,但两个组件都拿到了它们想要的数据😊
如果你希望请求结果可以被缓存,你可以实现一个单例模式的函数来缓存数据,再使用promiseHijack包裹该函数,让promiseHijack帮你代理冗余请求,你只需要专注缓存策略
2、JWT Token 刷新
JWT(JSON Web Token)是一种用于在各方之间作为 JSON 对象安全传输信息的紧凑、URL安全的令牌,常用于认证和授权。
JWT一般使用双token的方式来进行用户信息验证:
- 短效token:有效时间较短,一般用于进行易变更的用户信息校验,如会员信息,订单信息等
- 长效token:有效时间较长,一般用于不易变更的用户信息校验,如uid等
短效token易过期就涉及到了token刷新的问题,token刷新可以由后端刷新,也可由前端刷新,我们这里采用前端刷新的策略,而前端来刷新token就涉及到一个问题——当token过期需要刷新时如何拦截已执行的request,让它们在token刷新完毕之后再真正发起HTTP请求。
关于JWT的相关知识这里不做过多介绍,我们暂时专注于promiseHijack的介绍
拦截它们👻!
我们使用promiseHijack来达到目的,接下来我们先模拟使用场景;
// token.js
import { promiseHijack } from "./promiseHijack"
// storage 可以为localStorage、sessionStorage等
export function getToken(key) {
return storage.getItem(key);
}
export function setToken(key, token) {
storage.setItem(key, token);
}
export function deleteToken(key) {
storage.removeItem(key);
}
/*
* access_token 短效token
* refresh_token 长效token
*/
export async function getAccessToken() {
try {
const { token: accessToken, exp } = getToken('access_token') || {};
const restTime = exp ? new Date(exp).getTime() - Date.now() : 0;
// 提前一分钟刷新token
const onMinute = 60000;
if (restTime > onMinute) return accessToken;
const refreshToken = getToken('refresh_token');
if (!refreshToken) return '';
const { access_token, expires, refresh_token } = await refreshTokenApi({ refresh_token: refreshToken });
setToken('access_token', { token: access_token, exp: expires });
setToken('refresh_token', refresh_token);
return access_token;
} catch (error) {
deleteToken('access_token');
deleteToken('refresh_token');
console.error('token error:', error);
return '';
}
}
export const hijackedGetAccessToken = promiseHijack(getAccessToken)
// request.js
import { hijackedGetAccessToken } from "./token"
const getHeaders = async (headers = {}) => {
const auth = await hijackedGetAccessToken();
if (auth) return { Authorization: auth, ...headers };
return headers;
};
async function request(url,options){
return fetch(url, {...options, headers: await getHeaders(options.headers) })
}
如此封装后,外部调用request时,当短效token过期后所有request请求都将被拦截直至getAccessToken
请求到最新的短效token,被拦截的所有request都将携带最新有效的短效token发起请求😀。
六、多想亿点
1、讨论微任务导致的问题
在讨论以下例子前,你至少需要了解的知识有:事件循环、微任务与宏任务、Promise的临时中间队列与执行队列、queueMicrotask
在第四节用例的第3小节 不被劫持的情况(setTimeout)中delay使用setTimeout来实现
// 使用setTimeout实现的delay
const hijackedMockApi = promiseHijack(mockApi);
hijackedMockApi().then((data) => console.log("hijackedMockApi_1:", data));
setTimeout(() => {
hijackedMockApi().then((data) => console.log("hijackedMockApi_2:", data));
},500)
// 👻 ~ mockApi ~ running
// hijackedMockApi_1: undefined-timestamp-1
// hijackedMockApi_2: undefined-timestamp-1
我们将delay改为用queueMicrotask
实现:
function delay(ms: number): Promise<void> {
return new Promise<void>((resolve) => {
const now = Date.now();
sleepLoop();
function sleepLoop() {
if (Date.now() - now > ms) resolve();
else queueMicrotask(sleepLoop);
}
});
}
再重新执行一次案例:
// 使用setTimeout实现的delay
const hijackedMockApi = promiseHijack(mockApi);
hijackedMockApi().then((data) => console.log("hijackedMockApi_1:", data));
// *标记
setTimeout(() => {
hijackedMockApi().then((data) => console.log("hijackedMockApi_2:", data));
},500)
// 👻 ~ mockApi ~ running
// hijackedMockApi_1: undefined-timestamp-1
// 👻 ~ mockApi ~ running
// hijackedMockApi_2: undefined-timestamp-2
分析:
这种情况下 mockApi 持续产生微任务,导致*标记
处setTimeout的回调一直不被执行,进而使得hijackedMockApi_2没有命中hijack。
直观上hijackedMockApi_2应该被hijack命中,但由于Javascript运行环境的运行机制导致结果不如预期,这是我们在使用promiseHijack时需要注意的问题
2、讨论执行栈阻塞的情况
在讨论以下例子前,你至少需要了解的知识有:事件循环、微任务与宏任务
我们先实现一个阻塞执行栈的sleepSync
函数
// 利用while使得JavaScript主线程空转,这里是为了讨论需要,日开开发中应禁止使用这种奇技淫巧
function sleepSync(ms){
const now = Date.now()
while (Date.now() - now <= ms) {}
}
// const delay = () => new Promise(resolve => setTimeout(() => resolve(), 1000))
const hijackedMockApi = promiseHijack(mockApi);
hijackedMockApi().then((data) => console.log("hijackedMockApi_1:", data));
sleepSync(2000);
hijackedMockApi().then((data) => console.log("hijackedMockApi_2:", data));
// 👻 ~ mockApi ~ running
// hijackedMockApi_1: undefined-timestamp-1
// hijackedMockApi_2: undefined-timestamp-1
mockApi
1000ms后便执行完成,直观上由于 sleepSync 同步执行了2000ms,hijackedMockApi_2 执行时已经在 2000ms 后 ,时 ,hijackedMockApi_1 理应已经完成,它们不应该共用同一结果啊;
之所以使用的了同一个结果,是因为即使sleepSync导致阻塞了2000ms,但是两个hijackedMockApi也是同步执行的,无论中间耗时多久,同一次事件循环中被同步调用,那么就会被同一个hijack代理
3、讨论promiseHijack功能增强
现有promiseHijack采用的策略是多个promise操作共享首个promise的操作结果,此时若首个promise的结果为异常结果(reject)则多个promise操作都将收到该异常结果,但实际情况可能出现 第一次的promise请求异常,而第二次请求却能获取到正常结果(如网络波动等),如果promiseHijack能有一个配置使得“首个promise获取到异常结果后不直接返回异常,而是重新执行n次尝试获取到正常结果后再返回给其他promise操作”
上述只是promiseHijack功能增强的一种场景,当然还有很多实际开发中遇到的场景,我们都可以基于promiseHijack来达到我们的目的
七、附件
转载自:https://juejin.cn/post/7380569775687008295