檔案上傳下載攻略,斷點續傳等等那些事兒,滿滿乾貨(react&node)

江魚兒呃呃呃發表於2020-04-06

前言

前一陣子忙著做畢設,做完了之後想著閒著也是閒著,還是寫個文章總結下。也想看看這次自己能堅持多久寫文章把,畢竟以前每次都是一時興起,過一段時間就莫得啥熱情遼。

檔案上傳基本流程

前端通過 input 獲取到使用者選擇的檔案,放入 FormData 中,設定 content-typemultipart/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
});
複製程式碼

拖拽上傳

拖拽上傳就是利用onDroponDragOver,阻止瀏覽器預設事件,然後就得到對應檔案遼。

<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

webwoker的基礎知識傳送門

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-TypeContent-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 });
  //...
};
複製程式碼

相關文章