Vue 与 Electron 的开发、构建和桥接工作流
从三个运行位置理解 Vue 热更新、生产资源路径和受控桥接,区分 Web 预览与真实桌面验证。
本页内容
从 Vue 项目到桌面项目,新增的不是另一套组件语法#
你已经熟悉组件、路由、状态和浏览器请求,本章不重新讲响应式。新的问题是:界面如何取得受控的桌面能力,开发服务器为什么在安装后不存在,以及三个运行位置为什么不能共用一套构建假设。目标是能够解释一个按钮从 Vue 到 preload 再到 main 的完整路径,并独立定位“网页能用、桌面不能用”的原因。
前置是已经读过 Electron 的主进程、渲染进程、隔离上下文与 IPC 基础章节。本章仍把 Node 看作新环境:main 是由 Electron 启动的程序入口,可以使用 Node 模块;renderer 是 Chromium 中的网页;preload 在页面脚本之前执行,负责建立有限接口。三个文件可能同属一个仓库,却并不拥有相同的全局变量、模块加载方式和生命周期。
浏览器中的组件通过 fetch 调后端,Electron 组件通过 bridge 请求主进程,两者都可以看作远程边界。差别在于 main 往往能访问当前用户文件与系统窗口,所以不能把 HTTP 客户端中“传任意 URL”的通用设计直接复制成“传任意 IPC 频道”。界面需要的是读取应用信息、选择文件等业务动作,不是获得 ipcRenderer、fs 或 shell 的完整控制权。
三份产物为什么必须分别考虑#
renderer 构建目标是浏览器:Vue 单文件组件经过插件转换,CSS 与图片形成静态资源,模块按浏览器方式加载。main 构建目标是 Electron 内置的 Node 环境:electron 与 Node 内建模块必须保留为运行时依赖;若使用 TypeScript,还要考虑输出格式与源码映射。preload 处于两者之间,但不是普通网页入口,也不是完整 Node 主进程。
默认启用 sandbox 的 preload 只能使用 Electron 提供的受限模块环境。不要在 preload 里任意 require 自己的本地模块、数据库驱动或者完整 Node 依赖链。本章将 preload 保持为一个很小的 .cjs 文件,只引入 electron。复杂逻辑放入 main 或纯模块,renderer 与 preload 共享的是合同,而不是共享有系统权限的实现。进程模型
本章刻意不引入同时管理三个构建器的脚手架:renderer 由 Vite 构建,main 与 preload 使用已经可以直接运行的 CommonJS,不做转译。这不是少了两个运行位置,而是它们暂时不需要额外编译。之后换成 Forge Vite 插件时,仍要分别认识三个入口;插件替你编排构建,不能替你理解每个输出会在哪个进程执行。
热更新也由所在位置决定。修改 Vue 组件,Vite 通常替换相关模块并尽量保留界面状态;修改 main,必须重启 Electron 主进程才会重新注册窗口和处理器;修改 preload,至少要重新加载相应页面才能重新执行,但本章统一重启 Electron,减少旧 bridge 留在页面里的误判。仅看到终端提示重新编译,不能证明当前窗口已经加载新代码。
实验一:一个真实 Vue renderer 与窄桥接#
这是需要桌面环境的完整多文件实验,目录名为 vue-desktop-info。没有文件访问、数据库或模型调用,只读取应用版本与操作系统名称。依赖版本在二〇二六年九月十一日核对:Electron 44.3.0、Vue 3.5.42、Vite 8.3.0、Vue 插件 6.0.8。命令可在 Node 22.22.0 环境执行;首次安装 Electron 会下载运行时,本文未执行这项安装或 GUI 启动。
先保存 package.json,再执行安装命令。这里的 type:module 让 Vite 配置使用 ESM;main.cjs 与 preload.cjs 的扩展名明确保留 CommonJS,不受它影响。
{
"name": "vue-desktop-info",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "main.cjs",
"scripts": {
"dev:web": "vite",
"dev:desktop": "electron . --dev",
"build:renderer": "vite build",
"preview:desktop": "electron ."
}
}
npm install --save-exact vue@3.5.42
npm install --save-dev --save-exact electron@44.3.0 vite@8.3.0 @vitejs/plugin-vue@6.0.8
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
base: "./", // loadFile 下资源相对 index.html,不指向文件系统根目录。
server: { host: "127.0.0.1", port: 5173, strictPort: true },
build: { outDir: "dist", emptyOutDir: true }
});
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://127.0.0.1:5173; object-src 'none'; base-uri 'none'">
<title>桌面环境读取</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
import { createApp } from "vue";
import App from "./App.vue";
createApp(App).mount("#app");
<script setup>
import { ref } from "vue";
const text = ref("尚未读取");
async function read() {
if (!window.desktopInfo) {
text.value = "这是 Web 预览:未提供桌面能力";
return;
}
text.value = "读取中";
try {
const result = await window.desktopInfo.readEnvironment();
text.value = result.ok
? "版本 " + result.data.version + ",平台 " + result.data.platform
: "读取失败:" + result.error.code;
} catch {
text.value = "桌面连接中断,请重新打开窗口";
}
}
</script>
<template>
<main>
<h1>应用信息</h1>
<button @click="read">读取桌面环境</button>
<p role="status">{{ text }}</p>
</main>
</template>
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("desktopInfo", {
readEnvironment: () => ipcRenderer.invoke("environment:read")
});
const { app, BrowserWindow, ipcMain, session } = require("electron");
const path = require("node:path");
const { pathToFileURL } = require("node:url");
let win;
const dev = !app.isPackaged && process.argv.includes("--dev");
const entry = dev ? "http://127.0.0.1:5173/"
: pathToFileURL(path.join(__dirname, "dist", "index.html")).href;
function withoutHash(value) {
const url = new URL(value);
url.hash = "";
return url.href;
}
function trusted(event) {
return win && !win.isDestroyed() &&
event.sender === win.webContents &&
event.senderFrame === win.webContents.mainFrame &&
withoutHash(event.senderFrame.url) === entry;
}
async function createWindow() {
win = new BrowserWindow({
width: 760, height: 520,
webPreferences: {
preload: path.join(__dirname, "preload.cjs"),
contextIsolation: true, sandbox: true, nodeIntegration: false
}
});
win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
win.webContents.on("will-navigate", event => event.preventDefault());
if (dev) await win.loadURL(entry);
else await win.loadFile(path.join(__dirname, "dist", "index.html"));
}
app.whenReady().then(async () => {
session.defaultSession.setPermissionRequestHandler((_wc, _permission, done) => done(false));
session.defaultSession.setPermissionCheckHandler(() => false);
ipcMain.handle("environment:read", event => {
if (!trusted(event)) return { ok: false, error: { code: "FORBIDDEN" } };
return { ok: true, data: { version: app.getVersion(), platform: process.platform } };
});
await createWindow();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow().catch(error => { console.error(error); app.quit(); });
}
});
}).catch(error => { console.error(error); app.quit(); });
app.on("window-all-closed", () => { if (process.platform !== "darwin") app.quit(); });
先开终端执行 npm run dev:web,确认它监听指定地址,再开第二个终端执行 npm run dev:desktop。桌面按钮预期显示版本与平台;同一个地址放进普通浏览器,按钮应明确显示 Web 预览提示。关闭两个进程,执行 npm run build:renderer,然后 npm run preview:desktop:此时无需 Vite 服务器,窗口读取 dist 产物。预览产物仍是开发目录中的 Electron 运行,不是安装包。
strictPort:true 的作用是端口占用时立即失败。默认自动换端口对纯网页开发很方便,但 main 仍加载固定端口时,会导致它连到错误服务或者连接失败。host 限制监听回环地址;这个地址只用于你控制的本地开发服务器,不应从用户输入读取。开发服务器也属于受信任代码来源,运行桌面开发命令前要确认端口上的确是本项目。
main 的入口选择同时检查 app.isPackaged 与显式参数,避免已打包应用因为残留环境变量而加载开发页面。loadURL 与 loadFile 都返回 Promise,加载失败必须有可观察的错误;否则空白窗口容易被误认为 Vue 挂载失败。preload 路径必须是绝对路径,__dirname 在 CommonJS 中指向当前文件目录,与启动命令所在工作目录无关。
上面的 file 加载用于理解资源路径,资源全部来自本地受控构建目录;复杂应用应采用受限自定义协议,减少 file 协议的额外能力和来源处理差异。生产 CSP 也应去掉开发专用 WebSocket 来源,并检查是否仍需要内联样式。不要为修复白屏关闭 webSecurity,也不要把 nodeIntegration 改成 true 来绕开 bridge。安全边界
类型声明只解决开发体验,不构成授权#
如果项目使用 TypeScript,可以增加下列完整声明文件。它只描述 renderer 看得见的形状,不导入 Electron 类型,因此 Vue 层不会顺手依赖整个 Electron API。desktopInfo 可选是有意的:普通浏览器没有 preload,强行声明永远存在,只会把真实环境差异藏到运行时。
export {};
type EnvironmentResult =
| { ok: true; data: { version: string; platform: string } }
| { ok: false; error: { code: "FORBIDDEN" } };
declare global {
interface Window {
desktopInfo?: {
readEnvironment(): Promise<EnvironmentResult>;
};
}
}
类型声明不会验证 IPC 发送者,也不会过滤恶意参数。一个函数写了 Promise<Result>,并不保证主进程一定返回该结构。应用有更多字段后,应把可共享的 schema、错误码和桥接版本放到无系统权限的合同模块,main 校验输入,renderer 校验关键输出。类型检查负责发现编写期不一致,运行时校验负责处理真实输入,两者对应不同的失败来源。
contextBridge 传递的是受支持的可序列化值与代理函数,不是共享同一块 Vue 响应式状态。把 ref、DOM 节点、复杂类实例或事件对象穿过桥接会产生意外语义。桥接应接受简单数据,返回新结果;长任务进度则设计单独订阅,并提供取消订阅函数,组件卸载时清理。不能因为两个脚本都使用 JavaScript,就假设它们可以像普通模块一样共享对象。
实验二:不启动 Electron,验证桌面适配层的失败语义#
在独立目录保存下面两个文件,执行 node adapter-check.mjs。它验证 Vue 外层可以依赖的业务接口,不验证 contextBridge 或 IPC 的真实传输。这个层很适合先用 Node 跑通,因为输入输出与窗口无关;之后仍必须回到实验一验证真实 bridge 存在、来源检查和页面生命周期。
export function createDesktopAdapter(bridge) {
return {
async readEnvironment() {
if (!bridge) return { kind: "unavailable" };
try {
const result = await bridge.readEnvironment();
if (result?.ok === false && result.error?.code === "FORBIDDEN") {
return { kind: "denied" };
}
if (result?.ok !== true || typeof result.data?.version !== "string" ||
typeof result.data?.platform !== "string") {
return { kind: "invalid-response" };
}
return { kind: "ready", version: result.data.version, platform: result.data.platform };
} catch {
return { kind: "disconnected" };
}
}
};
}
import assert from "node:assert/strict";
import { createDesktopAdapter } from "./desktop-adapter.mjs";
assert.deepEqual(await createDesktopAdapter(undefined).readEnvironment(),
{ kind: "unavailable" });
const success = { readEnvironment: async () =>
({ ok: true, data: { version: "1.0.0", platform: "fixture" } }) };
assert.equal((await createDesktopAdapter(success).readEnvironment()).kind, "ready");
const invalid = { readEnvironment: async () => ({ ok: true, data: {} }) };
assert.equal((await createDesktopAdapter(invalid).readEnvironment()).kind, "invalid-response");
const denied = { readEnvironment: async () => ({ ok: false, error: { code: "FORBIDDEN" } }) };
assert.equal((await createDesktopAdapter(denied).readEnvironment()).kind, "denied");
const lost = { readEnvironment: async () => { throw new Error("IPC gone"); } };
assert.equal((await createDesktopAdapter(lost).readEnvironment()).kind, "disconnected");
const extra = { readEnvironment: async () =>
({ ok: true, data: { version: "1.0.0", platform: "fixture", kind: "denied" } }) };
assert.deepEqual(await createDesktopAdapter(extra).readEnvironment(),
{ kind: "ready", version: "1.0.0", platform: "fixture" });
console.log("adapter checks passed: 6");
这里没有把所有错误都转成“未安装桌面端”。缺少 bridge 是运行环境能力不足;FORBIDDEN 是来源或权限不被接受;连接异常意味着主进程、窗口或处理器生命周期中断;结构不符说明两端合同不同。前端分别显示可理解的恢复方式,比统一弹一个重试按钮更有价值。尤其在版本升级时,结构不符应留下版本信息,不能继续读取 undefined 字段构造看似成功的界面。
把白屏按依赖顺序拆开#
先判断 Electron 是否真的创建窗口,再判断文档是否加载,再判断脚本是否加载,最后才判断 Vue 是否挂载。main 终端有连接拒绝,优先查开发服务器和端口;页面已出现但 Network 中资源路径指向磁盘根目录,检查 Vite base 与产物路径;界面正常而 bridge 不存在,检查 preload 的绝对路径、语法错误和 sandbox 限制。顺序错误会让你在 Vue 组件里修一个根本不属于组件的问题。
生产文件路径还要区分应用代码目录与用户数据目录。dist 与 preload 是随应用交付的只读资源,不应该在运行时保存配置;process.cwd() 受启动方式影响,双击应用时尤其不能依赖。下一章会使用 userData 保存数据。Vue Router 若使用 history 模式,还需要与本地协议的文档回退一致;本章没有路由,因此不会假装一个 index.html 已经解决所有深链接问题。
Web 预览只能验证 Web 能力。系统菜单、原生文件对话框、拖拽文件路径、窗口焦点和应用退出,在普通浏览器里不是同一套行为。可以为布局开发提供明确标注的假数据,但不能让 mock 悄悄记录真实“保存成功”。团队应为网页截图与桌面验证分别命名证据,避免把浏览器通过误写成桌面功能完成。
三种“构建完成”应该怎样核对#
renderer 构建完成时,首先检查 dist/index.html 与它引用的资源是否同属这次输出。Vite 的 emptyOutDir 会清理配置中的输出目录,所以不要把用户数据或手工维护文件放进 dist。若生产加载的是旧目录,即使构建终端显示成功,窗口仍可能运行旧脚本。定位时记录实际入口绝对路径与应用版本,比只看文件修改时间可靠。
main 如果以后改用 TypeScript,构建结果必须是 Electron 能执行的模块格式。package.json 的 main 要指向输出文件,而不是仍指向 TypeScript 源文件。Node 内建模块与 electron 由运行时提供,打包器不应把它们模拟成浏览器依赖。与 renderer 共用一份针对浏览器的配置,会出现 fs 被替换为空模块、__dirname 语义变化等难以理解的问题。
preload 的构建目标要单独确认。沙箱中的 require 不是任意模块加载器,所以即使源码中拆成多个模块,最终也要知道输出是否仍依赖外部本地文件。保留小型单文件桥接能降低这个风险。若引入共享合同代码,应检查构建结果是否把必要纯逻辑收进 preload,不能因开发时未触发某分支而误以为所有依赖都可用。
三份产物还必须来自同一份接口合同。renderer 新增了 readEnvironment 的字段,而旧 preload 仍暴露旧方法,开发热更新可能让你短暂处于新旧混合状态。重启整套桌面进程后再验证一次,可以排除热更新残留。正式打包应使用一次干净构建产生的完整集合,不手工把旧 preload 复制进新 renderer 目录。
开发服务器不是可以忽略的信任边界#
开发 main 加载回环地址,并不代表这个地址天然安全。其他进程可能占用同一端口,开发者也可能误开另一个项目。因此本例固定端口并要求先确认服务,不从聊天内容或配置表单接受任意开发 URL。生产包禁用开发入口选择,避免用户设置一个环境变量便把桌面能力注入任意站点。
在真实团队中,可以让启动脚本等待指定服务就绪,再启动 Electron,并在一个进程退出时清理另一个进程。但等待的条件应该是本应用的就绪响应,而不是“任意 HTTP 二百”;否则仍可能连错服务。这里使用两个终端是为了让初学者看清谁监听端口、谁创建窗口,理解之后再自动编排才不会失去排错入口。
Vite 的 HMR WebSocket 连接失败不总会让页面首次加载失败。你可能看到初始界面正常,保存组件后却不更新;应单独检查 HMR 连接与允许来源。生产资源没有热更新服务,不应该为了消除开发警告,在正式包里继续依赖外部 HMR 地址。开发能力和生产能力应通过明确构建模式分离。
把“网页没有这个能力”设计成正常状态#
一个好用的桌面适配层不仅为了测试。设计稿预览、纯 Web 部署、组件文档和桌面应用可能共用 renderer,但系统能力不同。页面应依据能力决定显示解释、禁用动作还是提供替代入口;不能默默调用假实现并显示操作成功。用户看到保存成功,就会认为数据已经落盘,这个承诺比布局预览更强。
适配层可以选择不同实现,但选择依据应由运行环境与可信初始化确定。不要允许任意 URL 查询参数把真实桌面接口切换成测试后端,也不要在生产中发现桥接异常后自动落到 mock。桥接本应存在却缺失时,应暴露诊断状态,让你发现 preload 或版本问题,而不是用假数据把错误藏住。
前端仍需要处理组件生命周期。开始读取后用户离开页面,Promise 不会自动取消;旧结果到达时应核对请求代号或组件存活状态。对于只读应用信息,丢弃迟到结果足够;对于写操作,丢弃 UI 结果不等于取消主进程保存。状态管理应记录操作是否已经提交,必要时重新查询结果,不能用 loading=false 推断业务已停止。
为什么这里不直接选一个“全自动脚手架”#
脚手架可以节省进程启动、构建与重启配置,但它也引入自己的约定:入口目录、输出路径、环境变量、热重载范围和打包集成。遇到问题时,你仍要知道这些约定最终调用了 loadURL 还是 loadFile,preload 指向源码还是产物,main 何时重新启动。先完成本章小实验,能让后续选择工具建立在可解释的基础上。
切换到 Forge Vite 插件时,逐项迁移即可:登记 main 和 preload 构建入口,登记 renderer 页面,使用插件提供的开发地址与产物位置,再验证安全选项没有被模板改松。插件文档可能说明实验性或版本配套要求,应该按所安装版本阅读。不要因为模板的某个分支能启动,就把整个模板的默认权限当成业务授权。
最后要保留一个最小回归动作:关闭开发服务,从构建目录启动桌面并读取信息。它同时检验产物是否完整、入口选择是否正确、bridge 是否存在,以及合同是否保持一致。这个动作很小,却能抓住仅靠 Vue 浏览器预览看不到的整类问题;真正的文件、窗口和系统功能再分别增加桌面验收。
练习:让组件只依赖适配层#
把实验一按钮读取逻辑改成使用实验二的适配层,不直接在组件里解释 IPC envelope。要求浏览器显示不可用,拒绝显示无权限,连接丢失显示重新打开,错误结构提示版本不兼容。先保持接口只有一个方法,不添加通用 invoke。提示是将适配层文件放进 src,组件只消费 kind 联合状态。
完整参考实现:替换 src/App.vue
<script setup>
import { ref } from "vue";
import { createDesktopAdapter } from "./desktop-adapter.mjs";
const desktop = createDesktopAdapter(window.desktopInfo);
const text = ref("尚未读取");
const busy = ref(false);
const messages = {
unavailable: "Web 预览不提供桌面能力",
denied: "当前页面无权读取",
disconnected: "桌面连接中断,请重新打开",
"invalid-response": "两端合同不一致,请检查应用版本"
};
async function read() {
if (busy.value) return;
busy.value = true;
try {
const result = await desktop.readEnvironment();
text.value = result.kind === "ready"
? "版本 " + result.version + ",平台 " + result.platform
: messages[result.kind];
} finally {
busy.value = false;
}
}
</script>
<template>
<main>
<h1>应用信息</h1>
<button :disabled="busy" @click="read">读取桌面环境</button>
<p role="status">{{ text }}</p>
</main>
</template>
验收时应分别展示网页预览、桌面开发模式和关闭开发服务器后的产物预览;修改 main 后确认进程确实重启,修改 Vue 后确认界面更新。离线适配层检查通过只证明五种结果被正确映射,不能证明 Electron 已启动。这里提供完整桌面步骤,但编写阶段没有执行 GUI、依赖安装或 renderer 构建。
自测一:为什么 preload 不能直接成为第二个 Node 后端?因为沙箱中可用能力有限,而且它的职责是缩小跨边界接口。自测二:为什么 Vite 换一个空闲端口可能造成桌面失败?因为 main 加载地址必须与真实服务一致。自测三:类型化 bridge 能替代发送者校验吗?不能,类型没有运行时授权作用。
延伸阅读应围绕当前遇到的问题:资源路径查 Vite 构建说明,端口与主机查 Vite 服务选项,类型检查查 Vue TypeScript 指南,未来引入多入口编排时阅读 Forge Vite 插件。不要仅凭脚手架能启动就省略三个运行位置的理解。
本章验证记录#
Node 22.22.0 已运行 adapter-check.mjs,覆盖正常、缺少桥接、拒绝、连接失败、结构错误和额外 kind 字段六个输入。额外字段不能覆盖适配层生成的状态。main/preload/Vite 配置与页面脚本只做 JavaScript 语法检查;Vue 单文件组件、类型声明、Vite 构建、真实窗口与 IPC 未在本章执行。