React, Vue file download progress bar

1. Demand scenario

The download of large file resources on the server is too slow, the page does not display anything, and the experience is too poor. Therefore, it is necessary to increase the progress bar to optimize the display

2. Implementation principle

  1. Send an asynchronous HTTP request, listen to the onprogress event, read the downloaded resource and the total size of the resource to get the download percentage

  2. After the resource request is completed, convert the file content into a blob, and download the file through the browser through the a tag

3. React implementation steps

1. Host static resources

Prerequisite: react project created by create-react-app

Put the static resource file in the public folder, so that after starting the project, you can directly access the static resource through http://localhost:3000/1.pdf. In actual work, it must be directly accessing the resources on the server

2. Package hook

New useDownload.ts

import {<!-- --> useCallback, useRef, useState } from 'react';

interface Options {<!-- -->
  fileName: string; //Downloaded file name
  onCompleted?: () => void; //Callback method for request completion
  onError?: (error: Error) => void; //Callback method for request failure
}

interface FileDownReturn {<!-- -->
  download: () => void; //download
  cancel: () => void; //Cancel
  progress: number; //Download progress percentage
  isDownloading: boolean; //Whether it is downloading
}

export default function useFileDown(url: string, options: Options): FileDownReturn {<!-- -->
  const {<!-- --> fileName, onCompleted, onError } = options;
  const [progress, setProgress] = useState(0);
  const [isDownloading, setIsDownloading] = useState(false);
  const xhrRef = useRef<XMLHttpRequest | null>(null);

  const download = useCallback(() => {<!-- -->
    const xhr = (xhrRef. current = new XMLHttpRequest());
    xhr.open('GET', url); //default asynchronous request
    xhr.responseType = 'blob';
    xhr.onprogress = (e) => {<!-- -->
      / / Determine whether the resource length can be calculated
      if (e. length Computable) {<!-- -->
        const percent = Math. floor((e. loaded / e. total) * 100);
        setProgress(percent);
      }
    };
    xhr.onload = () => {<!-- -->
      if (xhr. status === 200) {<!-- -->
        //The request resource is completed, and the content of the file is converted to a blob
        const blob = new Blob([xhr. response], {<!-- --> type: 'application/octet-stream' });
        //Download resources through a tag
        const link = document. createElement('a');
        link.href = window.URL.createObjectURL(blob);
        link.download = decodeURIComponent(fileName);
        link. click();
        window.URL.revokeObjectURL(link.href);
        onCompleted & amp; & amp; onCompleted();
      } else {<!-- -->
        onError & amp; & amp; onError(new Error('Download failed'));
      }
      setIsDownloading(false);
    };
    xhr.onerror = () => {<!-- -->
      onError & amp; & amp; onError(new Error('Download failed'));
      setIsDownloading(false);
    };
    xhrRef.current.send(); //Send request
    setProgress(0); //Reset the progress to 0 each time it is sent
    setIsDownloading(true);
  }, [fileName, onCompleted, onError, url]);

  const cancel = useCallback(() => {<!-- -->
    xhrRef.current?.abort(); //Cancel request
    setIsDownloading(false);
  }, [xhrRef]);

  return {<!-- -->
    download,
    cancel,
    progress,
    isDownloading,
  };
}

3. Use hook

import {<!-- --> memo } from 'react';

import useFileDown from './useDownload';

const list = [
  {<!-- -->
    fileName: 'The history of urban development.pdf',
    url: 'http://localhost:3000/1.pdf',
    type: 'pdf',
  },
  {<!-- -->
    fileName: 'table.xlsx',
    url: 'http://localhost:3000/table.xlsx',
    type: 'xlsx',
  },
  {<!-- -->
    fileName: 'Report.doc',
    url: 'http://localhost:3000/report.doc',
    type: 'doc',
  },
];
interface Options {<!-- -->
  url: string;
  fileName: string;
}

const Item = memo(({<!-- --> url, fileName }: Options) => {<!-- -->
  //Each item needs to have its own useFileDown hook
  const {<!-- --> download, cancel, progress, isDownloading } = useFileDown(url, {<!-- --> fileName });

  return (
    <div>
      <span style={<!-- -->{<!-- --> cursor: 'pointer' }} onClick={<!-- -->download}>
        {<!-- -->fileName}
      </span>
      {<!-- -->isDownloading ? (
        <span>
          {<!-- -->`Downloading: ${<!-- -->progress}`}
          <button onClick={<!-- -->cancel}>Cancel download</button>
        </span>
      ) : (
        ''
      )}
    </div>
  );
});

const Download = () => {<!-- -->
  return (
    <div>
      {<!-- -->list. map((item, index) => (
        <Item url={<!-- -->item.url} fileName={<!-- -->item.fileName} key={<!-- -->index} />
      ))}
    </div>
  );
};

export default Download;

4. vue implementation steps

1. Host static resources

Premise: Vue project created by vite

Put the static resource file in the public folder, so that after starting the project, you can directly access the static resource through http://127.0.0.1:5173/1.pdf

2. Package hook

Create new hooks/useDownload.ts (new hooks folder)

import {<!-- --> ref } from "vue";

export interface Options {<!-- -->
  fileName: string;
  onCompleted?: () => void; //Callback method for request completion
  onError?: (error: Error) => void; //Callback method for request failure
}

export interface FileDownReturn {<!-- -->
  download: () => void; //download
  cancel: () => void; //Cancel
  progress: number; //Download progress percentage
  isDownloading: boolean; //Whether it is downloading
}

export default function useFileDown(
  url: string,
  options: Options
): FileDownReturn {<!-- -->
  const {<!-- --> fileName, onCompleted, onError } = options;
  const progress = ref(0);
  const isDownloading = ref(false);

  const xhrRef = ref<XMLHttpRequest | null>(null);

  const download = () => {<!-- -->
    const xhr = (xhrRef. value = new XMLHttpRequest());
    xhr.open("GET", url); //Default asynchronous request
    xhr.responseType = "blob";
    xhr.onprogress = (e) => {<!-- -->
      / / Determine whether the resource length can be calculated
      if (e. length Computable) {<!-- -->
        const percent = Math. floor((e. loaded / e. total) * 100);
        progress.value = percent;
      }
    };
    xhr.onload = () => {<!-- -->
      if (xhr. status === 200) {<!-- -->
        //The request resource is completed, and the content of the file is converted to a blob
        const blob = new Blob([xhr. response], {<!-- -->
          type: "application/octet-stream",
        });
        //Download resources through a tag
        const link = document. createElement("a");
        link.href = window.URL.createObjectURL(blob);
        link.download = decodeURIComponent(fileName);
        link. click();
        window.URL.revokeObjectURL(link.href);
        onCompleted & amp; & amp; onCompleted();
      } else {<!-- -->
        onError & amp; & amp; onError(new Error("Download failed"));
      }
      isDownloading. value = false;
    };
    xhr.onerror = () => {<!-- -->
      onError & amp; & amp; onError(new Error("Download failed"));
      isDownloading. value = false;
    };
    xhrRef.value.send(); //Send request
    progress.value = 0; //Reset the progress to 0 each time it is sent
    isDownloading. value = true;
  };

  const cancel = () => {<!-- -->
    xhrRef.value?.abort(); //Cancel request
    isDownloading. value = false;
  };

  return {<!-- -->
    download,
    cancel,
    progress,
    isDownloading,
  };
}

3. Use hook

  1. Modify App.vue
<script setup lang="ts">
import Item from "./components/Item.vue";

const list = [
  {<!-- -->
    fileName: "History of Urban Development.pdf",
    url: "http://127.0.0.1:5173/1.pdf",
    type: "pdf",
  },
  {<!-- -->
    fileName: "table.xlsx",
    url: "http://127.0.0.1:5173/table.xlsx",
    type: "xlsx",
  },
  {<!-- -->
    fileName: "Report.doc",
    url: "http://127.0.0.1:5173/report.doc",
    type: "doc",
  },
];
</script>

<template>
  <div>
    <div v-for="(item, index) in list" :key="index">
      <Item :url="item.url" :fileName="item.fileName"<script setup lang="ts">
import useFileDown from "../hooks/useDownload.ts";


const props = defineProps<{<!-- --> url: string; fileName: string }>();

const {<!-- --> url, fileName } = props;

const {<!-- --> download, cancel, progress, isDownloading } = useFileDown(url, {<!-- -->
  fileName,
});
</script>

<template>
  <div>
    <span style="cursor: pointer" @click="download">
      {<!-- -->{<!-- --> fileName }}
    </span>
    <span v-if="isDownloading">
      Downloading:{<!-- -->{<!-- --> progress }} <button @click="cancel">Cancel download</button></span
    >
  </div>
</template> />
    </div>
  </div>
</template>
  1. New components/Item.vue
<script setup lang="ts">
import useFileDown from "../hooks/useDownload.ts";


const props = defineProps<{<!-- --> url: string; fileName: string }>();

const {<!-- --> url, fileName } = props;

const {<!-- --> download, cancel, progress, isDownloading } = useFileDown(url, {<!-- -->
  fileName,
});
</script>

<template>
  <div>
    <span style="cursor: pointer" @click="download">
      {<!-- -->{<!-- --> fileName }}
    </span>
    <span v-if="isDownloading">
      Downloading:{<!-- -->{<!-- --> progress }} <button @click="cancel">Cancel download</button></span
    >
  </div>
</template>

5. Possible problems: lengthComputable is false

Reason 1: The backend response header does not return Content-Length;

Solution: Just let the backend add it

Reason 2: gzip compression is enabled

After enabling gzip, the server enables file chunk encoding by default (the response header returns Transfer-Encoding: chunked). Block encoding divides the “message” into several blocks of known size, and the blocks are sent next to each other. When using this transmission method to respond, the Content-Length header information will not be transmitted, even if it is carried, it will be inaccurate

Respectively for gzip compression, block encoding:

clipboard.png

For example, there is a js file with a size of 877k, and the size of the network request is 247k. But the printed e.loaded finally returns 877k

7ACD3DB2BB1B4EF9B8CB59505F92C49E.jpg

Solution: The backend stores the file size in other fields, such as: header[‘x-content-length’]