文件上传流程详解

285 阅读11分钟

平常业务开发中遇到一些文件上传相关需求时,要么就是使用封装好的Upload组件,要么就是写一个定式的代码,这也导致自己好像也从来没有真正了解过文件上传到底是怎样一个流程,以及有些地方为什么要那么处理。例如下面的代码,算是文件上传最基本的代码了,但是要问到为什么 enctype 要设置为 multipart/form-data时,我哑口无言!所以,通过这篇文章,去探讨文件上传的具体流程以及其中的一些细节。

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

一、文件选择

在上传文件之前,首先我们要选择待上传的文件。对于 Web 端而言,最常用的方式就是通过 <input type = 'file'>打开操作系统的文件资源管理器来选择文件。除此之外,我们还可以通过拖拽或者粘贴的方式来指定。

1.1 <input type = 'file'>

带有 type="file"  的 <input>元素允许用户可以从他们的设备中选择一个或多个文件。

file 类型的 input 支持下列特有属性:

  • accept :定义了文件 input 应该接受的文件类型。这个字符串是一个以逗号为分隔的唯一文件类型说明符列表。
  • capture :如果 accept 属性指出了 input 是图片或者视频类型,则它指定了使用哪个摄像头去获取这些数据
  • multiple:指定是否允许用户选择多个文件。

获取文件列表

对于通过文件 input 选择的文件,我们可以通过该元素的 files 属性获取。

<input id='file-input' input='file' />

<script>
    const fileInput = document.getElementById('file-input');
    // 文件列表
    const files = fileInput.files;
    ...
</script>

此外,我们也可以通过监听该元素的 onchange 事件来获取(这里以React为例):

const Upload: React.FC<Props> = (props) => {
  const [fileList, setFileList] = useState<FileList | null>();

  const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
    setFileList(e.target.files)
  };

  return (
      <input type='file' onChange={handleChange}/>
  );
};

文件 input 元素的 files 属性是一个FileList 对象,其中每个文件都被指定为一个 File 对象,包含以下几个属性:

  • name:文件名称,只读字符串。只包含文件名,不包含任何路径信息。
  • size:以字节数为单位的文件大小,只读的 64 位整数。
  • type:文件的 MIME 类型,只读字符串,当类型不能确定时为 ""
  • File.lastModified (只读):文件的最后修改时间,以 UNIX 纪元(1970 年 1 月 1 日午夜)以来的毫秒为单位。
  • File.lastModifiedDate (已弃用):File 对象引用的文件的最后修改时间的 Date
  • File.webkitRelativePath(只读):File 对象相对于 URL 的路径。

自定义样式

对于 Web 端而言,浏览器出于安全考虑,不允许网页直接访问或操作用户的文件系统。所以目前打开操作系统文件资源管理器的唯一方式就是通过 <input type="file">
但是,文件 input 元素的默认样式公认很难看,而且无法通过 CSS 去修改。对此,我们可以通过如下两种方式去自定义我们的文件选择器。

  • 隐藏的文件 input 元素,通过调用其 click() 方法打开文件资源管理器

如下代码,我们通过设置 display: none 隐藏的文件 input 元素,然后监听 button 元素的 click 事件,在点击 button 时调用 input 元素的 click() 方法。这样我们就可以给文件选择器指定我们想要的样式了。

<input id='file-input' input='file' style='display: none' />
<button id='file-select'> 选择文件<button>

<script>
    const fileInput = document.getElementById('file-input');
    const fileSelect = document.getElementById('file-select');
    
    fileSelect.addEventListener(
        'click', 
        (e) => {fileElem?.click();}, 
        false
    );
</script>
  • 使用 label 元素来触发一个隐藏的文件 input 元素

如果你不想使用 javascript 来调用文件 input 元素的 click() 方法,可以使用 <label> 元素,但在这种情况下,input 元素不能用 display: none 或 visibility: hidden来隐藏,否则标签就不具有键盘无障碍性。具体实现如下:

<input type="file" id="fileElem" class="visually-hidden" />
<label for="fileElem">选择一些文件</label>
.visually-hidden {
  position: absolute !important;
  height: 1px;
  width: 1px;
  overflow: hidden;
  clip: rect(1px, 1px, 1px, 1px);
}

input.visually-hidden:is(:focus, :focus-within) + label {
  outline: thin dotted;
}

注意:目前已经有 showOpenFilePicker API 可以选择文件,但是还处于实验性阶段。后面 <input type="file"> 可能就不是打开文件资源管理器的唯一方式了。

1.2 拖拽(Dragevent)

如上所述,文件 input 元素的 files 属性是一个FileList 对象,该对象还用于表示拖放 API 中放入 Web 内容中的文件列表。也就是说,我们也可以通过将文件拖放到 Web 内容中来选择相关文件。

拖放事件(DragEvent) 的 dataTransfer 属性是一个DataTransfer 对象,用于存放拖放交互期间传输的数据。我们可以通过 DataTransfer 对象的 files 属性来获取数据传输中可用的所有本地文件的列表。

具体实现如下代码所示:

const Upload: React.FC<Props> = (props) => {

  const [fileList, setFileList] = useState<FileList | null>();

  // 阻止默认行为
  // 文件托放到浏览器默认会打开该文件,所以要阻止默认行为
  const preventDefault = (e: any) => {
    e.preventDefault();
    e.stopPropagation();
  };

  // 处理拖拽进入
  const handleDragEnter: React.DragEventHandler = (e) => {
    preventDefault(e);
  };

  // 处理拖拽放置
  const handleDragOver: React.DragEventHandler = (e) => {
    preventDefault(e);
  };

  // 处理拖拽离开
  const handleDragLeave: React.DragEventHandler = (e) => {
    preventDefault(e);
  };

  // 处理放置
  const handleDrop: React.DragEventHandler = (e) => {
    preventDefault(e);
    // 获取文件列表
    const files = e.dataTransfer.files;
    setFileList(files);
  };

  return (
    <div className={prefixClass}>
      <div 
        className='upload-block'
        onDragEnter={handleDragEnter}
        onDragOver={handleDragOver}
        onDragLeave={handleDragLeave}
        onDrop={handleDrop}
      >
        拖拽文件到此处上传
      </div>
    </div>
  );
};

image.png

此外,我们可以通过 DataTransfer 对象的 items 属性来获取文件。 items属性是一个DataTransferItemList 对象,包含了表示拖动操作中被拖动项的DataTransferItem对象,每个DataTransferItem包含以下属性和方法

例如,我们可以通过如下代码来获取第一个数据项的文件:

// 处理放置
const handleDrop: React.DragEventHandler = (e) => {
    preventDefault(e);
    const file = e.dataTransfer.items[0].getAsFile();
    ...
};

1.3 粘贴(paste)

除了 1.2 中 DragEventdataTransfer 属性之外,ClipboardEventclipboardData也是一个 DataTransfer 对象。记录了由 paste 事件处理器拷贝进剪切板的数据。

我们平常说的文件粘贴上传,也就是通过监听 paste 事件,然后通过 clipboardData 属性获取拷贝进剪切板的文件的。具体实现如下:

// 监听粘贴事件
useEffect(() => {
    // 处理粘贴上传
    const handlePaste= (e: any) => {
      const files = e.clipboardData.files;
      setFileList(files);
    };

    document.addEventListener('paste', handlePaste);
    return () => {
      document.removeEventListener('paste', handlePaste);
    };
}, []);

1.4 小结

总的来说,选择文件主要有以下三种方式:

  1. <input type='file'>:通过文件 input 元素的 files 属性访问文件列表
  2. 拖拽事件(DragEvent):通过 DragEvent.dataTransfer.files 属性访问文件列表
  3. 粘贴事件(ClipboardEvent):通过ClipboardEvent.clipboardData.files 属性访问文件列表。

未来可能还会有第四种方式,即通过 showOpenFilePicker API 来选择。

二、上传文件

对于上传文件而言,通常就是构建一个 FormData,并将选择的文件添加到 FormData 中,然后调用接口上传。

const uploadFile = (file: File) => {
    // 构建formData
    const formData = new FormData();
    formData.append('file',  file)
    
    // 示例用的 fetch api,也可以使用 axios/XMLHttpRequest 等请求库
    fetch('/api/upload',  {
        method: 'POST',
        body: formData
    })
    .then(() => {
        console.log('上传成功')
    })
    .catch(() => {
        console.log('上传失败')
    })
}

那为什么在上传文件时,我们都需要先构建一个 FormData,然后将文件放置其中呢?我们打开浏览器的开发者工具看下上传请求,可以发现 Headers 中的 Content-typemultipart/form-data。再回到文章开头的例子中,我们通过表单元素(form)上传文件时,也必须设置 enctype="multipart/form-data" multipart/form-data或许就是上传文件的关键所在!

企业微信截图_17369942926526.png

企业微信截图_17369943102933.png

2.1 multipart/form-data

由于在早期的 HTML 表单中,只能提交文本数据,无法直接上传文件。《RFC 1867: Form-based File Upload in HTML》 提出了一种新的表单数据编码格式 multipart/form-data,用于支持文件上传。传统的 application/x-www-form-urlencoded 只能传输键值对形式的文本数据,无法处理二进制文件。而 multipart/form-data 可以将文件数据和文本数据一起传输。

也就是说,multipart/form-data 的诞生就是为了有效传输文件。

规范格式

multipart/form-dataContent-Type 会包含一个 boundary 的参数,该参数将分隔表单数据的不同 part ,每个 part 包含一个字段的数据。基本结构如下:

--<boundary>
Content-Disposition: form-data; name="<field-name>"

<field-value>
--<boundary>
Content-Disposition: form-data; name="<field-name>"; filename="<file-name>"
Content-Type: <mime-type>

<file-content>
--<boundary>--

说明

  • <boundary> :边界字符串,用于分隔不同的部分。
  • <field-name> :表单字段的名称。
  • <field-value> :表单字段的值(文本数据)。
  • <file-name> :上传文件的名称。
  • <mime-type> :文件的 MIME 类型(如 image/png)。
  • <file-content> :文件的内容(二进制数据)。

如下,摘自《RFC 1867: Form-based File Upload in HTML》中有关 multipart/form-data 的例子:

Content-type: multipart/form-data, boundary=AaB03x

--AaB03x
content-disposition: form-data; name="field1"

Joe Blow
--AaB03x
content-disposition: form-data; name="pics"; filename="file1.txt"
Content-Type: text/plain

 ... contents of file1.txt ...
--AaB03x--

2.2 Base64

我们知道文件是二进制数据, multipart/form-data 编码格式的出现也就是为了支持这种二进制数据的传递。但除了这种方式之外,还有其他方法吗?答案是肯定的,那就是我们常说的Base64 编码。对于一些小文件的传输,我们经常会用到这种方式。

Base64 是一组二进制到文本(binary-to-text)的编码规则,让二进制数据在解释成 64 进制的表现形式后能够用 ASCII 字符串的格式表示出来。以便在只能处理 ASCII 文本的媒体上进行存储或传输。也确保了数据在传输过程中保持不变。

浏览器原生提供了两个 JavaScript 函数,用于解码和编码 Base64 字符串:

  • Window.btoa():从二进制数据字符串创建一个 Base64 编码的 ASCII 字符串(“btoa”应看作“从二进制到 ASCII”)
  • Window.atob():解码通过 Base64 编码的字符串数据(“atob”应看作“从 ASCII 到二进制”)

有关 Base64 转换的原理,我这里就不献丑了,具体可阅读base64原理

对于文件而言,我们可以直接使用 FileReader 将文件转换成 Base64 编码再进行上传,如下代码所示:

const uploadFileByBase64 = (file: File) => {
    // 将文件转换为 Base64
    const reader = new FileReader();
    reader.onload = (e) => {
      const base64 = e?.target?.result; // 获取 Base64 编码的 Data URL
      if (!base64) {
        return;
      }
      // 去掉 Data URL 前缀(如 "data:image/png;base64,")
      const base64Data = (base64 as string).split(',')[1];

      // 构造请求体
      const payload = {
        file: base64Data,
        fileName: file.name,
      };

      fetch('/api/upload/base64',  {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(payload)
      })
      .then(() => {
        console.log('上传成功')
      })
      .catch(() => {
        console.log('上传失败')
      })
    };
    reader.readAsDataURL(file);
}

但是使用 Base64 这种方式,会导致文件大小增加大于三分之一并且编码和解码需要额外的计算资源。所以一般大文件不建议这种方式。

三、文件上传进度监控

为了优化用户交互体验,上传文件过程中通常会展示上传进度条。对此,我们可以通过XMLHttpRequest 对象的 progress 事件来监控上传进度。

const uploadFile = (file: File) => {
  // 构建formData
  const formData = new FormData();
  formData.append('file',  file)

  const xhr = new XMLHttpRequest();
  xhr.open('POST', '/api/upload', true);
  
  xhr.onreadystatechange = (e: any) => {
    if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
      console.log(xhr.responseText);
    }
  };
  
  // 监控进度
  xhr.onprogress = (e) => {
    if (e.lengthComputable) {
      const percentComplete = (e.loaded / e.total) * 100;
      console.log('上传进度', `${percentComplete}%`);
    }
  }

  xhr.send(formData);
}

如果您使用的是 axios,可以通过 onUploadProgress 回调函数来监控上传进度。

axios.post('/upload', formData, {
    onUploadProgress: (progressEvent) => {
        const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
        console.log(`上传进度: ${percentCompleted}%`);
    }
});

四、服务端上传文件

前面三节介绍的都是浏览器中如何实现文件上传,下面我们来看看服务器端实现文件上传。

对于服务器端而言,其不会像浏览器中一样帮我们自动将文件转换成二进制数据,也没有原生的 FormData

对于第一个问题我们可以通过 node.js 的 文件系统来读取待上传文件,将其转换成二进制数据。对于 FormData 我们可以通过multipart/form-data的规范手动构造,也可以使用开源库 form-data来构造。

我这里就直接使用 form-data 库来实现了,就不手动构造了。如果想了解如何手动构造的,可以阅读《一文了解文件上传全过程(1.8w字深度解析,进阶必备)》

const fs = require('fs');
const path = require('path');
const http = require('http');
const FormData = require('form-data');

const formData = new FormData();
formData.append('file', fs.readFileSync(path.join(__dirname, '1.png')), {
    filename: '1.png',
    contentType: 'image/png',
});
const request = http.request({
    method: 'post',
    host: 'localhost',
    port: '3000',
    path: '/upload',
    headers: formData.getHeaders()
});
formData.pipe(request);
request.on('response', function(res: any) {
    console.log(res.statusCode);
});

或者您也可以通过 axios 来配合 form-data 实现:

const fs = require('fs');
const path = require('path');
const FormData = require('form-data');
const axios = require('axios')

const formData = new FormData();
formData.append('file', fs.readFileSync(path.join(__dirname, '1.png')), {
    filename: '1.png',
    contentType: 'image/png',
});


axios.post(
  'http://localhost:3000/upload', 
  formData,
  {
    headers: {'Content-Type':'multipart/form-data'}
  }
)
.then((response: any) => {
  console.log('响应数据:', response.data);
})
.catch((error: any) => {
  console.error('请求出错:', error.message);
});

以上就是服务器端上传文件的方式。当然,服务器端也可以将文件转换成 Base64 编码的形式来上传,我这里就不献丑了。此外,如果您还想继续了解服务器端是如何接收并解析文件的,可以继续阅读《一文了解文件上传全过程(1.8w字深度解析,进阶必备)》

本文到这里就结束啦,如有错误之处,欢迎大家评论指正!