Skip to content
应用结构与初始化
Electron 应用至少包含一个主进程脚本和一个渲染进程的 HTML 页面。文件读取器项目的目录可以按如下方式组织:
file-reader/
├── package.json
├── tsconfig.json
├── src/
│ ├── main.ts # 主进程入口
│ ├── preload.ts # preload 脚本
│ └── renderer/
│ ├── index.html # 渲染进程页面
│ └── renderer.js # 渲染进程脚本package.json 通过 main 字段指定编译后的主进程入口,并配置构建与启动脚本:
json
{
"name": "file-reader",
"main": "dist/main.js",
"scripts": {
"build": "tsc",
"start": "npm run build && electron ."
},
"devDependencies": {
"electron": "^28.0.0",
"typescript": "^5.3.0"
}
}主进程入口 main.ts 负责创建窗口并加载 preload 脚本:
typescript
import { app, BrowserWindow } from 'electron';
import * as path from 'path';
function createWindow() {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
win.loadFile(path.join(__dirname, 'renderer', 'index.html'));
}
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});contextIsolation: true 与 nodeIntegration: false 是 Electron 的安全基准设置。渲染进程无法直接使用 Node.js API,所有需要系统能力的操作只能通过 preload 脚本暴露的接口完成。preload 脚本路径使用 path.join(__dirname, 'preload.js'),这要求 TypeScript 编译输出的 preload.js 与主进程脚本位于同一 dist/ 目录。window-all-closed 事件对 macOS 做了区分处理——关闭所有窗口后不退出应用是 macOS 的常规行为,此时 Dock 图标仍处于运行状态。
渲染进程界面:按钮与内容区
文件读取器的 UI 由一个触发文件选择的按钮、一个展示文件路径的段落以及一个显示文件内容的 <pre> 元素构成。
index.html:
html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>文件读取器</title>
<style>
body { font-family: system-ui; padding: 2rem; }
#content {
margin-top: 1rem;
padding: 1rem;
border: 1px solid #ccc;
min-height: 200px;
white-space: pre-wrap;
background: #f9f9f9;
}
.error { color: #c00; }
</style>
</head>
<body>
<button id="openBtn">选择文件</button>
<p id="filePath"></p>
<pre id="content"></pre>
<script src="./renderer.js"></script>
</body>
</html>renderer.js 此时尚未包含实际的 IPC 调用逻辑,待 preload 暴露 API 后再补充交互代码。
开启文件对话框:dialog 的调用与配置
文件选择需要调用系统原生对话框,dialog 模块只能在主进程中使用。主进程通过 ipcMain.handle 注册一个通道供渲染进程调用:
typescript
import { dialog, ipcMain } from 'electron';
ipcMain.handle('dialog:openFile', async () => {
const result = await dialog.showOpenDialog({
title: '选择文本文件',
filters: [
{ name: '文本文件', extensions: ['txt', 'md', 'json', 'js', 'ts'] },
{ name: '所有文件', extensions: ['*'] },
],
properties: ['openFile'],
});
return result;
});dialog.showOpenDialog 返回一个 Promise,resolve 后得到如下结构的对象:
typescript
interface OpenDialogReturnValue {
canceled: boolean;
filePaths: string[];
}canceled— 用户是否点击了取消按钮或关闭了对话框。filePaths— 选中文件的绝对路径数组。使用openFile属性进行单选时数组中通常只有一个元素;若加上multiSelections,则可包含多个路径。
filters 对象的 extensions 不应带点号前缀,示例中写为 ['txt'] 而非 ['.txt']。在 macOS 上,defaultPath 的行为与 Windows 存在差异:它仅将对话框导航到目标目录,而不会自动选中该文件。
properties 还支持:
openDirectory— 选择目录。multiSelections— 允许多选。createDirectory— 允许在对话框中创建新目录。showHiddenFiles— 显示隐藏文件。
设计安全的 Preload 接口
preload 脚本运行在 contextIsolation 隔离环境中,它可以访问 ipcRenderer,但渲染进程的页面上下文则不能。因此通过 contextBridge.exposeInMainWorld 桥接必要的能力:
typescript
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('electronAPI', {
openFile: () => ipcRenderer.invoke('dialog:openFile'),
});暴露出的 window.electronAPI.openFile 在渲染进程调用后发起对 dialog:openFile 通道的 invoke 请求并返回 Promise。
preload 只暴露了 openFile 这一个方法。preload 接口应当遵循最小暴露面原则——仅提供渲染进程真正需要的操作,而非将整个 ipcRenderer 暴露出去。这样即使渲染进程中某段第三方脚本被注入恶意代码,其攻击面也仅限于定义好的方法,无法直接向主进程发起任意通道请求。
主进程响应:读取文件并返回内容
当前 dialog:openFile 的 handle 回调只返回了对话框的结果。读取文件内容的方式有两种:在同一个 handle 中获取路径后直接调用 fs.readFile;或将读取文件拆分为独立的 IPC 通道 file:read,由渲染进程拿到路径后再发起第二次调用。对于单文件读取的场景,合并到一个通道更为简洁。修改主进程 handle 逻辑:
typescript
import { dialog, ipcMain } from 'electron';
import * as fs from 'fs/promises';
ipcMain.handle('dialog:openFile', async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
title: '选择文本文件',
filters: [
{ name: '文本文件', extensions: ['txt', 'md', 'json', 'js', 'ts'] },
{ name: '所有文件', extensions: ['*'] },
],
properties: ['openFile'],
});
if (canceled) {
return { canceled: true };
}
const filePath = filePaths[0];
try {
const content = await fs.readFile(filePath, 'utf-8');
return {
canceled: false,
filePath,
content,
};
} catch (err: any) {
return {
canceled: false,
filePath,
error: err.message,
};
}
});逻辑分支:
- 用户取消对话框:返回
{ canceled: true },渲染进程据此不更新 UI。 - 文件读取成功:返回路径与内容。
- 文件读取失败:不抛出异常,而将错误信息放在返回对象中。若 handle 回调抛出未捕获异常,
ipcRenderer.invoke的 Promise 会 reject,则需在渲染进程中使用try/catch处理。返回结构化对象能让渲染进程以统一的逻辑处理成功与失败。
handle 回调处于主进程的事件循环异步上下文中,使用 fs.promises.readFile 不会阻塞其他 IPC 请求的处理。fs.readFileSync 会阻塞主进程的事件循环,如果文件较大或磁盘 I/O 延迟,整个应用界面都会卡住,应优先使用异步版本。
连接 IPC 与 UI 交互
preload 暴露了 openFile,主进程已注册 dialog:openFile handle。在 renderer.js 中将两者连接起来:
javascript
document.getElementById('openBtn').addEventListener('click', async () => {
const result = await window.electronAPI.openFile();
const filePathEl = document.getElementById('filePath');
const contentEl = document.getElementById('content');
if (result.canceled) {
filePathEl.textContent = '未选择文件';
contentEl.textContent = '';
return;
}
filePathEl.textContent = result.filePath;
if (result.error) {
contentEl.textContent = `读取失败:${result.error}`;
contentEl.className = 'error';
} else {
contentEl.textContent = result.content;
contentEl.className = '';
}
});调用链路:用户点击按钮 → window.electronAPI.openFile() → ipcRenderer.invoke('dialog:openFile') → 主进程 handle 执行 → 打开系统对话框并读取文件 → 返回结果对象 → Promise resolve → renderer.js 获得 result 并更新 DOM。
这段代码未使用 try/catch,因为主进程 handle 不会抛出未处理异常——取消和错误均通过返回值的字段区分。若 handle 内部改为抛出异常,则渲染进程需要采用 try/catch 捕获。两种模式都可工作,但应在一个应用内保持统一。
处理取消与读取错误
取消对话框的处理已在上一节覆盖。实际运行中还会遇到几种边界情况。
权限问题:系统级权限阻止读取文件时,fs.readFile 抛出 EACCES 错误,err.message 包含操作不允许的说明。上述 catch 分支会捕获并以 { error: err.message } 返回,渲染进程将显示“读取失败:EACCES: permission denied, open '...'”。
文件不存在:用户选中文件后,在对话框关闭到实际读取之间文件被删除(概率较小),会触发 ENOENT 错误,错误信息同样通过 error 字段带回。
非 UTF-8 编码:示例中固定了 'utf-8' 编码。若用户选取了 GBK 编码的文本文件,fs.readFile 可能解码后为乱码而非报错。兼容方案是先调用 fs.readFile(不带编码参数)获取 Buffer,再使用 iconv-lite 等库检测或手动指定编码。对于简单的文件读取器,可以先约束到 UTF-8 编码范围内。
对话框快速关闭:Electron 在同一时间只允许一个对话框,连续点击时的第二次调用会被阻止,但在某些 Linux 窗口管理器下行为可能不一致。
完整代码集成与运行验证
整合所有部分后的文件清单如下。
src/main.ts
typescript
import { app, BrowserWindow, dialog, ipcMain } from 'electron';
import * as path from 'path';
import * as fs from 'fs/promises';
function createWindow() {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
win.loadFile(path.join(__dirname, 'renderer', 'index.html'));
}
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
ipcMain.handle('dialog:openFile', async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
title: '选择文本文件',
filters: [
{ name: '文本文件', extensions: ['txt', 'md', 'json', 'js', 'ts'] },
{ name: '所有文件', extensions: ['*'] },
],
properties: ['openFile'],
});
if (canceled) {
return { canceled: true };
}
const filePath = filePaths[0];
try {
const content = await fs.readFile(filePath, 'utf-8');
return { canceled: false, filePath, content };
} catch (err: any) {
return { canceled: false, filePath, error: err.message };
}
});src/preload.ts
typescript
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('electronAPI', {
openFile: () => ipcRenderer.invoke('dialog:openFile'),
});src/renderer/index.html
html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>文件读取器</title>
<style>
body { font-family: system-ui; padding: 2rem; }
#content {
margin-top: 1rem;
padding: 1rem;
border: 1px solid #ccc;
min-height: 200px;
white-space: pre-wrap;
background: #f9f9f9;
}
.error { color: #c00; }
</style>
</head>
<body>
<button id="openBtn">选择文件</button>
<p id="filePath"></p>
<pre id="content"></pre>
<script src="./renderer.js"></script>
</body>
</html>src/renderer/renderer.js
javascript
document.getElementById('openBtn').addEventListener('click', async () => {
const result = await window.electronAPI.openFile();
const filePathEl = document.getElementById('filePath');
const contentEl = document.getElementById('content');
if (result.canceled) {
filePathEl.textContent = '未选择文件';
contentEl.textContent = '';
return;
}
filePathEl.textContent = result.filePath;
if (result.error) {
contentEl.textContent = `读取失败:${result.error}`;
contentEl.className = 'error';
} else {
contentEl.textContent = result.content;
contentEl.className = '';
}
});tsconfig.json
json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}运行步骤:
bash
npm install
npm run build
npm start启动后的验证路径:
- 点击“选择文件”,系统原生文件对话框弹出。
- 选择一个
.txt文件并确认,页面显示文件绝对路径和内容。 - 点击“选择文件”后点取消,页面显示“未选择文件”,内容区清空。
- 选取一个无读取权限的文件(例如 Linux 下的
/etc/shadow),页面显示“读取失败:……”红色提示。
HTML 和 renderer.js 为源文件,不经过 TypeScript 编译。dist/ 目录中需要包含编译后的 main.js、preload.js 以及原样复制过来的 renderer/ 目录。若使用 electron-builder 打包,这些静态资源的路径需在配置中指定。
