如何将Tauri前后端通信设计成请求、响应式

1,813 阅读5分钟

如何将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其实区别并不明显。