揭秘前端文件上传原理(一)

在平时工作中,总是会接触过很多文件上传的功能,因为用惯了各种操作库来处理,所以总有一种云里雾里的感觉,没有清晰的思路,归根到底还是没有理解文件上传的原理。接下来将揭起工具库的面纱,看看文件上传到底是怎么一回事,深入了解文件上传的本质。

先解释一下文件上传这个过程是怎么一回事。因为浏览器本身的限制,浏览器是不能直接操作文件系统的,需要通过浏览器所暴露出来的统一接口,由用户主动授权发起来访问文件动作,然后读取文件内容进指定内存里,最后执行提交请求操作,将内存里的文件内容数据上传到服务端,最后服务端解析前端传来的数据信息后存入文件里。

最简单的上传实现

这里利用form表单标签和类型为file的Input标签来完成上传,要将表单数据编码格式置为 multipart/form-data 类型,这个编码类型会对文件内容在上传时进行处理,以便服务端处理程序解析文件类型与内容,完成上传操作。

<form method="POST" enctype="multipart/form-data">
  <input type="file" name="file" value="请选择文件"><br />
  <input type="submit">
</form>

如果单单对于前端来讲,上面的代码就够了,但是为了了解上传的本质,这里从全栈的角度来看看文件上传。先以 Nodejs作为服务端,提供一个上传接口给前端,来看看上面的前端代码与后端是怎么传递文件数据的。

...
//上传接口逻辑
if(url ==='/upload' && method === 'POST') {
  // 定义一个缓存区
  const arr = []
  req.on('data', buffer => {
    // 将前端传来的数据进行存储进缓存区
    arr.push(buffer);
  })
  
  req.on('end', () => {
    // 前端请求结束后进行数据解析 处理
    const buffer = Buffer.concat(arr);
    // 将数据变成string类型
    const content = buffer.toString();
    // 从传来的数存进test的文件里
    fileStream('test').write(buffer);
    // 返回前端请求完成
    res.writeHead(200, {  'Content-Type': 'text/html; charset=utf-8' });
    res.end('上传完成');
  })
}
...

这里的服务端代码先将前端上传的数据内容毫不处理直接写入一个名为test的文件内,以便我们查看前端到底传来了什么样的数据。

在前端发起一次上传操作请求,获取部分请求头信息。

再来看看从前端传来的被上传到服务端的文件数据。

------WebKitFormBoundary7YGEQ1Wf4VuKd0cE
Content-Disposition: form-data; name="file"; filename="index.html"
Content-Type: text/html

<html>
  <head>
    <title>上传文件</title>
  </head>
  <body>
    <form method="POST" enctype="multipart/form-data">
      <input type="file" name="file" value="请选择文件"><br />
      <input type="submit">
    </form>
  </body>
</html>
------WebKitFormBoundary7YGEQ1Wf4VuKd0cE--

从上面被上传到服务端的数据可以看出相比于客户端本地的文件中多了几行内容,先是第一行和最后一行的WebKitFormBoundary 码,第二行的ContentDisposition,该行包含一些文件基本信息,还有第三行文件内容类型,所以后端如果要获取到正确的文件内容则需要自己去除由浏览器在上传时所添加的进来的几行内容,而保留有效文件内容后进行写文件操作,完成上传目的。

从上面的最简单的实现中可以看出以下几个点 。

  1. 前端文件上传实际是文件内容的传递,是数据的传递,并非我们最常使用的文件拷贝与复制操作。
  2. 传递过程中要进行编码来制定发送的文件数据规则,以便于后端能够实现一套对应的解析规则。
  3. 传递的数据规则里包含所传递文件的基本信息 ,如文件名与文件类型,以便后端写出正确格式的文件。

上面的代码可以在这里查看,有兴趣的同学可自行调试。

https://github.com/FantasyGao/Practice-book/tree/master/upload/upload1

最常用的上传实现

上面利用了form表单的能力来上传本地文件,但是由于form表单提交操作网页会造成整体刷新,所以一般比较少用,而是利用熟悉的异步请求操作AJAX来完成上传动作,而一个新的问题出现了,不使用form表单,那文件编码该怎么处理呢?接下来看看下面的代码。

<div>
  <input id="file" type="file" />
  <input type="button" value="文件上传" onclick="uploadFile()" />
</div>
<script>
function uploadFile() {
  const file = document.getElementById('file').files[0];
  const xhr = new XMLHttpRequest();
  xhr.open('POST', 'http://127.0.0.1:8000/upload', true);
  xhr.onreadystatechange = function() {
    if (xhr.readyState == 4 && xhr.status == 200) {
      alert(xhr.responseText);
    }
  };
  xhr.send(file);
}
</script>

上面的相比于Form表单的提交,使用了浏览器的XMLHttpRequest自定义的提交方式,也就是俗称的AJAX技术。但是使用这种提交方式没有设置编码 enctype="multipart/form-data" 类型,如果直接将文件内容上传,会导致后端在解析Form表单上传的文件时与Ajax上传的不一致,所以为了后端能够使用相同的代码就能解析前端这两种提交方式,所以前端需要自行格式化文件内容。在格式化的过程中,则需要通过浏览器自身提供的FormData构造函数来实例化的一个文件fd,然后使用实例的append方法将文件内容插入进去,最后利用XMLHttpRequest的实例做出发送动作。所以最终上传部分应为如下代码:

function uploadFile() {
  const file = document.getElementById('file').files[0];
  const xhr = new XMLHttpRequest();
  const fd = new FormData();
  fd.append('file', file);
  xhr.open('POST', 'http://127.0.0.1:8000/upload', true);
  xhr.onreadystatechange = function() {
    if (xhr.readyState == 4 && xhr.status == 200) {
      alert(xhr.responseText);
    }
  };
  xhr.send(fd);
}

从前端发起一次请求观察请求信息,可以看出已经成功的变成了FormData的编码类型。

在后端也收到的数据和上面Form表单一样的内容,为了能够真正的体验上传过程,接下来我们在服务端做一个解析器来解出正在的文件内容,并且写进文件里,完成上传目标。

先看看上传的文件内容,它的特点前面已经描述过了,其实在真正的文件内容外多了几行文件信息,所以我们解析器的目的就是去掉这几行内容,并且在这几行简要信息里摘出文件名,以便写文件。

实现思路:将前端传来的文件按行分成数组,数组的第一个第二第三个和最后一个元素删除,并且在第二个元素里匹配出文件名。代码如下:

/**
 * @step1 过滤第一行
 * @step2 过滤最后一行
 * @step3 过滤最先出现Content-Disposition的一行
 * @step4 过滤最先出现Content-Type:的一行
 */
const decodeContent = content => {
  let lines = content.split('\n');
  const findFlagNo = (arr, flag) => arr.findIndex(o => o.includes(flag));
  // 查找 ----- Content-Disposition Content-Type 位置并且删除
  const startNo = findFlagNo(lines, '------');
  lines.splice(startNo, 1);
  const ContentDispositionNo = findFlagNo(lines, 'Content-Disposition');
  lines.splice(ContentDispositionNo, 1);
  const ContentTypeNo = findFlagNo(lines, 'Content-Type');
  lines.splice(ContentTypeNo, 1);
  // 最后的 ----- 要在数组末往前找
  const endNo = lines.length - findFlagNo(lines.reverse(), '------') - 1;
  // 先反转回来
  lines.reverse().splice(endNo, 1);
  return Buffer.from(lines.join('\n'));
}

一个简单的解析器完成了,一般情况下你所使用的框架会解决解码这一部分问题,无论是Nodejs或是Java,他们的本质都是摘出有效的文件内容然后写进新文件里,从而达到文件上传的目的。

最终的服务端代码如下:

if(url ==='/upload' && method === 'POST') {
    //文件类型
    const arr = []
    req.on('data', (buffer) => {
      arr.push(buffer);
    })
    req.on('end', () => {
      const buffer = Buffer.concat(arr);
      const content = buffer.toString();
      const result = decodeContent(content);
      const fileName = content.match(/(?<=filename=").*?(?=")/)[0];
      fileStream(fileName).write(result);
      res.writeHead(200, {  'Content-Type': 'text/html; charset=utf-8' });
      res.end('上传完成')
    })
  }

通过上面的代码,便可以完成一个以FormData类型提交的文件上传的操作了,代码在下面欢迎下载体验。

https://github.com/FantasyGao/Practice-book/tree/master/upload/upload2

上面先从最基础的角度去实现了文件上传,然后从最常用的角度了解了文件上传以及后端对于前端上传的Form表单类型的文件所解析要经过的步骤,那除了以Form表单类型,前端还有没有其他方式上传呢?当然是有的,如现在比较常用的Blob数据方式,它又是怎么做的呢?还有在需要上传的文件体积太大的时候,需要将内容切片成一个一个小块的来上传又是怎么实现呢?下一节将来梳理和讲解这些内容,敬请期待。


如上内容均为自己总结,难免会有错误或者认识偏差,如有问题,希望大家留言指正,以免误人,若有什么问题请留言,会尽力回答之。如果对你有帮助不要忘了分享给你的朋友或者点击右下方的“在看”哦!也可以关注作者,查看历史文章并且关注最新动态,助你早日成为一名全栈工程师!

原文发布于微信公众号 - 全栈者(fullStackEngineer)

原文发表时间:2019-09-06

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券