普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月21日首页

不会 Rust 也能玩 WebAssembly:3 个 npm install 就能用的 WASM 神器

作者 ofox
2026年3月21日 09:47

刷掘金热榜发现 WebAssembly 又上去了,评论区一堆人说「学 WASM 得先学 Rust」,劝退了不少人。

说实话我之前也是这么想的——直到上个月做一个内部工具的时候,发现有些 npm 包底层就是 WASM,安装完直接用,完全不需要碰 Rust。今天分享 3 个我实际用过的,都是 npm install 一把梭,零 Rust 基础也能直接上手。

先说结论

干什么的 性能提升 上手难度
sql.js 浏览器里跑 SQLite 比 IndexedDB 查询快 5-10x ⭐ 极低
@ffmpeg/ffmpeg 浏览器里处理视频 JS 根本做不到的事 ⭐⭐ 低
photon-wasm 图片滤镜/裁剪/压缩 比 Canvas API 快 2-5x ⭐ 极低

场景一:浏览器里跑 SQLite(sql.js)

做后台管理系统的时候遇到一个需求:前端要对一个几万行的 CSV 做复杂筛选和聚合。一开始用 JS 数组硬撸 filter + reduce,代码写得我自己都看不懂,而且 5 万行数据一个聚合查询要卡 3 秒。

后来想到——为什么不在浏览器里直接用 SQL?

npm install sql.js
import initSqlJs from 'sql.js';

// 初始化,需要指定 wasm 文件位置
const SQL = await initSqlJs({
  locateFile: file => `https://sql.js.org/dist/${file}`
});

// 创建内存数据库
const db = new SQL.Database();

// 建表 + 导入 CSV 数据
db.run(`CREATE TABLE sales (
  date TEXT,
  region TEXT,
  product TEXT,
  amount REAL,
  quantity INTEGER
)`);

// 批量插入(用事务,不然会巨慢)
db.run('BEGIN TRANSACTION');
csvData.forEach(row => {
  db.run(
    'INSERT INTO sales VALUES (?, ?, ?, ?, ?)',
    [row.date, row.region, row.product, row.amount, row.quantity]
  );
});
db.run('COMMIT');

// 现在可以用 SQL 了!
const result = db.exec(`
  SELECT region,
         SUM(amount) as total_sales,
         COUNT(*) as order_count,
         AVG(amount) as avg_order
  FROM sales
  WHERE date >= '2026-01-01'
  GROUP BY region
  ORDER BY total_sales DESC
`);

console.log(result[0].values);
// [['华东', 2847563.5, 12847, 221.6], ['华南', ...]]

5 万行数据,这个聚合查询 60ms 搞定。之前纯 JS 要 3 秒多。

踩坑点

  1. wasm 文件要单独加载。如果用 Vite,需要把 sql-wasm.wasm 放到 public 目录,locateFile 指向 /sql-wasm.wasm
  2. 数据库在内存里,刷新就没了。想持久化可以用 db.export() 导出 Uint8Array,存到 IndexedDB 或者 localStorage
  3. 不支持并发写入。如果有 Web Worker 也在操作同一个数据库实例,会出问题。建议把 sql.js 整个跑在一个 Worker 里
// 持久化方案
const data = db.export();
const buffer = new Uint8Array(data);
localStorage.setItem('mydb', JSON.stringify(Array.from(buffer)));

// 恢复
const saved = JSON.parse(localStorage.getItem('mydb'));
const db = new SQL.Database(new Uint8Array(saved));

场景二:浏览器里剪视频(@ffmpeg/ffmpeg)

这个是真没想到的——FFmpeg 编译成了 WASM,能在浏览器里跑。

我的场景是做一个视频剪辑小工具,用户上传视频后自动截取前 30 秒作为预览。之前都是传到后端处理,现在直接前端搞定,省了一台服务器。

npm install @ffmpeg/ffmpeg @ffmpeg/util
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile, toBlobURL } from '@ffmpeg/util';

const ffmpeg = new FFmpeg();

// 加载 WASM(首次会比较慢,约 25MB)
const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';
await ffmpeg.load({
  coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
  wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
});

// 监听进度
ffmpeg.on('progress', ({ progress }) => {
  console.log(`处理进度: ${(progress * 100).toFixed(1)}%`);
});

// 写入文件到虚拟文件系统
const videoFile = document.querySelector('input[type="file"]').files[0];
await ffmpeg.writeFile('input.mp4', await fetchFile(videoFile));

// 截取前 30 秒 + 压缩
await ffmpeg.exec([
  '-i', 'input.mp4',
  '-t', '30',           // 只要前 30 秒
  '-vf', 'scale=720:-2', // 压缩到 720p
  '-c:v', 'libx264',
  '-preset', 'fast',
  '-crf', '28',
  'output.mp4'
]);

// 读取结果
const data = await ffmpeg.readFile('output.mp4');
const blob = new Blob([data], { type: 'video/mp4' });
const url = URL.createObjectURL(blob);

// 直接在页面上播放
document.querySelector('video').src = url;

踩坑点

  1. WASM 文件巨大。ffmpeg-core.wasm 大概 25MB,首次加载会很慢。建议做 loading 动画 + 缓存到 Service Worker
  2. SharedArrayBuffer 限制。多线程版本需要页面设置 COOP/COEP 响应头,很多部署环境不支持。单线程版也能用,就是慢一些:
    // 单线程版本,兼容性更好
    const baseURL = 'https://unpkg.com/@ffmpeg/core-st@0.12.6/dist/esm';
    
  3. 2GB 文件上限。WASM 内存限制,超过 2GB 的视频处理不了。不过前端场景一般也碰不到这个上限
  4. iOS Safari 有坑。部分老版本 Safari 对 WASM 内存分配有 bug,大文件处理可能崩溃。2026 年的 Safari 17+ 基本没问题了

场景三:图片处理快到飞起(photon-wasm)

Canvas API 做图片处理不是不能用,但一旦图片大一点(比如 4K),肉眼可见地卡。photon 是 Rust 写的图片处理库,编译成 WASM 后性能碾压 Canvas。

npm install @aspect-build/photon-wasm
# 或者直接用 CDN
import * as photon from '@aspect-build/photon-wasm';

// 从 Canvas 获取图片数据
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');

// 加载图片到 canvas
const img = new Image();
img.src = 'photo.jpg';
await new Promise(resolve => img.onload = resolve);
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);

// 创建 PhotonImage
const image = photon.open_image(canvas, ctx);

// 应用滤镜 —— 一行代码搞定
photon.filter(image, 'oceanic');    // 海洋风滤镜
// photon.grayscale(image);         // 灰度
// photon.gaussian_blur(image, 3);  // 高斯模糊
// photon.sharpen(image);           // 锐化

// 调整亮度对比度
photon.alter_channel(image, 0, 20);  // R通道+20

// 写回 canvas
photon.putImageData(canvas, ctx, image);

// 导出为 Blob 下载
canvas.toBlob(blob => {
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = 'processed.jpg';
  a.click();
}, 'image/jpeg', 0.9);

实测一张 4000x3000 的照片:

操作 Canvas API photon-wasm 提速
灰度转换 180ms 35ms 5.1x
高斯模糊 2400ms 480ms 5.0x
批量滤镜(3个叠加) 850ms 190ms 4.5x

踩坑点

  1. npm 包名很混乱。搜 photon wasm 会找到好几个包,认准 GitHub(silvia-odwyer/photon)上的官方版本
  2. 内存要手动管理PhotonImage 对象用完记得调用 .free() 释放 WASM 内存,不然会内存泄漏
  3. 不支持 HEIC 格式。苹果的 HEIC 图片需要先用其他库转成 JPEG/PNG 再处理

什么时候该用 WASM,什么时候别折腾

说实话大部分前端场景不需要 WASM。如果你只是做个 CRUD 后台,加什么 WASM 纯属给自己找事。

但这几种情况值得考虑:

  • 计算密集型:大量数据处理、加解密、图片/音视频处理
  • 现有 C/C++/Rust 轮子:比如 SQLite、FFmpeg,直接编译过来比用 JS 重写强一万倍
  • 需要离线能力:数据库、文档解析这些在端上跑,不依赖后端

而且 WASI 0.3 刚在今年 2 月发布了,以后 WebAssembly 不只是浏览器的东西——边缘计算、Serverless 都能用。路线图里甚至有 wasi:nn 专门用来跑 AI 推理,以后在浏览器里直接跑模型可能也不是梦。

小结

说白了 WebAssembly 对前端来说就是一个性能工具箱。不需要学 Rust,不需要懂编译原理,npm install 完就能享受 native 级别的性能。

这三个库我在实际项目里都用过,sql.js 用得最多(报表系统的前端聚合),ffmpeg.wasm 偶尔用(用户端视频预处理),photon 适合需要批量图片处理的场景。

热榜在问「前端要不要学 WASM」——我觉得不用「学」它,就行了。

Electron 太胖了?试试 Electrobun,12MB 打包一个 AI 桌面助手

作者 ofox
2026年3月20日 21:05

前两天刷掘金热榜看到 Electrobun 这个名字,第一反应是——又一个 Electron 替代品?Tauri 不是已经卷过一轮了吗?

但是当我看到打包体积 12MB 的时候,还是没忍住试了一下。结果一个下午就撸出了一个能用的 AI 聊天桌面助手,打包完一看体积,确实有点离谱。

先说结论

对比项 Electron Tauri Electrobun
运行时 Node.js + Chromium Rust + 系统 WebView Bun + 系统 WebView
开发语言 JS/TS Rust + JS/TS 纯 TypeScript
Hello World 包体积 ~270MB ~8MB ~12MB
冷启动速度 很快
学习成本 高(要会 Rust)
生态成熟度 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐(刚 v1)

Tauri 体积更小,但你得写 Rust。Electrobun 的卖点就是:纯 TypeScript 全栈,不用学新语言,体积还能压到 12MB 级别。

为什么不用 Electron?

不是黑 Electron,我之前的几个小工具都是 Electron 写的。但问题真的很实际:

  1. 一个 Hello World 就 270MB,用户下载要等半天
  2. 每个 Electron 应用都自带一个 Chromium,开 3 个 Electron 应用等于开了 3 个 Chrome
  3. 内存占用,随便一个小工具就吃 200MB+ 内存

Tauri 解决了体积和性能问题,但代价是你得写 Rust。对于纯前端来说,这个门槛确实不低。

Electrobun 的思路是:用 Bun 替代 Node.js 做主进程(Bun 本身就比 Node 快),渲染层用操作系统自带的 WebView(macOS 用 WebKit,Windows 用 Edge WebView2),不捆绑浏览器引擎。

上手:5 分钟跑起来

前置条件:装好 Bun(没装的话 curl -fsSL https://bun.sh/install | bash)。

# 创建项目
bunx electrobun init my-ai-chat
cd my-ai-chat

# 装依赖
bun install

# 跑起来
bun run dev

跑完你会看到一个原生窗口弹出来,里面是一个简单的欢迎页面。整个过程不到 1 分钟。

项目结构长这样:

my-ai-chat/
├── src/
│   ├── main.ts              # 主进程(Bun 环境)
│   └── renderer/
│       ├── index.html        # 页面
│       ├── style.css         # 样式
│       └── script.ts         # 前端逻辑
├── electrobun.config.ts      # 构建配置
└── package.json

和 Electron 的结构很像,main.ts 对应 Electron 的 main.jsrenderer/ 对应渲染进程。

改造成 AI 聊天助手

我的目标:做一个桌面版的 AI 聊天工具,支持多模型切换(GPT-4o、Claude、DeepSeek 等),流式输出。

主进程:创建窗口 + IPC

// src/main.ts
import { BrowserWindow } from "electrobun/bun";

const win = new BrowserWindow({
  title: "AI Chat Desktop",
  width: 800,
  height: 600,
  url: "electrobun://renderer/index.html",
});

// 监听渲染进程发来的消息
win.webview.onMessage("chat-request", async (data) => {
  const { model, messages } = data;

  try {
    // 调用 AI API,流式返回
    const response = await fetch("https://api.ofox.ai/v1/chat/completions", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.OFOX_API_KEY}`,
      },
      body: JSON.stringify({
        model: model,
        messages: messages,
        stream: true,
      }),
    });

    const reader = response.body?.getReader();
    const decoder = new TextDecoder();

    while (reader) {
      const { done, value } = await reader.read();
      if (done) break;

      const chunk = decoder.decode(value);
      const lines = chunk.split("\n").filter((line) => line.startsWith("data:"));

      for (const line of lines) {
        const json = line.slice(5).trim();
        if (json === "[DONE]") continue;

        try {
          const parsed = JSON.parse(json);
          const content = parsed.choices?.[0]?.delta?.content;
          if (content) {
            // 实时推送到渲染进程
            win.webview.sendMessage("chat-stream", { content });
          }
        } catch {}
      }
    }

    win.webview.sendMessage("chat-done", {});
  } catch (err) {
    win.webview.sendMessage("chat-error", { error: String(err) });
  }
});

这里有个细节:Electrobun 的 IPC 通信用的是 onMessage / sendMessage,比 Electron 的 ipcMain / ipcRenderer 简洁不少。不需要单独写 preload 脚本。

渲染进程:聊天界面

<!-- src/renderer/index.html -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>AI Chat</title>
  <link rel="stylesheet" href="./style.css">
</head>
<body>
  <div id="app">
    <div class="header">
      <select id="model-select">
        <option value="gpt-4o">GPT-4o</option>
        <option value="claude-sonnet-4-20250514">Claude Sonnet 4</option>
        <option value="deepseek-chat">DeepSeek V3</option>
        <option value="qwen-plus">Qwen Plus</option>
      </select>
    </div>
    <div id="messages" class="messages"></div>
    <div class="input-area">
      <textarea id="input" placeholder="输入消息..." rows="3"></textarea>
      <button id="send-btn">发送</button>
    </div>
  </div>
  <script src="./script.ts"></script>
</body>
</html>
// src/renderer/script.ts
import { webview } from "electrobun/webview";

const messagesDiv = document.getElementById("messages")!;
const input = document.getElementById("input") as HTMLTextAreaElement;
const sendBtn = document.getElementById("send-btn")!;
const modelSelect = document.getElementById("model-select") as HTMLSelectElement;

let chatHistory: Array<{ role: string; content: string }> = [];
let currentAssistantMsg: HTMLDivElement | null = null;

function addMessage(role: string, content: string) {
  const div = document.createElement("div");
  div.className = `message ${role}`;
  div.textContent = content;
  messagesDiv.appendChild(div);
  messagesDiv.scrollTop = messagesDiv.scrollHeight;
  return div;
}

sendBtn.addEventListener("click", () => {
  const text = input.value.trim();
  if (!text) return;

  // 显示用户消息
  addMessage("user", text);
  chatHistory.push({ role: "user", content: text });

  // 创建助手消息占位
  currentAssistantMsg = addMessage("assistant", "") as HTMLDivElement;

  // 发送到主进程
  webview.sendMessage("chat-request", {
    model: modelSelect.value,
    messages: chatHistory,
  });

  input.value = "";
  sendBtn.disabled = true;
});

// 接收流式响应
webview.onMessage("chat-stream", (data) => {
  if (currentAssistantMsg) {
    currentAssistantMsg.textContent += data.content;
    messagesDiv.scrollTop = messagesDiv.scrollHeight;
  }
});

webview.onMessage("chat-done", () => {
  if (currentAssistantMsg) {
    chatHistory.push({
      role: "assistant",
      content: currentAssistantMsg.textContent || "",
    });
  }
  currentAssistantMsg = null;
  sendBtn.disabled = false;
});

// Ctrl+Enter 发送
input.addEventListener("keydown", (e) => {
  if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
    sendBtn.click();
  }
});

样式(简洁暗色主题)

/* src/renderer/style.css */
* { margin: 0; padding: 0; box-sizing: border-box; }

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  background: #1a1a2e;
  color: #eee;
  height: 100vh;
}

#app {
  display: flex;
  flex-direction: column;
  height: 100%;
}

.header {
  padding: 12px 16px;
  background: #16213e;
  border-bottom: 1px solid #333;
}

.header select {
  background: #0f3460;
  color: #eee;
  border: 1px solid #444;
  padding: 6px 12px;
  border-radius: 6px;
  font-size: 14px;
}

.messages {
  flex: 1;
  overflow-y: auto;
  padding: 16px;
}

.message {
  margin-bottom: 12px;
  padding: 10px 14px;
  border-radius: 10px;
  max-width: 80%;
  line-height: 1.6;
  white-space: pre-wrap;
}

.message.user {
  background: #0f3460;
  margin-left: auto;
}

.message.assistant {
  background: #1a1a3e;
  border: 1px solid #333;
}

.input-area {
  display: flex;
  gap: 8px;
  padding: 12px 16px;
  background: #16213e;
  border-top: 1px solid #333;
}

.input-area textarea {
  flex: 1;
  background: #0f3460;
  color: #eee;
  border: 1px solid #444;
  border-radius: 8px;
  padding: 10px;
  font-size: 14px;
  resize: none;
}

.input-area button {
  background: #e94560;
  color: white;
  border: none;
  padding: 10px 20px;
  border-radius: 8px;
  cursor: pointer;
  font-size: 14px;
}

.input-area button:hover { background: #c81e45; }
.input-area button:disabled { opacity: 0.5; cursor: not-allowed; }

打包体验

bun run build

打包完成后:

dist/
└── AI Chat Desktop.app   # macOS
    └── (总大小: ~64MB)

等等,说好的 12MB 呢?

这里要解释一下:12MB 是 Electrobun 框架本身的开销,加上你的业务代码和依赖。我的项目因为没有额外的 npm 包(AI 调用用的是原生 fetch),实际打包大约 64MB,主要是 Bun runtime 占了大头。

作为对比,同样功能的 Electron 版本打包后 310MB。差了将近 5 倍。

而且 Electrobun 有个杀手锏:差分更新。它内置了增量更新机制,版本迭代时只推送差异补丁,补丁大小可以小到 14KB。Electron 每次更新基本要重新下载整个 Chromium。

踩坑记录

1. Bun 版本兼容

Electrobun v1 要求 Bun >= 1.2。我一开始用的 1.1.x,bunx electrobun init 直接报了一堆类型错误。升级 Bun 后就好了:

bun upgrade

2. 环境变量加载

Bun 主进程不会自动读 .env 文件。需要手动加载:

// main.ts 顶部
import { $ } from "bun";
// 或者直接在 electrobun.config.ts 里配 env

我最后的方案是在 electrobun.config.tsenv 字段里写死(开发时),打包时从系统环境变量读取。

3. WebView 兼容性

macOS 上用的是 WebKit,不是 Chromium。这意味着一些 Chrome 特有的 API 不能用。我一开始用了 structuredClone 在 IPC 里传数据,结果在某些 macOS 版本上挂了。改成 JSON.parse(JSON.stringify(...)) 就没问题了。

4. 流式响应的坑

Bun 的 fetch 对 SSE 流式响应的支持和 Node.js 有点不一样。response.body 返回的是 ReadableStream,需要用 getReader() 来读,不能直接 for await...of。这个搞了我半小时。

值不值得用?

说实话,Electrobun 目前还是 v1 早期阶段,生态和 Electron 没法比。但如果你的场景是:

  • 轻量级工具类应用(不需要复杂原生功能)
  • 对包体积敏感(给客户分发不想让人等半天)
  • 团队全是前端,不想碰 Rust
  • 想尝鲜 Bun 生态

那完全值得试试。

我这个 AI 聊天桌面助手的完整流程:从 bunx electrobun init 到打包出可用的 .app,大概 3 小时(包括踩坑时间)。体验下来比第一次用 Electron 顺畅不少,至少不用折腾 webpack 配置和 preload 脚本。

API 层我用的是兼容 OpenAI 协议的聚合接口,改个 base_url 就能切不同模型,省得每个模型单独对接 SDK。如果你也想做类似的多模型桌面工具,这个思路可以参考。

完整代码我后续整理后会放 GitHub,有兴趣的可以先 mark 一下。

❌
❌