likes
comments
collection
share

前端性能优化:劫持Promise一、使用场景 API 请求优化: 当多个地方需要请求相同的数据时,可以使用 promis

作者站长头像
站长
· 阅读数 16

阅读建议:

一、二、三、四节推荐所有人阅读,五、六节可以选择性阅读

一、使用场景

  1. API 请求优化: 当多个地方需要请求相同的数据时,可以使用 promiseHijack 来避免重复的API请求。例如,在单页应用(SPA)中,如果多个组件需要相同的数据,可以防止多个相同的请求同时发送。

  2. 缓存异步操作: 例如,查询数据库或读取文件系统,如果同一查询在短时间内被多次请求,可以利用这个函数进行优化,避免重复的查询或读取操作。

  3. 并发控制: 在高并发场景下,可以减少对外部资源的压力,如减少对数据库或外部API的重复调用。

二、实现原理

当第一个`promise`没有完成时(没有从`pendding`转变为`fulfilled``rejected`),后续异步操作都将等待首个promise的完成并共享它的执行结果

前端性能优化:劫持Promise一、使用场景 API 请求优化: 当多个地方需要请求相同的数据时,可以使用 promis

三、实现

前端性能优化:劫持Promise一、使用场景 API 请求优化: 当多个地方需要请求相同的数据时,可以使用 promis

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_1hijackedMockApi_2 的请求方
  • 2000ms:hijackedMockApi_3 执行,预计在3000ms执行完成,此时并无未完成promise,则由新的hijack代理
  • 2500ms:hijackedMockApi_4 执行,此时hijackedMockApi_3未完成,则hijackedMockApi_4hijackedMockApi_3 共用同一结果
  • 3000ms:hijackedMockApi_3 执行完成,将结果返回给 hijackedMockApi_3hijackedMockApi_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、不同组件内的异步请求

日常开发中总会遇到不同组件之间同时请求同一个接口的情况(如首次渲染)

前端性能优化:劫持Promise一、使用场景 API 请求优化: 当多个地方需要请求相同的数据时,可以使用 promis

模拟一个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 />
    </>
  );
}

最终渲染

前端性能优化:劫持Promise一、使用场景 API 请求优化: 当多个地方需要请求相同的数据时,可以使用 promis

控制台打印

// 🌈 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

mockApi1000ms后便执行完成,直观上由于 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
评论
请登录