Skip to content

同一套 React/Vite 代码,如何同时跑在 PWA 与 Tauri 上 ​

译注:本文编译自 dev.to 文章《One React/Vite product across PWA and Tauri—without pretending they are identical》,作者以开源项目 WorldScript Studio 为例,讨论跨端复用代码时的边界问题。原文链接见文末。

一套 React/Vite 代码库,可以同时支撑 GitHub Pages 站点、已安装的 PWA、边缘托管部署,以及 Tauri 桌面应用。但这并不意味着这些运行环境可以互换。

当应用涉及用户项目、离线行为、AI 服务商、文件系统访问或安全策略时,这一区分就变得尤为重要。共享组件是有价值的,但共享假设可能是危险的。

WorldScript Studio 是一个有参考价值的案例:它的 React/Vite 应用被有意地同时发布到浏览器/PWA 与 Tauri 桌面环境。产品共享领域语义,但并不假装浏览器标签页与原生外壳拥有相同的权限边界。文中代码引用对应仓库 commit 2d9157c0(2026-09-28),release v1.28.8。

共享产品模型,而非每个实现细节 ​

跨平台产品需要对以下问题给出稳定答案:什么是项目?其中包含哪些数据?哪个状态是权威的?导出意味着什么?当 AI 服务商不可用时应该发生什么?

这些答案应当共享,但其底层机制不应被强行做成完全一致。对 PWA 而言,浏览器提供 IndexedDB、Cache Storage、Service Worker、Web API、WebGPU 以及按源隔离的存储;桌面外壳则可提供应用数据文件系统访问、原生网络、操作系统集成与平台打包。

试图用单一通用抽象隐藏所有差异,往往会带来更糟的结果:浏览器 API 泄漏进桌面代码,原生假设泄漏进 Web 代码,产品内部积累出多套偶然形成的持久化或网络策略定义。更好的原则是:共享领域语义与互操作契约,通过显式适配器暴露平台能力。

宿主环境改变安全边界 ​

同一份静态构建可以部署到不同宿主,但托管方式会改变应用能够保证的东西。

运行环境重要能力重要限制
GitHub Pages静态公开部署无法注入任意 HTTP 安全响应头
Vercel / Cloudflare Pages响应头与边缘函数能力Web 项目数据仍受浏览器源存储限制
已安装 PWA缓存外壳与浏览器原生安装存储仍与源和浏览器绑定
Tauri 桌面文件系统持久化与原生 HTTP桌面项目文件目前未做应用层静态加密

GitHub Pages 很好地说明了为什么“同一份源码部署”并不足够。WorldScript 在该平台使用 meta 标签形式的 Content Security Policy,因为 GitHub Pages 无法添加对应的响应头——项目部署文档称该 meta 标签是该宿主上唯一的执行点。Vercel 和 Cloudflare Pages 则可以设置真正的响应头来镜像该 meta CSP,并且都能运行同源边缘中继(Claude 代理在 Vercel 上位于 api/claude-proxy.ts,在 Cloudflare 上位于 functions/api/claude-proxy.ts,共享同一个核心模块)。即便用户看到的是同一个 React 界面,这些部署保证也截然不同。正确的文档不应抹平这一区别,而应明确指出它。

存储是权限决策,不是便利 API ​

PWA 的实时项目路径使用浏览器存储,桌面路径使用应用数据目录下的文件系统存储。两者都是本地存储,但并不相同。

浏览器持久化受浏览器源、配额、淘汰行为和存储 API 约束;桌面持久化受文件系统访问、原生进程边界以及应用自身读写规则约束。这一差异在安全表述上尤为关键:浏览器/PWA 受保护的 IndexedDB 数据在配置后可启用基于口令的加密生命周期,而文件系统支撑的桌面项目存储目前并未获得同样的静态加密。一个同名 UI 开关并不足以让保护等级对等——真正决定保护范围的是实际的持久化路径。

原生网络改变“本地服务器”的含义 ​

浏览器连接 localhost 仍受 CORS 和 Private Network Access 等浏览器规则约束;Tauri 桌面应用则可以使用被许可的原生 HTTP 能力访问本地或云端端点。

这并不意味着桌面网络自动更安全,而是说其策略必须以不同方式定义和执行——这里的“被许可”是字面意义上的。桌面外壳的 HTTP 能力是显式白名单,而非开放管道:

json
// src-tauri/capabilities/default.json (excerpt)
{
  "identifier": "http:default",
  "allow": [
    "http://localhost:*/*",
    "http://127.0.0.1:*/*",
    "https://generativelanguage.googleapis.com/*",
    "https://api.openai.com/*"
    // …remaining provider hosts, nothing else
  ]
}

运行时,fetch 适配器按环境选择实现:在 Tauri 运行时动态加载原生 HTTP 插件,其他环境使用浏览器的 fetch。对 WorldScript 而言,这条桌面原生路径支持 Ollama 兼容端点等本地推理服务器工作流,而无需 WebView 绕过浏览器源规则。浏览器/PWA 路径则不应静默探测本地端口,也不应假装同一路由在无需用户配置服务器的情况下就能工作。

一般性结论很简单:浏览器安全限制是产品约束,而非需要绕过的麻烦;原生能力应被窄范围许可,而非全局开放;UI 文案必须说明某功能仅限桌面,或依赖本地服务器配置。

PWA 缓存不得泄漏到桌面行为 ​

Service Worker 是强大的浏览器特性,但不是通用应用运行时。WorldScript 的 Service Worker 在 Web 端缓存其 Web 外壳并处理离线回退;在 Tauri 中,注册代码走相反路径,主动拆除该浏览器机制:

typescript
// register-sw.ts (excerpt — called only from the Tauri branch)
async function teardownServiceWorkerInTauri(): Promise<void> {
  if (!('serviceWorker' in navigator)) return;
  const registrations = await navigator.serviceWorker.getRegistrations();
  await Promise.all(registrations.map((reg) => reg.unregister()));
  const keys = await caches.keys();
  await Promise.all(
    keys.filter(isWorldScriptOwnedCacheName).map((k) => caches.delete(k)),
  );
}

它会注销所有已存在的 Service Worker,并清除应用自有缓存,从而防止过期的浏览器缓存以离线回退覆盖真正的桌面打包应用。这是一个平台适配做得好的微妙例子:该功能被禁用并非因为它不方便,而是因为其浏览器生命周期对原生打包而言是错误的权限来源。

跨平台检查清单 ​

在宣称 Web 与桌面产品是“同一个应用”之前,请先问:

  1. 各平台把权威项目持久化在哪里?
  2. 哪个平台控制响应头、CSP 下发与网络策略?
  3. 离线行为由缓存外壳、原生打包,还是两者共同提供?
  4. 哪些集成需要浏览器权限、CORS 配置或原生能力?
  5. 各存储路径是否具备相同的加密与恢复属性?
  6. 平台特有的边界是否在 UI 和文档中可见?

共享代码库是实现层面的优势,但它不是抹平用户和维护者需要理解的差异的理由。目标应当是:在产品契约共享之处保持一致,在平台改变契约之处保持诚实。

原文链接 ​