第 5 章:系统交互与文件操作

本章将带你掌握 Electron 应用与操作系统深度交互的核心能力。从文件读写、对话框操作,到系统通知、剪贴板控制——这些能力让 Web 页面真正拥有了"原生应用"的灵魂。

5.1 Node.js fs 模块在 Electron 中的使用

Electron 的设计允许渲染进程通过预加载脚本安全地访问 Node.js API。这意味着你熟悉的 fs 模块,可以通过 contextBridge 暴露给渲染进程使用——当然,这需要遵循安全最佳实践。

💡 给前端开发者的建议
把主进程想象成 Node.js 服务器,渲染进程就是前端页面。在 Electron 中,前端页面通过预加载脚本暴露的安全 API 来访问文件系统等 Node.js 能力,不需要通过 HTTP API 中转。

三种文件读取方式对比

方式API特点适用场景
同步fs.readFileSync阻塞执行,代码简单配置文件读取、启动时初始化
回调异步fs.readFile传统 Node.js 风格兼容旧代码
Promisefs.promises.readFile支持 async/await现代项目首选

同步方式读取文件

JavaScript
// 同步读取:适合小文件、配置文件
const fs = require('fs');
const path = require('path');

// 读取应用配置文件
const configPath = path.join(__dirname, 'config.json');
try {
  const data = fs.readFileSync(configPath, 'utf-8');
  const config = JSON.parse(data);
  console.log('配置加载成功:', config);
} catch (err) {
  console.error('读取失败:', err.message);
}

Promise 方式(推荐)

JavaScript
// 使用 fs.promises,支持 async/await
const fsp = require('fs').promises;
const path = require('path');

async function readAppData() {
  const dataPath = path.join(app.getPath('userData'), 'data.json');
  try {
    const data = await fsp.readFile(dataPath, 'utf-8');
    return JSON.parse(data);
  } catch (err) {
    // 文件不存在时返回默认值
    if (err.code === 'ENOENT') {
      return { version: 1, items: [] };
    }
    throw err;
  }
}

async function saveAppData(data) {
  const dataPath = path.join(app.getPath('userData'), 'data.json');
  // 确保目录存在
  await fsp.mkdir(path.dirname(dataPath), { recursive: true });
  await fsp.writeFile(dataPath, JSON.stringify(data, null, 2), 'utf-8');
}

5.2 在渲染进程中使用 fs(通过 Preload 脚本暴露 API)

虽然 Electron 允许在渲染进程中直接 require('fs'),但出于安全考虑,最佳实践是通过 Preload 脚本有选择地暴露 API。这类似于前端项目中"封装 API 层"的做法。

⚠️ 安全提醒
开启 nodeIntegration: true 虽然方便,但会让渲染进程拥有完整的 Node.js 能力,存在安全风险。生产环境务必使用 contextBridge
preload.js
const { contextBridge } = require('electron');
const fs = require('fs').promises;
const path = require('path');

// 只暴露必要的文件操作 API
contextBridge.exposeInMainWorld('electronAPI', {
  // 读取文件
  readFile: (filePath) => fs.readFile(filePath, 'utf-8'),
  
  // 写入文件
  writeFile: (filePath, content) => fs.writeFile(filePath, content, 'utf-8'),
  
  // 读取目录
  readDir: (dirPath) => fs.readdir(dirPath, { withFileTypes: true }),
  
  // 检查文件是否存在
  fileExists: (filePath) => fs.access(filePath).then(() => true).catch(() => false),
  
  // 获取应用数据目录
  getUserDataPath: () => require('electron').app.getPath('userData'),
  
  // 路径拼接(避免渲染进程直接使用 path)
  joinPath: (...segments) => path.join(...segments)
});
renderer.js / Vue 组件
// 在渲染进程中使用暴露的 API
async function loadNotes() {
  const userData = await window.electronAPI.getUserDataPath();
  const notesPath = await window.electronAPI.joinPath(userData, 'notes.json');
  
  const exists = await window.electronAPI.fileExists(notesPath);
  if (exists) {
    const content = await window.electronAPI.readFile(notesPath);
    return JSON.parse(content);
  }
  return [];
}

async function saveNotes(notes) {
  const userData = await window.electronAPI.getUserDataPath();
  const notesPath = await window.electronAPI.joinPath(userData, 'notes.json');
  await window.electronAPI.writeFile(notesPath, JSON.stringify(notes, null, 2));
}

5.3 对话框 Dialog

Electron 的 dialog 模块提供了原生系统对话框,包括文件选择、保存、消息提示等。这比浏览器自带的 <input type="file"> 强大得多。

打开文件对话框

main.js
const { dialog, BrowserWindow } = require('electron');

// 打开单个文件
async function openFile() {
  const win = BrowserWindow.getFocusedWindow();
  const result = await dialog.showOpenDialog(win, {
    title: '选择文件',
    defaultPath: app.getPath('documents'),
    buttonLabel: '选择',
    filters: [
      { name: '文本文件', extensions: ['txt', 'md'] },
      { name: '图片', extensions: ['jpg', 'png', 'gif'] },
      { name: '所有文件', extensions: ['*'] }
    ],
    properties: ['openFile'] // 可多选:['openFile', 'multiSelections']
  });
  
  if (!result.canceled) {
    console.log('选择的文件:', result.filePaths);
    return result.filePaths[0];
  }
  return null;
}

保存文件对话框

main.js
// 保存文件对话框
async function saveFile(defaultName, content) {
  const win = BrowserWindow.getFocusedWindow();
  const result = await dialog.showSaveDialog(win, {
    title: '保存文件',
    defaultPath: defaultName,
    filters: [
      { name: 'Markdown', extensions: ['md'] },
      { name: '文本文件', extensions: ['txt'] }
    ]
  });
  
  if (!result.canceled) {
    await fs.promises.writeFile(result.filePath, content, 'utf-8');
    return result.filePath;
  }
  return null;
}

消息对话框

main.js
// 信息提示
const result = await dialog.showMessageBox(win, {
  type: 'info',        // 'none' | 'info' | 'error' | 'question' | 'warning'
  title: '保存成功',
  message: '文件已保存到指定位置',
  detail: '你可以在文档目录中找到它',
  buttons: ['确定', '打开所在文件夹'],
  defaultId: 0,
  cancelId: 0
});

if (result.response === 1) {
  // 用户点击"打开所在文件夹"
  shell.showItemInFolder(filePath);
}
💡 提示
在 macOS 上,对话框默认会以"sheet"形式从窗口顶部滑下,体验更原生。Windows 上则是独立的模态窗口。

5.4 系统通知 Notification

Electron 的通知 API 与 Web Notification API 几乎一致,但会调用系统原生的通知中心,而不是浏览器自己的实现。

renderer.js
// 在渲染进程中发送系统通知
function sendNotification(title, body, iconPath) {
  // 检查通知权限(Electron 中默认允许)
  if (Notification.permission === 'granted') {
    const notification = new Notification(title, {
      body: body,
      icon: iconPath,  // 支持本地文件路径
      silent: false,   // 是否播放提示音
      timeoutType: 'default' // 'default' | 'never'
    });
    
    notification.onclick = () => {
      console.log('用户点击了通知');
      // 可以在这里聚焦窗口
      window.electronAPI.focusWindow();
    };
    
    notification.onclose = () => {
      console.log('通知关闭');
    };
  }
}

// 使用示例
sendNotification(
  '下载完成',
  '文件 "report.pdf" 已下载到下载文件夹',
  '/path/to/app-icon.png'
);
⚠️ Windows 通知注意事项
Windows 10/11 的通知需要在系统设置中开启应用通知权限。如果通知不显示,检查"设置 > 系统 > 通知和操作"中你的应用是否被允许。

5.5 剪贴板操作 Clipboard

Electron 的 clipboard 模块支持丰富的剪贴板操作,远超浏览器的 navigator.clipboard API。

操作API说明
读取纯文本clipboard.readText()获取剪贴板中的文本
写入纯文本clipboard.writeText(text)设置剪贴板文本
读取 HTMLclipboard.readHTML()获取富文本 HTML
写入 HTMLclipboard.writeHTML(html)设置富文本
读取图片clipboard.readImage()返回 NativeImage 对象
写入图片clipboard.writeImage(image)将图片放入剪贴板
读取文件列表clipboard.readBuffer('FileNameW')获取拖拽/复制的文件路径
preload.js / main.js
const { clipboard, nativeImage } = require('electron');

// 文本操作
clipboard.writeText('Hello from Electron!');
const text = clipboard.readText();

// HTML 富文本
clipboard.writeHTML('加粗文本斜体');

// 图片操作
const image = nativeImage.createFromPath('/path/to/image.png');
clipboard.writeImage(image);

// 读取剪贴板图片并保存
const clipboardImage = clipboard.readImage();
if (!clipboardImage.isEmpty()) {
  const buffer = clipboardImage.toPNG();
  fs.writeFileSync('clipboard-screenshot.png', buffer);
}

5.6 执行系统命令 child_process

通过 child_process 模块,Electron 可以执行系统命令、调用外部程序,这是实现自动化和系统集成的关键能力。

main.js
const { exec, execFile, spawn } = require('child_process');
const util = require('util');
const execPromise = util.promisify(exec);

// ===== exec:执行 shell 命令(适合简单命令)=====
async function runCommand() {
  try {
    const { stdout, stderr } = await execPromise('ls -la', {
      cwd: '/home/user',
      timeout: 5000,
      maxBuffer: 1024 * 1024 // 1MB 输出缓冲
    });
    console.log('输出:', stdout);
  } catch (error) {
    console.error('命令失败:', error);
  }
}

// ===== execFile:执行可执行文件(更安全,不经过 shell)=====
execFile('node', ['--version'], (error, stdout) => {
  if (error) return console.error(error);
  console.log('Node 版本:', stdout.trim());
});

// ===== spawn:适合长时间运行、大输出、需要实时数据 ====
function spawnProcess() {
  const child = spawn('ping', ['-c', '4', 'baidu.com']);
  
  child.stdout.on('data', (data) => {
    console.log(`输出: ${data}`);
  });
  
  child.stderr.on('data', (data) => {
    console.error(`错误: ${data}`);
  });
  
  child.on('close', (code) => {
    console.log(`进程退出,代码: ${code}`);
  });
  
  // 5秒后终止进程
  setTimeout(() => child.kill('SIGTERM'), 5000);
}
🚨 安全警告
永远不要将用户输入直接拼接到 exec 命令中!这会导致命令注入漏洞。优先使用 execFile 或 spawn,并传递参数数组。

5.7 文件拖拽支持(Vue 组件实现)

Electron 应用可以接收从系统拖拽进来的文件,这在传统 Web 应用中受限(浏览器安全策略)。在 Electron 中,你可以轻松实现拖拽打开文件的功能。

DropZone.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" class="placeholder">
      <i class="icon-upload"></i>
      <p>拖拽文件到此处,或 <span @click="selectFile">点击选择</span></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.electronAPI.readFile(file.path);
      console.log(`文件 ${file.name} 内容:`, content.substring(0, 200));
    } 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;
}
.drop-zone.drag-over {
  border-color: #409eff;
  background: #f0f9ff;
}
.file-item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px;
  border-bottom: 1px solid #eee;
}
.preview {
  width: 60px;
  height: 60px;
  object-fit: cover;
  border-radius: 4px;
}
</style>
💡 关键区别
在浏览器中,dataTransfer.files 只有文件名和类型,没有路径。在 Electron 中,file.path 直接就是完整的系统路径,可以直接用于 fs 操作。

5.8 跨平台路径处理(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 综合示例:文件管理器

让我们把本章所学整合成一个简单的文件管理器功能:

fileManager.js
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 entries.map(entry => ({
      name: entry.name,
      path: path.join(dirPath, entry.name),
      isDirectory: entry.isDirectory(),
      isFile: entry.isFile(),
      size: entry.isFile() ? (await fs.stat(path.join(dirPath, entry.name))).size : 0,
      modified: (await fs.stat(path.join(dirPath, entry.name))).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 模块:同步/异步/Promise 三种文件操作方式
  • Preload 脚本:安全地在渲染进程暴露 Node.js API
  • Dialog:打开、保存、消息三种原生对话框
  • Notification:系统级通知,跨平台一致体验
  • Clipboard:文本、HTML、图片的读写操作
  • child_process:执行系统命令,注意安全防护
  • 拖拽:接收系统文件拖拽,获取完整路径
  • path:跨平台路径处理,使用标准目录

掌握这些能力后,你的 Electron 应用已经具备了与操作系统深度集成的能力。下一章,我们将探索更强大的屏幕控制与自动化功能。