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~120ms6-7x
视频帧提取~2000ms~300ms6-7x
SHA-256 哈希(10MB)~150ms~30ms5x
JSON 解析(大文件)~50ms~50ms1x(无优势)
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/util

4.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 对比结果参考

任务纯 JSWasmWasm + Worker
SHA-256(10MB)145ms28ms30ms(不阻塞 UI)
JPEG → WebP820ms115ms120ms(不阻塞 UI)
视频帧提取N/A350ms360ms
JSON 解析45ms48ms不推荐

结论

  • 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 密集型任务无优势