这可能是最通用的合并请求的方法了

731 阅读11分钟

emmmm,有点吹牛,问题不大。

背景

点赞点踩在大部分的业务中都会使用到。比如下面的需求,给某个用户点赞。

image.png

本篇文章,会从这个业务场景切入,逐步抽象封装出一个通用的适用于需要合并请求参数或者频率控制的操作的方法。

在本篇文章中,我将使用mockLikeRequest方法模拟向后端发送点赞或者点踩请求的接口。

interface RequestParamsProps {
  userId: string; // 给谁点赞
  num: number; // 数量
  type: 0 | 1; // 0 点踩, 1点赞
}

// 模拟向后端发送点赞或点踩的请求接口
const mockLikeRequest = (params: RequestParamsProps) =>
  new Promise((resolve) => {
    setTimeout(() => {
      console.log('后端接口请求成功,参数', params);
      resolve(1);
    }, 500);
  });

为什么需要合并请求

上面点赞的需求,有点经验的同学,马上就可以写出下面的代码:

const Demo = memo(() => {
  const [count, setCount] = useState(0);

  return (
    <div>
      <button
        onClick={() => {
          setCount(count + 1);
          mockLikeRequest({
            userId: '123',
            num: 1,
            type: 1,
          });
        }}
      >
        给小明点赞{count}
      </button>
    </div>
  );
});

非常简单,点击一次就调用一次接口,向后端发送请求。前端同学是轻松了,但后端同学显然不太满意:你这点击一次就调用一次接口,如果用户连点N次,就有N个请求过来,如果有M个用户同时连点,那就有N * M个请求过来,我服务器压力会很大,前端能不能合并一下请求?

极简版合并请求的方法

好的,当然可以。这时,经验稍稍丰富的同学,这不就是节流吗,然后合并一下点赞数量就好了,很简单的,于是,有了下面的代码:

const Demo = memo(() => {
  const [count, setCount] = useState(0);
  const timerId = useRef<any>();
  const likeNum = useRef(0);
  return (
    <div>
      <button
        onClick={() => {
          setCount(count + 1);
          // 先累计likeNum
          likeNum.current = likeNum.current + 1;
          // 节流,1秒内只发送一次请求,合并点赞数量
          if (!timerId.current) {
            timerId.current = setTimeout(() => {
              mockLikeRequest({
                userId: '123',
                num: likeNum.current,
                type: 1,
              });
              // 重置状态
              likeNum.current = 0;
              timerId.current = null;
            }, 1000);
          }
        }}
      >
        给小明点赞{count}
      </button>
    </div>
  );
});

image.png

确实满足了请求。既满足了产品需求,又满足了后端的要求。但,你以为这样就完美了吗。如果产品改了需求,比如加了个给小明点踩的功能。如下面所示,既可以给小明点赞,又可以给小明点踩。

image.png 为了方便将后端接收到的点赞和点踩数量区分开,我们修改一下mock接口,分别统计后端接收到的点赞和点踩数量。

let disLikeCount = 0;
let likeCount = 0;
// 模拟向后端发送点赞或点踩的请求接口
const mockLikeRequest = (params: RequestParamsProps) =>
  new Promise((resolve) => {
    setTimeout(() => {
      if (params.type === 0) {
        disLikeCount = disLikeCount + params.num;
        console.log('后端接口请求成功,参数', params, '后端接收到的累计的点踩数量:', disLikeCount);
      } else {
        likeCount = likeCount + params.num;
        console.log('后端接口请求成功,参数', params, '后端接收到的累计的点赞数量:', likeCount);
      }
      resolve(1);
    }, 500);
  });

基于这个业务场景,经验稍稍丰富的同学可能就这么写了:

const Demo = memo(() => {
  const [count, setCount] = useState(0);
  const timerId = useRef<any>();
  const likeNum = useRef(0);

  const [disLikeCount, setDisLikeCount] = useState(0);
  const disLikeTimerId = useRef<any>();
  const disLikeNum = useRef(0);
  return (
    <div>
      <button
        onClick={() => {
          setCount(count + 1);
          // 先累计likeNum
          likeNum.current = likeNum.current + 1;
          // 节流,1秒内只发送一次请求,合并点赞数量
          if (!timerId.current) {
            timerId.current = setTimeout(() => {
              mockLikeRequest({
                userId: '123',
                num: likeNum.current,
                type: 1,
              });
              // 重置状态
              likeNum.current = 0;
              timerId.current = null;
            }, 1000);
          }
        }}
      >
        给小明点赞{count}
      </button>
      <button
        onClick={() => {
          setDisLikeCount(disLikeCount + 1);
          // 先累计disLikeNum
          disLikeNum.current = disLikeNum.current + 1;
          // 节流,1秒内只发送一次请求,合并点踩数量
          if (!disLikeTimerId.current) {
            disLikeTimerId.current = setTimeout(() => {
              mockLikeRequest({
                userId: '123',
                num: disLikeNum.current,
                type: 0,
              });
              // 重置状态
              disLikeNum.current = 0;
              disLikeTimerId.current = null;
            }, 1000);
          }
        }}
      >
        给小明点踩{disLikeCount}
      </button>
    </div>
  );
});

自测了一下,如下图,完美,没啥问题。

image.png

但是,如果此时需求再变化了,比如产品又增加了给小红点赞和点踩的能力:

image.png

上面的代码虽然能跑,但已经没法满足我们业务迭代扩展的需求。总不能继续堆屎山,复制粘贴吧?经验较丰富的同学此时已经开始思考如何抽象封装通用的方法,既能满足业务需求,又能满足性能的要求,同时代码还容易维护

抽象版合并请求的方法

在开始前,我们先改造一下mock后端请求的方法,方便分人分点赞或点踩打印出后端累计接收到的数量,这里维护了一个countMap,用userid和type当作键,这样就可以分人和点赞,点踩类型统计数量。

const countMap = {};
const mockLikeRequest = (params: RequestParamsProps) =>
  new Promise((resolve) => {
    setTimeout(() => {
      const id = `${params.userId}-${params.type}`;
      countMap[id] = countMap[id] ? countMap[id] + params.num : params.num;
      console.log(
        '后端接口请求成功,参数',
        params,
        `后端接收到的${params.userId}累计的${params.type === 0 ? '点踩' : '点赞'}数量:`,
        countMap[id],
      );
      resolve(1);
    }, 500);
  });

经验稍丰富的同学马上就可以抽象出一个createAutoMergeRequest方法:


const createAutoMergeRequest = (userId: string, type: 0 | 1) => {
  let timerId: any = null;
  let total = 0;
  return (requestParams: { num: number }) => {
    total = total + requestParams.num;
    if (!timerId) {
      timerId = setTimeout(() => {
        mockLikeRequest({
          userId,
          num: total,
          type,
        });
        total = 0;
        timerId = 0;
      }, 1000);
    }
  };
};

这个方法放回一个函数,函数里面累计num,并启动定时器执行真正的请求方法。于是,我们可以为小明和小红分别创建4个具有合并请求的方法:

// 合并点赞小明的接口
const likeXiaoMingRequest = createAutoMergeRequest('小明', 1);
// 合并点踩小明的接口
const disLikeXiaoMingRequest = createAutoMergeRequest('小明', 0);
// 合并点赞小红的接口
const likeXiaoHongRequest = createAutoMergeRequest('小红', 1);
// 合并点踩小红的接口
const disLikeXiaoHongRequest = createAutoMergeRequest('小红', 0);

然后这么使用:

const Demo = memo(() => {
  // 以下计数器只是用于方便演示实际数量和后端收到的数量是否一致
  const [likeXiaoMingcount, setLikeXiaoMingCount] = useState(0);
  const [dislikeXiaoMingcount, setDisLikeXiaoMingCount] = useState(0);
  const [likeXiaoHongcount, setLikeXiaoHongCount] = useState(0);
  const [dislikeXiaoHongcount, setDisLikeXiaoHongCount] = useState(0);
  return (
    <div>
      <button
        onClick={() => {
          setLikeXiaoMingCount(likeXiaoMingcount + 1);
          likeXiaoMingRequest({
            num: 1,
          });
        }}
      >
        给小明点赞{likeXiaoMingcount}
      </button>
      <button
        onClick={() => {
          setDisLikeXiaoMingCount(dislikeXiaoMingcount + 1);
          disLikeXiaoMingRequest({
            num: 1,
          });
        }}
      >
        给小明点踩{dislikeXiaoMingcount}
      </button>
      <button
        onClick={() => {
          setLikeXiaoHongCount(likeXiaoHongcount + 1);
          likeXiaoHongRequest({
            num: 1,
          });
        }}
      >
        给小红点赞{likeXiaoHongcount}
      </button>
      <button
        onClick={() => {
          setDisLikeXiaoHongCount(dislikeXiaoHongcount + 1);
          disLikeXiaoHongRequest({
            num: 1,
          });
        }}
      >
        给小红点踩{dislikeXiaoHongcount}
      </button>
    </div>
  );
});

自测了一下,结果如下:

image.png

可以发现其实还是能满足需求的。实际上,到这里已经说明我们具备了一定的抽象封装能力,这也是我们第一版抽象,很nice。

但是,如果我们再仔细看看createAutoMergeRequest的逻辑,会发现它不够通用。因为这里我们耦合了参数合并的逻辑,这次需求是合并num,那如果下次的需求是合并评论呢,比如将多条评论的消息合并成一个数组,并调用一次接口。显然createAutoMergeRequest并不满足需求。

按照我们的做法,我们就得创建一个合并评论的方法:

const createAutoMergeCommentRequest = (userId: string) => {
  let timerId: any = null;
  let comments: string[] = [];
  return (requestParams: { comment: string }) => {
    comments.push(requestParams.comment);
    if (!timerId) {
      timerId = setTimeout(() => {
        mockLikeRequest({
          userId,
          comments,
        });
        comments = [];
        timerId = 0;
      }, 1000);
    }
  };
};

然后为每个用户创建一个请求的方法:

// 合并评论小明的接口
const likeXiaoMingRequest = createAutoMergeCommentRequest('小明');

// 合并评论小红的接口
const likeXiaoHongRequest = createAutoMergeCommentRequest('小红');

显然,这里面有个弊端,我们没法知道合并参数的具体逻辑是什么,如果逻辑不同,我们的createAutoMergeRequest方法就没法做到复用!

将参数合并策略开放出来

到这里,经验较丰富的同学已经想到可以将参数合并的策略暴露出来给开发定制。改造createAutoMergeRequest方法,同时将mockLikeRequest改名为mockRequest。

interface OptionsProps<reqT> {
  howToMergeRequest: (reqParams: reqT, result: reqT) => reqT;
}
export const createAutoMergeRequest = <reqT>(options: OptionsProps<reqT>) => {
  const { howToMergeRequest } = options;
  let timerId: any = null;
  let resultReq: reqT | null = null;
  return (requestParams: reqT) => {
    if (!resultReq) {
      // 说明是第一次调用,此时直接将第一次调用的参数赋给resultReq,不需要调用howToMergeRequest合并参数
      resultReq = requestParams;
    } else {
      // 第二次及以后调用,则需要合并参数
      resultReq = howToMergeRequest(requestParams, resultReq);
    }
    if (!timerId) {
      timerId = setTimeout(() => {
        mockRequest(requestParams);
        resultReq = null;
        timerId = null;
      }, 1000);
    }
  };
};

这里我们将合并参数的策略交给调用方定制。使用如下:

// 合并点赞小明的接口
const likeXiaoMingRequest = createAutoMergeRequest<RequestParamsProps>({
  howToMergeRequest: (req, resultReq) => {
    resultReq.num = req.num + resultReq.num;
    return resultReq;
  },
});
// 合并点踩小明的接口
const disLikeXiaoMingRequest = createAutoMergeRequest<RequestParamsProps>({
  howToMergeRequest: (req, resultReq) => {
    resultReq.num = req.num + resultReq.num;
    return resultReq;
  },
});
// 合并点赞小红的接口
const likeXiaoHongRequest = createAutoMergeRequest<RequestParamsProps>({
  howToMergeRequest: (req, resultReq) => {
    resultReq.num = req.num + resultReq.num;
    return resultReq;
  },
});
// 合并点踩小红的接口
const disLikeXiaoHongRequest = createAutoMergeRequest<RequestParamsProps>({
  howToMergeRequest: (req, resultReq) => {
    resultReq.num = req.num + resultReq.num;
    return resultReq;
  },
});

const Demo = memo(() => {
  // 以下计数器只是用于方便演示实际数量和后端收到的数量是否一致
  const [likeXiaoMingcount, setLikeXiaoMingCount] = useState(0);
  const [dislikeXiaoMingcount, setDisLikeXiaoMingCount] = useState(0);
  const [likeXiaoHongcount, setLikeXiaoHongCount] = useState(0);
  const [dislikeXiaoHongcount, setDisLikeXiaoHongCount] = useState(0);
  return (
    <div>
      <button
        onClick={() => {
          setLikeXiaoMingCount(likeXiaoMingcount + 1);
          likeXiaoMingRequest({
            num: 1,
            userId: '小明',
            type: 1,
          });
        }}
      >
        给小明点赞{likeXiaoMingcount}
      </button>
      <button
        onClick={() => {
          setDisLikeXiaoMingCount(dislikeXiaoMingcount + 1);
          disLikeXiaoMingRequest({
            num: 1,
            userId: '小明',
            type: 0,
          });
        }}
      >
        给小明点踩{dislikeXiaoMingcount}
      </button>
      <button
        onClick={() => {
          setLikeXiaoHongCount(likeXiaoHongcount + 1);
          likeXiaoHongRequest({
            num: 1,
            userId: '小红',
            type: 1,
          });
        }}
      >
        给小红点赞{likeXiaoHongcount}
      </button>
      <button
        onClick={() => {
          setDisLikeXiaoHongCount(dislikeXiaoHongcount + 1);
          disLikeXiaoHongRequest({
            num: 1,
            userId: '小红',
            type: 0,
          });
        }}
      >
        给小红点踩{dislikeXiaoHongcount}
      </button>
    </div>
  );
});

结果如下:

image.png

这次我们的方法更加通用了,但是还有问题,可以发现我们调用了四次createAutoMergeRequest方法创建了四个函数,但这几个函数的参数都是一样的,同时操作也是一样的。能不能做成这样,我只需调用一次createAutoMergeRequest方法,然后就可以直接使用。比如:

// 只需调用一次
const autoMergeRequest = createAutoMergeRequest<RequestParamsProps>({
  howToMergeRequest: (req, resultReq) => {
    resultReq.num = req.num + resultReq.num;
    return resultReq;
  },
});

// 就可以像下面这样使用:

// 给小明点赞
autoMergeRequest({
  num: 1,
  userId: '小明',
  type: 1,
});

// 给小明点踩
autoMergeRequest({
  num: 1,
  userId: '小明',
  type: 0,
});

显然目前的createAutoMergeRequest方法不满足要求,因为调用一次createAutoMergeRequest方法只会存在一个定时器,没法做到分开调用接口,比如调用接口给小明点赞,又调用一次接口给小明点踩,如果要满足这个,就需要不同的定时器

优化定时器的逻辑

继续改造createAutoMergeRequest方法,这里,我们需要使用一个map维护定时器,比如timerMap,针对不同的userId和type生成唯一的键值。同时也需要维护一个resultMap。如下面所示:

export const createAutoMergeRequest = <reqT>(options: OptionsProps<reqT>) => {
  const { howToMergeRequest } = options;
  const timerIdMap: Record<string, any> = {};
  const resultMap: Record<string, reqT> = {};
  return (reqParams: reqT) => {
    // @ts-ignore
    const timerId = `${reqParams.userId}-${reqParams.type}`;

    if (!resultMap[timerId]) {
      // 说明是第一次调用,此时直接将第一次调用的参数赋给result,不需要调用howToMergeRequest合并参数
      resultMap[timerId] = reqParams;
    } else {
      // 第二次及以后调用,则需要合并参数
      resultMap[timerId] = howToMergeRequest(reqParams, resultMap[timerId]);
    }
    if (!timerIdMap[timerId]) {
      // 定时器不存在,则启动一个定时器执行操作
      timerIdMap[timerId] = setTimeout(() => {
        mockRequest({ ...resultMap[timerId] });
        delete resultMap[timerId];
        delete timerIdMap[timerId];
      }, 1000);
    }
  };
};

然后就可以像下面这样使用:

import React, { memo, useState } from 'react';

import { createAutoMergeRequest } from './test';
interface RequestParamsProps {
  userId: string; // 给谁点赞
  num: number; // 数量
  type: 0 | 1; // 0 点踩, 1点赞
}
// 只需调用一次
const autoMergeRequest = createAutoMergeRequest<RequestParamsProps>({
  howToMergeRequest: (req, resultReq) => {
    resultReq.num = req.num + resultReq.num;
    return resultReq;
  },
});

const Demo = memo(() => {
  // 以下计数器只是用于方便演示实际数量和后端收到的数量是否一致
  const [likeXiaoMingcount, setLikeXiaoMingCount] = useState(0);
  const [dislikeXiaoMingcount, setDisLikeXiaoMingCount] = useState(0);
  const [likeXiaoHongcount, setLikeXiaoHongCount] = useState(0);
  const [dislikeXiaoHongcount, setDisLikeXiaoHongCount] = useState(0);
  return (
    <div>
      <button
        onClick={() => {
          setLikeXiaoMingCount(likeXiaoMingcount + 1);
          autoMergeRequest({
            num: 1,
            userId: '小明',
            type: 1,
          });
        }}
      >
        给小明点赞{likeXiaoMingcount}
      </button>
      <button
        onClick={() => {
          setDisLikeXiaoMingCount(dislikeXiaoMingcount + 1);
          autoMergeRequest({
            num: 1,
            userId: '小明',
            type: 0,
          });
        }}
      >
        给小明点踩{dislikeXiaoMingcount}
      </button>
      <button
        onClick={() => {
          setLikeXiaoHongCount(likeXiaoHongcount + 1);
          autoMergeRequest({
            num: 1,
            userId: '小红',
            type: 1,
          });
        }}
      >
        给小红点赞{likeXiaoHongcount}
      </button>
      <button
        onClick={() => {
          setDisLikeXiaoHongCount(dislikeXiaoHongcount + 1);
          autoMergeRequest({
            num: 1,
            userId: '小红',
            type: 0,
          });
        }}
      >
        给小红点踩{dislikeXiaoHongcount}
      </button>
    </div>
  );
});

Demo.displayName = 'Demo';
export default Demo;

结果如下: image.png

可以发现,通用程度更高了。但是还有个小问题,这里面timerid的生成规则还是比较hard code,同时mockRequest也比较耦合业务,如果是其他请求怎么办?

接下来,我们封装终极版通用程度更高的方法

终极版

interface OptionsProps<reqT, resR> {
  limitTime?: number;
  idGenerator: (reqParams: reqT) => string;
  operateFn: (reqPrams: reqT) => resR;
  howToMergeRequest: (reqParams: reqT, result: reqT) => reqT;
}
// T参数类型,R返回值类型
export const createAutoMergeRequestOperation = <reqT, resR>(
  options: OptionsProps<reqT, resR>,
): ((reqPrams: reqT) => void) => {
  const { operateFn, limitTime = 1000, idGenerator, howToMergeRequest } = options;
  const timerIdMap: Record<ReturnType<typeof idGenerator>, any> = {};
  const resultMap: Record<ReturnType<typeof idGenerator>, reqT> = {};
  return (reqParams: reqT) => {
    const timerId = idGenerator(reqParams);
    if (!resultMap[timerId]) {
      // 说明是第一次调用,此时直接将第一次调用的参数赋给result,不需要调用howToMergeRequest合并参数
      resultMap[timerId] = reqParams;
    } else {
      // 第二次及以后调用,则需要合并参数
      resultMap[timerId] = howToMergeRequest(reqParams, resultMap[timerId]);
    }
    if (!timerIdMap[timerId]) {
      // 定时器不存在,则启动一个定时器执行操作
      timerIdMap[timerId] = setTimeout(() => {
        // TODO:需要考虑调用失败重试
        operateFn({ ...resultMap[timerId] });
        delete resultMap[timerId];
        delete timerIdMap[timerId];
      }, limitTime);
    }
  };
};

使用:

// 只需调用一次
const autoMergeRequest = createAutoMergeRequestOperation<RequestParamsProps, any>({
  idGenerator: (req) => `${req.userId}-${req.type}`,
  howToMergeRequest: (req, resultReq) => {
    resultReq.num = req.num + resultReq.num;
    return resultReq;
  },
  operateFn: (requestparams) => {
    mockRequest(requestparams);
  },
});

const Demo = memo(() => {
  // 以下计数器只是用于方便演示实际数量和后端收到的数量是否一致
  const [likeXiaoMingcount, setLikeXiaoMingCount] = useState(0);
  const [dislikeXiaoMingcount, setDisLikeXiaoMingCount] = useState(0);
  const [likeXiaoHongcount, setLikeXiaoHongCount] = useState(0);
  const [dislikeXiaoHongcount, setDisLikeXiaoHongCount] = useState(0);
  return (
    <div>
      <button
        onClick={() => {
          setLikeXiaoMingCount(likeXiaoMingcount + 1);
          autoMergeRequest({
            num: 1,
            userId: '小明',
            type: 1,
          });
        }}
      >
        给小明点赞{likeXiaoMingcount}
      </button>
      <button
        onClick={() => {
          setDisLikeXiaoMingCount(dislikeXiaoMingcount + 1);
          autoMergeRequest({
            num: 1,
            userId: '小明',
            type: 0,
          });
        }}
      >
        给小明点踩{dislikeXiaoMingcount}
      </button>
      <button
        onClick={() => {
          setLikeXiaoHongCount(likeXiaoHongcount + 1);
          autoMergeRequest({
            num: 1,
            userId: '小红',
            type: 1,
          });
        }}
      >
        给小红点赞{likeXiaoHongcount}
      </button>
      <button
        onClick={() => {
          setDisLikeXiaoHongCount(dislikeXiaoHongcount + 1);
          autoMergeRequest({
            num: 1,
            userId: '小红',
            type: 0,
          });
        }}
      >
        给小红点踩{dislikeXiaoHongcount}
      </button>
    </div>
  );
});

image.png

这个版本的方法已经升级为合并参数的操作,不仅限于合并请求,凡是需要合并字段的操作都可以基于这个方法二次封装一层。