浏览器环境下的文件读写

4872

群里有人问到如何通过纯前端的方式来读取本地文件再编辑后保存为新文件的问题。类似这种纯前端读写的方式正好在项目开发过程中遇到过,我们是读取一个Markdown文件的模板通过一些信息的修改(如版本,名称,日志)来为产品生产新的帮助文档。以前纯前端实现文件读写要借助于各种浏览器插件(如flash)或者只能寄托于后端,而现在HTML5强大的 File API规范,无疑让浏览器与本地文件的交互变的更加简单。本文将结合示例来演示如大文件读取,监控文件读取进度、大文件保存等的实现过程。

如何读取文件内容

文件内容的读取可分为通过File文件域获取本地文件信息以及通过Ajax获取远程文件内容。Ajax方式本身是用来做异步请求的,也可以用来获取同源文件的内容。浏览器只允许同源的Ajax操作,如果跨域,就必须使用CORS权限。

本文以下内容将借助《将MARKDOWN代码块转换成可运行实例》 实现的功能提供在线展示DEMO。

本地文件读取

受制于各种安全限制,在浏览器层面,我们目前只能通过文件域的方式来获取本地文件的引用,然后再通过FileReader这个接口来对本地文件进行一系列的异步读取操作。FileReader提供了以下四种API来供我们读取文件内容:

  • readAsArrayBuffer: 以 ArrayBuffer 数据对象的格式来读取指定的File或者Blob
  • readAsBinaryString: 以原始二进制格式来读取指定的File或者Blob
  • readAsDataURL: 以base64编码的字符串格式来读取指定的File或者Blob
  • readAsText: 以指定编码格式(默认utf-8)的字符串格式来读取指定的File或者Blob

为了便于展示,这里主要演示如何读取纯文本格式(如txt, js, css, md, ...)文件的内容。

简单文件内容读取

加载本地文件最为简单的方式就是使用文件域<input type="file">。JavaScript 会返回选定的File对象的列表作为 FileListFileList可以通过文件域onchange事件的event.target.files来获取。其次还可以通过drag/drop拖拽API来获取FileList。本文所有示例都通过文件域的方式来获取。

当获取到File(fileList[0])的引用后,通过实例化 FileReader 对象,可以将文件内容读取到内存中。当加载结束后,将触发读取程序的 onload 等事件,而其 result属性可用于访问文件数据。

您可以通过以下示例,点击选择文件,选择一个本地文本类型(demo中是通过readAsText接口读取内容的)文件后,然后点击读取文件按钮,来将文件内容读取到下方文本框内。

在线示例

<link rel="stylesheet" href="https://smohan.oss-cn-beijing.aliyuncs.com/static/demos/styles/file-rw-in-browser.css">

<div class="file-reader">
  <div class="form">
    <label class="uploader">
      <input type="file" id="file">
      <span class="btn" role="button">选择文件</span>
    </label>
    <div class="actions">
      <button class="btn" id="readFile" disabled>读取文件</button>
    </div>
  </div>
  <div class="infos" id="infos">
    <p style="color: #999;">请选择文本类型文件</p>
  </div>  
  <div class="content">
    <textarea id="content" placeholder="文件内容..."></textarea>
  </div>  
</div>  

<script>
  const $ = id => document.getElementById(id)
  const $file = $('file')
  const $content = $('content')
  const $readBtn = $('readFile')
  const $infos = $('infos')

  const fileReader = new FileReader()

  // 保存当前文件域引用
  let currentFile = null

  const readFile = () => {
    if (!currentFile) {
      alert('请选择文件')
      return
    }
    fileReader.onloadend = function(evt) {
      // 在文件读取完毕后,其内容将被保存在result属性中
      const content = evt.target.result
      $content.focus()
      $content.value = content
    }
    // 以utf-8编码文本格式来读取
    fileReader.readAsText(currentFile, 'utf-8')
  }

  const setInfos = file => { 
    const html = `
      <ul>
        <li>文件名称:${file.name}</li>
        <li>文件大小:${file.size} Bytes</li>
      </ul>
    `
    $infos.innerHTML = html
  }

  $file.onchange = evt => {
    const file = evt.target.files[0]
    setInfos(file)
    currentFile = file
    evt.value = null
    $readBtn.removeAttribute('disabled')
  }

  $readBtn.addEventListener('click', () => readFile())
</script>
超大文件的读取

上面例子当我们读取一个小型文件时毫无压力,但是当读取一个大型文件时,你会发现文件内容迟迟无法写入到文本框内,甚至会引发浏览器卡死现象。这里我提供了一个100MB左右的JS测试文件(点击可下载),你可以下载后在上面demo中上传读取测试。这是因为一次性将大约100M的内容读取到页面内存中是很耗资源的,就像上传一个大型文件到服务器,在普通宽带下可能需要很久,这时候需要利用分片上传的方式将资源分割成若干份,然后一份一份的上传到服务器,最后由服务器负责按正确顺序合并各个分片文件。

幸运的事,File 接口支持文件分割方法。通过Blob.slice这个方法我们可以创建一个包含源 Blob的指定字节范围内的数据的新 Blob 对像。其语法是const blob = instanceOfBlob.slice([start [, end [, contentType]]]}, 就像String.slice方法一样,start是起始索引,end是结束索引,通过该方法,我们可以获取到一段(start, end]开闭区间内的字节组成的新的blob对象。

接着,我们修改下Demo, 增加一些如startIndex等的全局变量来控制整个读取过程,Demo中设置了const STEP = 1024的控制项来控制每次读取1024个字节,因此你需要上传一个至少大于1024字节的文本文件来测试效果,过程中可以随时的暂停和开始读取文件。

可以看到,随着读取的深入,文件内容被一节一节的写入文本框内。过程中您可能会听到电脑风扇剧烈运动的声音😁~

在线示例

<link rel="stylesheet" href="https://smohan.oss-cn-beijing.aliyuncs.com/static/demos/styles/file-rw-in-browser.css">

<div class="file-reader">
  <div class="form">
    <label class="uploader">
      <input type="file" id="file">
      <span class="btn" role="button">选择文件</span>
    </label>
    <div class="actions">
      <button class="btn" id="readFile" disabled>开始</button>
    </div>
  </div>
  <div class="infos">
    <p style="color: red;">为了防止内存溢出,文本框内仅显示当前读取的片段内容</p>
    <ul id="infos"></ul>
    <ul id="logs"></ul>
  </div>  
  <div class="content">
    <textarea id="content" placeholder="文件内容..."></textarea>
  </div>  
</div>  

<script>
const $ = id => document.getElementById(id)
const $file = $('file')
const $content = $('content')
const $readBtn = $('readFile')
const $infos = $('infos')
const $logs = $('logs')

const fileReader = new FileReader()

// 分片步进
const STEP = 1024

// 保存当前文件域引用
let currentFile = null
// 用来标注是否已停止读取
let isStopRead = true
// 当前索引
let startIndex = 0
// 当前分片
let currentSeg = 1
// 文件总大小
let totalSize = 0
// 文件总片段数
let totalSegs = 0
// 已读字节数
let loadedSize = 0

function readFile() {

  fileReader.onloadend = function(evt) {
    const content = evt.target.result
    // 为了防止内存溢出,这里仅显示当前读取的内容,可在实际测试中改为内容拼接的形式
    // $content.value += content
    $content.value = content
    $content.focus()
    // 递归继续读取
    if (startIndex < totalSize) {
      if (!isStopRead) {
        currentSeg++
        readFile()
      }
    } else {
      $readBtn.textContent = '读取完毕'
      $readBtn.setAttribute('disabled', 'disabled')
    }
  }

  // 读取进度
  fileReader.onprogress = function(evt) {
    if (evt.lengthComputable) {
      loadedSize += evt.loaded
    }
  }

  const readFile = () => {
    const end = Math.min(startIndex + STEP, totalSize)
    const blob = currentFile.slice(startIndex, end)
    startIndex = end
    fileReader.readAsText(blob, 'utf-8')
    updatelogs()
  }

  readFile()
}

function updatelogs() {
  $logs.innerHTML = `
    <li>已读取:${loadedSize}/${totalSize} Bytes</li>
    <li>正在读取: ${currentSeg}/${totalSegs} 分片</li>
  `
}

// 重新初始化全局变量
function init() {
  $readBtn.removeAttribute('disabled')
  $readBtn.textContent = '开始'
  isStopRead = true
  currentSeg = 1
  startIndex = 0
  loadedSize = 0
  totalSize = currentFile.size
  totalSegs = Math.ceil(totalSize / STEP)
  $logs.innerHTML = ''
  $content.value = ''
}

// 文件域改变时触发
$file.onchange = evt => {
  const file = evt.target.files[0]
  if (file.size < STEP) {
    alert(`请上传至少${STEP}字节的文件`)
    return
  }
  currentFile = file
  $infos.innerHTML = `
  <li>文件名称:${file.name}</li>
  <li>文件大小:${file.size} Bytes</li>
  `
  evt.value = null
  init()
}

// 开始暂停的切换
$readBtn.addEventListener('click', () => {
  if (loadedSize >= totalSize) {
    alert('文件已读取完毕,请重新选择文件')
    return
  }
  if (isStopRead) {
    isStopRead = false
    $readBtn.textContent = '暂停'
    readFile()
  } else {
    isStopRead = true
    $readBtn.textContent = '开始'
  }
})
</script>
监控文件读取进度

XMLHttpRequest为我们上传文件提供了progress事件一样,FileReader接口也同样提供了监听文件读取过程的事件,这对于读取大文件、查找错误和预测读取完成时间非常实用。其用法如下:

fileReader.onprogress = evt => {
  if (evt.lengthComputable) {
    const percentLoaded = Math.round((evt.loaded / evt.total) * 100)
  }
}

小文件读取很快,基本看不到效果。这里我们还是结合上面超大文件的读取的示例来演示。由于使用了分片读取方案,fileReader.onprogress事件读取到的进度仅仅只是本次分片过程的读取进度,因此我们还需要在evt.loaded的基础上加上已经读取到的字节数。

在线示例

<link rel="stylesheet" href="https://smohan.oss-cn-beijing.aliyuncs.com/static/demos/styles/file-rw-in-browser.css">

<div class="file-reader">
  <div class="form">
    <label class="uploader">
      <input type="file" id="file">
      <span class="btn" role="button">选择文件</span>
    </label>
    <div class="actions">
      <button class="btn" id="readFile" disabled>开始</button>
    </div>
  </div>
  <div class="infos">
    <p style="color: red;">为了防止内存溢出,文本框内仅显示当前读取的片段内容</p>
    <ul id="infos"></ul>
    <ul id="logs"></ul>
    <label class="progress">
      <progress value="0" id="progress"></progress>
      <span id="progressText"></span>
    </label>
  </div>
  <div class="content">
    <textarea id="content"></textarea>
  </div>
</div>

<script>
const $ = id => document.getElementById(id)
const $file = $('file')
const $content = $('content')
const $readBtn = $('readFile')
const $infos = $('infos')
const $logs = $('logs')
const $progress = $('progress')
const $progressText = $('progressText')

const fileReader = new FileReader()

// 分片步进
const STEP = 1024

// 保存当前文件域引用
let currentFile = null
// 用来标注是否已停止读取
let isStopRead = true
// 当前索引
let startIndex = 0
// 当前分片
let currentSeg = 1
// 文件总大小
let totalSize = 0
// 文件总片段数
let totalSegs = 0
// 已读字节数
let loadedSize = 0

function readFile() {

  fileReader.onloadend = function(evt) {
    const content = evt.target.result
    // 为了防止内存溢出,这里仅显示当前读取的内容,可在实际测试中改为内容拼接的形式
    // $content.value += content
    $content.value = content
    $content.focus()

    // 递归继续读取
    if (startIndex < totalSize) {
      if (!isStopRead) {
        currentSeg++
        readFile()
      }
    } else {
      $readBtn.textContent = '读取完毕'
      $readBtn.setAttribute('disabled', 'disabled')
    }
  }

  // 读取进度
  fileReader.onprogress = function(evt) {
    if (evt.lengthComputable) {
      // 当前片段的读取进度,在片段很小的时候基本看不到效果
      const segProgress = evt.loaded / evt.total
      loadedSize += evt.loaded
      // 在startIndex字节的基础上加上当前片段的读取加载字节就可以算出总文件的读取进度
      const totalProgress = Math.min(1, loadedSize / totalSize)
      $progress.value = totalProgress
      $progressText.textContent = Math.round(totalProgress * 100) + '%'
    }
  }

  const readFile = () => {
    const end = Math.min(startIndex + STEP, totalSize)
    const blob = currentFile.slice(startIndex, end)
    startIndex = end
    fileReader.readAsText(blob, 'utf-8')
    updatelogs()
  }

  readFile()
}

function updatelogs() {
  $logs.innerHTML = `
    <li>已读取:${loadedSize}/${totalSize} Bytes</li>
    <li>正在读取: ${currentSeg}/${totalSegs} 分片</li>
  `
}

// 重新初始化全局变量
function init() {
  $readBtn.removeAttribute('disabled')
  $readBtn.textContent = '开始'
  isStopRead = true
  currentSeg = 1
  startIndex = 0
  loadedSize = 0
  totalSize = currentFile.size
  totalSegs = Math.ceil(totalSize / STEP)
  $logs.innerHTML = ''
  $progress.value = 0
  $progressText.textContent = ''
  $content.value = ''
}

// 文件域改变时触发
$file.onchange = evt => {
  const file = evt.target.files[0]
  if (file.size < STEP) {
    alert(`请上传至少${STEP}字节的文件`)
    return
  }
  currentFile = file
  $infos.innerHTML = `
  <li>文件名称:${file.name}</li>
  <li>文件大小:${file.size} Bytes</li>
  `
  evt.value = null
  init()
}

// 开始暂停的切换
$readBtn.addEventListener('click', () => {
  if (loadedSize >= totalSize) {
    alert('文件已读取完毕,请重新选择文件')
    return
  }
  if (isStopRead) {
    isStopRead = false
    $readBtn.textContent = '暂停'
    readFile()
  } else {
    isStopRead = true
    $readBtn.textContent = '开始'
  }
})
</script>

远程文件读取

远程文件读取就是Ajax方法,注意文件必须同域,不然会跨域,可以通过CORS方式或者代理方式来避免跨域。

文件的创建和下载

上面我们演示了本地文件的读取方式,接着来看下本地文件的创建方式。这里依然以文本文件为例,如果需要了解图片文件的创建过程,可参考我的项目canvasTools

小文件下载

HTML5规范中支持了a标签的download属性,通过设置downloadhref属性,我们可以很容易的下载一个文件。如直接下载一个同域文件:

在线示例

<a href="https://smohan.net/5e4d09540c297e2333c2565d.md" download="浏览器环境下的文件读写.md">点击下载本文Markdown文件</a>

在比如我们可以通过base64的方式将一个文本框中的文件写入一个下载链接的href属性中:

在线示例

<link rel="stylesheet" href="https://smohan.oss-cn-beijing.aliyuncs.com/static/demos/styles/file-rw-in-browser.css">
<div class="file-reader">
  <div class="form">
    <label class="uploader">
      <span>文件名</span>
      <input type="text" id="filename" class="input" placeholder="请输入文件名" value="test.txt" maxlength="20">
    </label>
    <div class="actions">
      <button class="btn" id="download">下载</button>
    </div>
  </div>
  <div class="content">
    <textarea id="content" maxlength="100" placeholder="请输入文件内容"></textarea>
  </div>
</div>
<script>
  const $ = id => document.getElementById(id)
  const $filename = $('filename')
  const $content = $('content')
  const $download = $('download')
  $download.addEventListener('click', function () {
    const content = $content.value
    const filename = $filename.value
    if (!filename) {
      $filename.focus()
      return
    }
    if (!content) {
      $content.focus()
      return
    }
    const a = document.createElement('a')
    a.href = 'data:text/txt;charset=utf-8,\uFEFF'+content
    a.download = filename
    a.click()
  })
</script>

但是因为URL长度的限制,当遇到大文件时,这个方法就完全无效了。

兼容大文件的下载方案

URL.createObjectURL() 静态方法会创建一个 DOMString,其中包含一个表示参数中给出的对象的URL。这个 URL 的生命周期和创建它的窗口中的 document 绑定。这个新的URL 对象表示指定的 File 对象或 Blob 对象。-- MDN

你也许已经在很多视频网站中见过很多类似于这样的URL:blob:www.bilibili.com/875adde4-97… 如bilibili的视频链接:

bilibili blob格式链接

通过URL.createObjectURL()就可以将一个File 对象或 Blob 对象转换为一个可用的链接,这样就突破了URL长度的限制。

在线示例

<link rel="stylesheet" href="https://smohan.oss-cn-beijing.aliyuncs.com/static/demos/styles/file-rw-in-browser.css">
<div class="file-reader">
  <div class="form">
    <label class="uploader">
      <span>文件名</span>
      <input type="text" id="filename" class="input" placeholder="请输入文件名" value="test.txt" maxlength="20">
    </label>
    <div class="actions">
      <button class="btn" id="download">下载</button>
    </div>
  </div>
  <div class="content">
    <textarea id="content" maxlength="1000" placeholder="请输入文件内容"></textarea>
  </div>
</div>
<script>
  const $ = id => document.getElementById(id)
  const $filename = $('filename')
  const $content = $('content')
  const $download = $('download')
  const doc = document
  $download.addEventListener('click', function () {
    const content = $content.value
    const filename = $filename.value
    if (!filename) {
      $filename.focus()
      return
    }
    if (!content) {
      $content.focus()
      return
    }
    const a = doc.createElement('a')
    // 创建一个blob对象
    const blob = new Blob([content], {type : 'application/txt'})
    // 创建URL
    a.href = URL.createObjectURL(blob)
    a.download = filename
    a.click()
  })
</script>
更好的方案

W3C File API标准包含一个FileSaver接口,该接口使保存生成的数据与saveAs(data,filename)一样容易。但是不幸的是,目前还没有浏览器厂商实现这一接口。

虽然浏览器厂商还未实现,但是Github中这个Star超过13.6K的项目FileSaver.js以及StreamSaver.js这个为了保存超过blob大小限制的超大文件或者没有足够内存来读取文件的项目不容错过。尤其是StreamSaver这个项目,它可以利用流将数据异步直接保存到硬盘中。

参考