你真的清楚文件上传的进度展示吗,为啥进度显示不准确?

最近在做一个项目,要求展示文件上传进度,测试发现进度结束后好久接口才返回成功。发现进度展示不准确。一开始自信的认为是后端返回的进度不对。研究了半天,又自己写了demo.才终于搞明白问题原因。先上代码

node.js原生实现文件上传:

js 复制代码
const http = require("http");
const fs = require("fs");
const path = require("path");
const os = require("os");

const UPLOAD_DIR = path.join(__dirname, "uploads");

// 创建 uploads 文件夹
if (!fs.existsSync(UPLOAD_DIR)) {
  fs.mkdirSync(UPLOAD_DIR);
}

// 解析 boundary
function getBoundary(header) {
  const items = header.split(";");
  for (let i = 0; i < items.length; i++) {
    let item = items[i].trim();
    if (item.startsWith("boundary=")) {
      return item.split("=")[1];
    }
  }
  return null;
}

// 启动服务器
const server = http.createServer((req, res) => {
  if (req.method === "OPTIONS") {
    // 处理 CORS 预请求
    res.writeHead(204, {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Methods": "POST, OPTIONS",
      "Access-Control-Allow-Headers": "Content-Type",
      "Access-Control-Max-Age": 86400,
    });
    return res.end();
  }

  if (req.method === "POST" && req.url === "/upload") {
    const contentType = req.headers["content-type"];
    const contentLength = parseInt(req.headers["content-length"], 10);
    const boundary = getBoundary(contentType);

    if (!boundary) {
      res.writeHead(400, { "Content-Type": "text/plain" });
      return res.end("No boundary found");
    }

    let progress = 0;

    let rawData = Buffer.alloc(0);

    req.on("data", (chunk) => {
      progress += chunk.length;
      rawData = Buffer.concat([rawData, chunk]);

      // 输出上传进度(可传到客户端)
      const percent = ((progress / contentLength) * 100).toFixed(2);
      process.stdout.write(`\r上传进度: ${percent}%`);
    });

    req.on("end", () => {
      // 分隔字段
      const parts = rawData.toString().split(`--${boundary}`);
      for (let part of parts) {
        // 跳过不是文件字段的内容
        if (part.includes("filename=")) {
          const match = part.match(/filename="(.+)"/);
          if (!match) continue;

          const filename = match[1];
          const fileStart = part.indexOf("\r\n\r\n") + 4;
          const fileEnd = part.lastIndexOf("\r\n");
          const fileData = part.slice(fileStart, fileEnd);

          const fileBuffer = Buffer.from(fileData, "binary");

          const filePath = path.join(UPLOAD_DIR, filename);

          // 写入文件
          fs.writeFileSync(filePath, fileBuffer, "binary");
          console.log(`\n文件已保存:${filename}`);
        }
      }

      res.writeHead(200, {
        "Content-Type": "application/json",
        "Access-Control-Allow-Origin": "*",
      });
      res.end(JSON.stringify({ message: "文件上传成功!" }));
    });

    req.on("error", (err) => {
      console.error("上传失败:", err);
      res.writeHead(500, { "Content-Type": "text/plain" });
      res.end("服务器错误");
    });
  } else {
    // 非上传路由
    res.writeHead(404, { "Content-Type": "text/plain" });
    res.end("Not Found");
  }
});

const PORT = 5000;
server.listen(PORT, () => {
  console.log(`🚀 原生 Node.js 文件上传服务运行于 http://localhost:${PORT}`);
});

前端的关键代码:

js 复制代码
<template>
  <div class="upload-container">
    <input type="file" ref="fileInput" @change="handleFileSelect" />
    <button @click="uploadFile" :disabled="!file || isUploading">
      {{ isUploading ? '上传中...' : '开始上传' }}
    </button>

    <div v-if="progress > 0" class="progress-container">
      <div class="progress-bar" :style="{ width: progress + '%' }">
        {{ progress }}%
      </div>
    </div>

    <div v-if="message" class="message">{{ message }}</div>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import axios from 'axios';

const fileInput = ref(null);
const file = ref(null);
const progress = ref(0);
const isUploading = ref(false);
const message = ref('');

const handleFileSelect = () => {
  file.value = fileInput.value.files[0];
  progress.value = 0;
  message.value = '';
};

const uploadFile = async () => {
  if (!file.value) return;

  isUploading.value = true;
  message.value = '';
  progress.value = 0;

  const formData = new FormData();
  formData.append('file', file.value);

  try {
    const response = await axios.post('http://localhost:5000/upload', formData, {
      headers: { 'Content-Type': 'multipart/form-data' },
      onUploadProgress: (progressEvent) => {
        console.log("🚀 ~ uploadFile ~ progressEvent.loaded:", progressEvent.loaded)
        if (progressEvent.total) {
          progress.value = Math.round(
            (progressEvent.loaded * 100) / progressEvent.total
          );
        }
      }
    });

    message.value = `上传成功!文件名: ${response.data.filename}`;
  } catch (error) {
    message.value = `上传失败: ${error.message}`;
    console.error('上传错误:', error);
  } finally {
    isUploading.value = false;
  }
};
</script>

<style scoped>
.upload-container {
  max-width: 500px;
  margin: 2rem auto;
  padding: 20px;
  border: 1px solid #eee;
  border-radius: 8px;
}

input[type="file"] {
  display: block;
  margin-bottom: 15px;
}

button {
  background: #42b983;
  color: white;
  border: none;
  padding: 10px 20px;
  border-radius: 4px;
  cursor: pointer;
}

button:disabled {
  background: #ccc;
  cursor: not-allowed;
}

.progress-container {
  height: 25px;
  background: #f0f0f0;
  border-radius: 4px;
  margin: 15px 0;
  overflow: hidden;
}

.progress-bar {
  height: 100%;
  background: #42b983;
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
  font-weight: bold;
  transition: width 0.3s ease;
}

.message {
  margin-top: 15px;
  padding: 10px;
  border-radius: 4px;
  background: #f8f8f8;
}
</style>

这个demo可以正确展示进度。为啥同样的前端代码在项目里不能正确展示进度呢?再仔细看看后端的代码。诶,后端并没有通知前端进度。难道是前端自己根据上传的进度算的?那就翻去吧。结果真的如下。

浏览器实现 XMLHttpRequest 的upload.onprogress 的原理

XMLHttpRequestupload.onprogress 事件的原理,简单来说,是浏览器自身在上传数据到服务器的过程中,实时追踪已经发送的字节数,并据此定期触发事件告知开发者进度。

它是一个纯粹的客户端(浏览器)机制,不依赖于服务器的任何响应或配合。

下面我们来详细解析其背后的原理:

  1. 数据准备与总大小计算 (total)

    • 当你调用 xhr.send(data) 时,如果 data 是一个 FileBlob 对象或者 FormData 对象(其中包含文件),浏览器在开始发送之前,会先计算出需要发送的整个 HTTP 请求体(Request Body)的总字节数。
    • 对于 FileBlob,其 size 属性就直接提供了总大小。
    • 对于 FormData,浏览器会将其内部的数据(包括文本字段和文件)序列化成 multipart/form-data 格式,并计算出这个序列化后的总大小。
    • 这个计算出来的值就是 ProgressEvent 对象中的 total 属性。
    • 如果浏览器无法预先计算出总大小(例如,当数据是流式传输,或者没有明确的大小信息时),那么 lengthComputable 属性将为 false,此时 total 可能是 0。但在文件上传场景,lengthComputable 通常为 true
  2. 数据发送与内部计数 (loaded)

    • xhr.send() 方法被调用后,浏览器会启动其内部的网络引擎(例如 Chrome 的 Net stack,Firefox 的 Necko)。
    • 浏览器不会一次性将所有数据都塞到网络中,特别是对于大文件。它会将数据切分成小块(chunks)。
    • 浏览器将这些数据块写入到底层操作系统的 TCP/IP 栈的发送缓冲区中。每当成功将一个数据块写入到发送缓冲区,或者操作系统确认该数据块已经被成功发送出去(离开了应用程序的控制,进入网络传输阶段),浏览器就会更新一个内部的计数器,记录下已发送的字节数。
    • 这个内部计数器累加的值,就是 ProgressEvent 对象中的 loaded 属性。
  3. 事件触发机制

    • 浏览器不会在每发送一个字节时都触发 onprogress 事件,那样会产生过多的事件,影响性能。

    • 通常,浏览器会在以下几种情况下触发 upload.onprogress 事件:

      • 当一定数量的字节(例如,每发送 10KB 或 50KB)被成功写入发送缓冲区后。
      • 或者,在一个固定的时间间隔内(例如,每 50 毫秒),即使发送的字节数不多,也会触发一次,以确保UI可以定期更新。
      • 浏览器内部的网络模块会根据实际发送情况和网络状况来决定触发事件的频率和时机。
  4. XMLHttpRequest.upload 对象的角色

    • XMLHttpRequest 对象本身负责管理整个 HTTP 请求和响应的生命周期。
    • XMLHttpRequest.upload 是一个特殊的 XMLHttpRequestUpload 对象,它专门用于监听请求体(即上传数据)的进展事件。
    • 这意味着 xhr.upload.onprogress 监听的是上传 的进度,(不带 .uploadxhr.onprogress监听的是下载响应体的进度。)

所以,upload.onprogress 的原理是浏览器利用其对网络传输的控制权和内部计数能力,在数据离开应用程序层进入网络协议栈时,实时跟踪并报告进度。它是一个高效且完全在客户端实现的特性。

知道这个原理就看项目的后端为啥上传后很久才返回成功,原来如此,后端又去调了iobs服务,等iobs服务返回成功后才返回给前端成功。

今天又搞明白了个技术点,不错不错。

相关推荐
一只猫猫熊1 分钟前
密码强度、一致性、相似度校验
前端
木林9071 分钟前
使用Vue3开发商品管理器(一)
前端
孤蓬&听雨4 分钟前
Axure高保真LayUI框架 V2.6.8元件库
前端·layui·产品经理·axure·原型设计
Hilaku5 分钟前
为什么我放弃使用 Pinia?
前端·javascript·vue.js
yrjw9 分钟前
使用ReactNative加载Svga动画支持三端【android/ios/harmony】
前端
贩卖纯净水.11 分钟前
webpack继续学习
前端·学习·webpack
陈_杨11 分钟前
鸿蒙5开发宝藏案例分享---在线短视频流畅切换
前端·javascript
Smile_frank14 分钟前
vue-06(“$emit”和事件修饰符)
前端
前端小巷子20 分钟前
JS的 DOM 尺寸与位置属性
前端·javascript·面试
二月垂耳兔79831 分钟前
Vue3基础
前端