Lesson 01 · 进程与边界

跑起来,并分清代码跑在哪

在 Electron 项目里,「代码跑在哪个进程」决定了你该去哪个控制台找问题。 分不清,你会在错的地方找一下午。

基线:2026-08-12 的 gameboxclient 工作区快照。 文中每条实现描述都带 file:line,写的时候逐条核对过。

先说人话:为什么「跑起来」是三件事

你熟悉的 Laya 项目跑起来是一件事:起个服务,浏览器打开,完事。 浏览器里的代码就是全部的代码。

Electron 不是。它同时跑着两个完全独立的 Node/Chromium 进程, 它们之间连变量都不共享。你写的代码分散在这两边, 而且从长相上看不出区别 —— 都是 TypeScript,都能写 import

这带来一个非常具体的后果。假设界面上一个按钮点了没反应,你打开 DevTools,控制台干干净净,什么错误都没有。

这时候不是「没有错误」,是「错误发生在你看不到的地方」。 那个按钮的处理函数可能把活儿交给了主进程,主进程报的错在另一个终端窗口里。 这一课就是让你不再掉进这个坑。

三层,各是什么

代码在哪 它是什么 能干什么
渲染进程 src/** 一个 Chromium 页面。DOM、Vue、CSS 都在这 画界面、发 HTTP 请求。本项目里它还能直接用 Node —— 后面会讲这为什么不正常
主进程 electron/main/** 一个 Node.js 进程,负责管窗口和操作系统交互 开关窗口、系统菜单、自动更新、文件对话框、fork 子进程
子进程 extend/worker/*.js 主进程 fork() 出来的独立 Node 进程 干耗时的脏活:下载几个 G 的资源包、解压。放在主进程会卡住整个应用

主进程的入口小得惊人,只有两行 (electron/main/index.ts):

import { ready } from './handler/ready'

ready()

真正的启动流程在 electron/main/handler/ready.ts:23。 完整的八步时序画在了 架构图 FIG.02,这里不重复。

第一步:把它装上

直接 npm i 会失败

# ❌ peer dependency 冲突
npm i

# ✅
npm i --legacy-peer-deps

# ✅ 或者用项目自带的(等价,还会顺带跑 cpsvn 脚本)
npm run i

原因不在你。仓库里原本有个 .npmrc 写着 legacy-peer-deps=true,git 历史里还能看到当初专门为此做的提交 (chore(npm): .npmrc 增加 legacy-peer-deps,让裸 npm i 也能装上), 但当前工作区里这个文件不见了

❌ 别学

把「怎么装依赖」这种信息藏在一个叫 i 的脚本名里。 新人打开项目的第一反应永远是 npm i, 没有人会想到去翻 package.json 找一个单字母脚本。

规范做法:装依赖的特殊要求要么写进 .npmrc 让它自动生效,要么写在 README 第一屏。

你可以顺手修:.npmrc 加回去。 一行改动,是个很好的第一次提交。

第二步:跑起来

npm run dev

它实际是 vite --mode base。发生的事情有两件:

  1. Vite dev server 在 4080 端口起来,服务 src/ 下的代码;
  2. vite-plugin-electron 编译 electron/main/, 然后拉起 Electron 进程去加载那个 dev server (vite.config.tsonstartoptions.startup(['.', '--no-sandbox']))。

所以你的终端里同时混着两个进程的输出: Vite 的构建日志和 Electron 主进程的 console.log。 而渲染进程的日志不在终端里,在应用窗口的 DevTools 里。

打开 DevTools

Shift+Cmd/Ctrl+I。 这个快捷键不是 Electron 默认给的,是项目自己注册的: 菜单模板定义在 electron/main/config/menu.tsdebugMenu,由渲染层在 src/App.vue:50 发一个 debug IPC 消息触发装配。

这本身就是一个好例子:「注册一个系统菜单」是主进程的能力, 「决定什么时候注册」是渲染层的逻辑。两边靠一条 IPC 消息接起来。 这就是 Electron 应用最典型的分工。

第三步:登录 —— 这一步比你想的重要

在多数项目里,登录就是「换个页面」。在这个项目里不是。

登录接口 /user/info 的响应里带着一个 game 字段, 里面装着 SVN 账号密码、Jenkins 地址账号 token、GM 接口地址、项目编码src/utils/extend/envOption.ts:8initEnv() 把它们逐个写进 Pinia,此后整个应用都从那里取配置。

源码里用到约 50 个 VITE_* 键, 而 .env 系列文件加起来只定义了 11 个。 差额全靠登录下发。

登录成功后立刻做这一步

打开 DevTools 控制台,敲:

JSON.parse(localStorage.getItem('GameENV'))

应该能看到 VITE_SVN_URLVITE_JENKINS_URLVITE_API_BASEPATH 这些键有值。

记住这条命令。它是你未来排查「点了没反应」类问题的第一条命令, 能省掉大量无效排查。完整链路见 架构图 FIG.03「钥匙链」

核心:三层怎么区分

现在给你三段真实代码。先别往下看答案, 自己判断每一段跑在哪一层。

片段 A

const mainWindow = new BrowserWindow(mergeOption)
manager.addWindow(mainWindow, windowNames.main, true)
mainWindow.setMinimumSize(1280, 960)

片段 B

process.on('message', (msg) => {
  if (msg.type === 'start') {
    const { url, outputPath, name } = msg
    const file = fs.createWriteStream(outputPath)
    // …
  }
})

片段 C

const clickDownloadCosResources = async () => {
  const pathDir = DialogFunctions.showSaveDialog()
  // …
  Fs.writeFileSync(localPath, data)
  ElMessage({ type: 'success', message: '下载成功' })
}

片段 C 跑在哪一层?

为什么片段 C 让人不安

标准的 Electron 应用里,渲染进程不能碰 Node。 它就是个普通网页,想读文件必须请主进程代劳。 中间那道门叫 preload:一个特殊脚本, 在页面加载前运行,用 contextBridge明确列出的几个方法 挂到 window 上。页面只能用这几个,别的一概摸不到。

本项目没有这道门。三件事叠加:

electron/main/config/options.ts:6-8 —— 窗口配置里 nodeIntegration: truecontextIsolation: falsewebSecurity: false,三个安全开关全关。

vite.config.ts:58-72 —— preload 的构建配置整段被注释掉。 文件 electron/preload/index.ts 存在,里面还写着规规矩矩的 contextBridge.exposeInMainWorld,但它从来没被编译过。

src/utils/extend/electron.ts:1 —— 取而代之的是 @electron/remote:渲染层可以直接抓到主进程的 dialogappprocessBrowserWindow 对象。

所以片段 C 里的 DialogFunctions.showSaveDialog() —— 一个本该属于主进程的系统对话框 —— 在渲染层被直接调用了。

⚠️ 隔离

渲染层全开 Node 权限。这是架构级决定, src/ 下几百个文件建立在它之上。你接手第一个月绝不该动它。

但要清楚代价:渲染层加载的任何内容 —— 包括从服务端拿到的、被渲染进页面的任何东西 —— 都拥有读写整台机器的能力。 webSecurity: false 还额外关掉了同源策略。

规范做法是保持 contextIsolation: true, 用 preload + contextBridge 只暴露必要方法。见 Electron · Context IsolationElectron · Security Checklist

你现在该遵守的规矩:新写的代码不要继续扩散 @electron/remote。要用主进程能力就老实加一条 IPC, 至少让边界是显式的、能在 channel.ts 里一眼数完的。

✅ 照做

重活 fork 给子进程(片段 B)。 下载和解压是 CPU / IO 密集的长任务,放在主进程会让整个应用的窗口失去响应 (主进程也负责窗口消息循环)。项目把它们放到 extend/worker/*.js,用 process.send() 回传进度 —— 这是标准做法,新功能可以照抄这个模式。

出问题时去哪找

症状 先怀疑哪层 去哪看
页面白屏、样式错乱、按钮点了没反应 渲染进程 DevTools 控制台
请求 404 / 403 / 打到了错的服务器 渲染进程 DevTools Network(展开 Payload 看 cmd 字段
窗口起不来、应用直接白着不动 主进程 启动 Electron 的那个终端
菜单 / 快捷键 / 系统对话框失灵 主进程 同上
下载或解压进度条卡住不动 子进程 最难查。子进程自己的 console 看不到,只能沿 process.send → 主进程 → 渲染层这条链加日志
dev 正常,打包后才出问题 主进程的路径计算 handler/ready.ts:10-20PUBLIC 显式按 app.isPackaged 分两种算法,其余路径全从 __dirname 推 —— 而 __dirname 在打包前后指向完全不同的位置
关于主进程日志的一个坑: electron-log 只在非 development 模式下才初始化 (electron/main/handler/ready.ts:24-26)。 开发时你在 Logger.info() 里写的东西只会出现在终端, 不会落进日志文件。反过来,用户报障时要的日志文件,只有正式包才有。

真机验证

如果你在 macOS 上

登录时控制台会报几个错误,跟 initNginx / moveTemp 有关 (views/ReLogin/index.vue:256,273)。 它们要跑的是 .exe,Mac 上必然失败。

这不影响登录本身,现阶段可以忽略。 但它是一个信号:这个工具实际上是 Windows 优先的。 完整影响范围见依赖清单