😎进阶面试--如何原生JS实现上传下载进度条

929 阅读9分钟

前言

在开发中,我们经常会有上传和下载的需求,大多数时候并没有实现对接口进度的检测,但是把进度条做出来,对用户的体验无疑是加分的

在浏览器中,请求接口有两个对象,分别是 fetch、XMLHttpRequest。这两个对象目前支持的能力,如下图所示:
image.png
可以看到 XHR 和 Fetch 都可以监控响应进度,借此可以实现下载进度的监控。但是对于请求进度只有 XHR 才可以做到,也就是说想做上传进度监控,只能用 XHR。

在现代前端编程,用的最多的 axios,而在浏览器环境中,axios 就是基于 xhr 对象,所以 axios 也就具有监控上传进度和下载进度的能力。

new Axios({
  url: '/',
  method: 'post',
  onUploadProgress:()=>{},
  onDownloadProgress:()=>{}
})

在 request config 中传入两个 progress 回调函数,就可以实现了,非常简单。

然而这篇文章不是分享用 axios 来做监控进度,而是用原生的 API 来做

Fetch

首先用大家熟悉的 Fetch 对象来实现下载进度的监控

先创建一个非常简单的 Demo,不用服务器就能实现一个下载的功能--下载图片--即用 fetch 请求一个图片。

在网上随便找了一个图片:
image.png

然后准备一个 html,用来显示进度条和图片:

<div class="process-container">
    <div class="process">
            <div class="process-line"></div>
    </div>
    <span class="process-text"></span>
</div>

静态状态下是这样:
image.png
我想做的是,这个图片在页面打开的时候,就开始用 fetch 请求该图片,然后监控同步更新进度条的长度。先准备一个 fetch 请求的函数:

function fetchRequest(
  url,
  stepCallback = () => null,
  completeCallback = () => null
) {
  //...
}

这个函数接受三个参数:

  1. 请求的 url
  2. 每个进度反馈的回调函数,每拿到一个响应的 chunk,这个函数就会被调用
  3. 响应结束的回调函数

调用这个函数,并且传入改变进度条的回调函数:

// 处理进度条长度,以及进度文字更新
function calcLine(percent) {
	const line = document.querySelector(".process-line");
	line.style.width = percent * 300 + "px";

	const processText = document.querySelector(".process-text");
	if (percent == 1) {
		processText.innerHTML = "100%";
	} else {
		processText.innerHTML = (percent * 100 + "").substring(0, 2) + "%";
	}
}

// 负责显示图片
function displayImg(chunks) {
	const blob = new Blob(chunks, { type: "image/jpeg" });
	const url = URL.createObjectURL(blob);
	const img = document.createElement("img");
	img.src = url;
	document.body.appendChild(img);
}

// 初始函数
async function action() {
	fetchRequest(img, calcLine, displayImg);
}

action();

在 action 函数中调用fetchRequest函数,然后传入进度监控的回调函数,以及响应完成的回调函数。
看看效果:
2024-08-17 23.07.07.gif

GIF 录制工具有点偏色,大概就是这个效果

想让进度条走得慢些,有个小妙招,在开发者工具里设置NetWork的网络环境,设为Slow 3G,进度条就很慢了

函数实现:

function fetchRequest(
  url,
  stepCallback = () => null,
  completeCallback = () => null
) {
  let totalSize = 0;
  return new Promise((resolve, reject) => {
    fetch(url)
      .then((res) => {
        if (!res.ok) reject();
        totalSize = res.headers.get("content-length");
        totalSize = totalSize ? parseInt(totalSize, 10) : 0;
        return res.body;
      })
      .then((body) => body.getReader())
      .then(async (reader) => {
        let result = [];
        let loadSize = 0;

        while (1) {
          const { done, value } = await reader.read();
          if (done) {
            console.log("success");
            break;
          }

          loadSize += value.byteLength;
          stepCallback(loadSize / totalSize);
          result.push(value);
        }

        // loadImg(result);
        completeCallback(result);
        resolve(result);
      });
  });
}

函数的定义和调用都不难,一看就懂,难的是如何在 chunk 的粒度上接收后端的响应数据,或者说采用流的方式接收响应数据。

fetch 中提供了这样的能力,通过body.getReader(),就可以得到一个浏览器环境下的可读流。开发者就可以通过这个可读流来一段一段地接收数据

可读流的用法:

while (1) {
  const { done, value } = await reader.read();
  if (done) {
    console.log("success");
    break;
  }

  loadSize += value.byteLength;
  stepCallback(loadSize / totalSize);
  result.push(value);
}

通过reader.read(),在死循环中不断读取可读流中的内容。返回的结果有点像迭代器,有 done,表示内容是否被读取完,value 表示读取到的具体内容,value 的类型是 arrayBuffer,所以要通过value.byteLength读取内容的长度,而不是value.length

在循环的尾部,用一个数组将一段一段的 value 收集起来,等收集完毕,就全部传递给completeCallback

可读流的参考链接:ReadableStream - Web API | MDN

XMLHttpRequest

XMLHttpRequest 相对就要更古老了,从 Api 就可以看出,该对象是古老的事件触发机制,还支持神奇的同步请求。

简单用法

Get 请求:

const url = 'http://example';

const xhr = new XMLHttpRequest();
xhr.open('get', url, true);
xhr.send(null);

open 第三个参数为布尔值,如果为 true, 表示请求是异步;如果为 false,表示请求是同步的。这很有意思。

Post 请求:

const url = 'http://example/upload';

const xhr = new XMLHttpRequest();
xhr.open('post', url, true);

const form = document.getElementById('form');
xhr.send(new FormData(form));

xhr.onload = () => {
    if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
            const res = xhr.response;
            console.log('successful: ',res);

    } else {
            reject(xhr.status);
    }
};

下载监控

下载监控和 Fetch 实现用的例子相同,还是请求一张图片,并且在页面上显示出来。html 和之前一致,只需要修改请求函数:

function xmlRequest(
  url,
  stepCallback = () => null,
  completeCallback = () => null
) {
  //...
}

xmlRequest 函数是用 xmlHttpRequest 发起的请求,接收的参数和上文的 fetchRequest 函数一致。所以调用方法也和 fetchRequest 一致

async function action() {
	// const chunk = await fetchRequest(img, calcLine);
	let chunk = await xmlRequest(img, calcLine); // 调用XMLHttpRequest
	chunk = [chunk];
	displayImg(chunk);
}

action();

函数实现:

export default function xmlRequest(
  url,
  stepCallback = () => null,
  completeCallback = () => null
) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.responseType = "blob";
    
    xhr.onload = () => {
      if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
        
        const res = xhr.response;
        completeCallback(res);
        resolve(res);
      } else {
        reject(xhr.status);
      }
    };

    xhr.onprogress = (e) => {
      if (e.lengthComputable) {
        stepCallback(e.loaded / e.total);
      }
    };

    xhr.open("get", url, true);
    xhr.send(null);
  });
}

要点:

  1. 用 onprogress 回调函数监控进度,每收到后端一段响应数据,progress 事件就会被触发
  2. 当响应结束, onload 就会被调用
  3. xmlRequest 中采用了 Promise,所以也可以通过.then方式获取响应数据

最后页面效果和上面的一致:
2024-08-17 23.07.07.gif

上传监控

实现上传监控的,就是 XHR 的拿手好戏了

我们准备一个服务器用来接收前端上传的文件:

const http = require("http");
const { formidable } = require("formidable");
const path = require("path");
const { getFilesName } = require("./utils");

/**
 * @param {http.IncomingMessage} req
 * @param {http.ServerResponse<http.IncomingMessage>} res
 */
async function setCors(req, res) {
	res.setHeader("Access-Control-Allow-Origin", "*");
}

/**
 * @param {http.IncomingMessage} req
 * @param {http.ServerResponse<http.IncomingMessage>} res
 */
async function handlePost(req, res) {
    const form = formidable({ allowEmptyFiles: true, minFileSize: 0 });
    form.uploadDir = path.join(__dirname, "./upload");
    form.keepExtensions = true;

    try {
        await form.parse(req);

        res.statusCode = 200;
        res.end(JSON.stringify({ status: "success", files: getFilesName(files) }));
    } catch (error) {
        console.error(error);
        res.writeHead(400, { "Content-Type": "text/plain" });
        res.end(JSON.stringify(error));
    }
}

/**
 * @param {http.IncomingMessage} req
 * @param {http.ServerResponse<http.IncomingMessage>} res
 */
function handleGet(req, res) {
  
  function renderFile(filePath) {
    filePath = path.resolve(__dirname, filePath);
    if (fs.existsSync(filePath)) {
      // 如果是js文件,需要设置响应头
      if (filePath.endsWith("js")) {
        res.setHeader("content-type", "text/javascript");
      }
      fs.createReadStream(filePath).pipe(res);
    } else {
      renderFile("../index.html");
    }
  }
  
  let filePath = req.url == "/" ? "/index.html" : req.url;

  renderFile(".." + filePath);
}

const server = http.createServer((req, res) => {
	setCors(req, res);
	if (req.method.toLowerCase() == "post") {
		handlePost(req, res);
	} else {
		handleGet(req, res);
	}
});

const port = 3000;
server.listen(port, () => {
	console.log("server listen: ", port);
});

handleGet专门用来处理 Get 请求,函数中的主要用来向前端返回 HTML 文件以及文件中依赖的 script 文件。

handlePost转门用来处理 Post 请求。函数中借助了第三方库formidable来解析 form 表单,并且将表单中文件自动保存至uploadDir的目录中。

前端上传代码:

html 内容:

<form id="formEl">
    <div class="form-item">
      <span class="label">Select Png: </span>
      <input type="file" multiple name="png" accept=".png,.jpg,.jpeg" />
    </div>
    <div class="form-item">
      <span class="label">Select MD: </span>
      <input type="file" multiple name="md" accept=".md" />
    </div>
    <div class="form-item">
      <input type="submit" value="提交" class="submit-input" />
    </div>
</form>

<script type="module" src="./upload.js"></script>

为了代码更清晰,这里利用了浏览器支持的 ESModule 模块化方式构建 js 代码。

前端代码

upload.js
import xmlRequest from "./xmlRequest.js";

const formEl = document.getElementById("formEl");
formEl.onsubmit = async (e) => {
  // 阻止默认行为
  e.preventDefault();

  // 当前同源下的路径
  const url = "/upload";
  await xmlRequest(url, new FormData(formEl), {
    startCallback: displayProcess,
    stepCallback: calcLine,
  });

  finishProcess();
};

/**
  * 以下皆为控制进度条的内容
*/
function displayProcess() {
  // ...
}

function calcLine(percent) {
  // ...
}

function finishProcess() {
  // ...
}

要点:

  1. 因为 html 文件是后端直接返回的,所以上传的路径可以直接用同源的/upload,即实际访问localhost:3000/upload。一旦同源,也就没有了跨域的烦恼了
  2. 上传表单的时候,直接用FormData构造函数处理 form dom,这是原生 js 所支持的,极大地简化了表单上传的代码
xmlRequest.js
/**
 *
 * @param {string} url
 * @param {{stepCallback: Function, startCallback: Function}} reqData
 * @param {{stepCallback: Function, completeCallback: Function}} resData
 */
export default function xmlRequest(url, data, reqData = {}, resData = {}) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    // 如果是下载二进制文件,需要指定responseType为blob
    // xhr.responseType = "blob";

    xhr.upload.onloadstart = () => {
      console.log("request start");
      reqData?.startCallback?.();
    };

    // 上传进度触发函数
    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) {
        reqData?.stepCallback?.(e.loaded / e.total);
      }
    };

    xhr.onload = () => {
      if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
        const res = xhr.response;
        resData?.completeCallback?.(res);
        resolve(res);
        console.log("response success");
      } else {
        reject(xhr.status);
      }
    };
    
    xhr.onprogress = (e) => {
      if (e.lengthComputable) {
        resData?.stepCallback?.(e.loaded / e.total);
      }
    };

    xhr.open("post", url, true);
    xhr.send(data);
  });
}

上面是xmlRequest的完整代码,其实上传进度监控的核心代码就只有几行:

xhr.upload.onprogress = (e) => {
    if (e.lengthComputable) {
      reqData?.stepCallback?.(e.loaded / e.total);
    }
};

能如此简单地实现,也是依托于 xmlHttpRequest 能够提供这样的 API 啊🙏🙏

最终效果:

2024-08-19 23.01.57.gif

流式展示数据

进度条监控的本质是在于可以一段一段地拿到数据,既然如此,就可以做点更多的事情,比如说流式展示数据

最经典的就是 GPT 问答对话,GPT 回答问题输出文字的时候,是一点一点输出的,很像人在交流的时候,一个字一个字地蹦出来

我们可以模拟下这样的效果:
2024-08-19 22.10.36.gif

代码实现

function action() {
  const url = "http://localhost:3002";
  const container = document.querySelector(".container");

  // 接收两个参数,一个百分比进度,一个是当前接收到的chunk
  fetchRequest(url, (percent, data) => {
    console.log(percent, data);
    // 每获取到内容,就要输出到屏幕上
    container.innerHTML += data; 
  });
}

action();

fetchRequest函数里面基本保持不变,只对其中的 stepCallback 的入参做点修改:

while (1) {
    const { done, value } = await reader.read();
    if (done) {
      console.log("success");
      break;
    }

    loadSize += value.byteLength;
+   var decoder = new TextDecoder("utf-8");
+   stepCallback(loadSize / totalSize, decoder.decode(value));
-   stepCallback(loadSize / totalSize);
    result.push(value);
}

传入 chunk 内容给 stepCallback 的同时,需要讲 araryBuffer 类型的数据转成字符串,这里利用了TextDecoder做处理

流式获取只能用 Fetch对象来做,XMLHttpRequest不支持。

后端代码实现

后端可以控制文字出现的速度:

/**
 * @param {http.IncomingMessage} req
 * @param {http.ServerResponse<http.IncomingMessage>} res
 */
async function handleGet(req, res) {
  const filePath = path.resolve(__dirname, "./welcome.txt");
  const fileContent = fs.readFileSync(filePath, "utf-8");
  const fileSize = fs.statSync(filePath).size;

  let currentIndex = 0;
  const chunkSize = 10; // 每次发送10个字符

  // 定义一个函数,用来发送数据块
  const sendChunk = () => {
    if (currentIndex < fileContent.length) {
      const chunk = fileContent.slice(currentIndex, currentIndex + chunkSize);
      res.write(chunk);
      currentIndex += chunkSize;
    } else {
      console.log("file be sent finish");
      clearInterval(intervalId); // 全部发送完毕,清除定时器
      res.end(); // 结束响应
    }
  };

  res.setHeader(
    "content-length",
    fileSize
  );

  // 使用 setInterval 来控制发送的频率
  const intervalId = setInterval(sendChunk, 300); // 每300毫秒发送一次

  // 如果客户端中断连接,则清除定时器
  res.on("close", () => {
    clearInterval(intervalId);
  });
}

要点:

  1. 这里控制的速度是,每 300 毫秒发送一次数据
  2. 使用 res.write()传递一次数据,而不是 res.end()
  3. 响应数据之前,需要设置响应头content-length, 该值需要是整个文本的字节长度,而不是字符长度。该值被前端用来计算响应的进度。

本篇文章所有的代码:

总结

在前端开发中,经常会遇见上传和下载。而对该过程的进度监控并显示出来,无疑能极大地提高用户的体验。所以这篇文章与大家分享了,通过前端两大原生的请求对象FetchXMLHttpRequest,实现上传和下载的进度监控。

Fetch 中的进度监控是用可读流来实现的,文末又利用了可读流来实现 GPT 对话式的流式展示数据,相信大家对可读流会有更深的理解