HTML5的File API应用

长文一篇,分享我最近使用HTML5 File API的经验。

HTML5新增了很多特性,其中File API是非常重要的部分。在肉大师中,我大量使用了HTML5的文件API,这样一来可以给予用户近乎桌面软件的体验,二来还能减少服务器和带宽的消耗。今天终于把最后几个问题解决了,在这里总结下HTML5 File API的使用。

随着用的越来越多,发现自己其实搞混了“File API和FileSystem API”两个东西。而且类写的也有问题。等到有空的时候把这篇文章重写一下好了。(2012-09-13)

用途

在W3C页面上,列出了File API可能用到的场合(以下为意译,可能有所偏颇,欢迎对比原文阅读):

  1. 断点续传
    • 上传时,先把目标文件复制到本地沙箱,然后分解逐块上传
    • 浏览器崩溃或者网络中断也没关系,因为恢复后可以续传
  2. 需要大量媒体素材的应用,比如视频游戏
    • 下载压缩包,在本地解压,就能恢复之前目录结构
    • 跨平台
    • 通过渐进式下载,进入新关卡或者开启新功能均无需等待,因为玩的时候所需素材已经通过后台下载完成了
    • 从本地缓存中直接读取素材,速度飞快
    • 二进制文件也不在话下
    • 使用压缩包可以大大减轻带宽和服务器消耗,也避免了频繁下载碎片文件带来的检索问题
  3. 离线图片/音频编辑器通
    • 不怕频繁读写大量数据
    • 只想重写文件的某些部分也能做到(比如修改ID3或者EXIF信息)
    • 创建目录组织项目后用起来舒服多了
    • 编辑完的文件还能被iTunes、Picasa之类的本地应用访问
  4. 离线视频播放器
    • 下载超过1G的大文件,将来想看再看
    • 可以在不同时间点间来回跳转播放
    • 能够给Video标签提供URL
    • 即便片子还没下完,也能把下载到的部分先睹为快
    • 还能任意截取一段视频交给Video标签播放
  5. 离线邮件客户端
    • 下载保存附件到本地自不必说
    • 断网的情况下,可以缓存用户要上传的附件,以后再上传
    • 需要时可以列出缓存里的附件,通过缩略图显示,预览后上传
    • 能像正常服务器那样触发标准的下载动作
    • 不仅能使用XHR一次性上传全部内容,还可以把邮件和附件拆解成小块依次发送

听起来都是些令人振奋的功能,实际用起来还是要踩点坑。下面就把我的经验分享一下。

FileReader

很多浏览器都实现了FileReader,关于它的教程和文章很多,而且常与同样被HTML5引入的DND(Drag & Drop)API连用,以支持“上传图片文件前先预览”的功能。比如:

  1. NATIVE HTML5 DRAG AND DROP
  2. Reading local files in JavaScript
  3. HTML5 Drag and Drop Upload and File API Tutorial

所以这个主题我就不再多着笔墨去写了,值得注意的有以下几点:

  1. 只有 <input type="file" /> 和拖拽文件后可以获得 FileList,无法通过URL直接读取文件(后面要说到的本地文件可以)
  2. FileList 形似数组,可以用脚标取元素,而且有length属性,但它本身并不是数组,不支持 concatslice 等方法,要操作只能遍历,或者用 Array.prototype.slice.call()

本地文件

这里必须解释一下“本地文件”的概念。本地文件是HTML5本地存储的一部分(不特指那个 localStorage),本地存储现在分为4个等级:

  1. Cookie,最古老,100K限制,通用
  2. localStorage,新增,各浏览器的容量限制不等,采用“键-值”对应的方式存储,按域划分沙箱,不能跨域读写数据
  3. 数据库,新增,容量限制不等,标准不一
  4. 本地文件,新增,目前只有Chrome 12+支持;全体域的临时文件共享1G空间,每个域需单独请求持久化存储空间;不能跨域操作,但同域下本地文件和远程文件之间不算跨域

Windows的存储路径为:C:\Users\用户名\AppData\Local\Google\Chrome\User Data\Default\File System;
Mac的存储路径为:~/Library/Application Support/Google/Chrome/Default/File System/。
不能像对普通文件那样直接操作它们的真身,只能用JavaScrip。

这里的“本地文件”并不是我们通常意义上说的文件,没有直接存放在操作系统的文件体系中,直接搜索文件名是找不到的。可以用“filesystem:http://domain/temporary/文件名”(假设域名是domain)来访问,比如:

// 经本地环境下的js写入的文件
<img src="filesystem:file:///temporary/MG_8764.jpg" />

// 经远程环境下的js写入的文件
<img src="filesystem:http://blog.meathill.net/temporary/MG_8764.jpg" />

本地环境下,这个API同样受到限制,需要在Chrome启动时添加参数:--allow-file-access-from-files 方可正常使用。

后面几节的内容基本都是我从 EXPLORING THE FILESYSTEM APIS 和 File API: Directories and System 中学到的。目前关于本地文件API的介绍很少,应用也不多(毕竟仅限Chrome),不过看看那些令人心动的应用场景,相信不久我们就能在更多浏览器里看到它的身影了。

接下来,我们还是边看代码边进行吧。操作本地文件前需要请求空间:

// 判断是何种浏览器,使用不同的函数
window.requestFileSystem  = window.requestFileSystem || window.webkitRequestFileSystem;
// type 类型,TEMPORARY 临时,所有文件共享1G空间;或者PERSISTENT 永久,需单独请求
// size 容量,单位是字节
// 成功失败两个回调函数,后面仍会大量出现
window.requestFileSystem(type, size, successCallback, opt_errorCallback);

请求空间成功后,会调用成功的回调函数,并传入FileSystem的实例,我们可以把它存起来,以备后用。

function fileSystemReadyHandler(fs) {
  fileSystem = fs;
}

错误的回调函数写一个通用的就行了,以后几乎每次都要用到:

function errorHandler(e) {
  var msg = '';
  switch (e.code) {
    case FileError.QUOTA_EXCEEDED_ERR:
      msg = 'QUOTA_EXCEEDED_ERR&';
      break;
    case FileError.NOT_FOUND_ERR:
      msg = 'NOT_FOUND_ERR';
      break;
    case FileError.SECURITY_ERR:
      msg = 'SECURITY_ERR';
      break;
    case FileError.INVALID_MODIFICATION_ERR:
      msg = 'INVALID_MODIFICATION_ERR';
      break;
    case FileError.INVALID_STATE_ERR:
      msg = 'INVALID_STATE_ERR';
      break;
    default:
      msg = 'Unknown Error';
      break;
  };
  console.log('Error: ' + msg);
}

请求临时空间(TEMPORARY)就这么简单;请求持久化存储空间(PERSISTENT)稍微复杂些,因为临时存储是所有应用共享1G空间,而持久化存储则是按照域来单独授予空间,所以后者后必须经过用户许可才行。代码方面要在 requestFileSystem 之前,先请求空间,用户许可后方可继续:

window.webkitStorageInfo.requestQuota(PERSISTENT, 1024*1024, function(grantedBytes) {
  window.requestFileSystem(PERSISTENT, grantedBytes, fileSystemReadyHandler, errorHandler);
}, function(e) {
  console.log('Error', e);
});

Chrome会降下黄条,询问用户是否允许该域使用持久化存储,用户同意后才会请求FileSystem。

复制文件

复制文件指把文件从操作系统的文件系统复制到本地文件的文件系统中。这个操作很简单,也很有代表性,请先看代码:

this.clone = function (file) {
  // file 即通过拖拽或者<input type="file" />选择产生的File实例
  targetFile = file;
  // create true表示如果该文件不存在,则创建之
  fileSystem.root.getFile(file.name, {create: true}, fileEntry_cloneReadyHandler, errorHandler);
}
function fileEntry_cloneReadyHandler(fileEntry) {
  // 获取本地文件的URL,可以赋给img的src属性,一般是filesystem://domain://temporary或者persistent/路径/文件名
  fileURL = fileEntry.toURL();
  fileEntry.createWriter(fileWriter_cloneReadyHandler, errorHandler);
}
function fileWriter_cloneReadyHandler(fileWriter) {
  // onwrite的话可能文件还没写完,所以最好用onwriteend,这点我参考的教程中没有提到
  fileWriter.onwriteend = function(e) {
    console.log('Write completed.');
  };
  fileWriter.onerror = function(e) {
    console.log('Write failed: ' + e.toString());
  };
  fileWriter.write(targetFile);
  targetFile = null;
}

几乎所有的文件型操作都包括以上三步:获取文件、创建 FileWriter、写入。与我们日常的文件操作不太一样,HTML5的File API要先找到或创建文件,然后再对文件进行操作,所以文件的URL在 getFile 之后就已经确定下来。

getFile 有4个参数,分别是文件名(文件路径我这次没用到,所以这篇文章中不会提及)、文件处理策略、成功回调函数、错误回调函数。按照w3c规范,文件处理策略有两个参数,分别是 createexclusive。前者代表如果目标文件不存在,是否创建;后者代表如果目标文件已存在,是否抛出异常,在后面“写文件”一节里会特别讲解这个参数的用法。

写文件

近期W3C修改了规范,开始支持直接构造Blob,于是BlobBuilder即将被废弃,今天终于抽时间文中涉及的部分更新了。(2012-09-13)

与复制文件不同,为了保证输出的文件可用,我们需要选择合适的文件格式和文件类型。这里我姑且假设输入的内容都是字符串,直接以文本文件来保存就可以了。至于二进制文件后面再讨论。继续看代码吧:

this.save = function (name, content, type) {
  fileName = name;
  fileContent = content;
  fileType = type || 'text/plain';
  fileSystem.root.getFile(fileName, {create: true, exclusive: true}, fileEntry_saveReadyHandler, errorHandler);
}
function fileEntry_saveReadyHandler(fileEntry) {
  fileURL = fileEntry.toURL();
  fileEntry.createWriter(fileWriter_saveReadyHandler, errorHandler);
}
function errorHandler(error) {
  // 前面可以照搬,后面需要增加一个处理
  // 当文件已存在时,应先删除
  if (error.code == FileError.INVALID_MODIFICATION_ERR) {
    fileSystem.root.getFile(fileName, {create: false}, fileEntry_removeReadyHandler, errorHandler);
  }
}
function fileWriter_saveReadyHandler(fileWriter) {
  fileWriter.onwriteend = function (event) {
    console.log('Write completed.');
  };
  fileWriter.onerror = function (error) {
    console.log('Write failed: ' + error.toString());
  };

  var blob,
      byteArray
      i = len = 0;
  if (/text/i.test(fileType)) {
    blog = new Blob([fileContent]);
  } else {
    len = fileContent.length;
    byteArray = new Uint8Array(len);
    for (; i < len; i++) {
      byteArray[i] = fileContent.charCodeAt(i) & 0xFF;
    }
    blob = new Blob([byteArray], {type: fileType});
  }
  fileWriter.write(blob);
  fileContent = null;
}
function fileEntry_removeReadyHandler(fileEntry) {
  fileEntry.remove(fileRemoveHandler, errorHandler);
}
function fileRemoveHandler() {
  self.save(fileName, fileContent, fileType);
}

可以看到,大体上还是三步:获取文件,创建 FileWriter、写入内容,不过增加了很多异常处理。这就要回到前面提到的那个getFile,它的第二个参数“文件处理策略”,里面有个字段叫 exclusive,代表如果目标文件已经存在,是否抛出异常。因为 FileWriter 写入内容时以文件指针为标准,从0开始,逐字节写入,直到 fileContent 写完;当目标文件存在时,它仍会这么做,这种逐字节覆盖的方式导致如果先前的内容比后写入的内容要长,文件内容就会是新老相接的。这明显不是我们希望的结果,所以我要修改 errorHandler ,当遇到 FileError.INVALID_MODIFICATION_ERR 时就先把目标文件删除,然后再重新写入。

删除文件的操作是两步:获取文件、删除文件。完成之后,再次调用 save 方法,写入内容。

Blob 是HTML5中引入的新类型,代表“不可变的原始二进制数据”。它是所有文件的基础,可以是任何一种文件类型的数据。新的规范引入了 Blob 的构造函数,可以接收两个参数:内容属性包。其中,内容 可以是 ArrayBuffer(后面会介绍)、其他 Blob、或者文本字符串,不过都需要用数组包裹。属性包 则是一个对象,用来指明该 Blob 的MIME类型和换行标记。

JSZip 与 Blob

我在项目中主要用到的操作有:

  1. 用户通过拖拽或者选择文件的方式,将文件复制到本地
  2. 将用户生成的内容保存为本地文件
  3. 将用户生成的所有内容(html、js、css、图片、视频等)存入一个压缩包,交给用户下载

zip压缩方面,我选择了JSZip这个类库(官网:http://stuartk.com/jszip/Github。最开始,我按照官网介绍使用 location 触发下载,小容量内容测试时一切都好,正式导出时Chrome就反复崩溃。Google之,原来Chrome的URL上限是2M,当压缩后的内容超过2M后,就不能再通过 location 触发下载了。没办法继续Google,从国外一个大侠的博客中找到了克敌制胜的法宝:Uint8ArrayArrayBuffer

Uint8ArrayArrayBuffer 也是新标准带来的好东西。前者表示一个由8位无符号整数组成的数组,后者则代表一段二进制数据缓冲,这样说大家可能不明白,按照我的理解和用法,就是存储在 Uint8ArrayJavaScript 类型数组的一种)的数据可以通过访问其 ArrayBuffer 属性来转化成二进制对象。前文的代码经修改已经可以支持二进制内容的写入,这里不再赘述。

相比外国大侠的解决方案,这样将zip文件保存到本地的做法,牺牲了部分浏览器兼容性(目前只有Chrome支持File API),但是文件名可读性要好的多,也方便通过 XHR2 将文件上传到服务器,相信日后会有更多应用选择我的这种方式。

类库下载和最后

我把这些操作封装在一个类里,方便使用,有兴趣的同学可以下载。这个类依赖Backbone.Events。大家如果有问题和建议,也欢迎在留言里跟我交流。

FileReferrence

用法:

var file = new FileReferrence();
// 复制文件到本地
file.clone(file);
// 保存内容到指定文件
file.save('temp.txt', 'text');