Vue 组件放送之文件上传

本文深入探讨了基于Vue的文件上传组件设计与实现,重点介绍了图片上传功能,包括无刷新上传、图片预览、压缩、文件类型检测及iOS照片旋转问题的解决。

文件上传组件是常见的 Web 组件。HTML 提供了 input file 原始上传组件,我们在此基础上利用各种 HTML5 特性来封装该组件,而不是通过内嵌一个隐藏 Flash 上传(古老的做法)。又因,利用了 Vue 的 MVVM 和组件化的强大特性,这一切都变得简单轻松。

先谈谈组件的需求——一般图片上传的场景较多,故该组件除了任意文件上传,还特意针对图片上传来开发:

  1. 可以无刷新上传
  2. 可以美化 input file 元素,允许自定义样式
  3. 可以预览本地图片
  4. 可以先压缩图片
  5. 可以检测文件扩展名、文件大小、实际文件类型检测、图片分辨率大小
  6. 可以在弹出的文件选择框限制文件类型
  7. 可以显示实时的上传进度
  8. 可以断点续传
  9. 在 iOS 上,有照片旋转不正确的问题,这个问题也必须得到解决。

下面我们逐一分析需求的解决办法。

XHR 2.0 文件上传

无刷新上传,实际上在 XHR 2 之前我一直使用 iframe 实现,原理是隐藏一个 ifame 并赋予 id,然后 form 表单 target 属性指向的就是那个 iframe id,这会形成经过 ifame 上传的文件,表单的目的地是该 iframe,又因 iframe 是隐藏的,故能实现“无刷新”上传。

不过坑也不能避免的,一个表单是不允许嵌套另外一个表单的,所以我写这种 vue 组件时十分累,避免在同一个 iframe 中,要写大量 dom 控制语句,而且又要考虑多个上传组件的话,更麻烦了。

后来XHR 2.0 即 XmlHttpRequest 2.0 支持文件上传,问题迎刃而解。

var fd = new FormData();
if(this.$blob){ // File 类型不需要文件名,Blob 强制需要
	fd.append("file", this.$blob, this.$fileName);
} else 
	fd.append("file", this.$fileObj);

var xhr = new XMLHttpRequest(), self = this;
xhr.onreadystatechange = ajaxjs.xhr.callback.delegate(null, this.uploadOk_callback, 'json');
xhr.open("POST", this.action, true);
xhr.onprogress = function(evt) {
    var progress = 0;
    var p = ~~(evt.loaded * 1000 / evt.total);
    p = p / 10;
    
    if(progress !== p) {
    	progress = p;
    	console.log('progress: ', p);
    }
    self.progress = progress;
};
xhr.send(fd);

原理比较简单,就是 FormData 作为容器放置 file/blob 对象。注意 blob 必须指定文件名,否则后台会报错。

如上所见,XHR 包含了进度反馈的功能,也就是 progress 事件。于是把第9点需求也解决了。

美化上传按钮

如何美化就是样式 CSS 问题了。我们知道 label 标签有个特性,在设置了 for 属性后(该值是一个 id),就是点击标签文本会使得 id 所在的元素获得焦点,典型如 input 元素。有一个便捷不用设置 id 的方法就是 label 包含提示文字和 input 两者。
利用该方法,我们于是可以隐藏一个 input,然后通过 for/id 关联起来 label 与 input 元素,如下代码所示。

<label for="uploadInput_663">
	<div><div>+</div>点击选择图片</div>
</label>

<input type="file" id="uploadInput_663" accept="image/*" class="hide">

于是那个丑陋的 input 界面被隐藏了,我们便可以随心所欲地美化 label 标签,如我制作的,
在这里插入图片描述
但背后依然调用 input 的功能。
值得一提的的是, H5 可自定义弹出的文件选择框限制文件类型,如属性 accept=“image/” 表示只选择 图片 来上传,非常简单。

预览本地图片

我们知道 < img src=“xxx” /> 元素的图片可以是一个 url,或者 base64 编码的文本。图片还未上传到服务器,自然就没用所谓的 url,自然应该从 base64 方面“打主意”。H5 为我们提供 file 类型对象及其读取器 file reader,可以在浏览器本地读取到客户端计算机里面的图片,并转换为 base64 编码。先说说 File 对象,

// 当前 input file 元素触发的 onchange(e) 事件如下
this.$fileObj = e.target.files[0]; // 保留 File 对象 的引用。数组表示可以多选文件
this.$fileName = this.$fileObj.name; // 文件名
this.$fileType = this.$fileObj.type; // 文件类型,MIME
var size = this.$fileObj.size; // 文件大小

看到没用,可以轻易获取文件的相关信息,于是第五点得到解决。对了还有一个“实际文件检测”这个怎么玩?比较 hack 下面说说。

File 只是文件对象,还需要通过 FileReader 转换为 base64 编码

// 转换为 base64 字符串
var reader = new FileReader();
reader.onload = function(e) {
		var imgBase64Str = e.target.result; // 得到了图片的 base64 编码
		……
}
reader.readAsDataURL(file);

直接把 base64 结果塞给 img src 属性就好,这下有预览图了。还没完,我们 hardcode 检测图片,如下所示

// 文件头判别,看看是否为图片
var imgHeader = {
	"jpeg" : "/9j/4",
	"gif" : "R0lGOD",
	"png" : "iVBORw"
};

for ( var i in imgHeader) {
	if (~imgBase64Str.indexOf(imgHeader[i])) {
		self.isExtName = true;
		return;
	}
}

self.errMsg = "亲,改了扩展名我还能认得你不是图片哦";

至此,第三和第五点得到解决。

压缩图片

网上都说压缩图片很简单,不就是 canvas,简单是简单,不过笔者踩了大坑,花了两天时间才找出 bug。

var comp = new Image();
comp.onload = function() {
	var canvas = document.createElement('canvas');
	canvas.width = targetWidth;
	canvas.height = targetHeight;
	canvas.getContext('2d').drawImage(this, 0, 0, targetWidth, targetHeight); // 图片压缩
	
	// canvas转为blob并上传
	canvas.toBlob(function(blob) {
		self.$blob = blob;
	}, self.$fileType || 'image/jpeg'); //  MIME 不能传错!!!
}

话说没传对 ‘image/jpeg’ 而是 ‘image/jpg’,这下弄成图片不是压缩,反而变大了!后来传 File 对象的 type 属性即可。

另外,iOS 10 需要打补丁,

//polyfill JavaScript-Canvas-to-Blob 解决了 HTMLCanvasElement.toBlob 的兼容性
//https://github.com/blueimp/JavaScript-Canvas-to-Blob
if (!HTMLCanvasElement.prototype.toBlob) {
	Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', {
		value : function(callback, type, quality) {
			var binStr = atob(this.toDataURL(type, quality).split(',')[1]), len = binStr.length, arr = new Uint8Array(len);

			for (var i = 0; i < len; i++) {
				arr[i] = binStr.charCodeAt(i);
			}

			callback(new Blob([ arr ], {
				type : type || 'image/png'
			}));
		}
	});
}

至此,第四点得到解决。
附:如果要指定压缩后的大小,可参考这个 https://juejin.im/post/5c1b4eac6fb9a049d441c520 作者使用了二分法。

iOS 照片旋转问题

可以在后台转,但这个是前台的方案。首先下一个 exif.js 的库,读取照片元信息的。不算太大,压缩了 80kb 左右,
https://github.com/exif-js/exif-js

rotate : function (img, orient) {
	var width = img.width, height = img.height, 
		canvas = document.createElement('canvas'), ctx = canvas.getContext("2d");
		
		// set proper canvas dimensions before transform & export
		if ([ 5, 6, 7, 8 ].indexOf(orient) > -1) {
			canvas.width = height;
			canvas.height = width;
		} else {
			canvas.width = width;
			canvas.height = height;
		}
		
		// transform context before drawing image
		switch (orient) {
		case 2:
			ctx.transform(-1, 0, 0, 1, width, 0);
			break;
		case 3:
			ctx.transform(-1, 0, 0, -1, width, height);
			break;
		case 4:
			ctx.transform(1, 0, 0, -1, 0, height);
			break;
		case 5:
			ctx.transform(0, 1, 1, 0, 0, 0);
			break;
		case 6:
			ctx.transform(0, 1, -1, 0, height, 0);
			break;
		case 7:
			ctx.transform(0, -1, -1, 0, height, width);
			break;
		case 8:
			ctx.transform(0, -1, 1, 0, 0, width);
			break;
		default:
			ctx.transform(1, 0, 0, 1, 0, 0);
		}

		ctx.drawImage(img, 0, 0);
		
		return canvas.toDataURL('image/jpeg');
	}

这段代码也是网上扒的,笔者不太懂,此处略过一万字……总之,把第九点给解决了。

断点续传

暂时没空研究了……待续

END

说了那么多,还没贴完整 vue 代码(注意下 aj-xhr-upload 那个才是):
https://gitee.com/sp42_admin/ajaxjs/blob/master/ajaxjs-web-js/WebContent/js/widgets/upload.js
本文把各个要点说了下,至于怎么把点串起来罗织就是 vue 组件的力量了。

backup

借宝地备份一下旧代码

// 文件选择器和校验
Vue.component('ajaxjs-file-upload', {
	data() {
		return {
			isFileSize : false,			// 文件大小检查
			isExtName : false,			// 文件扩展名检查
			errMsg : null,				// 错误信息
			newlyId : null				// 成功上传之后的文件 id
		};
	},
	props: {
		fieldName : {
			type: String,
			required: false
		},
		
		filedId: {
	      type: Number,
	      required: false
	    },
	    
	    limitSize: Number,
	    
	    limitFileType: String,
	    labelId: {
	    	type: String,
	    	required: false,
	    	default: 'input_file_molding'
	    }
	},
	template: 
		'<div class="ajaxjs-file-upload">\
			<div class="pseudoFilePicker">\
				<input type="hidden" v-if="fieldName" :name="fieldName" :value="newlyId || filedId" :id="labelId" />\
				<label :for="labelId"><div><div>+</div>点击选择文件</div></label>\
			</div>\
			<div v-if="!isFileSize || !isExtName">{{errMsg}}</div>\
			<div v-if="isFileSize && isExtName">\
				<button @click.prevent="doUpload($event);">上传</button>\
			</div>\
		</div>',
	methods: {
		onUploadInputChange(e) {
			var fileInput = e.target;
			var ext = fileInput.value.split('.').pop(); // 扩展名
			var size = fileInput.files[0].size;
			
			if(this.limitSize)
				this.isFileSize = size < this.limitSize;
			else
				this.isFileSize = true;
			
			if(this.limitFileType)
				this.isExtName = new RegExp(this.limitFileType, 'i').test(ext);
			else
				this.isExtName = true;
		},
		doUpload(e) {
			// 先周围找下 form,没有的话找全局的
			var form = this.$parent.$refs.uploadIframe && this.$parent.$refs.uploadIframe.$el;
			if(!form) {
				//form = aj('form[target=upframe]');
				form = this.$parent.$el.$('form');
			}
			
			form && form.submit();
			
			e.preventDefault();
			return false;
		}
	}
});

//图片选择器、预览和校验
//建议每次创建实例时声明 ref="uploadControl" 以便对应组件
Vue.component('ajaxjs-img-upload-perview', {
	data() {
		return {
			imgBase64Str : null, 		// 图片的 base64 形式,用于预览
			isFileSize : false,			// 文件大小检查
			isExtName : false,			// 文件扩展名检查
			isImgSize : false, 			// 图片分辨率大小检查
			isFileTypeCheck : false, 	// 图片二进制的类型检查
			errMsg : null,				// 错误信息
			imgNewlyId : null			// 成功上传之后的图片 id
		};
	},
	
	props: {
		imgPlace : String, // 图片占位符,用户没有选定图片时候使用的图片
		imgName : {
			type: String,
			required: false // false 表示不随表单设置值
		},
		imgId : {
	      type: Number,
	      required: false
	    },
	    limit : {
	    	type : Object,
	    	required : false,
	    	default(){
	    		return { // 上传限制
					maxSize : 600,
					fileExt: /png|gif|jpg|jpeg/i,
					maxWidth: 1200,
					maxHeight:1680
				};
	    	}
	    },
	    labelId: {
	    	type: String,
	    	required : false,
	    	default : 'input_file_molding'
	    }
	},
	template: 
		'<div class="ajaxjs-img-upload-perview">\
			<div>\
				<img class="upload_img_perview" :src="(isFileSize && isExtName && isImgSize && isFileTypeCheck && imgBase64Str) ? imgBase64Str : imgPlace" />\
				<input v-if="imgName" type="hidden" :name="imgName" :value="imgNewlyId || imgId" />\
			</div>\
			<div class="pseudoFilePicker">\
				<label :for="labelId"><div><div>+</div>点击选择图片</div></label>\
			</div>\
			<div v-if="isShowErrMessage()">{{errMsg}}</div>\
			<div v-if="isFileSize && isExtName && isImgSize && isFileTypeCheck && imgBase64Str">\
				<button @click.prevent="doUpload($event);" style="padding: .4em 1.3em; width: 80px;">上传</button>\
			</div>\
		</div>',
		
	created() {
		this.BUS.$on('upload-file-selected', this.onUploadInputChange);
	},

	methods: (function () {
		// 文件头判别,看看是否为图片
		var imgHeader = {
			"jpeg" : "/9j/4",
			"gif" : "R0lGOD",
			"png" : "iVBORw"
		};
		
		return {
			onUploadInputChange(e) {
				var fileInput = e.target;
				
				for(var i = 0, j = fileInput.files.length; i < j; i++) {
					var reader = new FileReader(), fileObj = fileInput.files[i];
					reader.onload = this.afterLoad.delegate(null, fileObj, this);
					reader.readAsDataURL(fileObj);
				}
			},
			afterLoad (e, fileObj, self) {
				var imgBase64Str = e.target.result;
				var isOk = self.checkFile(fileObj, imgBase64Str, self);
				
				self.imgBase64Str = imgBase64Str;
			},
			isShowErrMessage() {
				return !this.isFileSize || !this.isExtName || !this.isImgSize || !this.isFileTypeCheck;
			},
			checkFile(fileObj, imgBase64Str, cfg) {
				var defaultLimit = cfg.limit;
				
				if(fileObj.size > defaultLimit.maxSize * 1024) {  // 文件的大小,单位为字节B
					cfg.isFileSize = false;
					cfg.errMsg = "要上传的文件容量过大,请压缩到 " + defaultLimit.maxSize + "kb 以下";
					return;
				} else {
					cfg.isFileSize = true;
				}
				
				var ext = fileObj.name.split('.').pop();
				if (!defaultLimit.fileExt.test(ext)) {
					cfg.isExtName = false;
					cfg.errMsg = '根据文件后缀名判断,此文件不是图片'; 
					return;
				} else {
					cfg.isExtName = true;
				}
				
				var imgEl = new Image();
				imgEl.onload = function() {
					if (imgEl.width > cfg.maxWidth || imgEl.height > cfg.maxHeight) {
						cfg.isImgSize = false;
						cfg.errMsg = '图片大小尺寸不符合要求哦,请重新图片吧~';
					} else {
						cfg.isImgSize = true;
					}
				}
				
				imgEl.src = imgBase64Str;
				
				cfg.isFileTypeCheck = false;
				
				for(var i in imgHeader) {
					if (~imgBase64Str.indexOf(imgHeader[i])){
						cfg.isFileTypeCheck = true;
						return;
					}
				}
				
				cfg.errMsg = "亲,改了扩展名我还能认得你不是图片哦";
			},
			doUpload(e) {
				// 先周围找下 form,没有的话找全局的
				var form = this.$parent.$refs.uploadIframe && this.$parent.$refs.uploadIframe.$el;
				if(!form) {
					//form = aj('form[target=upframe]');
					form = this.$parent.$el.$('form');
				}
				
				form && form.submit();
				
				e.preventDefault();
				return false;
			}
		};
	})()
});


//通过 iframe 实现无刷新文件上传
//这里包含一个 form元素,form元素不能嵌套在 form 里面,故独立出来一个组件
Vue.component('ajaxjs-fileupload-iframe', {
	data() {
		return {
			radomId : Math.round(Math.random() * 1000),
			uploadOk_callback: function(){}
		};
	},
	props : {
		uploadUrl: {
			type: String,
			required: true
		},
	    labelId: {
	    	type : String,
	    	required : false,
	    	default : 'input_file_molding'
	    },
	    accpectFileType: { // 可以上传类型
	    	type : String
	    }
	},
	template : // 隐藏的 input 上传控件为了无刷新上传,对应 form 的 target 
		'<form :action="uploadUrl" method="POST" enctype="multipart/form-data" :target="\'upframe_\' + radomId">\
			<input name="fileInput" :id="labelId" type="file" multiple="multiple" class="hide" @change="fireUploadFileSelected($event)" :accept="accpectFileType" />\
			<iframe :name="\'upframe_\' + radomId" class="hide" @load="iframe_callback($event);"></iframe>\
		</form>',
	methods : {
		// 上传后成功的提示
		iframe_callback (e) { 
			var json = e.target.contentDocument.body.innerText;
			
			if(json[0] == '{') {
				json = JSON.parse(json);
				
				if(json.isOk) {
					aj.msg.show('上传成功!');
					
					if(this.uploadOk_callback && typeof this.uploadOk_callback == 'function') {
						var imgUrl = json.fullUrl || json.imgUrl || json.visitPath;
						this.uploadOk_callback(imgUrl, json);
					}
				}
			}
		},
		fireUploadFileSelected(e) {// 在附近查找 上传组件:就近原则
			var p = this.$parent;
			while(p && p.$refs && !p.$refs.uploadControl) {
				p = p.$parent;
			}
			
			p.$refs.uploadControl.onUploadInputChange(e);
		}
	}
});
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

sp42a

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值