Nuxt4 集成 WebAssembly
JavaScript 在数值计算、图像处理、音视频编解码等 CPU 密集型任务上有天然瓶颈。WebAssembly(Wasm)可以在浏览器中以接近原生的速度运行 C/C++/Rust 编译的代码。本章介绍如何在 Nuxt4 项目中集成 Wasm 模块——从 Vite 配置、加载初始化、SSR 兼容到实战场景(视频缩略图生成、图片压缩、加密运算),并给出 JS vs Wasm 的性能对比数据。
1. WebAssembly 基础
1.1 什么时候需要 Wasm
| 场景 | JS 耗时 | Wasm 耗时 | 提升倍数 |
|---|---|---|---|
| 图片压缩(1MB JPEG → WebP) | ~800ms | ~120ms | 6-7x |
| 视频帧提取 | ~2000ms | ~300ms | 6-7x |
| SHA-256 哈希(10MB) | ~150ms | ~30ms | 5x |
| JSON 解析(大文件) | ~50ms | ~50ms | 1x(无优势) |
| DOM 操作 | JS 更快 | 有跨界开销 | - |
结论:CPU 密集型计算用 Wasm,IO 密集型和 DOM 操作用 JS。
1.2 Wasm 在浏览器中的执行模型
┌──────────────────────────────┐
│ JavaScript │
│ ┌────────┐ ┌──────────┐ │
│ │ 调用 │───→│ Wasm 模块 │ │
│ │ JS API │←───│ 线性内存 │ │
│ └────────┘ └──────────┘ │
│ ↕ │
│ ┌────────────────────────┐ │
│ │ Web Worker (可选) │ │
│ │ 不阻塞主线程 │ │
│ └────────────────────────┘ │
└──────────────────────────────┘
2. Vite 配置
2.1 vite-plugin-wasm
pnpm add -D vite-plugin-wasm vite-plugin-top-level-await// nuxt.config.ts
import wasm from 'vite-plugin-wasm'
import topLevelAwait from 'vite-plugin-top-level-await'
export default defineNuxtConfig({
vite: {
plugins: [wasm(), topLevelAwait()],
optimizeDeps: {
exclude: ['@ffmpeg/ffmpeg', '@ffmpeg/util'],
},
},
})2.2 直接加载 .wasm 文件
// Vite 支持直接 import .wasm(需要 vite-plugin-wasm)
import init, { compress_image } from '@aspect/image-codec/image_codec_bg.wasm'
await init()
const result = compress_image(inputBuffer, quality)2.3 手动加载方式
// composables/useWasm.ts
export function useWasm<T>(wasmUrl: string) {
const module = ref<T | null>(null)
const loading = ref(true)
const error = ref<Error | null>(null)
async function load() {
try {
loading.value = true
const response = await fetch(wasmUrl)
const buffer = await response.arrayBuffer()
const { instance } = await WebAssembly.instantiate(buffer, {
env: { memory: new WebAssembly.Memory({ initial: 256 }) },
})
module.value = instance.exports as T
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
if (import.meta.client) {
load()
}
return { module, loading, error, reload: load }
}3. SSR 兼容
3.1 核心问题
Wasm 依赖浏览器的 WebAssembly API,Node.js 虽然也支持,但很多 Wasm 库(如 FFmpeg.wasm)只设计给浏览器用。SSR 时直接 import 会报错。
3.2 方案 1:.client 后缀隔离
<!-- components/ImageCompressor.client.vue -->
<script setup lang="ts">
import init, { compress } from 'image-codec-wasm'
const ready = ref(false)
onMounted(async () => {
await init()
ready.value = true
})
async function compressImage(file: File): Promise<Blob> {
const buffer = new Uint8Array(await file.arrayBuffer())
const result = compress(buffer, 80)
return new Blob([result], { type: 'image/webp' })
}
</script>3.3 方案 2:动态导入守卫
// composables/useImageCodec.ts
export function useImageCodec() {
const ready = ref(false)
let codec: any = null
async function init() {
if (import.meta.server) return
const module = await import('image-codec-wasm')
await module.default()
codec = module
ready.value = true
}
async function compress(buffer: Uint8Array, quality: number): Promise<Uint8Array> {
if (!codec) throw new Error('Codec not initialized')
return codec.compress(buffer, quality)
}
return { init, compress, ready }
}3.4 方案 3:服务端用原生库
// server/api/image/compress.post.ts
import sharp from 'sharp'
export default defineEventHandler(async (event) => {
const body = await readMultipartFormData(event)
const file = body?.find(f => f.name === 'image')
if (!file) throw createError({ statusCode: 400 })
const compressed = await sharp(file.data)
.webp({ quality: 80 })
.toBuffer()
setResponseHeader(event, 'Content-Type', 'image/webp')
return compressed
})策略:客户端用 Wasm 做实时预览压缩,上传到服务端后用 sharp(C++ 绑定)做最终处理。
4. 实战:FFmpeg.wasm 视频处理
4.1 安装
pnpm add @ffmpeg/ffmpeg @ffmpeg/util4.2 视频缩略图提取
<!-- components/VideoThumbnailGenerator.client.vue -->
<script setup lang="ts">
import { FFmpeg } from '@ffmpeg/ffmpeg'
import { fetchFile, toBlobURL } from '@ffmpeg/util'
const ffmpeg = new FFmpeg()
const loaded = ref(false)
const thumbnail = ref<string | null>(null)
const progress = ref(0)
async function loadFFmpeg() {
const baseURL = 'https://unpkg.com/@ffmpeg/[email protected]/dist/esm'
await ffmpeg.load({
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
})
loaded.value = true
}
async function extractThumbnail(file: File) {
if (!loaded.value) await loadFFmpeg()
ffmpeg.on('progress', ({ progress: p }) => {
progress.value = Math.round(p * 100)
})
await ffmpeg.writeFile('input.mp4', await fetchFile(file))
await ffmpeg.exec([
'-i', 'input.mp4',
'-ss', '00:00:01',
'-frames:v', '1',
'-q:v', '2',
'thumbnail.jpg',
])
const data = await ffmpeg.readFile('thumbnail.jpg')
const blob = new Blob([data], { type: 'image/jpeg' })
thumbnail.value = URL.createObjectURL(blob)
}
</script>
<template>
<div>
<input type="file" accept="video/*" @change="extractThumbnail($event.target.files[0])" />
<div v-if="progress > 0 && progress < 100">处理中: {{ progress }}%</div>
<img v-if="thumbnail" :src="thumbnail" class="mt-4 max-w-md rounded" />
</div>
</template>4.3 视频格式转换
async function convertToMp4(inputFile: File): Promise<Blob> {
await ffmpeg.writeFile('input', await fetchFile(inputFile))
await ffmpeg.exec([
'-i', 'input',
'-c:v', 'libx264',
'-preset', 'fast',
'-c:a', 'aac',
'output.mp4',
])
const data = await ffmpeg.readFile('output.mp4')
return new Blob([data], { type: 'video/mp4' })
}5. 实战:图片压缩
5.1 使用 squoosh-wasm
// composables/useImageCompress.ts
export function useImageCompress() {
async function compressToWebP(file: File, quality = 80): Promise<Blob> {
if (import.meta.server) throw new Error('Client only')
const { encode } = await import('@aspect/image-codec')
const bitmap = await createImageBitmap(file)
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height)
const ctx = canvas.getContext('2d')!
ctx.drawImage(bitmap, 0, 0)
const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height)
const result = encode(imageData.data, bitmap.width, bitmap.height, quality)
return new Blob([result], { type: 'image/webp' })
}
return { compressToWebP }
}5.2 Web Worker 避免阻塞主线程
// workers/image-compress.worker.ts
self.onmessage = async (e: MessageEvent) => {
const { imageData, width, height, quality } = e.data
const { encode } = await import('@aspect/image-codec')
const result = encode(new Uint8Array(imageData), width, height, quality)
self.postMessage({ result }, [result.buffer])
}// composables/useImageCompressWorker.ts
export function useImageCompressWorker() {
const worker = import.meta.client
? new Worker(new URL('../workers/image-compress.worker.ts', import.meta.url), { type: 'module' })
: null
function compress(imageData: ArrayBuffer, width: number, height: number, quality: number): Promise<Uint8Array> {
return new Promise((resolve) => {
worker!.onmessage = (e) => resolve(e.data.result)
worker!.postMessage({ imageData, width, height, quality }, [imageData])
})
}
onUnmounted(() => worker?.terminate())
return { compress }
}6. 性能对比与基准测试
6.1 基准测试工具
// utils/benchmark.ts
export async function benchmark(name: string, fn: () => Promise<void>, iterations = 10) {
const times: number[] = []
for (let i = 0; i < iterations; i++) {
const start = performance.now()
await fn()
times.push(performance.now() - start)
}
const avg = times.reduce((a, b) => a + b) / times.length
const min = Math.min(...times)
const max = Math.max(...times)
console.table({ name, avg: `${avg.toFixed(1)}ms`, min: `${min.toFixed(1)}ms`, max: `${max.toFixed(1)}ms` })
return { avg, min, max }
}6.2 对比结果参考
| 任务 | 纯 JS | Wasm | Wasm + Worker |
|---|---|---|---|
| SHA-256(10MB) | 145ms | 28ms | 30ms(不阻塞 UI) |
| JPEG → WebP | 820ms | 115ms | 120ms(不阻塞 UI) |
| 视频帧提取 | N/A | 350ms | 360ms |
| JSON 解析 | 45ms | 48ms | 不推荐 |
结论:
- Wasm 在计算密集型任务上提升 5-7 倍
- Web Worker 额外开销 < 5ms,但保持 UI 流畅
- IO 密集型任务(JSON、网络请求)不需要 Wasm
本章小结
- 适用场景:图像压缩、视频处理、加密运算等 CPU 密集型任务
- Vite 配置:vite-plugin-wasm + top-level-await,排除特定包的优化
- SSR 兼容:.client.vue 后缀、import.meta.client 守卫、服务端用原生库替代
- FFmpeg.wasm:浏览器端视频缩略图提取、格式转换,约 300ms 完成
- Web Worker:Wasm 放到 Worker 中避免阻塞主线程,额外开销 < 5ms
- 性能:计算密集型任务 Wasm 提升 5-7 倍,IO 密集型任务无优势