Go语言结合Wails构建一个本地笔记工具

 更新时间:2026年04月22日 08:55:30   作者:扉页的墨  
这篇文章主要为大家详细介绍了Go语言如何结合Wails构建一个本地笔记工具,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下

本文基于 Wails v2.9+ / Go 1.22+,所有代码均可直接运行。不是教程,是实战复盘。

痛点:Electron 太重,原生 GUI 太难

作为一个 Go 开发者,我一直想写个桌面工具——不是 Web 套壳,不是 Electron(吃内存大户),更不是 C++ 套 Qt(学习曲线陡峭到怀疑人生)。

直到我遇到了 Wails:用 Go 写后端逻辑,用 Web 技术(Vue/React/Svelte/纯 HTML)写前端,编译出来一个十几 MB 的原生二进制文件。

今天这篇文章,不讲 Hello World,直接带你从 0 到 1 构建一个本地 Markdown 笔记应用,包含:

  • 文件读写(本地存储,不依赖数据库)
  • Go 后端与前端的双向通信
  • 全局快捷键支持
  • 打包发布(Windows/macOS/Linux)

一、Wails 的核心架构:不是 Electron,但有 Electron 的爽

Wails 的原理很简单:

┌─────────────────────────────────────┐
│ 前端 (Vue/React/HTML) │
│ 运行在 WebView2 / WebKit │
├─────────────────────────────────────┤
│ Runtime Bridge (JS ↔ Go) │
│ 自动绑定,无需手写胶水代码 │
├─────────────────────────────────────┤
│ 后端 Go 逻辑 │
│ 文件 IO / HTTP / 系统调用 │
└─────────────────────────────────────┘

跟 Electron 的本质区别:

对比项ElectronWails
运行时内嵌 Chromium + Node.js系统原生 WebView
包体积150MB+10~20MB
内存占用200MB80MB
后端语言JavaScript/TypeScriptGo
跨平台

结论:Wails 不是 Electron 的替代品,它是给 Go 开发者的桌面应用捷径。

二、项目初始化

# 安装 wails CLI
go install github.com/wailsapp/wails/v2/cmd/wails@latest
# 创建项目(选 Vue + TypeScript 模板)
wails init -n wails-notes -t vue-ts
cd wails-notes

目录结构:

wails-notes/
├── main.go # 入口
├── wails.json # 项目配置
├── app/
│ └── app.go # Go 后端逻辑(核心)
├── frontend/
│ ├── src/
│ │ ├── main.ts
│ │ ├── App.vue
│ │ └── components/
│ └── package.json
└── build/
└── ...

三、Go 后端:实现笔记的核心逻辑

app/app.go 是我们的核心。Wails 的规矩:在结构体方法上加注释 //go:build wails,编译时自动生成前端 TypeScript 绑定。

package app
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// Note 笔记结构
type Note struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
FilePath string `json:"file_path"`
}
// App 是 Wails 后端应用结构体
type App struct {
ctx context.Context
mu sync.RWMutex
notes []*Note
}
// NewApp 构造函数
func NewApp() *App {
return &App{
notes: make([]*Note, 0),
}
}
// startup 是 Wails 生命周期钩子
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
// 启动时自动加载笔记目录
a.loadNotesFromDir(a.getDefaultDir())
}
// getDefaultDir 获取默认笔记目录
func (a *App) getDefaultDir() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, "wails-notes-data")
}
// loadNotesFromDir 从目录加载所有 .md 文件
func (a *App) loadNotesFromDir(dir string) error {
// 确保目录存在
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("创建目录失败: %w", err)
}
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
a.mu.Lock()
defer a.mu.Unlock()
a.notes = make([]*Note, 0)
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}
content, err := os.ReadFile(filepath.Join(dir, entry.Name()))
if err != nil {
continue
}
info, _ := entry.Info()
note := &Note{
ID: strings.TrimSuffix(entry.Name(), ".md"),
Title: strings.TrimSuffix(entry.Name(), ".md"),
Content: string(content),
CreatedAt: info.ModTime(),
UpdatedAt: info.ModTime(),
FilePath: filepath.Join(dir, entry.Name()),
}
a.notes = append(a.notes, note)
}
return nil
}
// GetAllNotes 获取所有笔记(前端可直接调用)
func (a *App) GetAllNotes() []*Note {
a.mu.RLock()
defer a.mu.RUnlock()
// 返回副本,避免并发问题
result := make([]*Note, len(a.notes))
copy(result, a.notes)
return result
}
// CreateNote 创建笔记
func (a *App) CreateNote(title, content string) (*Note, error) {
a.mu.Lock()
defer a.mu.Unlock()
id := fmt.Sprintf("%d", time.Now().UnixNano())
filePath := filepath.Join(a.getDefaultDir(), id+".md")
data := []byte(content)
if err := os.WriteFile(filePath, data, 0644); err != nil {
return nil, fmt.Errorf("保存文件失败: %w", err)
}
note := &Note{
ID: id,
Title: title,
Content: content,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
FilePath: filePath,
}
a.notes = append(a.notes, note)
return note, nil
}
// UpdateNote 更新笔记
func (a *App) UpdateNote(id, title, content string) error {
a.mu.Lock()
defer a.mu.Unlock()
for i, note := range a.notes {
if note.ID == id {
note.Title = title
note.Content = content
note.UpdatedAt = time.Now()
if err := os.WriteFile(note.FilePath, []byte(content), 0644); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
a.notes[i] = note
return nil
}
}
return fmt.Errorf("笔记不存在: %s", id)
}
// DeleteNote 删除笔记
func (a *App) DeleteNote(id string) error {
a.mu.Lock()
defer a.mu.Unlock()
for i, note := range a.notes {
if note.ID == id {
os.Remove(note.FilePath)
a.notes = append(a.notes[:i], a.notes[i+1:]...)
return nil
}
}
return fmt.Errorf("笔记不存在: %s", id)
}

这里有个关键细节

注意 CreateNoteUpdateNote 中写文件的时机——先写磁盘,再更新内存。反过来也行,但必须保证一致性。很多新手会先更新内存再写文件,一旦写文件失败,内存和磁盘就不一致了。

四、main.go:注册后端 + 启动

package main
import (
"embed"
"log"
"wails-notes/app"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
)
//go:embed all:frontend/dist
var assets embed.FS
func main() {
// 创建应用实例
application := app.NewApp()
err := wails.Run(&options.App{
Title: "Wails Notes",
Width: 1024,
Height: 768,
MinWidth: 800,
MinHeight: 600,
AssetServer: &assetserver.Options{
Assets: assets,
},
OnStartup: application.Startup, // 绑定生命周期
OnBeforeClose: application.beforeClose,
Bind: []interface{}{
application, // 注册后端结构体,自动暴露方法到前端
},
})
if err != nil {
log.Fatal(err)
}
}

五、前端:Vue 3 + TypeScript 调用 Go

Wails 编译后会在 frontend/src/wailsjs/go/main/ 自动生成 TypeScript 绑定文件,直接 import 即可。

<!-- frontend/src/App.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { GetAllNotes, CreateNote, UpdateNote, DeleteNote } from '../wailsjs/go/main/App'
interface Note {
id: string
title: string
content: string
created_at: string
updated_at: string
}
const notes = ref<Note[]>([])
const selectedNote = ref<Note | null>(null)
const editingContent = ref('')
const editingTitle = ref('')
const showEditor = ref(false)
// 加载笔记列表
const loadNotes = async () => {
try {
notes.value = await GetAllNotes()
} catch (err) {
console.error('加载笔记失败:', err)
}
}
// 创建新笔记
const createNewNote = async () => {
const title = prompt('笔记标题:')
if (!title) return
const note = await CreateNote(title, '# ' + title + '\n\n开始写作...')
if (note) {
notes.value.push(note)
selectNote(note)
}
}
// 选择笔记
const selectNote = (note: Note) => {
selectedNote.value = note
editingContent.value = note.content
editingTitle.value = note.title
showEditor.value = true
}
// 保存笔记
const saveNote = async () => {
if (!selectedNote.value) return
await UpdateNote(selectedNote.value.id, editingTitle.value, editingContent.value)
await loadNotes() // 刷新列表
}
// 删除笔记
const deleteNote = async (id: string) => {
if (!confirm('确定删除?')) return
await DeleteNote(id)
showEditor.value = false
selectedNote.value = null
await loadNotes()
}
onMounted(() => {
loadNotes()
})
</script>
<template>
<div class="app">
<div class="sidebar">
<button class="btn-new" @click="createNewNote">+ 新建笔记</button>
<ul class="note-list">
<li
v-for="note in notes"
:key="note.id"
:class="{ active: selectedNote?.id === note.id }"
@click="selectNote(note)"
>
<span class="note-title">{{ note.title }}</span>
<button class="btn-del" @click.stop="deleteNote(note.id)">✕</button>
</li>
</ul>
</div>
<div class="editor" v-if="showEditor">
<input v-model="editingTitle" class="title-input" />
<textarea v-model="editingContent" class="content-input" />
<button class="btn-save" @click="saveNote">保存</button>
</div>
<div class="empty" v-else>
<p>← 选择或创建一个笔记开始写作</p>
</div>
</div>
</template>
<style scoped>
.app { display: flex; height: 100vh; font-family: -apple-system, sans-serif; }
.sidebar { width: 260px; border-right: 1px solid #e0e0e0; padding: 16px; background: #fafafa; }
.btn-new {
width: 100%; padding: 10px; background: #1976d2; color: white;
border: none; border-radius: 6px; cursor: pointer; font-size: 14px;
}
.note-list { list-style: none; padding: 0; margin-top: 12px; }
.note-list li {
padding: 8px 12px; cursor: pointer; border-radius: 4px;
display: flex; justify-content: space-between; align-items: center;
}
.note-list li:hover { background: #e3f2fd; }
.note-list li.active { background: #bbdefb; }
.btn-del {
background: none; border: none; color: #999; cursor: pointer; font-size: 12px;
}
.btn-del:hover { color: #f44336; }
.editor { flex: 1; display: flex; flex-direction: column; padding: 20px; }
.title-input {
font-size: 24px; border: none; border-bottom: 2px solid #1976d2;
padding: 8px 0; margin-bottom: 16px; outline: none;
}
.content-input {
flex: 1; border: 1px solid #e0e0e0; border-radius: 8px;
padding: 16px; font-size: 15px; line-height: 1.6; resize: none;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.btn-save {
margin-top: 12px; padding: 10px 24px; background: #4caf50; color: white;
border: none; border-radius: 6px; cursor: pointer; align-self: flex-end;
}
.empty { flex: 1; display: flex; align-items: center; justify-content: center; color: #999; }
</style>

六、避坑指南(踩过的坑,你别再踩)

坑 1:Go 结构体方法必须导出(首字母大写)

Wails 只能绑定首字母大写的方法。func (a *App) getAllNotes() 不会被暴露,必须是 GetAllNotes()。我在这个坑里花了 20 分钟,因为 Go 写习惯了私有方法。

坑 2:前端调用返回的是 Promise

所有 Go 方法在前端都是异步的。GetAllNotes() 返回 Promise<Note[]>,必须 await。有人直接 notes.value = GetAllNotes() 然后说 "Wails 的绑定坏了"。

坑 3:macOS 打包需要签名

wails build -platform darwin/universal

如果不签名,用户打开会报"无法验证开发者"。本地开发无所谓,但发布时必须去 Apple Developer 申请证书,或者让用户右键 → 打开。

坑 4:并发写文件要加锁

笔记应用看起来简单,但如果你加了自动保存(每 30 秒写一次),多个 goroutine 同时写同一个文件就会 panic。sync.RWMutex 是标配,别偷懒。

坑 5:WebView 的 CORS 问题

开发模式下 Wails 自动处理了 CORS,但如果你从 Go 后端调外部 API(比如图床),记得在 options.App 里配置:

options.App{
// ...
DisableFramelessWindowDecorations: false,
}

七、打包发布

# Windows
wails build -platform windows/amd64
# macOS (Universal)
wails build -platform darwin/universal
# Linux
wails build -platform linux/amd64

打包出来的产物:

  • Windows: wails-notes.exe(~12MB)
  • macOS: wails-notes.app(~15MB)
  • Linux: wails-notes(~10MB)

对比 Electron 的 150MB+,这就是 Go 的魅力。

八、还能做什么?

这个项目只是一个起点。下一步可以加:

  • Markdown 实时预览:集成 marked.jsmarkdown-it
  • 全文搜索:Go 端用 bleve 建索引
  • 云同步:通过 S3/OSS 做端到端加密同步
  • 插件系统:用 Go 的 plugin 包或 gRPC 扩展功能

Wails 的真正价值不在于"写个桌面应用",而在于用你最熟悉的 Go 语言,快速验证桌面端的产品想法。不需要学前端框架、不需要学原生 GUI 库、不需要搞构建工具链——Go 写后端,Vue/React 写前端,完事。

总结

Wails v2 已经足够成熟,社区活跃,文档完善。如果你是一个 Go 开发者,想写桌面工具但没有精力从头学一套 GUI 框架,Wails 是目前最好的选择。

以上就是Go语言结合Wails构建一个本地笔记工具的详细内容,更多关于Go语言构建本地笔记工具的资料请关注脚本之家其它相关文章!

相关文章

  • Go语言使用goimports格式化代码的完整教学

    Go语言使用goimports格式化代码的完整教学

    goimports 是 Go 语言中一个非常常用的工具,用于自动格式化 Go 源代码并管理 import 声明,下面小编就和大家详细介绍一下如何使用goimports进行格式化代码吧
    2026-01-01
  • Go语言中的速率限流策略全面详解

    Go语言中的速率限流策略全面详解

    这篇文章主要为大家介绍了Go语言中的速率限流策略全面详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2023-11-11
  • Go语言中的GitOps实战

    Go语言中的GitOps实战

    本文主要介绍了Go语言中的GitOps实战,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2026-04-04
  • Go结构体SliceHeader及StringHeader作用详解

    Go结构体SliceHeader及StringHeader作用详解

    这篇文章主要为大家介绍了Go结构体SliceHeader及StringHeader作用的功能及面试官爱问的实际意义详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-07-07
  • Go Web编程添加服务器错误和访问日志

    Go Web编程添加服务器错误和访问日志

    这篇文章主要为大家介绍了Go Web编程添加服务器错误日志和访问日志的示例解析,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-06-06
  • 浅谈Go语言不提供隐式数字转换的原因

    浅谈Go语言不提供隐式数字转换的原因

    本文主要介绍了浅谈Go语言不提供隐式数字转换的原因,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2023-03-03
  • Golang开发gRPC服务入门介绍

    Golang开发gRPC服务入门介绍

    这篇文章主要介绍了Golang开发gRPC服务,Golang开发gRPC应用程序的套路也已经很清晰,这篇文章就来做一个简单的介绍,算是入门,需要的朋友可以参考下
    2022-04-04
  • 详解Go语言中make和new的区别

    详解Go语言中make和new的区别

    Go语言中,有两个比较雷同的内置函数,分别是new和make方法,那他们有什么区别呢?本文将通过一些示例为大家详细介绍一下,感兴趣的可以了解一下
    2023-02-02
  • Go Web服务优雅平滑重启

    Go Web服务优雅平滑重启

    在生产环境中,当我们需要对正在运行的服务进行升级时,如何确保不影响当前未处理完的请求,同时又能应用新的代码,下面就来介绍一下Go Web服务优雅平滑重启,感兴趣的可以了解一下
    2025-09-09
  • Go 中的Map与字符处理指南

    Go 中的Map与字符处理指南

    在Go中,map可以存储字符,但需要理解字符在Go中的表示方式,本文给大家介绍Go中的Map与字符处理指南,本文给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友参考下吧
    2025-06-06

最新评论