前言

前端无法像原生 APP 一样直接操作本地文件,需要通过用户触发, 用户可通过以下三种方式操作触发:

  • 通过 input type="file" 选择本地文件
  • 通过拖拽的方式把文件拖过来
  • 在编辑框里面复制粘贴

实践

input[type=”file”] 方式

第一种是最常用的手段, 通常还会自定义一个按钮,然后盖在它上面,因为 type="file" 的 input 不容易改变样式。

<form>
  <input type="file" id="file-input" name="fileContent">
</form>

FormData 获取整个表单的内容

$('#file-input').on('change', function() {
  console.log(`file name is ${this.value}`);
  let formData = new FormData(this.form);
  formData.append('fileName', this.value);
  console.log(formData);
});

把 input 的 valueformData 打印出来是这样的

可以看到文件路径不是真实的路径,浏览器无法获取到文件的真实存放位置。同时 FormData 打印出来是一个空的 Object, 但并不是说它的内容是空的,只是它对前端是透明的,无法查看、修改、删除里面的内容,只能 append 添加字段。

FormData 无法得到文件的内容, 而使用 FileReader 可以读取整个文件的内容。 用户选择文件之后, input.files 就可以得到用户选中的文件

$('#file-input').on('change', function() {
  const fileReader = new FileReader();
  const fileType = this.files[0].type;
  fileReader.onload = function() {
    if (/^image/.test(fileType)) {
      // 读取结果在fileReader.result里面
      $(`<img src="${this.result}">`).appendTo('body');
    }
  };
  // 打印原始File对象
  console.log(this.files[0]);
  // base64方式读取
  fileReader.readAsDataURL(this.files[0]);
});

它是一个 window.File 的实例,包含了文件修改时间、文件名、文件大小、文件的 mime 类型等。如果需要限制上传文件的大小就可以通过判断 size 属性是否超出范围,单位是字节,判断是否为图片文件就可以通过 type 类型是否以 image 开头。上面使用了一个正则判断,但是 Web 不是所有的图片都能通过 img 标签展示出来, 通常是 jpg/jpeg/png/gif 这四种,我们优化一下代码:

/^image\/[jpeg|jpg|png|gif]/.test(this.type);

然后实例化一个 FileReader, 调用它的 readAsDataURL并把 File 对象传给它, 监听它的 onload 事件,load 完读取的结果就在它的 result 属性里了。它是一个 base64 格式的, 可直接赋值给一个 img 的 src。

使用 FileReader 除了可读取为 base64 之外,还能读取为以下格式

// 按base64的方式读取,结果是base64, 任何文件都可转成base64的形式
fileReader.readAsDataURL(this.files[0]);
// 以二进制字符串方式读取,结果是二进制内容的utf-8形式,巳被废弃了
fileReader.readAsBinaryString(this.files[0]);
// 以原始二进制方式读取,读取结杲可直接转成整数数组
fileReader.readAsArrayBuffer(this.files[0]);

其他的主要是能读取为 ArrayBuffer, 它是一个原始二进制格式的结果。把 ArrayBuffer 打印出来是这样的

可以看到,它对前端人员也是透明的,不能够直接读取里面的内容, 但可以通过ArrayBuffer.length 得到长度,还能转成整型数组,从而知道文件的原始二进制内容

let buffer = fileReader.result;
// 依次每字节8位读取, 放到一个整数数组
let view = new Uint8Array(buffer);
console.log(view);

通过拖拽的方式

<div class="img-container">
  drop your image here
</div>

然后监听它的拖拽事件

$('.img-container')
  .on('dragover', function(event) {
    event.preventDefault();
  })
  .on('drop', function(event) {
    event.preventDefault();
    const fileReader = new FileReader();
    //数据在event的dataTransfer对象里
    let file = event.originalEvent.dataTransfer.files[0];
    //然后就可以使用FileReader进行操作
    fileReader.readAsDataURL(file);
    console.log(fileReader);
    //或者是添加到一个FormData
    let formData = new FormData();
    formData.append('fileContent', file);
    console.log(formData);
  });

数据在 drop 事件的 event.dataTransfer.files 里面, 拿到这个 File 对象之后就可以和输入框进行一样的操作了,即使用 FileReader 读取,或者是新建一个空的 formData, 然后把它 append 到 formData 里面。

粘贴的方式

通常是在一个编辑框里操作,如把 div 的 contenteditable设置为 true

<div id="editor" contenteditable="true">
  hello, paste your image here
</div>

粘贴的数据是在 event.clipboardData.files 里面

$('#editor').on('paste', function(event) {
  let file = event.originalEvent.clipboardData.files[0];
});

但是 Safari 的粘贴不是通过 event 传递的,而是直接在输入框里面添加一张图片,它新建了一个 img 标签, 并把 img 的 src 指向一个 blob 的本地数据。什么是 blob 呢,如何读取 blob 的内容呢?

blob 是一种类文件的存储格式 (Blob 派生了 File), 它可以存储几乎任何格式的内容

let data = { hello: 'world' };
let blob = new Blob([JSON.stringify(data)], { type: 'application/json' });

为了获取本地的 blob 数据,我们可以用 ajax 发个本地的请求:

$('#editor').on('paste', function(event) {
  // 需要setTimeout 0等图片出来了再处理
  setTimeout(() => {
    let img = $(this).find('img[src ^="blob"]')[0];
    console.log(img.src);
    // 用-个xhr获取blob数据
    let xhr = new XMLHttpRequest();
    xhr.open('GET', img.src);
    // 改变mime类型
    xhr.responseType = 'blob';
    xhr.onload = function() {
      // response就是一个Blob对象
      console.log(this.response);
    };
    xhr.send();
  }, 0);
});

这样能得到它的大小和类型,但是具体内容也是不可见的,它有一个 slice 的方法,可用千切割大文件。和 File 一样,可以使用 FileReader 读取它的内容

function readBlob(blobImg) {
  let fileReader = new FileReader();
  fileReader.onload = function() {
    console.log(this.result);
  };
  fileReader.onerror = function(err) {
    console.log(err);
  };
  fileReader.readAsDataURL(blobImg);
}
xhr.onload = function() {
  // response就是一个Blob对象
  readBlob(this.response);
  console.log(this.response);
};
xhr.send();

除此之外,还能使用 window.URL 读取, 这是一个新的 API, 经常和 Service Worker 配套使用, 因为 Service Worker 里面常常要解析 url。

function readBlob(blobImg) {
  let urlCreator = window.URL || window.webkitURL;
  // 得到base64结果
  let imageUrl = urlCreator.createObjectURL(this.response);
  return imageUrl;
}

关于 src 使用的是 blob 链接的,除了上面提到的 img 之外, 另外一个很常见的是 video 标签。

这种数据不是直接在本地的,而是通过持续请求视频数据,然后再通过 blob 这个容器媒介添加到 video 里面,它也是通过 URL 的 API 创建的。

let mediaSource = new MediaSource();
video.src = URL.createObjectURL(mediaSource);
let sourceBuffer = mediaSource.addSourceBuffer(
  'video/mp4; codecs="avc1.42E01E,mp4a.40.2"'
);
sourceBuffer.appendBuffer(buf);

上面, 我们使用了三种方式获取文件内容,最后得到:

  • FormData 格式
  • FileReader 读取得到的 base64 或者 ArrayBuffer 二进制格式

如果直接就是一个 FormData 了, 那么直接用 AJAX 发出去就行了, 不用做任何处理。

let form = document.querySelector('form');
let formData = new FormData(form);
formData.append('fileName', 'photo.png');
let xhr = new XMLHttpRequest();
// 假设上传文件的接口叫upload
xhr.open('POST', '/upload');
xhr.send(formData);

如果用 jQuery 的话, 要设置两个属性为 false

$.ajax({
  url: '/upload',
  type: 'POST',
  data: formData,
  processData: false, //不处理数据
  contentType: false //不设置内容类型
});

因为 jQuery 会自动对内容进行转义,并且根据 data 自动设置请求 mime 类型,这里告诉 jQuery 直接用 xhr.send 发出去就行了。

从请求数据结果可以看到它的编码格式是 multipart/form-data, 就是上传文件 form 表单写的 enctype

<form enctype="multipart/form-data" method="post">
  <input type="file" name="fileContent">
</form>

如果 xhr.send 是 FormData 类型,它会自动设置 enctype, 如果你用默认表单提交上传文件的话就得在 form 上面设置这个属性,因为上传文件只能使用 POST 的这种编码。 常用的 POST 编码是 application/x-www-form-urlencoded, 它和 GET 一样,发送的数据里面,参数和参数之间使用&连接

key1=value1&key2=value2

特殊字符做转义,这个数据 POST 是放在请求 body 里的,而 GET 是拼在 url 上面的,如果用 jq 的话,jq 会帮你拼并做转义。

而上传文件用的这种 multipart/form-data, 参数和参数之间是一个相同的字符串隔开的,上面的是使用:

-----WebKitFormBoundary72yvM25iSPYZ4a3F

这个字符通常会取得比较长、而且随机,因为要保证正常的内容里面不会出现这个字符串,这样内容的特殊字符就不用做转义了。

请求的 contentType 被浏览器设置成:

Content-Type:
multipart/form-data; boundary=-----WebKitFormBoundary72yvM25iSPYZ4a3F

后端服务通过这个就知道怎么解析这么一段数据了(通常是使用框架处理,而具体的接口不需要关心应该怎么解析)。

如果读取结果是 ArrayBuffer 的话,也是可以直接用 xhr.send 发送出去,但是一般我们不会直接把一个文件的内容发出去,而是用某个字段名等于文件内容的方式。如果你读取为 ArrayBuffer 再上传的话其实作用不是很大,还不如直接用 formData 添加一个 File 对象的内容,因为上面三种方式都可以拿到 File 对象。如果一开始就是一个 ArrayBuffer 了,那么可以转成 blob 然后再 append 到 FormData 里面。

使用比较多的应该是 base64, 因为前端经常要处理图片,读取为 base64 之后就可以把它画到一个 canvas 里面,然后就可以做一些处理,如压缩、裁剪、旋转等。 最后再用 canvas 导出一个 base64 格式的图片, 那怎么上传 base64 格式的呢?

第一种是拼一个表单上传的 multipart/form-data 的格式, 再用 xhr.sendAsBinary 发出去

let base64Data = base64Data.replace(/^data:image\/[^;]+;base64,/, '');
let boundary = '----------boundaryasoifvlkasldvavoadv';
xhr.sendAsBinary(
  [
    // name=data
    boundary,
    'Content一Disposition: form-data; name="data"; filename="' + fileName + '"',
    'Content-Type: ' + 'image/' + fileType,
    '',
    atob(base64Data),
    boundary,
    //name=imageType
    boundary,
    'Content-Disposition: forrn-data; name="imageType"',
    '',
    fileType,
    boundary + '--'
  ].join('\r\n')
);

上面代码使用了 window.atob 的 api, 它可以把 base64 还原成原始内容的字符串表示。

btoa 是把内容转化成 base64 编码,而 atob 是把 base64 还原。在调 atob 之前,需要把表示内容格式的不属千 base64 内容的字符串去掉,即上面代码第一行的 replace 处理。

这样就和使用 formData 类似了,但是由于 sendAsBinary 巳经被 deprecated 了, 所以新代码不建议再使用这种方式。

可以把 base64 转化成 blob, 然后再 append 到一个 formData 里面,下面的函数可以把 base64 转成 blob

function b64toBlob(b64Data, contentType, sliceSize) {
  contentType = contentType || '';
  sliceSize = sliceSize || 512;
  var byteCharacters = atob(b64Data);
  var byteArrays = [];
  for (var offset = O; offset < byteCharacters.length; offset += sliceSize) {
    var slice = byteCharacters.slice(offset, offset + sliceSize);
    var byteNumbers = new Array(slice.length);
    for (var i = O; i < slice.length; i++) {
      byteNumbers[i] = slice.charCodeAt(i);
    }
    var byteArray = new Uint8Array(byteNumbers);
    byteArrays.push(byteArray);
  }
  var blob = new Blob(byteArrays, { type: contentType });
  return blob;
}

然后就可以 append 到 formData 里面

let blob = b64toBlob(b64Data, 'image/png');
let formData = new FormData();
formData.append('fileContent', blob);

这样就不用自己去拼一个 multipart/form-data 的格式数据了。

上面处理和上传文件的 API 可以兼容到 IElO+, 如果要兼容老的浏览器应该怎么办呢?

可以借助一个 iframe, 原理是默认的 form 表单提交会刷新页面, 或者跳到 target 指定的那个 url, 但是如果把 iframe 的 target 指向一个 iframe, 那么刷新的就是 iframe, 返回结果也会显示在 iframe, 然后获取这个 iframe 的内容就可得到上传接口返回的结果。

let iframe = document.createElement('iframe');
iframe.display = 'none';
iframe.name = 'form-iframe';
document.body.appendChild(iframe);
// 改变form的target
form.target = 'form-iframe';
iframe.onload = function() {
  // 获取iframe的内容 即服务返回的数据
  let responseText =
    this.contentDocument.body.textContent ||
    this.contentWindow.document.body.textContent;
};
form.submit();

form.submit 会触发表单提交,当请求完成(成功或者失败)之后就会触发 iframe 的 onload 事件,然后在 onload 事件获取返回的数据,如果请求失败,则 iframe 里的内容就为空(可以用这个判断请求有没有成功)。

使用 iframe 没有办法获取上传进度,使用 xhr 可以获取当前上传的进度。

xhr.upload.onprogress = function(event) {
  if (event.lengthComputable) {
    // 当前上传进度的百分比
    duringCallback((event.loaded / event.total) * lOO);
  }
};

总结

本文列举了 3 种交互方式的读取方式

  • 通过 input 控件在 input.files 可以得到 File 文件对象
  • 通过拖拽的是在 drop 事件的 event.dataTransfer.files 里面
  • 通过粘贴的 paste 事件在 event.clipboardData.files 里面
    • Safari 是在编辑器里面插入一个 src 指向本地的 img 标签,可以通过发送一个请求加载本地的 blob 数据,然后再通过 FileReader 读取,或者直接 append 到 formData 里面。得到的 File 对象就可以直接添加到 FormData 里面,如果需要先读取 base64 格式做处理的,可以把处理后的 base64 转化为 blob 数据再 append 到 formData 里面。对于老浏览器,可以使用一个 iframe 解决表单提交刷新页面或者跳页的问题。

补充

  1. 如果要上传多个文件,只要给 input 加个 multiple 的属性即可。

  2. 网盘的断点续传是怎么实现的?

  • 使用 blob 分割大文件上传
let fileReader = new FileReader();
file = this.files[0];
console.log(`总共发送${file.size}字节`);
const ONE_MB = 1024 * 1024;
let sendedBytes = O;
fileReader.onload = function() {
  // 发送分割的片段
  xhr.open();
  xhr.send(this.result);
  sendedBytes += ONE_MB;
  if (sendedBytes < file.size) {
    // File的slice方法继承于Blob
    let blob = file.slice(sendedBytes, sendedBytes + ONE_MB);
    fileReader.readAsArrayBuffer(blob);
  }
};
let blob = file.slice(O, ONE_MB);
console.log(blob instanceof Blob); //true
fileReader.readAsArrayBuffer(blob);

这段代码把一个文件分割成 1MB 的 blob 片段依次上传,如果上传一半突然断了的话下一次再重新上传的时候,服务端会告知上一次已经接收的数据量,我们可以根据后端告知的这些字节数去换算一下需要从哪个 blob 片段开始上传。这样就实现断点续传了。还有一个问题就是怎么知道用户又选了同一个文件呢?或者怎么知道这个文件有没有被它修改过了?可以通过计算文件内容的哈希值作为一个文件的标志。