news 2026/11/3 17:18:58

Wails v2 实战:用 Go 和 Vue3 打造轻量级 AI 桌面应用

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Wails v2 实战:用 Go 和 Vue3 打造轻量级 AI 桌面应用

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 技术选型的对比逻辑

我把当时做的选型对比整理成了一张表,方便你参考:

维度ElectronWails v2Tauri
后端语言Node.jsGoRust
渲染方式自带 Chromium系统 WebView系统 WebView
打包体积80-200MB8-20MB5-15MB
内存占用150MB+40-80MB30-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/amd64

Linux 编译的坑最多。不同发行版的 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 确实值得一试。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/3 17:18:08

牛牛影院TV版电视端安装与播放调优:解码、带宽、缓存全链路

电视端观影这件事,说简单也简单,装个应用、点开就能看;说麻烦也麻烦,同一个牛牛影院TV版,有人装完流畅得跟本地播放一样,有人装完卡成幻灯片,还有人干脆卡在“应用未安装”这一步。差别往往不在…

作者头像 李华
网站建设 2026/11/3 17:16:45

gcc-arm-none-eabi工具链配置与裸机工程实践

1. 先搞清楚这套工具链到底在干什么1.1 从一次"编译过了但板子不亮"说起几年前接手一个 Cortex-M 的小项目,我拿到手的第一件事是把工程拉下来敲make。宿主机上的编译器很痛快地吐出了一个可执行文件,我把它烧进去,板子纹丝不动。串…

作者头像 李华
网站建设 2026/11/3 17:16:23

Shell特殊变量详解:$*与$@的区别及$?错误处理实战

写Shell脚本的人大概都有过这样的经历:翻出一段几个月前写的脚本,看到里面密密麻麻的$?、$、$#,脑子先卡壳三秒,然后默默去搜索引擎里再查一遍。这些位置参数和特殊变量,说难不难,说简单也真容易记混——尤…

作者头像 李华
网站建设 2026/11/3 17:14:25

STM32智能物流分拣系统:硬件闭环与抗干扰实战源码

简介:本资源是全国大学生电子设计竞赛国家一等奖获奖项目——基于STM32H7系列的智能物流分拣机器人完整工程源码,面向嵌入式开发初学者、竞赛备赛学生及自动化方向实践者,解决智能分拣系统中自主导航、图像识别、多模态交互与精准执行等核心问…

作者头像 李华
网站建设 2026/11/3 17:13:58

从AI热搜词看智能体与多AI协作如何重塑工作流

今天打开搜索趋势,AI 相关热词里最频繁出现的不再是单纯的“对话大模型”,而是“AI Agent”“AI 编程”“AI 工作流”“多 AI 协作”这一串组合拳。这说明大家已经不满足于跟聊天机器人说两句话解解闷了,而是真的想让 AI 进到业务流程里干活。…

作者头像 李华
网站建设 2026/11/3 17:13:56

STM32F407智能物流分拣系统:闭环控制与工业抗干扰实战

简介:本资源是全国大学生电子设计竞赛国家一等奖获奖项目——基于STM32H7系列的智能物流分拣机器人完整源码工程,面向嵌入式开发初学者、电赛备赛学生及STM32进阶实践者,聚焦工业场景下的自主导航、图像识别与多模态分拣控制等核心问题。压缩…

作者头像 李华