1. 为什么我最终选了 Wails v2 而不是 Electron
1.1 一个真实的需求场景
去年年底我接了个私活,客户想要一个本地跑的 AI 对话工具,核心诉求很明确:能调用本地大模型接口、能管理对话历史、能导出 Markdown、界面要好看、安装包不能太大。听起来不难,但我一开始用 Electron 做了一版,打包出来 180MB,客户当场就皱眉了——他说这玩意儿装完占的空间比他整个办公软件套装还大。
后来我换了个思路,用 Go 写后端逻辑,前端继续用 Vue3,中间用 Wails v2 做桥接。最终打包出来的 Windows 安装包只有 12MB,macOS 的 dmg 也就 15MB 左右。这个体积差异不是玄学,是实打实的架构差异带来的。
1.2 Wails v2 到底解决了什么问题
Electron 的本质是"把整个 Chromium 浏览器塞进你的应用里",所以不管你的业务逻辑多简单,光运行时就得占 100MB 起步。而 Wails v2 走的是另一条路:它用系统自带的 WebView 来渲染前端界面。Windows 上用 WebView2,macOS 上用 WKWebView,Linux 上用 WebKitGTK。这意味着你的应用不需要自带浏览器内核,体积自然就下来了。
但体积小只是表面优势,真正让我决定用 Wails 的是它的绑定机制。你在 Go 里写一个结构体方法,Wails 会自动生成对应的 TypeScript 类型定义和调用桩,前端直接import就能用,不需要手写任何 HTTP 接口或者 IPC 通信代码。这个体验比 Electron 的ipcRenderer.invoke清爽太多了。
注意:Wails v2 和 v3 的 API 差异很大,网上很多教程混着讲,你搜资料的时候一定要看清楚版本号。本文所有内容基于 v2.12 稳定版。
1.3 技术选型的对比逻辑
我把当时做的选型对比整理成了一张表,方便你参考:
| 维度 | Electron | Wails v2 | Tauri |
|---|---|---|---|
| 后端语言 | Node.js | Go | Rust |
| 渲染方式 | 自带 Chromium | 系统 WebView | 系统 WebView |
| 打包体积 | 80-200MB | 8-20MB | 5-15MB |
| 内存占用 | 150MB+ | 40-80MB | 30-60MB |
| 前端框架 | 任意 | 任意 | 任意 |
| 学习曲线 | 低 | 中(需会 Go) | 高(需会 Rust) |
| 生态成熟度 | 很高 | 中等 | 中等 |
选 Wails 而不是 Tauri,纯粹是因为我对 Go 更熟。Tauri 用 Rust 做后端,性能确实更好,但如果你不熟悉 Rust 的所有权模型,光是处理异步和生命周期就能耗掉大量时间。Go 的 goroutine 和 channel 在处理 AI 流式响应这种场景时特别顺手,代码写起来也直观。
2. 环境搭建与项目初始化
2.1 Go 环境与 Wails CLI 安装
先说 Go 的安装。Windows 用户直接去官网下 msi 安装包,一路下一步就行。macOS 用brew install go。Linux 用户注意了,很多发行版自带的 Go 版本太老,建议手动下载官方二进制包解压到/usr/local/go,然后把/usr/local/go/bin加到 PATH 里。
装完 Go 之后验证一下:
go version # 应该输出 go version go1.21.x 或更高Wails CLI 的安装命令很简单:
go install github.com/wailsapp/wails/v2/cmd/wails@latest装完之后跑wails doctor,这个命令会检查你的系统环境是否满足 Wails 的编译要求。它会告诉你 WebView2 运行时有没有装、gcc 有没有配好、Node.js 版本够不够。我强烈建议你在创建项目之前先跑一遍这个命令,把环境问题提前解决掉。
提示:Windows 上如果提示找不到 WebView2,去微软官网下载 Evergreen Bootstrapper 安装即可。Windows 11 一般自带,Windows 10 可能需要手动装。
2.2 创建项目与目录结构解读
初始化命令:
wails init -n ai-desktop -t vue-ts这里-t vue-ts指定用 Vue3 + TypeScript 模板。Wails 内置了好几种模板,包括 React、Svelte、Vanilla,但我建议你用 vue-ts,因为 TypeScript 的类型提示在调用 Go 方法时特别有用。
创建完的项目结构大概是这样:
ai-desktop/ ├── build/ # 打包相关资源 │ ├── appicon.png # 应用图标 │ └── windows/ # Windows 平台配置 ├── frontend/ # Vue3 前端项目 │ ├── src/ │ │ ├── App.vue │ │ ├── main.ts │ │ └── assets/ │ ├── package.json │ └── vite.config.ts ├── app.go # 主应用逻辑 ├── main.go # 入口文件 ├── go.mod └── wails.json # Wails 项目配置main.go是入口,负责创建应用实例、配置窗口参数、嵌入前端资源。app.go是你写业务逻辑的地方,里面定义的结构体方法会被自动绑定到前端。wails.json控制构建行为,比如前端构建命令、输出路径等。
2.3 前端依赖安装与开发模式启动
进入frontend目录装依赖:
cd frontend npm install如果你要用 SCSS,再装一下:
npm install -D sass回到项目根目录,启动开发模式:
wails dev这个命令会同时启动 Vite 开发服务器和 Go 后端,并且支持热重载。你改 Go 代码,它会自动重新编译;你改 Vue 代码,界面会即时刷新。第一次编译会下载一些依赖,耐心等一会儿。
注意:如果你在 Linux 上跑
wails dev报错说找不到libwebkit2gtk,需要先装系统依赖。Debian 系用apt install libwebkit2gtk-4.0-dev,Arch 系用pacman -S webkit2gtk。
3. Go 后端核心逻辑设计
3.1 应用结构体与生命周期
Wails 的核心概念是"把 Go 结构体的方法暴露给前端"。我们先定义一个 App 结构体:
package main import ( "context" "sync" ) type App struct { ctx context.Context mu sync.RWMutex messages []Message config AppConfig } type Message struct { Role string `json:"role"` Content string `json:"content"` Time int64 `json:"time"` } type AppConfig struct { APIEndpoint string `json:"apiEndpoint"` ModelName string `json:"modelName"` Temperature float64 `json:"temperature"` MaxTokens int `json:"maxTokens"` }这里有几个设计决策值得说明。第一,我用sync.RWMutex保护共享状态,因为 AI 对话是流式的,可能同时有多个 goroutine 在读写消息列表。第二,所有字段都加了jsontag,Wails 在序列化时会用到,前端拿到的就是标准的 JSON 对象。
生命周期钩子:
func (a *App) startup(ctx context.Context) { a.ctx = ctx a.loadConfig() a.loadHistory() } func (a *App) shutdown(ctx context.Context) { a.saveConfig() a.saveHistory() }startup在应用启动时调用,适合做初始化工作。shutdown在退出时调用,用来持久化数据。这两个方法名是 Wails 约定的,不需要手动注册。
3.2 流式响应的实现方式
AI 对话最核心的体验就是"打字机效果",也就是流式输出。在 Wails 里实现这个,需要用runtime.EventsEmit往前端推送事件。
import "github.com/wailsapp/wails/v2/pkg/runtime" func (a *App) SendMessage(content string) error { a.mu.Lock() a.messages = append(a.messages, Message{ Role: "user", Content: content, Time: time.Now().Unix(), }) a.mu.Unlock() go a.streamResponse(content) return nil } func (a *App) streamResponse(prompt string) { reader, err := a.callAIAPI(prompt) if err != nil { runtime.EventsEmit(a.ctx, "ai:error", err.Error()) return } defer reader.Close() buf := make([]byte, 1024) for { n, err := reader.Read(buf) if n > 0 { chunk := string(buf[:n]) runtime.EventsEmit(a.ctx, "ai:chunk", chunk) } if err != nil { break } } runtime.EventsEmit(a.ctx, "ai:done", nil) }关键点在于SendMessage立即返回,实际的网络请求放在 goroutine 里跑。前端通过监听ai:chunk事件来逐段更新界面。这样做的好处是 UI 不会卡死,用户可以随时取消或者发送新消息。
实操心得:
runtime.EventsEmit是线程安全的,可以在任意 goroutine 里调用。但要注意不要发送过大的数据块,建议每次 1-4KB,太大会导致前端渲染卡顿。
3.3 配置持久化与文件管理
配置和对话历史需要存到本地。我选择存在用户目录下的.ai-desktop文件夹里:
import ( "os" "path/filepath" "encoding/json" ) func (a *App) configPath() string { home, _ := os.UserHomeDir() dir := filepath.Join(home, ".ai-desktop") os.MkdirAll(dir, 0755) return filepath.Join(dir, "config.json") } func (a *App) saveConfig() error { data, err := json.MarshalIndent(a.config, "", " ") if err != nil { return err } return os.WriteFile(a.configPath(), data, 0644) } func (a *App) loadConfig() error { data, err := os.ReadFile(a.configPath()) if err != nil { a.config = defaultConfig() return nil } return json.Unmarshal(data, &a.config) }用os.UserHomeDir()而不是硬编码路径,这样跨平台都能正常工作。MkdirAll的第二个参数0755是权限位,表示所有者可读写执行,其他人可读可执行。
对话历史我建议用 JSONL 格式(每行一个 JSON 对象),而不是一个大 JSON 数组。原因是追加写入更方便,而且即使文件损坏也只影响一行,不会整个历史都读不出来。
4. Vue3 前端与 Wails 绑定实战
4.1 自动生成的绑定代码怎么用
wails dev启动后,会在frontend/wailsjs/go/main/目录下生成绑定文件。比如你的 App 结构体叫App,就会生成App.d.ts和App.js。
前端调用方式:
import { SendMessage, GetMessages } from '../wailsjs/go/main/App' const send = async (text: string) => { await SendMessage(text) }注意这些函数返回的是 Promise,因为 Wails 底层是异步通信。TypeScript 类型定义也是自动生成的,你在 Go 里改方法签名,前端会立刻感知到类型变化。
4.2 事件监听与流式渲染
前端监听 Go 推送的事件:
import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime' onMounted(() => { EventsOn('ai:chunk', (chunk: string) => { const last = messages.value[messages.value.length - 1] if (last && last.role === 'assistant') { last.content += chunk } }) EventsOn('ai:done', () => { isStreaming.value = false }) EventsOn('ai:error', (err: string) => { isStreaming.value = false errorMsg.value = err }) }) onUnmounted(() => { EventsOff('ai:chunk') EventsOff('ai:done') EventsOff('ai:error') })这里有个容易踩的坑:EventsOn每次调用都会注册一个新的监听器,如果你在组件里反复挂载卸载,会累积大量监听器导致内存泄漏。所以一定要在onUnmounted里用EventsOff清理。
提示:如果你需要传递复杂对象,Go 那边用
runtime.EventsEmit(ctx, "event:name", structValue),前端拿到的就是反序列化后的 JS 对象,不需要手动JSON.parse。
4.3 界面状态管理的几个关键点
AI 桌面应用的状态比普通 CRUD 应用复杂,因为涉及流式输出、多轮对话、错误重试等场景。我用的是 Vue3 的reactive+computed组合:
const state = reactive({ messages: [] as Message[], isStreaming: false, currentSessionId: '', error: null as string | null }) const canSend = computed(() => { return !state.isStreaming && inputText.value.trim().length > 0 })isStreaming这个标志位特别重要。流式输出期间要禁用发送按钮,否则用户连点会导致多个请求并发,消息顺序就乱了。另外建议加一个"停止生成"按钮,实现方式是在 Go 那边维护一个context.CancelFunc,前端调用CancelStream()时触发取消。
滚动到底部的逻辑也要注意。流式输出时内容不断增长,你需要监听消息变化并自动滚动:
watch( () => state.messages[state.messages.length - 1]?.content, () => { nextTick(() => { chatContainer.value?.scrollTo({ top: chatContainer.value.scrollHeight, behavior: 'smooth' }) }) } )用nextTick是为了等 DOM 更新完成后再滚动,否则滚动的还是旧高度。
5. 打包发布与跨平台编译
5.1 各平台打包命令与注意事项
打包命令:
wails build默认会为当前平台生成可执行文件,输出在build/bin/目录下。
Windows 上生成的是.exe,如果你想做安装包,需要配合 NSIS 或者 Inno Setup。Wails 的build/windows/目录下有installer/子目录,里面有个project.nsi模板,你可以基于它定制安装界面。
macOS 上生成的是.app包,如果要分发还需要签名和公证。个人使用的话可以跳过,但用户第一次打开会提示"无法验证开发者",需要右键打开。
Linux 上生成的是二进制文件,如果要打包成 deb 或 rpm,可以用nfpm工具。
5.2 减小体积的几个实用技巧
虽然 Wails 本身已经很小了,但还有优化空间:
第一,在wails.json里开启-trimpath和-ldflags="-s -w",去掉调试信息和符号表,能减少 20-30% 的体积。
第二,前端构建时开启压缩和 tree-shaking。Vite 默认就会做,但你可以检查一下vite.config.ts里有没有引入不必要的 polyfill。
第三,如果用了图标库,只导入用到的图标,不要全量引入。比如import { Send, Stop } from 'lucide-vue-next'而不是import * as Icons from 'lucide-vue-next'。
第四,Go 那边如果引入了大型依赖,考虑用 build tag 做条件编译,把不需要的功能排除掉。
5.3 跨平台编译的坑
Wails 支持交叉编译,但有限制。从 macOS 编译 Windows 版本是可以的,需要装mingw-w64。从 Linux 编译 macOS 版本基本不可能,因为需要 macOS 的 SDK。
# 在 macOS 上编译 Windows 版本 wails build -platform windows/amd64Linux 编译的坑最多。不同发行版的 WebKitGTK 版本不一样,编译出来的二进制在别的发行版上可能跑不起来。我的建议是在目标平台上编译,或者用 Docker 容器做构建环境。
实操心得:如果你在 Linux 上编译时报
undefined reference to webkit_web_view_new之类的错误,八成是 WebKitGTK 的开发头文件没装。Debian 系装libwebkit2gtk-4.0-dev,Fedora 系装webkit2gtk4.0-devel。
6. 常见问题排查与避坑指南
6.1 开发阶段高频问题速查
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
wails dev启动后白屏 | 前端构建失败或路径错误 | 检查frontend/dist是否存在,看终端有无报错 |
| 前端调用 Go 方法报 undefined | 绑定未生成或未重新编译 | 重启wails dev,检查wailsjs目录 |
| 事件监听收不到消息 | 事件名拼写不一致 | Go 和前端的事件名必须完全一致,区分大小写 |
| 打包后界面样式丢失 | 资源路径用了绝对路径 | 改用相对路径,Vite 的base设为./ |
| Windows 上窗口图标不显示 | 图标格式或尺寸不对 | 用 256x256 的 PNG,放在build/appicon.png |
| macOS 上无法输入中文 | WebView 输入法兼容问题 | 升级 Wails 到最新版,v2.9 之后已修复 |
6.2 流式输出的边界情况处理
流式输出有几个边界情况必须处理,否则用户体验会很差。
第一种是网络中断。如果读取流的过程中连接断了,你要在前端显示一个"生成中断"的提示,而不是让用户对着一个永远不动的光标发呆。我的做法是在 Go 那边捕获错误后发送ai:error事件,前端收到后把最后一条消息标记为不完整。
第二种是用户中途取消。用户点了停止按钮后,Go 那边要真正取消 HTTP 请求,而不是只停止发送事件。这需要用到context.WithCancel:
func (a *App) SendMessage(content string) error { ctx, cancel := context.WithCancel(a.ctx) a.cancelFunc = cancel go a.streamResponse(ctx, content) return nil } func (a *App) CancelStream() { if a.cancelFunc != nil { a.cancelFunc() a.cancelFunc = nil } }第三种是超长响应。有些模型会输出几千字,如果全部堆在一个消息气泡里,界面会很难看。我建议在渲染时做分页或者折叠,超过一定高度就显示"展开全文"。
6.3 性能优化的几个实测有效手段
第一个是虚拟滚动。当对话历史超过 100 条时,DOM 节点会非常多,滚动会卡。用vue-virtual-scroller或者自己实现一个简单的虚拟列表,只渲染可视区域内的消息。
第二个是防抖保存。对话历史不需要每来一个字就写磁盘,我设置的是流式结束后延迟 2 秒保存,如果这期间又有新消息就重置计时器。
第三个是图片懒加载。如果 AI 返回的内容里有图片链接,用loading="lazy"属性让浏览器按需加载。
第四个是 Go 那边的连接池。如果你频繁调用同一个 API 端点,用http.Client复用连接,不要每次请求都新建一个。设置MaxIdleConns和IdleConnTimeout能显著减少延迟。
7. 这个项目还能怎么扩展
7.1 接入本地模型与多模型切换
目前我接的是远程 API,但如果你想接本地模型(比如通过 Ollama 或者 llama.cpp 启动的服务),只需要改APIEndpoint配置就行。更进一步,你可以做一个模型管理器,在界面上让用户自由切换不同的模型和端点。
实现思路是在 Go 那边维护一个map[string]ModelConfig,前端用下拉框选择,切换时更新a.config.APIEndpoint和a.config.ModelName。注意不同模型的 API 格式可能不一样,OpenAI 兼容格式是主流,但有些模型有自己的协议,需要做适配层。
7.2 对话导出与知识库功能
导出功能我做了 Markdown 和 JSON 两种格式。Markdown 适合分享,JSON 适合备份和迁移。导出时用 Go 的runtime.SaveFileDialog弹出保存对话框:
func (a *App) ExportMarkdown(sessionId string) (string, error) { path, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{ DefaultFilename: "conversation.md", Filters: []runtime.FileFilter{ {DisplayName: "Markdown", Pattern: "*.md"}, }, }) if err != nil || path == "" { return "", err } content := a.buildMarkdown(sessionId) os.WriteFile(path, []byte(content), 0644) return path, nil }知识库功能稍微复杂一点,需要做文本分块、向量化、相似度检索。如果你不想引入太重的依赖,可以用简单的关键词匹配做初版,后面再升级到向量检索。
7.3 自动更新与崩溃上报
自动更新对桌面应用来说很重要,但 Wails 本身没有内置这个功能。我的做法是在 Go 那边启动时请求一个版本检查接口,如果发现新版本就弹窗提示用户去下载。全自动更新涉及替换可执行文件,在 Windows 上比较麻烦,建议初版先做手动更新提示。
崩溃上报可以用 Go 的recover捕获 panic,把堆栈信息写到本地日志文件,下次启动时询问用户是否上报。注意不要收集任何用户隐私数据,只上报崩溃堆栈和系统版本信息。
这个项目从零到能用的版本大概花了我两周的业余时间,其中大部分时间花在调试流式输出的边界情况和跨平台兼容性上。Wails v2 的文档虽然不算特别详细,但社区挺活跃的,遇到问题去 GitHub Discussions 搜一下基本都能找到答案。如果你也想做一个体积小、启动快、体验好的桌面应用,Wails 确实值得一试。