本文同步在我的博客shymean.com上,歡迎關注php
最近碰見一個須要上傳百兆大文件的需求,調研了七牛和騰訊雲的切片分段上傳功能,所以在此整理前端大文件上傳相關功能的實現。html
在某些業務中,大文件上傳是一個比較重要的交互場景,如上傳入庫比較大的Excel表格數據、上傳影音文件等。若是文件體積比較大,或者網絡條件很差時,上傳的時間會比較長(要傳輸更多的報文,丟包重傳的機率也更大),用戶不能刷新頁面,只能耐心等待請求完成。前端
下面從文件上傳方式入手,整理大文件上傳的思路,並給出了相關實例代碼,因爲PHP內置了比較方便的文件拆分和拼接方法,所以服務端代碼使用PHP進行示例編寫。ios
本文相關示例代碼位於github上,主要參考nginx
首先咱們來看看文件上傳的幾種方式。git
使用PHP來展現常規的表單上傳是一個不錯的選擇。首先構建文件上傳的表單,並指定表單的提交內容類型爲enctype="multipart/form-data"
,代表表單須要上傳二進制數據。github
<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,前端也能夠進行異步上傳文件的操做,通常由兩個思路。json
第一個思路是將文件進行編碼,而後在服務端進行解碼,以前寫過一篇在前端實現圖片壓縮上傳的博客,其主要實現原理就是將圖片轉換成base64進行傳遞canvas
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的知識,能夠參考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對象主要用來組裝一組用 XMLHttpRequest發送請求的鍵/值對,能夠更加靈活地發送Ajax請求。可使用FormData來模擬表單提交。
let files = e.target.files // 獲取input的file對象
let formData = new FormData();
formData.append('file', file);
axios.post(url, formData);
複製代碼
服務端處理方式與直接form表單請求基本相同。
在低版本的瀏覽器(如IE)上,xhr是不支持直接上傳formdata的,所以只能用form來上傳文件,而form提交自己會進行頁面跳轉,這是由於form表單的target屬性致使的,其取值有
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)
}
})
}
複製代碼
如今來看看在上面提到的幾種上傳方式中實現大文件上傳會碰見的超時問題,
大文件上傳最主要的問題就在於:在同一個請求中,要上傳大量的數據,致使整個過程會比較漫長,且失敗後須要重頭開始上傳。試想,若是咱們將這個請求拆分紅多個請求,每一個請求的時間就會縮短,且若是某個請求失敗,只須要從新發送這一次請求便可,無需從頭開始,這樣是否能夠解決大文件上傳的問題呢?
綜合上面的問題,看來大文件上傳須要實現下面幾個需求
接下來讓咱們依次實現這些功能,看起來最主要的功能應該就是切片了。
參考: 大文件切割上傳
編碼方式上傳中,在前端咱們只要先獲取文件的二進制內容,而後對其內容進行拆分,最後將每一個切片上傳到服務端便可。
在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,同一個文件會返回相同的值
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
接口通知服務端進行文件合併所以問題就落在瞭如何保存已上傳切片的信息了,保存通常有兩種策略
下面讓咱們經過在本地保存已上傳切片記錄,來實現斷點上傳的功能
// 獲取已上傳切片記錄
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等,也許並不須要咱們手動去實現一個簡陋的大文件上傳庫,可是瞭解其原理仍是十分有必要的。
本文首先整理了前端文件上傳的幾種方式,而後討論了大文件上傳的幾種場景,以及大文件上傳須要實現的幾個功能
slice
方法將文件拆分紅切片還留下了一些問題,如:合併文件時避免內存溢出、切片失效策略、上傳進度暫停等功能,並無去深刻或一一實現,繼續學習吧~