一个产品要同时有网页版、桌面版和命令行版。最自然的做法是先写 Next.js,然后想办法把它塞进 Electron,再想办法给 CLI 复用点什么。
这条路会很难走,因为它默认了 Next.js 是主应用。换个说法就顺了:Next.js 前端只是一个外壳,和 Electron 完全平级。CLI 是第三个外壳。三个外壳共享同一套逻辑包。
数据流
flowchart TD
W["Web 外壳
Next.js 前端"] --> CORE["packages/core
API 客户端 / 状态机"]
D["桌面外壳
Electron"] --> CORE
C["CLI 外壳
Node.js"] --> CORE
CORE --> S["Next.js Server
所有服务端逻辑"]
S --> DB["数据库 / 云端"]
三个外壳谁也不依赖谁,都只依赖 core。
目录长这样
1 | my-project/ |
关键是 feature 包内部那条线:core-logic 和 components 分开。
这条线是被 CLI 逼出来的
只有 Web 和 Desktop 两个端时,这条线可有可无——两边都跑 React,feature 包整个复用就行。
CLI 进来之后它就是硬性的。CLI 不能 import 任何 React 组件,不是”最好别”,是根本跑不起来。所以 feature 包必须能被拆开用:界面归界面,逻辑归逻辑,CLI 只拿后者。
这条线也顺带解决了一个长期问题:业务逻辑写在组件里,想复用只能连着 UI 一起搬。强制拆开之后,逻辑那半天然是可测的——不用 jsdom,不用 render,直接调函数。
跨端差异靠注入,不靠判断
同一个功能在不同端行为不同,是必然的。比如点”下载文件”:Electron 端应该调 Node 的 fs 存到硬盘,Web 端应该走浏览器 Blob 下载。
有两种写法。一种是在 feature 里判断当前环境:
1 | if (isElectron()) { /* ... */ } else { /* ... */ } |
这种写法每加一个端就要回头改一遍所有 feature,而且 feature 包从此依赖”我知道自己跑在哪”。
另一种是定义接口,由外壳注入实现:
1 | // packages/feature-chat/src/types.ts |
- Web 外壳传入用 Blob 实现的
saveFile - Electron 外壳传入通过 IPC 调主进程
fs的saveFile - CLI 外壳直接传
fs.writeFile
feature 包只认这个接口,永远不知道自己跑在哪个端。加第四个端时,feature 一行不用动,写个新的实现注入进去就行。
这就是防腐层:差异只存在于外壳,不渗进共享包。
一个不对称的地方
前面说”Next.js 和 Electron 平级”,但这句话只对客户端那一半成立。
服务端逻辑全在 Next.js 的 API 路由里。也就是说 apps/web 这个包身兼两职:它既是三个平级外壳之一,又是另外两个外壳要访问的服务端。
这个安排本身没问题——挑一个地方放服务端总比放三个地方好。但要知道代价:
apps/web删不掉。哪天想换个前端框架,得先把 API 搬走- CLI 和 Desktop 在开发时也要跑起 Next.js 的 dev server,哪怕它们只用到 API 路由
- “外壳”和”服务端”两种职责混在一个包里,边界只靠约定维持
真想彻底对称的话,服务端应该单独成 apps/server,Next.js 退回纯外壳。多一个部署单元,换来的是三个外壳真正对等。做不做取决于换框架的可能性有多大。
落地时踩的两个点
React 会偷偷跑进 core-logic。 一个 useState 引进来,CLI 就挂了。这条约束不能靠自觉——在 packages/*/core-logic 上加 lint 规则禁掉 react 的 import,或者 CI 里跑一次 CLI 的构建,越早炸越好。
“纯逻辑”不等于”没有副作用”。 core 里有 Git 交互和本地文件读写,这些在浏览器里根本不存在。所以 core 本身也得按运行环境拆:跨端都有的部分(API 客户端、状态机)和只有 Node 才有的部分(fs、git)不能放在同一个入口里,否则打包 Web 时会炸。
下一篇写鉴权怎么处理:Web 靠 Cookie、CLI 靠 Bearer Token,但 feature 里调用 API 的代码要保持一套。