从投标客户端看 Electron + React 工程化实践
引言
-
Electron + React ,桌面端跨平台的组合架构。
-
结合真实项目(投标客户端),从工程化角度聊聊落地。
一、项目架构总览
-
整体目录结构
客户端整体目录分为工程化、静态资源、业务代码,三个层面。
首先,工程化目录主要包含整个客户端的构建逻辑与资源,如:webpack基础配置、构建脚本、构建资源。
.erb/ ├── configs/ # webpack 主进程 / 渲染进程 / preload / DLL 等配置 ├── dll/ # 开发态 DLL 构建产物 ├── img/ # ERB 相关图片 ├── mocks/ # Jest 等 mock └── scripts/ # 清理、端口检测、notarize、electron-rebuild… └── package/ # npm run package 打包其次,静态资源目录里的资源属于业务级,只存放有业务代码所需要使用的资源。如:三方插件、子进程脚本。
static/ 静态资源和子进程 ├── childProcess/ # Node 子进程脚本(zip/unzip、磁盘信息等),自带依赖 ├── generic/ # PDF.js 相关(build/、web/) ├── viewer.pdf / viewer.scan.pdf 等 └── README.md最后,业务级代码统一存放在src目录下,具体如下。
src/ ├── clientLog/ # 客户端日志封装 ├── constants/ # 全局常量(含 IPC 键名、窗口尺寸等) ├── main/ # Electron 主进程 │ ├── index.ts # 应用入口(单例锁、协议、快捷键、创建主窗口等) │ ├── preload.ts # Preload(contextBridge 暴露 electron API) │ ├── mainBrowserWindow/ # 主窗口创建、Koa 服务、加载 URL、注册 IPC │ ├── server/ # Koa 本地静态服务 │ ├── pathFinder/ # 解析 preload / 静态目录 / HTML 路径 │ ├── ipc/ # ipcMain 模块化 handlers(browser、dialog、file、zip…) │ ├── ipcRenderHelper/ # 供 preload 使用的 ipc 封装 │ ├── ipcUpdate/ # 自动更新相关 IPC │ ├── database/ # SQLite 等主进程侧存储 │ ├── logger.ts, getProcess.ts, createChildProcess.ts, … │ └── config/ # 如安装路径等 ├── renderer/ # React 渲染进程 │ ├── index.tsx # 渲染入口 │ ├── App.tsx │ ├── index.ejs # HTML 模板 │ ├── router/ # React Router │ ├── store/ # Redux(RTK)各 slice │ ├── api/ # 与后端/本地 DB 等接口层 │ ├── common/ # hooks、constants、util、类型等共享逻辑 │ ├── components/ # 通用 UI 组件 │ ├── modules/ # 业务模块(Header、导入工程、树形侧边栏等) │ ├── layouts/ # 布局(Default、Process 流程布局等) │ ├── pages/ # 页面级(Login、ProjectList、ProjectInfo、制标、验标、PDF 导入等) │ ├── style/ # 全局与主题 less │ └── resource/ # 如内嵌 js 资源 ├── util/ # 与 renderer/main 弱耦合的小工具 ├── global.d.ts └── window.d.ts # window 上挂载类型(如 electron) -
核心入口文件梳理
先说明,Electron 应用不像单页 Web 项目那样只有一个 index.html 入口,而是至少经历 主进程启动 →本地服务 → 加载页面 → Preload 注入 → 渲染进程挂载 React 这一条链。链条上的每一步都由少量「核心文件」承担:有的负责进程与窗口生命周期,有的负责把系统能力以受控方式暴露给页面,有的则负责前端路由与业务层。下表按 从声明到构建 的顺序,记录这些文件与各自职责,便于快速建立全局心智。
层级 文件(路径) 作用 启动声明 package.json 的 main 指定 Electron 加载的主进程入口模块。 主进程 · 进程 src/main/index.ts 应用级生命周期:单例锁、协议、就绪后触发建窗等。 主进程 · 应用窗口配置 src/main/mainBrowserWindow/index.ts 窗口级:创建 BrowserWindow、起 Koa 本地服务、loadURL、注册 IPC。 主进程 · 路径 src/main/pathFinder/index.ts 统一解析 Preload 路径、页面 URL(开发 Dev Server / 生产本地 HTML)、静态目录等。 主进程 · 服务 src/main/server/index.ts 本地 HTTP 静态服务(供生产态加载 index.html 与资源)。 主进程 · 通信 src/main/ipc/index.ts 及同目录各模块 ipcMain 能力按模块拆分注册(文件、对话框、下载等) 桥接 src/main/preload.ts Preload:contextBridge 暴露 window.electron(IPC 封装、日志、fs 等)。 桥接 · IPC 封装 src/main/ipcRenderHelper/index.ts 渲染侧 IPC 白名单与 on/once 封装,避免裸传 ipcRenderer。 渲染进程 · 前端入口 src/renderer/index.tsx 全局初始化(日志、CA 等)后 ReactDOM.render,挂 Provider + HashRouter。 渲染进程 · 应用 src/renderer/App.tsx 路由之上的全局层:环境扫描、更新、与主进程协同的窗口/路由副作用等。 渲染进程 · 路由 src/renderer/router/index.tsx useRoutes 路由表:登录、项目列表、流程各页。 主进程构建 .erb/configs/webpack.config.main.prod.ts 生产打 主进程 + preload 双入口(main.js / preload.js)。 渲染进程构建 .erb/configs/webpack.config.renderer.*.ts 开发/生产打 渲染进程,入口为 src/renderer/index.tsx。 -
多进程模型:主进程 + 渲染进程 + Preload 脚本
读者需明确electron应用里同时存在主进程和渲染进程,如果没有沙箱机制,会导致两个进程的上下文混乱引发各种问题,同时主进程和渲染进程也是无法直接通信的,接下来用以下两点进行讲述。
-
什么是沙箱机制,它有什么作用?
沙箱(Sandbox):从安全的角度来说,在计算机里,指的是把某段程序关在「权限受限的运行环境」里跑,让它拿不到或很难拿到整套系统能力,从而降低「一段网页脚本、一个插件崩溃或被利用」时对整台电脑的伤害。
在 Chromium / Electron 里,和前端同学最相关的是 渲染进程沙箱:
打开页面时,HTML/CSS/JS 主要在 渲染进程里执行,这个进程在操作系统层面往往被加上各种限制(不能想读哪就读哪、不能随意启动任意程序等,具体能力随版本与配置而变)。 真正需要 读用户文件、弹系统对话框、深度集成系统 的事,通常交给 主进程或经主进程授权后再做。 这样做的直接好处是:界面再复杂,默认也有一层「隔离带」,恶意或出错的页面代码更难一步跳到「整盘操作系统权限」。
Electron 里还要叠一层「上下文」概念:页面里的 JavaScript 和 Preload 脚本并不混在同一个全局环境里;Preload 可以通过 contextBridge 有选择地把少量 API 挂到页面上。你可以把沙箱想成「围墙」,把 Preload 想成围墙上 登记过的门——只放行约定好的能力(例如封装好的 IPC),而不是把 Node、文件系统整包塞进页面。
-
在客户端里沙箱机制是如何应用的?
electron里沙箱机制被称为沙盒化行为,这是electron里可以进行配置的,包括指定沙盒可进行的行为权限,从electron 20 版本开始,默认就启用了沙盒,当然也可被配置禁用沙盒,但对于相关禁用沙盒的指令行为,官方文档里明确提及,“我们强烈建议你只针对测试用途开启此标志,并且 永远 不要用于生产环境。”具体想了解electron的沙盒化行为,详细了解可阅读进程沙盒化。
那么,在客户端里,分为主进程、渲染进程,它们之间如何进行通信,如下图所示。
二、多窗口架构设计
-
主窗口与子窗口的创建管理
设计思路:一类窗口,两种用法
统一窗口壳
主窗口和子窗口共用同一套窗口封装(同一类「浏览器窗口」配置),避免两套 Preload、两套 IPC 约定。差别主要在:有没有父窗口、首次打开的地址是否立刻加载、初始尺寸是否一致。
主窗口
应用启动后创建第一个窗口,没有父窗口。创建顺序为:建好窗口 → 为页面准备访问方式→ 加载入口页(登录页) → 再挂上主窗口要用的系统能力(文件、下载、窗口状态等)。窗口关掉时,要顺带释放只为页面服务的那部分资源(例如本地 HTTP),避免残留。
子窗口
由已有窗口在逻辑上发起:传入父窗口,让系统在窗口层级上建立父子关系。表现层上父子窗口可达到同时存在,互不干扰。
-
投标场景下哪些功能走了子窗口?
在投标场景下,子窗口,专门用来「只读看另一个项目」。子窗口解决的是 同时两件事:
主窗口:继续编制新项目标书(可写、可走流程)。
子窗口:只读打开别的项目(常见是旧项目)的标书,用于对照、核对、摘录,而不打断当前编制上下文。也就是说,在投标场景下,子窗口不是「流程里下一步换一块屏」,而是 「边写新的,边打开旧的(只读)」 的并行布局;还可以再理解成:多个只读视窗对应多份历史材料,主窗口始终守住「当前这份正在编的活」。
三、IPC 通信模式
-
模块化的 IPC Handlers
本工程里通过模块化,来管理 IPC Handlers,按照以下概念进行划分,按领域分文件、统一出口再注册。
之所以这么处理,是因为主进程里的 IPC 很容易长成「一个文件几千行」:文件对话框、下载、解压、环境检测、窗口尺寸...全堆在一起。结果是:改一处怕牵一片、Code Review 难拆、多人协作总冲突。
模块化就是把主进程能力按业务域拆开:每个文件只负责一类事(例如「保存到本地」「打开文件」「浏览器窗口行为」),对外只暴露一段注册逻辑——在窗口就绪时,把 ipcMain 的监听或 invoke 处理绑好。只要记住模型:「许多小模块 + 一个总装配点」。
-
Preload 脚本与 Context Bridge
**Preload(预加载脚本):**渲染进程里的页面(React 业务代码)和 Node / 系统能力之间,不能指望「像写服务端一样随便 require」。Electron 的做法是:在页面真正执行之前,先跑一段 Preload 脚本。它夹在主进程和页面脚本之间:负责把「允许页面使用的极少数能力」整理好,再交给页面用,以此把控安全。
Context Bridge (上下文隔离):
开启上下文隔离时,Preload 里的 JavaScript 和页面里的 JavaScript 不共用同一个全局环境,页面无法直接访问到 Preload 里的变量。
Context Bridge 的作用就是:在隔离的前提下,显式指定哪些东西可以挂到页面的 window 上,变成页面能访问的稳定、可预期的 API。用官方文档里的具体描述【在渲染进程中,预加载脚本暴露给已加载的页面 API 是一个常见的使用方式。 当上下文隔离时,您的预加载脚本可能会暴露一个常见的全局window对象给渲染进程。 此后,您可以从中添加任意的属性到预加载脚本】。通过上下文隔离,可以解决这个问题,详见上下文隔离。
对读者来说,只需明白三句话:
隔离:页面和 Preload 各住一间屋,减少页面脚本「顺手牵走」过多能力。
登记:只有通过 Context Bridge 登记过的属性/方法,才会出现在页面上。
契约:页面侧只认 window 上这一小块 API,相当于前后端之间的「客户端 SDK」。
-
业务模块与主进程通信
等同于渲染进程如何与主进程进行通信,结合上文介绍,渲染进程和主进程之间还有一层Preload和Context Bridge,他们之间关系如下:
四、React 与 Electron 的融合
-
Webpack 多入口打包
首先说明,为什么存在多入口打包。对于普通前端项目往往只有一个浏览器端入口;Electron 里却至少存在三种要被打成 JavaScript 的角色:
主进程:Node 侧、管窗口和系统能力,构建目标一般是 electron-main。
Preload:在页面加载前注入,构建目标通常与主进程产物同族。
渲染进程:本工程中是React 应用,构建目标是带 DOM 的 electron-renderer / web,走 Dev Server 或打进静态资源。
三份代码跑在 不同上下文,依赖和环境变量也不完全一样,因此用 多个 Webpack 入口(多份配置或多 entry) 分别打包,比硬塞进一个 entry 更符合 Electron 的边界。因此,在投标客户端里,有以下的打包配置,当然实际情况中,还有其他的模块需要打包,这里,仅供参考。
文件 作用 webpack.config.base.ts 各环境共用的 基础配置(如 externals、与 release/app/package.json 依赖对齐等),被其它 config merge 使用。 webpack.paths.ts 路径与别名:src/main、src/renderer、dll、主题 less、以及 @common、@pages、@store 等 alias,供所有 webpack 配置引用。 webpack.env.config.ts 构建期环境/主题:读取根目录 environment.js 的 getEnvConfig(),默认主题 theme(如 lan),供路径与 less 变量等使用。 webpack.config.main.prod.ts 生产主进程:target: electron-main,双入口 main(src/main/index.ts)+ preload(src/main/preload.ts),产出主进程与 preload 的 JS。 webpack.config.preload.dev.ts 开发 Preload:单独把 preload.ts 编到 .erb/dll/preload.js,对应脚本 npm run start:preload。 webpack.config.renderer.dev.ts 开发渲染进程:webpack-dev-server + HMR 等,入口含 src/renderer/index.tsx,对应 npm run start / start:renderer。 webpack.config.renderer.prod.ts 生产渲染进程:打包 React 页面资源,入口 src/renderer/index.tsx,对应 npm run build:renderer。 webpack.config.renderer.dev.dll.ts 开发 DLL:把部分依赖打成 DLL,加速开发构建;在 postinstall 里执行。 webpack.config.eslint.ts ESLint 解析用:直接 require renderer dev 配置,让 eslint-import-resolver-webpack 与开发态别名一致。 -
React + Redux + Router 的集成
这三件套集成都跑在 渲染进程 里,由 渲染侧的主入口打进同一个前端包。它们不和主进程、Preload 混编,只是通过 IPC / window.electron 与桌面能力协作。也就是说:Electron 只提供壳与系统能力,应用状态与路由仍是典型 React 工程。也意味着该部分是可以自定义的,不一定要采用React + Redux +Router来构建前端应用。
-
Koa 本地开发服务器
主进程里起了一个基于 Koa 的轻量 HTTP 服务,配合 loadURL 用 http://localhost:端口/... 拉取页面和静态资源,行为更接近浏览器环境,也便于约定路径与静态资源根目录。服务挂了 koa-static 做静态文件。窗口关闭时对监听端口做优雅关停,避免残留占用。
生产环境下,主窗口加载的地址会落在本机 Koa 端口上,并指向打包后的 index.html 等资源,整页应用由这台本地服务统一提供。
开发环境下,日常界面入口在 Webpack Dev Server 上跑热更新,端口与 Koa 不是同一个;Koa 仍会随窗口创建而启动,用于提供静态根路径,并通过 IPC 把 http://localhost:<Koa端口> 交给渲染层。PDF 预览、扫描预览、电子签章等需要以URL形式读取文件的功能,会依赖这个基地址,而不是在业务里写死端口。
Koa 只承担「在本机起一个稳定的静态与地址出口」,业务状态与路由仍在 React 侧;主进程保持服务层尽量薄,复杂业务不往中间件里堆,以免主进程膨胀、也难维护。
与 开发服务器相比:Webpack 服务给前端开发用,Koa 是装进客户端里的本地小站,二者分工不同。
总而言之,Koa就是一个部署在用户端环境下的小型服务器,用来搭载客户端的进程。
五、项目打包
- electron-builder
electron-builder,实际上由一个个构建脚本组成。在本项目中 以 npm run package 启动,按照流水线一样的工作机制依次执行构建脚本,具体流程如下。
六、结语
读者可以把 Electron 桌面应用想成几层叠在一起的系统:主进程负责窗口与系统能力,Preload 负责把能力以受控方式交给页面,渲染进程里再用熟悉的 前端技术栈做界面与状态。工程上是否省心,往往取决于这些边界是否干净、通信是否可追踪、打包是否一条命令能复现。本文以投标客户端为例,串起了多窗口、IPC、Webpack 多入口、本地 HTTP 服务以及从构建一条流水线,当然,这并不是唯一标准答案。最后,希望对于想要构建electron的读者们,有所帮助。