前言
前一陣子忙著做畢設,做完了之後想著閒著也是閒著,還是寫個文章總結下。也想看看這次自己能堅持多久寫文章把,畢竟以前每次都是一時興起,過一段時間就莫得啥熱情遼。
檔案上傳基本流程
前端通過 input
獲取到使用者選擇的檔案,放入 FormData
中,設定 content-type
為 multipart/form-data
傳送給服務端。服務端通過 cookie/token/...
等等資訊,再對檔案/資料庫進行處理操作。
好像沒啥講的,直接上程式碼吧。
<input type="file" onChange={handleFileChange} ref={inputRef} multiple />
複製程式碼
const files = inputRef.current?.files;
// 獲取到file陣列,對陣列處理,然後得到file
const formData = new FormData();
formData.append('file', file);
formData.append('xx', xx);
// 可以帶上一些資訊
const { code } = await uploadFiles(formData);
複製程式碼
服務端我用的是 node express
,然後使用 multer
去處理 multipart/form-data
型別的表單資料。multer傳送門
import multer from 'multer';
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, './dist')
},
filename: (req, file, cb) => {
cb(null, `${(+ new Date())}${file.originalname}`)
}
});
const upload = multer({ storage });
app.use(upload.single('file'));
// 我這邊是單檔案處理,對應這req.file;如果要多檔案,那就是array,對應req.files
複製程式碼
router.post('/upload', async (req, res) => {
const { file } = req;
// to do sth.
// 目錄轉移的話,可以用fs.renameSync/fs.rename
});
複製程式碼
拖拽上傳
拖拽上傳就是利用onDrop
和onDragOver
,阻止瀏覽器預設事件,然後就得到對應檔案遼。
<div onDrop={onDrop} onDragOver={onDragOver} ></div>
複製程式碼
const onDragOver = (e: DragEvent | React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
};
const onDrop = (e: DragEvent | React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
handleFileChange(e.dataTransfer?.files);
};
複製程式碼
大檔案上傳
大檔案上傳,可能會出現,上傳時間過長,介面限制了檔案大小。所以,大檔案直接上傳,也很不友好,一般採用分片上傳的方式去上傳。而 blob
提供了 slice
方法, file
繼承了 blob
自然也能使用 slice
去進行分片處理。
處理流程:
- 前端對大檔案進行分片,分片名採用檔案
hash
(後續會講)加下標
- 為了防止完全佔用
tcp
資源,我這裡是採用4個4個上傳 - 在最後,再傳送一個合併請求
- 服務端根據請求,對前面的分片內容進行合併成一個整體檔案,然後刪除分片
const handleUpload = () => {
chunks = [];
const file: File = files.current[dataIndex];
// 獲取對應檔案file
let start = 0, end = BIG_FILE_SIZE;
// const BIG_FILE_SIZE = 1024 * 1024 * 2;
while(true) {
const part: Blob = file.slice(start, end);
if (part.size) {
chunks.push(part);
} else {
break;
}
start += BIG_FILE_SIZE;
end += BIG_FILE_SIZE;
};
// worker!.postMessage({ chunkList: chunks });
// 利用webworker獲取hash,後面會講
return;
};
const partUpload = async (start: number) => {
const uploadArr = [];
let restReq = MAX_FILE_TCP;
// MAX_FILE_TCP = 4;同時發起4個連線
let index = start;
while (restReq) {
if (index >= chunkCount) {
// chunkCount是chunk的length,即多少個片段
break;
};
// const blobPart = `${hash}-${index}`;
// if (hashData[hash] && hashData[hash][blobPart])
// {
// index++;
// continue;
// };
// 註釋部分是,斷點續傳部分程式碼,可跳過
const formData = new FormData();
formData.append('file', chunks[index]);
formData.append('xx', xx);
const uploadFunc = () => {
return new Promise(async (res) => {
const { code } = await uploadPart(formData);
res(code);
});
};
uploadArr.push(uploadFunc);
index++;
restReq--;
};
const result = await Promise.all(uploadArr.map(v => v()));
result.map((v) => {
if (v === 0) {
console.log('上傳片段成功');
} else {
throw new Error('上傳失敗');
}
return null;
});
if (index < chunkCount) {
partUpload(index);
} else {
const params = {
// sth.
};
const {code} = await partMerge(params);
// 傳送合併請求
// todo code sth.
}
};
複製程式碼
服務端的話,我這邊是把檔案根據對應的 hash
和 下標
進行命名,即 static/hash/hash-i
。利用 fs.rename
去修改檔案&路徑, 通過 pipe
合併檔案。
router.post('/upload_part', (req, res) => {
try {
const { hash, index } = req.body;
const { file } = req;
const sourcePath = path.join(__dirname, file.path);
const destPath = path.join(__dirname, `xxxx/${hash}/${hash}-${index}`);
fs.renameSync(sourcePath, destPath);
return res.json({code: 0});
} catch(e) {
return res.json({code: 1, msg: e});
}
});
router.post('/merge_part', (req, res) => {
try {
const destPath = 'xxx/yyy/zzz/a.png';
const writePath = fs.createWriteStream(destPath);
// 最終合併結果儲存在哪
const fileMerge = (i: number) => {
const blobPath = `xxx/part/${hash}/${hash}-${i}`;
const blobFile = fs.createReadStream(blobPath);
blobFile.on("end", async (err) => {
if (err) {
return res.json({code: 1, msg: err});
};
fs.unlink(blobPath);
// 刪除片段
if (i + 1 < chunkCount) {
fileMerge(i + 1);
} else {
fs.rmdirSync(`xxx/part/${hash}`);
// 刪除資料夾
// 資料庫操作 todo
return res.json({ code: 0 });
}
});
blobFile.pipe(writeStream, { end: false });
};
fileMerge(0);
} catch(e) {
return res.json({code: 1, msg: e});
}
});
// 省略了不必要內容
複製程式碼
斷點續傳
大檔案分片上傳,如果客戶端發生異常中斷了上傳過程,那麼下次重新上傳的時候,比較友好的做法應該是跳過那些已經上傳的片段。
那麼問題也就是,怎麼跳過那些檔案?剛才前面的程式碼,也顯示了,其實就是通過${hash}-${i}
設定檔名,然後儲存已經上傳成功的片段資料,在檔案分片上傳的時候,跳過已經上傳過的片段,就是斷點續傳遼。
對於這類資料儲存,一般都是兩個方法:(1)前端儲存;(2)服務端儲存;
前端儲存的話,一般就是用localStorage
,不太推薦使用。因為使用者如果清了快取,或者換了裝置登陸,就無法生效。
服務端的話,就返回對應的已成功上傳的片段名。因為對應userId
下的檔案hash-i
,是唯一的。node
這裡就採用readdirSync
去讀取檔名遼。
前端這裡就是前面提到過的部分。
const blobPart = `${hash}-${index}`;
if (hashData[hash] && hashData[hash][blobPart])
{
// hashData是服務端傳回來的資料,判斷片段是否存在,存在就跳過
// 具體可以看看前面
index++;
continue;
};
複製程式碼
WebWoker獲取hash
那麼就剩下一個問題遼,去獲取檔案的hash
。推薦使用spark-md5
去生成檔案的hash
。因為生成hash
過程是比較耗時間的,我這邊採用了webworker
去計算hash
。
webworker
我這邊是直接是將webworker
的程式碼寫到html
上面,然後利用Blob & URL.createObjectURL
去本地建立webwoker
。
<script id="worker" type="app/worker">
self.importScripts("https://cdn.bootcss.com/spark-md5/3.0.0/spark-md5.min.js");
self.onmessage = function(e) {
var spark = new self.SparkMD5.ArrayBuffer();
var chunkList = e.data.chunkList;
var count = 0;
var next = function(index) {
var fileReader = new FileReader();
fileReader.readAsArrayBuffer(chunkList[index]);
fileReader.onload = function(e) {
count ++;
spark.append(e.target.result);
if (count === chunkList.length) {
self.postMessage({
hash: spark.end()
});
} else {
loadNext(count);
}
};
};
next(0);
};
</script>
複製程式碼
然後在hook
函式裡呼叫createObjectURL
去生成DOMString
,作為Worker
的引數。
const newWebWorker = () => {
const textContent = document.getElementById('worker')!.textContent;
const blob = new Blob([textContent as BlobPart]);
createURL = window.URL.createObjectURL(blob);
worker = new Worker(createURL);
worker.onmessage = (e) => {
hash = e.data.hash;
// 這裡獲取到hash
// todo sth.
partUpload(0);
// 進行檔案片段上傳操作
};
};
複製程式碼
這裡利用createObjectURL生成URL後,使用完,需要使用revokeObjectURL去釋放
檔案下載
檔案下載方法還是挺多的,form
表單呀,location.href
呀,直接a
標籤呀,blob
方式呀等等。我這邊採用的是利用blob
去下載檔案,主要考慮到,可以進行鑑權操作&可以下載各種型別檔案等等。
過程就是
- 服務端返回
Blob
- 前端這裡通過
createObjectURL
生成DOMString
- 設定
a
標籤的href
然後執行它的點選事件
{
url
? <a ref={aRef} style={{ display: 'none' }} href={url} download={data.file_name} />
: null
}
// download就是你檔案下載下來的檔名
複製程式碼
const onDownload = async () => {
const blob = await getFileBlob({ id: data.id, type: data.type });
const url = window.URL.createObjectURL(blob);
setUrl(url);
aRef.current?.click();
setUrl('');
window.URL.revokeObjectURL(url);
};
export const getFileBlob = async (params: GetFileBlob) => {
const { data } = await api.get<Data>('/get_file_blob', {
params,
responseType: 'blob'
// responseType需要設定blob
});
return data;
};
複製程式碼
node
這裡就用createReadStream
讀取檔案,需要設定Content-Type
和Content-Disposition
。
router.get('/get_file_blob', async (req, res) => {
// todo sth.
const destPath = 'xxx/xxx';
const fileStream = fs.createReadStream(destPath);
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment;filename=whatareudoing`);
// 前面a標籤已經設定了download,這裡的filename不會有影響
fileStream.on("data", (chunk) => res.write(chunk, "binary"));
fileStream.on("end", () => res.end());
});
複製程式碼
進度條
進度條就是ProgressEvent
,如果是斷點續傳,就先對片段進行遍歷判斷是否有已經上傳過的,然後就得到一個初始進度。
const onUploadProgress = (event: ProgressEvent) => {
const progress = Math.floor((event.loaded / event.total) * 100);
// todo sth.
};
export const uploadFiles = async ({ formData, onUploadProgress }: UploadFiles) => {
const { data } = await api.post<Data>('/upload', formData, { onUploadProgress });
//...
};
複製程式碼