本文基于 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 的本质区别:
| 对比项 | Electron | Wails |
|-------|---------|-------|
| 运行时 | 内嵌 Chromium + Node.js | 系统原生 WebView |
| 包体积 | 150MB+ | 10~20MB |
| 内存占用 | 200MB500MB | 30MB80MB |
| 后端语言 | JavaScript/TypeScript | Go |
| 跨平台 | ✅ | ✅ |
结论: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)
}
这里有个关键细节
注意 CreateNote 和 UpdateNote 中写文件的时机——先写磁盘,再更新内存。反过来也行,但必须保证一致性。很多新手会先更新内存再写文件,一旦写文件失败,内存和磁盘就不一致了。
四、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.js或markdown-it -
全文搜索:Go 端用
bleve建索引 -
云同步:通过 S3/OSS 做端到端加密同步
-
插件系统:用 Go 的
plugin包或 gRPC 扩展功能
Wails 的真正价值不在于"写个桌面应用",而在于用你最熟悉的 Go 语言,快速验证桌面端的产品想法。不需要学前端框架、不需要学原生 GUI 库、不需要搞构建工具链——Go 写后端,Vue/React 写前端,完事。
总结
Wails v2 已经足够成熟,社区活跃,文档完善。如果你是一个 Go 开发者,想写桌面工具但没有精力从头学一套 GUI 框架,Wails 是目前最好的选择。
代码已完整贴出,wails init 后直接替换对应文件即可跑起来。有问题评论区见。