Obi Madu 的博客
返回所有文章
System DesignDesktopBackendFrontend

一份代码库,Web 和桌面

如何设计一个 Tauri 应用,让同一份 React 代码在浏览器和桌面都跑,方法是把运行时差异藏在一个服务接口后面。

一份代码库,Web 和桌面

如果你读过 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 集成不同。表单校验一样。状态管理一样。路由一样。线画在运行时真正分叉的地方,一旦画下,分叉就集中在一个地方。