技术方案拆解 · 硬核但寓教于乐
iOS · Android · macOS · Windows · Linux · 浏览器 —— 看起来完全一样,又能灵活适配,还改一处多端生效。这份文档把它讲透。
实证来源:本地 fork /Users/park0er/coding/paseo-contrib/paseo
能「一套代码多端通吃」,靠的不是一个魔法,而是三根支柱叠在一起:
前两条解决「看起来一样」,第三条解决「改一处、多端生效」。
传统认知:iOS 一份 Swift、Android 一份 Kotlin、Mac 一份、网页又一份 —— 四份代码,改一个功能改四遍。Paseo 把「端」拆成了两块:
关键洞察:壳很薄,芯很厚。你 99% 的工作都在写只有一份的「芯」。代码库结构直接印证了这个设计 —— 这是一个 monorepo:
paseo/packages/
├── app/ ← 【芯·UI】Expo/RN 界面,iOS/Android/Web 共用
├── desktop/ ← 【壳】Electron 外壳,装的是 app 的 web 版
├── server/ ← 【大脑】daemon 守护进程,业务逻辑在这
├── protocol/ ← 【契约】前后端通信的共同语言
├── client/ ← 【SDK】连 daemon 的客户端库
├── cli/ ← 命令行遥控器
└── relay/ ← 可选加密中继(远程穿透用)
传统写界面是「命令式」:手动创建元素、手动同步数据。React 换成「声明式」—— 你只写「界面 = 当前数据的函数」,数据变了 React 自己算出哪里要改。
function Greeting({ name }) {
return <Text>你好,{name}</Text>; // 界面 = 数据的函数
}
普通 React 渲染成网页 HTML。React Native 的天才在于:把你写的 <View>、<Text> 在 iOS 上翻译成苹果原生控件、在 Android 上翻译成安卓原生控件。你写的不是网页,是「界面意图」,由 RN 翻译成真正的原生 UI。
packages/app 就是一个 Expo 项目。这个库把 RN 的 <View>/<Text> 反向翻译回浏览器的 <div>/<span> + CSS。于是同一段组件有了三个归宿:
你写的一份组件 <View><Text>你好</Text></View>
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
iOS 原生 UIView Android 原生 View 浏览器 <div>
(React Native) (React Native) (react-native-web)
这就是「iOS、Android、桌面页面感觉完全一样」的第一层原因:它们本来就从同一份组件树生成,只是最后一步翻译目标不同。
三端不可能 100% 相同(摄像头、终端渲染都不同)。难点是:处理差异又不写成一堆 if(平台==='ios') 的意大利面。Paseo 用两个干净机制:
打包器 Metro 遇到 import './terminal-emulator',按当前构建平台自动挑对应后缀:
| 构建平台 | 优先选中的文件 |
|---|---|
| iOS | terminal-emulator.ios.tsx |
| Android | terminal-emulator.android.tsx |
| Web | terminal-emulator.web.tsx |
| iOS + Android 共用 | terminal-emulator.native.tsx |
| 都没有 → 通用兜底 | terminal-emulator.tsx |
调用方完全无感,只写 import './terminal-emulator',永远拿到当前平台正确的实现。代码库里实际扫到 31 个这样的分叉文件,例如:
browser-pane.web.tsx ← 网页版内嵌浏览器
browser-pane.electron.tsx ← 桌面版内嵌浏览器(Electron webview)
markdown-text.ios/android/web.tsx ← 三端各自的 Markdown 渲染
voice/audio-engine.native.ts / .web.ts ← 原生 vs Web Audio
这就是「灵活适配」的核心:99% 共享,真正因平台而异的 1% 拆成 .平台 小文件,编译期自动挑。没有运行时判断,每个平台产物只含它自己那份。
.electron.tsx 是 Paseo 自己发明的后缀。因为桌面版本质是「网页版」(平台=web),但又多了原生能力。怎么区分「普通浏览器」和「Electron 桌面」?答案在 metro.config.cjs:
const customWebPlatform = process.env.PASEO_WEB_PLATFORM; // "electron"
// web 构建 + electron 时:先找 .electron 版,没有就退回普通 web 版
if (platform === "web" && customWebPlatform === "electron") {
try { return resolve(..., ["electron.tsx", "electron.ts"]); }
catch { /* 退回普通 web */ }
}
桌面构建脚本正是这么触发:PASEO_WEB_PLATFORM=electron npx expo export --platform web。一个环境变量,切换整层行为 —— 桌面版 = 网页版 + 一层薄薄的原生增强。
第一步 —— 把 app 导出成静态网页,产物 app/dist 塞进桌面安装包(electron-builder.yml):
extraResources:
- from: ../app/dist # app 的网页导出
to: app-dist # 打进桌面 App
第二步 —— Electron 主进程注册自定义协议,让窗口加载它(main.ts):
protocol.handle("paseo", (request) => {
// 映射到资源里的 app-dist;找不到文件就回退 index.html(SPA 路由)
return net.fetch(pathToFileURL(path.join(appDistDir, ...)));
});
await mainWindow.loadURL("paseo://app/"); // ← 窗口里装的就是 app 网页版!
结论坐实:桌面 App 窗口里跑的,和浏览器 web 版是字节级相同的同一份网页产物。它们不是「长得像」,是「本来就是同一个东西」,一个套在 Electron 窗口、一个套在 Chrome 标签。Mac/Win/Linux 三桌面又共用这同一份 app-dist,区别只在外壳的打包格式。
用 react-native-unistyles v3,解决两件事:
theme.colors.surface0 这种语义变量而非写死颜色,切深浅色时全端自动换肤。const styles = StyleSheet.create((theme) => ({
container: { backgroundColor: theme.colors.surface0 }, // 换肤不触发重渲染
}));
团队甚至立铁律禁用 useUnistyles() Hook(它会导致全界面重渲染)。这种性能纪律侧面说明:跨端共享 UI 要跑得顺,样式架构选择很关键。
目录结构直接就是页面结构:
src/app/h/[serverId]/workspace/[workspaceId]/index.tsx
→ URL: /h/某主机/workspace/某工作区
同一套 URL 路由在手机(原生导航栈)、桌面(Electron)、网页(真 URL)上都成立。一份路由定义,三端通吃。
你观察到的「改一处、多端生效」,更根本的原因是:Paseo 绝大部分聪明逻辑压根不在客户端。
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 手机 App │ │ CLI │ │ 桌面 App │
└────┬────┘ └────┬────┘ └────┬────┘
│ WebSocket │ WebSocket │
└────────────┼────────────┘
▼
┌─────────────┐
│ Daemon │ ← 大脑:所有业务逻辑在这
│ (Node.js) │
└──────┬──────┘
Claude · Codex · Copilot · OpenCode · Pi ← 各 AI agent
daemon(packages/server)启动/管理 AI agent、流式输出、管 git、跑终端。三个客户端全是遥控器,自己不含业务逻辑。改一个业务功能(如 agent 状态机),只改 daemon,三个客户端一行不动就同时变聪明 —— 因为它们本就只是显示 daemon 的状态。
WebSocket 消息格式、二进制帧编码全在 packages/protocol 定义一次,server 和三个客户端都依赖它。改一个字段,TypeScript 在所有端同时报错,逼你全改对 —— 用类型系统把「多端一致性」变成编译期硬约束。协议还规定只能追加不能删字段,让新老客户端和同一 daemon 共存。
「怎么连、断线重连、发 RPC」封装在 packages/client,App 和 CLI 共用。连接层也是共享的一份。
.ios/.android/.web/.electron(约 1%,自动选)结论:日常改功能 90% 落在「共享 UI」「共享大脑」两层,天然多端生效。只有真正的平台差异才落到最上面那薄薄一层分叉文件。这套分层就是「既统一又能适配」的全部秘密。
| 平台 | 打包工具 | 更新机制 |
|---|---|---|
| iOS / Android | EAS Build(云构建)→ App Store / Play | EAS Update(OTA 热更新,纯 JS 改动不发商店直接推) |
| macOS | electron-builder → dmg + zip(公证) | electron-updater(源:GitHub Releases)+ 分阶段灰度 |
| Windows | electron-builder → nsis + zip(x64/arm64) | 同上,含 beta 通道 |
| Linux | electron-builder → AppImage/deb/rpm/tar.gz | 同上 |
| 浏览器 | expo export → Cloudflare Pages | 部署即更新 |
monorepo 用 npm workspaces + sync-workspace-versions 让所有包版本同步,发布脚本一条龙:检查 → 升版本 → 发 npm → 打 tag。docs/release.md 里还有完整的分阶段放量(先放一小撮、逐步扩大、可放慢可回滚)。
packages/app(一份 UI 代码,RN + RN-Web)
│
┌─────────────────┼──────────────────┐
expo run expo export expo run
(原生编译) --platform web (原生编译)
▼ ▼ ▼
iOS / Android app/dist(网页) (同左,原生)
┌────┴────┐
▼ ▼
浏览器直接跑 Electron 套壳
(web 版) Mac/Win/Linux
所有客户端 ── 同一套 WebSocket 协议(protocol)──▶ daemon(大脑)
Paseo 用「RN + RN-Web 让一份 UI 渲染到原生和网页」+「Electron 把网页版套成桌面 App」+「业务逻辑全下沉到共享 daemon」这三招,把「四份代码四个平台」压缩成「一份芯 + 几层薄壳」。看起来一样是因为本来就是同一份;能灵活适配是因为差异被隔离进 .平台 分叉文件和薄原生壳;改一处多端生效是因为逻辑都在共享层。
不讲代价是不诚实的。从代码库文档能看到团队实实在在踩过的坑:
> * 子选择器污染)、滚动容器主题不更新等诡异 bug。抽象越厚,漏抽象时越烧脑。.平台 文件意味着某些功能要为多平台各写一遍,保持一致需要纪律。这些代价换来的,是几人小团队能同时维护 6 个平台、改一次多端生效。对 Paseo 这种产品这笔交易显然划算 —— 但它是有意识的工程权衡,不是魔法。
packages/app/package.json(看 react-native + react-native-web)packages/app/src/**/*.{ios,android,web,native,electron}.tsxpackages/app/metro.config.cjs 的 PASEO_WEB_PLATFORMpackages/desktop/src/main.ts(搜 protocol.handle / loadURL("paseo://app/"))+ electron-builder.yml(搜 app-dist)docs/architecture.md、packages/protocol、packages/serverdocs/release.md、packages/app/eas.json、packages/desktop/electron-builder.yml