实战案例 #
一、项目概述 #
1.1 项目介绍 #
我们将开发一个功能完整的 Markdown 编辑器,包含以下功能:
text
- Markdown 编辑与预览
- 文件新建、打开、保存
- 自动保存
- 主题切换
- 导出 PDF
- 快捷键支持
1.2 技术栈 #
| 技术 | 用途 |
|---|---|
| Electron | 桌面应用框架 |
| Vue 3 | 前端框架 |
| TypeScript | 类型支持 |
| Pinia | 状态管理 |
| CodeMirror | 编辑器 |
| Marked | Markdown 解析 |
| electron-store | 本地存储 |
二、项目结构 #
text
markdown-editor/
├── electron/
│ ├── main.ts # 主进程
│ ├── preload.ts # 预加载脚本
│ ├── ipc/
│ │ ├── index.ts # IPC 统一导出
│ │ └── handlers/
│ │ ├── file.ts # 文件操作
│ │ └── app.ts # 应用操作
│ └── utils/
│ └── store.ts # 本地存储
├── src/
│ ├── App.vue # 根组件
│ ├── main.ts # 入口文件
│ ├── components/
│ │ ├── Editor.vue # 编辑器组件
│ │ ├── Preview.vue # 预览组件
│ │ ├── Toolbar.vue # 工具栏
│ │ └── StatusBar.vue # 状态栏
│ ├── composables/
│ │ ├── useFile.ts # 文件操作
│ │ └── useTheme.ts # 主题切换
│ ├── stores/
│ │ └── editor.ts # 编辑器状态
│ └── types/
│ └── index.ts # 类型定义
├── package.json
├── vite.config.ts
├── tsconfig.json
└── electron-builder.yml
三、主进程实现 #
3.1 主进程入口 #
typescript
// electron/main.ts
import { app, BrowserWindow, ipcMain } from 'electron';
import path from 'path';
import { setupIpc } from './ipc';
const isDev = !app.isPackaged;
let mainWindow: BrowserWindow | null = null;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true
},
titleBarStyle: 'hiddenInset'
});
if (isDev) {
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools();
} else {
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'));
}
mainWindow.on('closed', () => {
mainWindow = null;
});
}
app.whenReady().then(() => {
createWindow();
setupIpc();
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (mainWindow === null) {
createWindow();
}
});
3.2 文件操作处理器 #
typescript
// electron/ipc/handlers/file.ts
import { ipcMain, dialog, BrowserWindow } from 'electron';
import fs from 'fs/promises';
export function setupFileHandlers() {
ipcMain.handle('file:new', async () => {
return { content: '', path: null, modified: false };
});
ipcMain.handle('file:open', async () => {
const result = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow()!, {
filters: [{ name: 'Markdown', extensions: ['md', 'markdown', 'txt'] }],
properties: ['openFile']
});
if (result.canceled || result.filePaths.length === 0) {
return null;
}
const filePath = result.filePaths[0];
const content = await fs.readFile(filePath, 'utf-8');
return { content, path: filePath, modified: false };
});
ipcMain.handle('file:save', async (_, { path: filePath, content }) => {
if (!filePath) {
const result = await dialog.showSaveDialog(BrowserWindow.getFocusedWindow()!, {
filters: [{ name: 'Markdown', extensions: ['md'] }],
defaultPath: 'untitled.md'
});
if (result.canceled || !result.filePath) {
return null;
}
filePath = result.filePath;
}
await fs.writeFile(filePath, content, 'utf-8');
return filePath;
});
ipcMain.handle('file:export-pdf', async (_, html) => {
const result = await dialog.showSaveDialog(BrowserWindow.getFocusedWindow()!, {
filters: [{ name: 'PDF', extensions: ['pdf'] }],
defaultPath: 'export.pdf'
});
if (result.canceled || !result.filePath) {
return false;
}
const win = new BrowserWindow({
width: 800,
height: 1100,
show: false,
webPreferences: {
nodeIntegration: false,
contextIsolation: true
}
});
win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
await win.webContents.printToPDF({
pageSize: 'A4',
printBackground: true
}).then(async (data) => {
await fs.writeFile(result.filePath!, data);
});
win.close();
return true;
});
}
3.3 预加载脚本 #
typescript
// electron/preload.ts
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('electronAPI', {
file: {
new: () => ipcRenderer.invoke('file:new'),
open: () => ipcRenderer.invoke('file:open'),
save: (path: string, content: string) =>
ipcRenderer.invoke('file:save', { path, content }),
exportPDF: (html: string) =>
ipcRenderer.invoke('file:export-pdf', html)
},
app: {
getVersion: () => ipcRenderer.invoke('app:get-version'),
setTheme: (theme: 'light' | 'dark') =>
ipcRenderer.send('app:set-theme', theme)
}
});
四、渲染进程实现 #
4.1 编辑器组件 #
vue
<!-- src/components/Editor.vue -->
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue';
import CodeMirror from 'codemirror';
import 'codemirror/lib/codemirror.css';
import 'codemirror/mode/markdown/markdown';
import 'codemirror/theme/idea.css';
import 'codemirror/theme/darcula.css';
import { useEditorStore } from '@/stores/editor';
const editorRef = ref<HTMLTextAreaElement>();
const store = useEditorStore();
let editor: CodeMirror.EditorFromTextArea | null = null;
onMounted(() => {
if (editorRef.value) {
editor = CodeMirror.fromTextArea(editorRef.value, {
mode: 'markdown',
theme: store.theme === 'dark' ? 'darcula' : 'idea',
lineNumbers: true,
lineWrapping: true,
autofocus: true
});
editor.on('change', () => {
store.setContent(editor!.getValue());
});
}
});
watch(() => store.theme, (theme) => {
editor?.setOption('theme', theme === 'dark' ? 'darcula' : 'idea');
});
</script>
<template>
<div class="editor">
<textarea ref="editorRef" :value="store.content"></textarea>
</div>
</template>
<style scoped>
.editor {
height: 100%;
overflow: auto;
}
.editor :deep(.CodeMirror) {
height: 100%;
}
</style>
4.2 预览组件 #
vue
<!-- src/components/Preview.vue -->
<script setup lang="ts">
import { computed } from 'vue';
import { marked } from 'marked';
import { useEditorStore } from '@/stores/editor';
const store = useEditorStore();
const html = computed(() => {
return marked(store.content);
});
</script>
<template>
<div class="preview" v-html="html"></div>
</template>
<style scoped>
.preview {
height: 100%;
padding: 20px;
overflow: auto;
}
.preview :deep(h1) {
font-size: 2em;
border-bottom: 1px solid #eee;
padding-bottom: 0.3em;
}
.preview :deep(h2) {
font-size: 1.5em;
border-bottom: 1px solid #eee;
padding-bottom: 0.3em;
}
.preview :deep(code) {
background: #f4f4f4;
padding: 2px 6px;
border-radius: 3px;
}
.preview :deep(pre code) {
display: block;
padding: 16px;
overflow-x: auto;
}
</style>
4.3 工具栏组件 #
vue
<!-- src/components/Toolbar.vue -->
<script setup lang="ts">
import { useEditorStore } from '@/stores/editor';
import { useFile } from '@/composables/useFile';
const store = useEditorStore();
const { newFile, openFile, saveFile, exportPDF } = useFile();
const handleNew = async () => {
if (store.modified) {
const confirm = window.confirm('文件未保存,是否继续?');
if (!confirm) return;
}
await newFile();
};
const handleOpen = async () => {
if (store.modified) {
const confirm = window.confirm('文件未保存,是否继续?');
if (!confirm) return;
}
await openFile();
};
const handleSave = async () => {
await saveFile();
};
const handleExport = async () => {
await exportPDF();
};
const toggleTheme = () => {
store.setTheme(store.theme === 'light' ? 'dark' : 'light');
};
</script>
<template>
<div class="toolbar">
<div class="toolbar-left">
<button @click="handleNew" title="新建 (Ctrl+N)">
<span class="icon">📄</span>
</button>
<button @click="handleOpen" title="打开 (Ctrl+O)">
<span class="icon">📂</span>
</button>
<button @click="handleSave" title="保存 (Ctrl+S)">
<span class="icon">💾</span>
</button>
<span class="separator"></span>
<button @click="handleExport" title="导出PDF">
<span class="icon">📑</span>
</button>
</div>
<div class="toolbar-right">
<button @click="toggleTheme" :title="store.theme === 'light' ? '深色模式' : '浅色模式'">
<span class="icon">{{ store.theme === 'light' ? '🌙' : '☀️' }}</span>
</button>
</div>
</div>
</template>
<style scoped>
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background: #f5f5f5;
border-bottom: 1px solid #ddd;
}
.toolbar button {
padding: 6px 12px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 4px;
}
.toolbar button:hover {
background: #e0e0e0;
}
.separator {
width: 1px;
height: 24px;
background: #ddd;
margin: 0 8px;
}
</style>
4.4 状态管理 #
typescript
// src/stores/editor.ts
import { defineStore } from 'pinia';
interface EditorState {
content: string;
filePath: string | null;
modified: boolean;
theme: 'light' | 'dark';
}
export const useEditorStore = defineStore('editor', {
state: (): EditorState => ({
content: '',
filePath: null,
modified: false,
theme: 'light'
}),
actions: {
setContent(content: string) {
this.content = content;
this.modified = true;
},
setFilePath(path: string | null) {
this.filePath = path;
},
setModified(modified: boolean) {
this.modified = modified;
},
setTheme(theme: 'light' | 'dark') {
this.theme = theme;
},
reset() {
this.content = '';
this.filePath = null;
this.modified = false;
}
}
});
4.5 文件操作组合式函数 #
typescript
// src/composables/useFile.ts
import { useEditorStore } from '@/stores/editor';
import { marked } from 'marked';
export function useFile() {
const store = useEditorStore();
const newFile = async () => {
store.reset();
};
const openFile = async () => {
const result = await window.electronAPI.file.open();
if (result) {
store.setContent(result.content);
store.setFilePath(result.path);
store.setModified(false);
}
};
const saveFile = async () => {
const path = await window.electronAPI.file.save(
store.filePath,
store.content
);
if (path) {
store.setFilePath(path);
store.setModified(false);
}
};
const exportPDF = async () => {
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: Arial, sans-serif; padding: 40px; }
h1, h2, h3 { color: #333; }
code { background: #f4f4f4; padding: 2px 6px; }
pre { background: #f4f4f4; padding: 16px; }
</style>
</head>
<body>${marked(store.content)}</body>
</html>
`;
await window.electronAPI.file.exportPDF(html);
};
return { newFile, openFile, saveFile, exportPDF };
}
五、打包发布 #
5.1 package.json #
json
{
"name": "markdown-editor",
"version": "1.0.0",
"main": "dist-electron/main.js",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build && electron-builder",
"preview": "vite preview"
}
}
5.2 electron-builder.yml #
yaml
appId: com.example.markdown-editor
productName: Markdown Editor
directories:
output: release
files:
- dist/**/*
- dist-electron/**/*
mac:
icon: build/icon.icns
category: public.app-category.productivity
win:
icon: build/icon.ico
linux:
icon: build/icon.png
category: Utility
六、总结 #
6.1 项目要点 #
| 要点 | 说明 |
|---|---|
| 项目结构 | 清晰的目录组织 |
| IPC 通信 | 类型安全的进程间通信 |
| 状态管理 | Pinia 管理应用状态 |
| 组件化 | Vue 组件化开发 |
| 打包发布 | electron-builder 打包 |
6.2 扩展方向 #
text
- 添加更多 Markdown 语法支持
- 集成云同步功能
- 添加图片管理
- 支持多标签页
- 添加插件系统
恭喜你完成了 Electron 完全指南的学习!现在你已经掌握了 Electron 开发的全部核心知识,可以开始开发自己的桌面应用了!
最后更新:2026-03-28