如果你读过 Tauri 介绍,你就知道 Tauri 是什么、怎么工作的:一个原生桌面应用,UI 用 Web 技术,Rust 后端通过 IPC 处理操作系统那侧。接下来通常会冒出来的问题是:怎么写一份既能在那里跑、又能在普通浏览器里跑的代码库?
这个问题很早就出现。你的 React 组件需要读写文件、显示通知、跟操作系统对话。在 Tauri 里,这些都走 invoke() 和 Rust 后端。但你也想在开发时把同一份前端跑在浏览器里,或者把它作为 Web 应用跟桌面版本一起发布。在浏览器里,invoke() 什么也不做。另一端没有 Rust。
天真的做法是在组件里到处撒 isTauri() 检查。一个组件在 Tauri 里调 invoke(),在浏览器里调 fetch()。另一个对通知做同样的事。第三个管文件保存。过一阵子,每个组件都知道两个运行时,代码里到处是按应用跑在哪里而做不同事情的分支。
这样能跑,但扩展性差。每个需要原生能力的新组件都得重新学一遍同样的课。每个组件都背着两个运行时的重量。等你想要加第三个运行时,比如一个移动构建,你又得改组件。
有一种更好的形状。
把运行时藏在接口后面
模式是:别再让你的组件知道运行时。转而定义一个接口,根据应用跑在哪里来切换实现。
拿文件操作来。你的组件需要保存文档、读取文件、列出目录。定义那看起来是什么样,不用管它怎么做:
interface FileService {
saveDocument(doc: Document): Promise<void>;
getFiles(): Promise<string[]>;
readFile(path: string): Promise<string>;
}你的 React 组件依赖这个接口。它们从不调 invoke()。它们从不调 fetch()。它们调 fileService.saveDocument(doc) 然后继续。
function SaveButton({ doc }: { doc: Document }) {
const fileService = useFileService();
return (
<button onClick={() => fileService.saveDocument(doc)}>
Save
</button>
);
}组件不知道这次保存是去 Rust、去 HTTP API,还是去测试里的 mock。它不需要知道。
两个实现
你写这个接口的两个实现。一个给浏览器,一个给 Tauri。
浏览器版本通过 HTTP 跟你的后端对话:
class WebFileService implements FileService {
async saveDocument(doc: Document): Promise<void> {
await fetch("/api/documents", {
method: "POST",
body: JSON.stringify(doc),
});
}
async getFiles(): Promise<string[]> {
const res = await fetch("/api/files");
return res.json();
}
async readFile(path: string): Promise<string> {
const res = await fetch(`/api/files/${encodeURIComponent(path)}`);
return res.text();
}
}Tauri 版本通过 invoke() 给 Rust 发消息:
class TauriFileService implements FileService {
async saveDocument(doc: Document): Promise<void> {
await invoke("save_document", { doc });
}
async getFiles(): Promise<string[]> {
return invoke<string[]>("get_files");
}
async readFile(path: string): Promise<string> {
return invoke<string>("read_file", { path });
}
}同一个接口,底下是完全不同的两套机制。一个走网络。另一个走 IPC 到一个编译过的 Rust 函数,写到真实的文件系统。
在边界选一个
你在应用启动的边界处选一次实现,下游全部拿到对的那个:
function createFileService(): FileService {
if (isTauri()) {
return new TauriFileService();
}
return new WebFileService();
}或者用 provider,如果你喜欢 React context:
const FileServiceContext = createContext<FileService>(
isTauri() ? new TauriFileService() : new WebFileService()
);
function useFileService(): FileService {
return useContext(FileServiceContext);
}现在应用里每个组件都拿到对的实现,从来不知道是哪一个。isTauri() 检查发生在一个地方。组件是干净的。
什么被共享,什么被替换
这个模式可行,是因为你应用的绝大部分不在乎运行时。UI、状态管理、业务规则、校验、路由、组件树,这些在应用跑在哪里都一样。
| 共享 | 替换 |
|---|---|
| 组件 | 文件访问 |
| 状态管理 | 通知 |
| 业务规则 | OS 集成 |
| 校验 | 认证方式 |
| 路由 | 存储 |
| 组件树 | 网络层 |
共享那栏是你代码库的大部分。替换那栏是你应用触碰外部世界的面,那个面就是运行时真正重要的地方。把每一个被替换的部件藏在一个接口后面,运行时差异就集中在一个地方,而不是散布在每个组件里。
为什么这对 Tauri 是对的模式
Tauri 不会免费给你这个。如果你只是开始在组件里调 invoke(),你得到的是一个恰好包含 Web 代码的桌面应用。它再也跑不到浏览器里,因为每个组件都依赖 Rust 桥接在那。你把前端锁死在桌面运行时上。
接口模式反过来。你得到的是一个能变成桌面应用的 Web 应用,因为这个 Web 应用构建时不知道桌面运行时。桌面能力是在边界加的,核心应用从来没依赖它们。
这个区分不仅仅关系到在浏览器里跑。它意味着你可以把组件隔离测试,因为它们依赖的是接口,不是 invoke()。它意味着你可以加一个新运行时而不动组件,只写接口的一个新实现。它意味着"这是个 Web 应用还是个桌面应用"是一个配置选择,不是结构选择。
一个真实例子
假设你在构建一个笔记应用。在浏览器里,笔记存到你的后端 API。在 Tauri 里,笔记通过 Rust 存到本地文件系统。两边 UI 一样:一个笔记列表、一个编辑器、一个保存按钮。
SaveButton
|
FileService (接口)
/ \
/ \
WebFileService TauriFileService
| |
HTTP POST /api/notes invoke("save_note")
| |
后端服务器 Rust 函数
| |
数据库 本地文件系统SaveButton 不知道自己在哪一边。编辑器不知道。NoteList 不知道。唯一知道的是应用启动时选实现的那一行代码。
用户在浏览器里点保存,请求去你的后端。他们在桌面应用里点保存,请求去 Rust,Rust 写到磁盘。组件代码一样。体验不同。存储位置不同。信任边界不同。但用户交互的 React 代码是一样的。
在哪里画线
你定义的接口应该匹配你应用的需求,不是运行时的能力。不要创建一个包装每个 invoke() 调用的 TauriService。那只是搬动问题。创建一个 FileService 管文件操作,一个 NotificationService 管通知,一个 AuthService 管认证。每个接口描述你应用需要做什么,每个运行时提供自己对那个需求的实现。
决定某样东西是否放到接口后面的标准是:它在浏览器里和在 Tauri 里工作方式不一样吗?是的话,它放到接口后面。不是的话,它是共享代码,两个运行时用同一个实现。
文件访问不同。通知不同。OS 集成不同。表单校验一样。状态管理一样。路由一样。线画在运行时真正分叉的地方,一旦画下,分叉就集中在一个地方。
