Tauri2 开发入门:应用是如何启动的
IntroTauri 2 应用本质上是两个独立进程的协作系统前端进程负责用户界面渲染后端进程Rust 二进制提供系统能力与业务逻辑。完整的启动流程需要先后端进程先行启动由其托管并唤起前端页面随后两者通过进程间通信IPC建立双向数据通道。只有两个进程均完成初始化并建立通信连接应用才算真正启动就绪。Tauri 项目分为两个进程Tauri 应用采用双进程模型Rust 核心进程Core Process用 Rust 编写负责创建窗口、管理系统 API、处理 IPC进程间通信、安全控制等。这是整个应用的“后端大脑”。WebView 进程使用操作系统原生 WebViewWindows 用 WebView2、macOS 用 WebKit、Linux 用 WebKitGTK渲染前端界面HTML/JS/CSS 或 React/Vue/Svelte 等。前端不直接访问系统只能通过 Rust 暴露的 commands命令进行安全 IPC 调用。注两个进程之间的通讯方式采用 IPC对于此概念有疑问可以看后文『What is RPC』一节理解这一协作模型后下一步是观察项目文件结构前端代码位于 src 目录后端入口位于 src-tauri/src/main.rs而 src-tauri/tauri.conf.json 则定义了二者如何关联及启动时的行为。后续章节将逐一拆解这些文件在启动流程中的具体作用。Tauri 项目的文件结构my-tauri-app/ ├── src-tauri/ # Tauri后端核心目录 │ ├── Cargo.toml # Rust项目配置文件 │ ├── Cargo.lock # 依赖版本锁定文件 │ ├── src/ # Rust源代码目录 │ │ ├── main.rs # 入口文件包含Tauri命令定义 │ │ └── lib.rs # 库文件 │ ├── icons/ # 应用图标多种尺寸和格式 │ ├── target/ # Rust编译输出目录 │ └── tauri.conf.json # Tauri应用配置文件 │ ├── src/ # 前端源代码目录如React/Vue/Svelte │ ├── assets/ # 静态资源图片、字体等 │ ├── components/ # 前端组件 │ └── main.js # 前端入口文件 │ ├── node_modules/ # 前端依赖目录 ├── package.json # 前端项目配置文件 ├── pnpm-lock.yaml # 包管理器锁定文件若使用pnpm └── index.html # 前端HTML入口文件main.rs是 Rust 程序的入口文件在 Tauri 2.x 中一般只负责调用run()。而位于lib.rs中的run()则实际负责初始化 Tauri 应用、配置前端窗口、定义系统托盘等核心逻辑。阅读 Tauri 项目的后端部分一般会先从main.rs开始入手lib.rs作为核心逻辑载体通常会放run() 函数应用启动逻辑command handlers#[tauri::command]状态管理state插件注册窗口配置注在 Tauri v1.x 中更多的函数被写在main.rs中但这并不符合 Rust 的 Crate 设计哲学main.rs→ binary crate入口lib.rs→ library crate逻辑run() 的职责读到这里不难发现 Tauri 应用是通过在main.rs中调用run()启动的应用程序。有读者可能会好奇main.rs怎么知道run()在lib.rs中实际上这是因为 Tauri 项目在生成时采用了库 二进制lib bin 的标准 Rust 结构而main.rs通过 crate 声明引入了lib.rs中导出的内容。这样的对应关系在src-tauri/Cargo.toml中进行配置。不过main.rs和lib.rs的对应关系是默认的约定可以不用管。要是改成别的对应关系就得去这里配置。run()函数总是从tauri::Builder::default()发起一个链式调用如下所示#[cfg_attr(mobile, tauri::mobile_entry_point)]pubfnrun(){tauri::Builder::default().plugin(tauri_plugin_opener::init()).setup(|app|{#[cfg(desktop)]setup_desktop(app)?;Ok(())}).invoke_handler(tauri::generate_handler![greet]).run(tauri::generate_context!()).expect(error while running tauri application);}tauri::Builder::default()是 Tauri 框架中的一个核心方法用于创建一个默认配置的Builder实例。Builder是 Tauri 应用的构建器负责配置和启动 Tauri 应用。Builder实例含有一些默认配置包括窗口设置、应用名称、图标等基础属性。还可以通过链式调用添加自定义逻辑。在Setup函数中通过条件编译完成了对不同平台的初始化操作这是 Tauri 的常见做法如果是 Rust 初学者你可能好奇|app| {...}是什么语法实际上这是 Rust 的闭包更通俗地讲可以理解为 lambda 函数详细内容参见附录『Lambda in Rust』一节随后在invoke_handler中注册 Rust 函数作为命令 使前端 JavaScript 代码能够调用这些 Rust 函数这是 Tauri实现前后端通信的核心机制。generate_handler!和#[tauri::command]协作构成了 Tauri 命令系统的编译时代码生成链路关于 Tauri 为什么要依靠#[tauri::command]实现前后端通信的核心机制参见附录『Invoke from Frontend』一节。更加进阶的内容即generate_handler!和#[tauri::command]如何协作参见附录『Code Generation Pipeline』一节前端加载机制开发模式连接 Vite / Webpack 等 dev server生产模式加载嵌入二进制中的前端资源通过 tauri_build 和 include_str! 或 rust-embedWebView 与前端的交互边界通信机制Tauri框架精心设计了两套强大且互补的通信机制以应对不同的交互场景即基于命令invoke的请求-响应模式以及基于事件listen/emit的发布-订阅模式。前端通过 invoke 与后端进行交互这涉及的#[tauri::command]与generate_handler!已经在前文提到了。通过这两个宏的配合可以在后端定义可供前端调用的函数。这种机制主要负责前端向后端发起请求并同步获取返回结果。它适用于直接执行特定后端操作并期望立即获得处理结果的场景例如保存文件、查询数据库等其行为模式类似于传统的API调用。与此不同事件系统listen/emit则提供了一种双向、异步、解耦的通信范式。它允许应用中的任一部分前端或后端发布emit一个具名事件并可携带数据同时另一部分则可以监听listen这些事件并在事件发生时触发相应的处理函数。显然这个机制既可以用于后端通知前端也可以用于前端通知后端。这一机制完美解决了后端主动向前端推送实时更新、进度通知、广播消息等问题也允许前端向后端发送非阻塞的“即发即忘”式通知从而极大地增强了应用的响应性、灵活性和模块化程度。关于Tauri 的事件系统的细节可以参阅附录『Event System in Tauri』一节事件监听器可以在应用的任何时刻、任何地方注册不一定局限在setup中尽管在这里是相当常见的。在 WebView 完成页面加载后通过 WebView 注入的window.__TAURI__对象Tauri 核心会自动注入该对象。这意味着前端 JavaScript 代码必须等待window.__TAURI__可用后才能安全使用任何 Tauri API。实际的动态注入由 Tauri 框架完成window.__TAURI__本质上是一段 JavaScript 桥接代码其核心作用正是为前端 JavaScript 提供与 Rust 后端进行进程间通信IPC的能力。更进一步地说其本质是将 Rust 与 JavaScript 之间定义好的通信协议以 JavaScript 可直接调用的函数形式封装起来让前端代码能以简单的方式发起跨语言调用而无需手动处理底层序列化、消息传递和 WebView IPC 接口。显然在前端不论是 Invoke 还是listen/emit都需要依靠window.__TAURI__才能实现总结从启动到就绪的完整时序启动流程Rust 二进制启动用户双击 exe或通过终端运行操作系统加载 Rust 编译后的二进制文件执行src-tauri/src/main.rs中的fn main()函数。Tauri Builder 初始化tauri::Builder::default()创建构建器对象开始配置应用的基本信息窗口、菜单、插件等。执行 setup 钩子.setup(|app| { ... })被调用。这是开发者可以介入的最早时机通常在这里注册全局事件监听初始化插件准备后端共享状态此阶段仍在 Rust 主线程执行。创建主窗口Tauri 根据tauri.conf.json中的窗口配置创建 WebviewWindow包含原生标题栏、边框等。启动 WebView 并加载前端页面WebViewWindows 用 WebView2、macOS 用 WKWebView、Linux 用 WebKitGTK开始加载前端资源通常是index.html由 Vite、Next.js 等打包生成。前端页面加载完成浏览器内核触发DOMContentLoaded或load事件。此时 HTML、CSS 已解析完成。Tauri 注入通信桥接代码Tauri 核心通过 WebView 的脚本注入机制向页面注入一段 JavaScript 代码创建window.__TAURI__全局对象或通过tauri-apps/api包间接提供。此时前端可以安全地使用invoke、listen、emit等 API。前端框架初始化与渲染前端 JavaScript 执行React/Vue/Svelte 等框架开始挂载组件执行useEffect、onMounted等生命周期钩子可能发起第一个invoke调用或设置事件监听用户界面逐渐变得可交互。后端进入事件循环tauri::Builder::run(tauri::generate_context!())被调用后端主线程进入事件循环event loop。从这一刻起Rust 开始持续处理来自前端的 IPC 请求invoke事件发送与接收emit/listen窗口事件、系统消息、托盘点击等启动完成的明确标志Tauri 程序真正启动完成的标志是以下两点同时成立后端事件循环已运行run()方法已执行Rust 侧能够持续响应 IPC 消息和系统事件。后端“活起来”了。前端页面完全可交互前端 JavaScript 执行完毕window.__TAURI__已注入用户可以正常点击按钮、输入内容、看到界面响应。此时invoke、listen、emit等通信功能均可正常工作。只有同时满足以上两点应用才进入可用状态。如果只看到窗口出现但点击无反应前端还没初始化完或后端还没进入事件循环无法响应 invoke都不能算启动完成。实际开发中的对应位置Rust 侧主要逻辑在src-tauri/src/main.rs的setup和run()中。前端侧初始化逻辑通常放在src/main.tsx或App.tsx的顶层useEffect中。如果需要确认启动完成可以在前端监听tauri://window-created或自定义一个启动完成事件从 Rust 发出通知前端“后端已就绪”。这个流程在 Tauri v1 和 v2 中核心时序基本一致v2 在多窗口和插件初始化时更加清晰有序。附录What is IPCIPCInter-Process Communication进程间通信是操作系统必备且常见的功能它用于让不同进程之间交换数据或协调工作。基于数据传输的通信方式有管道Pipe一种最简单的通信方式可以把它理解为“一个单向的数据通道”一个进程写数据另一个进程读数据。常见于父子进程之间比如命令行里的|。消息队列Message Queue类似“消息收发箱”进程可以把一条一条的消息放进去其他进程按顺序或按类型取出来。相比管道它更灵活可以传递结构化数据。共享内存Shared Memory多个进程共同使用同一块内存区域就像“共同编辑一张白板”。它的优点是速度非常快但缺点是需要额外机制来避免多个进程同时修改数据导致混乱。基于同步/控制的通信方式信号Signal可以理解为“提醒机制”或“通知”一个进程可以向另一个进程发送信号比如告诉它“该停止了”或“有事情发生了”。但它只能传递很简单的信息。信号量Semaphore用于控制多个进程对资源的访问可以理解为“门口的计数器”。例如限制最多只有几个进程可以同时访问某个资源常用于避免数据冲突。互斥锁Mutex类似“一把锁”同一时间只允许一个进程访问某个资源。谁拿到锁谁用用完再释放防止多个进程同时操作同一数据。条件变量Condition Variable用于让进程“等待某个条件成立”。比如一个进程可以等待数据准备好另一个进程在准备好后通知它继续执行。IPC in TauriTauri 的 IPC 本质上采用异步消息传递Asynchronous Message Passing模型属于基于数据传输的通信方式中的消息传递Message Passing类别。它主要通过两种核心原语实现invoke命令请求-响应模式前端调用 Rust 后端的函数支持参数传递和返回值类似fetchAPI。emit / listen事件单向通知模式支持双向发射Frontend ↔ Core适合生命周期事件、状态变更等 fire-and-forget 场景。内部会结合必要的同步机制如请求 ID 匹配来保证消息有序处理和响应对应但开发者几乎无需直接操作底层同步原语。与传统操作系统 IPC 不同Tauri 的 IPC不依赖管道单向字节流、共享内存或 OS 级消息队列而是通过序列化消息在前端 WebView 进程和 Rust 后端进程之间通信。v1 主要使用 JSON 序列化v2则进行了重大重构支持更高效的二进制 payloadRaw Payloads可直接传递ArrayBuffer等避免了 JSON 在大对象或二进制数据上的开销。这种设计优先保障了安全性所有消息均经过能力系统 / Capabilities 校验和跨平台一致性同时对开发者非常友好。异步消息传递既保留了传统消息传递的灵活性又借助 Rust 的类型安全和 Tauri 的权限系统提供了更高的安全性非常适合构建现代桌面和移动应用。Tauri v2 的 IPC 实现细节Tauri v2当前主流版本 对 IPC 层进行了重写主要采用自定义 URI 协议custom protocol并在必要时回退到postMessage。具体来说核心机制前端通过fetch或底层等价实现向自定义协议如ipc://或http://ipc.localhost发起请求WebView 将其拦截并交给 Rust 侧处理而非真正走网络。Rust 侧通过 wry/tao 注册的register_uri_scheme_protocol或等价内部实现接收请求并返回响应。与 postMessage 的关系并非简单的“postMessage custom protocol 混合”而是以 custom protocol 为主Windows、macOS 等平台优先使用以提升性能和支持二进制数据在 custom protocol 不可用或失败时回退到传统的window.ipc.postMessage由 WebView 自身提供。Linux 等平台可能根据 WebKitGTK 版本选择合适方式。底层通道完全在 WebView 内部完成OS 不参与任何系统级 IPC无管道、无共享内存、无 OS 消息队列。它利用 WebView 的协议注册机制将通信“伪装”成本地 HTTP 请求但本质仍是 WebView 与宿主进程间的内部消息传递通道。相比 v1v2 的 custom protocol 实现显著提升了性能支持二进制、无需全部 JSON 字符串化同时保持了高度的安全性和跨平台一致性。开发者仍通过统一的tauri-apps/api接口使用无需关心底层差异。Compare to Traditional IPC优点安全所有通信都经过序列化 能力系统Permissions校验后端可以精确控制前端能调用什么。简单易用开发者无需关心底层序列化、线程安全等问题。跨平台一致无论 Windows、macOS、Linux还是移动端API 体验相同。v2 版本大幅优化了性能支持二进制数据传输和新的 Channels 机制适合更高频或较大 payload 的场景。缺点相对共享内存有序列化/反序列化开销不过 v2 已显著改善。不适合极高频、超大块数据的零拷贝传输Tauri 优先选择安全而非极致性能。总体而言Tauri 的 IPC 设计在安全、性能和开发者体验之间取得了优秀的平衡是其轻量级、现代桌面/移动应用框架的重要基石。Lambda in Rust / ClosureRust 习惯叫闭包closure而不是 lambdaClosure SyntaxRust中的Lambda表达式通常被称为闭包使用简洁的语法捕获周围环境中的变量。闭包在Rust中是匿名函数可以像普通函数一样被调用但支持捕获外部变量。|参数列表|-返回类型{函数体}例如letadd|a,b|ab;println!({},add(1,2));// 输出: 3其中参数列表与函数参数类似但类型通常可以省略由编译器推断当然也可以显式指明letadd|a:i32,b:i32|-i32{ab};// 明确指定类型letadd:fn(i32,i32)-i32|a,b|ab;// 或者通过变量类型标注Closures Traits闭包自动实现以下特性之一Fn不可变借用捕获。FnMut可变借用捕获。FnOnce所有权转移捕获只能调用一次。不可变借用默认letx10;letprint_x||println!({},x);print_x();// 输出: 10Invoke from Frontendinvoke_handler在 Tauri 应用中的作用是注册 Rust 函数作为命令 使前端 JavaScript 代码能够调用这些 Rust 函数。这是 Tauri 实现前后端通信的核心机制。能够在invoke_handler中注册的函数必须由#[tauri::command]这一宏进行标记。这是因为#[tauri::command]会为函数生成必要的元数据包括函数签名信息参数和返回值的类型信息命令名称默认使用函数名这些元数据是 generate_handler! 宏识别和注册命令的依据。另一方面来讲generate_handler!宏的作用是收集所有被#[tauri::command]标记的函数 并生成一个统一的命令处理程序。如果函数没有被#[tauri::command]标记generate_handler!就无法识别它因为函数缺少必要的元数据未经过序列化/反序列化处理不具备错误处理能力进一步探究#[tauri::command]这个宏所做的远不止是标记一个函数。它是在编译时执行一段代码生成程序为你的普通函数包裹上一层 Tauri 框架能理解和调用的外壳。具体来说它会为你完成以下几项关键工作参数解析与反序列化前端传来的数据是 JSON 格式的字符串。这个宏会生成代码自动将 JSON 反序列化为 Rust 函数期望的具体类型如String、i32或自定义结构体。这是任何胶水代码都必须做的基础工作。依赖项自动注入Tauri 命令可以请求一些特殊参数比如 Window调用者窗口或StateT全局状态。这个宏生成的代码会识别这些特殊类型并从上下文中自动注入它们你无需手动传递。generate_handler!宏配合工作就是负责构建这个上下文并建立函数名到处理函数的映射。返回值的序列化与发送函数返回的结果比如String或自定义结构体需要被序列化回 JSON并通过 IPC 通道安全地发送回前端。这部分逻辑也由宏生成的代码自动完成。异步与错误处理无论你的函数是同步还是异步的这个宏都能生成合适的包装代码来处理.await和Result成功/失败类型确保错误能被优雅地传递回前端。所以这并不是“既然都知道函数函数在哪了直接通过函数名注册“就能解决的事情。就技术上来讲Rust 缺乏稳定的反射API。如果是 Java 或 C# 那样的语言在运行时能保留完整的类型信息。但 Rust 的函数、结构体等信息在编译后就被擦除了。即使退一步讲为之实现了一套运行时反射但是这种运行时的类型检查不仅会拖慢应用启动速度还会因为无法剔除无用代码而导致最终二进制体积显著增大。Rust 作为一门零成本抽象的静态语言其设计哲学就是尽可能将工作从运行时转移到编译时当然读到这里的读者可能有疑问那对于一个中大型项目来说需要注册的函数数目很大这时候在generate_handler!函数中岂不是参数多到爆炸还真是。目前社区已经有一些缓解方法Tauri 团队也正在讨论几个解决方案不过显然作为入门文章这不属于本文的讨论范围。Code Generation Pipeline前文提到generate_handler!宏与#[tauri::command]构成了 Tauri 命令系统的编译时代码生成链路。两者分工明确#[tauri::command]负责函数层面的包装generate_handler!负责调度层面的注册。组件职责缺失后果#[tauri::command]将任意函数适配为统一签名generate_handler!无法获得类型一致的函数指针generate_handler!构建函数名到包装函数的映射包装函数存在但无法被前端发现和路由1.#[tauri::command]生成的包装函数当一个函数被#[tauri::command]标记时过程宏会为原始函数生成一个符合 Tauri IPC 调用约定的包装函数。示例如下#[tauri::command]fnadd(a:i32,b:i32)-i32{ab}宏展开后大致等效于// 原始函数保持不变fnadd(a:i32,b:i32)-i32{ab}// 生成的包装函数fnadd_wrapper(state:tauri::State,args:tauri::ipc::Args)-Resulttauri::ipc::Response,tauri::ipc::Error{// 1. 从 args 中反序列化参数 a, b// 2. 调用原始 add// 3. 将返回值序列化为 JSON}这个包装函数具有固定的函数签名接受State和Args返回ResultResponse, Error。这是 Tauri 调度器能够统一调用的前提。2.generate_handler!生成的调度器generate_handler!宏接收多个函数标识符如add,subtract为每个函数生成一个类型安全的调度条目tauri::generate_handler![add,subtract]宏展开后会生成一个函数指针数组或元组其中每个元素包含函数名的字符串表示用于前端匹配指向上述包装函数的指针3. 两者的完整协作流程[编译时] 原始函数 add │ ├── #[tauri::command] 展开 │ │ │ └── 生成 add_wrapper (统一签名) │ └── generate_handler![add] 展开 │ └── 生成调度条目: (add, add_wrapper) [运行时] 前端调用 invoke(add, { a: 1, b: 2 }) │ ▼ Tauri IPC 层接收 │ ▼ 在调度条目中查找 add │ ▼ 调用对应的 add_wrapper │ ▼ add_wrapper 反序列化 → 调用原始 add → 序列化返回Event System in TauriArchitecture在架构上Tauri 的消息系统分为三个核心角色事件发射器Rust 后端或前端都可以作为生产者调用 emit 发送事件。事件总线Tauri 运行时Runtime维护着一个全局的监听器注册表。当事件被发出时总线负责根据EventTarget进行路由分发 。事件监听器前端或后端注册的回调函数等待事件触发。在这里任何事件的内容都是由事件负载Payload记录的它本质是 JSON 字符串。它可以由前端或者后端代码行程由于规范是统一的这在 Rust 和 JavaScript 之间架设了一座标准化的桥梁。具体来说Tauri 在两端使用了对等的序列化策略Rust → 前端Rust 侧的值通过serde_json::to_string()序列化为 JSON前端收到的event.payload已经是解析好的 JavaScript 对象前端 → Rust前端的对象通过JSON.stringify()序列化Rust 侧收到的是str或可以通过serde_json::from_str()反序列化这也是为什么 Tauri 官方不建议用事件系统发送大文件或进行高频低延迟的数据流传输——序列化和 JSON 解析的开销在极端场景下会成为瓶颈换言之Rust后端只能发送实现#[derive(Serialize)]的数据进阶地如果你的场景需要高频通信如每秒几百次建议聚合多个数据点后再发送使用 Tauri 的 Channels 机制基于二进制流考虑 Commands 自定义序列化Type of EventTauri v2 明确了两种事件分发范围 全局事件发送给所有窗口和所有监听者。特定 Webview 事件只发送给指定标签Label的窗口。因此如果意图实现前端 → 前端跨窗口的事件需要先发给 Rust 后端由其代为转发由于 Tauri 的架构是一个 Rust 主进程 N 个 WebView 窗口。虽然整个 Tauri 应用只有一个 Rust 主进程但每个窗口有自己的上下文如#[tauri::command]fnwindow_specific_command(window:tauri::Window)-String{// 每个窗口调用这个命令时会传入自己的 Window 实例format!(当前窗口标签: {},window.label())}因此前端向后端发送数据时可以通过特定 WebView 事件区分区分来源app.listen_global(some-event,|event|{ifletSome(label)event.window_label(){matchlabel{mainprintln!(来自主窗口),childprintln!(来自子窗口),_println!(其他窗口),}}});