用 TypeScript 写一个轻量级的 UI 框架之九:表单控件之文件上传

文件上传,无论前端还是后端,笔者都重写过好几次了,一直不太满意。这次前端的用 TypeScript 再写一次,发现旧的问题不少,——幸好在这次重构中,找到比较理想的办法来处理。下面是旧文回顾。

下面就让我们看看新版的文件上传组件吧。有图胜千言,先过目一下组件截图。

在这里插入图片描述动图:
在这里插入图片描述

以上包含两个组件:一个文件上传,另外一个图片上传。当然文件上传也可以上传图片。

在线例子: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上传路径,必填Stringy
field-name表单提交时字段的名称Stringn
field-value表单提交时字段的值Stringn
limit-file-type限制的文件扩展名,这是一个正则。如无限制,不设置或者空字符串。例如 txt|pdf|docStringn
limit-size文件大小限制,单位:KB。若为 0 则不限制Numbern
accpect-file-type允许文件选择器列出的文件类型,例如限定图片为 image/*Stringn
uploadOk_callback上传之后的回调函数。一般晚绑定这个属性,而不是 通过 props 指定Functionn

话说……

话说上一款的文件上传组件,就是我最初接触 Vue 时候写的,感受到了 Vue 的强大和精妙无比,不过也有理解不到位的地方,所以现在回过头来看还是有挺多不足的,特别是细节方面的,还有一个当时也头痛的问题,就是把文件上传和图片上传两者混在一起,主要是因为上传功能需求繁多芜杂,罗列一下就有这些:

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

那时想分开两个组件写的,但上述功能之间存在着严重的耦合,于是一气之下统统写到一个组件身上。最理想的情形是,写好文件上传这个组件,通过继承扩展一个子类,也就是派生一个新组件:图片上传,后者可以复用前者的逻辑。

绘制 UI

单纯文件上传没啥 UI,最简单就是 File Picker (文件选择器)然后加多个上传按钮,前者浏览器已经有默认的本地 UI 不用我们操劳了(也无法改变),后者不就是一个按钮的事情?如果图片上传的,顶多加多一个 <img /> 来预览。UI 本无甚述之处,画好了 UI 的那些 HTML 等于解决了“无刷新”上传的功能。其原理非常简单,一句表述:通过 label 标签 for 属性关联具体的 input[type=file] 触发本地 File Pickerinput[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: stringfileSize: 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);
    };
}
相关推荐
©️2020 CSDN 皮肤主题: 数字20 设计师:CSDN官方博客 返回首页