如何在Rust中实现Svelte存储(附代码)

150 阅读9分钟

我们将建立一个用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",
  ...
}
  1. 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');
  1. 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;
  1. 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,
    }
  }

还有incrementset 函数,它们几乎做同样的事情。在这两个函数中,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_subscriberssubscribe (另一个借用这个变量的地方)都在单一的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<...>> 来包裹HashMapRc 部分意味着内部可以通过调用.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)
  }
}

这就结束了!

我希望我表达的意思是,这花了大量的时间来摆弄,并遇到了死胡同,我不确定我是否用了 "正确 "的方法,或者我只是偶然发现了一些可行的方法。所以如果你有任何改进的想法,请告诉我。