ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Wails v2 + Go + Vue3 实战:构建轻量级 AI 桌面应用

Wails v2 + Go + Vue3 实战:构建轻量级 AI 桌面应用

1. 为什么我最终选了 Wails v2 而不是 Electron

1.1 一个真实的需求场景

去年年底我接了个私活,客户想要一个本地跑的 AI 对话工具,核心诉求很明确:能调用本地模型接口、能管理对话历史、能打包成一个双击就能运行的桌面程序,而且最好别让用户装一堆运行环境。我第一反应是 Electron,毕竟生态成熟、资料多,但真上手算了一下体积就犹豫了——一个空壳 Electron 应用打包出来轻松 150MB 起步,加上前端依赖和模型 SDK,奔着 300MB 去了。客户那边是给内部员工用的,几十台老机器,硬盘空间紧张,这个体积实在不友好。

后来我把目光转向了 Wails v2。它的思路很直接:用 Go 写后端逻辑,用系统自带的 WebView 渲染前端界面,最终产物就是一个几 MB 到几十 MB 的单文件可执行程序。Windows 上用 WebView2,macOS 上用 WKWebView,Linux 上用 WebKitGTK,不需要把整个 Chromium 塞进去。这个体积优势对我来说是决定性的。

这篇文章我就把整个实战过程拆开讲清楚:从环境搭建、Go 后端和 Vue3 前端的通信机制,到 AI 能力的接入、打包发布,再到我踩过的那些坑。如果你也在找一个"轻量、能用 Go 写逻辑、前端还能用 Vue3"的桌面方案,这篇应该能帮你少走不少弯路。

1.2 Wails v2 到底解决了什么问题

先说清楚 Wails 的定位。它不是一个"Go 版的 Electron",两者的架构差异很大。Electron 是把 Node.js 运行时和 Chromium 一起打包,前后端都在 JS 生态里;Wails 是 Go 进程作为宿主,前端跑在系统 WebView 里,两边通过绑定层通信。

这个架构带来几个实际好处。第一是体积,前面说了,几 MB 级别。第二是性能,Go 编译成原生二进制,处理文件、网络、并发这些活儿比 Node 快不少,尤其是你要做本地文件索引、批量处理的时候差距明显。第三是部署简单,一个 exe 丢过去就能跑,不用管用户机器上装没装 Node。

代价也有。WebView 的兼容性不如 Chromium 统一,尤其是 Linux 上不同发行版的 WebKitGTK 版本差异会导致一些 CSS 和 JS 特性表现不一致。另外 Wails 的生态比 Electron 小很多,遇到冷门问题搜不到现成答案,得自己啃源码。但对我这个项目来说,这些代价可以接受。

1.3 技术选型的对比表

我把当时考虑的几个方案列了个表,方便你对照自己的需求判断:

方案打包体积前端技术后端语言学习成本适合场景
Electron150MB+任意 WebNode.js低生态依赖重、需要 Chromium 一致性
Wails v25-30MB任意 WebGo中轻量工具、需要 Go 性能
Tauri3-10MB任意 WebRust高极致体积、愿意学 Rust
CEF100MB+任意 WebC++/多语言高需要深度定制浏览器

Wails 和 Tauri 是最像的,都是"系统 WebView + 原生后端"的路子。区别在于后端语言:Go 对我来说上手快,标准库强大,写网络和并发逻辑很舒服;Rust 性能更好但学习曲线陡,这个项目没必要。所以最终定了 Wails v2 + Go + Vue3。

2. 环境搭建:从零到跑通第一个窗口

2.1 安装 Go 和 Wails CLI

Go 的安装不复杂,去官网下对应平台的安装包就行。Windows 上装完记得把GOPATH\bin加进 PATH,不然wails命令找不到。装完验证一下:

go version # 应该输出类似 go version go1.21.x windows/amd64

然后装 Wails CLI。这里有个坑要注意,Wails v2 和 v1 的命令完全不兼容,网上很多老教程还是 v1 的写法,别照着抄。装 v2 用:

go install github.com/wailsapp/wails/v2/cmd/wails@latest

装完跑wails doctor,这个命令会检查你的环境是否齐全。它会告诉你 Go 版本、Node 版本、WebView2 是否安装、各种构建工具是否到位。这一步千万别跳过,我见过太多人卡在环境问题上,其实wails doctor早就把问题指出来了。

Windows 上最常见的缺失是 WebView2 Runtime。Win11 自带,Win10 早期版本可能没有,需要单独装。wails doctor会明确提示,按提示装就行。

2.2 创建项目骨架

环境没问题后,创建项目:

wails init -n ai-desktop -t vue-ts

-t vue-ts表示用 Vue3 + TypeScript 模板。Wails 内置了好几种模板,React、Svelte、Vanilla 都有,选自己顺手的。生成出来的目录结构大概是这样:

ai-desktop/ ├── frontend/ # Vue3 前端 │ ├── src/ │ ├── package.json │ └── vite.config.ts ├── app.go # 应用主逻辑 ├── main.go # 入口 ├── wails.json # 项目配置 └── go.mod

main.go里定义了窗口尺寸、标题、启动时加载的前端资源这些。app.go是你写业务逻辑的地方,也是前后端绑定的核心。

2.3 前端依赖的安装细节

进frontend目录跑npm install。这里有个国内开发者常遇到的问题:npm 源慢导致装依赖卡住。换成国内镜像会快很多:

npm config set registry https://registry.npmmirror.com

Vue3 项目里我习惯再装几个常用的东西:vue-router做路由、pinia做状态管理、axios做 HTTP 请求。如果你要用 SCSS,记得装sass和sass-loader,Vue3 + Vite 的配置和 Vue2 + Webpack 时代不太一样,Vite 里直接在vite.config.ts配css.preprocessorOptions就行。

装完跑wails dev,应该能弹出一个窗口显示模板页面。这个命令会启动热重载,改前端代码窗口会自动刷新,改 Go 代码会重新编译,开发体验很顺。

提示:wails dev第一次跑会下载前端依赖并编译,比较慢,耐心等。如果卡在某个步骤,看终端输出,通常是网络问题。

3. 前后端通信:Wails 绑定的核心机制

3.1 绑定是怎么工作的

Wails 最核心的能力就是"把 Go 的方法暴露给前端调用"。你在app.go里定义一个结构体,把它的方法写好,然后在main.go里通过Bind注册,Wails 会自动生成对应的 JS/TS 调用代码。

举个例子,我在app.go里定义:

type App struct { ctx context.Context } func (a *App) Greet(name string) string { return "Hello " + name }

然后在main.go里:

err := wails.Run(&options.App{ Title: "AI Desktop", Width: 1024, Height: 768, Bind: []interface{}{ app, }, })

前端就能这样调:

import { Greet } from '../wailsjs/go/main/App' const result = await Greet('World')

注意wailsjs这个目录是 Wails 自动生成的,每次wails dev或wails build都会重新生成,不要手动改里面的文件,改了也会被覆盖。

3.2 方法签名的约束

绑定不是万能的,方法签名有要求。参数和返回值必须是可序列化的类型,Go 的struct、slice、map、基本类型都行,但channel、函数指针这些不行。返回值如果只有一个 error,Wails 会把它当成错误处理,前端await会抛异常。

我踩过一个坑:方法返回(string, error)时,前端拿到的直接是 string,error 非 nil 时会 reject。但如果你返回(string, int, error)这种多返回值,前端拿到的是一个数组。这个行为要记清楚,不然解构的时候容易懵。

3.3 事件系统:Go 主动通知前端

绑定是前端主动调 Go,反过来 Go 要主动推消息给前端,就得用事件系统。比如 AI 流式输出的时候,Go 那边每收到一段就推一次,前端实时渲染。

Go 侧:

import "github.com/wailsapp/wails/v2/pkg/runtime" func (a *App) StreamResponse(prompt string) { // 模拟流式输出 for _, chunk := range chunks { runtime.EventsEmit(a.ctx, "ai:chunk", chunk) } runtime.EventsEmit(a.ctx, "ai:done", nil) }

前端侧:

import { EventsOn } from '../wailsjs/runtime/runtime' EventsOn('ai:chunk', (chunk: string) => { messageContent.value += chunk }) EventsOn('ai:done', () => { isLoading.value = false })

这套机制做流式对话特别顺手。相比轮询或者 WebSocket,事件系统是 Wails 原生支持的,不用额外起服务,省事。

3.4 通信方式的选型建议

我把三种通信方式整理了一下,方便你按场景选:

方式方向适用场景注意事项
Bind 方法调用前端→Go请求-响应式操作返回值要可序列化
EventsEmit/EventsOnGo→前端流式输出、进度通知事件名要统一管理
前端直接 fetch前端→外部调第三方 HTTP API注意跨域和密钥安全

我的建议是:业务逻辑尽量放 Go 侧,前端只做展示和交互。原因很简单,Go 处理文件、网络、并发比 JS 强,而且逻辑放后端,将来要换前端框架也不用重写。前端直接 fetch 外部 API 只适合那种纯展示、不涉及密钥的场景。

4. AI 能力的接入与流式输出实现

4.1 接口调用的基本结构

AI 桌面应用的核心就是调模型接口。不管你是接云端 API 还是本地模型服务,基本都是 HTTP + JSON 的套路。我在 Go 侧封装了一个 client:

type AIClient struct { BaseURL string APIKey string Client *http.Client } type ChatRequest struct { Model string `json:"model"` Messages []Message `json:"messages"` Stream bool `json:"stream"` } type Message struct { Role string `json:"role"` Content string `json:"content"` }

http.Client一定要设置超时,不然网络卡住整个应用会假死。我一般设 60 秒,流式请求可以更长。

4.2 流式输出的解析

流式输出用的是 SSE(Server-Sent Events),响应体是一行行的data: {...}。Go 侧解析的时候要注意,不能一次性ReadAll,得用bufio.Scanner逐行读:

scanner := bufio.NewScanner(resp.Body) for scanner.Scan() { line := scanner.Text() if !strings.HasPrefix(line, "data: ") { continue } data := strings.TrimPrefix(line, "data: ") if data == "[DONE]" { break } var chunk StreamChunk if err := json.Unmarshal([]byte(data), &chunk); err != nil { continue } if len(chunk.Choices) > 0 { content := chunk.Choices[0].Delta.Content runtime.EventsEmit(a.ctx, "ai:chunk", content) } }

这里有个细节:bufio.Scanner默认的单行长度上限是 64KB,如果某一行特别长会报错。稳妥起见调大 buffer:

scanner.Buffer(make([]byte, 1024*1024), 1024*1024)

4.3 对话历史的管理

AI 对话是有上下文的,每次请求都要把历史消息带上。我在 Go 侧用 SQLite 存对话记录,前端只负责展示。为什么不用 localStorage?因为对话数据可能很大,localStorage 有 5MB 限制,而且用户换设备就丢了。SQLite 单文件、零配置,配合 Go 的database/sql用起来很舒服。

表结构很简单:

CREATE TABLE conversations ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, conversation_id INTEGER, role TEXT, content TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP );

每次发消息前,从数据库读出当前会话的历史消息,拼成messages数组发给模型。注意上下文长度限制,历史太长要截断,一般保留最近 N 轮或者按 token 数估算。

4.4 密钥安全:别把 API Key 写死在前端

这是新手最容易犯的错。API Key 绝对不能出现在前端代码里,因为打包后的前端资源是可以被解包的,Key 会直接暴露。正确做法是存在 Go 侧,前端通过绑定方法间接调用。

存储位置也有讲究。直接写死在代码里不行,用户换 Key 要重新编译。我用的方案是存在用户配置目录下的一个 JSON 文件里,路径通过os.UserConfigDir()获取,跨平台通用。敏感字段可以再做一层简单加密,虽然本地应用防不住有心人,但至少别明文躺着。

注意:如果你的应用要分发给别人用,让用户自己填 Key,别内置你自己的。内置的 Key 一旦泄露,账单可能让你怀疑人生。

5. 前端界面:Vue3 在桌面端的适配要点

5.1 窗口拖拽区域的设置

桌面应用和网页最大的区别之一就是窗口拖拽。网页没有标题栏拖拽的概念,但桌面应用需要。Wails 提供了 CSS 属性来标记可拖拽区域:

.titlebar { --wails-draggable: drag; height: 32px; background: #1e1e1e; } .titlebar button { --wails-draggable: no-drag; }

注意按钮这些需要点击的元素要设成no-drag,不然点不动。这个坑我踩过,当时按钮死活没反应,查了半天才发现是拖拽区域覆盖了。

如果你用系统原生标题栏,就不用管这些。但原生标题栏样式没法定制,想要自定义外观就得用无边框窗口 + 自己画标题栏。main.go里设Frameless: true就行。

5.2 路由和状态管理

Vue3 项目里我用vue-router的 hash 模式,不用 history 模式。原因是打包后前端资源是通过file://或者内部协议加载的,history 模式的路由在桌面环境里容易出问题。hash 模式最稳。

状态管理用 Pinia,比 Vuex 简洁很多。对话列表、当前会话、加载状态这些全局状态都放 store 里。注意一点:Pinia 的 store 在窗口刷新后会重置,如果需要持久化,得配合pinia-plugin-persistedstate或者手动存到 Go 侧。

5.3 流式渲染的性能问题

流式输出的时候,如果每收到一个字符就更新一次响应式数据,Vue 的响应式系统会被高频触发,界面可能卡顿。我的做法是做个节流:用一个缓冲区累积 chunk,每 50ms 或者累积到一定长度才更新一次 DOM。

let buffer = '' let timer: number | null = null EventsOn('ai:chunk', (chunk: string) => { buffer += chunk if (timer === null) { timer = window.setTimeout(() => { messageContent.value += buffer buffer = '' timer = null }, 50) } })

这样既保证了实时感,又不会让渲染压力过大。实测下来,即使模型输出很快,界面也很流畅。

5.4 常见的前端适配坑

Vue3 在 WebView 里跑,有几个和浏览器不一样的地方要注意。一是右键菜单,WebView 默认可能没有,需要自己实现或者禁用。二是快捷键,Ctrl+C 复制这些在 WebView 里默认行为可能不符合预期,需要监听处理。三是文件拖拽,桌面应用经常要支持拖文件进来,Wails 提供了OnFileDrop的钩子,比纯 Web 的拖拽 API 更可靠。

还有个小细节:Vue3 的on-success这类回调在某些 UI 框架里监听不到,通常是版本不匹配或者事件名写错了。遇到这种问题先看框架文档对应版本的 API,别硬猜。

6. 打包发布:从开发到可执行文件

6.1 构建命令和产物

开发调试用wails dev,正式打包用:

wails build

默认会在build/bin下生成可执行文件。Windows 上是.exe,macOS 上是.app,Linux 上是无后缀的二进制。加-clean参数可以清掉之前的缓存重新构建,遇到奇怪的构建问题时先试这个。

如果要减小体积,加-ldflags "-s -w"去掉调试信息:

wails build -ldflags "-s -w"

这个能让二进制小 20% 到 30%,代价是没法用调试器看符号,正式发布用没问题。

6.2 跨平台构建的注意事项

Wails 支持交叉编译,但有限制。Windows 上可以编译 Linux 和 macOS 的产物,但 macOS 的产物需要签名才能正常分发。Linux 上编译 Windows 产物需要装 mingw-w64。

我实际的经验是:能原生构建就原生构建。交叉编译偶尔会遇到 WebView 相关的链接问题,尤其是 Linux 上不同发行版的 WebKitGTK 版本差异。如果团队里有对应平台的机器,直接在目标平台上构建最省心。

Linux 构建还有个坑:不同发行版的 WebKitGTK 包名不一样。Ubuntu/Debian 上是libwebkit2gtk-4.0-dev,Fedora 上是webkit2gtk4.0-devel。构建前先确认依赖装齐,wails doctor会帮你检查。

6.3 应用图标和元信息

图标配置在wails.json里,指定一个 PNG 文件,Wails 会自动生成各平台需要的格式。Windows 需要.ico,macOS 需要.icns,但你只需要提供 PNG,转换是自动的。

应用名称、版本号、版权信息这些也在wails.json里配。Windows 上还可以配置 UAC 权限级别,如果你的应用需要访问系统目录或者注册表,得提权。

6.4 自动更新的思路

Wails 本身没有内置自动更新,但实现起来不难。思路是:应用启动时请求一个版本检查接口,对比本地版本和最新版本,如果有更新就下载新版本的可执行文件,替换后重启。

替换正在运行的可执行文件在 Windows 上是个麻烦事,文件被占用没法直接覆盖。常见做法是下载到一个临时文件,然后启动一个小的更新脚本,脚本等主程序退出后替换文件再启动新版本。这个逻辑用 Go 写个辅助程序就行。

7. 踩坑实录与排查技巧

7.1 开发环境类问题

问题一:wails dev启动后窗口白屏。这个最常见的原因是前端编译报错但没在终端显示。打开开发者工具(wails dev模式下按 F12)看 Console,通常是某个依赖没装或者路径写错了。还有一种可能是wailsjs目录没生成,删掉重新wails dev会重新生成。

问题二:改了 Go 代码热重载不生效。Wails 的热重载对 Go 代码是重新编译整个应用,有时候编译失败但旧进程还在跑,看起来像没生效。看终端有没有编译错误,有的话先解决。

问题三:局域网访问前端 dev server 空白。这个和 Vite 的配置有关,需要在vite.config.ts里设server.host: '0.0.0.0'。但桌面应用一般不需要局域网访问,除非你要在手机上调试界面。

7.2 运行时类问题

问题四:调用绑定方法报 "not found"。检查方法名大小写,Go 的方法必须是导出的(首字母大写)才能被绑定。另外确认main.go里Bind注册了对应的结构体。

问题五:事件监听收不到消息。事件名要完全一致,包括大小写。另外EventsOn要在组件挂载后注册,在onMounted里写。如果组件销毁了没取消监听,下次挂载会重复注册,导致回调执行多次。记得在onUnmounted里调EventsOff。

问题六:打包后接口调用失败。开发环境能跑,打包后不行,八成是路径问题。开发时前端资源从 dev server 加载,打包后从嵌入的二进制加载,相对路径的基准变了。用 Wails 提供的资源 API,别用硬编码路径。

7.3 排查速查表

现象可能原因排查方向
窗口白屏前端编译错误F12 看 Console
绑定方法找不到方法未导出/未注册检查首字母大写和 Bind
事件收不到事件名不一致/未注册核对名称和注册时机
打包后路径错误相对路径基准变化用 Wails 资源 API
构建失败依赖缺失/缓存问题wails doctor+-clean
体积过大未去调试信息加-ldflags "-s -w"

7.4 几个独家经验

第一个经验:把wailsjs目录加进.gitignore。它是自动生成的,提交到仓库会导致每次构建都产生大量 diff,而且不同人机器上生成的可能有细微差异。让每个人本地生成就行。

第二个经验:Go 侧的日志要输出到文件。打包后的应用没有终端,fmt.Println的内容看不到。用log包写到用户目录下的日志文件,出问题时让用户把日志发过来,排查效率高很多。

第三个经验:前端资源尽量内联。Wails 默认会把前端资源嵌入二进制,但如果你的资源里有大图片、大字体,考虑压缩或者按需加载。我见过有人把几十 MB 的模型文件打进前端资源,结果二进制巨大,启动还慢。

第四个经验:测试要在目标系统上做。WebView 的差异比想象中大,尤其是 Linux 上。我在 Ubuntu 上测得好好的,到某个国产 Linux 发行版上字体渲染就出问题。有条件的话,主要目标平台都跑一遍。

8. 这个方案还能怎么扩展

8.1 本地模型集成

现在很多人在本地跑模型,Wails 应用完全可以作为本地模型的前端。Go 侧通过 HTTP 调用本地模型服务,前端做交互界面。好处是模型跑在本地,数据不出机器,隐私性好。坏处是对用户机器配置有要求,得在应用里做好检测和提示。

8.2 多模态能力

除了文本对话,还可以接入图片、语音。图片处理 Go 的标准库够用,语音识别可以调本地或云端服务。Wails 的文件拖拽钩子让用户拖张图片进来就能识别,体验很顺。

8.3 插件化架构

如果功能越来越多,可以考虑插件化。Go 侧定义插件接口,每个插件是一个独立的包,启动时动态加载。前端对应地做插件管理界面。这样核心应用保持精简,功能按需扩展。

8.4 和 Web 版共用代码

如果你的项目同时有 Web 版和桌面版,可以把业务逻辑抽到一个共享的 Go 包里,桌面版和 Web 版都引用。前端也可以共用大部分组件,只是通信层不一样——Web 版用 HTTP,桌面版用 Wails 绑定。这样维护成本能降不少。

我在实际项目里就是这么干的,核心的 AI 调用、对话管理逻辑放在一个独立的 Go module 里,桌面版和 Web 版各自包一层适配。改一处逻辑,两边都生效,省了很多重复劳动。唯一要注意的是别把平台相关的代码混进去,比如文件路径处理、系统调用这些,得用接口抽象出来。

最后分享一个小技巧:Wails 的wails dev模式下,前端改动是热更新的,但如果你改了wails.json或者main.go里的窗口配置,得重启才生效。开发时把窗口尺寸调大一点,方便调试,发布前再改回正常尺寸。这个细节虽小,但能省不少来回重启的时间。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进