在后台结合异步请求
#javascript #async #promise

当我在Shopee中时,有一个有趣的案例,关于触发某些API请求的React组件,并且该组件的一长长列表经常出现在页面中。结果,客户端在短时间内发送了许多请求,不仅放慢了界面,而且也大大占用了服务器资源。

为了改善这一点,我的聪明队友创建​​了一个我非常喜欢的“批处理”解决方案,并在稍后采访中使用了这个想法。

我们是如何实现这一目标的?

让我首先使用一些详细的设置回顾一下情况:

  1. 有一个<ListItem/>组件在某些事件(例如,鼠标enter)下发送带有强制性参数的api GET /item,例如项目ID:
import React, { useCallback, useState } from 'react';

// Proper error handling is ignored here...
const fetchItem = id => fetch(`/item?id=${id}`).then(res => res.json());

export function ListItem({ id }) {
  const [item, setItem] = useState({});
  const onMouseEnter = useCallback(() => {
    fetchItem(id).then(setItem);
  }, [id]);
  return <div onMouseEnter={onMouseEnter} >{item.name}</div>;
}
  1. <ListItem/>组件用于显示一个长列表,例如:
import React from 'react';
import { ListItem } from './ListItem';
export function List({ ids }) {
  return (
    <div>
      {ids.map(id => 
        <ListItem id={id} />
      )}
    </div>
  );
}

现在,您可能会质疑不是仅在<List/>组件中发送诸如GET /items?ids=1,2,3之类的列表请求的解决方案吗?好吧,在这里并非如此,因为仅在用户触发特定事件时,请求才发送。但是您越来越接近点 - 而不是用不同的参数反复反复的GET /item?id=xxx,最好找到一种发送GET /items?ids=1,2,3的方法。

由于这些请求是在不同时间发送的,这是做出的重要决定:在哪个条件下,我们应结合请求

这里有2个想法:

  1. 结合一定期内触发的请求;
  2. 当请求队列达到一定长度时,请组合请求。

时序是考虑接口正在等待请求的响应数据的第一个因素,我们不会让用户等待太久。

排队请求长度但是,更多取决于服务器端。他们一次可以处理多少个项目(考虑每个项目可能需要额外的信息来处理)?是否有任何请求URL(用于获取请求)或身体尺寸限制(nginx这样的负载平衡器具有默认设置,有时也是框架集限制)?

通过时间结合

说到时间和要求,有两种众所周知的现有策略:throttle and debounce

调试将函数调用延迟到定义的时间段,以避免不必要的调用。它可以在按钮点击和关键事件中效果很好。

节气门只要活动触发器处于活动状态,就会定期调用回调函数。它更适合连续事件。

在这里,我将通过节气门启发的策略演示。

首先,让我们添加一个fetchItems()助手:

const fetchItems = ids => fetch(`/item?ids=${ids.join(',')}`).then(res => res.json());

然后调整原始fetchItem()将单个参数存储到队列中:

const queue = [];
const fetchItem = id => {
    queque.push(id);
    // TODO: flush the queue
};

并用计时器延迟200ms

const queue = [];
const fetchItem = id => {
  queue.push(id);
    if (queue.length === 1) {
      setTimeout(() => {
        fetchItems(quque);
        queue.length = 0;
      }, 200);
    }
}

您可能会注意到,我只有在参数开始排队时才创建一个计时器,并在发送批处理请求后清除队列。

现在,它应该适用于不希望响应的请求(例如记录用户行为)。如果我们想处理响应,我们可能会创建一个Promise并以稍后的方式缓存:

const queue = [];
let task;
const fetchItem = id => {
  queue.push(id);
    if (queue.length === 1) {
      task = new Promise((resolve) => {
            setTimeout(() => {
            fetchItems(quque);
            queue.length = 0;
          }, 200);
        });
    }
    return task;
}

承诺正在包装计时器,每个单个fetchItem()“请求”都返回了这一承诺。结果,一旦这项承诺解决:
您可能会在同一批次中获得所有项目

fetchItem(1).then(items => items[0]);
fetchItem(2).then(items => items[1]);
fetchItem(3).then(items => items[2]);

此外,我们可以将此逻辑抽象为未来用法:

function batchRequests(request, paramMerger, period = 200) {
  const queue = [];
  let task;

  return (params) => {
    queue.push(params);
    if (queue.length === 1) {
      task = new Promise((resolve) => {
        setTimeout(() => {
          const params = paramMerger(queue.slice());
          queue.length = 0;
          resolve(request(params));
        }, period);
      });
    }
    return task;
  };
}
const fetchItem = id => batchRequests(
  fetchItems,
  ids => ids
);

这是可以尝试的代码框:

结合计数

仅通过计数组合请求非常简单。让我们重复使用上面创建的fetchItems()

const MAX_QUEUE = 5;
const queue = [];
const fetchItem = id => {
  queue.push(id);
    if (queue.length === MAX_QUEUE) {
    fetchItems(quque);
    queue.length = 0;
    }
}

但是,仅考虑计数是一个坏主意,因为fetchItem()在收集足够的参数之前永远不会发送请求。因此,我们将一起考虑这样的时间:

const MAX_QUEUE = 5;
const queue = [];
let timer;
const flushQueue = () => {
    clearTimeout(timer);
    fetchItems(quque);
  queue.length = 0;
};
const fetchItem = id => {
  queue.push(id);
    if (queue.length === 1) {
      timer = setTimeout(flushQueue, 200);
    } else if (queue.length === MAX_QUEUE) {
    flushQueue();
    }
}

和糟糕,如果我们期望来自函数的响应数据,那将变得很棘手:

const MAX_QUEUE = 5;
const queue = [];
let timer;
let flushQueue = () => null;
const fetchItem = id => {
  queue.push(id);
  if (queue.length === 1) {
    const timer = setTimeout(() => flushQueue(), period);
    task = new Promise((resolve) => {
      flushQueue = () => {
        const params = paramMerger(queue.slice());
        queue.length = 0;
        clearTimeout(timer);
        resolve(request(params));
      };
    });
  } else if (queue.length === MAX_QUEUE) {
    flushQueue();
  }
  return task;
};

这是因为它试图解决两个两个条件的承诺,但是我们只为每批次创建一次计时器和承诺。它看起来很混乱和冒险。我们稍后将讨论替代方案。

与上述类似,我们可能会为将来的用法提取逻辑:

function batchRequests(request, paramMerger, period = 200, maxQueue = 5) {
  const queue = [];
  let task;
  let flush = () => null;
  if (maxQueue < 2) {
    throw new Error("Max queuable requests must be more than 1!");
  }

  return (params) => {
    queue.push(params);
    if (queue.length === 1) {
      const timer = setTimeout(() => flush(), period);
      task = new Promise((resolve) => {
        flush = () => {
          const params = paramMerger(queue.slice());
          queue.length = 0;
                    clearTimeout(timer);
          resolve(request(params));
        };
      });
    } else if (queue.length === maxQueue) {
      flush();
    }
    return task;
  };
}

这是可以尝试的代码框:

替代方案

当逻辑变得棘手时,我们很可能存在错误或简单的替代方法。

在这里获得fetchItems()响应,我相信诸如Redux之类的州经理是更好的选择。使用州经理,我们只需要在修改后的fetchItem()中更新项目状态即可。然后,“反应性”框架将通知更改并渲染最新数据,而解决的承诺不再是fetchItem()

风险

在用户关闭选项卡而无需发送请求和您的重要数据(例如,数据与广告费用有关的数据)丢失之前,一切看起来都很好!有必要考虑将逻辑的这一部分移至ServiceWorker中以避免损失。

终于

总结,处理异步请求是当今必不可少的前端技能,有时不仅仅是应用aysnc/awaitPromise Apis。