主题切换
第 5 章:系统交互与文件操作
本章将带你掌握 Electron 应用与操作系统深度交互的核心能力。从文件读写、对话框操作,到系统通知、剪贴板控制——这些能力让 Web 页面真正拥有了"原生应用"的灵魂。
5.1 Node.js fs 模块在 Electron 中的使用
Electron 的设计允许渲染进程通过预加载脚本安全地访问 Node.js API。这意味着你熟悉的 fs 模块,可以通过 contextBridge 暴露给渲染进程使用——当然,这需要遵循安全最佳实践。
给前端开发者的建议
把主进程想象成 Node.js 服务器,渲染进程就是前端页面。在 Electron 中,前端页面通过预加载脚本暴露的安全 API 来访问文件系统等 Node.js 能力,不需要通过 HTTP API 中转。
三种文件操作方式
javascript
const fs = require('fs')
const fsPromises = require('fs').promises
const path = require('path')
// 方式一:同步读取(仅在启动时使用,不阻塞 UI)
function loadConfigSync() {
const configPath = path.join(__dirname, 'config.json')
try {
const data = fs.readFileSync(configPath, 'utf-8')
return JSON.parse(data)
} catch (err) {
return { theme: 'dark' } // 默认配置
}
}
// 方式二:回调方式(传统,不推荐)
function readFileCallback(filePath, callback) {
fs.readFile(filePath, 'utf-8', (err, data) => {
if (err) return callback(err)
callback(null, data)
})
}
// 方式三:Promise + async/await(推荐)
async function readFileModern(filePath) {
try {
const data = await fsPromises.readFile(filePath, 'utf-8')
return data
} catch (err) {
console.error('读取失败:', err.message)
throw err
}
}5.2 Preload 文件操作 API 封装
在渲染进程中安全使用文件操作,需要通过 preload 脚本暴露受限的 API:
javascript
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('fileAPI', {
// 读取文本文件
readTextFile: (filePath) => ipcRenderer.invoke('file:readText', filePath),
// 写入文本文件
writeTextFile: (filePath, content) => ipcRenderer.invoke('file:writeText', filePath, content),
// 读取目录
readDirectory: (dirPath) => ipcRenderer.invoke('file:readDir', dirPath),
// 获取文件信息
getFileInfo: (filePath) => ipcRenderer.invoke('file:getInfo', filePath),
// 删除文件(移到回收站)
trashFile: (filePath) => ipcRenderer.invoke('file:trash', filePath),
// 在文件管理器中显示
showInFolder: (filePath) => ipcRenderer.invoke('file:showInFolder', filePath),
})javascript
const { ipcMain, shell } = require('electron')
const fs = require('fs').promises
const path = require('path')
function setupFileIPC() {
ipcMain.handle('file:readText', async (event, filePath) => {
// 安全检查:确保路径在允许的范围内
if (!isPathAllowed(filePath)) {
throw new Error('不允许访问该路径')
}
return await fs.readFile(filePath, 'utf-8')
})
ipcMain.handle('file:writeText', async (event, filePath, content) => {
await fs.writeFile(filePath, content, 'utf-8')
return { success: true }
})
ipcMain.handle('file:readDir', async (event, dirPath) => {
const entries = await fs.readdir(dirPath, { withFileTypes: true })
return entries.map(entry => ({
name: entry.name,
isDirectory: entry.isDirectory(),
isFile: entry.isFile(),
}))
})
ipcMain.handle('file:getInfo', async (event, filePath) => {
const stat = await fs.stat(filePath)
return {
size: stat.size,
created: stat.birthtime,
modified: stat.mtime,
isDirectory: stat.isDirectory(),
}
})
ipcMain.handle('file:trash', async (event, filePath) => {
await shell.trashItem(filePath)
})
ipcMain.handle('file:showInFolder', async (event, filePath) => {
shell.showItemInFolder(filePath)
})
}安全提示
始终在 preload 中对文件路径做白名单校验。不要让渲染进程直接传递任意路径给 Node.js API——这相当于给前端页面开放了整个文件系统。
5.3 Dialog 原生对话框
dialog 模块提供打开文件、保存文件、消息提示等原生对话框。与浏览器中的 <input type="file"> 不同,它能直接返回文件的完整系统路径。
打开文件对话框
javascript
const { dialog } = require('electron')
ipcMain.handle('dialog:openFile', async (event, options = {}) => {
const result = await dialog.showOpenDialog({
title: '选择文件',
properties: ['openFile'],
filters: [
{ name: '文档', extensions: ['txt', 'md', 'json'] },
{ name: '图片', extensions: ['jpg', 'png', 'gif'] },
{ name: '所有文件', extensions: ['*'] },
],
...options,
})
if (result.canceled) {
return { canceled: true }
}
return {
canceled: false,
filePath: result.filePaths[0],
}
})保存文件对话框
javascript
ipcMain.handle('dialog:saveFile', async (event, { defaultPath, content }) => {
const result = await dialog.showSaveDialog({
title: '保存文件',
defaultPath: defaultPath || 'untitled.txt',
filters: [
{ name: '文本文件', extensions: ['txt'] },
{ name: 'JSON 文件', extensions: ['json'] },
],
})
if (result.canceled) return { canceled: true }
await fs.writeFile(result.filePath, content, 'utf-8')
return { canceled: false, filePath: result.filePath }
})消息对话框
javascript
ipcMain.handle('dialog:message', async (event, options) => {
const result = await dialog.showMessageBox({
type: options.type || 'info', // 'none' | 'info' | 'error' | 'question' | 'warning'
title: options.title || '提示',
message: options.message,
detail: options.detail,
buttons: options.buttons || ['确定', '取消'],
defaultId: options.defaultId || 0,
cancelId: options.cancelId || 1,
})
return {
response: result.response, // 点击的按钮索引
checkboxChecked: result.checkboxChecked,
}
})5.4 Notification 系统通知
Electron 支持跨平台的系统级通知,外观和行为与原生应用一致。
主进程通知
javascript
const { Notification } = require('electron')
function showNotification({ title, body, icon }) {
// 仅在应用获得焦点时才显示通知(可选优化)
if (Notification.isSupported()) {
const notification = new Notification({
title,
body,
icon: icon || path.join(__dirname, 'assets/icon.png'),
silent: false, // 是否静默
urgency: 'normal', // 'normal' | 'critical' | 'low'
})
notification.on('click', () => {
// 点击通知时聚焦到主窗口
const win = BrowserWindow.getAllWindows()[0]
if (win) {
win.show()
win.focus()
}
})
notification.show()
}
}渲染进程触发通知
javascript
contextBridge.exposeInMainWorld('notificationAPI', {
show: (options) => ipcRenderer.invoke('notification:show', options),
})javascript
async function remindUser() {
const result = await window.notificationAPI.show({
title: '自动化任务完成',
body: '数据采集已完成,共采集 1,200 条记录',
type: 'success',
})
}通知最佳实践
- 不要在通知中放置敏感信息(通知内容可能被系统日志记录)
- 尊重用户的专注模式(Windows 有专注助手,可配合
urgency: 'low'避免打扰) - macOS 需要应用获得焦点后通知才会显示,可通过
app.dock.bounce()引起注意
5.5 Clipboard 剪贴板操作
clipboard 模块支持文本、HTML、图片和 RTF 等多种格式的读写。
javascript
const { clipboard, nativeImage } = require('electron')
// 读写文本
clipboard.writeText('已复制到剪贴板')
const text = clipboard.readText()
// 读写 HTML
clipboard.writeHTML('<b>粗体文本</b>')
const html = clipboard.readHTML()
// 读写图片
const img = nativeImage.createFromPath('/path/to/image.png')
clipboard.writeImage(img)
const image = clipboard.readImage()
const dataURL = image.toDataURL() // 转为 base64 data URL
// 清空剪贴板
clipboard.clear()
// 获取可用格式
const formats = clipboard.availableFormats()
// 例:['text/plain', 'text/html', 'image/png']javascript
contextBridge.exposeInMainWorld('clipboardAPI', {
copyText: (text) => ipcRenderer.invoke('clipboard:copyText', text),
pasteText: () => ipcRenderer.invoke('clipboard:pasteText'),
copyImage: (imagePath) => ipcRenderer.invoke('clipboard:copyImage', imagePath),
pasteImage: () => ipcRenderer.invoke('clipboard:pasteImage'),
})5.6 child_process 子进程管理
RPA 应用经常需要调用外部程序或脚本——比如执行 Python 脚本进行数据清洗、调用系统命令行工具。child_process 模块提供了三种方式:
| 方法 | 适用场景 | 特点 |
|---|---|---|
exec | 执行简单命令,输出量小 | 缓冲全部输出到内存,有默认 1MB 限制 |
execFile | 执行可执行文件 | 不启动 shell,更安全高效 |
spawn | 长时间运行、流式输出 | 以流的方式处理输出,无大小限制 |
javascript
const { exec, execFile, spawn } = require('child_process')
const { promisify } = require('util')
const execAsync = promisify(exec)
// 方式一:exec — 执行简单命令
async function runCommand(command) {
try {
const { stdout, stderr } = await execAsync(command, {
timeout: 30000, // 30 秒超时
maxBuffer: 1024 * 1024 * 5, // 5MB 输出上限
})
return { success: true, output: stdout }
} catch (err) {
return { success: false, error: err.message }
}
}
// 方式二:spawn — 流式输出(适合长时间运行的脚本)
function runPythonScript(scriptPath, args = []) {
return new Promise((resolve, reject) => {
const proc = spawn('python', [scriptPath, ...args])
let stdout = ''
let stderr = ''
proc.stdout.on('data', (data) => {
stdout += data.toString()
// 实时推送进度到渲染进程
mainWindow?.webContents.send('script:output', data.toString())
})
proc.stderr.on('data', (data) => {
stderr += data.toString()
})
proc.on('close', (code) => {
if (code === 0) {
resolve({ success: true, output: stdout })
} else {
resolve({ success: false, error: stderr, code })
}
})
proc.on('error', (err) => {
reject(err)
})
})
}安全警告
永远不要将用户输入拼接到命令字符串中执行! 这会引入命令注入漏洞。
javascript
// ❌ 危险写法:
exec(`ping ${userInput}`)
// ✅ 安全写法:使用参数数组
spawn('ping', [userInput])
// 或对输入做严格校验
const sanitized = userInput.replace(/[^a-zA-Z0-9.\-]/g, '')
exec(`ping ${sanitized}`)5.7 文件拖放
在 Electron 中,拖放操作的关键区别是——拖放的文件对象 file.path 包含完整的系统路径,可以直接传给 Node.js 进行文件操作。
Vue 拖放组件
vue
<template>
<div
class="drop-zone"
:class="{ 'drag-over': isDragOver }"
@dragenter.prevent="onDragEnter"
@dragover.prevent="onDragOver"
@dragleave="onDragLeave"
@drop.prevent="onDrop"
>
<div v-if="files.length === 0" class="drop-placeholder">
<span class="drop-icon">📁</span>
<p>拖放文件到此处</p>
<p class="hint">支持文本、图片、文档</p>
</div>
<div v-else class="file-list">
<div v-for="file in files" :key="file.path" class="file-item">
<img v-if="isImage(file)" :src="file.path" class="preview" />
<div class="file-info">
<span class="file-name">{{ file.name }}</span>
<span class="file-size">{{ formatSize(file.size) }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const isDragOver = ref(false)
const files = ref([])
function onDragEnter() {
isDragOver.value = true
}
function onDragOver() {
isDragOver.value = true
}
function onDragLeave() {
isDragOver.value = false
}
function onDrop(event) {
isDragOver.value = false
// Electron 中 dataTransfer.files 包含完整系统路径
const droppedFiles = Array.from(event.dataTransfer.files)
files.value = droppedFiles.map(file => ({
name: file.name,
path: file.path, // Electron 特有:完整系统路径
size: file.size,
type: file.type
}))
// 通知主进程处理文件
readFiles(files.value)
}
async function readFiles(fileList) {
for (const file of fileList) {
try {
const content = await window.fileAPI.readTextFile(file.path)
console.log(`文件 ${file.name} 读取成功,长度: ${content.length}`)
} catch (err) {
console.error(`读取 ${file.name} 失败:`, err)
}
}
}
function isImage(file) {
return file.type.startsWith('image/')
}
function formatSize(bytes) {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
</script>
<style scoped>
.drop-zone {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 40px;
text-align: center;
transition: all 0.3s;
min-height: 200px;
}
.drop-zone.drag-over {
border-color: #409eff;
background: rgba(64, 158, 255, 0.05);
}
.drop-placeholder {
color: var(--text-secondary);
}
.drop-icon {
font-size: 48px;
display: block;
margin-bottom: 12px;
}
.hint {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 8px;
}
.file-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.file-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-bottom: 1px solid var(--border);
}
.preview {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 4px;
}
.file-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.file-name {
font-weight: 500;
}
.file-size {
font-size: 12px;
color: var(--text-secondary);
}
</style>关键区别
在浏览器中,dataTransfer.files 只有文件名和类型,没有路径。在 Electron 中,file.path 直接就是完整的系统路径,可以直接用于 fs 操作。
5.8 跨平台路径处理(node:path)
Windows 使用反斜杠 \,macOS/Linux 使用正斜杠 /。Node.js 的 path 模块让你无需关心这些差异。
javascript
const path = require('path')
const { app } = require('electron')
// ===== 路径拼接(自动处理分隔符)=====
const configPath = path.join(__dirname, 'config', 'app.json')
// Windows: C:\project\config\app.json
// macOS: /project/config/app.json
// ===== 获取标准目录 =====
const paths = {
home: app.getPath('home'), // 用户主目录
documents: app.getPath('documents'), // 文档目录
downloads: app.getPath('downloads'), // 下载目录
userData: app.getPath('userData'), // 应用数据目录(推荐存储配置和数据)
temp: app.getPath('temp'), // 临时目录
exe: app.getPath('exe'), // 当前可执行文件路径
logs: app.getPath('logs'), // 日志目录
}
// ===== 路径解析 =====
path.basename('/foo/bar/baz.txt') // 'baz.txt'
path.dirname('/foo/bar/baz.txt') // '/foo/bar'
path.extname('/foo/bar/baz.txt') // '.txt'
path.parse('/foo/bar/baz.txt')
// { root: '/', dir: '/foo/bar', base: 'baz.txt', ext: '.txt', name: 'baz' }
// ===== 路径规范化 =====
path.normalize('/foo/bar//baz/asdf/quux/..') // '/foo/bar/baz/asdf'
path.resolve('foo/bar', '/tmp/file/', '..', 'a/../subfile')
// 相当于 cd 命令,返回绝对路径最佳实践
应用配置和数据永远存放在 app.getPath('userData'),而不是应用安装目录。这样即使用户更新或重装应用,数据也不会丢失。
5.9 综合示例:文件管理器
让我们把本章所学整合成一个简单的文件管理器功能:
javascript
const fs = require('fs').promises
const path = require('path')
const { dialog, shell } = require('electron')
class FileManager {
constructor() {
this.currentPath = ''
}
// 读取目录内容
async readDirectory(dirPath) {
this.currentPath = dirPath
const entries = await fs.readdir(dirPath, { withFileTypes: true })
return Promise.all(entries.map(async (entry) => {
const fullPath = path.join(dirPath, entry.name)
const stat = await fs.stat(fullPath)
return {
name: entry.name,
path: fullPath,
isDirectory: entry.isDirectory(),
isFile: entry.isFile(),
size: stat.size,
modified: stat.mtime,
}
}))
}
// 打开文件(使用系统默认程序)
async openFile(filePath) {
await shell.openPath(filePath)
}
// 在文件夹中显示
async showInFolder(filePath) {
shell.showItemInFolder(filePath)
}
// 删除文件(移动到回收站)
async trashFile(filePath) {
await shell.trashItem(filePath)
}
// 创建新文件夹
async createFolder(parentPath, name) {
const newPath = path.join(parentPath, name)
await fs.mkdir(newPath, { recursive: true })
return newPath
}
// 复制文件
async copyFile(src, dest) {
await fs.copyFile(src, dest)
}
}
module.exports = { FileManager }本章小结
本章涵盖了 Electron 系统交互的核心能力:
- fs 模块:通过 preload 脚本安全地在渲染进程暴露受限的文件操作 API
- Dialog:打开、保存、消息三种原生对话框,返回完整系统路径
- Notification:跨平台系统级通知,支持点击回调
- Clipboard:纯文本、HTML、图片的读写操作
- child_process:执行外部命令和脚本,注意安全防护(禁止拼接用户输入到命令)
- 文件拖放:接收系统文件拖放,Electron 中
file.path提供完整系统路径 - path 模块:跨平台路径处理,使用
app.getPath()获取标准目录
5.8 自定义协议(protocol)
通过注册自定义协议(如 myapp://),你可以实现从浏览器或其他应用唤醒你的 Electron 应用,这是实现"点击网页链接打开桌面应用"的关键技术。
注册自定义协议
javascript
// main.js
const { app, protocol } = require('electron')
const path = require('path')
app.whenReady().then(() => {
// 注册文件协议
protocol.registerFileProtocol('myapp', (request, callback) => {
const url = request.url.replace('myapp://', '')
callback({ path: path.normalize(`${__dirname}/${url}`) })
})
})作为系统默认协议打开
在 package.json 中配置(Windows):
json
{
"build": {
"protocols": [
{
"name": "MyApp Protocol",
"schemes": ["myapp"]
}
]
}
}或在 electron-builder.yml 中配置:
yaml
protocols:
- name: "MyApp Protocol"
schemes:
- myapp处理协议链接参数
javascript
// 解析 myapp://open?taskId=123
function handleDeepLink(url) {
if (!url) return
const urlObj = new URL(url)
if (urlObj.protocol === 'myapp:') {
const taskId = urlObj.searchParams.get('taskId')
mainWindow.webContents.send('deep-link', { path: urlObj.pathname, taskId })
}
}
// macOS: 通过 open-url 事件接收
app.on('open-url', (event, url) => {
event.preventDefault()
handleDeepLink(url)
})
// Windows/Linux: 通过 second-instance 接收
app.on('second-instance', (event, argv) => {
const deepLink = argv.find(arg => arg.startsWith('myapp://'))
handleDeepLink(deepLink)
})协议链接的实际应用
- 邮件激活:发送验证邮件,点击
myapp://verify?token=xxx自动打开应用完成验证 - 网页唤醒:在网页上放置"打开应用"按钮,调用
window.location.href = 'myapp://open' - 任务直达:RPA 场景中,通过链接直接跳转到特定任务编辑页
5.9 打印功能
Electron 支持调用系统打印对话框,以及静默打印到默认打印机,这在需要生成纸质报告或发票的场景中非常实用。
打开打印对话框
javascript
// 渲染进程中调用
const { ipcRenderer } = require('electron')
// 打开系统打印对话框(用户可选择打印机和设置)
ipcRenderer.invoke('print', { silent: false })
// main.js 中处理
const { ipcMain } = require('electron')
ipcMain.handle('print', async (event, options) => {
const win = BrowserWindow.fromWebContents(event.sender)
win.webContents.print({
silent: options.silent,
printBackground: true,
deviceName: options.printerName || '',
pageSize: 'A4'
}, (success, failureReason) => {
if (!success) console.log('打印失败:', failureReason)
})
})静默打印
javascript
// 直接打印到默认打印机,不显示对话框
win.webContents.print({ silent: true, printBackground: true })打印为 PDF
javascript
const fs = require('fs')
const path = require('path')
async function printToPDF(win) {
const pdfPath = path.join(app.getPath('temp'), 'output.pdf')
const data = await win.webContents.printToPDF({
marginsType: 1, // 无边距
printBackground: true, // 打印背景色
printSelectionOnly: false,
landscape: false
})
fs.writeFileSync(pdfPath, data)
return pdfPath
}打印预览页面
vue
<template>
<div class="print-preview">
<button @click="handlePrint">打印</button>
<button @click="handlePrintPDF">导出 PDF</button>
<div class="print-content">
<!-- 打印内容区域,添加 media print 样式 -->
<h1>任务报告</h1>
<table>...</table>
</div>
</div>
</template>
<style media="print">
button { display: none; } /* 打印时隐藏按钮 */
</style>打印注意事项
- 打印前确保页面样式已加载完毕
- 使用
@media print媒体查询优化打印布局 - 静默打印需要应用已获得足够的系统权限
- macOS 和 Windows 的打印对话框 UI 不同,需分别测试
掌握这些能力后,你的 Electron 应用已经具备了与操作系统深度集成的能力。下一章,我们将探索更强大的屏幕控制与自动化功能。