Electron 实战:将用户输入保存到本地文件 —— 基于 `fs.writeFileSync` 与 IPC 的安全写入方案

个人主页:ujainu

文章目录

引言

在桌面应用开发中,"保存用户数据到本地"是最基础也最关键的交互之一。Electron 凭借其融合 Web 技术与 Node.js 能力的独特架构,使得这一操作既直观又强大。

本文将深入剖析一个典型场景:用户在页面输入框中编辑内容,点击"保存"按钮后,内容被写入系统"文档"目录下的 notes.txt 文件 。我们将使用 ipcRenderer.send() 触发保存请求,主进程通过 fs.writeFileSync 同步写入文件,并通过 IPC 回传结果状态。

该方案已在 HarmonyOS PC 的 Linux 兼容层成功运行,特别适合鸿蒙生态下的轻量级笔记、配置或日志类工具开发。


一、整体通信流程概览

整个功能依赖 渲染进程 → 主进程 → 文件系统 → 渲染进程 的闭环:
文件系统 (fs) 主进程 (Main) 渲染进程 (UI) 文件系统 (fs) 主进程 (Main) 渲染进程 (UI) ipcRenderer.send('save-note', {content}) fs.writeFileSync(path, content) 写入成功/失败 event.reply('save-result', {success, message, filePath}) 显示状态提示 + 文件路径

设计原则

  • 渲染进程不直接访问文件系统(安全隔离)
  • 所有 I/O 操作由主进程代理执行
  • 用户反馈实时、明确、带路径信息

二、渲染进程详解:用户交互与消息发送

2.1 输入与按钮绑定

html 复制代码
<textarea id="noteInput" placeholder="在这里输入笔记内容..."></textarea>
<button class="action-btn save" onclick="saveNote()">💾 保存到本地</button>
  • 使用原生 <textarea> 确保多行文本支持。
  • user-select: text 在 CSS 中显式启用文本选择(因全局禁用了 user-select: none)。

2.2 保存逻辑:saveNote()

js 复制代码
function saveNote() {
    const noteContent = document.getElementById('noteInput').value;
    if (!noteContent.trim()) {
        showStatus('❌ 请输入内容后再保存', 'error');
        return;
    }
    ipcRenderer.send('save-note', {
        content: noteContent,
        filename: 'notes.txt'
    });
    showStatus('⏳ 正在保存...', 'success');
}

关键点解析

步骤 说明
空值校验 trim() 防止纯空格提交,提升用户体验
消息结构 包含 contentfilename,便于主进程灵活处理
即时反馈 发送后立即显示"正在保存",避免用户重复点击

💡 为什么不用 dialog.showSaveDialog

本例目标是固定保存到"文档"目录 ,无需用户选择路径,简化流程。若需自定义路径,则应引入 dialog 模块(后文扩展建议)。

2.3 接收保存结果:ipcRenderer.on('save-result')

js 复制代码
ipcRenderer.on('save-result', (event, result) => {
    if (result.success) {
        showStatus('✅ ' + result.message, 'success');
        document.getElementById('filePath').textContent = '保存位置:' + result.filePath;
    } else {
        showStatus('❌ ' + result.message, 'error');
    }
});
  • 结构化响应{ success, message, filePath } 便于前端统一处理。
  • 路径展示:让用户知道文件具体存哪里,增强可信度(尤其在 HarmonyOS 等新系统中)。

三、主进程详解:安全写入本地文件

3.1 监听保存请求:ipcMain.on('save-note')

js 复制代码
ipcMain.on('save-note', (event, data) => {
    // 处理逻辑...
});
  • data 即渲染进程传来的 { content, filename } 对象。
  • 此处未做额外验证(因来源可信),但生产环境可增加类型检查。

3.2 获取标准文档目录:app.getPath('documents')

js 复制代码
const documentsPath = app.getPath('documents');
const filePath = path.join(documentsPath, data.filename);

为什么用 app.getPath('documents')

方法 路径示例(Linux/HarmonyOS PC) 优势
app.getPath('documents') /home/user/Documents/ 跨平台、符合用户习惯、有写权限
__dirname 应用安装目录 可能无写权限,且非用户可见区域
硬编码路径 /home/user/notes.txt 不可移植,多用户环境失效

HarmonyOS PC 适配关键

在兼容层中,app.getPath('documents') 会正确映射到用户的 "文档"目录,确保文件可被其他应用(如文件管理器)访问。

3.3 同步写入文件:fs.writeFileSync

js 复制代码
fs.writeFileSync(filePath, data.content, 'utf-8');

参数说明

  • filePath:完整绝对路径
  • data.content:用户输入的字符串
  • 'utf-8':指定编码,避免中文乱码(HarmonyOS 中文支持必备

⚠️ 同步 vs 异步

  • writeFileSync阻塞式,简单可靠,适合小文件(<1MB)
  • 若处理大文件或高频写入,应改用 fs.writeFile + async/await 避免卡死主进程

3.4 错误处理与结果回传

js 复制代码
try {
    fs.writeFileSync(...);
    event.reply('save-result', { success: true, ... });
} catch (error) {
    event.reply('save-result', { success: false, message: error.message });
}
  • 必须包裹 try/catch:文件写入可能因权限、磁盘满、路径非法等失败。
  • 错误信息透传 :将 error.message 返回前端,便于调试(如"EACCES: permission denied")。

四、关键安全与权限考量

尽管当前配置启用了 nodeIntegration,但文件写入操作仍严格限制在主进程,这是安全设计的核心:

风险 本方案如何规避
渲染进程直接写任意文件 ❌ 不可能,fs 仅在主进程可用
路径遍历攻击(如 ../../etc/passwd ⚠️ 当前未过滤 filename,存在风险
覆盖系统关键文件 ⚠️ 若 filename 被篡改,可能写入危险位置

✅ 安全加固建议(生产环境必做):

  1. 白名单文件名

    js 复制代码
    const allowedFilenames = ['notes.txt', 'log.txt'];
    if (!allowedFilenames.includes(data.filename)) {
        throw new Error('非法文件名');
    }
  2. 强制限定目录

    js 复制代码
    const safePath = path.join(documentsPath, path.basename(data.filename));
    // path.basename() 剥离路径,只保留文件名
  3. 迁移到 preload 模式 :彻底禁用 nodeIntegration,通过预加载脚本暴露 saveNote 方法。


五、HarmonyOS PC 专属适配要点

问题 解决方案
中文乱码 显式指定 utf-8 编码
文档目录不可写 使用 app.getPath('documents') 而非硬编码
硬件加速导致透明窗口异常 app.disableHardwareAcceleration() 必须调用
字体渲染模糊 CSS 中指定 PingFang SC, Microsoft YaHei 等高 DPI 字体

六、功能对比:不同文件保存策略

方案 适用场景 用户控制 安全性 代码复杂度
固定路径写入(本文) 日志、自动备份、配置 高(主进程控制) ★☆☆
dialog.showSaveDialog 用户主动保存 有(选路径/文件名) ★★☆
渲染进程直写(不推荐) 快速原型 极低(XSS 可删系统文件) ★☆☆

📌 结论:对于"自动保存到标准位置"的需求,本文方案是最简洁、安全、用户友好的选择。


七、扩展建议

  1. 追加模式 :若需记录多条笔记,可改用 fs.appendFileSync
  2. 自动保存 :监听 textareainput 事件,500ms 后触发保存(防抖)。
  3. 文件存在提示 :写入前检查 fs.existsSync(filePath),弹窗确认覆盖。
  4. 多格式支持 :根据扩展名 .txt / .md 自动切换内容格式。

完整代码

javascript 复制代码
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const fs = require('fs');
const path = require('path');

let mainWindow;

// 1. 在 Electron 层面禁用硬件加速
app.disableHardwareAcceleration();

function createWindow() {
    // 获取屏幕尺寸
    const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize;

    // 窗口尺寸
    const windowWidth = 650;
    const windowHeight = 550;

    // 计算居中位置
    const x = (screenWidth - windowWidth) / 2;
    const y = (screenHeight - windowHeight) / 2;

    mainWindow = new BrowserWindow({
        width: windowWidth,
        height: windowHeight,
        x: x,
        y: y,
        frame: false,
        transparent: true,
        resizable: false,
        movable: true,
        minimizable: true,
        closable: true,
        skipTaskbar: false,
        roundedCorners: true,
        hasShadow: true,
        webPreferences: {
            nodeIntegration: true,
            contextIsolation: false,
        },
    });

    // 创建 HTML 内容(包含文件保存功能)
    const htmlContent = `
        <!DOCTYPE html>
        <html>
        <head>
            <meta charset="UTF-8">
            <style>
                * {
                    margin: 0;
                    padding: 0;
                    box-sizing: border-box;
                    user-select: none;
                }

                body {
                    font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif;
                    overflow: hidden;
                    background: transparent;
                }

                .window-container {
                    width: 100%;
                    height: 100vh;
                    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
                    border-radius: 16px;
                    overflow: hidden;
                    display: flex;
                    flex-direction: column;
                    box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
                }

                .title-bar {
                    height: 44px;
                    background: linear-gradient(to bottom, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.05));
                    backdrop-filter: blur(20px);
                    -webkit-backdrop-filter: blur(20px);
                    display: flex;
                    justify-content: space-between;
                    align-items: center;
                    padding: 0 20px;
                    -webkit-app-region: drag;
                    border-bottom: 1px solid rgba(255, 255, 255, 0.1);
                }

                .title-text {
                    color: white;
                    font-size: 15px;
                    font-weight: 600;
                    letter-spacing: 0.5px;
                    text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
                    font-family: 'SF Pro Display', 'Segoe UI', 'PingFang SC', sans-serif;
                }

                .window-controls {
                    display: flex;
                    gap: 10px;
                    -webkit-app-region: no-drag;
                }

                .control-btn {
                    width: 14px;
                    height: 14px;
                    border-radius: 50%;
                    border: none;
                    cursor: pointer;
                    transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
                    position: relative;
                    outline: none;
                }

                .control-btn::before {
                    content: '';
                    position: absolute;
                    top: 50%;
                    left: 50%;
                    transform: translate(-50%, -50%);
                    width: 100%;
                    height: 100%;
                    border-radius: 50%;
                    background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.3), transparent);
                }

                .control-btn:hover {
                    transform: scale(1.1);
                }

                .control-btn:active {
                    transform: scale(0.95);
                }

                .close-btn {
                    background: linear-gradient(135deg, #ff6b6b, #ee5a5a);
                    box-shadow: 0 2px 4px rgba(255, 107, 107, 0.4);
                }

                .close-btn:hover {
                    background: linear-gradient(135deg, #ff5252, #e04545);
                    box-shadow: 0 3px 8px rgba(255, 107, 107, 0.6);
                }

                .minimize-btn {
                    background: linear-gradient(135deg, #ffd93d, #ffcd38);
                    box-shadow: 0 2px 4px rgba(255, 217, 61, 0.4);
                }

                .minimize-btn:hover {
                    background: linear-gradient(135deg, #ffc929, #ffbd2e);
                    box-shadow: 0 3px 8px rgba(255, 217, 61, 0.6);
                }

                .maximize-btn {
                    background: linear-gradient(135deg, #6bcf7f, #4fd66a);
                    box-shadow: 0 2px 4px rgba(107, 207, 127, 0.4);
                }

                .maximize-btn:hover {
                    background: linear-gradient(135deg, #5dd66f, #45c75f);
                    box-shadow: 0 3px 8px rgba(107, 207, 127, 0.6);
                }

                .content {
                    flex: 1;
                    display: flex;
                    flex-direction: column;
                    justify-content: center;
                    align-items: center;
                    color: white;
                    padding: 40px 50px;
                }

                h1 {
                    font-size: 36px;
                    font-weight: 700;
                    background: linear-gradient(135deg, #ffffff 0%, #f0f0f0 100%);
                    -webkit-background-clip: text;
                    -webkit-text-fill-color: transparent;
                    background-clip: text;
                    text-shadow: none;
                    letter-spacing: 1px;
                    margin-bottom: 35px;
                    font-family: 'SF Pro Display', 'PingFang SC', 'Microsoft YaHei', sans-serif;
                }

                .editor-area {
                    width: 100%;
                    max-width: 550px;
                    background: rgba(255, 255, 255, 0.15);
                    backdrop-filter: blur(10px);
                    -webkit-backdrop-filter: blur(10px);
                    border-radius: 12px;
                    padding: 30px;
                    border: 1px solid rgba(255, 255, 255, 0.2);
                }

                .editor-title {
                    font-size: 20px;
                    font-weight: 600;
                    margin-bottom: 20px;
                    color: rgba(255, 255, 255, 0.95);
                    font-family: 'SF Pro Text', 'PingFang SC', sans-serif;
                    display: flex;
                    align-items: center;
                    gap: 10px;
                }

                .icon {
                    font-size: 24px;
                }

                .textarea-container {
                    margin-bottom: 25px;
                }

                textarea {
                    width: 100%;
                    height: 280px;
                    background: rgba(255, 255, 255, 0.95);
                    border: 2px solid rgba(255, 255, 255, 0.3);
                    border-radius: 8px;
                    padding: 18px;
                    font-size: 16px;
                    font-family: 'SF Pro Text', 'PingFang SC', 'Microsoft YaHei', monospace;
                    resize: none;
                    outline: none;
                    transition: all 0.3s ease;
                    color: #333;
                    user-select: text;
                    line-height: 1.6;
                }

                textarea:focus {
                    border-color: rgba(255, 255, 255, 0.8);
                    box-shadow: 0 0 20px rgba(255, 255, 255, 0.3);
                }

                textarea::placeholder {
                    color: #999;
                }

                .button-group {
                    display: flex;
                    gap: 15px;
                }

                .action-btn {
                    flex: 1;
                    padding: 18px 25px;
                    background: linear-gradient(135deg, #ffffff, #f0f0f0);
                    border: none;
                    border-radius: 10px;
                    color: #667eea;
                    font-size: 17px;
                    font-weight: 600;
                    cursor: pointer;
                    transition: all 0.3s ease;
                    font-family: 'SF Pro Text', 'PingFang SC', sans-serif;
                    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
                    display: flex;
                    align-items: center;
                    justify-content: center;
                    gap: 10px;
                }

                .action-btn:hover {
                    transform: translateY(-2px);
                    box-shadow: 0 6px 12px rgba(0, 0, 0, 0.2);
                }

                .action-btn:active {
                    transform: translateY(0);
                }

                .action-btn.save {
                    background: linear-gradient(135deg, #6bcf7f, #4fd66a);
                    color: white;
                }

                .action-btn.save:hover {
                    background: linear-gradient(135deg, #5dd66f, #45c75f);
                }

                .status-message {
                    margin-top: 20px;
                    padding: 15px;
                    border-radius: 8px;
                    font-size: 15px;
                    font-weight: 500;
                    text-align: center;
                    font-family: 'SF Pro Text', sans-serif;
                    opacity: 0;
                    transition: opacity 0.3s ease;
                }

                .status-message.show {
                    opacity: 1;
                }

                .status-message.success {
                    background: rgba(107, 207, 127, 0.3);
                    color: #ffffff;
                    border: 1px solid rgba(107, 207, 127, 0.5);
                }

                .status-message.error {
                    background: rgba(255, 107, 107, 0.3);
                    color: #ffffff;
                    border: 1px solid rgba(255, 107, 107, 0.5);
                }

                .file-path {
                    margin-top: 15px;
                    font-size: 13px;
                    color: rgba(255, 255, 255, 0.7);
                    font-family: 'SF Pro Text', monospace;
                    text-align: center;
                    word-break: break-all;
                }
            </style>
        </head>
        <body>
            <div class="window-container">
                <div class="title-bar">
                    <div class="title-text">笔记保存工具</div>
                    <div class="window-controls">
                        <button class="control-btn minimize-btn" onclick="minimizeWindow()" title="最小化"></button>
                        <button class="control-btn maximize-btn" onclick="maximizeWindow()" title="最大化"></button>
                        <button class="control-btn close-btn" onclick="closeWindow()" title="关闭"></button>
                    </div>
                </div>
                <div class="content">
                    <h1>📝 保存笔记到本地</h1>

                    <div class="editor-area">
                        <div class="editor-title">
                            <span class="icon">📄</span>
                            输入你的笔记内容
                        </div>

                        <div class="textarea-container">
                            <textarea id="noteInput" placeholder="在这里输入笔记内容...&#10;&#10;例如:&#10;- 今天学习了 Electron&#10;- HarmonyOS PC 很有趣&#10;- 明天要继续加油!"></textarea>
                        </div>

                        <div class="button-group">
                            <button class="action-btn" onclick="clearContent()">
                                🗑️ 清空
                            </button>
                            <button class="action-btn save" onclick="saveNote()">
                                💾 保存到本地
                            </button>
                        </div>

                        <div class="status-message" id="statusMessage"></div>
                        <div class="file-path" id="filePath"></div>
                    </div>
                </div>
            </div>

            <script>
                const { ipcRenderer } = require('electron');

                // 窗口控制函数
                function closeWindow() {
                    ipcRenderer.send('close-window');
                }

                function minimizeWindow() {
                    ipcRenderer.send('minimize-window');
                }

                function maximizeWindow() {
                    ipcRenderer.send('maximize-window');
                }

                // 清空内容
                function clearContent() {
                    document.getElementById('noteInput').value = '';
                    showStatus('已清空', 'success');
                }

                // 显示状态消息
                function showStatus(message, type) {
                    const statusEl = document.getElementById('statusMessage');
                    statusEl.textContent = message;
                    statusEl.className = 'status-message show ' + type;

                    setTimeout(() => {
                        statusEl.className = 'status-message';
                    }, 3000);
                }

                // 保存笔记
                function saveNote() {
                    const noteContent = document.getElementById('noteInput').value;

                    if (!noteContent || noteContent.trim() === '') {
                        showStatus('❌ 请输入内容后再保存', 'error');
                        return;
                    }

                    // 发送消息给主进程,请求保存文件
                    ipcRenderer.send('save-note', {
                        content: noteContent,
                        filename: 'notes.txt'
                    });

                    showStatus('⏳ 正在保存...', 'success');
                }

                // 监听主进程的保存结果
                ipcRenderer.on('save-result', (event, result) => {
                    if (result.success) {
                        showStatus('✅ ' + result.message, 'success');
                        document.getElementById('filePath').textContent = '保存位置:' + result.filePath;
                    } else {
                        showStatus('❌ ' + result.message, 'error');
                        document.getElementById('filePath').textContent = '';
                    }
                });
            </script>
        </body>
        </html>
    `;

    mainWindow.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(htmlContent));

    // ========== 主进程 IPC 处理 ==========

    // 处理保存笔记的请求
    ipcMain.on('save-note', (event, data) => {
        try {
            // 获取用户的文档目录
            const documentsPath = app.getPath('documents');
            const filePath = path.join(documentsPath, data.filename);

            console.log('准备保存文件到:', filePath);
            console.log('文件内容:', data.content);

            // 使用 Node.js 的 fs.writeFileSync 写入文件
            // 这是同步写入,会阻塞直到写入完成
            fs.writeFileSync(filePath, data.content, 'utf-8');

            console.log('文件保存成功:', filePath);

            // 发送成功消息回渲染进程
            event.reply('save-result', {
                success: true,
                message: '笔记已成功保存到文档文件夹!',
                filePath: filePath,
                filename: data.filename
            });

        } catch (error) {
            console.error('保存文件失败:', error);

            // 发送错误消息回渲染进程
            event.reply('save-result', {
                success: false,
                message: '保存失败:' + error.message,
                filePath: '',
                filename: data.filename
            });
        }
    });

    // 监听窗口控制事件
    ipcMain.on('close-window', () => {
        mainWindow.close();
    });

    ipcMain.on('minimize-window', () => {
        mainWindow.minimize();
    });

    ipcMain.on('maximize-window', () => {
        if (mainWindow.isMaximized()) {
            mainWindow.unmaximize();
        } else {
            mainWindow.maximize();
        }
    });
}

// 引入 screen 模块
const { screen } = require('electron');

app.whenReady().then(createWindow);

// 处理 macOS 上的所有窗口关闭事件
app.on('window-all-closed', () => {
    app.quit();
});

运行界面:

八、总结

本文通过一个"保存笔记到 文档/notes.txt"的完整案例,系统讲解了 Electron 中如何安全、可靠地将用户输入持久化到本地文件。核心要点包括:

  • IPC 作为唯一通信通道,确保渲染进程无法直接访问文件系统;
  • app.getPath('documents') 提供跨平台标准路径,完美适配 HarmonyOS PC;
  • 同步写入 + 错误捕获 保证操作原子性与反馈完整性;
  • 前端状态反馈 + 路径展示 极大提升用户体验。

此模式可直接复用于配置保存、日志记录、草稿自动备份等场景,是 Electron 桌面应用开发的基础范式之一。掌握它,你就迈出了构建专业级鸿蒙桌面应用的关键一步。


欢迎加入开源鸿蒙PC社区:https://harmonypc.csdn.net/

相关推荐
阿梦Anmory2 小时前
Redis配置远程访问(绑定0.0.0.0):从配置到安全实战
redis·安全·bootstrap
进击的尘埃2 小时前
基于 LangChain.js 的前端 Agent 工作流编排:Tool 注册、思维链可视化与多步推理的实时 DAG 渲染
javascript
颜酱3 小时前
最小生成树(MST)核心原理 + Kruskal & Prim 算法
javascript·后端·算法
黄焖鸡能干四碗3 小时前
业务数据中台技术方案(PPT)
大数据·数据库·人工智能·安全·需求分析
蜡台3 小时前
Node 版本管理器NVM 安装配置和使用
前端·javascript·vue.js·node·nvm
奶昔不会射手4 小时前
自定义vue3函数式弹窗
前端·javascript·css
wuhen_n5 小时前
破冰——建立我们的AI开发实验环境
前端·javascript
张元清6 小时前
React Hooks vs Vue Composables:2026 年全面对比
前端·javascript·面试
yuki_uix6 小时前
从三个自定义 Hook 看 React 状态管理的设计思想
前端·javascript