浏览器原生能力系列 - 上传文件类型检测

突然想写一些关于浏览器原生能力的文章,这些能力不但能减少代码冗余,从可靠性、扩展性、功能性上都要比自己定义的方法更加强大。

前端在上传文件时,往往会要求指定上传的文件类型,可能是图片、可能是视频、可能是一个 Word 文件,依靠文件扩展名只能进行简单校验,但是如果碰上文件没有扩展名,或者扩展名与文件的实际类型不一致的情况,后果可能是后期服务器端处理时发生问题。

所以需要一个更加靠谱的方案,不是依靠文件扩展名,而是实际的文件内容进行类型判断。

Linux/Mac/Unix 中检查文件类型的方法 - 没兴趣的同学可以略过此章节。

通过扩展名进行类型判断准确而言应该是从 Windows 操作系统的传统,但类 Unix 操作系统不依赖文件扩展名的特性(很多文件根本没有扩展名,最典型的就是 /bin 下面的可执行程序),所以它们最需要文件类型推断功能。

提供此功能的命令叫做 file,它已经被包含在大多数 Unix 系统中,基本的使用方法是直接使用 file [文件名] 即可显示出它的文件类型,例如:

$ file logo.png
logo.png: PNG image data, 556 x 36, 8-bit/color RGBA, non-interlaced
$ cp logo.png logo-real-png.gif # 复制一下改一下扩展名
$ file logo-real-png.gif # 继续探测,可以发现检测出的文件类型不变,
logo-2.png: PNG image data, 556 x 36, 8-bit/color RGBA, non-interlaced
$ file test.txt
test.txt: UTF-8 Unicode (with BOM) text

file 就是传说中不依靠扩展名直接对文件内容进行推断的命令,基本原理是文件内容中的特征码进行检测,具体方法后面再说。

文件类型的标准 - Mime Types

在前端开发中应该能接触到 Mime Types 这个概念,它是文件类型的一个标准,通过将文件进行分组归类的方式,可以很轻松地识别该文件属于什么类型、具体是什么格式,很常见的有 text/html - HTML 文本、text/plain - 纯文本、image/jpeg - JPEG 图片、image/png - PNG 图片等等,text 就是文本类型分类,而 image 是图片类型分类,后面的就是具体格式。

具体的请参考 mime.types - Wikipedia 在此对它的概念不多说。

同样的,file 命令也能够输出 Mime Type,参数是 --mime,依然不依靠扩展名,而直接检查文件内容,在输出 Mime Type 时还会把字符集显示出来。

$ file --mime logo.png 
logo.png: image/png; charset=binary
$ file --mime logo-real-png.gif
logo.gif: image/png; charset=binary
$ file test.txt
test.txt: text/plain; charset=utf-8

完整的 Mime Types 类型如果是 Linux 系统可以去 /etc/mime.types 下找到。

通过 Javascript 简单地获取 Mime Type

做前端开发时,很有可能会通过限制扩展名来进行可上传文件约束,这可以通过 input tag 的 accept attribute 进行限制,但在某些情况下可能无需限制,但需要知道上传文件类型,常规做法是做一个扩展名的类型分类,根据扩展名进行分组,但其实绕了弯路。

Web 引擎早已经提供了基于 Mime Types 的类型了,范例代码非常简单。

var input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.addEventListener('change', evt => [...evt.currentTarget.files].forEach(f => console.log(`${f.name} type is ${f.type}`)));

关键就在于 input.files 这个 FileList 对象中的每个 file,它是一个 File 对象,会有一个 type property,记录了该文件的 Mime Type,这个范例代码可以一次选中多个文件,多个文件的 Mime Type 都能直接输出,这是几个测试文件后的输出结果,依然有刚才被重命名的 gif 实际为 png 的图片:

> input.click()
logo.gif type is image/gif
logo-real-png.gif type is image/gif
logo.png type is image/png

** 但是,这里有个问题 - 浏览器的文件类型推断依然是基于扩展名的,并不能做到重命名扩展名后,对文件内容的推断,logo-real-png.gif 本来应该是 png 但是却识别成了 gif。**

我查了一下,未来浏览器会实现这个功能的,但是这个标准名为 《MIME Sniffing》,今年3月才刚刚更新,各大浏览器还不能支持。

所以需要手工来进行检测了。

文件特征码

以前了解过杀毒软件的,可能会知道计算机病毒都有特征编码,这些编码标志着该段代码可能会干点奇奇怪怪的事情,让你的电脑不正常。

同样的,每一种文件类型也都有自己的编码,比较好识别的是文本类型的文件,只要所有字符集都在 Unicode/Ascii 编码范围之内,肯定就是文本类型了,如果有兴趣打开一个 zip 包,或者 Java 编译后的 jar 文件,可以看到开头一定是一个 PK,PK 是 Phil Katz 的名字缩写,那是另一个有趣的故事了。

A zip

但是,并不是每个 PK 开头的都是 zip 包,后面还会有一些其它二进制编码来告诉计算机,它是某种类型的文件。

只需要知道,每种文件类型,都会有不同的特征编码,那就够了。

通过 Javascript 来实现基于文件内容的类型检测

这里需要用到 FileReader 方法,它可以直接通过浏览器中将文件以 ArrayBuffer 形式读入内存,通过 Javascript 进行处理。

先贴完整代码:

var input = document.createElement('input');
input.type = 'file';
input.multiple = true;

function detectFileType(file) {
  // Fallback when browser was not supported FileReader.
  if (window.FileReader === void 0) {
    return console.log(`${file.name} type is: ${type}`);
  }

  // Start detecting
  var fileReader = new FileReader();
  fileReader.onload = function(e) {
    var arr = (new Uint8Array(e.target.result)).subarray(0, 4);
    var header = "";
    for(var i = 0; i < arr.length; i++) {
      header += arr[i].toString(16);
    }
    // Check the file signature against known types
    switch (header) {
      case "89504e47": {
        type = "image/png";
        break;
      }
      case "47494638": {
        type = "image/gif";
        break;
      }
      case "ffd8ffe0":
      case "ffd8ffe1":
      case "ffd8ffe2": {
        type = "image/jpeg";
        break;
      }
      default: {
        type = "unknown"; // Or you can use the blob.type as fallback
        return console.error(`Unkonwn ${file.name} type header: ${header}`);
      }
    }
    console.log(`${file.name} type is: ${type}`);
  };
  fileReader.readAsArrayBuffer(file);
}

input.addEventListener('change', evt => [...evt.currentTarget.files].forEach(detectFileType));

执行结果如下,这一下文件类型都被正确检测出来了:

> input.click()
logo.gif type is: image/gif
logo-real-png.gif type is: image/png
logo.png type is: image/png

解释一下:

FileReader 的 onload 方法可以在文件加载完成之后进行一些处理,同样的它是异步的,可能随着文件大小不一致执行顺序有所差异。

文件加载,首先将文件通过 Uint8Array 转换一些数组类型,然后通过 subarray 截取前 4 个 item,即为文件头,然后通过 toString(16) 转换为 16 进制的字符串。

这段代码只能检测 jpeg、gif 和 png 三种图片类型,除此以外的都进入 unkown 类型分支,因为图片类型的特征码都在文件头部前几个字节就能获取具体文件类型,相对比较好打比方,对于更加复杂的情况或者实现 file 的能力,这是远远不够的。

万能的 npm

npm 上有一个比较复杂的文件类型检测实现 file-type 可以参考一下,它通过文件的前 4100 个字节来进行类型判断,可以判断出的文件类型种类也更加丰富。

版权所有丨转载请注明出处:https://kxq.io/archives/浏览器原生能力系列-上传文件类型检测