如果你手里只有一个叫rea的项目代号,第一反应通常是懵的:它到底是个框架、一个工具,还是一次内部项目的代号?我接触过的做法比较直接——把rea当成React Electron App的缩写来用,也就是一个专门用于生成 Electron + React + Vite 桌面应用骨架的命令行脚手架。这篇文章就围绕这个定位展开:为什么一个命令行工具能替代一整套手工初始化流程,它内部是怎么把主进程、渲染进程、构建和打包串起来的,以及实际跑项目时最容易在哪个环节翻车。
我这个想法不是凭空拍脑袋。如果你自己手动搭过 Electron 项目,大概率经历过同样的循环:新建文件夹、初始化 npm、装 electron、装 react、配 vite、写主进程入口、写 preload、再配构建脚本……整套流程做一次还行,做三五次之后,你就会开始想要一条命令直接产出标准骨架。rea解决的就是这个重复劳动,也是我说“它不是框架、而是流程自动化”的原因。
1. rea 的定位:把重复的 Electron 初始化流程压缩成一条命令
1.1 rea 不是又一个框架,而是脚手架工具
先说清楚边界。React 管渲染、Electron 管桌面容器、Vite 管开发与构建,三者各司其职,本来不需要谁去发明新东西。rea做的事情更像一个“初始化器”:它把一个带默认配置的完整项目目录生成到你面前,让你省掉手工拼接的时间。这跟 Create React App 的思路类似,只不过 CRA 只解决浏览器端,rea面向的是桌面应用场景。
我第一次用这类工具时的感受是:它不会帮你写业务代码,但能把项目最底层的地基一次性打好。生成之后的目录里,主进程入口、preload 脚本、React 入口、Vite 配置、electron-builder 打包配置都是现成的。你可以直接npm run dev跑起来,也能看到主进程和渲染进程之间的示例通信。对新手来说,它是一份“能跑的最小桌面应用”;对老手来说,它是继续改造的起点。
1.2 从骨架层面理解三个角色怎么配合
很多人刚接触桌面应用开发时,会把 Electron 和 Node.js 混在一起理解,其实需要分开。Electron 的main进程是 Node 环境,负责窗口创建、系统交互、生命周期管理;renderer进程是浏览器环境,跑的是 React 页面;中间的preload脚本则是一个桥梁,通过contextBridge暴露安全的 API 给渲染进程调用。
rea生成的项目结构,默认就是按照这个三层关系组织的:
rea-project/ ├── src/ │ ├── main/ │ │ └── index.ts # 主进程入口 │ ├── preload/ │ │ └── index.ts # 预加载脚本 │ └── renderer/ │ ├── index.html # 渲染进程 HTML 入口 │ └── App.tsx # React 组件入口 ├── electron-builder.yml # 打包配置 ├── vite.config.ts # 渲染进程构建配置 └── package.json这个结构最大的好处是职责清晰。你改主进程逻辑不会碰到 React 代码,调整界面也不会干扰系统级别的操作。后面所有排查问题,也都是先问一句“问题出在哪一层”,再去看对应文件。
2. Electron + React + Vite 这套组合是怎么定下来的
2.1 桌面容器为什么选 Electron
桌面端方案现在不止一种,Electron、Tauri、Qt、原生开发都有人用。选 Electron,最核心的理由还是生态成熟和上手成本低。Electron 允许你直接用 Web 技术写桌面应用,团队里会 React 的人不需要额外学习原生 GUI 体系;而且它的社区积累足够厚,窗口、托盘、系统通知、自动更新、崩溃上报这些都有成熟方案,遇到问题基本搜得到。
代价当然也有,就是打包体积和内存占用比原生应用大。如果一个工具应用对安装包大小极其敏感,那 Electron 可能不是最优解;但大多数业务场景里,开发效率和生态成熟度比那几 MB 体积更值钱。rea默认选 Electron,本质是一种“求稳”的取舍。
2.2 渲染层为什么是 React 而不是别的
React 的竞争力在于组件模型和状态管理生态。桌面应用界面的复杂度通常不低,尤其是设置面板、数据表格这类交互密集的页面,用组件拆分会清晰很多。再加上 React 开发者的基数,团队内部接手时不需要重新培训。Vue 当然也能做,但既然代号都叫rea了,渲染层默认 React,算是保持代号和实现一致。
2.3 Vite 的速度优势不是玄学,是实测出来的
构建工具这个环节,我最早用过 Webpack 配 Electron,配置长度能写满一屏。换成 Vite 之后,最有体感的变化是开发服务器的启动速度和热更新响应。Vite 依赖原生 ES Module,启动时不用先打包整个应用,而是按需编译,所以哪怕项目里引了几十个模块,首次启动通常也在一秒量级。相比之下,Webpack Dev Server 在大型项目里启动到可用状态,经常要等好几秒甚至更久。
| 对比项 | Vite | Webpack Dev Server |
|---|---|---|
| 启动模式 | 原生 ESM 按需加载 | 先打包再提供访问 |
| 首次启动速度 | 明显更快 | 项目越大越慢 |
| 热更新粒度 | 模块级精准更新 | 视配置而定,可能整页刷新 |
| 配置复杂度 | 低,开箱即用 | 高,需要大量 loader/plugin |
这个差距在 Electron 场景里会被放大,因为渲染进程加载地址如果等太久,视觉上会让人觉得应用“卡死”。换用 Vite 之后,开发体验提升非常直接。
2.4 为什么不顺便一步到位换 Tauri
有人会问:既然都在聊构建速度和体积,为什么不直接选 Tauri?Tauri 的安装包确实小很多,内存占用也更低,但它的前提是系统里有 WebView 运行时,部分老系统上的表现不稳定;而且它要求后端起 Rust,如果团队没有 Rust 背景,上手曲线会更陡。rea作为脚手架,需要考虑的是大多数团队能直接跑通,Electron 仍然是容错率最高的选择。选型这件事,没有绝对最好,只有当前团队条件下最不折腾。
3. 用 rea 实跑一遍:从安装到首个窗口打开
3.1 初始化命令与参数约定
假设你已经安装了 Node.js 环境(建议 18 以上),rea的用法很简单。初始化一个新项目:
npm install -g rea rea create my-desktop-app执行后,默认会生成上一节那种目录结构,然后自动安装依赖。如果你已经在一个空目录里,也可以直接运行rea init。生成完的package.json里会预置好这几个脚本:
{ "scripts": { "dev": "vite --config vite.config.ts", "dev:electron": "wait-on tcp:5173 && electron .", "build": "vite build", "dist": "npm run build && electron-builder --config electron-builder.yml" } }dev负责启动渲染进程开发服务器,dev:electron会在端口就绪后拉起主进程。这两个命令拆开写,比写在一行里更容易排查问题:如果窗口没弹出来,你可以先确认 Vite 是不是真的起来了。
3.2 生成目录里最关键的三件事
安装完之后,先把目录里的几个关键文件打开看一遍,不要急着写业务代码。重点理解三件事。
第一,主进程入口src/main/index.ts里,窗口是用BrowserWindow创建的,加载方式区分开发和生产环境:
import { app, BrowserWindow } from 'electron'; function createWindow() { const win = new BrowserWindow({ width: 1024, height: 720, webPreferences: { preload: path.join(__dirname, '../preload/index.js'), contextIsolation: true, nodeIntegration: false } }); if (process.env.VITE_DEV_SERVER_URL) { win.loadURL(process.env.VITE_DEV_SERVER_URL); } else { win.loadFile(path.join(__dirname, '../renderer/index.html')); } }这段代码里的contextIsolation: true和nodeIntegration: false是安全默认值。很多老教程会让你把nodeIntegration打开,方便在页面里直接require,但那样等于把 Node 的完整能力暴露给了渲染进程。一旦页面里混入不可信内容,风险非常大。rea默认的配置就是为了避开这个坑,坚持用preload桥接。
第二,preload 脚本通过contextBridge暴露最小 API:
import { contextBridge, ipcRenderer } from 'electron'; contextBridge.exposeInMainWorld('desktop', { getVersion: () => { return process.versions.electron; }, sendEvent: (channel: string, data: any) => { ipcRenderer.send(channel, data); } });渲染进程里拿到的是window.desktop,而不是直接拿到ipcRenderer。这个封装的意义是:你能控制渲染进程到底能调用哪些能力,而不是把整个 IPC 通信通道都交给页面级代码。
第三,Vite 配置里需要关注base和build.outDir的配合。桌面应用打包后通常用file://协议加载页面,如果base是绝对路径,资源就会找不到。rea生成的配置默认会设置base: './',让构建产物使用相对路径,避免白屏。
3.3 启动后的第一次验证
运行npm run dev和npm run dev:electron之后,预期是一个空白窗口,标题栏带默认标题,页面上显示 Electron 的版本号。如果版本号能正常显示,说明三个进程之间的链路已经全通了。
如果窗口弹出来但页面是空白,优先检查两个地方:终端里有没有 Vite 的报错;主进程加载的 URL 和vite.config.ts里配置的server.port是否一致。这类问题九成是端口不一致导致的。
4. 打包分发阶段的高频坑:从白屏到路径错乱
开发模式跑通不等于能交付安装包。rea生成的骨架在npm run dist之后通常会出包,但第一次出包之后,你大概率会遇到下面几个问题。
4.1 打包后启动白屏:先查loadFile路径
开发环境走loadURL,打包后走loadFile。如果打包后双击应用只有白屏,最可能的原因是主进程找不到渲染产物的index.html。Electron 打包后,__dirname指向的是app.asar解包后的虚拟路径,不是项目源码路径。用path.join(__dirname, '../renderer/index.html')这种相对定位方式,前提是 electron-builder 的files配置里把这些目录都打进去了。
一个更稳妥的做法是启动时打印实际加载路径,开发阶段先在主进程里加一行日志,确认产物位置。我自己实际排查时发现,electron-builder 默认会把out、dist这类目录忽略掉,如果渲染产物被放在了手动指定的目录里,就要在files里显式说明。
4.2 preload 脚本打包后丢了一半功能
开发环境用的是 TypeScript 源码,打包后 preload 会变成编译后的 JS。有时候你会发现:开发环境一切正常,打包后window.desktop变成了undefined。这个坑通常来自 preload 脚本的构建配置没跟上。
一种常见情况是,vite.config.ts里只配置了渲染进程的入口,preload 脚本被交给了tsup或tsc单独编译,而打包配置引用的路径还是src/preload/index.ts这个源文件。Electron 主进程 load preload 时,要求的是一个真实存在的 JS 文件路径,不能直接把.ts交给它。解决方法是让 preload 也走一遍构建,或者在生产加载时指向dist-electron/preload/index.js。
注意:如果给
BrowserWindow传的preload路径是字符串且不含path.join,可以通过path.join(__dirname, ...)拼接。Electron 官方文档里也建议使用绝对路径。
4.3 资源文件“找不到”不一定是真的丢失
页面需要读取配置文件、图片或者本地数据库时,经常有人把文件放在项目根目录,然后直接用相对路径引用。开发时没问题,打包后却报ENOENT。原因是 Electron 打包后的工作目录不一定是应用目录,尤其当你从命令行启动时,相对路径会相对于启动位置解析。
规范做法是把动态资源放到app.getPath('userData')或app.getPath('resources')这类系统明确指定的目录里。安装包内置的只读文件,通过process.resourcesPath读取;运行时需要改写的文件,放到userData目录底下。
| 文件类型 | 推荐位置 | 示例 |
|---|---|---|
| 静态打包资源 | process.resourcesPath下 | 图标、内置模板 |
| 运行时用户数据 | app.getPath('userData') | 用户配置、日志 |
| 临时缓存 | app.getPath('temp') | 下载的临时文件 |
4.4 跨平台打包的另外几处细节
如果团队里有 Windows、macOS、Linux 都要发版,单靠一台机器出包通常不够。Windows 上构建 macOS 包受限,macOS 上构建 Windows 包也经常要额外装工具。更省事的方式是配置好 CI 流水线,每个平台在自己的 runner 上执行同一套rea生成的构建脚本。
还有几个跨平台小细节,代码里建议大家提前处理:路径分隔符用path.join不要自己拼字符串;涉及文件监听时,macOS 和 Linux 的行为有差异;窗口关闭事件在 macOS 上默认不应该直接退出应用,而是隐藏到 Dock。rea骨架在这些细节上不一定全部覆盖,落地时得根据目标平台补配置。
5. 再把 rea 改造成自己的项目模板
5.1 模板不是复制粘贴,而是增量沉淀
rea的价值在初始化,真正的生产力提升在长期迭代里。你不可能一个模板满足所有业务项目,更合理的做法是一套主模板加若干场景模板。比如:纯工具类桌面应用用一个精简模板;需要复杂界面和状态管理的中后台桌面应用,则把路由、状态库、UI 组件库预设进去。
模板维护上,我建议把“常用但不通用”的配置保持默认,把“每个项目都不同”的配置抽成交互式选项。rea初始化时如果只问两三个问题,比如是否需要托盘、是否需要自动更新、是否需要多窗口管理,生成出来的项目会比千篇一律的骨架贴合业务得多。
5.2 在骨架里追加多窗口与托盘能力
多窗口是桌面应用很常见的需求。主进程里维护一个Map<窗口名称, BrowserWindow>,用统一函数创建窗口,然后在渲染进程里通过 IPC 请求打开新窗口,是比较稳妥的设计。托盘功能则依赖Tray和Menu,核心是设置好右键菜单和悬浮提示,并处理好“关闭窗口不退出”的逻辑。
自动更新是个容易被低估的功能。Electron 生态里比较成熟的方案是 electron-updater,需要在 electron-builder 配置里提供发布服务器地址和更新通道;需要注意签名问题,尤其 macOS 上不签名基本无法正常更新。第一次落地自动更新时,建议先在内部测试环境跑通“低版本检查到高版本并完成下载安装”的全流程,再开放到生产。
5.3 和团队规范绑定才是长期收益
单独一个人用rea,省的是重复劳动;整个团队用,沉淀的才是规范。比如代码检查、提交信息格式、目录命名规范、日志规范,这些都可以固化到模板里。新同学加入项目时,跑一遍rea得到的项目就是他需要遵守的工程约束,少了很多口头对齐的成本。
我在实际操作中发现,模板里最值得加入的是“最小可用单元测试示例”。桌面应用很多时候没人写测试,但主进程里的工具函数、preload 暴露的 API 边界,其实非常适合自动化测试。骨架里带了测试,后面项目里才有继续补测试的动力。
6. 落地 rea 之后的几点真实体会
最后说几个不一定写进文档、但确实影响体验的细节。
首先,contextIsolation这个开关一定要保持默认开启,哪怕单位的老项目代码里到处是nodeIntegration,也别在新项目里走回头路。渲染进程和 Node 的隔离边界一旦被突破,代码层面是方便了,安全层面的口子也开了。
其次,如果你所在团队的网络环境访问 npm registry 很慢,rea第一次跑npm install可能会卡。遇到这种情况,可以在模板里预设镜像源地址,或者在初始化完成后手动改.npmrc。
再有,开发时 Vite 端口被人占用是个高频问题。rea这个骨架里可以考虑加一个端口自适应逻辑,发现 5173 被占用就自动换 5174,同时把环境变量同步给主进程,避免启动链路断掉。这些都是小改动,但非常能提升日常开发体验。
我自己的习惯是:每做完一个桌面应用项目,就回头看看rea模板哪部分可以改进,比如公共依赖升级、默认配置优化、新踩的坑补充成注释。让模板跟着项目一起变老,这才是脚手架工具该有的活法。