使用純原生JS實現(xiàn)大文件分片上傳
寫在前面
前段時間在工作中接觸到了文件上傳的內容,但業(yè)務中實現(xiàn)的功能比較簡單,于是我想著能不能使用純原生的方式實現(xiàn)一個大文件的上傳DEMO,從而在本質上學習大文件上傳的思路。本教程使用純原生的html+node.js實現(xiàn),能快速上手一個簡單的大文件上傳,深入理解其內部的原理,也能方便在后續(xù)的工作中對DEMO進行快速擴展,非常適合想入門學習大文件上傳的同學。
效果展示
首先來看看最后的效果。

實現(xiàn)思路

上圖是大文件上傳的整體流程圖,顯示了客戶端和服務端的交互邏輯,方便大家從宏觀上理解大文件上傳的過程,但如果按照上面的流程講解大文件上傳入門,很容易被勸退。
下面我們將按照功能點逐步迭代的方式講解大文件上傳,每個功能點都很簡單,每實現(xiàn)一個功能點都會極大的增漲我們的信心。大文件上傳一共分為分片上傳、分片合并、文件秒傳、斷點續(xù)傳、上傳進度這五個功能點,后面的功能都是在前面的功能基礎上迭代完成。如果能實現(xiàn)一個分片上傳功能就算是入門了大文件上傳了,后面都是在此基礎上增加功能而已。
具體實現(xiàn)
分片上傳
首先我們來實現(xiàn)一個最簡單也最核心的分片上傳,這個功能點分為客戶端的文件分片、計算hash值、上傳分片文件和服務端的創(chuàng)建分片目錄并存儲分片??蛻舳撕头斩嗽创a分別存放在BigFileUpload.html 和server.js文件中。
客戶端
為了方便后面能夠處理取消上傳和上傳進度,我們首先對fetch 請求做一個簡單的封裝。
/**
* @description: 封裝fetch
* @param {Object} FetchConfig fetch config
* @return {Promise} fetch result
*/
const requestApi = ({
url,
method = "GET",
...fetchProps
}) => {
return new Promise(async (resolve, reject) => {
const res = await fetch(url, {
method,
...fetchProps,
});
resolve(res.json());
});
};
下面是分片功能需要的標簽元素。
<input type="file" name="file" id="file" multiple /> <button id="upload" onClick="handleUpload()">上傳</button> <p id="hash-progress"></p> <p id="total-slice"></p>
首先,我們需要使用slice() 方法對大文件進行分片,并把分片的內容、大小等信息都放入到分片列表中,最后在頁面上顯示一下分片數量。
// 文件分片
const createFileChunk = (file) => {
const chunkList = [];
//計算文件切片總數
const sliceSize = 5 * 1024 * 1024; // 每個文件切片大小定為5MB
const totalSlice = Math.ceil(fileSize / sliceSize);
for (let i = 1; i <= totalSlice; i++) {
let chunk;
if (i == totalSlice) {
// 最后一片
chunk = file.slice((i - 1) * sliceSize, fileSize - 1); //切割文件
} else {
chunk = file.slice((i - 1) * sliceSize, i * sliceSize);
}
chunkList.push({
file: chunk,
fileSize,
size: Math.min(sliceSize, file.size),
});
}
const sliceText = `一共分片:${totalSlice}`;
document.getElementById("total-slice").innerHTML = sliceText;
console.log(sliceText);
return chunkList;
};
然后, 使用spark-md5 分別計算每個分片的hash值,最后得到整個文件hash值。計算hash值需要比較長的時間,可以在頁面上輸出計算hash值的進度。
// 根據分片生成hash
const calculateHash = (fileChunkList) => {
return new Promise((resolve) => {
const spark = new SparkMD5.ArrayBuffer();
let count = 0;
// 計算出hash
const loadNext = (index) => {
const reader = new FileReader(); // 文件閱讀對象
reader.readAsArrayBuffer(fileChunkList[index].file);
reader.onload = (e) => {
count++;
spark.append(e.target.result);
if (count === fileChunkList.length) {
resolve(spark.end());
} else {
// 還沒讀完
const percentage = parseInt(
((count + 1) / fileChunkList.length) * 100
);
const progressText = `計算hash值:${percentage}%`;
document.getElementById("hash-progress").innerHTML =
progressText;
console.log(progressText);
loadNext(count);
}
};
};
loadNext(0);
});
};
緊接著,需要將分片數據全部上傳到服務器,這里需要注意是的分片的hash值是 ${fileHash}-${index}, 服務端會根據這個hash值創(chuàng)建分片文件。
let fileName = "",
fileHash = "",
fileSize = 0,
fileChunkListData = [];
const HOST = "http://localhost:3000";
// ...
const handleUpload = async () => {
const file = document.getElementById("file").files[0];
if (!file) return alert("請選擇文件!");
fileName = file.name; // 文件名
fileSize = file.size; // 文件大小
const fileChunkList = createFileChunk(file);
fileHash = await calculateHash(fileChunkList); // 文件hash
fileChunkListData = fileChunkList.map(({ file, size }, index) => {
const hash = `${fileHash}-${index}`;
return {
file,
size,
fileName,
fileHash,
hash,
};
});
await uploadChunks();
};
//上傳分片
const uploadChunks = async () => {
const requestList = fileChunkListData
.map(({ file, fileHash, fileName, hash }, index) => {
const formData = new FormData();
formData.append("file", file);
formData.append("fileHash", fileHash);
formData.append("name", fileName);
formData.append("hash", hash);
return { formData };
})
.map(async ({ formData }) => {
return requestApi({
url: `${HOST}`,
method: "POST",
body: formData,
});
});
await Promise.all(requestList);
};
服務端
首先,我們使用原生node.js啟動一個后端服務。
import * as http from "http"; //ES 6
import path from "path";
const server = http.createServer();
server.on("request", async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Headers", "*");
if (req.method === "OPTIONS") {
res.status = 200;
res.end();
return;
}
});
server.listen(3000, () => console.log("正在監(jiān)聽 3000 端口"));
接下來,我們就可以在里面添加上傳分片的接口。使用multiparty讀取到客戶端提交的表單數據后,判斷切片目錄是否存在,不存在就使用 fileHash 值創(chuàng)建一個臨時的分片目錄,并使用fs-extra 的move 方法存儲文件分片到對應的分片目錄下。
import * as http from "http"; //ES 6
import path from "path";
import fse from "fs-extra";
import multiparty from "multiparty";
const server = http.createServer();
const UPLOAD_DIR = path.resolve("/Users/sxg/Downloads/", "target"); // 大文件存儲目錄
server.on("request", async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Headers", "*");
if (req.method === "OPTIONS") {
res.status = 200;
res.end();
return;
}
if (req.url === "/") {
const multipart = new multiparty.Form();
multipart.parse(req, async (err, fields, files) => {
if (err) {
console.error(err);
res.status = 500;
res.end(
JSON.stringify({
messaage: "process file chunk failed",
})
);
return;
}
const [chunk] = files.file;
const [hash] = fields.hash;
const [filename] = fields.name;
const [fileHash] = fields.fileHash;
const chunkDir = `${UPLOAD_DIR}/${fileHash}`;
const filePath = path.resolve(
UPLOAD_DIR,
`${fileHash}${extractExt(filename)}`
);
// 文件存在直接返回
if (fse.existsSync(filePath)) {
res.end(
JSON.stringify({
messaage: "file exist",
})
);
return;
}
// 切片目錄不存在,創(chuàng)建切片目錄
if (!fse.existsSync(chunkDir)) {
await fse.mkdirs(chunkDir);
}
// fs-extra 專用方法,類似 fs.rename 并且跨平臺
// fs-extra 的 rename 方法 windows 平臺會有權限問題
// https://github.com/meteor/meteor/issues/7852#issuecomment-255767835
await fse.move(chunk.path, `${chunkDir}/${hash}`);
res.status = 200;
res.end(
JSON.stringify({
messaage: "received file chunk",
})
);
});
}
});
server.listen(3000, () => console.log("正在監(jiān)聽 3000 端口"));
到這里為止,我們就已經實現(xiàn)了文件上傳最基本的功能,后續(xù)只是在此基礎上進行迭代。
合并分片
客戶端
在上傳完文件分片之后,我們就可以對所有文件分片進行合并,這里需要請求一個合并分片的接口,需要傳遞文件的fileHash 和 filename 。
//上傳分片
const uploadChunks = async () => {
//...
await mergeRequest(fileName, fileHash);
};
// 合并分片
const mergeRequest = async (fileName, fileHash) => {
await requestApi({
url: `${HOST}/merge`,
method: "POST",
headers: {
"Content-Type": "application/json;charset=utf-8",
},
body: JSON.stringify({
filename: fileName,
fileHash,
}),
});
};
服務端
合并切片功能最核心的功能就是根據fileHash讀取對應分片目錄下的分片文件列表,并按照分片下標進行排序,避免后面合并時順序錯亂。然后,使用 writeFile 方法創(chuàng)建一個空文件,再使用appendFileSync 依次向文件中添加分片數據,最后刪除臨時的分片目錄。
// 合并切片
const mergeFileChunk = async (filePath, fileHash) => {
const chunkDir = `${UPLOAD_DIR}/${fileHash}`;
const chunkPaths = await fse.readdir(chunkDir);
// 根據切片下標進行排序,否則直接讀取目錄的獲得的順序可能會錯亂
chunkPaths.sort((a, b) => a.split("-")[1] - b.split("-")[1]);
await fse.writeFile(filePath, "");
chunkPaths.forEach((chunkPath) => {
fse.appendFileSync(filePath, fse.readFileSync(`${chunkDir}/${chunkPath}`));
fse.unlinkSync(`${chunkDir}/${chunkPath}`);
});
fse.rmdirSync(chunkDir); // 合并后刪除保存切片的目錄
};
這里實現(xiàn)一下合并分片的接口,首先需要讀取請求中的數據,然后拼接出合并后的文件名稱 ${UPLOAD_DIR}/${fileHash}${ext},最后調用合并分片方法。
import * as http from "http"; //ES 6
import path from "path";
import fse from "fs-extra";
import multiparty from "multiparty";
const server = http.createServer();
const extractExt = (filename) =>
filename.slice(filename.lastIndexOf("."), filename.length); // 提取后綴名
//...
const resolvePost = (req) =>
new Promise((resolve) => {
let chunk = "";
req.on("data", (data) => {
chunk += data;
});
req.on("end", () => {
resolve(JSON.parse(chunk));
});
});
server.on("request", async (req, res) => {
//...
if (req.url === "/merge") {
const data = await resolvePost(req);
const { filename, fileHash } = data;
const ext = extractExt(filename);
const filePath = `${UPLOAD_DIR}/${fileHash}${ext}`;
await mergeFileChunk(filePath, fileHash);
res.status = 200;
res.end(JSON.stringify("file merged success"));
}
});
server.listen(3000, () => console.log("正在監(jiān)聽 3000 端口"));
秒傳
客戶端
實現(xiàn)秒傳只需要在文件上傳之前請求接口驗證一下文件是否存在。
const handleUpload = async () => {
//...
const { shouldUpload } = await verifyUpload(
fileName,
fileHash
);
if (!shouldUpload) {
alert("秒傳:上傳成功");
return;
}
//...
};
//文件秒傳
const verifyUpload = async (filename, fileHash) => {
const data = await requestApi({
url: `${HOST}/verify`,
method: "POST",
headers: {
"Content-Type": "application/json;charset=utf-8",
},
body: JSON.stringify({
filename,
fileHash,
}),
});
return data;
};
服務端
如果文件存在shouldUpload 就返回 false,否則就返回 true 。
import * as http from "http"; //ES 6
import path from "path";
import fse from "fs-extra";
import multiparty from "multiparty";
const server = http.createServer();
//...
server.on("request", async (req, res) => {
//...
if (req.url === "/verify") {
const data = await resolvePost(req);
const { fileHash, filename } = data;
const ext = extractExt(filename);
const filePath = `${UPLOAD_DIR}/${fileHash}${ext}`;
if (fse.existsSync(filePath)) {
res.end(
JSON.stringify({
shouldUpload: false,
})
);
} else {
res.end(
JSON.stringify({
shouldUpload: true,
})
);
}
}
});
server.listen(3000, () => console.log("正在監(jiān)聽 3000 端口"));
斷點續(xù)傳
客戶端
斷點續(xù)傳新增了兩個按鈕,來控制文件上傳進度。
/* ... */ <button id="pause" onClick="handlePause()" style="display: none"> 暫停 </button> <button id="resume" onClick="handleResume()" style="display: none"> 恢復 </button> /* ... */
這里需要對requestApi 進行一些改造,添加 abortControllerList 用于存儲需要被取消的請求,如果接口請求成功,則將fetch從 abortControllerList 中移除。
/**
* @description: 封裝fetch
* @param {Object} FetchConfig fetch config
* @return {Promise} fetch result
*/
const requestApi = ({
url,
method = "GET",
onProgress,
...fetchProps
}) => {
const controller = new AbortController();
abortControllerList.push(controller);
return new Promise(async (resolve, reject) => {
const res = await fetch(url, {
method,
...fetchProps,
signal: controller.signal,
});
// 將請求成功的 fetch 從列表中刪除
const aCIndex = abortControllerList.findIndex(
(c) => c.signal === controller.signal
);
abortControllerList.splice(aCIndex, 1);
//...
});
};
在分片上傳也需要做一些改造,將接口中獲取到的uploadedList ,從所有分片列表中過濾出去,當已上傳的uploadedList 數量加 requestList 的數量等于分片列表fileChunkListData 的數量時才進行分片合并。
let fileName = "",
fileHash = "",
fileSize = 0,
fileChunkListData = [],
abortControllerList = [];
const HOST = "http://localhost:3000";
//...
const handleUpload = async () => {
//...
const { shouldUpload, uploadedList } = await verifyUpload(
fileName,
fileHash
);
if (!shouldUpload) {
alert("秒傳:上傳成功");
return;
}
//...
await uploadChunks(uploadedList);
};
//上傳分片
const uploadChunks = async (uploadedList) => {
const requestList = fileChunkListData
.filter(({ hash }) => !uploadedList.includes(hash))
.map(({ file, fileHash, fileName, hash }, index) => {
//...
})
.map(async ({ formData, hash }) => {
. //...
});
//...
// 之前上傳的切片數量 + 本次上傳的切片數量 = 所有切片數量時
//合并分片
if (
uploadedList.length + requestList.length ===
fileChunkListData.length
) {
await mergeRequest(fileName, fileHash);
}
};
然后,實現(xiàn)一下暫停和恢復的事件處理,暫停是通過調用 AbortController 的 abort() 方法實現(xiàn)?;謴蛣t是重新獲取uploadedList 后再進行分片上傳實現(xiàn)。
//暫停
const handlePause = () => {
abortControllerList.forEach((controller) => controller?.abort());
abortControllerList = [];
};
// 恢復
const handleResume = async () => {
const { uploadedList } = await verifyUpload(fileName, fileHash);
await uploadChunks(uploadedList);
};
服務端
斷點續(xù)傳是在秒傳接口的基礎上實現(xiàn)的,只是需要新增已上傳分片列表uploadedList 。
import * as http from "http"; //ES 6
import path from "path";
import fse from "fs-extra";
import multiparty from "multiparty";
const server = http.createServer();
//...
// 返回已經上傳切片名列表
const createUploadedList = async (fileHash) =>
fse.existsSync(`${UPLOAD_DIR}/${fileHash}`)
? await fse.readdir(`${UPLOAD_DIR}/${fileHash}`)
: [];
server.on("request", async (req, res) => {
//...
if (req.url === "/verify") {
const data = await resolvePost(req);
const { fileHash, filename } = data;
const ext = extractExt(filename);
const filePath = `${UPLOAD_DIR}/${fileHash}${ext}`;
if (fse.existsSync(filePath)) {
res.end(
JSON.stringify({
shouldUpload: false,
})
);
} else {
res.end(
JSON.stringify({
shouldUpload: true,
uploadedList: await createUploadedList(fileHash),
})
);
}
}
});
server.listen(3000, () => console.log("正在監(jiān)聽 3000 端口"));
上傳進度
上傳進度只需要改造客戶端,首先,新增顯示進度的標簽。
<p id="progress"></p>
上傳進度需要對fetch請求再做一點改造,這里需要使用getReader() 手動讀取數據流,獲取到當前上傳進度,并添加onProgress 回調。
/**
* @description: 封裝fetch
* @param {Object} FetchConfig fetch config
* @return {Promise} fetch result
*/
const requestApi = ({
url,
method = "GET",
onProgress,
...fetchProps
}) => {
//...
return new Promise(async (resolve, reject) => {
const res = await fetch(url, {
method,
...fetchProps,
});
const total = res.headers.get("content-length");
const reader = res.body.getReader(); //創(chuàng)建可讀流
const decoder = new TextDecoder();
let loaded = 0;
let data = "";
while (true) {
const { done, value } = await reader.read();
loaded += value?.length || 0;
data += decoder.decode(value);
onProgress && onProgress({ loaded, total });
if (done) {
break;
}
}
//...
resolve(JSON.parse(data));
});
};
然后,在上傳的時候將已上傳進度設置成100,并添加onProgress回調處理,累計每個分片的進度,得到整體的上傳進度。
let fileName = "",
fileHash = "",
fileSize = 0,
fileChunkListData = [],
abortControllerList = [];
const HOST = "http://localhost:3000";
//...
const handleUpload = async () => {
//...
fileChunkListData = fileChunkList.map(({ file, size }, index) => {
//...
return {
percentage: uploadedList.includes(hash) ? 100 : 0,
};
});
//...
};
//上傳分片
const uploadChunks = async (uploadedList) => {
const requestList = fileChunkListData
.filter(({ hash }) => !uploadedList.includes(hash))
.map(({ file, fileHash, fileName, hash }, index) => {
//...
})
.map(async ({ formData, hash }) => {
return requestApi({
url: `${HOST}`,
method: "POST",
body: formData,
onProgress: ({ loaded, total }) => {
const percentage = parseInt((loaded / total) * 100);
// console.log("分片上傳百分比:", percentage);
const curIndex = fileChunkListData.findIndex(
({ hash: h }) => h === hash
);
fileChunkListData[curIndex].percentage = percentage;
const totalLoaded = fileChunkListData
.map((item) => item.size * item.percentage)
.reduce((acc, cur) => acc + cur);
const totalPercentage = parseInt(
(totalLoaded / fileSize).toFixed(2)
);
const progressText = `上傳進度:${totalPercentage}%`;
document.getElementById("progress").innerHTML = progressText;
console.log(progressText);
},
});
});
//...
};
總結
大文件上傳其實很多時候不需要我們自己去實現(xiàn),因為已經有很多成熟的解決方案。
但深入理解大文件上傳背后的原理,更加有利于我們對已有的大文件上傳方案進行個性化改造。
在線實現(xiàn)大文件上傳的過程中使用到了三個插件,multiparty、fs-extra、spark-md5,如果大家不太理解,需要自己去補充一下相關知識。
以上就是使用純原生JS實現(xiàn)大文件分片上傳的詳細內容,更多關于JS大文件分片上傳的資料請關注腳本之家其它相關文章!
相關文章
JS使用Expires?max-age判斷緩存過期的瀏覽器實例
這篇文章主要為大家介紹了JS使用Expires?max-age判斷緩存過期的瀏覽器實例,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-11-11
對比分析Django的Q查詢及AngularJS的Datatables分頁插件
通過本文給大家對比分析了Django的Q查詢及AngularJS的Datatables分頁插件,非常不錯,具有參考借鑒價值,需要的朋友可以參考下2017-02-02
一個不錯的用JavaScript實現(xiàn)的UBB編碼函數
一個不錯的用JavaScript實現(xiàn)的UBB編碼函數...2007-03-03
JS獲取input[file]的值并顯示在頁面的實現(xiàn)方法
下面小編就為大家分享一篇JS獲取input[file]的值并顯示在頁面的實現(xiàn)方法,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2018-03-03
PPK 談 JavaScript 的 this 關鍵字 [翻譯]
在 JavaScript 中 this 是最強的關鍵字之一。這篇貼文就是要告訴你如何用好 this。2009-09-09

