实战案例 #

一、项目概述 #

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