在 Vue3 中使用 Rust+WebAssembly处理文件——极大减少内存消耗,速度提升n倍

当需要在前端读取文件时,首先想到的便是使用 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 并不会因为没有通信就自己终止和销毁,所以需要手动处理,不然它会一直占用内存资源。并且,只用 terminateclose 仅会让 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

https://juejin.cn/post/7112544960934576136

Leave a Reply

Your email address will not be published. Required fields are marked *