如何为Npm发布WebAssembly包

如何为Npm发布WebAssembly包

WebAssembly (WASM) 代码可以显著提高速度,基于它编写和发布 npm 软件包现在已经成为一个非常有趣的目标。本文将向您展示如何开发和发布 WASM 软件包,以便您能在自己的工作中应用这项技术。

WebAssembly(通常缩写为 Wasm)是一种突破性的二进制指令格式,它改变了网络开发的格局。它允许开发人员在浏览器中以接近原生的速度运行 JavaScript 之外的其他语言编写的代码。WebAssembly 最令人兴奋的应用之一是在 npm 包领域。

在深入探讨文章的其他部分之前,让我们先快速讨论一下使用 WebAssembly 的目的。我们的主要目标是通过构建一个搜索索引来发现使用 WebAssembly 实现 npm 包的优势,该索引可以让我们高效地搜索字符串集合并返回相关匹配结果。这将是 Wade.js npm 软件包的简化版本,但使用的是 Rust 和 Wasm。最后,你将知道如何开发一个高性能的搜索包。

使用 WebAssembly 编写 npm 软件包的好处包括:

  1. 性能:WebAssembly 模块以接近原生的速度运行,比同等 JavaScript 实现快得多。
  2. 灵活性:开发人员可以在网络环境中利用 Rust、C 和 C++ 等语言的功能和特性。
  3. 安全性:WebAssembly 提供了一个沙箱式的执行环境,即使代码中存在错误,也能确保代码不会对主机系统造成危害。

要继续学习,请确保您的开发环境包括:

  • Node.js:构建服务器端应用程序必不可少的 JavaScript 运行环境。
  • NPM account:确保您有一个激活的 NPM 注册表账户,因为它是发布 npm 包所必需的。

在接下来的章节中,您将了解如何设置 Rust 开发环境、在 Rust 中实现搜索功能以及编译和发布 npm 软件包。

设置新的 Rust 项目

您可以按照此处的说明在本地计算机上安装 Rust

安装好 Rust 后,就该获取 wasm-pack 二进制文件了。这个小工具可以帮助你将 Rust 代码编译成 WebAssembly,并打包以实现无缝开发。

设置成功后,运行下面的 cargo 命令来设置一个新的 Rust 项目:

cargo new --lib refactored-couscous

通过添加 --lib,你可以指示 cargo 生成一个 Rust 库模板。你应该将文件夹名称 “refactored-couscous” 改为你喜欢的名称。cargo 生成的文件夹结构应与下面的目录相同:

refactored-couscous/
├── Cargo.lock
├── Cargo.toml
├── LICENSE_APACHE
├── src/
│   └── lib.rs
└── target/

接下来,让我们在代码执行过程中添加必要的依赖项。打开 Cargo.toml 文件,更新其依赖项部分:

# file: ./Cargo.toml
[dependencies]
wasm-bindgen = "0.2"
js-sys = "0.3"

wasm-bindgen crate 对于促进 Rust 和 JavaScript 之间的高级交互至关重要。它提供了 #[wasm_bindgen] 属性,你可以用它来注释 Rust 结构和函数,使 JavaScript 可以访问它们。

js-sys crate 为 Rust 中的所有 JavaScript 全局对象和函数提供绑定。它是 wasm-bindgen 生态系统的一部分,旨在与 wasm-bindgen 配合使用。

用 Rust 重写简化的 Wade.js npm 软件包

在完成设置后,让我们开始实现搜索包。

在深入研究代码之前,了解我们的主要目标至关重要。我们的目标是建立一个搜索索引,让我们能够高效地搜索字符串集合,并返回相关的匹配结果。

// file: src/lib.rs
use std::collections::HashMap;
use wasm_bindgen::prelude::*;
use js_sys::Array;
#[wasm_bindgen]
#[derive(Debug, Clone)]
pub struct Token {
word: String,
position: usize,
frequency: usize,
}

我们首先导入必要的库。然后声明包含三个字段的 Token 结构:wordposition, 和 frequencystruct 将表示单个单词、单词在数据中的位置以及单词在给定字符串中的频率。

接下来,让我们定义搜索索引结构。搜索包的核心是 Index 结构,它将存储我们要搜索的所有字符串,并允许我们快速查找单词的出现。

// file: ./src/lib.rs
#[wasm_bindgen]
#[derive(Debug)]
pub struct Index {
data: Vec<String>,
tokens: HashMap<String, Vec<Token>>,
}

在上述代码段中,data 字段是一个存储所有字符串的向量。tokens 字段是一个 hashmap,其中每个键是一个单词,其值是一个 Token 结构向量。通过这种结构,我们可以快速查找数据中出现的所有单词。

接下来,让我们为每个字符串实现标记化。标记化就是将字符串分解成单个词或 “tokens”。有了单个标记,我们就可以单独分析和处理每个单词。这种粒度使我们能够专注于特定的单词,从而更容易搜索、分析或处理文本。

// file: ./src/lib.rs
impl Token {
fn tokenize(s: &str) -> Vec<String> {
s.to_lowercase()
.split_whitespace()
.map(|word| word.to_string())
.collect()
}
}

tokenize 函数将字符串作为输入。它会将字符串转换为小写,以确保搜索不区分大小写。然后将字符串分割成单词,再将每个单词转换成字符串。最后,单词被收集到一个向量中并返回。

接下来,让我们初始化并填充搜索 Index

// file: ./src/lib.rs
#[wasm_bindgen]
impl Index {
#[wasm_bindgen(constructor)]
pub fn new() -> Self {
Index {
data: Vec::new(),
tokens: HashMap::new(),
}
}
pub fn add(&mut self, s: &str) {
let position = self.data.len();
self.data.push(s.to_string());
let tokens = Token::tokenize(s);
for token in tokens {
let frequency = s.matches(&token).count();
self.tokens.entry(token.clone()).or_insert_with(Vec::new).push(Token {
word: token,
position,
frequency,
});
}        
}
...   
}

在上述代码段中,new 函数初始化了一个空 Index。然后,add 函数允许我们向索引中添加新字符串。为此,它会对字符串进行标记化处理,计算每个标记的频率,并相应地更新 tokens 哈希表。

#[wasm_bindgen(constructor)] 属性表示当从 JavaScript 访问 Rust 结构时,相关函数应被视为 Rust 结构的构造函数。

接下来,让我们实现搜索功能。为了在索引中搜索匹配项,我们将如下定义搜索函数:

// file: ./src/lib.rs
#[wasm_bindgen]
impl Index {
...
pub fn search(&self, query: &str) -> Array {
let tokens = Token::tokenize(query);
let mut results = Vec::new();
for token in tokens {
if let Some(matches) = self.tokens.get(&token) {
for match_ in matches {
results.push(self.data[match_.position].clone());
}
}
}
results.sort();
results.dedup();
// Convert Vec<String> to js_sys::Array
results.into_iter().map(JsValue::from).collect()
}
}

search 函数首先对查询进行标记化。对于查询中的每个标记,它都会检查 tokens 哈希表中是否有匹配项。如果发现匹配,就会将 data 向量中的相应字符串添加到搜索结果中。然后对结果进行排序,并删除重复的结果。最后,使用 js_sys::Array 将结果转换为 JavaScript 数组并返回。

有了这个实现,我们就有了一个使用 Rust 构建的强大搜索索引。在下一节中,我们将深入探讨如何将 Rust 代码编译到 WebAssembly 中,以便将其无缝集成到 JavaScript 环境中。

将 Rust 代码反编译为 WebAssembly

本节将深入探讨将 Rust 代码转编到 WebAssembly 的不同方法,这取决于特定的 JavaScript 环境。在本讨论中,我们将集中讨论两个主要的编译目标:网络(与基于浏览器的应用程序有关)和捆绑程序(与服务器端操作有关)。

要转译为 WebAssembly,需要在项目根目录下运行下面的 wasm-pack 命令:

wasm-pack build --target web

执行命令后,会启动一系列进程。首先是将 Rust 源代码转译为 WebAssembly。随后,在生成的 WebAssembly 上执行 wasm-bindgen 工具,生成一个 JavaScript 封装器,以促进浏览器与 WebAssembly 模块的兼容性。这个过程还协调了 pkg 目录的形成,将 JavaScript 封装器和原始 WebAssembly 代码重新定位到这个位置。根据 Cargo.toml 提供的信息,它还会创建相应的 package.json。如果存在 README.md 或许可证文件,则将其复制到软件包中。最终,这一系列操作会在 pkg 目录中创建一个合并的软件包。

在继续第二个目标之前,让我们先简单了解一下生成的 pkg 目录:

./pkg
├── LICENSE_APACHE
├── package.json
├── refactored_couscous.d.ts
├── refactored_couscous.js
├── refactored_couscous_bg.js
├── refactored_couscous_bg.wasm
└── refactored_couscous_bg.wasm.d.ts

refactored_couscous.d.ts 是 TypeScript 声明文件,通过详细说明包中函数和模块的类型,为 TypeScript 开发人员提供了类型安全。 refactored_couscous.js 是由 wasm-bindgen 创建的 JavaScript 封装器,它将 WebAssembly 模块与 JavaScript 领域连接起来,实现无缝集成。作为补充,refactored_couscous_bg.js 是一个辅助文件,用于处理一些底层操作以及与 WebAssembly 模块的交互。软件包的核心在于 refactored_couscous_bg.wasm,这是一个从 Rust 派生的 WebAssembly 二进制文件,封装了软件包的主要逻辑。最后,refactored_couscous_bg.wasm.d.ts 是另一个 TypeScript 声明文件,与之前的 .d.ts 文件类似,但根据 WebAssembly 模块的具体情况进行了定制。

下一条 wasm-pack 命令会将 Rust 代码转换为 WebAssembly 模块,该模块专门为使用基于 npm 的捆绑程序(如 Webpack 或 Rollup)而定制:

wasm-pack build --target bundler

-target bundler 标志表示输出结果应与这些捆绑工具兼容,从而使生成的 WebAssembly 模块更容易集成到现代网络开发工作流程中。该命令生成的 pkg 目录与 --target web 标志生成的文件数量相同,但文件内容略有不同。

在 Web 应用程序中集成 WebAssembly 模块

既然我们已经知道了如何针对不同的 JavaScript 环境,那么让我们从 --target web 转置开始,在基于浏览器的应用程序中使用生成的模块。

在根目录下创建一个 index.html 文件,并用以下内容更新它:

<!-- file: ./index.html -->
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<title>Wade Search in WebAssembly</title>
<style>
/* for the styles, see the https://github.com/Ikeh-Akinyemi/refactored-couscous/blob/main/index.html */
</style>
</head>
<body>
<input type="text" id="searchInput" placeholder="Search..." />
<button onclick="performSearch()">Search</button>
<ul id="results"></ul>
<script type="module">
import init, { Index } from "./pkg/refactored_couscous.js";
let index;
async function setup() {
await init();
index = new Index();
// Sample data for demonstration purposes
index.add("Hello world");
index.add("Start your Rust journey here")
index.add("Found my empress.");
index.add("Talkin about systems")
index.add("Wade in Rust");
}
function performSearch() {
const query = document.getElementById("searchInput").value;
const results = index.search(query);
displayResults(results);
}
window.performSearch = performSearch;
function displayResults(results) {
const resultsElement = document.getElementById("results");
resultsElement.innerHTML = "";
results.forEach((result) => {
const li = document.createElement("li");
li.textContent = result;
resultsElement.appendChild(li);
});
}
setup();
</script>
</body>
</html>

在上述代码段中, <script> 元素中的 JavaScript 代码从生成的 JavaScript 封装器(refactored_couscous.js)中导入了 init 函数和 Index 类。init 函数非常重要,因为它初始化了 WebAssembly 模块,确保其可随时使用。

页面加载时会调用 setup 函数。它首先使用 await init() 确保 WebAssembly 模块完全初始化。然后,创建 WebAssembly 模块中 Index 类的实例,用于存储和搜索数据。

当用户点击 “Search” 按钮时,将触发 performSearch 函数。它从文本字段中检索用户输入的内容,使用 Index 类的 search 方法查找匹配内容,然后使用 displayResults 函数显示结果。

displayResults 函数获取搜索结果,为每个结果创建一个列表项,并将其添加到网页上的 results 无序列表中。

在浏览器中加载 index.html 文件并进行单词搜索,如下图所示:

在浏览器中加载 index.html 文件并进行单词搜索

如何将 WebAssembly 模块作为 NPM 包

在本节中,我们将从 --target bundler 转译开始,用 Webpack 使用生成的模块。然后,我们将把该包发布到 NPM 注册表,并在 Node.js 中安装和使用它。

使用 --target bundler 标志运行 wasm-pack transpilation 命令。然后将位置更改为 pkg 目录,并运行下面的 npm 命令:

npm link

pkg 目录中运行 npm link 命令,就能像全局安装 NPM 软件包一样访问该软件包。这样,你就可以使用软件包,而无需将其实际发布到 NPM 注册表中。这对于测试和开发目的尤其有用。

接下来,在项目根目录下创建一个新文件夹,用于存放我们将要介绍的示例:

mkdir -p examples/webpack-impl

./examples/webpack-impl 文件夹中创建 package.json 文件,并添加以下配置:

// file: ./examples/webpack-impl/package.json
{
"scripts": {
"serve": "webpack-dev-server"
},
"dependencies": {
"refactored-couscous": "^0.1.0"
},
"devDependencies": {
"webpack": "^4.25.1",
"webpack-cli": "^3.1.2",
"webpack-dev-server": "^3.1.10"
}
}

接下来,运行以下 npm 命令链接软件包并安装其他软件包:

npm link refactored-couscous && npm install

安装完成后,创建一个 index.html 文件并添加以下 HTML 代码:

<!-- file: ./examples/webpack-impl/index.html -->
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<title>refactored-couscous example</title>
<style>
/* for the styles, see https://github.com/Ikeh-Akinyemi/refactored-couscous/blob/main/examples/webpack-impl/index.html*/
</style>
</head>
<body>
<div id="loading" style="display: none">
<div class="spinner"></div>
Loading...
</div>
<input type="file" id="fileInput" />
<input type="text" id="urlInput" placeholder="Enter URL" />
<button id="buildIndexButton">Build Index</button>
<input type="text" id="searchInput" placeholder="Search..." />
<button id="searchButton">Search</button>
<ul id="results"></ul>
<script src="./index.js"></script>
</body>
</html>

接下来,让我们创建一个 index.js 文件,并添加以下 JavaScript 代码:

// file: ./examples/webpack-impl/index.js
import("refactored-couscous").then((js) => {
function splitIntoSentences(text) {
return text.match(/[^\.!\?]+[\.!\?]+/g) || [];
}
const index = new js.Index();
...
});

本节首先动态导入 refactored-couscous 模块。模块导入后,回调函数将以导入的模块为参数( js )执行。

接下来,我们定义了一个名为 split into sentences 的实用函数,用于根据标点符号将给定文本分割成单个句子。然后,从导入模块中创建 Index 类的实例。

现在,让我们添加一个事件监听器,检查用户是否上传了要搜索的文件或 URL,然后使用资源的内容建立 Index

// file: ./examples/webpack-impl/index.js
import("refactored-couscous").then((js) => {
...
document
.getElementById("buildIndexButton")
.addEventListener("click", async () => {
const fileInput = document.getElementById("fileInput");
const urlInput = document.getElementById("urlInput");
const loadingDiv = document.getElementById("loading");
loadingDiv.style.display = "block";
if (fileInput.files.length) {
const file = fileInput.files[0];
const content = await file.text();
const sentences = splitIntoSentences(content);
sentences.forEach((sentence) => {
if (sentence.trim()) {
console.log(sentence);
index.add(sentence.trim());
}
});
} else if (urlInput.value) {
try {
const response = await fetch(urlInput.value);
const content = await response.text();
const sentences = splitIntoSentences(content);
sentences.forEach((sentence) => {
if (sentence.trim()) {
index.add(sentence.trim());
}
});
} catch (error) {
console.error("Error fetching URL:", error);
}
}
loadingDiv.style.display = "none";
});
...
});

在上述代码段中,我们通过 fileInput 文件输入元素检查是否提供了文件。如果是,它将读取文件内容,使用实用功能将其分割成句子,并将每个句子添加到索引中。

如果提供的是 URL 而不是文件,则会从 URL 获取内容,将其拆分成句子并添加到索引中。

接下来,让我们实现搜索 Index 并显示结果:

// file: ./examples/webpack-impl/index.js
import("refactored-couscous").then((js) => {
...
document.getElementById("searchButton").addEventListener("click", () => {
const loadingDiv = document.getElementById("loading");
loadingDiv.style.display = "block";
const query = document.getElementById("searchInput").value;
const results = index.search(query);
console.log(results);
loadingDiv.style.display = "none";
displayResults(results);
});
function displayResults(results) {
const resultsList = document.getElementById("results");
resultsList.innerHTML = ""; // Clear previous results
results.forEach((result) => {
const listItem = document.createElement("li");
listItem.textContent = result;
resultsList.appendChild(listItem);
});
}
});

之后,让我们在 examples/webpack-impl 文件夹的根目录下创建一个 webpack.config.js 文件来配置 Webpack,并在其中填充以下内容:

// file: ./examples/webpack-impl/webpack.config.js
const path = require("path");
module.exports = {
entry: "./index.js",
output: {
path: path.resolve(__dirname, "dist"),
filename: "index.js",
},
mode: "development",
};

设置完成后,就可以执行项目了。确保您使用的是 Node.js v16,特别是考虑到 Node.js 环境的特殊性。

npm run serve

执行后,在浏览器中访问 http://localhost:8080/ 。您可以选择直接上传文件或输入指向网络内容的 URL。

直接上传文件或输入指向网络内容的 URL

显示的图像证实了我们软件包的功能。下一步是将此软件包发布到 NPM 注册表,让更多人可以访问它。为此,请切换到 pkg 目录,并在 package.json 配置中加入  type: "module" 。这将确保软件包与 CommonJS 和 ESModule 模块系统兼容。

npm publish

这将把软件包发布到你在 npm.com 上的账户,如下图所示:

把软件包发布到你在 npm.com 上的账户

发布后,我们可以使用 npm install refactored-couscous 命令安装软件包,并使用 ESModule 系统将其导入 Node.js 应用程序

// file: ./examples/cli/src/index.js
import { Index } from 'refactored-couscous/refactored_couscous.js';
const index = new Index();
index.add("Hello world");
index.add("Rust is amazing");
index.add("Wade in Rust");
const results = index.search("rust");
console.log(results);

设置好代码后,就可以使用 Node.js 运行脚本了。确保启用 WebAssembly 模块的实验标志:

node --experimental-wasm-modules ./src/index.js

执行脚本后,你会看到控制台中打印的搜索结果,显示哪些条目包含 “rust” 一词:

[ 'Rust is amazing', 'Wade in Rust' ]

这表明我们的软件包在对所提供的数据进行搜索时非常有效。

小结

在本文中,我们使用 Rust 和 WebAssembly 成功重写了一个简化版的 Wade.js npm 软件包。我们深入研究了 Rust 类型系统的复杂性、WebAssembly 的性能优势,以及 Rust、WebAssembly 和 JavaScript 之间的无缝互操作性。我们还探讨了如何构建文本数据并编制索引、执行搜索,以及如何将 WebAssembly 模块集成到浏览器和服务器端 JavaScript 环境中。最后,我们迈出了关键的一步,将我们的软件包发布到 NPM 注册表,使其可以被更广泛地使用。这个练习不仅展示了 Rust 和 WebAssembly 的强大功能和灵活性,还为将来创建更复杂、更高效、更安全的网络应用程序奠定了基础。

GitHub 库的链接在此提供。

评论留言