使用napi-rs与Electron的实例指南

1,611 阅读8分钟

你可以通过将密集型任务卸载到Rust中来提高你的Electron应用程序的性能。

现在有两个主要的库可以帮助你做到这一点。Neonnapi-rs。就目前而言,Neon更受欢迎,在Github上有5700颗星,而napi-rs只有800多颗。

话虽如此,但星星并不代表一切对于我的用例(截至本文写作时),napi-rs支持一个Neon还没有的重要功能:Rust能够多次回调JS回调函数。

我去寻找一个最小的启动项目来使用Electron + napi-rs,但没有找到任何东西。因此发了这个帖子 :)

TL;DR:如果你只是想克隆这个项目,你可以在Github上找到electron-napi-rs

这篇文章的其余部分解释了这些部件是如何组合在一起的。

(btw 如果你想用Neon代替napi-rs,可以看看Mike Barber的electron-neon-rust,它基本上是我在这里做的事情的Neon版本)

一个使用Electron和napi-rs的极简项目

我从electron-quick-start的官方Electron启动器开始。这将使一个Electron应用出现在屏幕上。

然后我加入了Rust模块。这或多或少是从napi-rs的napi-derive-example中复制粘贴的,只是改变了一些相对路径。

我把Rust模块放在Electron项目中一个叫做hi-rust 的目录下。我们只需要添加4个文件。

Cargo.toml

[package]
authors = ["LongYinan <lynweklm@gmail.com>"]
edition = "2018"
name = "hi-rust"
version = "0.1.0"

[lib]
crate-type = ["cdylib"]

[dependencies]
napi = "1.7.5"
napi-derive = "1.1.0"

[build-dependencies]
napi-build = "1.1.0"

(修改为使用版本号而不是[dependencies][build-dependencies] 的相对路径 )

build.rs

extern crate napi_build;

fn main() {
  use napi_build::setup;

  setup();
}

(直接从napi-derive-example中取出)

这个build.rs 文件对Rust来说是特殊的。你可以在《Cargo》一书的Build Scripts部分阅读更多内容,但基本上Rust会寻找一个build.rs 文件,并在构建前运行它,如果它存在的话。

src/lib.rs

然后是代码本身,在src 文件夹下:

#[macro_use]
extern crate napi_derive;

use napi::{CallContext, Error, JsNumber, JsObject, JsUnknown, Result, Status};
use std::convert::TryInto;

#[module_exports]
fn init(mut exports: JsObject) -> Result<()> {
  exports.create_named_method("testThrow", test_throw)?;
  exports.create_named_method("fibonacci", fibonacci)?;

  Ok(())
}

#[js_function]
fn test_throw(_ctx: CallContext) -> Result<JsUnknown> {
  Err(Error::from_status(Status::GenericFailure))
}

#[js_function(1)]
fn fibonacci(ctx: CallContext) -> Result<JsNumber> {
  let n = ctx.get::<JsNumber>(0)?.try_into()?;
  ctx.env.create_int64(fibonacci_native(n))
}

#[inline]
fn fibonacci_native(n: i64) -> i64 {
  match n {
    1 | 2 => 1,
    _ => fibonacci_native(n - 1) + fibonacci_native(n - 2),
  }
}

(也是直接从napi-rs repo出来的)

它向JavaScript暴露了两个Rust函数:test_throwfibonacci 分别被暴露为testThrowfibonacci

init 实际上是JS <-> Rust绑定的 "入口",这个文件可以调用你想要的任何Rust代码。

package.json

运行npm init -y ,初始化一个默认的package.json,然后添加 "build "和 "install "脚本。

构建脚本依赖一个软件包来复制出已构建的Rust二进制文件,所以用npm install -D cargo-cp-artifact

{
  "name": "hi-rust",
  "version": "1.0.0",
  "description": "",
  "main": "index.node",
  "scripts": {
    "install": "npm run build",
    "build": "cargo-cp-artifact -nc index.node -- cargo build --message-format=json-render-diagnostics"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "cargo-cp-artifact": "^0.1.4"
  }
}

build 脚本有效地做了两件事:

  • cargo build 编译Rust模块,并将编译后的文件保存在数据库中。target/debug
  • cargo-cp-artifact 将输出的文件复制到项目的根目录下,作为index.node

install 脚本只是让它更容易运行。(你可以用npm i 来代替npm run build)

发布构建

Cargo默认会编译一个Debug版本,这个版本比较慢,比较大,但是包含了调试符号。

如果你想让它更快更小,请确保编译一个Release版本。如果/当你想这样做的时候,在cargo build 命令的末尾加上--release 标志。

我马上就这么做了,因为我的应用程序在Debug模式下要慢得多。

旁白:index.js与index.node?

在我设置这个的时候,发生了一件有趣的事情!

起初我根本没有改变 "main",而是将其值保留为默认的index.js 。这......工作得非常好,尽管只有一个index.node文件(没有index.js)。

我猜Node知道如果它找不到index.js ,就会去找index.node

总之,这有点让人不安,所以我把 "main "键改为直接指向index.node ,这样也能正常工作。我想最好是把它指向一个实际存在的文件🤷,至少可以在导入过程中减少几个周期,嗯?

建立index.node

hi-rust 目录下运行npm install 将会下载所需的包,并构建index.node 文件,这是我们的原生 Rust 代码,经过打包后,Node 可以require()

添加Rust模块为依赖项

回到顶层的Electron项目中,将Rust模块作为一个依赖项添加到package.json中。

{
  ...

  "dependencies": {
    "hi-rust": "./hi-rust"
  }
}

然后运行npm install ,它将建立一个与项目的链接。

从这里开始,你可以修改和重建Rust项目(在hi-rust 内),而不需要重新运行npm install

用preload.js暴露Rust模块

我们有了本地代码,它被打包并构建为一个模块,Node可以导入。现在我们需要在Electron应用程序中导入它。

有两种方法可以做到这一点:一种是不安全的方法,另一种是更好的方法。

不安全的方法是设置nodeIntegration: true ,这样我们就可以直接从Electron的渲染器进程中require() 节点模块。这使得代码更加简单,但主要的缺点是它打开了一个巨大的安全漏洞。

为什么不在Electron中设置nodeIntegration: true

在不安全的设置下,由渲染器运行的任何JS都可以完全访问用户的系统。这意味着文件API、网络API、进程API,等等,等等。

它可以做任何用户可以做的事情。比如下载并运行一些恶意程序,或者勒索他们的主目录。

nodeIntegration: true 编写代码,可以稍微减少麻烦,但代价是一个巨大的安全漏洞。

更好的方法

更好的方法是使用Electron的preload 文件来有选择地将功能暴露给渲染器进程,也就是 "主世界",这就是我们要做的。

main.js ,Electron的启动项目设置了preload.js 作为指定的预加载文件。预加载器可以访问Node的API浏览器的API,但最关键的区别是它是孤立的:渲染器不能从预加载中调用东西,除非预加载明确地暴露了它。

因此,我们从preload.js 公开了我们的 Rust 模块,就像这样。

// Import the Rust library and expose it globally as `rustLib`
// in the renderer (also accessible as `window.rustLib`)
const rustLib = require('hi-rust')
const { contextBridge } = require('electron')
contextBridge.exposeInMainWorld('rustLib', rustLib)

请注意,这是对整个库的暴露你会想暂停并思考一下,从安全的角度来看,这是否是一个好主意。如果恶意代码可以调用你的库的任何函数,会发生什么?

作为一个潜在的更安全的选择,你可以暴露单个函数...

contextBridge.exposeInMainWorld('rustLib', {
  fibonacci: rustLib.fibonacci
})

或者将调用包在一个函数中,以确保只允许某些参数通过,或者做其他检查。

contextBridge.exposeInMainWorld('rustLib', {
  fibonacci: (num) => {
    if (num > 42) return;
    return rustLib.fibonacci(num);
  }
})

你也可以使用Electron的IPC系统,在主进程和渲染器进程之间来回发送请求。

在renderer.js中从Electron调用Rust代码

现在我们终于可以从渲染器中调用Rust函数了

一旦DOM准备好了,我们就调用rustLib.fibonacci ,引用来自预加载脚本的暴露的全局rustLib ,并将结果存储在一个元素中(我们仍然需要创建)。

window.addEventListener('DOMContentLoaded', () => {
  const result = rustLib.fibonacci(8);
  const content = document.querySelector('#rust-content');
  content.innerHTML = `This number came from Rust! <strong>${result}</strong>`;
});

如果你现在运行这个,你可能会得到一个错误,比如 "无法访问null的属性innerHTML",因为这个元素还不存在。

让我们添加一个带有id="rust-content" 的 div 来包含结果。

index.html

<html>
  <!-- snip -->
  <body>
    <!-- snip -->
    <div id="rust-content"></div>
  </body>
</html>

这就成功了!

这时你应该可以从顶层(Electron)目录中运行npm start ,应用程序应该弹出一个由Rust计算的数字:)

...同步进行!

有一点需要注意,这是对Rust的同步调用。如果fibonacci函数超级慢,或者我们要调用其他一些阻塞的函数,我们的应用程序就会冻结了。

你可以自己试试:试着给fibonacci传递一个大数,比如1234 ,而不是8

帮助!错误!

下面是我在使用过程中遇到的几个错误,以及我是如何解决的。如果你一直在学习,你可能不会遇到这些错误,但我在这里列出它们,以防万一。

缺少package.json

当我忘记在Rust库的目录中创建一个package.json ,我就遇到了这个错误。

Internal Error: Cannot find module '/Users/dceddia/Projects/electron-napi-rs/hi-rust/package.json'
Require stack:
- /usr/local/lib/node_modules/@napi-rs/cli/scripts/index.js
Require stack:
- /usr/local/lib/node_modules/@napi-rs/cli/scripts/index.js
    at Function.Module._resolveFilename (node:internal/modules/cjs/loader:933:15)
    at Function.Module._load (node:internal/modules/cjs/loader:778:27)
    at Module.require (node:internal/modules/cjs/loader:1005:19)
    at require (node:internal/modules/cjs/helpers:94:18)
    at getNapiConfig (/usr/local/lib/node_modules/@napi-rs/cli/scripts/index.js:23450:19)
    at BuildCommand.<lt;anonymous> (/usr/local/lib/node_modules/@napi-rs/cli/scripts/index.js:23579:30)
    at Generator.next (<lt;anonymous>)
    at /usr/local/lib/node_modules/@napi-rs/cli/scripts/index.js:65:61
    at new Promise (<lt;anonymous>)
    at __async (/usr/local/lib/node_modules/@napi-rs/cli/scripts/index.js:49:10)

最后的解决方法很简单:npm init -y 创建了一个package.json 文件并解决了这个错误。

从Electron的导出不正确preload.js

我第一次尝试将Rust库暴露在Electron的渲染器进程中是这样的:

const rustLib = require('hi-rust');
window.rustLib = rustLib;

我能够很好地启动Electron,但是它在浏览器控制台中记录了一个错误,表明window.rustLib 是未定义的......这意味着我这一行被忽略了:

Uncaught TypeError: Cannot read property 'fibonacci' of undefined

我想这是因为contextIsolation 默认是打开的,所以任何添加到window 对象的东西都不会被看到。

修复方法是使用Electron的contextBridge 模块,特别是exposeInMainWorld 函数。

preload.js

const rustLib = require('hi-rust');
const { contextBridge } = require('electron');

contextBridge.exposeInMainWorld('rustLib', rustLib)