还在为重复请求、脏响应而苦恼?看这篇文章就够了

1,392 阅读8分钟

在React中,组件生命周期各个阶段变得模糊,在Class Component时代还能依赖某个钩子函数来处理网络请求。在Function Component时代这一方式变成依赖 useEffect/useLayoutEffect hook进行。因为state的”异步性“,useEffect/useLayoutEffect依赖列表中的值会发生多次改变,同样也会触发多次事件回调。而这些往往就会引发重复请求,进而出现脏响应。

脏响应:已经发出去的请求返回的响应,响应本身没有问题,但是因为用户视图时空的改变让该响应失去了时空准确性,该响应成为脏响应

本文将从请求的三个阶段(请求前、请求中、响应后)全面分析重复请求发起的原因,并给出针对性的解决方案。

注意:本文示例代码是从实际项目中摘取出的,只提供编码思路,不确保功能正确。

本文思维导图:

重复请求处理大纲.png

请求前

防抖

典型场景:监听Input的onChange事件触发请求,进行远程搜索。
防抖:在事件被触发n秒后再执行回调,如果在这n秒内又被触发,则重新计时。
防抖是优化高频率执行js代码的一种手段,js中的一些事件如浏览器的resizescroll,鼠标的mousemovemouseover,input输入框的keypress等事件在触发时,会不断地调用绑定在事件上的回调函数,极大地浪费资源,降低前端性能。为了优化体验,需要对这类事件进行调用次数的限制。
使用方式也很简单,只需要在事件处理函数外包一层debounce就行,下面代码使用了lodash库的debounce函数进行Select远程搜索的优化:

...
import { debounce } from 'lodash';

const SelectUser = function () {
  const [user, setUser] = useState<{ name: string; age: number }[]>([]);

  const sendFetchRequest = () => {
    ...
  };

  const onSearchChange = debounce(() => {
    sendFetchRequest();
  }, 300);

  return (
    <div className="repeat-xhr">
      <Select showSearch onSearch={onSearchChange} style={{ width: 220 }}>
        {user.map((e) => (
          <Option value={e.name}>{e.name}</Option>
        ))}
      </Select>
    </div>
  );
};

export default SelectUser;

添加防抖后,Select输入事件的触发频率得到了有效遏制:

debounce.gif

参数完备性检测

典型场景:某个请求依赖于多个state,则需要在state都存在时才执行请求动作。
应该说参数完备性检测不仅是用来防范发出重复请求,更是确保请求正确发出的必备措施。保证参数正确且完备才发出请求,是必选项而不是优化项!
请看如下示例代码:

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

export default function() {
  // 分页器参数
  const [paginationData, updatePaginationData] = useState<{
    page: number;
    count: number;
  }>();
  // 查询参数
  const [searchParam, updateSearchParam] = useState<QueryReleaseCheckParam>();

  const getReleaseCheckList = async () => {};

  useEffect(() => {
    if (paginationData && searchParam) {
      getReleaseCheckList();
    }
  }, [paginationData, searchParam]);

  return ...;
}

getReleaseCheckList依赖于paginationData,searchParam两个参数,在useEffect的回调执行中先进行参数完备性判断,判断通过才执行查询。
通过完备性检测可以有效避免React State在组件生命周期阶段多次改变引发的依赖回调重复执行问题。

参数差异性检测

典型场景:某个请求依赖于多个state,且state存在默认值(完备性检测通过),但组件挂载过程中触发了多次state改变。
差异性检测一般是在完备性检测的基础上进行了,既参数确实存在,但是因为React funciton component挂载过程中会多次执行setState(比如子组件的挂载触发了setState),造成依赖回调多次触发,此时就需要进行差异性检测,确保前后两次请求的参数不一样。

import React, { useState, useEffect, useRef } from 'react';
import { cloneDeep, isEqual } from 'lodash';

export default function() {
  // 分页器参数
  const [paginationData, updatePaginationData] = useState<{
    page: number;
    count: number;
  }>();
  // 查询参数
  const [searchParam, updateSearchParam] = useState<QueryReleaseCheckParam>();

  const prevParam = useRef();

  const getReleaseCheckList = async () => {};

  useEffect(() => {
    if (paginationData && searchParam) {
      const param = {
        ...paginationData,
        ...searchParam,
      };
      if (!isEqual(prevParam.current, param)) {
        prevParam.current = cloneDeep(param);
        getReleaseCheckList();
      }
    }
  }, [paginationData, searchParam]);

  return ...;
}

上面代码引入了useRef hook存储”上一次“查询的param,并在下一次查询前执行两者值是否相等,相等则不发出请求,不相等则重新发起请求。

请求缓存

从ES6开始,引入了异步编程神器Promise,从此大家都会把XHR请求结果封装为一个Promise。随着Fetch兼容性越来越好,网络请求首席Web API也从XHR改为FetchFetch规范天然实现了Promise编程。自此大家完全接受了请求响应 = Promise对象这一最佳实践。
有了Promise作为请求响应的承载体,开发者处理接口响应变得更加灵活,处理接口请求不再是层层嵌套filter函数,而是不断地then(//处理&new Promise)。Promise解决”回调地狱“的能力在网络请求这里得到淋漓尽致地体现。
前面说到请求响应 = Promise,那更进一步地抽象:Promise = 请求 ?其实也是成立的。我们发起请求的目的是获取响应结果,那自然可以用响应结果来表示请求本身。所以可以通过缓存请求的Promise结果来达到缓存请求的目的,唯一的难点是如何识别唯一的缓存key。
答案是:请求方法(get/post/put/delete)+请求路由(/api/path/query)+请求参数(JSON.stringfy(param))
根据这个思路封装一个request.js工具函数,将缓存入口收敛到该工具函数里,方便后续维护和统一异常处理。

/**
 * 带有请求缓存队列的request方法
 * 机制:当请求未返回而新的同样的请求又发送过来时,会根据请求类型进行cache
 * 根据RESTful规范制定缓存策略
 * 对于patch,put,delete请求:直接返回pending状态的Promise对象
 * 对于get,post请求:返回CacheController中的缓存数据
 * @param url
 * @param options
 * @param silent
 * @param cacheOption - 缓存配置
 *  enable:boolean 是否启用缓存
 * @returns
 */
const cachedRequests = {};
 
export function requestWithCache(
  url: string,
  options: any,
  silent = false,
  cacheOption: CacheOption = {
    enable: false
  },
) {
  const hash = `${options.method} ${url} ${options.body || ''}`;
  if (cacheOption?.enable) {
    const cachedRequest = cachedRequests[hash];
    if (cachedRequest && ['PUT', 'DELETE', 'PATCH'].includes(options.method)) {
      return cachedRequest;
    } else {
      // get/post cache逻辑
      if (cache) {
        return Promise.resolve(cache);
      }
      if (cachedRequest) {
        return cachedRequest;
      }
    }
  }
 
  return (cachedRequests[hash] = fetch(url, options)
    .then((data) => {
      return cleanCache(hash)(data as Response);
    })
    .then(statusInterceptor)
    .then((res) => {
      return jsonInterceptor(res).then((data) => {
          return Promise.resolve(data);
        } catch (e) {
          console.error('cache data, ', e);
        }
      });
    }, errorInterceptor(silent))
    .then(errorInterceptor(silent))
    .catch((err) => {
      cleanCache(hash)();
      return Promise.reject(err);
    }));
}

上面的工具函数封装了fetch api,其缓存的核心逻辑代码是:

// ...
const hash = `${options.method} ${url} ${options.body || ''}`;
  if (cacheOption?.enable) {
    const cachedRequest = cachedRequests[hash];
    if (cachedRequest && ['PUT', 'DELETE', 'PATCH'].includes(options.method)) {
      return cachedRequest;
    } else {
      // get/post cache逻辑
      if (cache) {
        return Promise.resolve(cache);
      }
      if (cachedRequest) {
        return cachedRequest;
      }
    }
  }

在发起请求前现在 cachedRequests 内存对象中找到上一个同参请求Promise是否存在,如果存在则直接返回缓存的Promise,否则发起 fetch 请求并缓存。注意点:

  • 工具方法为了兼容一些特殊需要的重复查询,提供了 cacheOption 开关支持,设置为false时不会走缓存逻辑。
  • 需要在Promise的 .then 中手动 cleanCache,防止无效缓存一直存在。

请求中

参考作者上一篇文章:如何优雅地abort XHR/Fetch请求?

响应后

重复请求响应返回后,能做的其实只能是适时地丢弃响应结果。确保前端消费的永远是”最后一次“请求的响应,这样才是符合预期的结果。这里又分成两种情况:一种是对比响应结果和最后一次请求参数;另一种是对比当前响应的参数和最后一次请求的参数。

响应参数一致性检测

目的:对比当前响应结果和最后一次请求参数。
适用场景:响应结果中包含请求参数部分字段,且这些字段足以作为唯一key实现一致性比对。

情况1.png

如上图,Promise的响应里含有key,因此可以对比key以判断当前响应是否为正确响应:

 const paramRef = useRef<string | number>();

 const onSearchHandle = debounce(
    async (id: string | number, value: string) => {
      const param = { id, key: [value] };
      paramRef.current = id;
      const res = await queryModuleInfo(param);
      if (res && res.code === 0) {
        if (res.data.id === paramRef.current) {
          // 处理响应
          setData(res.data);
        } else {
          // 丢弃脏响应
          noop();
        }
      }
    },
    500
  );

上面的示例代码中使用 paramRef.current 存储着"最新请求”的id参数值,当异步响应返回时,需要比对 res.data.idparamRef.current是否相等,相等则是最新的请求响应,否则是“脏”响应。脏响应直接丢弃。

请求参数一致性检测

目的:对比当前响应结果的请求参数和最后一次请求参数。
适用场景:响应结果中不包含请求参数任何字段,只能通过比对当前响应的请求参数和最后一次请求参数进行一致性比对。

情况2.png

如上图,Promise的响应里不含有key,这时需要找到另一种方法来唯一标识每一个请求,然后存储到ref.current对象中,在Promise返回后:

  const currentModuleReq = useRef<CustomSymbolPromise>();
  /**
   * 使用带signal的Promise控制Promise的正确解析
   * 1.对于过期请求直接丢弃其结果,不再解析
   * 2.只读取最后一次query_module的响应结果
   */
  const onSearchHandle = debounce(
    async (value: string): CustomSymbolPromise => {
      const param = { level, key: [value] };
      const newReq = {
        symbol: Symbol(),
        promise: queryModuleInfo(param)
      };
      currentModuleReq.current = newReq;
      return newReq;
    },
    500
  );

  // ...
  const parseModuleRequest = async (val: string) => {
    try {
      const reqPromise = onSearchHandle(val);
      const res = await reqPromise.promise;
      if (reqPromise.symbol !== currentModuleReq.current?.symbol) {
        return; // 直接返回,意味丢弃”过时“的响应结果
      }
      if (res && res.code === 0) {
        // ...
      }
    } catch (e) {
      // @ts-ignore
      console.error(` 错误,${e?.message}`);
    }
  };
  
  useEffect(() => {
    parseModuleRequest(val);
  }, [val]);

上面的示例代码里对为每个 Promise 携带了一个 Symbol ,然后把整体的 newReq 赋值给 currentModuleReq.current,依次达到标识”最新“的请求目的。
当 Promise 响应后,判断当前响应是否是最新请求的响应,以此决定是否使用该响应数据。

总结

本文是对工作中实践的总结,有些方法可能还有缺陷,这里只起到抛砖引玉的作用,欢迎大家在评论里给出更优雅的解决方案!