阅读视图

发现新文章,点击刷新页面。

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

刷掘金热榜发现 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 桌面助手

前两天刷掘金热榜看到 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 一下。

❌