前端大文件上傳方法(深度好文)


文件上傳的幾種方式

首先我們來看看文件上傳的幾種方式。

普通表單上傳

使用PHP來展示常規的表單上傳是一個不錯的選擇。首先構建文件上傳的表單,並指定表單的提交內容類型為enctype="multipart/form-data",表明表單需要上傳二進制數據。

<form action="/index.php" method="POST" enctype="multipart/form-data">
 <input type="file" name="myfile">
 <input type="submit">
</form>

然后編寫index.php上傳文件接收代碼,使用move_uploaded_file方法即可(php大法好...)

$imgName = 'IMG'.time().'.'.str_replace('image/','',$_FILES["myfile"]['type']);
$fileName = 'upload/'.$imgName;
// 移動上傳文件至指定upload文件夾下,並根據返回值判斷操作是否成功
if (move_uploaded_file($_FILES['myfile']['tmp_name'], $fileName)){
 echo $fileName;
}else {
 echo "nonn";
}

form表單上傳大文件時,很容易遇見服務器超時的問題。通過xhr,前端也可以進行異步上傳文件的操作,一般由兩個思路。

文件編碼上傳

第一個思路是將文件進行編碼,然后在服務端進行解碼,其主要實現原理就是將圖片轉換成base64進行傳遞

var imgURL = URL.createObjectURL(file);
ctx.drawImage(imgURL, 0, 0);
// 獲取圖片的編碼,然后將圖片當做是一個很長的字符串進行傳遞
var data = canvas.toDataURL("image/jpeg", 0.5);

在服務端需要做的事情也比較簡單,首先解碼base64,然后保存圖片即可

$imgData = $_REQUEST['imgData'];
$base64 = explode(',', $imgData)[1];
$img = base64_decode($base64);
$url = './test.jpg';
if (file_put_contents($url, $img)) {
 exit(json_encode(array(
 url => $url
 )));
}

base64編碼的缺點在於其體積比原圖片更大(因為Base64將三個字節轉化成四個字節,因此編碼后的文本,會比原文本大出三分之一左右),對於體積很大的文件來說,上傳和解析的時間會明顯增加。

除了進行base64編碼,還可以在前端直接讀取文件內容后以二進制格式上傳

// 讀取二進制文件
function readBinary(text){
 var data = new ArrayBuffer(text.length);
 var ui8a = new Uint8Array(data, 0);
 for (var i = 0; i < text.length; i++){ 
 ui8a[i] = (text.charCodeAt(i) & 0xff);
 }
 console.log(ui8a)
}
var reader = new FileReader();
reader.onload = function(){
	 readBinary(this.result) // 讀取result或直接上傳
}
// 把從input里讀取的文件內容,放到fileReader的result字段里
reader.readAsBinaryString(file);

formData異步上傳

FormData對象主要用來組裝一組用 XMLHttpRequest發送請求的鍵/值對,可以更加靈活地發送Ajax請求。可以使用FormData來模擬表單提交。

let files = e.target.files // 獲取input的file對象
let formData = new FormData();
formData.append('file', file);
axios.post(url, formData);

服務端處理方式與直接form表單請求基本相同。

iframe無刷新頁面

在低版本的瀏覽器(如IE)上,xhr是不支持直接上傳formdata的,因此只能用form來上傳文件,而form提交本身會進行頁面跳轉,這是因為form表單的target屬性導致的,其取值有

  • _self,默認值,在相同的窗口中打開響應頁面
  • _blank,在新窗口打開
  • _parent,在父窗口打開
  • _top,在最頂層的窗口打開
  • framename,在指定名字的iframe中打開

如果需要讓用戶體驗異步上傳文件的感覺,可以通過framename指定iframe來實現。把form的target屬性設置為一個看不見的iframe,那么返回的數據就會被這個iframe接受,因此只有該iframe會被刷新,至於返回結果,也可以通過解析這個iframe內的文本來獲取。

function upload(){
 var now = +new Date()
 var id = 'frame' + now
 $("body").append(`<iframe style="display:none;" name="${id}" id="${id}" />`);
 var $form = $("#myForm")
 $form.attr({
 "action": '/index.php',
 "method": "post",
 "enctype": "multipart/form-data",
 "encoding": "multipart/form-data",
 "target": id
 }).submit()
 $("#"+id).on("load", function(){
 var content = $(this).contents().find("body").text()
 try{
 var data = JSON.parse(content)
 }catch(e){
 console.log(e)
 }
 })
}

大文件上傳

現在來看看在上面提到的幾種上傳方式中實現大文件上傳會遇見的超時問題,

  • 表單上傳和iframe無刷新頁面上傳,實際上都是通過form標簽進行上傳文件,這種方式將整個請求完全交給瀏覽器處理,當上傳大文件時,可能會遇見請求超時的情形
  • 通過fromData,其實際也是在xhr中封裝一組請求參數,用來模擬表單請求,無法避免大文件上傳超時的問題
  • 編碼上傳,我們可以比較靈活地控制上傳的內容

大文件上傳最主要的問題就在於:在同一個請求中,要上傳大量的數據,導致整個過程會比較漫長,且失敗后需要重頭開始上傳。試想,如果我們將這個請求拆分成多個請求,每個請求的時間就會縮短,且如果某個請求失敗,只需要重新發送這一次請求即可,無需從頭開始,這樣是否可以解決大文件上傳的問題呢?

綜合上面的問題,看來大文件上傳需要實現下面幾個需求

  • 支持拆分上傳請求(即切片)
  • 支持斷點續傳
  • 支持顯示上傳進度和暫停上傳

接下來讓我們依次實現這些功能,看起來最主要的功能應該就是切片了。

文件切片

編碼方式上傳中,在前端我們只要先獲取文件的二進制內容,然后對其內容進行拆分,最后將每個切片上傳到服務端即可。

在JavaScript中,文件FIle對象是Blob對象的子類,Blob對象包含一個重要的方法slice,通過這個方法,我們就可以對二進制文件進行拆分。

下面是一個拆分文件的示例

function slice(file, piece = 1024 * 1024 * 5) {
 let totalSize = file.size; // 文件總大小
 let start = 0; // 每次上傳的開始字節
 let end = start + piece; // 每次上傳的結尾字節
 let chunks = []
 while (start < totalSize) {
 // 根據長度截取每次需要上傳的數據
 // File對象繼承自Blob對象,因此包含slice方法
 let blob = file.slice(start, end); 
 chunks.push(blob)
 start = end;
 end = start + piece;
 }
 return chunks
}

將文件拆分成piece大小的分塊,然后每次請求只需要上傳這一個部分的分塊即可

let file = document.querySelector("[name=file]").files[0];
const LENGTH = 1024 * 1024 * 0.1;
let chunks = slice(file, LENGTH); // 首先拆分切片
chunks.forEach(chunk=>{
 let fd = new FormData();
 fd.append("file", chunk);
 post('/mkblk.php', fd)
})

服務器接收到這些切片后,再將他們拼接起來就可以了,下面是PHP拼接切片的示例代碼

$filename = './upload/' . $_POST['filename'];//確定上傳的文件名
//第一次上傳時沒有文件,就創建文件,此后上傳只需要把數據追加到此文件中
if(!file_exists($filename)){
 move_uploaded_file($_FILES['file']['tmp_name'],$filename);
}else{
 file_put_contents($filename,file_get_contents($_FILES['file']['tmp_name']),FILE_APPEND);
 echo $filename;
}

測試時記得修改nginx的server配置,否則大文件可能會提示413 Request Entity Too Large的錯誤。

server {
	// ...
	client_max_body_size 50m;
}

上面這種方式來存在一些問題

  • 無法識別一個切片是屬於哪一個切片的,當同時發生多個請求時,追加的文件內容會出錯
  • 切片上傳接口是異步的,無法保證服務器接收到的切片是按照請求順序拼接的

因此接下來我們來看看應該如何在服務端還原切片。

還原切片

在后端需要將多個相同文件的切片還原成一個文件,上面這種處理切片的做法存在下面幾個問題

  • 如何識別多個切片是來自於同一個文件的,這個可以在每個切片請求上傳遞一個相同文件的context參數
  • 如何將多個切片還原成一個文件
  • 確認所有切片都已上傳,這個可以通過客戶端在切片全部上傳后調用mkfile接口來通知服務端進行拼接
  • 找到同一個context下的所有切片,確認每個切片的順序,這個可以在每個切片上標記一個位置索引值
  • 按順序拼接切片,還原成文件

上面有一個重要的參數,即context,我們需要獲取為一個文件的唯一標識,可以通過下面兩種方式獲取

  • 根據文件名、文件長度等基本信息進行拼接,為了避免多個用戶上傳相同的文件,可以再額外拼接用戶信息如uid等保證唯一性
  • 根據文件的二進制內容計算文件的hash,這樣只要文件內容不一樣,則標識也會不一樣,缺點在於計算量比較大.

修改上傳代碼,增加相關參數

// 獲取context,同一個文件會返回相同的值
function createContext(file) {
 	return file.name + file.length
}
let file = document.querySelector("[name=file]").files[0];
const LENGTH = 1024 * 1024 * 0.1;
let chunks = slice(file, LENGTH);
// 獲取對於同一個文件,獲取其的context
let context = createContext(file);
let tasks = [];
chunks.forEach((chunk, index) => {
 let fd = new FormData();
 fd.append("file", chunk);
 // 傳遞context
 fd.append("context", context);
 // 傳遞切片索引值
 fd.append("chunk", index + 1);
	
 tasks.push(post("/mkblk.php", fd));
});
// 所有切片上傳完畢后,調用mkfile接口
Promise.all(tasks).then(res => {
 let fd = new FormData();
 fd.append("context", context);
 fd.append("chunks", chunks.length);
 post("/mkfile.php", fd).then(res => {
 console.log(res);
 });
});

在mkblk.php接口中,我們通過context來保存同一個文件相關的切片

// mkblk.php
$context = $_POST['context'];
$path = './upload/' . $context;
if(!is_dir($path)){
 mkdir($path);
}
// 把同一個文件的切片放在相同的目錄下
$filename = $path .'/'. $_POST['chunk'];
$res = move_uploaded_file($_FILES['file']['tmp_name'],$filename);

除了上面這種簡單通過目錄區分切片的方法之外,還可以將切片信息保存在數據庫來進行索引。接下來是mkfile.php接口的實現,這個接口會在所有切片上傳后調用

// mkfile.php
$context = $_POST['context'];
$chunks = (int)$_POST['chunks'];
//合並后的文件名
$filename = './upload/' . $context . '/file.jpg'; 
for($i = 1; $i <= $chunks; ++$i){
 $file = './upload/'.$context. '/' .$i; // 讀取單個切塊
 $content = file_get_contents($file);
 if(!file_exists($filename)){
 $fd = fopen($filename, "w+");
 }else{
 $fd = fopen($filename, "a");
 }
 fwrite($fd, $content); // 將切塊合並到一個文件上
}
echo $filename;

這樣就解決了上面的兩個問題:

  • 識別切片來源
  • 保證切片拼接順序

斷點續傳

即使將大文件拆分成切片上傳,我們仍需等待所有切片上傳完畢,在等待過程中,可能發生一系列導致部分切片上傳失敗的情形,如網絡故障、頁面關閉等。由於切片未全部上傳,因此無法通知服務端合成文件。這種情況下可以通過斷點續傳來進行處理。

斷點續傳指的是:可以從已經上傳部分開始繼續上傳未完成的部分,而沒有必要從頭開始上傳,節省上傳時間。

由於整個上傳過程是按切片維度進行的,且mkfile接口是在所有切片上傳完成后由客戶端主動調用的,因此斷點續傳的實現也十分簡單:

  • 在切片上傳成功后,保存已上傳的切片信息
  • 當下次傳輸相同文件時,遍歷切片列表,只選擇未上傳的切片進行上傳
  • 所有切片上傳完畢后,再調用mkfile接口通知服務端進行文件合並

因此問題就落在了如何保存已上傳切片的信息了,保存一般有兩種策略

  • 可以通過locaStorage等方式保存在前端瀏覽器中,這種方式不依賴於服務端,實現起來也比較方便,缺點在於如果用戶清除了本地文件,會導致上傳記錄丟失
  • 服務端本身知道哪些切片已經上傳,因此可以由服務端額外提供一個根據文件context查詢已上傳切片的接口,在上傳文件前調用該文件的歷史上傳記錄

下面讓我們通過在本地保存已上傳切片記錄,來實現斷點上傳的功能

// 獲取已上傳切片記錄
function getUploadSliceRecord(context){
 let record = localStorage.getItem(context)
 if(!record){
 return []
 }else {
 try{
 return JSON.parse(record)
 }catch(e){}
 }
}
// 保存已上傳切片
function saveUploadSliceRecord(context, sliceIndex){
 let list = getUploadSliceRecord(context)
 list.push(sliceIndex)
 localStorage.setItem(context, JSON.stringify(list))
}

然后對上傳邏輯稍作修改,主要是增加上傳前檢測是已經上傳、上傳后保存記錄的邏輯

let context = createContext(file);
// 獲取上傳記錄
let record = getUploadSliceRecord(context);
let tasks = [];
chunks.forEach((chunk, index) => {
 // 已上傳的切片則不再重新上傳
 if(record.includes(index)){
 return
 }
	
 let fd = new FormData();
 fd.append("file", chunk);
 fd.append("context", context);
 fd.append("chunk", index + 1);
 let task = post("/mkblk.php", fd).then(res=>{
 // 上傳成功后保存已上傳切片記錄
 saveUploadSliceRecord(context, index)
 record.push(index)
 })
 tasks.push(task);
});

此時上傳時刷新頁面或者關閉瀏覽器,再次上傳相同文件時,之前已經上傳成功的切片就不會再重新上傳了。

服務端實現斷點續傳的邏輯基本相似,只要在getUploadSliceRecord內部調用服務端的查詢接口獲取已上傳切片的記錄即可,因此這里不再展開。

此外斷點續傳還需要考慮切片過期的情況:如果調用了mkfile接口,則磁盤上的切片內容就可以清除掉了,如果客戶端一直不調用mkfile的接口,放任這些切片一直保存在磁盤顯然是不可靠的,一般情況下,切片上傳都有一段時間的有效期,超過該有效期,就會被清除掉。基於上述原因,斷點續傳也必須同步切片過期的實現邏輯。

上傳進度和暫停

通過xhr.upload中的progress方法可以實現監控每一個切片上傳進度。

上傳暫停的實現也比較簡單,通過xhr.abort可以取消當前未完成上傳切片的上傳,實現上傳暫停的效果,恢復上傳就跟斷點續傳類似,先獲取已上傳的切片列表,然后重新發送未上傳的切片。

由於篇幅關系,上傳進度和暫停的功能這里就先不實現了。

小結

目前社區已經存在一些成熟的大文件上傳解決方案,如七牛SDK,騰訊雲SDK等,也許並不需要我們手動去實現一個簡陋的大文件上傳庫,但是了解其原理還是十分有必要的。

本文首先整理了前端文件上傳的幾種方式,然后討論了大文件上傳的幾種場景,以及大文件上傳需要實現的幾個功能

  • 通過Blob對象的slice方法將文件拆分成切片
  • 整理了服務端還原文件所需條件和參數,演示了PHP將切片還原成文件
  • 通過保存已上傳切片的記錄來實現斷點續傳


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM