Skip to content

Electron 实战项目 - 构建完整的笔记应用

前言

理论学习很重要,但实战更能巩固知识。这篇文章将带你从零开始,构建一个功能完整的 Markdown 笔记应用,涵盖文件管理、编辑器集成、数据持久化等核心功能。

项目规划

功能需求

核心功能:
├── 笔记管理
│   ├── 创建笔记
│   ├── 编辑笔记
│   ├── 删除笔记
│   └── 搜索笔记
├── Markdown 编辑
│   ├── 实时预览
│   ├── 语法高亮
│   └── 快捷键支持
├── 文件系统
│   ├── 保存到本地
│   ├── 导入导出
│   └── 文件夹管理
└── 界面功能
    ├── 分栏布局
    ├── 主题切换
    └── 全屏模式

技术栈

前端:
- React
- Marked.js(Markdown 解析)
- CodeMirror(编辑器)
- TailwindCSS(样式)

后端:
- Electron
- Node.js fs 模块
- lowdb(本地数据库)

项目搭建

bash
# 创建项目
npm create @quick-start/electron my-notes

# 安装依赖
cd my-notes
npm install marked codemirror lowdb@5.0.0

# 启动开发
npm run dev

数据模型

javascript
// src/main/database.js
const { Low } = require('lowdb')
const { JSONFile } = require('lowdb/node')
const path = require('path')
const { app } = require('electron')

class Database {
  constructor() {
    const dbPath = path.join(app.getPath('userData'), 'notes.json')
    const adapter = new JSONFile(dbPath)
    this.db = new Low(adapter)
    this.init()
  }

  async init() {
    await this.db.read()
    this.db.data = this.db.data || { notes: [], settings: {} }
    await this.db.write()
  }

  async getNotes() {
    await this.db.read()
    return this.db.data.notes
  }

  async createNote(note) {
    await this.db.read()
    const newNote = {
      id: Date.now().toString(),
      title: note.title,
      content: note.content,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString()
    }
    this.db.data.notes.push(newNote)
    await this.db.write()
    return newNote
  }

  async updateNote(id, updates) {
    await this.db.read()
    const note = this.db.data.notes.find(n => n.id === id)
    if (note) {
      Object.assign(note, updates, {
        updatedAt: new Date().toISOString()
      })
      await this.db.write()
      return note
    }
    throw new Error('Note not found')
  }

  async deleteNote(id) {
    await this.db.read()
    const index = this.db.data.notes.findIndex(n => n.id === id)
    if (index !== -1) {
      this.db.data.notes.splice(index, 1)
      await this.db.write()
      return true
    }
    return false
  }
}

module.exports = new Database()

主进程实现

javascript
// src/main/main.js
const { app, BrowserWindow, ipcMain, Menu } = require('electron')
const path = require('path')
const database = require('./database')

let mainWindow

function createWindow() {
  mainWindow = new BrowserWindow({
    width: 1200,
    height: 800,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false
    }
  })

  mainWindow.loadFile('index.html')
  createMenu()
}

function createMenu() {
  const template = [
    {
      label: '文件',
      submenu: [
        {
          label: '新建笔记',
          accelerator: 'CmdOrCtrl+N',
          click: () => {
            mainWindow.webContents.send('menu:new-note')
          }
        },
        {
          label: '保存',
          accelerator: 'CmdOrCtrl+S',
          click: () => {
            mainWindow.webContents.send('menu:save')
          }
        },
        { type: 'separator' },
        {
          label: '退出',
          accelerator: 'CmdOrCtrl+Q',
          click: () => app.quit()
        }
      ]
    },
    {
      label: '编辑',
      submenu: [
        { role: 'undo' },
        { role: 'redo' },
        { type: 'separator' },
        { role: 'cut' },
        { role: 'copy' },
        { role: 'paste' }
      ]
    }
  ]

  const menu = Menu.buildFromTemplate(template)
  Menu.setApplicationMenu(menu)
}

// IPC 处理器
ipcMain.handle('notes:getAll', async () => {
  return await database.getNotes()
})

ipcMain.handle('notes:create', async (event, note) => {
  return await database.createNote(note)
})

ipcMain.handle('notes:update', async (event, id, updates) => {
  return await database.updateNote(id, updates)
})

ipcMain.handle('notes:delete', async (event, id) => {
  return await database.deleteNote(id)
})

app.whenReady().then(createWindow)

前端实现

jsx
// src/renderer/App.jsx
import React, { useState, useEffect } from 'react'
import { marked } from 'marked'
import NoteList from './components/NoteList'
import Editor from './components/Editor'
import Preview from './components/Preview'

function App() {
  const [notes, setNotes] = useState([])
  const [currentNote, setCurrentNote] = useState(null)
  const [content, setContent] = useState('')

  useEffect(() => {
    loadNotes()

    window.electronAPI.onMenuCommand('new-note', handleNewNote)
    window.electronAPI.onMenuCommand('save', handleSave)
  }, [])

  const loadNotes = async () => {
    const allNotes = await window.electronAPI.getNotes()
    setNotes(allNotes)
  }

  const handleNewNote = async () => {
    const note = await window.electronAPI.createNote({
      title: '新笔记',
      content: ''
    })
    setNotes([...notes, note])
    setCurrentNote(note)
    setContent('')
  }

  const handleSave = async () => {
    if (!currentNote) return

    await window.electronAPI.updateNote(currentNote.id, {
      content,
      title: content.split('\n')[0].replace(/^#\s*/, '') || '无标题'
    })

    await loadNotes()
  }

  const handleSelectNote = (note) => {
    setCurrentNote(note)
    setContent(note.content)
  }

  const handleDeleteNote = async (id) => {
    await window.electronAPI.deleteNote(id)
    await loadNotes()
    if (currentNote?.id === id) {
      setCurrentNote(null)
      setContent('')
    }
  }

  return (
    <div className="app">
      <NoteList
        notes={notes}
        currentNote={currentNote}
        onSelect={handleSelectNote}
        onDelete={handleDeleteNote}
        onNew={handleNewNote}
      />
      <Editor
        value={content}
        onChange={setContent}
      />
      <Preview
        content={marked(content)}
      />
    </div>
  )
}

export default App

编辑器组件

jsx
// src/renderer/components/Editor.jsx
import React, { useEffect, useRef } from 'react'
import { EditorState } from '@codemirror/state'
import { EditorView, keymap } from '@codemirror/view'
import { defaultKeymap } from '@codemirror/commands'
import { markdown } from '@codemirror/lang-markdown'

function Editor({ value, onChange }) {
  const editorRef = useRef(null)
  const viewRef = useRef(null)

  useEffect(() => {
    if (!editorRef.current) return

    const startState = EditorState.create({
      doc: value,
      extensions: [
        keymap.of(defaultKeymap),
        markdown(),
        EditorView.updateListener.of(update => {
          if (update.docChanged) {
            onChange(update.state.doc.toString())
          }
        })
      ]
    })

    viewRef.current = new EditorView({
      state: startState,
      parent: editorRef.current
    })

    return () => {
      viewRef.current?.destroy()
    }
  }, [])

  return (
    <div className="editor">
      <div ref={editorRef} />
    </div>
  )
}

export default Editor

样式设计

css
/* src/renderer/styles.css */
.app {
  display: grid;
  grid-template-columns: 250px 1fr 1fr;
  height: 100vh;
  overflow: hidden;
}

.note-list {
  border-right: 1px solid #e5e5e5;
  overflow-y: auto;
  background: #f8f9fa;
}

.note-item {
  padding: 12px;
  border-bottom: 1px solid #e5e5e5;
  cursor: pointer;
  transition: background 0.2s;
}

.note-item:hover {
  background: #e9ecef;
}

.note-item.active {
  background: #dee2e6;
}

.editor, .preview {
  padding: 20px;
  overflow-y: auto;
}

.preview {
  border-left: 1px solid #e5e5e5;
  background: white;
}

打包配置

json
// package.json
{
  "name": "my-notes",
  "version": "1.0.0",
  "main": "src/main/main.js",
  "scripts": {
    "start": "electron .",
    "build": "electron-builder"
  },
  "build": {
    "appId": "com.example.my-notes",
    "productName": "My Notes",
    "directories": {
      "output": "dist"
    },
    "mac": {
      "category": "public.app-category.productivity"
    },
    "win": {
      "target": "nsis"
    },
    "linux": {
      "target": ["AppImage", "deb"]
    }
  }
}

总结

通过这个实战项目,我们学习了:

  • 完整的 Electron 项目结构
  • 数据持久化方案
  • Markdown 编辑器集成
  • 文件管理实现

相关文章推荐: