我们将建立一个用Rust编写的Svelte商店,并在一个Electron应用中运行它。
这是从我最初用Swift编写的一个视频编辑器演变而来。结果发现Windows上的人也想编辑视频,所以现在我用Rust、Electron和Svelte重写了它,并考虑到了性能。
Rust是一种系统编程语言,类似于C或C++,但更安全。它以超快著称。
Electron是一个用HTML、CSS和JavaScript构建跨平台桌面应用的框架。它以有点慢和臃肿而闻名。但它有一张王牌:Electron应用程序可以用编译的本地代码进行扩展,如果你在本地代码中做繁琐的事情,你就可以很好地提升速度。
Svelte是一个JavaScript UI框架--是React、Vue、Angular或其他7500个框架中的一个替代。Svelte使用一个编译器来生成小型、快速、反应式的代码。
我想通过将它们结合起来,并在Rust中完成大部分繁重的工作,我最终会得到一个感觉敏捷的应用程序。
完整的、已完成的项目在GitHub上,它有关于如何运行它的说明,以及我在试图让它工作时的过山车般的提交历史。
下面是它的样子。
Svelte商店如何工作
我喜欢Svelte的原因之一是它的反应性模型,特别是它的存储空间概念。商店是一个反应式变量,持有一个单一的值。
应用程序的任何部分都可以订阅商店,当商店的值发生变化时,每个订阅者都会得到(同步的!)通知。
下面是一个简单的例子
<script>
import { onDestroy } from 'svelte';
import { writable } from 'svelte/store';
// Make a store
const count = writable(0);
// Subscribe to it, and update the displayed value
let visibleCount = 0;
const unsubscribe = count.subscribe(value => {
visibleCount = value;
});
function increment() {
// Replace the store's value with (value + 1)
count.update(n => n + 1);
}
// Tidy up when this component is unmounted
onDestroy(unsubscribe);
</script>
<button on:click={increment}>Increment</button>
<p>Current value: {visibleCount}</p>
你点击按钮,它就会更新。没有什么太惊人的。但这只是 "低级别的 "API。
当你把Svelte的特殊反应式存储语法和$ (试试实时例子)时,它看起来要好得多。
<script>
import { onDestroy } from 'svelte';
import { writable } from 'svelte/store';
// Make a store
const count = writable(0);
function increment() {
$count += 1;
}
</script>
<button on:click={increment}>Increment</button>
<p>Current value: {$count}</p>
它所做的事情完全一样,只是代码更少。
<p> 里面的特殊的$count 语法是在幕后设置一个订阅,并在值发生变化时更新那个特定的DOM元素。而且它自动处理unsubscribe 的清理工作。
还有一个是$count += 1 (也可以写成$count = $count + 1 )。它读起来就像普通的JavaScript,但在值改变后,这个商店会通知所有的订阅者--在这种情况下,这只是下面HTML中的$count 。
如果你想了解更多,Svelte文档中有一个很好的关于商店的互动教程。
最重要的是合同
我们很容易看到这样的代码,并认为它是神奇的,特别是当有像$store 这样花哨的语法时。
有一大块数据密集型的代码是我用JS而不是Rust写的,因为我有这样的想法:"我想要反应性,所以必须用JavaScript"。
但是,如果你退一步,看看这些魔法是如何运作的,有时你可以找到新的和有趣的方法来扩展它。
Svelte商店设计得很好,允许这样做:它们遵循一个契约。
简而言之,为了成为一个 "Svelte存储",一个对象需要:
- 一个
subscribe方法,返回一个unsubscribe函数 - 一个
set方法,如果你想让它可写的话 - 它必须(a)在订阅时和(b)在值发生变化时同步调用订阅者。
如果任何JS对象遵循这些规则,它就是一个Svelte存储。如果它是一个Svelte存储,它就可以使用花哨的$store 语法和一切
从JavaScript调用Rust
这个难题的下一个部分是编写一些可以在JavaScript中作为对象暴露的Rust代码。
为此,我们使用了napi-rs,一个将Rust和JavaScript连接在一起的很棒的框架。它的创造者LongYinan(又名Broooooklyn)在这方面做得很好,最新的更新(v2版)使Rust代码非常好写。这里有一个味道,Rust函数的 "你好世界":
#[macro_use]
extern crate napi;
/// import the preludes
use napi::bindgen_prelude::*;
/// annotating a function with #[napi] makes it available to JS,
/// kinda like `export { sum };`
#[napi]
pub fn sum(a: u32, b: u32) -> u32 {
a + b
}
然后在JavaScript中,我们可以这样做:
// Hand-wavy pseudocode for now...
// The native module has its own folder and
// build setup, which we'll look at below.
import { sum } from './bindings';
console.log(sum(2, 2)) // gives correct answer
一个使用Electron、Rust和Svelte的模板项目
我们已经想到了大的部分。Electron、Svelte存储、可以从JS中调用的Rust。
现在我们只需要......用3个不同的构建系统实际连接一个项目。好啊。我希望你能听到我声音中的兴奋。
所以,对于这个原型,我采取了懒惰的方式。
这是一个光秃秃的Electron应用,Svelte模板被克隆到一个子文件夹中,而本地Rust模块在另一个子文件夹中(由NAPI-RS CLI生成)。
开发体验(DX)是老式的:退出整个应用,重建,然后重新启动。当然,某种自动构建、自动重载的Rube Goldberg式的脚本和配置的纠结会很好,但我不想。
因此,它有一个一英里长的start 脚本,只是cd's到每个子文件夹并建立它。这并不漂亮,但它能完成工作
"scripts": {
"start": "cd bindings && npm run build && cd .. && cd ui && npm run build && cd .. && electron .",
"start:debug": "cd bindings && npm run build:debug && cd .. && cd ui && npm run build && cd .. && electron .",
"start:clean": "npm run clean && npm run start:debug",
"clean": "cd bindings && rm -rf target"
},
我们并不打算在这里搞什么了不起的DX。这只是一个原型。令人敬畏的DX是未来的工作™。
从头到尾:它是如何工作的
我个人非常喜欢从第一个入口点开始追踪执行。我认为这有助于我理解所有的部分是如何结合在一起的。因此,这里是导致这个东西工作的事件链,以及相关的代码位。
1.你运行npm start 。它构建了一切,然后运行electron .
package.json
"start": "cd bindings && npm run build && cd .. && cd ui && npm run build && cd .. && electron .",
2.Electron找到并执行main.js ,因为package.json 告诉它(通过main 键):
package.json
{
"name": "electron-quick-start",
"version": "1.0.0",
"description": "A minimal Electron application",
"main": "main.js",
...
}
main.js生成一个BrowserWindow,并加载index.html
main.js
function createWindow() {
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
contextIsolation: true,
// Preload will make the native module available
preload: path.join(__dirname, 'preload.js')
}
})
// Load the index.html of the app.
mainWindow.loadFile('index.html')
}
4.main.js 还指定了一个preload.js ,在这里你可以暴露本地模块。这就是Rust模块被导入并作为window.Napi 。(见下面的安全问题)
preload.js
// Make native bindings available to the renderer process
window.Napi = require('./bindings');
index.html加载步骤1中构建的Svelte应用程序的JavaScript。
index.html
<html>
...
<body>
<!-- You can also require other files to run in this process -->
<script src="./ui/public/build/bundle.js"></script>
</body>
</html>
6.Svelte有自己的ui/main.js ,它导入并创建了App 组件,并将其挂载在document.body 。
ui/main.js
import App from './App.svelte';
const app = new App({
target: document.body,
});
export default app;
App.svelte用一个初始值来实例化我们的Rust存储,它调用Rust中的构造函数。
ui/App.svelte
<script>
import Counter from "./Counter.svelte";
let showCounter = true;
let counter = new Napi.Counter(42);
</script>
8.由于Svelte需要渲染计数器,它立即用回调调用.subscribe ,在Rust中调用subscribe 。
function instance($$self, $$props, $$invalidate) {
let $counter;
let showCounter = true;
let counter = new Napi.Counter(42);
component_subscribe($$self, counter, value => $$invalidate(1, $counter = value));
const click_handler = () => $$invalidate(0, showCounter = !showCounter);
const click_handler_1 = () => set_store_value(counter, $counter = Math.floor(Math.random() * 1234), $counter);
return [showCounter, $counter, counter, click_handler, click_handler_1];
}
9.根据合同,subscribe 函数需要立即用当前值调用所提供的回调,所以它这样做了,然后保存回调以供以后使用。它还会返回一个unsubscribe ,当组件被卸载时,Svelte会调用该函数。
#[napi]
impl Counter {
// ...
#[napi]
pub fn subscribe(
&mut self, env: Env, callback: JsFunction
) -> Result<JsFunction> {
// Create a threadsafe wrapper.
// (to ensure the callback doesn't
// immediately get garbage collected)
let tsfn: ThreadsafeFunction<u32, ErrorStrategy::Fatal> = callback
.create_threadsafe_function(0, |ctx| {
ctx.env.create_uint32(ctx.value).map(|v| vec![v])
})?;
// Call once with the initial value
tsfn.call(self.value, ThreadsafeFunctionCallMode::Blocking);
// Save the callback so that we can call it later
let key = self.next_subscriber;
self.next_subscriber += 1;
self.subscribers.borrow_mut().insert(key, tsfn);
// Pass back an unsubscribe callback that
// will remove the subscription when called
let subscribers = self.subscribers.clone();
let unsubscribe = move |ctx: CallContext| -> Result<JsUndefined> {
subscribers.borrow_mut().remove(&key);
ctx.env.get_undefined()
};
env.create_function_from_closure("unsubscribe", unsubscribe)
}
}
安全性:Electron和contextIsolation
Electron分为两个进程:"主 "进程(运行Node,在我们的例子中运行main.js )和 "渲染器",这是你的UI代码运行所在。两者之间是preload.js 。 Electron的官方文档更详细地解释了进程模型。
有几层安全保障,以防止随机脚本不受限制地访问你的整个计算机(因为那会很糟糕)。
首先是nodeIntegration 标志,默认为false 。这使得你不能在渲染器进程中使用 Node 的require() 。这有点烦人,但好处是,如果你的Electron应用碰巧打开了(或被胁迫打开)一个来自某处的粗略的脚本,该脚本将无法导入Node模块并造成破坏。
第二个是contextIsolation 标志,它的默认值是true 。这使得在渲染器内部运行的preload 脚本不能访问window ,因此不能直接暴露任何敏感的 API。你必须使用contextBridge来公开一个呈现器可以使用的 API。
我为什么要告诉你这些呢?好吧,如果你看一下上面的preload.js 例子,你会发现它直接设置了window.Napi 。它没有使用contextBridge ,而且在这个项目中,contextIsolation 是禁用的。我试着把它打开,但显然构造函数不能通过桥来传递。可能有另一种方法来解决这个问题--如果你知道的话,请告诉我吧
如果你的应用程序不加载外部资源,只从磁盘上加载文件,我的理解是,禁用contextIsolation 。
我写这篇文章是为了证明这个概念,但要注意的是,这样做的安全性并不高(如果你有改进的想法,请在Twitter上告诉我)。
Rust是如何工作的
简短的回答是:它遵循Svelte的存储合同 :)让我们来看看是怎样的。
这一切都发生在一个文件中,bindings/src/lib.rs 。
首先,有一个struct ,用来保存计数器的当前值,以及它的订阅者。
我认为ThreadsafeFunctions不能进行平等的比较,所以我把它们放在一个地图中,而不是一个向量中,并使用next_subscriber 来保存一个递增的键来存储订阅者:
#[napi]
pub struct Counter {
value: u32,
subscribers: Rc<RefCell<HashMap<u64, ThreadsafeFunction<u32, ErrorStrategy::Fatal>>>>,
next_subscriber: u64,
}
然后有几个函数在这个结构上实现。有一个构造函数,它初始化了一个没有订阅者的Counter:
#[napi]
impl Counter {
#[napi(constructor)]
pub fn new(value: Option<u32>) -> Counter {
Counter {
value: value.unwrap_or(0),
subscribers: Rc::new(RefCell::new(HashMap::new())),
next_subscriber: 0,
}
}
还有increment 和set 函数,它们几乎做同样的事情。在这两个函数中,set 是比较特殊的,因为在Svelte看来,它是使这个存储空间 "可写 "的函数。当我们在JS中写$count = 7 ,最终会调用到这里的set:
#[napi]
pub fn increment(&mut self) -> Result<()> {
self.value += 1;
self.notify_subscribers()
}
#[napi]
pub fn set(&mut self, value: u32) -> Result<()> {
self.value = value;
self.notify_subscribers()
}
在修改值之后,这些函数会调用notify_subscribers 。这个函数没有#[napi] 注解,这意味着它不能从JS中调用。这是对订阅者的迭代,并调用每个订阅者的当前值。
因为self.subscribers 是一个Rc<RefCell<...>> ,我们需要在迭代之前明确地将其borrow() 。这种借用发生在运行时,而不是像Rust那样在编译时进行借用检查。如果当我们试图在这里借用它的时候,其他东西也有这个借用,程序就会恐慌(也就是崩溃)。
我推断这是无恐慌的,因为notify_subscribers 和subscribe (另一个借用这个变量的地方)都在单一的JS主线程中运行,所以它们应该不可能踩到对方的访问:
fn notify_subscribers(&mut self) -> Result<()> {
for (_, cbref) in self.subscribers.borrow().iter() {
cbref.call(self.value, ThreadsafeFunctionCallMode::Blocking);
}
Ok(())
}
大部分真正的工作发生在subscribe 。这里有一些评论,但也有一些微妙之处,我花了一些时间才弄明白。
首先,它用一个ThreadsafeFunction 来包装回调。我想这是由于ThreadsafeFunction 在内部设置了一个围绕回调的引用计数器。起初我试着不这样做,结果发现回调在订阅后立即被垃圾收集。尽管存储了callback (并使Rust对其所有权感到高兴),但试图实际调用它时却失败了。
ErrorStrategy::Fatal 可能看起来令人担忧,但另一种方法,即ErrorStrategy::CalleeHandled ,在这里根本不起作用。CalleeHandled 风格使用Node的回调调用惯例,它将错误作为第一个参数(或null)传递。这不符合Svelte的存储合同,它只期望有一个参数。Fatal 策略会直接传递这个参数。
create_threadsafe_function 调用本身有很多事情要做。当我们在线程安全函数上运行.call() 时,|ctx| { ... } 中传递的闭包将被调用。该闭包的工作是接收你传入的值,并将其转化为JavaScript值的数组。所以这个闭包接收u32 ,用create_uint32 ,把它包装成一个JsNumber,然后把它放到一个向量里。这个向量又被分散到JS回调的参数中。
保存回调是很重要的,这样我们以后就可以调用它了,所以self.subscribers.borrow_mut().insert(key, tsfn); 。我们需要borrow_mut ,因为我们要在这里做运行时借贷检查。
我最初打算在编译时进行借贷检查,但unsubscribe 闭包给工作带来了麻烦。你看,我们需要在订阅时向哈希玛添加一些东西,而在取消订阅时我们需要从同一个哈希玛中删除一些东西。在JS中这是小事一桩。在Rust中,由于所有权的工作方式,在同一时间只有一个东西可以 "拥有 "self.subscribers 。如果我们把它从self移到unsubscribe 闭包中,那么我们就不能再增加任何订阅者,也不能通知他们。
我找到的解决方案是用Rc<RefCell<...>> 来包裹HashMap 。Rc 部分意味着内部可以通过调用.clone() 在多个所有者之间共享。RefCell 部分意味着我们可以突变内部结构,而不需要通过借贷检查器关于突变的严格规则。其代价是,我们要确保不重叠调用.borrow() 和.borrow_mut() ,否则程序会出现恐慌:
#[napi]
impl Counter {
// ...
#[napi]
pub fn subscribe(
&mut self, env: Env, callback: JsFunction
) -> Result<JsFunction> {
// Create a threadsafe wrapper.
// (to ensure the callback doesn't
// immediately get garbage collected)
let tsfn: ThreadsafeFunction<u32, ErrorStrategy::Fatal> = callback
.create_threadsafe_function(0, |ctx| {
ctx.env.create_uint32(ctx.value).map(|v| vec![v])
})?;
// Call once with the initial value
tsfn.call(self.value, ThreadsafeFunctionCallMode::Blocking);
// Save the callback so that we can call it later
let key = self.next_subscriber;
self.next_subscriber += 1;
self.subscribers.borrow_mut().insert(key, tsfn);
// Pass back an unsubscribe callback that
// will remove the subscription when called
let subscribers = self.subscribers.clone();
let unsubscribe = move |ctx: CallContext| -> Result<JsUndefined> {
subscribers.borrow_mut().remove(&key);
ctx.env.get_undefined()
};
env.create_function_from_closure("unsubscribe", unsubscribe)
}
}
这就结束了!
我希望我表达的意思是,这花了大量的时间来摆弄,并遇到了死胡同,我不确定我是否用了 "正确 "的方法,或者我只是偶然发现了一些可行的方法。所以如果你有任何改进的想法,请告诉我。