Rust 编译为WebAssembly 在前端项目中使用

2023年 10月 27日 111.3k 0

前言

最近,不是加大了对Rust相关文章的输出吗,在评论区或者私信区。有一些不同的声音说:“Rust没有前途,然后...."。其实呢,看一个技术是否有需要学习的动力。想必大家的底层理由都是「一切都是向钱看」,毕竟在国内大家都是业务为主,想自己纯手搞一套符合自己的技术框架和范式,这是不切实际的。(当然也不能一杆子打死,还是有很多技术大牛的)现在大家纠结或者对这个技术属于观望态度,无非就是在平时开发工作中没有涉及到的点。

同时,由于国内技术的「滞后性」,有一些应用场景其实还是处于蛮荒的状态。(不是崇洋媚外,事实确实如此)。所以,在一些可以用到新的技术点的方向上,国内还是处于蓝海阶段。

所以,本着对该技术的独有关注度,我还是选择义无反顾的投身到学习和实际中。「冲破黎明之前的黑暗,你会拥有太阳,而晨曦中第一缕阳光也是为你而耀眼」。

图片图片

而具体,Rust到底能给你带来点啥,我们之前有文章讲过,这里就不在赘述了。

Last but not leaset,由于现在本人暂时专注于前端领域居多,所以我更多关注Rust能为前端带来点啥。而说到Rust和前端,第一点的联想就是:WebAssembly。(如果,不了解何为WebAssembly,可以参考我们之前的文章浏览器第四种语言-WebAssembly,里面的例子是用Emscripten写的)

其实,我们之前写过如何用C写wasm,也写过WebAssembly-C与JS互相操作等文章。但是,由于一些不可言喻的原因搁置了。

我们今天将使用Rust创建一个WebAssembly Hello World程序。我们将深入了解由wasm-bindgen生成的代码,以及它们如何共同协作来帮助我们进行开发。我们还将使用wabt来探索生成的wasm代码。这将使我们更好地理解Rust WebAssembly,并为我们的开发奠定良好的基础。

好了,天不早了,干点正事哇。

我们能所学到的知识点

  • 前置知识点
  • 项目搭建
  • 原理探析
  • 内容拓展
  • 1. 前置知识点

    「前置知识点」,只是做一个概念的介绍,不会做深度解释。因为,这些概念在下面文章中会有出现,为了让行文更加的顺畅,所以将本该在文内的概念解释放到前面来。「如果大家对这些概念熟悉,可以直接忽略」同时,由于阅读我文章的群体有很多,所以有些知识点可能「我视之若珍宝,尔视只如草芥,弃之如敝履」。以下知识点,请「酌情使用」。

    安装Rust

    如果是你一个Rust萌新,我们也给你提供Rust环境配置和入门指南。

    如果,想独立完成安装,可以到Rust 安装页面跟着教程安装。

    在安装成功Rust后,它会安装一个名为rustup的工具,这个工具能让我们管理多个不同版本的 Rust。默认情况下,它会安装用于惯常 Rust 开发的 stable 版本 Rust Release。

    Rustup 会安装

    • Rust 的编译器 rustc
    • Rust 的包管理工具 cargo
    • Rust 的标准库 rust-std
    • 以及一些有用的文档 rust-docs

    因为,我本机已经安装好了Rust。我们可以通过rustup --version来查看rustup的版本。以下是我本机的rustup版本信息。下文中所有的代码,都基于该版本。

    rustup --version
    rustup 1.26.0 (5af9b9484 2023-04-05)

    安装WebAssembly二进制工具包(wabt)

    图片图片

    这些工具旨在用于开发工具链或其他系统,这些系统希望「操作WebAssembly文件」。与WebAssembly规范解释器不同(该解释器旨在尽可能简单、声明性和“规范性”),这些工具是用C/C++编写的,并设计成更容易集成到其他系统中。这些工具不旨在提供优化平台或更高级的编译目标;相反,它们旨在实现与规范的完全适应和遵从。

    我们可以利用brew来在Mac环境下安装。

    图片图片

    2. 项目搭建

    2.1 安装wasm-bindgen

    我们可以通过cargo install --list来查看在$HOME/.cargo/bin位置安装过的Rust二进制文件。

    在一些其他的教程中可以不使用wasm-bindgen构建Hello World程序,但是在本文中,我们将使用它,因为它在Rust WebAssembly开发中是必不可少的。

    cargo install wasm-bindgen-cli

    Rust WebAssembly允许我们将WebAssembly模块有针对性地插入到现有的JavaScript应用程序中,尤其是在「性能关键的代码路径」中。我们可以将wasm-bindgen视为一种工具,它通过生成用于JavaScript和WebAssembly之间高效交互的「粘合代码」和绑定来帮助我们实现丝滑的交互体验。

    2.2 创建Rust WebAssembly项目

    巴拉拉小魔仙,念诵如下咒语,构建一个Rust WebAssembly项目。

    cargo new hello_world --lib

    上面的代码是使用Cargo工具创建一个新的Rust项目,项目的名称是hello_world,并且指定它是一个库(--lib)。这将创建一个包含基本项目结构的文件夹,其中包括一个Cargo.toml文件和一个src文件夹。

    +-- Cargo.toml
    +-- src
        +-- lib.rs
    • Cargo.toml文件用于管理项目的依赖和配置
    • src文件夹包含项目的Rust源代码文件
    • 项目名称hello_world是一个示例名称,我们可以根据自己的需求为项目指定不同的名称。

    2.3 修改Cargo.toml配置项

    使用宇宙最强IDE -VScode,打开Cargo.toml文件。我们应该会看到以下内容。

    [package]
    name = "hello_world"
    version = "0.1.0"
    authors = ["789"]
    edition = "2021"
    
    [dependencies]

    将其修改成下面的内容

    [package]
    name = "hello_world"
    version = "0.1.0"
    authors = ["789"]
    edition = "2021"
    
    [lib]
    crate-type = ["cdylib"]
    
    [dependencies]
    wasm-bindgen = "0.2"

    上面的大部分字段大家都能看懂,其中lib项的配置,这里稍微解释一下:

    crate-type = ["cdylib"]: 这一行「指定了生成的库的类型」。在这里,crate-type 设置为["cdylib"],这表示我们正在创建一个动态链接库(C-compatible dynamic library)。这用于编译一个供其他编程语言加载的动态库。此输出类型将在Linux上创建*.so文件,在macOS上创建*.dylib文件,在Windows上创建*.dll文件。

    这种类型的库可以被其他编程语言调用,因为它们与C语言兼容。这对于与WebAssembly(Wasm)互操作性很重要,因为Wasm通常需要与C语言接口进行交互。因此,cdylib 表示该库是一个可供其他语言使用的动态链接库。

    2.4 编辑lib.rs

    打开src/lib.rs文件。将其更改为以下内容:

    extern crate wasm_bindgen;
    use wasm_bindgen::prelude::*;
    
    // 导入 'window.alert'
    #[wasm_bindgen]
    extern "C" {
        fn alert(s: &str);
    }
    
    // 导出一个 'helloworld' 函数
    #[wasm_bindgen]
    pub fn helloworld(name: &str) {
        alert(&format!("Hello World : {}!", name));
    }

    我们简单解释一下核心代码:

  • extern crate wasm_bindgen;: 这一行声明了对wasm_bindgen库的依赖。wasm_bindgen是一个Rust库,用于构建Wasm模块并提供与JavaScript的互操作性。在 Rust 当中,库被称为crates,因为我们使用的是一个外部库,所以有 extern。
  • use wasm_bindgen::prelude::*;: 这一行导入了wasm_bindgen库的预导出(prelude)模块中的所有内容,以便在后续代码中使用。
  • 在 Rust 中调用来自 JavaScript 的外部函数

    #[wasm_bindgen]
    extern "C" {
        fn alert(s: &str);
    }

    #[wasm_bindgen]: 在 #[] 中的内容叫做 "属性",并以某种方式改变下面的语句。#[wasm_bindgen]是一个「属性标记」,用于指定与WebAssembly互操作相关的特性。

    extern "C" { fn alert(s: &str); }: 这里声明了一个「外部函数」alert,它使用extern "C" 指定了C ABI(应用二进制接口),这意味着它「可以与C语言进行交互」。「这个alert函数没有在Rust中实现,而是在JavaScript中实现,用于在浏览器中显示警告框」。

    在 JavaScript 中调用的 Rust 函数

    #[wasm_bindgen]
    pub fn helloworld(name: &str) {
        alert(&format!("Hello World : {}!", name));
    }

    #[wasm_bindgen] pub fn helloworld(name: &str): 这是一个Rust函数helloworld,它被标记为wasm_bindgen,这意味着它「可以被JavaScript调用」。这个函数接受一个「字符串参数」name,然后调用「之前声明」的alert函数,以显示带有Hello World消息的弹框,并在消息中包括name参数的内容。

    2.5 编译代码

    在命令行中输入以下命令:

    cargo build --target wasm32-unknown-unknown

    如果未安装对应的库,控制台会给出提示。

    图片图片

    那我们就照猫画虎的操作一下:

    rustup target add wasm32-unknown-unknown❞

  • cargo build: 这是 Cargo 工具的命令,用于构建 Rust 项目。它会编译项目的源代码并生成可执行文件或库文件,具体取决于项目的类型。
  • --target wasm32-unknown-unknown: 这部分是构建的目标参数。--target 标志用于指定要构建的目标平台。在这里,wasm32-unknown-unknown 是指定了 WebAssembly 目标平台。这告诉 Cargo 生成「适用于 WebAssembly 的二进制文件」,而不是生成本地平台的二进制文件。
  • 当运行这个命令后,Cargo 会使用 Rust 编译器(Rustc)以及与 WebAssembly 相关的工具链,将 Rust 代码编译为 WebAssembly 格式的二进制文件。这个生成的 Wasm 文件可以在浏览器中运行,或与其他支持 WebAssembly 的环境一起使用。

    运行结果如下:

    cargo build --target wasm32-unknown-unknown 命令的「默认输出位置」是在项目的 target 目录下,具体位置是:

    target/wasm32-unknown-unknown/debug/

    在这个目录下,我们会找到生成的 WebAssembly 文件(通常是一个 .wasm 文件),以及其他与编译过程相关的文件。

    图片图片

    2.6 构建Web服务器

    既然,我们通过上述的魔法,将Rust程序编译为了可以在浏览器环境下引用执行的格式。「为了这口醋,我们还专门包顿饺子」。

    我们需要一个Web服务器来测试我们的WebAssembly程序。我们将使用Webpack,我们需要创建三个文件:index.js、package.json和webpack.config.js。

    下面的代码,我们最熟悉不过了,就不解释了。

    index.js

    // 直接引入了,刚才编译后的文件
    const rust = import('./pkg/hello_world.js');
    
    rust
      .then(m => m.helloworld('World!'))
      .catch(console.error);

    package.json

    {
      "scripts": {
        "build": "webpack",
        "serve": "webpack-dev-server"
      },
    
      "devDependencies": {
        "@wasm-tool/wasm-pack-plugin": "0.4.2",
        "text-encoding": "^0.7.0",
        "html-webpack-plugin": "^3.2.0",
        "webpack": "^4.29.4",
        "webpack-cli": "^3.1.1",
        "webpack-dev-server": "^3.1.0"
      }
    }

    webpack.config.js

    const path = require('path');
    const HtmlWebpackPlugin = require('html-webpack-plugin');
    const webpack = require('webpack');
    const WasmPackPlugin = require("@wasm-tool/wasm-pack-plugin");
    
    module.exports = {
        entry: './index.js',
        output: {
            path: path.resolve(__dirname, 'dist'),
            filename: 'index.js',
        },
        plugins: [
            new HtmlWebpackPlugin(),
            new WasmPackPlugin({
                crateDirectory: path.resolve(__dirname, ".")
            }),
            // 让这个示例在不包含`TextEncoder`或`TextDecoder`的Edge浏览器中正常工作。
            new webpack.ProvidePlugin({
                TextDecoder: ['text-encoding', 'TextDecoder'],
                TextEncoder: ['text-encoding', 'TextEncoder']
            })
        ],
        mode: 'development'
    };

    安装指定的依赖。

    npm install webpack --save-dev
    npm install webpack-cli --save-dev
    npm install webpack-dev-server --save-dev
    npm install html-webpack-plugin --save-dev
    npm install @wasm-tool/wasm-pack-plugin --save-dev
    npm install text-encoding --save-dev

    2.7 构建&运行程序

    使用npm run build构建程序。

    使用npm run serve运行Hello World程序

    在浏览器中打开localhost:8080,我们将看到一个显示 Hello World! 的弹窗。

    图片图片

    到目前为止,我们已经构建了一个wasm并且能够和js实现功能交互的项目。其实,到这里已经完成了,我们这篇文章的使命。但是,在这里戛然而止,感觉缺失点啥。所以,我们继续深挖上面的项目的实现原理。

    3. 原理探析

    在使用cargo和wasm_bindgen编译源代码时,会在pkg文件中「自动生成」以下文件:

    • "hello_world_bg.wasm"
    • "hello_world.js"
    • "hello_world.d.ts"
    • "package.json"

    这些文件也可以通过使用以下wasm-bindgen命令手动生成:

    wasm-bindgen target/wasm32-unknown-unknown/debug/hello_world.wasm --out-dir ./pkg

    浏览器调用顺序

    以下显示了当我们在浏览器中访问localhost:8080时发生的函数调用序列。

  • index.js
  • hello_world.js (调用hello_world_bg.js)
  • helloworld_bg.wasm
  • index.js

    const rust = import('./pkg/hello_world.js');
    
    rust
      .then(m => m.helloworld('World!'))
      .catch(console.error);

    index.js 导入了 hello_world.js 并调用其中的 helloworld 函数。

    hello_world.js

    下面是hello_world.js的内容,在其中它调用了helloworld_bg.wasm

    import * as wasm from "./hello_world_bg.wasm";
    import { __wbg_set_wasm } from "./hello_world_bg.js";
    __wbg_set_wasm(wasm);
    export * from "./hello_world_bg.js";

    hello_world_bg.js

    // ...省去了部分代码
    export function helloworld(name) {
        const ptr0 = passStringToWasm0(name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
        const len0 = WASM_VECTOR_LEN;
        wasm.helloworld(ptr0, len0);
    }

    hello_world_bg.js 文件是由wasm-bindgen自动生成的,它包含了用于将DOM和JavaScript函数导入到Rust中的JavaScript粘合代码。它还在生成的WebAssembly函数上向JavaScript公开了API。

    Rust WebAssembly专注于将WebAssembly与现有的JavaScript应用程序集成在一起。为了实现这一目标,我们需要在JavaScript和WebAssembly函数之间「传递不同的值、对象或结构。这并不容易,因为需要协调两个不同系统的不同对象类型」。

    更糟糕的是,当前WebAssembly仅支持「整数」和「浮点数」,不支持字符串。这意味着我们不能简单地将字符串传递给WebAssembly函数。

    要将字符串传递给WebAssembly,我们需要「将字符串转换为数字」(请注意在webpack.config.js中指定的TextEncoderAPI),将这些数字放入WebAssembly的内存空间中,最后「返回一个指向字符串的指针」给WebAssembly函数,以便在JavaScript中使用它。在最后,我们需要释放WebAssembly使用的字符串内存空间。

    如果我们查看上面的JavaScript代码,这正是自动执行的操作。helloworld函数首先调用passStringToWasm。

    • 这个函数在WebAssembly中「创建一些内存空间」,将我们的字符串转换为数字,将数字写入内存空间,并返回一个指向字符串的指针。

    图片图片

    • 然后将指针传递给wasm.helloworld来执行JavaScript的alert。最后,wasm.__wbindgen_free释放了内存。

    如果只是传递一个简单的字符串,我们可能可以自己处理,但考虑到当涉及到更复杂的对象和结构时,这个工作会很快变得非常复杂。这说明了wasm-bindgen在Rust WebAssembly开发中的重要性。

    反编译wasm到txt

    在前面的步骤中,我们注意到wasm-bindgen生成了一个hello_world.js文件,其中的函数调用到我们生成的hello_world_bg.wasm中的WebAssembly代码。

    基本上,hello_world.js充当其他JavaScript(如index.js)与生成的WebAssembly的helloworld_bg.wasm之间的桥梁。

    我们可以通过输入以下命令进一步探索helloworld_bg.wasm:

    wasm2wat hello_world_bg.wasm > hello_world.txt

    这个命令使用wabt将WebAssembly转换为WebAssembly文本格式,并将其保存到一个hello_world.txt文件中。打开helloworld.txt文件,然后查找$helloworld函数。这是我们在src/lib.rs中定义的helloworld函数的生成WebAssembly函数。

    $helloworld函数

    图片图片

    在helloworld.txt中查找以下行:

    (export "helloworld" (func $helloworld))

    这一行导出了wasm.helloworld供宿主调用的WebAssembly函数。我们通过hello_world_bg.js中的wasm.helloworld来调用这个WebAssembly函数。

    图片图片

    接下来,查找以下行:

    (import "./hello_world_bg.js" "__wbg_alert_9ea5a791b0d4c7a3" (func $hello_world::alert::__wbg_alert_9ea5a791b0d4c7a3::h93c656ecd0e94e40 (type 4)))

    这对应于在hello_world_bg.js中生成的以下JavaScript函数:

    export function __wbg_alert_9ea5a791b0d4c7a3() { return logError(function (arg0, arg1) {
        alert(getStringFromWasm0(arg0, arg1));
    }, arguments) };

    这是wasm-bindgen提供的「粘合部分」,帮助我们在WebAssembly中使用JavaScript函数或DOM。

    最后,让我们看看wasm-bindgen生成的其他文件。

    hello_world.d.ts

    这个.d.ts文件包含JavaScript粘合的TypeScript类型声明,如果我们的现有JavaScript应用程序正在使用TypeScript,它会很有用。我们可以对调用WebAssembly函数进行「类型检查」,或者让我们的IDE提供自动完成。如果我们不使用TypeScript,可以安全地忽略这个文件。

    package.json

    package.json文件包含有关生成的JavaScript和WebAssembly包的元数据。它会自动从我们的Rust代码中填充所有npm依赖项,并使我们能够发布到npm。

    4. 内容拓展

    再次看一下以下代码:

    hello_world_bg.js

    function helloworld(name) {
        const ptr0 = passStringToWasm0(name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
        const len0 = WASM_VECTOR_LEN;
        wasm.helloworld(ptr0, len0);
    }

    该代码用于分配和释放内存,这一切都是「由程序自动处理」的。不需要垃圾回收器或完整的框架引擎,使得使用Rust编写的WebAssembly应用程序或模块变得小巧且优化。其他需要垃圾回收器的语言将需要包含用于其底层框架引擎的wasm代码。因此,无论它们有多么优化,其大小都不会小于Rust提供的大小。这使得Rust WebAssembly成为一个不错的选择,如果我们需要将小型WebAssembly模块集成或注入到JavaScript Web应用程序中。

    除了Hello World之外,还有一些其他需要注意的事项:

    web-sys

    使用wasm-bindgen,我们可以通过使用extern在Rust WebAssembly中调用JavaScript函数。请记住src/lib.rs中的以下代码:

    #[wasm_bindgen]
    extern "C" {
        fn alert(s: &str);
    }

    Web具有大量API,从DOM操作到WebGL再到Web Audio等等。因此,如果我们的Rust WebAssembly程序增长,并且我们需要对Web API进行多次不同的调用,我们将需要花时间编写大量的extern代码。

    web-sys充当wasm-bindgen的前端,为所有Web API提供原始绑定。

    这意味着如果我们使用web-sys,可以节省时间,而不必编写extern代码。

    图片图片

    引入web-sys

    将web-sys添加为Cargo.toml的依赖项:

    [dependencies]
    wasm-bindgen = "0.2"
    
    [dependencies.web-sys]
    version = "0.3"
    features = [
    ]

    为了保持构建速度非常快,web-sys将每个Web接口都封装在一个Cargo特性后面。在API文档中找到我们要使用的类型或方法;它将列出必须启用的特性才能访问该API。

    例如,如果我们要查找window.resizeTo函数,我们会在API文档中搜索resizeTo。我们将找到web_sys::Window::resize_to函数,它需要启用Window特性。要访问该函数,我们在Cargo.toml中启用Window特性:

    [dependencies.web-sys]
    version = "0.3"
    features = [
      "Window"
    ]

    调用这个方法:

    use wasm_bindgen::prelude::*;
    use web_sys::Window;
    
    #[wasm_bindgen]
    pub fn make_the_window_small() {
        // 调整窗口大小为500px x 500px。
        let window = web_sys::window().unwrap();
        window.resize_to(500, 500)
            .expect("无法调整窗口大小");
    }

    这段代码的目的是调整浏览器窗口的大小为500x500像素,并演示了如何使用web-sys和启用的Cargo特性来调用Web API。

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论