如何将Tauri前后端通信设计成请求、响应式
前言
在Tauri中,Webview(前端)和Rust线程(后端)之间的通信通常是分开的,也就是说,我们可以让Webview(通过JS)向Rust发送消息,也可以让Rust向Webview发送消息,但发送消息与接受消息通常在不同的位置,我们能不能将其设计成类似于网络请求那样,JS发送的消息可以直接通过Promise的方式拿到Rust的响应结果呢?
基本通信
首先,我们来看一下Tauri中基本的通信方式。
在Tauri中,JS向Rust发送消息可以通过invoke
函数来完成。
import { invoke } from '@tauri-apps/api/core'
invoke('js2rs', { message: 'hello rust' })
注意:我这里用的是v2版本的tauri,和v1版本中API有所差异,例如invoke函数在v1版本中位于'@tauri-apps/api/tauri' 这个模块中。
然后Rust通过js2rs
函数来接收这个消息,并通过app_handle.emit
向JS回传消息:
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use tauri::Manager;
#[tauri::command]
pub fn js2rs(message: String, app_handle: tauri::AppHandle) {
let _ = app_handle.emit("rs2js", "hello javascript");// 回传消息
}
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![js2rs])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
而在JS中,想要接收rs2js
的这个消息,需要通过listen
监听该事件来拿到:
import { listen } from '@tauri-apps/api/event'
listen('js2rs', (payload) => {
console.log(payload)
})
这就是我所说的,JS发送消息与接受消息发生在不同的位置,如果我们有一个连贯的需求,例如读取某个本地文件,假如不做任何封装,我们很可能要这么写:
Rust:
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::fs;
use std::path::{self, Path};
use tauri::Manager;
#[tauri::command]
pub fn open_file(path: String, app_handle: tauri::AppHandle) {
let f = Path::new(&path);
// 判断文件是否存在
if f.exists() {
// 读取本地文件
let text = fs::read_to_string(f).unwrap();
// 将文件内容以及文件路径一起发送回JS
app_handle
.emit("file-opened", [&text, &path])
.unwrap();
} else {
// 发送错误消息
app_handle.emit("file-open-error", "文件不存在").unwrap();
}
}
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![open_file])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
JS:
import { invoke } from '@tauri-apps/api/core'
function openFile(path: string) {
invoke('open_file', { path }) // 这里注意一个细节,参数的属性名称必须与Rust中open_file的参数名称一致
}
listen("file-opened", (payload) => {
let text = payload[0]
let path = payload[1]
// 假设我们需要根据文件的不同类型做不同的处理
if (path.endsWith('.txt')) {
// do something
}
else if (path.endsWith('.html')) {
// do something
}
})
// 监听错误
listen('file-open-error', error => {
console.error(error)
})
openFile('D:/A.txt')
openFile('D:/B.html')
说明一下,我这里只是以读取本地文件举例子,实际上,如果需求单纯只是读取文件,完全可以直接用Tauri提供的fs API,它内部已经帮我们解决了双向通信的问题,不需要我们手动通信这么麻烦。但如果遇到一些特殊的情况,例如读取一个压缩文件,在需要用Rust进行解压,然后返回数据,就样只能我们自己来封装了。
可以看到,这种做法缺点显而易见:成功和失败需要分开处理,而且不管我们在什么位置、调用多少次openFile
函数,都只能在同一个回调函数中集中处理。
于是我就想,能不能将其设计成类似于网络请求那样的API呢?
设计为请求、响应式
我的目标是将API设计成这样:
async function handle(path: string) {
const text = await openFile(path)
console.log(text)
}
其实这就和Tauri提供fs API很像了,在同一位置返回Promise,非常符合前端开发的习惯。
那么该如何实现这个openFile呢?
首先我们创建一个InvokeRequest
类,这么做是为了此方案能更加通用:
import type { InvokeArgs, InvokeOptions } from '@tauri-apps/api/core'
import type { Event } from '@tauri-apps/api/event'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
// Rust返回消息的类型通过泛型Payload、Error来传递
class InvokeRequest<Payload, Error = string> {
resolve: (value: Payload | PromiseLike<Payload>)=> void
reject: (reason?: Error)=> void
// 构造函数中监听成功和失败两种情况
constructor(sucess_type: string, error_type: string) {
this.resolve = () => {}
this.reject = () => {}
listen(sucess_type, (event: Event<Payload>) => {
this.resolve(event.payload)
})
listen(error_type, (event: Event<Error>) => {
this.reject(event.payload)
})
}
// 发送消息
invoke(type: string, args?: InvokeArgs, option?: InvokeOptions) {
return new Promise<Payload>((resolve, reject) => {
this.resolve = resolve
this.reject = reject
invoke(type, args, option)
})
}
}
然后我们就可以用它来创建对应的openFile
函数了:
const ir = new InvokeRequest<[string, string]>('file-opened', 'file-open-error')
function openFile(path: string) {
return ir.invoke('open_file', {path})
}
这里需要注意的是,new InvokeRequest
只能被执行一次,必须写在函数外面,如果写在函数里面,每次调用openFile
都会执行一次listen
,导致事件也被绑定多次,但我们可以利用IIFE创建闭包避免这一点:
const openFile = function() {
type Payload = [string, string]
const ir = new InvokeRequest<Payload>('file-opened', 'file-open-error')
return function(path: string) {
return ir.invoke('open_file', {path})
}
}()
这样就可以写在里面了。
openFile
的调用方式就如我最开始所示:
openFile('D:/A.txt').then(text=> {
console.log(text)
}, error => {
cosnole.error(error)
})
async function handle(path: string) {
const text = await openFile(path)
console.log(text)
}
Promise.withResolvers的应用实例
上面InvokeRequest
用到了一个平时不太能用得上的技巧,就是将resolve和reject保存在外部变量中,然后在外部去执行,这恰好对应上了一个比较新的API:Promise.withResolvers
的使用场景,如果使用它,我们可以将上面的例子改为:
import type { InvokeArgs, InvokeOptions } from '@tauri-apps/api/core'
import type { Event } from '@tauri-apps/api/event'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
class InvokeRequest<Payload, Error = string> {
resolve: (value: Payload | PromiseLike<Payload>)=> void
reject: (reason?: Error)=> void
constructor(sucess_type: string, error_type: string) {
this.resolve = () => {}
this.reject = () => {}
listen(sucess_type, (event: Event<Payload>) => {
this.resolve(event.payload)
})
listen(error_type, (event: Event<Error>) => {
this.reject(event.payload)
})
}
invoke(type: string, args?: InvokeArgs, option?: InvokeOptions) {
const { promise, resolve, reject } = Promise.withResolvers<Payload>()
this.resolve = resolve
this.reject = reject
invoke(type, args, option)
return promise
}
}
但话说回来,就我这个例子来说,用不用Promise.withResolvers
其实区别并不明显。