lgwebdream / FE-Interview

🔥🔥🔥 前端面试,独有前端面试题详解,前端面试刷题必备,1000+前端面试真题,Html、Css、JavaScript、Vue、React、Node、TypeScript、Webpack、算法、网络与安全、浏览器
https://lgwebdream.github.io/FE-Interview/
Other
6.76k stars 897 forks source link

Day320:文件上传如何实现?都有哪些方法? #1146

Open Genzhen opened 3 years ago

Genzhen commented 3 years ago

每日一题会在下午四点在交流群集中讨论,五点小程序中更新答案 欢迎大家在下方发表自己的优质见解

二维码加载失败可点击 小程序二维码

扫描下方二维码,收藏关注,及时获取答案以及详细解析,同时可解锁800+道前端面试题。

anthhub commented 3 years ago

每日一题会在下午四点在交流群集中讨论,五点小程序中更新答案 欢迎大家在下方发表自己的优质见解

二维码加载失败可点击 小程序二维码

扫描下方二维码,收藏关注,及时获取答案以及详细解析,同时可解锁800+道前端面试题。

文件上传

1.1 FileUpload 对象

在网页上传文件,最核心元素就是这个 HTML DOM 的 FileUpload 对象了。

FileUpload 对象是什么呢?看到下面的代码一下就明白了。

<input type="file" />

HTML 文档中该标签每出现一次,一个 FileUpload 对象就会被创建。该标签包含一个按钮,用来打开文件选择对话框,以及一段文字显示选中的文件名或提示没有文件被选中。

把这个标签放在<form>标签内,设置 form 的 action 为服务器目标上传地址,并点击 submit 按钮或通过 JS 调用 form 的submit()方法就可以实现最简单的文件上传了。

<form id="uploadForm" method="POST" action="upload" enctype="multipart/form-data">
      <input type="file" id="myFile" name="file"></input>
      <input type="submit" value="提交"></input>
 </form>

但是这样会刷新页面,那能不能无刷新呢?

1.2 无刷新上传

使用 XMLHttpRequest。但是要注意如果是 XMLHttpRequest Level 1 是不行的,但是现在主流的浏览器基本都是支持 XHR2。除了 IE 系列需要 IE10 及更高版本. 因此 IE10 以下是不支持 XHR2 的.

XMLHttpRequest Level 1 为什么不行?

  • 仅支持文本数据传输, 无法传输二进制数据.
  • 传输数据时, 没有进度信息提示, 只能提示是否完成.
  • 受浏览器 同源策略 限制, 只能请求同域资源.
  • 没有超时机制, 不方便掌控 ajax 请求节奏.

XMLHttpRequest Level 2 的改进

  • 支持二进制数据, 可以上传文件, 可以使用 FormData 对象管理表单.
  • 提供进度提示, 可通过 xhr.upload.onprogress 事件回调方法获取传输进度.
  • 依然受 同源策略 限制, 这个安全机制不会变. XHR2 新提供 Access-Control-Allow-Origin 等 headers, 设置为 * 时表示允许任何域名请求, 从而实现跨域 CORS 访问
  • 可以设置 timeout 及 ontimeout, 方便设置超时时长和超时后续处理.

上面提到的 FormData 就是比价常用的一种方式。通过在脚本里新建 FormData 对象,把 file 对象设置到表单项中,然后利用 XMLHttpRequest 异步上传到服务器。

let xhr = new XMLHttpRequest();
let formData = new FormData();
let fileInput = document.querySelector("#myFile");
let file = fileInput.files[0];
formData.append("myFile", file);
xhr.open("POST", "/upload.php");
xhr.onload = function () {
  if (this.status === 200) {
    // 对请求成功处理
  }
};
xhr.send(formData);
xrh = null;

这样一个文件无刷新的文件基本上传需求就完成了。

但是还可以优化下,比如上传进度显示,图片预览。

1.3 上传进度

使用 XMLHttpRequest Level 2, 很容易就可以支持对上传进度的监听。

但是这里要注意的是,XHR 对象的直属 progress 事件并不是用来监听上传资源的进度的。XHR 对象还有一个属性 upload, 它返回一个 XMLHttpRequestUpload 对象,这个对象拥有下列下列方法:

  • onloadstart
  • onprogress
  • onabort
  • onerror
  • onload
  • ontimeout
  • onloadend

这些方法在 XHR 对象中都存在同名版本,区别是后者是用于加载资源时,而前者用于资源上传时。其中 onprogress 事件回调方法可用于跟踪资源上传的进度,它的 event 参数对象包含两个重要的属性 loaded 和 total。分别代表当前已上传的字节数(number of bytes)和文件的总字节数。

具体代码示例:

xhr.upload.onprogress = function (event) {
  const { loaded, total, lengthComputable } = event;
  // lengthComputable 代表文件总大小是否可知
  if (lengthComputable) {
    let percentComplete = (loaded / total) * 100;
    // 对进度处理
  }
};

如果是现代浏览器中,可以直接配合HTML5提供的元素使用,方便快捷的显示进度条。

<progress id="myProgress" value="50" max="100">
</progress>

其value属性绑定上面代码中的percentComplete的值即可。

1.4 图片预览

一般实现预览的方式是,等待文件上传成功后,后台返回上传文件的url,然后把预览图片的img元素的src指向该url。

但是还有更好的实现方式。就是使用HTML5提供的 FileReader API。

function handleImageFile(file){
  let previewArea = document.querySelector("#previewArea");
  let img = document.createElement('img');
  let fileInput = document.querySelector('#myFile');
  let file = fileInput.files[0];
  img.file = file;
  previewArea.appendChild(img);
  let reader = new FileReader();
  reader.onload = (function(aImg){
    return function(e){
      aImg.src = e.target.result;
    }
  })(img)
  reader.readAsDataURL(file);
}

这里我们使用FileReader来处理图片的异步加载。在创建新的FileReader对象之后,我们建立了onload函数,然后调用readAsDataURL()开始在后台进行读取操作。当图像文件加载后,转换成一个 data: URL,并传递到onload回调函数中设置给img的src。

另外还可以使用对象URL来实现预览

let img = document.createElement("img");
img.src = window.URL.createObjectURL(file);
img.onload = function(){
  window.URL.revokeObjectURL(this.src);
}
previewArea.appendChild(img);

1.5 多文件上传

FileUpload对象有一个multiple属性。

<input id="myFile" type="file" multiple />

这样就能在打开的文件选择对话框中选中多个文件了。然后你在代码里拿到的FileUpload对象的files属性就是一个选中的多文件的数组了。

let fileInput = document.querySelector('#myFile');
let files = fileInput.files;
let formData = new FormData();
for(let i = 0;i<files.length;i++){
  let file = files[i];
  formData.append('files[]',file,file.name);
}

FormData的append方法提供第三个可选参数用于指定文件名,这样就可以使用同一个表单项名,然后用文件名区分上传的多个文件。这样也方便前后台的循环操作。

1.6 二进制上传

有了FileReader 其实还有一种上传的途径,读取文件内容后可以直接二进制格式上传。

let reader = new FileReader();
reader.onload = function(){
  xhr.sendAsBinary(this.result);
}
// 把input里读取的文件内容,放到fileReader的result字段里
reader.readAsBinaryString(file);

但是这里有个问题,查看MDN文档,可以看到XMLHttpRequest的sendAsBinary方法已经移除了,所以需要自己实现一个

XMLHttpRequest.prototype.sendAsBinary = function(text){
  let data = new ArrayBuffer(text.length);
  let ui8a = new Unit8Array(data,0);
  for(let i =0;i<text.length;i++){
    ui8a[i] = (text.charCodeAt(i) & 0xff);
  }
  this.send(ui8a);
}

这段代码将字符串转成8位无符号整型,然后存放到一个8位无符号整型数组里面,再把整个数组发送出去。

到这基本可以结合业务需求实现一个比较优雅的文件上传组件了。

如果还想做的更好,可以再加个拖拽。

1.7 拖拽的支持

利用HTML5的drag & drop事件,我们可以很快实现对拖拽的支持。首先我们可能需要确定一个允许拖放的区域,然后绑定相应的事件进行处理。

let dropArea;
dropArea = document.querySelector("#dropArea");
dropArea.addEventListener('dragenter',handleDragenter,false);
dropArea.addEventListener('dragover',handleDragover,false);
dropArea.addEventListener('drop',handleDrop,false);

// 阻止dragenter和dragover的默认行为,这样才能使drop事件被触发
function handleDragenter(e) {
    e.stopPropagation();
    e.preventDefault();
}

function handleDragover(e) {
    e.stopPropagation();
    e.preventDefault();
}

function handleDrop(e) {
    e.stopPropagation();
    e.preventDefault();

    let dt = e.dataTransfer;
    let files = dt.files;

    // handle files ...
}

这里可以把通过事件对象的dataTransfer拿到的files数组和之前相同处理,以实现预览上传等功能。有了这些事件回调,我们也可以在不同的事件给我们UI元素添加不同的class来实现更好交互效果。

如果要支持IE9还要实现无刷新上传怎么办?

1.8 借用iframe

为什么要用iframe?因为在现代浏览器中我们可以用XMLHttpRequest Level 2来支持二进制数据,异步文件上传,并且动态创建FormData。而低版本的IE里的XMLHttpRequest是Level 1。所以我们通过XHR异步向服务器发上传请求的路走不通了。只能老老实实的用form的submit。但是form的submit会导致页面刷新。

所以就有了借助iframe的方案。利用iframe。把form的target指定到一个看不见的iframe,那么返回的数据就会被这个iframe接受,于是乎就只有这个iframe会刷新。而它又是看不见的,用户自然就感知不到了。

window.__iframeCount = 0;
let hiddenFrame = document.createElement('iframe');
let frameName = "upload-iframe" + ++window.__iframeCount;
hiddenFrame.name = frameName;
hiddenFrame.id = frameName;
hiddenFrame.setAttribute("style", "width:0;height:0;display:none");
document.body.appendChild(hiddenFrame);

let form = document.getElementById('myForm');
form.target = frameName;

// 然后响应iframe的onload事件,获取response

hiddenFrame.onload = function(){
  // 获取iframe的内容,即服务返回的数据
  let resData = this.contentDocument.body.textContent || this.contentWindow.document.body.textContent;
  // 处理数据...

  // 删除iframe
  setTimeout(function(){
    let _frame = document.getElementById(frameName);
    _frame.parentNode.removeChild(_frame);
  },100)
}

iframe的实现大致如此,但是如果文件上传的地址与当前页面不在同一个域下就会出现跨域问题。导致iframe的onload回调里的访问服务返回的数据失败。

这时我们再使用JSONP这把利剑,来解决跨域问题。首先在上传之前注册一个全局的函数,把函数名发给服务器。服务器需要配合在response里让浏览器直接调用这个函数。

// 生成全局函数名,避免冲突
var CALLBACK_NAME = 'CALLBACK_NAME';
var genCallbackName = (function () {
    var i = 0;
    return function () {
        return CALLBACK_NAME + ++i;
    };
})();

var curCallbackName = genCallbackName();
window[curCallbackName] = function(res) {
    // 处理response 。。。

    // 删除iframe
    var _frame = document.getElementById(frameName);
    _frame.parentNode.removeChild(_frame);
    // 删除全局函数本身
    window[curCallbackName] = undefined;
}

// 如果已有其他参数,这里需要判断一下,改为拼接 &callback=
form.action = form.action + '?callback=' + curCallbackName;

mark

lidashuang1996 commented 2 years ago

标题有误导别人的意思,文件上传步骤里面,input 是选择文件使用,后面的提交方式可以通过表单提交或者通过 FormData 的方式提交文件。所以说文件上传必须需要 input 选择文件,标题里面不能说 “除了 input 还有哪些别的方法?”。