用HTML5实现断点续传、上传进度条、图片预览

用HTML5实现断点续传、上传进度条、图片预览 · Jul 23, 2014 clicks

利用HTML5中的新的XMLHttpRequest提供了实时上传进度的事件,
再加上File对象和FormData对象就可实现断点续传,
要实现即时预览文件,则需要FileReader对象读取文件绝对地址或内容。

XMLHttpRequest Level 2中的XMLHttpRequest对象有一个属性upload,类型是XMLHttpRequestUpload,监听它的progress事件可以获取上传进度。

同时,send方法的参数可以是ArrayBufferBlobDocumentFormData,和 string,所以我们可以把文件分片(利用File对象slice方法),把文件以Blob格式上传, 这样断点续传的条件就满足了。

upload.html代码

<!doctype html>
<html>
<head>
	<title>Upload File via XMLHttpRequest 2</title>
<script>
var fr_supported = typeof FileReader != 'undefined';
var fd_supported = typeof FormData != 'undefined';
if ( !fr_supported ){
	alert('you browser doesnt support FileReader!');
}
if ( !fd_supported ){
	alert('you browser doesnt support FormData!');
}

function get(id){
	return document.getElementById(id);
}
var upload_file = null;
function fileSelected(){
	var file = get('fileToUpload').files[0];		
	if ( file ){
		upload_file = file;
		var fileSize = 0;
		if (file.size > 1024*1024){
			fileSize = (Math.round(file.size * 100 / (1024 * 1024)) / 100).toString() + 'MB';
		}else{
			fileSize = (Math.round(file.size * 100 / 1024) / 100).toString() + 'KB';
		}
		get('fileName').innerHTML = file.name;
		get('fileSize').innerHTML = fileSize;
		get('fileType').innerHTML = file.type;	
		
		
	}
}
function uploadFile(){
    var xhr = new XMLHttpRequest();	
	var fd = new FormData();
	fd.append('uploadfile', upload_file );
	xhr.upload.addEventListener("progress", uploadProgress,false);
	xhr.addEventListener("load", uploadComplete,false);
	xhr.addEventListener("error", uploadFailed,false);
	xhr.addEventListener("abort", uploadCanceled, false);
	xhr.open("POST", "upload.php");
	xhr.send(fd);
}
function uploadProgress(evt){
	var percent = 'unable to compute progress';
	if (evt.lengthComputable){
		var percent = Math.round( (evt.loaded+pos)*100/evt.totalCount ) + '%';		
	}
	get('progressNumber').innerHTML = percent;
}
function uploadComplete(evt){	
	alert('finish uploading');
}
function uploadFailed(evt){
	alert('Failed to upload');
}
function uploadCanceled(evt){
	alert('the upload has been canceled');
}
</script>
<body>
	<form action="upload.php" id="form1" method="post" enctype="multipart/form-data">
	<div class="row">
		<label for="fileToUpload">Select a File to Upload</label> <br />
		<input type="file" name="fileToUpload" id="fileToUpload" onchange="fileSelected()" />
	</div>
	<p>FileName: <span id="fileName"></span></p>
	<p>FileSize: <span id="fileSize"></span></p>
	<p>FileType: <span id="fileType"></span></p>
	<p id="preview"></p>
	<div class="row">
		<input type="button" onclick="uploadFile()" value="Upload" />
	</div>
	<p>Progress: <span id="progressNumber"></span></p>
	</form>

上面的代码只是简单地实现了上传,要想断点续传,首先要获取上次下载到什么地方,所以在上传之前要查询一下这个文件是否上传过,上传了多少。

我们修改一下uploadFile函数,把原来的uploadFile函数改名成do_upload_file

var pos  = 0;
var slice_size = 1024 * 1024;
var upload_data=null;
var upload_finished = false;
var upload_file_size = 0;
var upload_file = null;
function uploadFile()
{
	var file = upload_file;
	upload_file_size = file.size;
	var xhr = new XMLHttpRequest();
	var fd = new FormData();
	fd.append('act','query');
	fd.append('qry_fname', file.name );	
	xhr.addEventListener('load',function(stat){
		pos = parseInt(this.responseText);
		if ( pos < upload_file_size){
			upload_data = upload_file.slice(pos, pos+slice_size);
			upload_finished = (pos + slice_size) >= upload_file_size;
			get('progressNumber').innerHTML =  Math.round(pos*100/upload_file_size)+'%';
			do_upload_file();							
		}else{
			upload_finished = true;
			get('progressNumber').innerHTML = '100%';
			alert('file already upladed');
		}
			
	},false);
	xhr.open("POST",'upload.php');
	xhr.send(fd);
}

再把do_upload_file函数修改一下:

function do_upload_file(){	
	if ( upload_data==null ){
		alert( 'nothing to upload' );
		return;
	}
	var xhr = new XMLHttpRequest();	
	var fd = new FormData();
	fd.append('act','upload');
	fd.append('uploadfile', upload_data );
	fd.append('filename', upload_file.name);
	fd.append('finished', upload_finished?'1':'0' );
	fd.append('position', pos);	
	xhr.upload.addEventListener("progress", uploadProgress,false);
	xhr.addEventListener("load", uploadComplete,false);
	xhr.addEventListener("error", uploadFailed,false);
	xhr.addEventListener("abort", uploadCanceled, false);
	xhr.open("POST", "upload.php");
	xhr.send(fd);
}

同时要修改load事件监听函数,上传完一个片段之后自动上传下一个

function uploadComplete(evt){
	if ( !upload_finished ){
		pos += slice_size;
		upload_data = upload_file.slice(pos, pos+slice_size);
		upload_finished = (pos + slice_size) >= upload_file_size;
		do_upload_file();
	}
}

这在每次上传开始之前,先用文件名去服务器端查询,服务器返回已上传的字节数pos,然后从文件的pos位置开始,截取slice_size个字节(本例中是1MB)的数据用来上传。

似乎忘了预览文件了? 预览很简单,稍微修改下fileSelected函数,判断下文件类型,如果是图片,就创建一个image对象

function fileSelected(){
	var file = get('fileToUpload').files[0];	
	upload_file = file;
	pos = 0;	
	upload_data = null ;
	upload_finished = false;
	upload_file_hash = '';
	if ( file ){
		upload_file_size = file.size;
		var fileSize = 0;
		if (file.size > 1024*1024){
			fileSize = (Math.round(file.size * 100 / (1024 * 1024)) / 100).toString() + 'MB';
		}else{
			fileSize = (Math.round(file.size * 100 / 1024) / 100).toString() + 'KB';
		}
		get('fileName').innerHTML = file.name;
		get('fileSize').innerHTML = fileSize;
		get('fileType').innerHTML = file.type;
		//preview image
		if ( file.type.split('/')[0] == 'image' ){
			var rd = new FileReader();
			rd.addEventListener('load',function(evt){
				var im = new Image();
				im.src = evt.target.result;
				get('preview').innerHTML = '';
				get('preview').appendChild(im);
			},false);
			rd.readAsDataURL(file);
		}
		
		
	}
}

 

似乎已经大功告成了, 但是要想更严密点,就还得加点东西了: 仅仅用文件名来判断文件是否上传远远不够,文件同名的情况是很常见的,即使再加上文件大小也不行,同一个文件替换掉相同数目的字符,大小是不变的!

那要怎么办呢? 对了, hash校验。

计算hash值的方法,浏览器端的js没有内置,所以在网上找了一个:Spark MD5

现在完整的代码如下:

<!doctype html>
<html>
<head>
	<title>Upload File via XMLHttpRequest 2</title>
	<script src="spark_md5.min.js"></script>
<script>
var fr_supported = typeof FileReader != 'undefined';
var fd_supported = typeof FormData != 'undefined';
if ( !fr_supported ){
	alert('you browser doesnt support FileReader!');
}
if ( !fd_supported ){
	alert('you browser doesnt support FormData!');
}

function get(id){
	return document.getElementById(id);
}
var pos  = 0;
var slice_size = 1024 * 1024;
var upload_data=null;
var upload_finished = false;
var upload_file_size = 0;
var upload_file = null;
var upload_file_hash= '';

function fileSelected(){
	var file = get('fileToUpload').files[0];	
	upload_file = file;
	pos = 0;	
	upload_data = null ;
	upload_finished = false;
	upload_file_hash = '';
	if ( file ){
		upload_file_size = file.size;
		var fileSize = 0;
		if (file.size > 1024*1024){
			fileSize = (Math.round(file.size * 100 / (1024 * 1024)) / 100).toString() + 'MB';
		}else{
			fileSize = (Math.round(file.size * 100 / 1024) / 100).toString() + 'KB';
		}
		get('fileName').innerHTML = file.name;
		get('fileSize').innerHTML = fileSize;
		get('fileType').innerHTML = file.type;
		//preview image
		if ( file.type.split('/')[0] == 'image' ){
			var rd = new FileReader();
			rd.addEventListener('load',function(evt){
				var im = new Image();
				im.src = evt.target.result;
				get('preview').innerHTML = '';
				get('preview').appendChild(im);
			},false);
			rd.readAsDataURL(file);
		}
		
		
	}
}
function uploadFile()
{
	var file = upload_file;
	//get md5 hash		
	var reader = new FileReader();
	reader.addEventListener('load',function(evt){			
		upload_file_hash = SparkMD5.hash(evt.target.result);
		console.log(['md5', upload_file_hash ]);
		//get size of uploaded data
		var xhr = new XMLHttpRequest();
		var fd = new FormData();
		fd.append('act','query');
		fd.append('qry_fname', file.name );		
		fd.append('file_hash', upload_file_hash);
		xhr.addEventListener('load',function(stat){
			pos = parseInt(this.responseText);
			console.log([upload_file,pos,upload_file_size]);
			if ( pos < upload_file_size){
				upload_data = upload_file.slice(pos, pos+slice_size);
				upload_finished = (pos + slice_size) >= upload_file_size;
				get('progressNumber').innerHTML =  Math.round(pos*100/upload_file_size)+'%';
				
				//upload file
				do_upload_file();
							
			}else{
				upload_finished = true;
				get('progressNumber').innerHTML = '100%';
				alert('file already upladed');
			}
			
		},false);
		xhr.open("POST",'upload.php');
		xhr.send(fd);

	});
	reader.readAsBinaryString(file);
}

function do_upload_file(){	
	if ( upload_data==null ){
		alert( 'nothing to upload' );
		return;
	}
	var xhr = new XMLHttpRequest();	
	var fd = new FormData();
	fd.append('act','upload');
	fd.append('uploadfile', upload_data );
	fd.append('filename', upload_file.name);
	fd.append('finished', upload_finished?'1':'0' );
	fd.append('position', pos);
	fd.append('file_hash', upload_file_hash);
	xhr.upload.addEventListener("progress", uploadProgress,false);
	xhr.addEventListener("load", uploadComplete,false);
	xhr.addEventListener("error", uploadFailed,false);
	xhr.addEventListener("abort", uploadCanceled, false);
	xhr.open("POST", "upload.php");
	xhr.send(fd);
}
function uploadProgress(evt){
	var percent = 'unable to compute progress';
	if (evt.lengthComputable){
		var percent = Math.round( (evt.loaded+pos)*100/upload_file_size ) + '%';		
	}
	get('progressNumber').innerHTML = percent;
}
function uploadComplete(evt){
	//alert( evt.target.responseText );
	console.log([evt.target.responseText]);
	if ( !upload_finished ){
		pos += slice_size;
		upload_data = upload_file.slice(pos, pos+slice_size);
		upload_finished = (pos + slice_size) >= upload_file_size;
		do_upload_file();
	}
}
function uploadFailed(evt){
	alert('Failed to upload');
}
function uploadCanceled(evt){
	alert('the upload has been canceled');
}
</script>

<body>
	<form action="upload.php" id="form1" method="post" enctype="multipart/form-data">
	<div class="row">
		<label for="fileToUpload">Select a File to Upload</label> <br />
		<input type="file" name="fileToUpload" id="fileToUpload" onchange="fileSelected()" />
	</div>
	<p>FileName: <span id="fileName"></span></p>
	<p>FileSize: <span id="fileSize"></span></p>
	<p>FileType: <span id="fileType"></span></p>
	<p id="preview"></p>
	<div class="row">
		<input type="button" onclick="uploadFile()" value="Upload" />
	</div>
	<p>Progress: <span id="progressNumber"></span></p>
	</form>

后台程序中以hash加上文件名作为保存文件的文件名字,在后面加".part"表示未完成的文件
upload.php的代码:
<?php
if ( $_SERVER['REQUEST_METHOD'] =='POST' ){
	$post = $_POST;
	$hash = $post['file_hash'];
	if ( !preg_match("/^[a-z0-9]{32}$/i",$hash) ){
		exit('invalid hash');
	}
	if( $post['act']=='query' && isset($post['qry_fname']) ){				
		$dest = 'uploads/'.$hash.'_'. $post['qry_fname'];
		$pdest = $dest.'.part';
		if ( file_exists( $pdest) ){
			echo filesize($pdest);
		}elseif( file_exists($dest)){
			echo filesize($dest);
		}else{
			echo 0;
		}
	}elseif( $post['act']=='upload'){
		$file = $_FILES['uploadfile'];
		$file_name = $post['filename'];
		$pos = intval($post['position']);		
		
		$dest = 'uploads/'.$hash.'_'.$file_name;
		$pdest = $dest.'.part';
		$finished = $post['finished'];
		if ( file_exists($pdest) ){
			if ( filesize($pdest) != $pos ){
				exit('Position Not matches filesize');
			}
		}
		if ( !$finished  ){
			file_put_contents( $pdest, file_get_contents($file['tmp_name']),FILE_APPEND );
			echo 'upload '. round($file['size']/1024).'KB';
		}
		if ( $finished ){
			if ( file_exists($pdest) ){
				rename($pdest, $dest);
			}else{
				if (! move_uploaded_file($file['tmp_name'], $dest ) ){
					echo 'Failed to move file ',$file['tmp_name'] . ' >> ' .$dest ;
					exit;
				}
			}			
			echo 'upload finished.';
		}
	}
}

 

把文件分块上传还有一个好处就是,无需理会服务器的配置了,以前要上传大文件必须修改web服务的配置,默认的配置小得可怜。

值得一说的是,上传时必须把文件名也提交上去,因为这种情况下,$_FILES['upload_file']['name']获取的全部都是BLOB。。。。。

要说遗憾,就是每次计算文件的hash值花时间太长,给人的感觉是点了upload之后会卡顿一会,只能用一个loading提示框来掩盖了,毕竟这个计算量是不可避免的。

最后再提示一点,html5下文件域可以添加一个multiple属性,有这个属性之后,就可以同时选择多个文件了。