Chrome的FileReader在处理大文件(>= 300MB)时返回空字符串

7

目标:

  • 在浏览器中以base64字符串的形式从用户文件系统读取文件
  • 这些文件最大可达1.5GB

问题:

  • 以下脚本在Firefox上完美运行,无论文件大小如何。
  • 在Chrome上,该脚本对于较小的文件(我已经测试了约为5MB的文件)可以正常工作。
  • 如果选择一个更大的文件(例如400MB),FileReader将在不出现错误或异常的情况下完成,但返回一个空字符串而不是base64字符串。

问题:

  • 这是Chrome的一个bug吗?
  • 为什么既没有错误也没有异常?
  • 我该如何修复或解决此问题?

重要提示:

请注意,对我来说,分块不是一个选项,因为我需要通过“POST”将完整的base64字符串发送到不支持块的API。

代码:

'use strict';

var filePickerElement = document.getElementById('filepicker');

filePickerElement.onchange = (event) => {
  const selectedFile = event.target.files[0];
  console.log('selectedFile', selectedFile);

  readFile(selectedFile);
};

function readFile(selectedFile) {
  console.log('START READING FILE');
  const reader = new FileReader();

  reader.onload = (e) => {
    const fileBase64 = reader.result.toString();

    console.log('ONLOAD','base64', fileBase64);
    
    if (fileBase64 === '') {
      alert('Result string is EMPTY :(');
    } else {
        alert('It worked as expected :)');
    }
  };

  reader.onprogress = (e) => {
    console.log('Progress', ~~((e.loaded / e.total) * 100 ), '%');
  };

  reader.onerror = (err) => {
    console.error('Error reading the file.', err);
  };

  reader.readAsDataURL(selectedFile);
}
<!doctype html>
<html lang="en">

<head>
  <!-- Required meta tags -->
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">

  <!-- Bootstrap CSS -->
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0/dist/css/bootstrap.min.css" rel="stylesheet"
    integrity="sha384-wEmeIV1mKuiNpC+IOBjI7aAzPcEZeedi5yW5f2yOq55WWLwNGmvvx4Um1vskeMj0" crossorigin="anonymous">

  <title>FileReader issue example</title>
</head>

<body>

  <div class="container">
    <h1>FileReader issue example</h1>
    <div class="card">
      <div class="card-header">
        Select File:
      </div>
      <div class="card-body">
        <input type="file" id="filepicker" />
      </div>
    </div>

  </div>

  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0/dist/js/bootstrap.bundle.min.js"
    integrity="sha384-p34f1UUtsS3wqzfto5wAAmdvj+osOnFyQFpp4Ua3gs/ZVWx6oOypYoCJhGGScy+8"
    crossorigin="anonymous"></script>
  <script src="main.js"></script>
</body>

</html>


1
我真的建议您在上传多个文件时使用FormData(对于单个文件,您可以将文件直接发送),使用它上传的文件/ blob大小没有限制(而且您不需要将其分块)... 当您使用reader.readAsDataURL时,会浪费大量处理、内存、时间和带宽。 - Endless
@Kaiido Chrome支持使用fetch API将ReadableStreams作为请求体进行提交。 - Endless
@Endless只使用实验性Web平台功能标志,对吧?(顺便说一下,我刚试了一下,似乎只有在打开该标志的情况下才能正常工作),但我现在没有时间写答案了... - Kaiido
嗯,是的,我想是这样。有时候我会忘记我开启了实验性标志... - Endless
谢谢大家提供的非常宝贵的意见。我会尝试联系API的创建者,并根据你们的建议努力说服他进行更改。 - tmuecksch
显示剩余2条评论
2个回答

4

这是Chrome的一个bug吗?

正如我在我的答案中所说,这是V8(Chrome、node-js和其他JavaScript JS引擎)的一个限制。
由于是故意的,因此实际上不能被认为是“一个bug”
其技术细节是,在64位系统上无法构建超过512MB(减去标题)的字符串,因为在V8中所有堆对象必须适合Smi(Small Integer)(请参阅此提交)。

为什么没有错误或异常?

那可能是一个bug... 正如我在链接的答案中展示的那样,当直接创建这样的字符串时,我们会得到一个RangeError:

const header = 24;
const bytes = new Uint8Array( (512 * 1024 * 1024) - header );
let txt = new TextDecoder().decode( bytes );
console.log( txt.length ); // 536870888
txt += "f"; // RangeError

FileReader::readOperation 的第三步中,用户代理需要:

如果 package data 抛出异常错误:

  • 将 fr 的错误设置为 error。
  • 在 fr 上触发一个名为 error 的进度事件。

但是在这里,我们没有这个错误。

const bytes = Uint32Array.from( { length: 600 * 1024 * 1024 / 4 }, (_) => Math.random() * 0xFFFFFFFF );
const blob = new Blob( [ bytes ] );
const fr = new FileReader();
fr.onerror = console.error;
fr.onload = (evt) => console.log( "success", fr.result.length, fr.error );
fr.readAsDataURL( blob );

我会就此问题开一个‘issue’,因为你应该能够从FileReader手动处理这个错误。
如何解决或解决此问题?
最好的方法肯定是使您的API端点直接接受二进制资源,而不是始终应该避免使用的data:// URLs。
如果这不可行,一个“未来的”解决方案是将ReadableStream POST到您的端点,并在从Blob流转换数据:// URL。
class base64StreamEncoder {
  constructor( header ) {
    if( header ) {
      this.header = new TextEncoder().encode( header );
    }
    this.tail = [];
  }
  transform( chunk, controller ) {
    const encoded = this.encode( chunk );
    if( this.header ) {
      controller.enqueue( this.header );
      this.header = null;
    }
    controller.enqueue( encoded );
  }
  encode( bytes ) {
    let binary = Array.from( this.tail )
        .reduce( (bin, byte) => bin + String.fromCharCode( byte ), "" );
    const tail_length = bytes.length % 3;
    const last_index = bytes.length - tail_length;
    this.tail = bytes.subarray( last_index );
    for( let i = 0; i<last_index; i++ ) {
        binary += String.fromCharCode( bytes[ i ] );
    }
    const b64String = window.btoa( binary );
    return new TextEncoder().encode( b64String );
  }
  flush( controller ) {
    // force the encoding of the tail
    controller.enqueue( this.encode( new Uint8Array() ) );
  }
}

实时示例:https://base64streamencoder.glitch.me/

目前为止,您必须像Endless的回答所示那样将base64表示的块存储到Blob中。

但是请注意,由于这是V8的限制,即使服务器端也可能面临字符串过大的问题,因此无论如何,您都应该联系API的维护者。


FYI,我也尝试使用blob.stream()和Transformer。但我遇到的问题是如何将base64打包成3个字节或类似的东西。这里有一个演示,你的base64生成错误的base64 https://jsfiddle.net/8vbeqypo/解决方案可能是使用异步可读流拉取源,该源使用FileReader读取x个字节而不是通过blob.stream()进行管道传输。然后您就可以摆脱变压器,并且还可以在FF中使用流。 - Endless
1
哇,谢谢@Endless。我之前在玩这个的时候想过这样做,但今天写答案的时候完全忘记了(我甚至没有改变那时留下的代码...)现在已经修复了。另外,关于FF,他们远远不支持发布ReadableStreams,我希望他们能更接近让TransformStreams工作。 - Kaiido
你可以将流转换为 Blob,然后上传它... new Response(readableStream).blob().then(uploadBlob) - Endless

0
这里有一个部分解决方案,将块中的 blob 转换为 base64 blob... 将所有内容连接成一个 json blob,并在 json 的前/后缀部分和中间插入 base64 块。
将其保留为 blob 可以让浏览器优化内存分配并在需要时将其卸载到磁盘上。
你可以尝试将 chunkSize 更改为更大的值,浏览器喜欢将较小的 blob 块保留在内存中(一个桶)。

// get some dummy gradient file (blob)
var a=document.createElement("canvas"),b=a.getContext("2d"),c=b.createLinearGradient(0,0,3000,3000);a.width=a.height=3000;c.addColorStop(0,"red");c.addColorStop(1,"blue");b.fillStyle=c;b.fillRect(0,0,a.width,a.height);a.toBlob(main);

async function main (blob) {
  var fr = new FileReader()
  // Best to add 2 so it strips == from all chunks
  // except from the last chunk
  var chunkSize = (1 << 16) + 2 
  var pos = 0
  var b64chunks = []
  
  while (pos < blob.size) {
    await new Promise(rs => {
      fr.readAsDataURL(blob.slice(pos, pos + chunkSize))
      fr.onload = () => {
        const b64 = fr.result.split(',')[1]
        // Keeping it as a blob allaws browser to offload memory to disk
        b64chunks.push(new Blob([b64]))
        rs()
      }
      pos += chunkSize
    })
  }

  // How you concatinate all chunks to json is now up to you.
  // this solution/answer is more of a guideline of what you need to do
  // There are some ways to do it more automatically but here is the most
  // simpliest form
  // (fyi: this new blob won't create so much data in memory, it will only keep references points to other blobs locations)
  const jsonBlob = new Blob([
    '{"data": "', ...b64chunks, '"}'
  ], { type: 'application/json' })

  /*
  // strongly advice you to tell the api developers 
  // to add support for binary/file upload (multipart-formdata)
  // base64 is roughly ~33% larger and streaming
  // this data on the server to the disk is almost impossible 
  fetch('./upload-files-to-bad-json-only-api', {
    method: 'POST',
    body: jsonBlob
  })
  */
  
  // Just a test that it still works
  //
  // new Response(jsonBlob).json().then(console.log)
  fetch('data:image/png;base64,' + await new Blob(b64chunks).text()).then(r => r.blob()).then(b => console.log(URL.createObjectURL(b)))
}

我已经避免使用 base64 += fr.result.split(',')[1]JSON.stringify,因为大量的数据会占用很多空间,而且 JSON 不应该处理二进制数据。


最好解释一下核心问题。不确定终点是否能够处理数据(例如如果它是节点服务器,并将有效负载读取为文本)。另外,base64 += fr.result.split(',')[1] 也无法正常工作。 - Kaiido
我尝试了 base64 += fr.result.split(',')[1],它可以正常工作 - 我基本上已经使用 blob 来完成了。他解释说在其他浏览器中可以正常工作,所以我认为问题不在服务器上,而是在 Chrome 本身。 - Endless
base64 达到 512MB 时,base64 += fr.result.split(',')[1] 会抛出异常。 - Kaiido
哦,我以为问题只出在FileReader上。 - Endless
不,问题在于字符串的最大长度。它必须适应SMI,并且这是V8的限制,这就是为什么我说值得注意的是服务器端也可能失败(即使现在对OP来说可能不适用)。 - Kaiido
@Kaiido 非常有用的信息,谢谢你提出来。我不知道字符串长度的最大值。我一直依赖于 ECMAScript 标准中定义的最大字符串长度 - 这是非常广泛的。 - tmuecksch

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接