当需要在前端读取文件时,首先想到的便是使用 FileReader
,在其 onload
回调中获取并处理需要的数据。
但在文件多且数据量大的情况下,例如在 https://hondata.nova.moe/ 的场景中,用户可能会上传多个大于 100MB 的 CSV 文件,由于我们只需要 CSV 中的某些列,为了减少发送到后端的数据量,解析和处理的流程便放在了前端进行,而这里用 FileReader
处理起来会很耗时且消耗大量内存,尤其是当多个大文件同时被读取时,内存消耗会更为显著。如果浏览器的内存使用超过了系统的可用内存,浏览器可能会变得非常缓慢或无响应。
想要解决上面的问题,通常情况下可以:
- 分 chunk 读取文件,减少单位时间内内存的消耗,但同时处理的时间会增加
- 将读取和处理文件的步骤放到 web worker 中运行,避免阻塞主线程,从而保持页面的响应性
有了上面的两种解决方式,那么就可以开始着手优化代码了。
💡 文中代码仅有关键步骤,实际处理的代码逻辑没有展示;图片中是带有实际数据处理逻辑的代码跑出来的结果(Chrome performance 录制的结果,所有测试每次处理 3 个体积约为 100MB 的文件,且这三个文件内容相同),所以两者没有实际联系。
分 chunk
实现的方式非常简单,用 FileReader
将文件内容读到后,设置每次需要读取的 size,然后循环整个文件,直到结束。
代码测试运行内存和时间结果如下:
由于分 chunk 读取,所以能看到实际运行的 task 非常多,虽然每次运行的时间不长,但总体的运行时间大于 3s。内存会在 task 结束后被有效回收,但由于实际处理的文件较大,实际使用的最大内存也有 174MB 之多。
worker + 分 chunk
和直接分chunk比,引入 worker 是为了将代码在后台执行,所以需要创建 worker,并且数据处理完成后需要与主线程通信。
创建 worker
通过 new Worker
创建一个 worker,然后将文件通过 postMessage
传递到 worker。
💡 这里有一点值得注意的是:一开始本来是想将整个 fileList 给到 worker,结果控制台报错:Failed to execute ‘postMessage’ on ‘Worker’: [object Array] could not be cloned.
原以为是文件数组本身不支持【结构化克隆】,后来发现是由于 Vue3 中,由ref
创建的非基本类型变量返回的是 proxy 对象,所以如果需要将整个 fileList 给到 worker,需要将其转为数组。(例:Array.from(fileList.value)
)
由于需要将所有文件都读完最后再拿到数据,所以用了 promise。
worker 并不会因为没有通信就自己终止和销毁,所以需要手动处理,不然它会一直占用内存资源。并且,只用 terminate
和 close
仅会让 worker 功能都不生效,它的引用还是会一直存在。
const promises = fileList.value.map((file, index) => { const worker = new Worker(new URL('path/to/worker.js', import.meta.url), { type: 'module' }) // 和 worker 通信 worker.postMessage({ file }) worker.onmessage = (e) => { const { data } = e.data // do somthing... resolve(true) worker.terminate() // 终止 worker = null // 销毁引用 } }) }) Promise.all(promises).then(() => { // do somthing... })
创建 worker.js
// worker.js self.onmessage = function (e) { const { file } = e.data const chunkSize = 1024 * 1024 * 10 // 10MB,每次读取的chunk let totalChunks = Math.ceil(file.size / chunkSize) // 总共需要读取的次数 let currentChunk = 0 // 已经读取的chunk数 let offset = 0 let data const readNextChunk = () => { const fileReader = new FileReader() // 取出需要读取的部分 const blob = file.slice(offset, Math.min(offset + chunkSize, file.size)) fileReader.onload = (e) => { data = e.target.result // 读取进度 progress = ((currentChunk / totalChunks) * 100).toFixed(2) offset += chunkSize currentChunk++ if (offset < file.size) { // Use setTimeout to avoid blocking the event loop setTimeout(readNextChunk, 0) } else { console.log('Finished reading file.') self.postMessage({ data }) self.close() } } fileReader.onerror = (e) => { console.error('Error reading file:', e) self.close() } fileReader.readAsText(blob) } readNextChunk() }
代码测试运行内存和时间结果如下:
得益于 worker 后台运行的优势,代码循环三个文件创建了三个 worker,最终三个文件并行执行读取,比直接分 chunk 运行时间减少了一半多。也有效减少了内存的消耗。
尽管做了这么多的工作,但……
- 时间还是很久
- 内存消耗还是很大
worker + WebAssembly
这里使用的是 Rust 编写 WebAssembly 模块。听说 Rust 在做大量计算方面很强,所以想试试用 Rust 重写处理文件的模块,并且在 Vue 中使用。
WebAssembly 是一种运行在现代 web 浏览器中的新型代码,并且提供新的性能特性,同时提升了性能。它设计的目的不是为了手写代码,而是为诸如 C、C++ 和 Rust 等源语言提供一个有效的编译目标。
💡 是时候让 Rust 大显身手了!过程很痛苦,但最后的结果令我叹为观止
在 Vue3 中使用 WebAssembly
- 安装 Rust 和 wasm-pack
- 创建 Rust 项目
cargo new [project_name] --lib
- 编辑
Cargo.toml
文件,添加wasm-bindgen
依赖 - 在
src/lib.rs
文件中编写代码 - 编译 Rust 代码为 WebAssembly 模块:
wasm-pack build --target web
- 上面的指令会产生一个
pkg
目录,目录中文件内容结构如下- project_name.d.ts
- project_name.js
- project_name_bg.wasm
- project_name_bg.wasm.d.ts
- package.json
- 将
pkg
目录中的所有文件复制到 Vue 项目的任意合适的位置 - 在 Vue 组件中加载和使用 WebAssembly 模块
其中,src/lib.rs
参考下面代码:
💡 大部分代码来自与 Chatgpt 的高强度聊天
// lib.rs use csv::ReaderBuilder; use js_sys::wasm_bindgen; use serde::Deserialize; use serde_wasm_bindgen::to_value; use wasm_bindgen::prelude::*; use web_sys::console; use std::io::{BufReader, Cursor}; #[derive(Debug, Deserialize)] struct Record { // do somthing... } #[wasm_bindgen] pub fn parse_file(content: &str) -> JsValue { let mut buf_reader = BufReader::new(Cursor::new(content.as_bytes())); let mut line: String = String::new(); while let Ok(bytes_read) = buf_reader.read_line(&mut line) { // do somthing... } } else { JsValue::NULL } }
worker 的使用方式和上面一样,处理文件内容的逻辑全部放到了 WebAssembly 中。
由于 Rust 目前并不能直接读取 File 对象,所以文件内容还是需要靠 FileReader 拿出来。
// worker.js import init, { parse_file } from 'path/to/pkg/project_name.js'; // 引入 pkg 目录下的模块 self.onmessage = async function (e) { // 必须 init,异步,需要使用 await,如果下面先执行完成而 wasm 没有 initial 完成的话会报错 await init(); const { file } = e.data const reader = new FileReader(); reader.onload = (e) => { const content = e.target?.result; try { const data = parse_file(content || ''); self.postMessage({ data }) } catch (error) { console.error("Error processing file with Wasm:", error); } }; reader.onerror = (err) => { console.error("Error reading file:", err); }; reader.readAsText(file); }
代码测试运行内存和时间结果如下:
单个 worker 处理的时间1s不到,又因为并行处理的加成,所以总共消耗的时间也是1s不到,比较一开始速度提升了3倍!由于这里仅测试了三个文件,所以展示的结果约快3倍,在文件数量更多的情况下,对比结果更赞。
同时内存的消耗极低,不知道这里的内存有没有被正确计算,不过这个结果实在是想让人吹一波 rust 牛逼~
总结
后续尝试了使用一个 worker 处理多个文件,目的是想解决每次开启 worker 都需要 init wasm 环境的问题。但是结果不太如意,也许是 js 事件循环的原因,虽然文件是同时开始读取,最后 FileReader 每两个文件触发 onload 回调的间隔总会很长,所以最后放弃了。
也尝试过用 papaparse,也许是方式没用对,最终效果很差。
为了 https://hondata.nova.moe/ 上线,一个自动化修正 AFM 曲线的小应用(借助 pandas, numpy 自动化修正 Hondata AFM 曲线),所以做了这些工作,期间调研代码和调试功能离不开【nova】和他朋友们的帮助,也希望这个网站能帮助到喜欢爱(折腾)车的朋友!
参考
https://developer.mozilla.org/zh-CN/docs/Web/API/Worker
https://developer.mozilla.org/zh-CN/docs/WebAssembly/Concepts