最近在做一个项目,要求展示文件上传进度,测试发现进度结束后好久接口才返回成功。发现进度展示不准确。一开始自信的认为是后端返回的进度不对。研究了半天,又自己写了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 的原理
XMLHttpRequest
的 upload.onprogress
事件的原理,简单来说,是浏览器自身在上传数据到服务器的过程中,实时追踪已经发送的字节数,并据此定期触发事件告知开发者进度。
它是一个纯粹的客户端(浏览器)机制,不依赖于服务器的任何响应或配合。
下面我们来详细解析其背后的原理:
-
数据准备与总大小计算 (
total
)- 当你调用
xhr.send(data)
时,如果data
是一个File
、Blob
对象或者FormData
对象(其中包含文件),浏览器在开始发送之前,会先计算出需要发送的整个 HTTP 请求体(Request Body)的总字节数。 - 对于
File
或Blob
,其size
属性就直接提供了总大小。 - 对于
FormData
,浏览器会将其内部的数据(包括文本字段和文件)序列化成multipart/form-data
格式,并计算出这个序列化后的总大小。 - 这个计算出来的值就是
ProgressEvent
对象中的total
属性。 - 如果浏览器无法预先计算出总大小(例如,当数据是流式传输,或者没有明确的大小信息时),那么
lengthComputable
属性将为false
,此时total
可能是0
。但在文件上传场景,lengthComputable
通常为true
。
- 当你调用
-
数据发送与内部计数 (
loaded
)- 当
xhr.send()
方法被调用后,浏览器会启动其内部的网络引擎(例如 Chrome 的 Net stack,Firefox 的 Necko)。 - 浏览器不会一次性将所有数据都塞到网络中,特别是对于大文件。它会将数据切分成小块(chunks)。
- 浏览器将这些数据块写入到底层操作系统的 TCP/IP 栈的发送缓冲区中。每当成功将一个数据块写入到发送缓冲区,或者操作系统确认该数据块已经被成功发送出去(离开了应用程序的控制,进入网络传输阶段),浏览器就会更新一个内部的计数器,记录下已发送的字节数。
- 这个内部计数器累加的值,就是
ProgressEvent
对象中的loaded
属性。
- 当
-
事件触发机制
-
浏览器不会在每发送一个字节时都触发
onprogress
事件,那样会产生过多的事件,影响性能。 -
通常,浏览器会在以下几种情况下触发
upload.onprogress
事件:- 当一定数量的字节(例如,每发送 10KB 或 50KB)被成功写入发送缓冲区后。
- 或者,在一个固定的时间间隔内(例如,每 50 毫秒),即使发送的字节数不多,也会触发一次,以确保UI可以定期更新。
- 浏览器内部的网络模块会根据实际发送情况和网络状况来决定触发事件的频率和时机。
-
-
XMLHttpRequest.upload
对象的角色XMLHttpRequest
对象本身负责管理整个 HTTP 请求和响应的生命周期。XMLHttpRequest.upload
是一个特殊的XMLHttpRequestUpload
对象,它专门用于监听请求体(即上传数据)的进展事件。- 这意味着
xhr.upload.onprogress
监听的是上传 的进度,(不带.upload
的xhr.onprogress
监听的是下载响应体的进度。)
所以,upload.onprogress
的原理是浏览器利用其对网络传输的控制权和内部计数能力,在数据离开应用程序层进入网络协议栈时,实时跟踪并报告进度。它是一个高效且完全在客户端实现的特性。
知道这个原理就看项目的后端为啥上传后很久才返回成功,原来如此,后端又去调了iobs服务,等iobs服务返回成功后才返回给前端成功。
今天又搞明白了个技术点,不错不错。