Bootstrap

Web文件上传总结


文件上传是 Web 开发常见需求,上传文件需要用到文件输入框。

指定文件类型

  • 一个以英文句号(“.”)开头的合法的不区分大小写的文件名扩展名。例如:.jpg.pdf.doc

  • 一个不带扩展名的 MIME 类型字符串。

  • 字符串 audio/*,表示“任何音频文件”。

  • 字符串 video/*,表示“任何视频文件”。

  • 字符串 image/*,表示“任何图片文件”。

accept 属性的值是包含一个或多个(用逗号分隔)唯一文件类型说明符的字符串。例如,一个文件选择器需要能被表示成一张图片的内容,包括标准的图片格式和 PDF 文件,大概是这样的:

<input type="file" accept="image/*,.pdf" />

可以用 accept 属性指定可接受的文件类型,它是一个以逗号间隔的文件扩展名和 MIME 类型列表。一些例子如下所示:

  • accept=“image/png” 或 accept=“.png”——接受 PNG 文件。
  • accept=“image/png, image/jpeg” 或 accept=“.png, .jpg, .jpeg”——接受 PNG 或 JPEG 文件。
  • accept=“image/*”——接受任何带有 image/* MIME 类型的文件。(许多移动设备也允许用户在使用它时用摄像头拍照。)
  • accept=“.doc,.docx,.xml,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document”——接受类似于 MS Word 文档的任何文件。

capture 属性是一个字符串,如果 accept 属性指出了 input 是图片或者视频类型,则它指定了使用哪个摄像头去获取这些数据。值 user 表示应该使用前置摄像头和(或)麦克风。值 environment 表示应该使用后置摄像头和(或)麦克风。如果缺少此属性,则用户代理可以自由决定做什么。如果请求的前置模式不可用,则用户代理可能退回到其首选的默认模式。

不支持的浏览器会自动忽略这些属性

在实际开发中,安卓与苹果表现不一致,更多使用APP封装的调用方法,类似微信js-sdk方案。

<input type="file" accept="image/*" capture="camera" /> //只调用相机
<input type="file" accept="video/*" capture="camcorder" /> //只调用摄像机
<input type="file" accept="audio/*" capture="microphone" /> //只调用录音设备,苹果表现依然为调用摄像机

<input type="file" accept="image/*" /> //调用相机与文件相册
<input type="file" accept="video/*" /> //调用摄像机与文件视频
<input type="file" accept="audio/*" /> //调用录音设备与文件录音

多文件选择

如果给文件输入框添加一个 multiple 属性则可以一次选择多个文件

<input type="file" multiple />

自定义样式

通过 click() 方法使用隐藏的 file input 元素

  1. 你可以通过给 input 元素添加 display:none 的样式,
  2. 再调用 <input> 元素的 click() 方法来实现,
  3. 同时监听 file <input> 元素的change事件,获取上传的文件信息。
<!DOCTYPE html>
<html lang="Zh-CN">
    <head>
        <style type="text/css">
            .file-choose {
                color: white;
                background: orange;
                width: 200px;
                height: 30px;
                text-align: center;
                line-height: 30px;
                border-radius: 5px;
                font-size: 14px;
                cursor: pointer;
            }
        </style>
    </head>
    <body>
        <input type="file" id="inputFile" style="display:none;" />
        <div class="file-choose">选择文件</div>
    </body>
    <script>
        const inputFile  = document.querySelector('#inputFile'),
        clickArea = document.querySelector('.file-choose')

        clickArea.addEventListener('click',() => {
            if(inputFile) inputFile.click()
        })

        inputFile.addEventListener('change',(e) => {
            console.log(e.target.files) // FileList {0: File, length: 1}
        }, false)

        
    </script>
</html>

使用 label 元素来触发一个隐藏的 file input 元素

  1. 允许在不使用 JavaScript(click() 方法)来打开文件选择器,可以使用 <label> 元素。
  2. 注意在这种情况下,input 元素最好不使用 display: none(或 visibility: hidden)隐藏,否则 label 将无法通过键盘访问。
<!DOCTYPE html>
<html lang="Zh-CN">
    <head>
        <style type="text/css">
            .visually-hidden {
                position: absolute !important;
                height: 1px;
                width: 1px;
                overflow: hidden;
                clip-path: inset(5px);
            }
            /* Separate rule for compatibility, :focus-within is required on modern Firefox and Chrome */
            input.visually-hidden:focus + label {
                outline: thin dotted;
            }
            input.visually-hidden:focus-within + label {
                outline: thin dotted;
            }
            .file-choose {
                display: inline-block;
                color: white;
                background: orange;
                width: 200px;
                height: 30px;
                text-align: center;
                line-height: 30px;
                border-radius: 5px;
                font-size: 14px;
                cursor: pointer;
            }
        </style>
    </head>
    <body>
        <input type="file" multiple accept="image/*" id="inputFile" class="visually-hidden">
        <label for="inputFile" class="file-choose">选择文件</label>
    </body>
    <script>
        const inputFile  = document.querySelector('#inputFile')
        inputFile.addEventListener('change',(e) => {
            console.log(e.target.files) // FileList {0: File, length: 1}
        }, false)
    </script>
</html>

这里不需要添加任何 JavaScript 代码来调用fileElem.click(),另外,这时你也可以给 label 元素添加你想要的样式。您需要在其 label 上提供隐藏 input 字段的焦点状态的视觉提示,比如上面用的轮廓,或者背景颜色或边框阴影。

基本上传方式

当把文件输入框放入表单中,提交表单的时候即可将选中的文件一起提交上传到服务器,需要注意的是由于提交的表单中包含文件,因此要修改一下表单元素的 enctype 属性为 multipart/form-data

<form action="#" enctype="multipart/form-data" method="post">
  <input name="file" type="file" />
  <input name="name" />
  <button type="submit">上传</button>
</form>

这是传统的上传方式,且无法自定义请求header中的参数(如token信息),目前基本不会采用这种方式进行,仅做了解。

访问文件

File API 提供了访问文件的能力,files 属性访问,这会得到一个 FileList,这是一个集合,如果只选择了一个文件,那么集合中的第一个元素就是这个文件

传统的 DOM 选择器访问一个已经被选择的文件

const file = document.querySelector('input[type="file"]').files[0]

console.log(file.name) // 文件名称
console.log(file.size) // 文件大小
console.log(file.type) // 文件类型

通过 change 事件访问被选择的文件

<input type="file" onchange="handleFiles(this.files)" />

function handleFiles (files) {
    console.log(files)
}

动态添加change监听器

你需要使用 EventTarget.addEventListener() 去添加 change 事件监听器,像这样:

const inputElement = document.getElementById("input");
inputElement.addEventListener("change", handleFiles, false);
function handleFiles() {
  const fileList = this.files;
}

注意在这个例子里,handleFiles() 方法本身是一个事件处理器,不像之前的例子中,它被事件处理器调用然后传递给它一个参数。

Ajax 上传

由于可以通过 File API 直接访问文件内容,再结合 XMLHttpRequest 对象直接将文件上传,将其作为参数传给 XMLHttpRequest 对象的 send 方法即可。

const xhr = new XMLHttpRequest()
xhr.open('POST', '/upload/url', true)
xhr.send(file)

不过一些原因不建议直接这样传递文件,而是使用 FormData 对象来包装需要上传的文件,FormData 是一个构造函数,使用的时候先 new 一个实例,然后通过实例的 append 方法向其中添加数据,直接把需要上传的文件添加进去

const formData = new FormData()
formData.append('file', file, file.name) // 第 3 个参数是文件名称
formData.append('username', 'Mary') // 还可以添加额外的参数

数据准备好后,就是上传了,同样是作为参数传给 XMLHttpRequest 对象的 send 方法

<input type="file" id="fileInput" />
<button onclick="submit()">提交</button>
function submit() {
  // 表单数据
  const formData = new FormData()
  const file = document.querySelector('#fileInput').files[0]
  formData.append('file', file, file.name) // 第 3 个参数是文件名称
  formData.append('appKey', 'xxx')
  formData.append('appSecret', 'xxx')

  const xhr = new XMLHttpRequest()

  // 上传成功响应
  function uploadComplete(evt) {
    const data = JSON.parse(evt.target.responseText);
    if (data.code === '200') {
      console.log('上传成功!');
    } else {
      console.log('上传失败!');
    }
  }
  // 上传失败
  function uploadFailed() {
    console.log('上传失败!');
  }
  // 取消上传
  function cancelUploadFile() {
    xhr.abort();
    console.log('取消上传')
  }

  xhr.open('POST', 'http://www.xxx.com/url', true) // true 该参数规定请求是否异步处理。
  xhr.onload = uploadComplete; // 请求完成
  xhr.onerror = uploadFailed; // 请求失败

  xhr.send(formData) // 开始上传,发送form数据

  setTimeout(cancelUploadFile, 5000); // 5秒后,模拟取消上传操作
}

监测上传进度

XMLHttpRequest 对象还提供了一个 progress 事件,基于这个事件可以知道上传进度如何

const xhr = new XMLHttpRequest()
xhr.open('POST', '/upload/url', true)
xhr.upload.onprogress = progressHandler // 这个函数接下来定义

上传的 progress 事件由 xhr.upload 对象触发,在事件处理程序中使用这个事件对象的 loaded(已上传字节数) 和 total(总数) 属性来计算上传的进度

function progressHandler(e) {
  const percent = Math.round((e.loaded / e.total) * 100)
}

上面的计算会得到一个表示完成百分比的数字,不过这两个值也不一定总会有,保险一点先判断一下事件对象的 lengthComputable 属性

function progressHandler(e) {
  if (e.lengthComputable) {
    const percent = Math.round((e.loaded / e.total) * 100)
  }
}

上传进度详细实现

<input type="file" id="fileInput" />
<div>
  <progress id="progressBar" value="0" max="100" style="width: 300px;"></progress>
  <span id="percentage"></span>
  <br/>
  <span id="speed"></span>
  <span id="remainTime"></span>
</div>
function submit() {
  let oldTime = 0 // 上一次时间
  let oldLoaded = 0 // 上一次加载数据字节大小

  const progressBarDom = document.querySelector('#progressBar')
  const percentageDom = document.querySelector('#percentage')
  const speedDom = document.querySelector('#speed')
  const remainTimeDom = document.querySelector('#remainTime')

  function onprogress(evt) {
    const nowTime = new Date().getTime() // 当前时间
    const perTime = (nowTime - oldTime) / 1000 // 函数调用间隔时间,单位为s
    oldTime = nowTime // 重新赋值,待下次计算

    // event.total是需要传输的总字节,event.loaded是已经传输的字节。如果event.lengthComputable不为真,则event.total等于0
    const perLoad = evt.loaded - oldLoaded // 函数调用间隔时间,新载入的字节大小
    oldLoaded = evt.loaded // 重新赋值,待下次计算

    let speed = perLoad / perTime // 原始速率,b/s
    const originSpeed = speed // 不参与后续速率计算,b/s
    let unit = 'b/s' // 速率单位
    if (speed / 1024 > 1) {
      speed /= 1024
      unit = 'k/s'
    }
    if (speed / 1024 > 1) {
      speed /= 1024
      unit = 'M/s'
    }

    if (evt.lengthComputable) {
      const loadPercent = (evt.loaded / evt.total) * 100
      progressBarDom.value = Math.round(loadPercent) // 进度条百分比
      percentageDom.innerText = `${(loadPercent).toFixed(1)}%` // 进度显示百分比
      speedDom.innerText = speed.toFixed(1) + unit // 上传速率
      remainTimeDom.innerText = `,还剩${((evt.total - evt.loaded) / originSpeed).toFixed()}` // 剩余时间
      if (originSpeed === 0) remainTimeDom.innerHTML = '上传已取消'
    }
  }

  const formData = new FormData()
  formData.append('appKey', 'xxx')
  formData.append('appSecret', 'xxx')
  formData.append('file', document.querySelector('#fileInput').files[0])

  const xhr = new XMLHttpRequest()
  xhr.open('post', 'http://wwww.xxx.com/url', true)
  xhr.upload.onprogress = onprogress
  xhr.send(formData)
}

分割上传

使用文件对象的 slice 方法可以分割文件,给该方法传递两个参数,一个起始位置和一个结束位置,这会返回一个新的 Blob 对象,包含原文件从起始位置到结束位置的那一部分(文件 File 对象其实也是 Blob 对象,这可以通过 new File(['name'], 'testFile') instanceof Blob 确定,BlobFile 的父类)

const blob = file.slice(0, 1024 * 1024) // 文件从字节位置 0 到字节位置 1024*1024 即 1M

将文件分割成几个 Blob 对象分别上传就能实现将大文件分割上传

function upload(file) {
  let formData = new FormData()
  formData.append('file', file)
  let xhr = new XMLHttpRequest()
  xhr.open('POST', '/upload/url', true)
  xhr.send(formData)
}

let pos = 0 // 起始位置
const size = 1024*1024 // 块的大小,即1M

// 通常用一个循环来处理更方便
while (pos < file.size) {
  let blob = file.slice(pos, pos + size) // 结束位置 = 起始位置 + 块大小

  upload(blob)
  pos += size // 下次从结束位置开始继续分割
}

服务器接收到分块文件进行重新组装的代码就不在这里展示了

使用这种方式上传文件会一次性发送多个 HTTP 请求,那么如何处理这种多个请求同时发送的情况呢?方法有很多,可以用 Promise 来处理,让每次上传都返回一个 promise 对象,然后用 Promise.all 方法来合并处理,Promise.all 方法接受一个数组作为参数,因此将每次上传返回的 promise 对象放在一个数组中

var promises = []

while (pos < file.size) {
  let blob = file.slice(pos, pos + size)

  promises.push(upload(blob)) // upload 应该返回一个 promise
  pos += size
}

同时改造一下 upload 函数使其返回一个 promise

function upload(file) {
  return new Promise((resolve, reject) => {
    let formData = new FormData()
    formData.append('file', file)
    let xhr = new XMLHttpRequest()
    xhr.open('POST', 'http://www.xxx.com/url', true)
    xhr.onload = () => resolve(xhr.responseText)
    xhr.onerror = () => reject(xhr.statusText)
    xhr.send(formData)
  })
}

当一切完成后

Promise.all(promises)
  .then((response) => {
    console.log('Upload success!')
  })
  .catch((err) => {
    console.log(err)
  })

经实际测试,将大文件分割上传,可极大缩短总上传时间

拖拽上传

你还可以让用户将文件拖拽到你的网页应用中。

第一步是创建一个drop区域。虽然你网页内容的哪部分接受拖放取决于你的应用设计,但是使一个元素接收drop事件是很容易的。

const dropbox = document.getElementById("dropbox");
dropbox.addEventListener("dragenter", dragenter, false);
dropbox.addEventListener("dragover", dragover, false);
dropbox.addEventListener("drop", drop, false);

在这个例子中,我们将id为dropbox的元素变为了我们的drop区域。这是通过给元素添加dragenter dragover, 和drop 事件监听器实现的。

我们其实并不需要对dragenter and dragover 事件进行处理,所以这些函数都很简单。他们只需要包括禁止事件传播和阻止默认事件:

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

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

真正的奥妙在drop()这个函数中:

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

  var dt = e.dataTransfer;
  var files = dt.files;

  handleFiles(files);
}

这里,我们从事件中获取到了dataTransfer 这个域,然后从中得到文件列表,再将它们传递给handleFiles()函数。在这之后,处理文件的方法与之前一致。

;