主题切换
第 5 章:系统交互与文件操作
本章将带你掌握 Electron 应用与操作系统深度交互的核心能力。从文件读写、对话框操作,到系统通知、剪贴板控制——这些能力让 Web 页面真正拥有了"原生应用"的灵魂。
Electron 系统交互模型:从渲染进程到操作系统
在所有系统操作(文件读写、对话框、通知、剪贴板)中,理解数据如何从你的 Vue 组件代码传递到操作系统内核,是掌握这一章的关键。Electron 的系统交互遵循一个严格的分层模型:
text
┌──────────────────────────────────────────────────────────┐
│ 渲染进程 (Renderer) │
│ await window.fileAPI.readTextFile('/path/to/file') │
│ │ Vue 组件中调用 │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ preload 脚本 (contextBridge) │ │
│ │ ipcRenderer.invoke('file:readText', path)│ │
│ │ → 安全检查:白名单路径校验 │ │
│ │ → 参数验证:类型检查、路径规范化 │ │
│ └──────────────┬───────────────────────────┘ │
│ │ IPC invoke │
├─────────────────┼─────────────────────────────────────────┤
│ 主进程 (Main) │ │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ ipcMain.handle('file:readText', ...) │ │
│ │ → fs.readFile(path, 'utf-8') │ │
│ └──────────────┬───────────────────────────┘ │
│ │ Node.js fs 模块 │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ libuv (Node.js I/O 库) │ │
│ │ → 线程池处理 (避免阻塞事件循环) │ │
│ │ → 异步 I/O 完成通知 │ │
│ └──────────────┬───────────────────────────┘ │
│ │ 系统调用 (syscall) │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ 操作系统内核 │ │
│ │ → 文件系统驱动 → 磁盘/SSD │ │
│ │ → 权限检查 (ACL / Unix permissions) │ │
│ │ → 返回数据到用户空间 │ │
│ └──────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘关键洞察:主进程中的 Node.js API(如 fs.readFile)并不是什么 Electron 特有的能力——它们就是标准 Node.js 的 API,在 Electron 环境下和在普通 Node.js 进程中没有区别。Node.js 的 fs 模块本质上是操作系统 read()/write() 系统调用的薄封装。Electron 提供的额外价值是:通过 IPC 桥梁,让运行在 Chromium 沙箱中的渲染进程也能安全地间接使用这些 API。
为什么不能给渲染进程直接访问 Node.js API:Chromium 的站点隔离机制要求每个渲染进程运行在受限的沙箱中,不能发起系统调用。这不是 Electron 的限制,而是 Chromium 的安全设计——如果任何一个被加载的网页或第三方脚本能直接执行 fs.unlink('/'),安全后果是灾难性的。主进程就是这个"安全守门人"——它验证请求、检查路径白名单、执行操作、返回结果。
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.10 自定义协议(protocol)
通过注册自定义协议(如 myapp://),你可以实现从浏览器或其他应用唤醒你的 Electron 应用,这是实现"点击网页链接打开桌面应用"的关键技术。
注册自定义协议
javascript
// main.js
const { app, protocol, net } = require('electron')
const path = require('path')
app.whenReady().then(() => {
// Electron 25+:使用 protocol.handle 替代已弃用的 registerFileProtocol
protocol.handle('myapp', (request) => {
const url = request.url.replace('myapp://', '')
const filePath = path.normalize(`${__dirname}/${url}`)
return net.fetch('file://' + filePath)
})
})作为系统默认协议打开
在 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.11 打印功能
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 应用已经具备了与操作系统深度集成的能力。下一章,我们将探索更强大的屏幕控制与自动化功能。