Go語(yǔ)言結(jié)合Wails構(gòu)建一個(gè)本地筆記工具
本文基于 Wails v2.9+ / Go 1.22+,所有代碼均可直接運(yùn)行。不是教程,是實(shí)戰(zhàn)復(fù)盤(pán)。
痛點(diǎn):Electron 太重,原生 GUI 太難
作為一個(gè) Go 開(kāi)發(fā)者,我一直想寫(xiě)個(gè)桌面工具——不是 Web 套殼,不是 Electron(吃內(nèi)存大戶),更不是 C++ 套 Qt(學(xué)習(xí)曲線陡峭到懷疑人生)。
直到我遇到了 Wails:用 Go 寫(xiě)后端邏輯,用 Web 技術(shù)(Vue/React/Svelte/純 HTML)寫(xiě)前端,編譯出來(lái)一個(gè)十幾 MB 的原生二進(jìn)制文件。
今天這篇文章,不講 Hello World,直接帶你從 0 到 1 構(gòu)建一個(gè)本地 Markdown 筆記應(yīng)用,包含:
- 文件讀寫(xiě)(本地存儲(chǔ),不依賴數(shù)據(jù)庫(kù))
- Go 后端與前端的雙向通信
- 全局快捷鍵支持
- 打包發(fā)布(Windows/macOS/Linux)
一、Wails 的核心架構(gòu):不是 Electron,但有 Electron 的爽
Wails 的原理很簡(jiǎn)單:
┌─────────────────────────────────────┐
│ 前端 (Vue/React/HTML) │
│ 運(yùn)行在 WebView2 / WebKit │
├─────────────────────────────────────┤
│ Runtime Bridge (JS ↔ Go) │
│ 自動(dòng)綁定,無(wú)需手寫(xiě)膠水代碼 │
├─────────────────────────────────────┤
│ 后端 Go 邏輯 │
│ 文件 IO / HTTP / 系統(tǒng)調(diào)用 │
└─────────────────────────────────────┘
跟 Electron 的本質(zhì)區(qū)別:
| 對(duì)比項(xiàng) | Electron | Wails |
|---|---|---|
| 運(yùn)行時(shí) | 內(nèi)嵌 Chromium + Node.js | 系統(tǒng)原生 WebView |
| 包體積 | 150MB+ | 10~20MB |
| 內(nèi)存占用 | 200MB | 80MB |
| 后端語(yǔ)言 | JavaScript/TypeScript | Go |
| 跨平臺(tái) | ? | ? |
結(jié)論:Wails 不是 Electron 的替代品,它是給 Go 開(kāi)發(fā)者的桌面應(yīng)用捷徑。
二、項(xiàng)目初始化
# 安裝 wails CLI go install github.com/wailsapp/wails/v2/cmd/wails@latest # 創(chuàng)建項(xiàng)目(選 Vue + TypeScript 模板) wails init -n wails-notes -t vue-ts cd wails-notes
目錄結(jié)構(gòu):
wails-notes/
├── main.go # 入口
├── wails.json # 項(xiàng)目配置
├── app/
│ └── app.go # Go 后端邏輯(核心)
├── frontend/
│ ├── src/
│ │ ├── main.ts
│ │ ├── App.vue
│ │ └── components/
│ └── package.json
└── build/
└── ...
三、Go 后端:實(shí)現(xiàn)筆記的核心邏輯
app/app.go 是我們的核心。Wails 的規(guī)矩:在結(jié)構(gòu)體方法上加注釋 //go:build wails,編譯時(shí)自動(dòng)生成前端 TypeScript 綁定。
package app
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// Note 筆記結(jié)構(gòu)
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 后端應(yīng)用結(jié)構(gòu)體
type App struct {
ctx context.Context
mu sync.RWMutex
notes []*Note
}
// NewApp 構(gòu)造函數(shù)
func NewApp() *App {
return &App{
notes: make([]*Note, 0),
}
}
// startup 是 Wails 生命周期鉤子
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
// 啟動(dòng)時(shí)自動(dòng)加載筆記目錄
a.loadNotesFromDir(a.getDefaultDir())
}
// getDefaultDir 獲取默認(rèn)筆記目錄
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("創(chuàng)建目錄失敗: %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 獲取所有筆記(前端可直接調(diào)用)
func (a *App) GetAllNotes() []*Note {
a.mu.RLock()
defer a.mu.RUnlock()
// 返回副本,避免并發(fā)問(wèn)題
result := make([]*Note, len(a.notes))
copy(result, a.notes)
return result
}
// CreateNote 創(chuàng)建筆記
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("寫(xiě)入文件失敗: %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)
}這里有個(gè)關(guān)鍵細(xì)節(jié)
注意 CreateNote 和 UpdateNote 中寫(xiě)文件的時(shí)機(jī)——先寫(xiě)磁盤(pán),再更新內(nèi)存。反過(guò)來(lái)也行,但必須保證一致性。很多新手會(huì)先更新內(nèi)存再寫(xiě)文件,一旦寫(xiě)文件失敗,內(nèi)存和磁盤(pán)就不一致了。
四、main.go:注冊(cè)后端 + 啟動(dòng)
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() {
// 創(chuàng)建應(yīng)用實(shí)例
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, // 注冊(cè)后端結(jié)構(gòu)體,自動(dòng)暴露方法到前端
},
})
if err != nil {
log.Fatal(err)
}
}五、前端:Vue 3 + TypeScript 調(diào)用 Go
Wails 編譯后會(huì)在 frontend/src/wailsjs/go/main/ 自動(dòng)生成 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)
}
}
// 創(chuàng)建新筆記
const createNewNote = async () => {
const title = prompt('筆記標(biāo)題:')
if (!title) return
const note = await CreateNote(title, '# ' + title + '\n\n開(kāi)始寫(xiě)作...')
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>← 選擇或創(chuàng)建一個(gè)筆記開(kāi)始寫(xiě)作</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>六、避坑指南(踩過(guò)的坑,你別再踩)
坑 1:Go 結(jié)構(gòu)體方法必須導(dǎo)出(首字母大寫(xiě))
Wails 只能綁定首字母大寫(xiě)的方法。func (a *App) getAllNotes() 不會(huì)被暴露,必須是 GetAllNotes()。我在這個(gè)坑里花了 20 分鐘,因?yàn)?Go 寫(xiě)習(xí)慣了私有方法。
坑 2:前端調(diào)用返回的是 Promise
所有 Go 方法在前端都是異步的。GetAllNotes() 返回 Promise<Note[]>,必須 await。有人直接 notes.value = GetAllNotes() 然后說(shuō) "Wails 的綁定壞了"。
坑 3:macOS 打包需要簽名
wails build -platform darwin/universal
如果不簽名,用戶打開(kāi)會(huì)報(bào)"無(wú)法驗(yàn)證開(kāi)發(fā)者"。本地開(kāi)發(fā)無(wú)所謂,但發(fā)布時(shí)必須去 Apple Developer 申請(qǐng)證書(shū),或者讓用戶右鍵 → 打開(kāi)。
坑 4:并發(fā)寫(xiě)文件要加鎖
筆記應(yīng)用看起來(lái)簡(jiǎn)單,但如果你加了自動(dòng)保存(每 30 秒寫(xiě)一次),多個(gè) goroutine 同時(shí)寫(xiě)同一個(gè)文件就會(huì) panic。sync.RWMutex 是標(biāo)配,別偷懶。
坑 5:WebView 的 CORS 問(wèn)題
開(kāi)發(fā)模式下 Wails 自動(dòng)處理了 CORS,但如果你從 Go 后端調(diào)外部 API(比如圖床),記得在 options.App 里配置:
options.App{
// ...
DisableFramelessWindowDecorations: false,
}七、打包發(fā)布
# Windows wails build -platform windows/amd64 # macOS (Universal) wails build -platform darwin/universal # Linux wails build -platform linux/amd64
打包出來(lái)的產(chǎn)物:
- Windows:
wails-notes.exe(~12MB) - macOS:
wails-notes.app(~15MB) - Linux:
wails-notes(~10MB)
對(duì)比 Electron 的 150MB+,這就是 Go 的魅力。
八、還能做什么?
這個(gè)項(xiàng)目只是一個(gè)起點(diǎn)。下一步可以加:
- Markdown 實(shí)時(shí)預(yù)覽:集成
marked.js或markdown-it - 全文搜索:Go 端用
bleve建索引 - 云同步:通過(guò) S3/OSS 做端到端加密同步
- 插件系統(tǒng):用 Go 的
plugin包或 gRPC 擴(kuò)展功能
Wails 的真正價(jià)值不在于"寫(xiě)個(gè)桌面應(yīng)用",而在于用你最熟悉的 Go 語(yǔ)言,快速驗(yàn)證桌面端的產(chǎn)品想法。不需要學(xué)前端框架、不需要學(xué)原生 GUI 庫(kù)、不需要搞構(gòu)建工具鏈——Go 寫(xiě)后端,Vue/React 寫(xiě)前端,完事。
總結(jié)
Wails v2 已經(jīng)足夠成熟,社區(qū)活躍,文檔完善。如果你是一個(gè) Go 開(kāi)發(fā)者,想寫(xiě)桌面工具但沒(méi)有精力從頭學(xué)一套 GUI 框架,Wails 是目前最好的選擇。
以上就是Go語(yǔ)言結(jié)合Wails構(gòu)建一個(gè)本地筆記工具的詳細(xì)內(nèi)容,更多關(guān)于Go語(yǔ)言構(gòu)建本地筆記工具的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
- Go?Wails開(kāi)發(fā)桌面應(yīng)用使用示例探索
- Golang WebView跨平臺(tái)的桌面應(yīng)用庫(kù)的使用
- 使用Go開(kāi)發(fā)一個(gè)文件同步小工具(附源碼)
- 基于Go語(yǔ)言實(shí)現(xiàn)一個(gè)簡(jiǎn)易遠(yuǎn)程傳屏工具
- 輕松管理多個(gè)Go版本之g工具安裝與使用方法
- 基于Go語(yǔ)言開(kāi)發(fā)一個(gè) IP 歸屬地查詢接口工具
- 基于Go語(yǔ)言開(kāi)發(fā)篇一個(gè)命令行進(jìn)程監(jiān)控工具
- Go語(yǔ)言開(kāi)發(fā)實(shí)現(xiàn)一個(gè)圖片批量壓縮工具
相關(guān)文章
go語(yǔ)言實(shí)現(xiàn)短信發(fā)送實(shí)例探究
這篇文章主要為大家介紹了go語(yǔ)言實(shí)現(xiàn)短信發(fā)送實(shí)例探究,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2024-01-01
Golang設(shè)計(jì)模式中的橋接模式詳細(xì)講解
橋接模式是一種結(jié)構(gòu)型設(shè)計(jì)模式,通過(guò)橋接模式可以將抽象部分和它的實(shí)現(xiàn)部分分離,本文主要介紹了GoLang橋接模式,文中通過(guò)示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2023-01-01
go原子級(jí)內(nèi)存操作實(shí)現(xiàn)
原子級(jí)內(nèi)存操作是在多線程并發(fā)執(zhí)行時(shí),能夠確保某個(gè)內(nèi)存操作是不可中斷的操作,本文主要介紹了go原子級(jí)內(nèi)存操作實(shí)現(xiàn),具有一定的參考價(jià)值,感興趣的可以了解一下2024-02-02
Go語(yǔ)言fmt庫(kù)詳解與應(yīng)用實(shí)例(格式化輸入輸出功能)
fmt庫(kù)是Go語(yǔ)言中一個(gè)強(qiáng)大而靈活的庫(kù),提供了豐富的格式化輸入輸出功能,通過(guò)本文的介紹和實(shí)例演示,相信你對(duì)fmt庫(kù)的使用有了更深的理解,感興趣的朋友一起看看吧2023-10-10

