突破JS性能瓶颈:WebAssembly实战指南与性能对比

434 阅读5分钟

参考文档

WebAssembly概念

什么是 WebAssembly?

WebAssembly(简称 WASM)是一种二进制格式的代码执行方式,它使得浏览器可以运行高效的低级语言(如 C/C++、Rust、Go 等)编译过来的代码。

WebAssembly 的出现解决了传统 JavaScript 性能瓶颈的问题,特别适用于计算密集型任务、图形渲染、音视频处理等场景。它可以在现代浏览器中运行,具有跨平台、性能高、内存安全等优点。

核心特点

  • 高效执行:接近原生代码的执行速度

  • 安全沙盒:在隔离的环境中执行

  • 语言无关:可由多种编程语言编译生成

  • 平台无关:在所有现代浏览器中运行

  • 与 Web 平台兼容:可与 JavaScript 和 DOM 交互

应用场景

  1. 非常复杂的计算,计算密集型任务,rust、c 然后在浏览器端执行
  2. 图形渲染,skia + Webassembly = canvaskit
  3. 音视频剪辑,webcodes、FFmpeg(这个是脚本,那怎么在浏览器端执行呢?wasm)
  4. 高性能渲染库,3D、webGis、rust(photon)、skia

WebAssembly工具链

我们可以选用这些主流的工具链用于编写代码和编译wasm模块

  1. Emscripten:C/C++ 到 WebAssembly 的主流编译器
  2. Rust + wasm-pack:将 Rust 编译为 WebAssembly
  3. AssemblyScript:TypeScript 的严格子集,编译为 WebAssembly
  4. Go:支持编译为 WebAssembly
  5. wat2wasm:将文本格式转换为二进制格式

AssemblyScript

AssemblyScript 是一个将 TypeScript 编译为 WebAssembly 的工具,它能够让开发者在熟悉的 JavaScript/TypeScript 环境中,快速开发出高性能的 WebAssembly 模块。

它提供了一套方便的工程配置工具,相比于其他几种方式来讲,他在以下几个方面有优势

  • 语言是类TS的

  • 编译工具是前端相关技术栈的

通过以下示例即可快速入门assemblyScript

www.assemblyscript.org/getting-sta…

初始化一个新的 Node.js 模块:

npm init

安装 AssemblyScript 编译器。假设它不是生产环境所必需的,将其作为开发依赖项:

npm install --save-dev assemblyscript

安装完成后,编译器提供了一个方便的脚手架工具,可以快速设置一个新项目,这里是在当前目录下:

npx asinit .

asinit 命令会自动创建推荐的目录结构和配置文件

我们可以编写一个简单的示例,演示WebAssembly和原生js的速度差距

我们先在assembly中编写随机数相加代码

export function add(): f64 {
  let res: f64 = 0;
  for (let i = 0; i < 10000000; i++) {
    res += Math.random();
  }
  return res;
}

再写js代码

<!DOCTYPE html>
<html lang="en">
        <head>
                <script type="module">
                        import { add } from "./build/release.js";
                        // wasm 测速
                        performance.mark("start");
                        console.log(add());
                        performance.mark("end");
                        performance.measure("wasm add", "start", "end");
                        const measure = performance.getEntriesByName("wasm add")[0];
                        console.log(`wasm add took ${measure.duration}ms`);

                        // js 测速
                        function js_add() {
                                let res = 0;
                                for (let i = 0; i < 10000000; i++) {
                                        res += Math.random();
                                }
                                return res;
                        }

                        performance.mark("start");
                        console.log(js_add());
                        performance.mark("end");
                        performance.measure("js add", "start", "end");
                        const jsMeasure = performance.getEntriesByName("js add")[0];
                        console.log(`js add took ${jsMeasure.duration}ms`);
                </script>
        </head>
        <body></body>
</html>

注意在js中,直接循环是不行的,V8会尽量优化代码,可能出现的情况是,wasm在某些情况下速度还不如原生js

比如

function add(): number {
    let res = 0;
    for (let i = 0; i < 100000000; i++) {
            res += 1;
    }
    return res;
}

function js_add() {
    let res = 0;
    for (let i = 0; i < 100000000; i++) {
      res += 1;
    }
    return res;
}

rust + wasm-pack

我们使用wasm-pack的例子,创建一个示例项目 drager.github.io/wasm-pack/

wasm-pack 可以直接将 rust项目编译为 wasm的 npm包,在js中直接引入使用

  1. 运行 wasm-pack new hello-wasm
  2. cd hello-wasm
  3. 运行 wasm-pack build --target web
  4. 这个工具会在 pkg 目录中生成文件
  5. 导入它: import init, { greet } from "./pkg/hello_wasm.js" ,初始化它:await init(),然后使用它:greet()

在html文件中引入wasm

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <script type="module">
    import init, { greet } from "./pkg/wpack_demo.js"
    await init()
    greet()
  </script>
</body>
</html>

就可以弹出alert

c + emcc

这里使用c + emcc简单入门wasm

我们以a+b的例子入门emcc编译wasm

首先编写c语言代码

int add(int a, int b) {
    return a + b;
}

运行编译命令

emcc add.c -O2 -s MODULARIZE=1 -s EXPORT_ES6=1 -s EXPORTED_FUNCTIONS='["_add"]' -o add.js

会得到一个胶水文件add.js和一个wasm模块

编写html文件

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>emcc add demo</title>
  </head>
  <body>
    <script type="module">
      import CreateModule from "./add.js";
      const Module = await CreateModule();
      console.log(Module._add(5, 6));
    </script>
  </body>
</html>

在浏览器中运行

编译模式

emcc包括如下几种编译模式,

  1. 默认(js胶水文件 + .wasm
  • 命令示例:emcc add.c -O2 -s EXPORTED_FUNCTIONS='["_add"]' -o add.js

  • 使用:直接引用 add.js,等待 runtime 初始化后调用 Module._add 或 Module.ccall。

<!doctype html>
<html>
<body>
<script src="./add.js"></script>
<script>
  Module.onRuntimeInitialized = () => {
    const r = Module._add(1, 2); // 或 Module.ccall('add', 'number', ['number','number'], [1,2])
    console.log('1+2=', r);
  };
</script>
</body>
</html>

2. Standalone WASM(纯 wasm)

  • 命令示例:emcc add.c -O2 -s STANDALONE_WASM=1 -s EXPORTED_FUNCTIONS='["_add"]' -Wl,--no-entry -o add.wasm

  • 使用:用原生 WebAssembly.instantiateStreaming / instantiate 加载 exports.add。若 wasm 需要 WASI,请传入对应 imports(通常不需要如果按上面命令编译)。

<!doctype html>
<html>
<body>
<script>
async function load() {
  const res = WebAssembly.instantiateStreaming
    ? await WebAssembly.instantiateStreaming(fetch('add.wasm'), {})
    : await WebAssembly.instantiate(await (await fetch('add.wasm')).arrayBuffer(), {});
  const { add } = res.instance.exports;
  console.log('1+2=', add(1,2));
}
load().catch(e => console.error(e));
</script>
</body>
</html>

3. 单文件(SINGLE_FILE,把 wasm 内联到 add.js)

  • 命令示例:emcc add.c -O2 -s SINGLE_FILE=1 -s EXPORTED_FUNCTIONS='["_add"]' -o add.js

  • 使用:与默认相同,但不需要单独的 .wasm 文件(部署单文件方便)。

<!doctype html>
<html>
<body>
<script src="./add.js"></script>
<script>
  Module.onRuntimeInitialized = () => {
    console.log(Module._add(3,4));
  };
</script>
</body>
</html>

4. MODULARIZE(可多次实例化的 JS wrapper)

  • 命令示例:emcc add.c -O2 -s MODULARIZE=1 -s EXPORT_NAME='CreateModule' -s EXPORTED_FUNCTIONS='["_add"]' -o add.js

  • 使用:先加载脚本,再通过 CreateModule() 得到 Module 实例(返回 Promise)。

<!doctype html>
<html>
<body>
<script src="./add.js"></script>
<script>
CreateModule().then(Module => {
  console.log('1+2=', Module._add(1,2));
});
</script>
</body>
</html>

5. ES6 模块化(EXPORT_ES6 + MODULARIZE)

  • 命令示例:emcc add.c -O2 -s MODULARIZE=1 -s EXPORT_ES6=1 -s EXPORTED_FUNCTIONS='["_add"]' -o add.js

  • 使用:作为 ES 模块 import,适合现代打包器/浏览器。

<!doctype html>
<html>
<body>
<script type="module">
import CreateModule from './add.js';
const Module = await CreateModule();
console.log(Module._add(5,6));
</script>
</body>
</html>

我们使用更适合ESM模块的模式即可

WebAssembly API

对于wasm模块,浏览器和Node.js提供了一套标准的WebAssembly API 用于操作

参考文档:

developer.mozilla.org/zh-CN/docs/…

WebAssembly 全局对象

  • 角色:命名空间,包含所有 WebAssembly 相关 API

  • 位置:全局作用域,如 window.WebAssembly

WebAssembly.Module
  • 作用:表示已编译但未实例化的 WebAssembly 模块

  • 特点

    • 可序列化(支持 postMessage、IndexedDB 存储)

    • 是无状态的蓝图,可创建多个实例

WebAssembly.Instance
  • 作用:已实例化的 WebAssembly 模块

  • 特点

    • 包含所有内存状态

    • 暴露导出的函数和内存供 JS 调用

WebAssembly.Memory
  • 作用:表示 WebAssembly 线性内存

  • 特点

    • 以页(64KB)为单位分配

    • 可动态扩展(grow)

    • 通过 .buffer 暴露为 ArrayBuffer,实现 JS/Wasm 内存共享

WebAssembly.Table
  • 作用:存储函数引用的可调整大小数组

  • 特点

    • 支持间接函数调用

    • 常用于动态链接和函数指针模拟

WebAssembly.Global
  • 作用:在模块间共享可变全局值

  • 特点

    • 可设置为可变或不可变
    • 支持跨实例共享状态