文件系统操作

深入理解 Node.js 文件系统 API、Electron 中的安全文件访问、以及常见文件操作场景的实践。


Node.js 文件系统基础

Electron 的主进程运行在 Node.js 环境中,可以直接使用 Node.js 的 fs 模块:

const fs = require('fs');
const path = require('path');

同步 vs 异步

// 同步(阻塞)
const data = fs.readFileSync('/path/to/file.txt', 'utf8');

// 异步回调
fs.readFile('/path/to/file.txt', 'utf8', (err, data) => {
  if (err) throw err;
  console.log(data);
});

// 异步 Promise(Node.js 8+)
const { promisify } = require('util');
const readFile = promisify(fs.readFile);
const data = await readFile('/path/to/file.txt', 'utf8');

// Node.js 10+ 原生 Promise API
const data = await fs.promises.readFile('/path/to/file.txt', 'utf8');

路径操作

const path = require('path');

// 路径拼接
const fullPath = path.join(__dirname, 'files', 'data.txt');

// 解析相对路径
const absolutePath = path.resolve('./data.txt');

// 获取文件扩展名
const ext = path.extname('document.pdf');  // '.pdf'

// 获取文件名
const basename = path.basename('/path/to/file.txt');  // 'file.txt'
const basenameNoExt = path.basename('/path/to/file.txt', '.txt');  // 'file'

// 获取目录名
const dirname = path.dirname('/path/to/file.txt');  // '/path/to'

// path.join 自动处理系统分隔符
const crossPlatformPath = path.join('folder', 'subfolder', 'file.txt');

读取文件

读取文本文件

async function readTextFile(filePath) {
  try {
    const content = await fs.promises.readFile(filePath, 'utf8');
    return content;
  } catch (err) {
    console.error('读取文件失败:', err);
    throw err;
  }
}

读取二进制文件

async function readBinaryFile(filePath) {
  const buffer = await fs.promises.readFile(filePath);
  return buffer;  // Buffer 对象
}

// 或者指定编码读取图片
async function readImageAsBase64(filePath) {
  const buffer = await fs.promises.readFile(filePath);
  return buffer.toString('base64');
}

逐行读取大文件

对于大文件,不应该一次性读取全部内容:

const readline = require('readline');
const { createInterface } = require('readline');

async function readFileLineByLine(filePath) {
  const fileStream = fs.createReadStream(filePath, {
    encoding: 'utf8',
    highWaterMark: 1024  // 缓冲区大小
  });

  const rl = createInterface({
    input: fileStream,
    crlfDelay: Infinity  // 识别 \n 和 \r\n
  });

  const lines = [];
  for await (const line of rl) {
    lines.push(line);
  }

  return lines;
}

// 处理大文件的另一种方式:流式处理
async function processLargeFile(filePath, processor) {
  const fileStream = fs.createReadStream(filePath);

  return new Promise((resolve, reject) => {
    let lineNumber = 0;

    const rl = createInterface({
      input: fileStream,
      crlfDelay: Infinity
    });

    rl.on('line', (line) => {
      lineNumber++;
      processor(line, lineNumber);
    });

    rl.on('close', resolve);
    rl.on('error', reject);
  });
}

监听文件变化

const chokidar = require('chokidar');  // 推荐使用 chokidar

// 监听文件变化
const watcher = chokidar.watch('/path/to/watch', {
  persistent: true,
  ignoreInitial: true
});

watcher
  .on('add', path => console.log(`File added: ${path}`))
  .on('change', path => console.log(`File changed: ${path}`))
  .on('unlink', path => console.log(`File removed: ${path}`))
  .on('error', error => console.error(`Watcher error: ${error}`));

// 停止监听
// watcher.close();

写入文件

写入文本文件

async function writeTextFile(filePath, content) {
  // 如果目录不存在,需要先创建
  await fs.promises.mkdir(path.dirname(filePath), { recursive: true });

  // 写入文件
  await fs.promises.writeFile(filePath, content, 'utf8');
}

// 或者使用 appendFile 追加内容
async function appendToFile(filePath, content) {
  await fs.promises.appendFile(filePath, content, 'utf8');
}

写入二进制数据

async function writeBinaryFile(filePath, buffer) {
  await fs.promises.writeFile(filePath, buffer);
}

// 从 base64 写入图片
async function writeBase64Image(filePath, base64Data) {
  const buffer = Buffer.from(base64Data, 'base64');
  await fs.promises.writeFile(filePath, buffer);
}

流式写入(处理大文件)

async function writeLargeFile(filePath, dataGenerator) {
  const writeStream = fs.createWriteStream(filePath);

  return new Promise((resolve, reject) => {
    writeStream.on('finish', resolve);
    writeStream.on('error', reject);

    // dataGenerator 是一个异步生成器
    (async () => {
      for await (const chunk of dataGenerator()) {
        writeStream.write(chunk);
      }
      writeStream.end();
    })();
  });
}

目录操作

创建目录

async function createDirectory(dirPath) {
  // recursive: true 会自动创建所有中间目录
  await fs.promises.mkdir(dirPath, { recursive: true });
}

// 例子:创建嵌套目录
await fs.promises.mkdir('/a/b/c/d', { recursive: true });

读取目录内容

async function readDirectory(dirPath) {
  const entries = await fs.promises.readdir(dirPath, {
    withFileTypes: true  // 返回 DirEntry 对象,包含类型信息
  });

  return entries.map(entry => ({
    name: entry.name,
    isFile: entry.isFile(),
    isDirectory: entry.isDirectory(),
    isSymbolicLink: entry.isSymbolicLink(),
    path: path.join(dirPath, entry.name)
  }));
}

// 递归读取目录树
async function readDirectoryTree(dirPath, maxDepth = Infinity) {
  const result = [];

  async function traverse(currentPath, depth) {
    if (depth > maxDepth) return;

    const entries = await fs.promises.readdir(currentPath, {
      withFileTypes: true
    });

    for (const entry of entries) {
      const fullPath = path.join(currentPath, entry.name);
      const item = {
        name: entry.name,
        path: fullPath,
        isDirectory: entry.isDirectory()
      };

      if (entry.isDirectory() && depth < maxDepth) {
        item.children = await traverse(fullPath, depth + 1);
      }

      result.push(item);
    }
  }

  await traverse(dirPath, 0);
  return result;
}

删除目录

async function deleteDirectory(dirPath) {
  // recursive: true 会递归删除所有内容
  await fs.promises.rm(dirPath, { recursive: true, force: true });
}

文件元数据

async function getFileStats(filePath) {
  const stats = await fs.promises.stat(filePath);

  return {
    size: stats.size,              // 文件大小(字节)
    created: stats.birthtime,      // 创建时间
    modified: stats.mtime,         // 修改时间
    accessed: stats.atime,         // 访问时间
    isFile: stats.isFile(),
    isDirectory: stats.isDirectory(),
    isSymbolicLink: stats.isSymbolicLink()
  };
}

// 判断文件是否存在
async function fileExists(filePath) {
  try {
    await fs.promises.access(filePath, fs.constants.F_OK);
    return true;
  } catch {
    return false;
  }
}

复制、移动、删除文件

复制文件

async function copyFile(source, destination) {
  // 确保目标目录存在
  await fs.promises.mkdir(path.dirname(destination), { recursive: true });

  await fs.promises.copyFile(source, destination);
}

// 复制整个目录
async function copyDirectory(source, destination) {
  await fs.promises.mkdir(destination, { recursive: true });

  const entries = await fs.promises.readdir(source, { withFileTypes: true });

  for (const entry of entries) {
    const srcPath = path.join(source, entry.name);
    const destPath = path.join(destination, entry.name);

    if (entry.isDirectory()) {
      await copyDirectory(srcPath, destPath);
    } else {
      await fs.promises.copyFile(srcPath, destPath);
    }
  }
}

移动文件

async function moveFile(source, destination) {
  // 确保目标目录存在
  await fs.promises.mkdir(path.dirname(destination), { recursive: true });

  await fs.promises.rename(source, destination);
}

删除文件

async function deleteFile(filePath) {
  await fs.promises.unlink(filePath);
}

// 安全删除:先检查再删除
async function safeDeleteFile(filePath) {
  if (await fileExists(filePath)) {
    await fs.promises.unlink(filePath);
  }
}

Electron 中的安全文件访问

路径验证

主进程中的文件操作必须验证路径,防止路径穿越攻击:

const { app } = require('electron');
const path = require('path');

ipcMain.handle('file:read', async (event, userPath) => {
  // 获取应用允许的目录
  const allowedDir = app.getPath('userData');

  // 解析用户输入的路径
  const resolvedPath = path.resolve(userPath);

  // 验证路径是否在允许目录内
  if (!resolvedPath.startsWith(allowedDir)) {
    throw new Error('Access denied: path outside allowed directory');
  }

  // 读取文件
  return await fs.promises.readFile(resolvedPath, 'utf8');
});

危险的错误示例

// 危险:直接使用用户输入的路径
ipcMain.handle('file:read', async (event, filePath) => {
  // 用户可能输入 ../../../etc/passwd
  return await fs.promises.readFile(filePath, 'utf8');
});

// 危险:使用相对路径
ipcMain.handle('file:read', async (event, filePath) => {
  // 用户可能输入 ../../../etc/passwd
  return await fs.promises.readFile(path.join(__dirname, filePath), 'utf8');
});

使用 dialog 让用户选择文件

const { dialog } = require('electron');

ipcMain.handle('file:open-dialog', async (event) => {
  const result = await dialog.showOpenDialog({
    properties: ['openFile'],
    filters: [
      { name: '文本文件', extensions: ['txt', 'md', 'json'] },
      { name: '所有文件', extensions: ['*'] }
    ]
  });

  if (result.canceled) {
    return null;
  }

  return result.filePaths[0];
});

常见文件操作场景

1. 保存用户数据

const { app } = require('electron');

async function saveUserData(filename, data) {
  const userDataPath = app.getPath('userData');
  const filePath = path.join(userDataPath, filename);

  await fs.promises.writeFile(filePath, JSON.stringify(data, null, 2), 'utf8');
}

async function loadUserData(filename) {
  const userDataPath = app.getPath('userData');
  const filePath = path.join(userDataPath, filename);

  try {
    const content = await fs.promises.readFile(filePath, 'utf8');
    return JSON.parse(content);
  } catch (err) {
    return null;
  }
}

2. 导出文件

const { dialog } = require('electron');

async function exportToFile(content, defaultName) {
  const result = await dialog.showSaveDialog({
    title: '导出文件',
    defaultPath: defaultName,
    filters: [
      { name: 'JSON 文件', extensions: ['json'] },
      { name: '文本文件', extensions: ['txt'] },
      { name: 'CSV 文件', extensions: ['csv'] }
    ]
  });

  if (result.canceled) {
    return false;
  }

  await fs.promises.writeFile(result.filePath, content, 'utf8');
  return true;
}

3. 应用日志

const { app } = require('electron');

class Logger {
  constructor(filename = 'app.log') {
    this.logPath = path.join(app.getPath('logs'), filename);
  }

  async write(level, message, data = {}) {
    const entry = {
      timestamp: new Date().toISOString(),
      level,
      message,
      ...data
    };

    const line = JSON.stringify(entry) + '\n';
    await fs.promises.appendFile(this.logPath, line, 'utf8');
  }

  info(message, data) { return this.write('INFO', message, data); }
  warn(message, data) { return this.write('WARN', message, data); }
  error(message, data) { return this.write('ERROR', message, data); }
}

const logger = new Logger();

这一章想说的

文件系统操作是 Electron 应用的基础:

  1. Node.js fs 模块:完整的文件系统 API,支持同步和异步
  2. 路径操作:使用 path.joinpath.resolve 处理路径
  3. 安全访问:主进程必须验证路径,防止路径穿越
  4. 常见场景:用户数据存储、文件导入导出、日志记录

合理使用文件 API,配合安全验证,可以构建安全可靠的桌面应用。


延展阅读