文件上传,无论前端还是后端,笔者都重写过好几次了,一直不太满意。这次前端的用 TypeScript 再写一次,发现旧的问题不少,——幸好在这次重构中,找到比较理想的办法来处理。下面是旧文回顾。
- 《Vue 组件放送之文件上传》https://blog.csdn.net/zhangxin09/article/details/86601308
- 后端:《JSP 实用程序之简易文件上传组件》https://blog.csdn.net/zhangxin09/article/details/51543300
- 后端:《文件上传之重制版》https://blog.csdn.net/zhangxin09/article/details/79054233
- 《网易云对象存储 HTTP 文件上传》https://blog.csdn.net/zhangxin09/article/details/108507136
下面就让我们看看新版的文件上传组件吧。有图胜千言,先过目一下组件截图。
动图:
以上包含两个组件:一个文件上传,另外一个图片上传。当然文件上传也可以上传图片。
在线例子:https://framework.ajaxjs.com/demo/form/file-upload.html。
源码在:https://gitee.com/sp42_admin/ajaxjs/blob/master/aj-ts/src/form/html-editor.ts。
使用方法
<p>File upload Demo:</p>
<div class="fileUpload">
<aj-file-uploader action="foo" limit-file-type="txt|pdf|doc"></aj-file-uploader>
</div>
<p>Image upload Demo:</p>
<div class="imgUpload">
<aj-img-uploder action="fooImg"></aj-img-uploder>
</div>
属性 | 含义 | 类型 | 是否必填,默认值 |
---|---|---|---|
action | 上传路径,必填 | String | y |
field-name | 表单提交时字段的名称 | String | n |
field-value | 表单提交时字段的值 | String | n |
limit-file-type | 限制的文件扩展名,这是一个正则。如无限制,不设置或者空字符串。例如 txt|pdf|doc | String | n |
limit-size | 文件大小限制,单位:KB。若为 0 则不限制 | Number | n |
accpect-file-type | 允许文件选择器列出的文件类型,例如限定图片为 image/* | String | n |
uploadOk_callback | 上传之后的回调函数。一般晚绑定这个属性,而不是 通过 props 指定 | Function | n |
话说……
话说上一款的文件上传组件,就是我最初接触 Vue 时候写的,感受到了 Vue 的强大和精妙无比,不过也有理解不到位的地方,所以现在回过头来看还是有挺多不足的,特别是细节方面的,还有一个当时也头痛的问题,就是把文件上传和图片上传两者混在一起,主要是因为上传功能需求繁多芜杂,罗列一下就有这些:
- 可以无刷新上传
- 可以美化 input file 元素,允许自定义样式
- 可以预览本地图片
- 可以先压缩图片
- 可以检测文件扩展名、文件大小、实际文件类型检测、图片分辨率大小
- 可以在弹出的文件选择框限制文件类型
- 可以显示实时的上传进度
- 可以断点续传
- 在 iOS 上,有照片旋转不正确的问题,这个问题也必须得到解决。
那时想分开两个组件写的,但上述功能之间存在着严重的耦合,于是一气之下统统写到一个组件身上。最理想的情形是,写好文件上传这个组件,通过继承扩展一个子类,也就是派生一个新组件:图片上传,后者可以复用前者的逻辑。
绘制 UI
单纯文件上传没啥 UI,最简单就是 File Picker
(文件选择器)然后加多个上传按钮,前者浏览器已经有默认的本地 UI 不用我们操劳了(也无法改变),后者不就是一个按钮的事情?如果图片上传的,顶多加多一个 <img />
来预览。UI 本无甚述之处,画好了 UI 的那些 HTML 等于解决了“无刷新”上传的功能。其原理非常简单,一句表述:通过 label
标签 for
属性关联具体的 input[type=file]
触发本地 File Picker
,input[type=file]
本身隐藏。
于是我们得到组件的标签如下。
<div class="aj-file-uploader">
<input type="hidden" :name="fieldName" :value="fieldValue" />
<input type="file" :id="'uploadInput_' + radomId" @change="onUploadInputChange" :accept="accpectFileType" />
<label class="pseudoFilePicker" :for="'uploadInput_' + radomId">
<div>
<div>+</div>点击选择文件
</div>
</label>
<div class="msg" v-if="errMsg == ''">
{{fileName}}<div v-if="fileSize">{{changeByte(fileSize)}}</div>
<button @click.prevent="doUpload">{{progress && progress !== 100 ? '上传中 ' + progress + '%': '上传'}}</button>
</div>
</div>
Less.js 样式如下。
.aj-file-uploader {
&>* {
display: inline-block;
}
label {
margin-right: 2%;
&>div {
border : 1px solid lightgray;
border-radius: 5px;
text-align : center;
color : gray;
cursor : pointer;
font-size : .8rem;
padding : 10px;
width : 100px;
height : 70px;
transition : border-color linear 300ms, color linear 300ms;
&:hover {
border-color: gray;
color : black;
}
&>div {
font-size: 2rem;
}
}
}
button {
.aj-btn-base();
min-width: 110px;
}
input[type=file] {
display: none;
}
.msg>div {
color : gray;
font-size: .8rem;
}
}
那些美化的问题参见笔者旧文《Vue 组件放送之文件上传》https://blog.csdn.net/zhangxin09/article/details/86601308 即可。
组件实现
通过 XmlHttpRequest 2.0 上传
这也是核心原理,变化不大,还是参见笔者旧文《Vue 组件放送之文件上传》https://blog.csdn.net/zhangxin09/article/details/86601308 。
组件结构
鉴于这个组件功能多,类属性也多,故设一个抽象类(abstract class
)专门声明属性。从这些属性大概可以清晰地勾勒出这个组件做些什么,有哪些能力。
/**
* 属性较多,设一个抽象类
*/
abstract class BaseFileUploader extends VueComponent implements FormFieldElementComponent {
fieldName: string = "";
fieldValue: string = "";
/**
* 不重复的 id,用关于关联 label 与 input[type=file]
*/
radomId: number = 0;
/**
* 上传路径,必填
*/
action: string = "";
/**
* 允许文件选择器列出的文件类型
*/
accpectFileType: string = "";
/**
* 限制的文件扩展名,这是一个正则。如无限制,不设置或者空字符串
*/
limitFileType: string = "";
/**
* 文件大小
*/
fileSize: number = 0;
/**
* 获取文件名称,只能是名称,不能获取完整的文件目录
*/
fileName: string = '';
/**
* 文件对象,实例属性
*/
$fileObj: File | null = null;
/**
* 二进制数据,用于图片预览
*/
$blob: Blob | null = null;
/**
* 上传按钮是否位于下方
*/
buttonBottom = false;
/**
* 文件大小限制,单位:KB。
* 若为 0 则不限制
*/
limitSize: number = 0;
/**
* 上传进度百分比
*/
progress: number = 0;
/**
* 错误信息。约定:只有为空字符串,才表示允许上传。
*/
errMsg: string = "init";
/**
* 固定的错误结构,元素[0]为文件大小,[1]为文件类型。
* 如果非空,表示不允许上传。
*/
errStatus: string[] = ["", "", ""];
/**
* 成功上传之后的文件 id
*/
newlyId: string = "";
/**
* 上传之后的回调函数
*/
$uploadOk_callback: Function = function (this: FileUploader, json: ImgUploadRepsonseResult) {
if (json.isOk)
this.fieldValue = json.imgUrl;
xhr.defaultCallBack(json);
}
}
从串行到并行
文件上传不是啥文件都可以给用户上传的,是有一定要求的,例如文件类型和文件大小诸如此类的问题。我们需要在前端作一定的检查。首先设定一个变量 errMsg: string
,如果 errMsg
不为空,表示检查不通过,有错误信息,那么不显示上传按钮并弹出提示框向用户说明那些条件不通过。检查步骤有多个,所以就有多个检查条件,也就有多个检查结果,最后要把所有的结果显示给用户看,不是只显示一个。例如有时候文件类型不对,有时候文件类型不对而且又文件大小超出限制(同时的)。
实现起来不难,下面是旧的写法:
onUploadInputChange($event) {
var fileInput = $event.target;
var ext = fileInput.value.split('.').pop(); // 扩展名
if(!fileInput.files || !fileInput.files[0]) return;
this.$fileObj = fileInput.files[0]; // 保留引用
this.$fileName = this.$fileObj.name;
this.$fileType = this.$fileObj.type;
var size = this.$fileObj.size;
if(this.limitSize) {
this.isFileSize = size < this.limitSize;
this.errMsg = "要上传的文件容量过大,请压缩到 " + this.limitSize + "kb 以下";
} else
this.isFileSize = true;
if(this.limitFileType) {
this.isExtName = new RegExp(this.limitFileType, 'i').test(ext);
this.errMsg = '根据文件后缀名判断,此文件不能上传';
} else
this.isExtName = true;
this.readBase64(fileInput.files[0]);
if(self.isImgUpload) {
var imgEl = new Image();
imgEl.onload = function() {
if (imgEl.width > self.imgMaxWidth || imgEl.height > self.imgMaxHeight) {
cfg.isImgSize = false;
self.errMsg = '图片大小尺寸不符合要求哦,请重新图片吧~';
} else {
cfg.isImgSize = true;
}
}
}
this.getFileName();
},
一步一步 if
判断写就可以了,——会有什么问题呢?首先第一个它并不能收集所有的错误消息,只能后来检查的覆盖前面检查的(errMsg
只有一种错误原因),并不符合我们的需求;其次,最后一个图片大小检查,是一个异步的操作,也就是说有一定时间差,只不过电脑运算很快,我们不能察觉而已。也正是这个原因,我们很难做到收集所有的错误消息。
为什么那么说呢?对于收集所有的错误消息,我们很容易想到用一个数据容器去保存它,例如声明一个数组 errStatus[]
,“一个萝卜一坑”,约定第一个元素是文件类型检查的、第二个元素是文件大小检查的……但是图片尺寸大小检查是异步的,必须等待它检查完毕才有最后检查结果,执行的时间是未知的。也就是说,我们必须等待最后一个异步结束才是能获取最终结果的时候。
类似模式还有多个异步 AJAX 请求,等着最后一个标志完成。同一时间发出去,不同时间响应结果,以最后一个为准标志结束。也可以理解为多个函数不同时段修改一个共享变量。JavaScript 有不少库解决这个问题,但我起初压根没想到要用那么复杂的手段去解决。
重构的时候,我在想,反正 fileName: string
、fileSize: number
这些字段(或叫变量)都拿去 Vue 的 data
上作数据驱动,既然如此,能不能我一得到字段的值就进行检查呢?也就是通过 Vue 的 watch
监视,如下实现。
watch = {
fileName(this: FileUploader, newV: string): void {
if (!this.limitFileType) { // 无限制,也不用检查,永远是 true
Vue.set(this.errStatus, 0, true);
return;
}
if (newV && this.limitFileType) {
let ext = <string>newV.split('.').pop(); // 扩展名,fileInput.value.split('.').pop(); 也可以获取
if (!new RegExp(this.limitFileType, 'i').test(ext)) {
let msg: string = `上传文件为 ${newV},<br />抱歉,不支持上传 *.${ext} 类型文件`;
Vue.set(this.errStatus, 0, msg);
} else
Vue.set(this.errStatus, 0, true); // 检查通过
}
},
fileSize(this: FileUploader, newV: number): void {
if (!this.limitSize) { // 无限制,也不用检查,永远是 true
Vue.set(this.errStatus, 1, true);
return;
}
if (this.limitSize && newV > this.limitSize * 1024) {
let msg: string = `要上传的文件容量过大(${this.changeByte(newV)}),请压缩到 ${this.changeByte(this.limitSize * 1024)} 以下`;
Vue.set(this.errStatus, 1, msg);
} else
Vue.set(this.errStatus, 1, true);
},
……
}
注意这里 Vue 不能对数组直接操作(如 arr[0] = 'xxx'
,好像 Vue 3 可以),而要用 Vue.set()
处理。
同时 errStatus[]
也进行数据监视,每次对 errStatus[]
修改都会触发 watch
函数的执行。刚开始的时候,我走入了两个误区,一个是没有复位数组 errStatus[]
,以致上一次检查的信息会残留,下一次又出现;另外就是修改了 errStatus[]
马上 UI 弹出错误信息,而不是收集起来显示,也就是没有解决上述并发异步请求的问题。
第一个问题解决起来简单,就是每次新文件选择之后复位 errStatus[]
。第二问题我思考了一下,就是说缺乏一种标志或者状态来说明所有检查都走完,这时候检查的结果才是最终结果。一开始我的思维是数组 errStatus[]
里面只收集错误信息,另外就是空字符串 ""
,表示尚未检查。有错误信息自然表示检查过的,但是对于检查通过的,是不是也要记住已经是检查过的呢?我们把检查通过的用 true
标记。如此一来,只要 errStatus[]
里面每个元素都是 true
或者 errStatus
,那么就表示这次检查都跑过一次,可以得到结果了。出于方便,我们设定初始化状态为 false
,表示未检查。那么反过来说,只要数组 errStatus[]
里面出现过一次 false
,就是表示检查还没跑完。
代码层面,首先声明一个类型 ErrStatus
。
/**
* 定义错误状态,可以为 boolean = true 表示通过,没有错误; string 的时候表示有错误,为具体的异常信息
*/
type ErrStatus = boolean | string;
组件类增加一个 errStatus
属性,收集所有可能的错误状态,
/**
* 固定的错误结构,元素[0]为文件大小,[1]为文件类型。
* 如果元素非 true,表示不允许上传。
*/
errStatus: ErrStatus[] = [false, false];
watch
项增加一个监视 errStatus
属性的函数,
errStatus(this: FileUploader, newV: ErrStatus[]): void {
let j = newV.length;
if (!j)
return;
let msg: string = "";
for (let i = 0; i < j; i++) {
let err: ErrStatus = newV[i];
if (err === false)
return; // 未检查完,退出
if (typeof err == 'string')
msg += err + ';<br/>';
}
// 到这步,所有检查完毕
if (msg) { // 有错误
alert(msg);
this.errMsg = msg;
} else { // 全部通过,复位
this.errMsg = "";
this.errStatus = [false, false];
}
}
每次修改 errStatus
,但不是每次都是有效的,因为未最终检查完毕,所以会提前退出(if (err === false) return; // 未检查完,退出
)。若有错误则弹出对话框提示(如下图所示),否则表示全部通过。
实际上,这个问题可以视作为:顺序操作的串行流程变为并行的异步操作。只要能解决异步最终同步问题,不但令代码更清晰地聚焦问题(反正不管3721,watch
变量引起的变化执行相应的操作),而且对于效率也有提升(异步并行操作,非阻塞)。结合 watch
数据特性还可以推广到 Node 那种 callback hell
问题的解决——隐约觉得可以,莫非就是 RxJS 那种所谓“响应式的编程”?或者简单一点 Observer
观察者模式?
参考
- 《了解JS压缩图片,这一篇就够了》https://zhuanlan.zhihu.com/p/187021794
- https://www.jianshu.com/p/d2f14489bbe9
- 图片旋转到正确的角度 https://www.cnblogs.com/moqiutao/p/8657926.html
// 获取图片旋转的角度
function getOrientation(file, callback) {
var reader = new FileReader();
reader.readAsArrayBuffer(file);
reader.onload = function(e) {
var view = new DataView(e.target.result);
if (view.getUint16(0, false) != 0xFFD8) return callback(-2);
var length = view.byteLength, offset = 2;
while (offset < length) {
var marker = view.getUint16(offset, false);
offset += 2;
if (marker == 0xFFE1) {
if (view.getUint32(offset += 2, false) != 0x45786966) return callback(-1);
var little = view.getUint16(offset += 6, false) == 0x4949;
offset += view.getUint32(offset + 4, little);
var tags = view.getUint16(offset, little);
offset += 2;
for (var i = 0; i < tags; i++)
if (view.getUint16(offset + (i * 12), little) == 0x0112)
return callback(view.getUint16(offset + (i * 12) + 8, little));
}
else if ((marker & 0xFF00) != 0xFF00) break;
else offset += view.getUint16(offset, false);
}
return callback(-1);
};
}