pekonchan/Blog

从选择上传文件缩略图预览到提交上传全流程总结方案

Opened this issue · 0 comments

前言

上传图片生成缩略图,这个需求很常见,网络上的文章也很多。但是大多数都是直接丢一堆代码出来,也不多解释,不说下注意点,不说明优缺点等,也缺乏场景的延伸。

我这里所写的,不仅是生成缩略图这个需求,还把验证上传文件、删除已选文件、提交上传文件的一个完整的实际流程来展开此文章。

因为网上很多就单纯地解决一个生成缩略图的方案,但是忽略实际场景的应用,很多时候还是存在一些不足,在zheli做些小优化和做下几个方法的对比,也有一些注意点提及出来,帮助大家避坑。我看很多文章,都仅只说生成缩略图,却不告知如何配合发送数据。

好记性不如烂笔头,人老了很多会忘记,趁我人老头秃前记一下为好。

需求

首先要明确需求,根据需求才能确定方案:

点击上传工具,弹出上传文件对话框,可单选可多选文件,此次选择之后生成文件的罗略图(本文主要针对图片格式文件),之后每次的选择文件上传,都会在原本已选择的基础上新增。最后把每次选择的文件一并进行上传。

要注意的一点是,在此需求中,并不是每一次的选择文件,会覆盖上次选择的文件。而是会保留上次选择的结果,然后把每次选择的结果一并提交了。要进行删除已选的文件的话,就删除缩略图即代表删除了已选文件。

需求细节分点描述:

  1. 记录每次选择结果,最后一并提交
  2. 每次选择可多选
  3. 对选择的文件进行校验
  4. 根据所选的文件生成缩略图(随机 or 根据选择顺序)
  5. 点击缩略图进行预览(在文章不展开说,因为单纯是css样式即可控制,没多大难度)
  6. 删除缩略图代表删除已选文件

生成缩略图

要生成缩略图,无非就是展示“图”。“图”的展示有两种表现形式,一个是用img标签来直接展示图片,一个是用background-image样式来展示图片。所以这里的生成缩略图,可以往这两个方向走。

这里以用img标签表示为例:

/**
 * 生成预览容器
 * @param {String} url - 文件的url
 * @param {Function} cb - 图像加载完成时的执行函数
 */
 function createPreviewWrap (url, cb) {
    let imgWrap = document.createElement('div');
    let image = new Image();
    imgWrap.className = 'img-wrap';
    image.src = url;
    cb && (image.onload = cb);
    // 这里我放在imgWrap里是为了更好地控制缩略图的样式
    // 实际上用background-image的方式呈现图片,反而更好地控制图片的显示样式
    imgWrap.appendChild(image);
    // 生成删除缩略图icon,样式请自己补充
    const deleteIcon = document.createElement('i');
    deleteIcon.onclick = deleteFile; // deleteFile函数后面在各自方案中说明
    imgWrap.appendChild(deleteIcon);
    // img-container为用来放置缩略图的所在标签,如div
    document.getElementById('img-container').appendChild(imgWrap);
}

但是要能展示出“图”,上面两个思路的首要条件都是要知道图片的“源”,即图片路径。因此要实现生成缩略图的问题,就转化为生成图片路径的问题了。

上传文件所用到的标签是

<input type="file" />

所以,要处理的是文件类型(Blob类型的子类),针对文件类型生成URL的方式有两种

  • FileReader
  • URL.createObjectURL

使用FileReader

使用FileReader,生成的url是基于base64编码的url(Data Url),因此长度会比较大。

基本用法:

var fileReader = new FileReader(); // 新建一个FileReader
fileReader.readAsDataURL(blob); // 对blob以data url的形式进行读取
 // 读取过程是异步的,读取成功后的this.result即为file的Data Url结果
fileReader.onload = function() {
    console.log(this.result);
};

知道了基本用法后,要对上传文件生成url就好办了:

/**
 * FileReader方式实现缩略图(按读取速度快慢生成)
 * @param {FileList} files - 此次选择上传的文件列表
 */
function createThumbnailRandom (files) {
    for (let i = 0; i < files.length; i++) {
        let fileReader = new FileReader();
        fileReader.readAsDataURL(files[i]);
        fileReader.onload = function() {
            createPreviewWrap(this.result);
        };
    }
}

由于选择上传文件时是可以多选的,所以上述方法中files是个FileList类型,类数组,需要对其遍历一个个生成url。可以留意到我的注释有这么一句“按读取速度快慢生成”,因为上面说了fileReader的读取是异步的,所以上述方法并没有控制读取顺序。

如果你想按照顺序,可参考下述方法:

/**
 * FileReader方式实现缩略图(按顺序生成)
 * @param fileReader
 * @param {FileList} files - 此次选择上传的文件列表
 * @param {Number} index - files要处理的下标
 */
function createThumbnail (fileReader, files, index) {
    let i = index || 0;
    if (i >= files.length) {
        return;
    }
    fileReader.readAsDataURL(files[i]);
    fileReader.onload = function() {
        createPreviewWrap(this.result);
        createThumbnail(fileReader, files, ++i);
    };
}

这里用递归的方式来实现只有每次读取完,再读取下一个文件,进而来控制顺序。

我们要知道,file类型的input,当你在某次选择上传文件时,是多选的,此时FileList的默认顺序是按照文件名升序排序的。

我们把上述的两个方法整合在一个方法里:

/**
 * FileReader方式实现缩略图
 * @param {FileList} files - 此次选择上传的文件列表
 * @param {Number} type - 1:按顺序回显 2:按速度回显
 */
function fileReaderPreview (files, type) {
    // IE9(含9)以下不支持FileReader
    if (!FileReader) {
        alert('您的浏览器不支持上传,请换用或升级浏览器!');
    }
    let type = type || 1;
    if (type === 1) {
        let fileReader = new FileReader();
        // 按在文件选择器里的文件顺序回显缩略图
        createThumbnail(fileReader, files);
    } else if (type === 2) {
        createThumbnailRandom(files)
    }
}

使用createObjectURL

语法:

window.URL.createObjectURL(blob)

参数为Blob类型,返回值为Objcet URL(Blob URL)

该方式简单明了,不多讲,直接看代码:

/**
 * createObjectURL方式实现缩略图
 * @param {FileList} files - 此次选择上传的文件列表
 */
function objectURLPreview (files) {
    // IE9(含9)以下不支持createObjectURL
    for (let i = 0; i < files.length; i++) {
        const url = window.URL.createObjectURL(files[i]);
        createPreviewWrap(url, function (){
            // 释放url内存
            // 如果是用background-image来呈现图片,那么也是要通过new Image对象来加载图片
            // 监听onload事件后再把url赋值给background-image,之后再释放内存即可
            URL.revokeObjectURL(url);
        });
    }
}

小结

既然有两个方式,我们选择哪个比较好呢,我们简单做个对比。

同:

  • 只支持IE10(含10)以上的浏览器

异:

执行时间

  • FileReader是异步执行的,读取文件时异步的
  • createObjcetURL是同步执行的

内存情况

  • FileReader产生的url是base64编码地址,所占内存一般比createObjcetURL要大。但是在它没用的时候就会自动释放内存(浏览器的内存回收机制)
  • createObjcetURL创建出来的网络地址所占用内存需要等到页面刷新/关闭或者执行revokeObjcetURL方法时才被释放

还有一些不确定因素,就是我在查阅资料时,看到有些人反馈说用FileReader在某些安卓机上不能生成预览图,但是createObjectURL可以,且createObjectURL性能会好点。 具体这点我尚未验证,请大家辩证地看待。

验证(限制)上传文件

本文主要讲述图片类型的上传,然后生成缩略图。对于其他文件类型的上传,其实大同小异,只是缩略图方面可能要额外处理,如找个固定的图片代表一类文件等,视频上传生成截图,也是一个值得分享的知识,但是不在本文之内。

言归正传,我们可以在html里对上传文件进行一定的限制

<!-- 用label替代input.file作为视觉上的上传交互触发器,方便样式统一和美化 -->
<label>
    请选择上传文件
    <!-- accept属性仅仅是实现了文件选择器里展示的文件类型默认过滤出指定类型,但是不会真正阻止上传别的类型,需要结合js检验 -->
    <input type="file" id="file" multiple accept="image/*">
</label>

关于accept的取值格式和形式可参考Unique file type specifiers

正如注释所说,要在js里做限制才是真正起到限制作用

/**
 * 检验上传文件类型
 * @param {FileList} files - 此次选择上传的文件列表
 */
function verifyType (files) {
    for (let i = 0; i < files.length; i++) {
        // type属性是根据文件后缀名来判断的,所以如果你修改了文件的后缀名,type值也会发生改变
        // 因此不建议仅用type来作为唯一的判断条件
        if (!/^image\//.test(files[i].type)) {
            return false;
        }
    }
    return true;
}

选择上传

有了上面的两个基础,我们来对上传选择这个操作来做个处理,以满足需求里的“多次选择一并提交”这个需求。

首先我们要知道,利用type为file的input实现的上传,每次打开文件选择框进行文件选择后,都会覆盖掉先前选择的内容,如果不做任何处理,进行数据的提交,只会提交最后一次选择的文件。所以我们需要有一个变量,来记录每次选择的文件。

<label>
    请选择上传文件
    <input type="file" id="file" multiple accept="image/*">
</label>
// 用于记录上传的所有文件
var uploadFiles = [];

// 对上传选择做监听
document.getElementById('file').addEventListener('change', upload, false);

/**
 * 选择上传文件后
 */
function upload (ev) {
    let e = ev || event;
    const files = e.target.files;
    if (!files.length) {
        return;
    }
    // 检验上传文件类型
    if (!verifyType(files)) {
        alert('请上传图片格式的文件');
        return;
    }

    Array.prototype.push.apply(uploadFiles, files);
    
    // 生成预览图,这里选择使用objectURL
    objectURLPreview(files);
}

提交数据

一切都就绪了,最后当然就是把数据提交给后台了,缺少这步,一切都失去意义了。

说起提交数据,传统的方式,莫过于使用form表单进行提交了。

直接采用form表单

简易版

从简入难,虽然该方案不是本文章主要讲的内容,但是还是简单介绍下吧,毕竟你搜索网上资料,都有此方案的推介,但是并没有提出该方案的不足。我写出来,引以为戒。

顺带有些小知识点,可以留意一下。

<!-- 在使用包含文件上传控件的表单时,必须使用enctype="multipart/form-data" -->
<!-- 因为是上传文件,用get请求大小受限制,所以用post -->
<form action="http://example.com" method="POST" enctype="multipart/form-data">
    <label>
        上传文件
        <!-- 表单元素一定要写name,不然提交不了数据给后台 -->
        <input type="file" id="uploadFiles" name="uplpoadFiles" multiple accept="image/*" onchange="upload()">
    </label>
    <!-- button的type属性默认是submit,但是为了兼容ie(默认是button),显示地写出type为submit比较好,会自动提交表单 -->
    <!--如果type是button,但是你绑定了onclick="submit()",就算你有一个函数名叫submit,但是还是当做表单提交的submit来处理,-->
    <!--即发挥了type="submit"的按钮处理-->
    <button type="submit">提交</button>
</form>

以上是一个最简单的利用表单用html就能实现的提交功能。

但是要注意,一个input只能记录最后一次选择的一批文件。例如你点击“上传文件”,弹出选择窗口,选择了一批后,点确定,此时窗口关闭了;然后你又点击“上传文件”,此时再重复上述过程,以此类推。那么这个iduploadFilesinput被提交上去的只会是上述过程中最后一次选择的那批文件。

为了配合此种行为,我们需要改造一下上面的upload函数,只是删除了一行代码Array.prototype.push.apply(uploadFiles, this.files);就好。

但是这样的效果并不是我们想要的,我们是想每次选择之后都会生成缩略图,然后提交的时候是要把每批选择的文件都提交上去。这时候我们需要对上述代码进行升级改造。

改造升级版

思路:既然一个input只能代表一批文件,所以你想最终提交几批,就对应新建几个input就好了。只要每选择一批文件之后,生成预览图后,已选择的input就隐藏,新建一个新的input,每次如此。

丑话说在前,在允许一个input多选的情况下,由于文件上传input的行为就是如果你选择错或者反悔了,那重新选择即可,后面一次覆盖前面一次选择,本身就没有配备删除已选文件功能。所以该方案的一个缺点就是没法删除已选文件。这很显然,该缺陷不能接受,那只能退而求其次,只要限制每个input为单选文件,这样删除缩略图时就把对应的input删掉就好了。

<form id="myForm" action="http://example.com" method="POST" enctype="multipart/form-data">
    <label>
        上传文件
        <input type="file" id="upload1" name="uplpoadFiles[]" accept="image/*" onchange="upload()">
    </label>
    <button id="submitBtn" type="submit">提交</button>
</form>

以上是html部分,我们在选择上传文件,触发的upload方法里,添加对input的隐藏和添加。

/**
 * 选择上传文件后
 */
function upload (ev) {
    let e = ev || event;
    const files = e.target.files;
    if (!files.length) {
        return;
    }
    // 检验上传文件类型
    if (!verifyType(files)) {
        alert('请上传图片格式的文件');
        return;
    }

    // 隐藏和生成新的input
    e.target.parentNode.style.display = 'none';
    const form = document.querySelector('#myForm');
    const label = document.createElement('label');
    label.innerText = '上传文件';
    const input = document.createElement('input');
    input.type = 'file';
    input.name = 'uplpoadFiles[]';
    input.accept = 'image/*';
    input.onchange = upload;
    label.appendChild(input);
    form.insertBefore(label, document.querySelector('#submitBtn'));
    
    // 生成预览图,这里函数有调整,下方有说明
    objectURLPreview(files, e.target.parentNode);
}

删除已选文件和缩略图

接下来就是,如果删除缩略图,就得删除对应的input。我们怎么找到这个对应的input呢,我们对上述的createPreviewWrapobjectURLPreview做个简单调整,把input所在的label节点(即上面upload方法中的e.target.parentNode)和缩略图节点当做参数进行传递,然后进行删除。

/**
 * createObjectURL方式实现缩略图
 * @param {FileList} files - 此次选择上传的文件列表
 * @param label- 要删除的包裹input的label
 */
function objectURLPreview (files, label) {
    for (let i = 0; i < files.length; i++) {
        const url = window.URL.createObjectURL(files[i]);
        createPreviewWrap(url, label, function (){ // 主要改动这里
            URL.revokeObjectURL(url);
        });
    }
}

/**
 * 生成预览容器
 * @param {String} url - 文件的url
 * @param {String} label - input的对应的label
 * @param {Function} cb - 图像加载完成时的执行函数
 */
 function createPreviewWrap (url, label, cb) {
    let imgWrap = document.createElement('div');
    let image = new Image();
    imgWrap.className = 'img-wrap';
    image.src = url;
    cb && (image.onload = cb);
    imgWrap.appendChild(image);
    const deleteIcon = document.createElement('span');
    deleteIcon.onclick = () => {deleteFile(label, imgWrap);} // 主要改动为这里
    imgWrap.appendChild(deleteIcon);
    document.getElementById('img-container').appendChild(imgWrap);
}

对应的删除节点函数

/**
 * 删除对应input的label和缩略图
 * @param label - input的对应的label
 * @param imgWrap - 要删除的缩略图
 */
function deleteFile (label, imgWrap) {
    document.querySelector('#myForm').removeChild(label);
    document.getElementById('img-container').removeChild(imgWrap);
}

小结

总结下该方案的缺点:

  • 每次选择文件只能单选
  • 频繁进行DOM操作,可能会引起重绘或重排

丢个demo

用XMLHttpRequest发请求提交

在这里的方案,我们能够在上文的基础上(除去form表单提交方案的内容),就能满足所有需求,且不用退而求其次。

按照传统的做法,要提交上传文件的内容,都是借助表单来实现的。那么如果不想直接用上述的html形式的表单提交,我们还可以借助FormData对象来表示有一份表单数据,因此可以不必写form标签,然后利用XMLHttpRequest发送请求提交表单数据。

html部分可以改成这样:

<div id="img-container"></div>
<div>
    <label>
        请选择上传文件
        <input type="file" id="file" multiple accept="image/*">
    </label>
    <button onclick="submitFormData()">提交</button>
</div>

提交按钮绑定了一个方法,就是用于提交上传数据的:

function submitFormData () {
    if (uploadFiles.length === 0) {
        alert('请选择文件');
        return;
    }
    // FFormData ie10以下不支持
    let formData = new FormData();
    uploadFiles.forEach(item => {
        formData.append('uplpoadFiles[]', item);
    });
    let xhr = new XMLHttpRequest();
    xhr.open('POST', 'http://example.com');
    xhr.send(formData);
    xhr.onload = function () {
        if (this.status === 200 || this.status == 304) {
            alert('上传成功');
        } else {
            alert('上传失败');
        }
    }
}

删除已选文件和缩略图

同样的,在该方案下的删除操作,需要对生成缩略图相关的两个函数做调整,这里仍然以objectURLPreview为例子。

我们要找到要删除对应的在uploadFiles数组中的元素,然后对其删除,这里我们利用数组下标确定找到。(主要调整在注释处)

function objectURLPreview (files) {
    const index = uploadFiles.length - files.length; // 找出用于计算本批文件对应下标的基础值
    for (let i = 0; i < files.length; i++) {
        const url = window.URL.createObjectURL(files[i]);
        // 基础值+此批对应的下标即为在整个uploadFiles数组中的下标
        createPreviewWrap(url, index + i, function (){
            URL.revokeObjectURL(url);
        });
    }
}
function createPreviewWrap (url, index, cb) {
    let imgWrap = document.createElement('div');
    let image = new Image();
    imgWrap.className = 'img-wrap';
    image.src = url;
    cb && (image.onload = cb);
    imgWrap.appendChild(image);
    const deleteIcon = document.createElement('span');
    deleteIcon.onclick = () => {deleteFile(index, imgWrap);}// 主要改动为这里
    imgWrap.appendChild(deleteIcon);
    document.getElementById('img-container').appendChild(imgWrap);
}

删除函数即为

function deleteFile (index, imgWrap) {
    uploadFiles.splice(index, 1);
    document.getElementById('img-container').removeChild(imgWrap);
}

最后,丢个demo,看看完整的一个例子

总结

整篇文章无论哪个方案,兼容性都是要在ie10含10以上才能正常运行。

未经允许,请勿私自转载