Electron 实战项目
做一个 Markdown 笔记应用:窗口、文件读写、菜单、自动保存。
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) => ({ '&': '&', '<': '<', '>': '>' }[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();这个项目用到了什么
| Feature | Concept |
|---|---|
| 窗口 | 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()
项目结构
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) => ({ '&': '&', '<': '<', '>': '>' }[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();这个项目用到了什么
| 功能 | 对应知识点 |
|---|---|
| 窗口 | BrowserWindow、ready-to-show |
| 打开/保存 | dialog、fs、原子写入 |
| 菜单快捷键 | Menu、accelerator、webContents.send |
| 安全桥 | contextIsolation、contextBridge |
| 自动保存 | 防抖 + IPC |
自动保存要防抖,不能每次按键都写磁盘——一次输入几十次写操作,SSD 和电池都受不了。800ms 是常见取值:既不丢字,又不至于频繁 IO。
可以继续加的功能
- 最近打开 —— 存到
userData/recent.json,菜单里列出 - 未保存提示 ——
win.on('close')里拦一下 - 深色/浅色切换 ——
nativeTheme.shouldUseDarkColors - 导出 PDF ——
webContents.printToPDF()