Electron 实战项目

做一个 Markdown 笔记应用:窗口、文件读写、菜单、自动保存。

Advanced
Key Points把前面的知识点串成一个能用的应用:选文件、编辑、预览、自动保存、最近打开。

项目结构

Text
markdown-notes/
├── package.json
├── main.js
├── preload.js
├── index.html
├── renderer.js
└── lib/
    ├── store.js        ← 最近文件列表
    └── markdown.js     ← 极简渲染

主进程

JavaScript
// main.js
const { app, BrowserWindow, dialog, ipcMain, Menu } = require('electron');
const fs = require('node:fs/promises');
const path = require('node:path');

let win;

function createWindow() {
  win = new BrowserWindow({
    width: 1100, height: 720, show: false,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false
    }
  });
  win.once('ready-to-show', () => win.show());
  win.loadFile('index.html');
}

ipcMain.handle('file:open', async () => {
  const { canceled, filePaths } = await dialog.showOpenDialog(win, {
    properties: ['openFile'],
    filters: [{ name: 'Markdown', extensions: ['md', 'markdown', 'txt'] }]
  });
  if (canceled) return null;
  const p = filePaths[0];
  return { path: p, name: path.basename(p), text: await fs.readFile(p, 'utf8') };
});

ipcMain.handle('file:save', async (_e, { path: p, text }) => {
  const tmp = p + '.tmp';
  await fs.writeFile(tmp, text, 'utf8');
  await fs.rename(tmp, p);
  return true;
});

ipcMain.handle('file:saveAs', async (_e, text) => {
  const { canceled, filePath } = await dialog.showSaveDialog(win, {
    defaultPath: 'untitled.md',
    filters: [{ name: 'Markdown', extensions: ['md'] }]
  });
  if (canceled) return null;
  await fs.writeFile(filePath, text, 'utf8');
  return { path: filePath, name: path.basename(filePath) };
});

app.whenReady().then(() => {
  createWindow();
  Menu.setApplicationMenu(Menu.buildFromTemplate([
    {
      label: '文件',
      submenu: [
        { label: '打开', accelerator: 'CmdOrCtrl+O',
          click: () => win.webContents.send('menu:open') },
        { label: '保存', accelerator: 'CmdOrCtrl+S',
          click: () => win.webContents.send('menu:save') },
        { type: 'separator' },
        { role: 'quit', label: '退出' }
      ]
    }
  ]));
});

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit();
});

预加载

JavaScript
// preload.js
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('api', {
  open: () => ipcRenderer.invoke('file:open'),
  save: (payload) => ipcRenderer.invoke('file:save', payload),
  saveAs: (text) => ipcRenderer.invoke('file:saveAs', text),
  onMenu: (cb) => {
    ipcRenderer.on('menu:open', () => cb('open'));
    ipcRenderer.on('menu:save', () => cb('save'));
  }
});

界面

HTML
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'">
  <title>Markdown 笔记</title>
  <style>
    * { box-sizing: border-box; }
    body { margin: 0; font: 14px/1.6 system-ui, sans-serif; height: 100vh;
           display: grid; grid-template-rows: auto 1fr; background: #0b1220; color: #cbd5e1; }
    header { display: flex; gap: .5rem; align-items: center; padding: .5rem .8rem;
             border-bottom: 1px solid #1e293b; }
    header button { padding: .3rem .7rem; border-radius: .35rem; border: 0;
                    background: #1e293b; color: #cbd5e1; cursor: pointer; }
    main { display: grid; grid-template-columns: 1fr 1fr; min-height: 0; }
    textarea { border: 0; outline: 0; resize: none; padding: 1rem;
               background: #0b1220; color: #e2e8f0; font: 14px/1.7 ui-monospace, monospace; }
    #preview { padding: 1rem 1.4rem; overflow: auto; border-left: 1px solid #1e293b; }
    #preview h1, #preview h2 { color: #f8fafc; }
    .hint { color: #64748b; font-size: 12px; margin-left: auto; }
  </style>
</head>
<body>
  <header>
    <button id="open">打开</button>
    <button id="save">保存</button>
    <span id="name">未命名</span>
    <span class="hint" id="status"></span>
  </header>
  <main>
    <textarea id="editor" placeholder="# 从这里开始写…"></textarea>
    <div id="preview"></div>
  </main>
  <script src="renderer.js"></script>
</body>
</html>

渲染逻辑

JavaScript
// renderer.js
const editor = document.getElementById('editor');
const preview = document.getElementById('preview');
const nameEl = document.getElementById('name');
const statusEl = document.getElementById('status');

let currentPath = null;
let timer = null;

function md2html(src) {
  const esc = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
  return esc(src)
    .replace(/^### (.*)$/gm, '<h3>$1</h3>')
    .replace(/^## (.*)$/gm, '<h2>$1</h2>')
    .replace(/^# (.*)$/gm, '<h1>$1</h1>')
    .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
    .replace(/`(.+?)`/g, '<code>$1</code>')
    .replace(/\n{2,}/g, '</p><p>')
    .replace(/^/, '<p>') + '</p>';
}

function render() {
  preview.innerHTML = md2html(editor.value);
}

function markSaved(text) {
  statusEl.textContent = text;
  setTimeout(() => { statusEl.textContent = ''; }, 1500);
}

editor.addEventListener('input', () => {
  render();
  clearTimeout(timer);
  timer = setTimeout(async () => {
    if (currentPath) {
      await window.api.save({ path: currentPath, text: editor.value });
      markSaved('已自动保存');
    }
  }, 800);                                  // 停手 800ms 后保存
});

document.getElementById('open').addEventListener('click', async () => {
  const f = await window.api.open();
  if (!f) return;
  editor.value = f.text;
  currentPath = f.path;
  nameEl.textContent = f.name;
  render();
});

document.getElementById('save').addEventListener('click', async () => {
  if (currentPath) {
    await window.api.save({ path: currentPath, text: editor.value });
  } else {
    const f = await window.api.saveAs(editor.value);
    if (!f) return;
    currentPath = f.path;
    nameEl.textContent = f.name;
  }
  markSaved('已保存');
});

window.api.onMenu((action) => {
  if (action === 'open') document.getElementById('open').click();
  if (action === 'save') document.getElementById('save').click();
});

render();

这个项目用到了什么

FeatureConcept
窗口BrowserWindow、ready-to-show
打开/保存dialog、fs、原子写入
菜单快捷键Menu、accelerator、webContents.send
安全桥contextIsolation、contextBridge
自动保存防抖 + IPC
Debounce autosave: writing on every keystroke means dozens of disk writes per sentence, which is bad for SSDs and batteries. 800ms is a common compromise — no lost text, no write storm.

可以继续加的功能

  • 最近打开 —— 存到 userData/recent.json,菜单里列出
  • 未保存提示 —— win.on('close') 里拦一下
  • 深色/浅色切换 —— nativeTheme.shouldUseDarkColors
  • 导出 PDF —— webContents.printToPDF()

Friends & Partners