在React中处理业务中获取文件流的接口异常

101 阅读3分钟

背景

业务上经常遇到【下载】和【导出】的场景,我们这边通常是后端根据参数返回1个文件流,前端拿到文件流进行下载,但是,总有些异常情况,比如服务器刚好崩溃、数据异常无法下载,这时候接口实际上返回的不是文件流,而是一个JSON

我们这边框架对Axios做了封装,对于指定了responseTypeblob类型的情况直接把response返回给组件,没有做任何处理,对于这类边界情况,在不改变框架代码的情况下封装了一个通用的hook,如果是改Axios配置,方式也大同小异。

分析

  • 处理正常情况,把文件流作为文件下载
  • 处理异常情况,把response转换为正常的JSON,再提取其中的错误信息作出提示

步骤

首先,我们来写一个正常调用的场景,点击【下载】,我们需要响应下载操作

export default function useDownload() {
  // 下载按钮点击事件
  const handleDownload = async (filename) => {
    // 下载接口调用
    try {
      const res = await downloadApi(params));
      const { data, status, headers } = res;
      const url = window.URL.createObjectURL(new Blob([data]));
      const link = document.createElement('a');
      link.style.display = 'none';
      link.target = '_blank';
      link.download = filename;
      link.href = url;
      document.body.appendChild(link);
      link.click();
      Message.success('下载成功!');
      // 释放
      window.URL.revokeObjectURL(url);
      document.body.removeChild(link);
    } catch (error) {
      Message.error('下载失败!');
    }
  };

  return {
    handleDownload
  };
}

很简单,但从产品角度考虑,首先我们需要一个在重复下载时的阻拦,比如给按钮加一个loading

export default function useDownload() {
   const [loading, setLoading] = useState(false); // 应该触发组件的重新渲染,因此使用useState,而不是useRef
   // 下载按钮点击事件
   const handleDownload = async () => {
     // 如果正在下载,则返回提示
     if (loading) {
       return Message.warning('正在下载,请稍候!');
     }
     ...
   };

   return {
     handleDownload
   };
}

ok,到这里一个正常的下载就写好了。说回到前文提到的异常场景,即返回的并不是预期的文件流,而是JSON 通过实验枚举出了错误的responseTypeapplication/jsontext/xml,只需判断命中这个类型的responseType时,把Axios处理过的blob转换回text就可以了,这里可以使用FileReader

  // 下载失败,弹出错误提示
  const parseText = (data) => {
    // 将Blob对象读取成文本格式
    const fileReader = new FileReader();
    fileReader.readAsText(new Blob([data], { type: 'text/plain' }));
    fileReader.onload = () => {
      Message(fileReader.result, 'error');
    };
  };

那么优化后的代码如下,补充一点,有时文件名需要取后端返回的,通常会约定好把文件名放在header里,可以获取一下

const filename = decodeURIComponent(headers[errorHeaderKey]?.split("utf-8''")?.[1] || ''
const JSON_TYPE = ['application/json', 'text/xml'];
const DEFAULT_TIP_MAP = {
  SUCCESS: '下载成功!',
  ERROR: '下载失败!',
  PENDING: '下载中,请稍候!',
};
const ERROR_HEADER_KEY = 'content-disposition';

export default function useDownload(tipMap = DEFAULT_TIP_MAP, errorHeaderKey = ERROR_HEADER_KEY) {
  const [loading, setLoading] = useState(false);

  // 下载失败,弹出错误提示
  const parseText = (data) => {
    // 将Blob对象读取成文本格式
    const fileReader = new FileReader();
    fileReader.readAsText(new Blob([data], { type: 'text/plain' }));
    fileReader.onload = () => {
      Message.error(fileReader.result);
    };
  };

  // 下载成功,文件流转a标签下载
  const parseFileStream = (data, filename) => {
    const url = window.URL.createObjectURL(new Blob([data]));
    const link = document.createElement('a');
    link.style.display = 'none';
    link.target = '_blank';
    link.download = filename;
    link.href = url;
    document.body.appendChild(link);
    link.click();
    Message.success(tipMap?.SUCCESS || DEFAULT_TIP_MAP.SUCCESS);
    // 释放
    window.URL.revokeObjectURL(url);
    document.body.removeChild(link);
  };

  // 业务场景中有需要通过url换文件流的情况,这里我们有一个通用的url转文件流接口,套进去
  const handleDownload = async ({
    url, // 如果只传入了url,则调通用的url换文件流接口
    downloadApi,
    params = {},
    filename,
  }) => {
    // 如果正在下载,则返回提示
    if (loading) {
      return Message.warning(tipMap?.PENDING || DEFAULT_TIP_MAP.PENDING);
    }
    setLoading(true);
    // 如果只传入了url,则调通用的url换文件流接口
    const res = await (url ? getFileStream(url) : downloadApi(params));
    const { data, status, headers } = res;
    try {
      if (JSON_TYPE.includes(data.type)) {
        parseText(data);
      } else if (status === 200) {
        // 如果指定了文件名则取指定的文件名,否则取response上返回的文件名
        const targetName =
          filename || decodeURIComponent(headers[errorHeaderKey]?.split("utf-8''")?.[1] || '');
        parseFileStream(data, targetName);
      }
    } catch (error) {
      Message.error(tipMap?.ERROR || DEFAULT_TIP_MAP.ERROR);
    }
    setLoading(false);
  };

  return {
    handleDownload,
    downloadLoading: loading,
  };
}

到这里,一个完整的useDownload就完成了。