Next.js 只是一个外壳,和 Electron 平级

多端 monorepo 里最容易搞错的一件事:把 Next.js 当成「主应用」,其他端当成「移植」。把它降级成外壳之后,包该怎么切就清楚了——尤其是 CLI 这种连 React 都装不了的端。

Posted by Jessie Jia on 2026-09-24

一个产品要同时有网页版、桌面版和命令行版。最自然的做法是先写 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
2
3
4
5
6
7
8
9
10
11
my-project/
├── packages/
│ ├── core/ # 纯逻辑:API 客户端、状态机、Git 交互、本地文件读写
│ └── feature-chat/
│ ├── src/
│ │ ├── core-logic/ # 聊天的纯逻辑:API 调用、状态、prompt 组装
│ │ └── components/ # React 界面
└── apps/
├── web/ # Next.js,引入 feature-chat/components
├── desktop/ # Electron,引入 feature-chat/components
└── cli/ # Node.js,只引入 core 和 feature-chat/core-logic

关键是 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
2
3
4
// packages/feature-chat/src/types.ts
export interface FileSaver {
saveFile: (filename: string, content: string) => Promise<void>
}
  • 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 的代码要保持一套。