Wails v2 实战:用 Go 写桌面应用,从 0 到 1 构建一个本地笔记工具

0 阅读7分钟

本文基于 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)

}

这里有个关键细节

注意 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 的魅力。


八、还能做什么?

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

  1. Markdown 实时预览:集成 marked.jsmarkdown-it

  2. 全文搜索:Go 端用 bleve 建索引

  3. 云同步:通过 S3/OSS 做端到端加密同步

  4. 插件系统:用 Go 的 plugin 包或 gRPC 扩展功能

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


总结

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

代码已完整贴出,wails init 后直接替换对应文件即可跑起来。有问题评论区见。