使用純?cè)鶭S實(shí)現(xiàn)大文件分片上傳
寫在前面
前段時(shí)間在工作中接觸到了文件上傳的內(nèi)容,但業(yè)務(wù)中實(shí)現(xiàn)的功能比較簡(jiǎn)單,于是我想著能不能使用純?cè)姆绞綄?shí)現(xiàn)一個(gè)大文件的上傳DEMO,從而在本質(zhì)上學(xué)習(xí)大文件上傳的思路。本教程使用純?cè)膆tml+node.js實(shí)現(xiàn),能快速上手一個(gè)簡(jiǎn)單的大文件上傳,深入理解其內(nèi)部的原理,也能方便在后續(xù)的工作中對(duì)DEMO進(jìn)行快速擴(kuò)展,非常適合想入門學(xué)習(xí)大文件上傳的同學(xué)。
效果展示
首先來看看最后的效果。
實(shí)現(xiàn)思路
上圖是大文件上傳的整體流程圖,顯示了客戶端和服務(wù)端的交互邏輯,方便大家從宏觀上理解大文件上傳的過程,但如果按照上面的流程講解大文件上傳入門,很容易被勸退。
下面我們將按照功能點(diǎn)逐步迭代的方式講解大文件上傳,每個(gè)功能點(diǎn)都很簡(jiǎn)單,每實(shí)現(xiàn)一個(gè)功能點(diǎn)都會(huì)極大的增漲我們的信心。大文件上傳一共分為分片上傳、分片合并、文件秒傳、斷點(diǎn)續(xù)傳、上傳進(jìn)度這五個(gè)功能點(diǎn),后面的功能都是在前面的功能基礎(chǔ)上迭代完成。如果能實(shí)現(xiàn)一個(gè)分片上傳功能就算是入門了大文件上傳了,后面都是在此基礎(chǔ)上增加功能而已。
具體實(shí)現(xiàn)
分片上傳
首先我們來實(shí)現(xiàn)一個(gè)最簡(jiǎn)單也最核心的分片上傳,這個(gè)功能點(diǎn)分為客戶端的文件分片、計(jì)算hash值、上傳分片文件和服務(wù)端的創(chuàng)建分片目錄并存儲(chǔ)分片。客戶端和服務(wù)端源代碼分別存放在BigFileUpload.html
和server.js
文件中。
客戶端
為了方便后面能夠處理取消上傳和上傳進(jìn)度,我們首先對(duì)fetch
請(qǐng)求做一個(gè)簡(jiǎn)單的封裝。
/** * @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()); }); };
下面是分片功能需要的標(biāo)簽元素。
<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()
方法對(duì)大文件進(jìn)行分片,并把分片的內(nèi)容、大小等信息都放入到分片列表中,最后在頁面上顯示一下分片數(shù)量。
// 文件分片 const createFileChunk = (file) => { const chunkList = []; //計(jì)算文件切片總數(shù) const sliceSize = 5 * 1024 * 1024; // 每個(gè)文件切片大小定為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 分別計(jì)算每個(gè)分片的hash值,最后得到整個(gè)文件hash值。計(jì)算hash值需要比較長的時(shí)間,可以在頁面上輸出計(jì)算hash值的進(jìn)度。
// 根據(jù)分片生成hash const calculateHash = (fileChunkList) => { return new Promise((resolve) => { const spark = new SparkMD5.ArrayBuffer(); let count = 0; // 計(jì)算出hash const loadNext = (index) => { const reader = new FileReader(); // 文件閱讀對(duì)象 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 = `計(jì)算hash值:${percentage}%`; document.getElementById("hash-progress").innerHTML = progressText; console.log(progressText); loadNext(count); } }; }; loadNext(0); }); };
緊接著,需要將分片數(shù)據(jù)全部上傳到服務(wù)器,這里需要注意是的分片的hash值是 ${fileHash}-${index}
, 服務(wù)端會(huì)根據(jù)這個(gè)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("請(qǐng)選擇文件!"); 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); };
服務(wù)端
首先,我們使用原生node.js
啟動(dòng)一個(gè)后端服務(wù)。
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讀取到客戶端提交的表單數(shù)據(jù)后,判斷切片目錄是否存在,不存在就使用 fileHash
值創(chuàng)建一個(gè)臨時(shí)的分片目錄,并使用fs-extra 的move
方法存儲(chǔ)文件分片到對(duì)應(yīng)的分片目錄下。
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"); // 大文件存儲(chǔ)目錄 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 并且跨平臺(tái) // fs-extra 的 rename 方法 windows 平臺(tái)會(huì)有權(quán)限問題 // 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 端口"));
到這里為止,我們就已經(jīng)實(shí)現(xiàn)了文件上傳最基本的功能,后續(xù)只是在此基礎(chǔ)上進(jìn)行迭代。
合并分片
客戶端
在上傳完文件分片之后,我們就可以對(duì)所有文件分片進(jìn)行合并,這里需要請(qǐng)求一個(gè)合并分片的接口,需要傳遞文件的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, }), }); };
服務(wù)端
合并切片功能最核心的功能就是根據(jù)fileHash
讀取對(duì)應(yīng)分片目錄下的分片文件列表,并按照分片下標(biāo)進(jìn)行排序,避免后面合并時(shí)順序錯(cuò)亂。然后,使用 writeFile
方法創(chuàng)建一個(gè)空文件,再使用appendFileSync
依次向文件中添加分片數(shù)據(jù),最后刪除臨時(shí)的分片目錄。
// 合并切片 const mergeFileChunk = async (filePath, fileHash) => { const chunkDir = `${UPLOAD_DIR}/${fileHash}`; const chunkPaths = await fse.readdir(chunkDir); // 根據(jù)切片下標(biāo)進(jìn)行排序,否則直接讀取目錄的獲得的順序可能會(huì)錯(cuò)亂 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); // 合并后刪除保存切片的目錄 };
這里實(shí)現(xiàn)一下合并分片的接口,首先需要讀取請(qǐng)求中的數(shù)據(jù),然后拼接出合并后的文件名稱 ${UPLOAD_DIR}/${fileHash}${ext}
,最后調(diào)用合并分片方法。
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 端口"));
秒傳
客戶端
實(shí)現(xiàn)秒傳只需要在文件上傳之前請(qǐng)求接口驗(yà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; };
服務(wù)端
如果文件存在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 端口"));
斷點(diǎn)續(xù)傳
客戶端
斷點(diǎn)續(xù)傳新增了兩個(gè)按鈕,來控制文件上傳進(jìn)度。
/* ... */ <button id="pause" onClick="handlePause()" style="display: none"> 暫停 </button> <button id="resume" onClick="handleResume()" style="display: none"> 恢復(fù) </button> /* ... */
這里需要對(duì)requestApi
進(jìn)行一些改造,添加 abortControllerList
用于存儲(chǔ)需要被取消的請(qǐng)求,如果接口請(qǐng)求成功,則將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, }); // 將請(qǐng)求成功的 fetch 從列表中刪除 const aCIndex = abortControllerList.findIndex( (c) => c.signal === controller.signal ); abortControllerList.splice(aCIndex, 1); //... }); };
在分片上傳也需要做一些改造,將接口中獲取到的uploadedList
,從所有分片列表中過濾出去,當(dāng)已上傳的uploadedList
數(shù)量加 requestList
的數(shù)量等于分片列表fileChunkListData
的數(shù)量時(shí)才進(jìn)行分片合并。
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 }) => { . //... }); //... // 之前上傳的切片數(shù)量 + 本次上傳的切片數(shù)量 = 所有切片數(shù)量時(shí) //合并分片 if ( uploadedList.length + requestList.length === fileChunkListData.length ) { await mergeRequest(fileName, fileHash); } };
然后,實(shí)現(xiàn)一下暫停和恢復(fù)的事件處理,暫停是通過調(diào)用 AbortController 的 abort()
方法實(shí)現(xiàn)?;謴?fù)則是重新獲取uploadedList
后再進(jìn)行分片上傳實(shí)現(xiàn)。
//暫停 const handlePause = () => { abortControllerList.forEach((controller) => controller?.abort()); abortControllerList = []; }; // 恢復(fù) const handleResume = async () => { const { uploadedList } = await verifyUpload(fileName, fileHash); await uploadChunks(uploadedList); };
服務(wù)端
斷點(diǎn)續(xù)傳是在秒傳接口的基礎(chǔ)上實(shí)現(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(); //... // 返回已經(jīng)上傳切片名列表 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 端口"));
上傳進(jìn)度
上傳進(jìn)度只需要改造客戶端,首先,新增顯示進(jìn)度的標(biāo)簽。
<p id="progress"></p>
上傳進(jìn)度需要對(duì)fetch
請(qǐng)求再做一點(diǎn)改造,這里需要使用getReader()
手動(dòng)讀取數(shù)據(jù)流,獲取到當(dāng)前上傳進(jìn)度,并添加onProgress
回調(diào)。
/** * @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)); }); };
然后,在上傳的時(shí)候?qū)⒁焉蟼鬟M(jìn)度設(shè)置成100,并添加onProgress
回調(diào)處理,累計(jì)每個(gè)分片的進(jìn)度,得到整體的上傳進(jìn)度。
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 = `上傳進(jìn)度:${totalPercentage}%`; document.getElementById("progress").innerHTML = progressText; console.log(progressText); }, }); }); //... };
總結(jié)
大文件上傳其實(shí)很多時(shí)候不需要我們自己去實(shí)現(xiàn),因?yàn)橐呀?jīng)有很多成熟的解決方案。
但深入理解大文件上傳背后的原理,更加有利于我們對(duì)已有的大文件上傳方案進(jìn)行個(gè)性化改造。
在線實(shí)現(xiàn)大文件上傳的過程中使用到了三個(gè)插件,multiparty
、fs-extra
、spark-md5
,如果大家不太理解,需要自己去補(bǔ)充一下相關(guān)知識(shí)。
以上就是使用純?cè)鶭S實(shí)現(xiàn)大文件分片上傳的詳細(xì)內(nèi)容,更多關(guān)于JS大文件分片上傳的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
JS使用Expires?max-age判斷緩存過期的瀏覽器實(shí)例
這篇文章主要為大家介紹了JS使用Expires?max-age判斷緩存過期的瀏覽器實(shí)例,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-11-11對(duì)比分析Django的Q查詢及AngularJS的Datatables分頁插件
通過本文給大家對(duì)比分析了Django的Q查詢及AngularJS的Datatables分頁插件,非常不錯(cuò),具有參考借鑒價(jià)值,需要的朋友可以參考下2017-02-02基于canvasJS在PHP中制作動(dòng)態(tài)圖表
這篇文章主要介紹了基于canvasJS在PHP中制作動(dòng)態(tài)圖表,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2020-05-05一個(gè)不錯(cuò)的用JavaScript實(shí)現(xiàn)的UBB編碼函數(shù)
一個(gè)不錯(cuò)的用JavaScript實(shí)現(xiàn)的UBB編碼函數(shù)...2007-03-03函數(shù)四種調(diào)用模式以及其中的this指向
本文主要介紹了函數(shù)四種調(diào)用模式以及其中的this指向的相關(guān)知識(shí),具有一定的參考價(jià)值,下面跟著小編一起來看下吧2017-01-01JS獲取input[file]的值并顯示在頁面的實(shí)現(xiàn)方法
下面小編就為大家分享一篇JS獲取input[file]的值并顯示在頁面的實(shí)現(xiàn)方法,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來看看吧2018-03-03PPK 談 JavaScript 的 this 關(guān)鍵字 [翻譯]
在 JavaScript 中 this 是最強(qiáng)的關(guān)鍵字之一。這篇貼文就是要告訴你如何用好 this。2009-09-09JavaScript塊級(jí)作用域綁定的實(shí)現(xiàn)流程
這篇文章主要給大家介紹了關(guān)于JavaScript塊級(jí)作用域綁定的相關(guān)資料,文中通過實(shí)例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2022-12-12