☰
Wails v2 + Go + Vue3:构建轻量级桌面AI客户端实战
2026/11/3 4:04:39 网站建设 项目流程

桌面端 AI 应用这件事,我前前后后折腾过好几套方案。最早用 Electron 写过一版,一个聊天窗口打包出来两百多兆,内存占用常年飘在 400MB 以上,用户还没开始用就先被安装包体积劝退了。后来试过 Tauri,Rust 那套工具链对前端出身的人不算友好,编译报错信息看得人头大。直到把 Wails v2 捡起来,用 Go 写后端、Vue3 写前端,才算找到一条真正能落地的路子——安装包压到十几兆,内存占用稳定在 80MB 上下,关键是 Go 的并发模型处理流式响应天然顺手,Vue3 的响应式系统做对话界面也足够灵活。

这篇内容就是把我这套组合拳的完整实现过程拆开讲清楚。从环境搭建、前后端通信、流式输出处理,到打包分发和几个实际踩过的坑,都会给到可直接复现的代码和配置。适合已经会一点 Go 和 Vue3、想做一个真正能用的桌面 AI 客户端的开发者,也适合正在选型桌面框架、想看看 Wails 到底靠不靠谱的技术负责人。我不会只给你一个跑得起来的 Demo,而是把那些文档里不会写、只有真正打包发给用户之后才会暴露的问题一并说透。

1. 为什么是 Wails v2 而不是 Electron 或 Tauri

选框架这件事,本质上是在选一套权衡。Electron 把整个 Chromium 和 Node.js 运行时都塞进安装包,换来的是生态最成熟、坑最少,但代价就是体积和内存。Tauri 用系统自带的 WebView 渲染前端,体积控制得很好,但后端是 Rust,学习曲线陡峭,而且不同平台 WebView 行为差异需要额外处理。Wails v2 走的是和 Tauri 类似的路线——用系统 WebView 渲染界面,但后端换成 Go,这对国内大量写 Go 的团队来说,迁移成本几乎为零。

1.1 体积与内存的实测对比

我在同一台 Windows 11 机器上,用三个框架各写了一个功能相同的极简聊天窗口(一个输入框、一个消息列表、一个发送按钮),打包后做了对比:

框架安装包体积空载内存冷启动时间后端语言
Electron 28约 210MB约 420MB约 2.1sNode.js
Tauri 1.5约 8MB约 65MB约 0.8sRust
Wails v2.12约 14MB约 80MB约 0.9sGo

Wails 比 Tauri 略大一点,主要是 Go 运行时静态链接进去的体积,但换来的是后端开发效率的大幅提升。对于 AI 应用这种需要处理 HTTP 流式请求、并发管理、本地文件读写的场景,Go 的net/http、goroutine、context这套组合用起来比 Rust 的异步生态要顺手得多,尤其是团队里已经有人写 Go 的情况下。

1.2 Go 后端处理 AI 请求的天然优势

AI 桌面应用的核心链路是:前端发消息 → 后端调模型接口 → 流式返回 token → 前端逐字渲染。这条链路里,后端要做的事情包括维护长连接、处理 SSE 流、管理请求取消、做错误重试。Go 在这几件事上都有现成的、经过大规模验证的标准库。

context.Context可以优雅地取消一个正在进行的流式请求——用户点了"停止生成",前端调一个绑定方法,后端 cancel 掉 context,底层 HTTP 连接立刻断开,不会留下僵尸 goroutine。bufio.Scanner配合http.Response.Body逐行读取 SSE 数据,代码不到二十行就能跑通。这些在 Node.js 里也能做,但 Go 的类型安全和并发原语让代码更不容易出隐蔽 bug。

1.3 Wails 的绑定机制到底做了什么

Wails 最核心的能力是"绑定"——你在 Go 里定义一个结构体和方法,Wails 在构建时自动生成对应的 JavaScript/TypeScript 调用代码,前端直接await调用,就像调本地函数一样。底层走的是 WebView 提供的消息通道,把调用序列化成 JSON 传过去,Go 侧执行完再把结果序列化回来。

这个机制的好处是省掉了手写 HTTP 路由和 fetch 封装的麻烦,但要注意:绑定方法的参数和返回值都必须是可 JSON 序列化的类型。我一开始想直接传一个chan string做流式返回,结果构建直接报错。流式场景得换思路,后面会详细讲。

2. 环境搭建与项目初始化里那些容易翻车的点

环境这块看起来简单,但我在三台不同机器上初始化项目时都遇到了不同的问题,值得单独拎出来说。

2.1 Go、Node、Wails CLI 的版本匹配

Wails v2.12 对 Go 版本有要求,建议 Go 1.21 以上。Node 建议 18 LTS 或 20 LTS。安装 Wails CLI 的命令是:

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

装完之后一定要跑一次wails doctor,它会检查你的环境是否齐全,包括 WebView2 运行时(Windows)、Xcode Command Line Tools(macOS)、以及各种系统依赖。我遇到过最常见的问题是 Windows 上没装 WebView2 Runtime,开发时能跑(因为开发模式用的是本地 devserver),但打包出来的 exe 在别人机器上白屏。wails doctor会明确告诉你缺什么。

提示:Windows 10 早期版本可能没有预装 WebView2,打包分发时要么在安装程序里带上 WebView2 的引导安装,要么在文档里明确要求用户先装。这是实际分发时最容易被忽略的一环。

2.2 用模板初始化,别从零手搓

Wails 提供了官方模板,直接一条命令生成带 Vue3 + TypeScript + Vite 的项目骨架:

wails init -n my-ai-app -t vue-ts

生成的结构大致是这样:

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

main.go里配置窗口大小、标题、是否可调整等。app.go里定义你要绑定给前端的方法。wails.json里配置前端构建命令、输出路径等。这个结构清晰,前后端职责分明,比 Electron 那种主进程渲染进程混在一起要好维护。

2.3 前端依赖安装的坑

模板生成后进frontend目录跑npm install。这里有个坑:如果你所在网络环境访问 npm 官方源慢,装依赖会卡很久甚至超时。可以换成国内镜像源:

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

另外 Vue3 项目里如果要装 SCSS,记得装的是sass而不是已经废弃的node-sass:

npm install -D sass

装完之后在vite.config.ts里不需要额外配置,Vite 会自动识别.scss文件。我见过有人装了node-sass然后编译报一堆 Python 相关的错,就是因为没注意这个区别。

3. 前后端通信:从绑定方法到流式输出的完整实现

这是整个项目最核心的部分,也是坑最多的地方。普通的方法绑定很简单,但 AI 应用需要流式输出,这块 Wails 没有开箱即用的方案,得自己设计。

3.1 基础绑定:定义一个可调用的后端服务

先在app.go里定义一个结构体,把要暴露给前端的方法都挂在上面:

package main import ( "context" "fmt" ) type App struct { ctx context.Context } func NewApp() *App { return &App{} } func (a *App) startup(ctx context.Context) { a.ctx = ctx } // 普通方法:前端可以直接 await 调用 func (a *App) GetAppVersion() string { return "1.0.0" } // 带参数的方法 func (a *App) SaveConversation(title string, content string) error { // 实际写文件的逻辑 return nil }

然后在main.go里把这个 App 实例绑定进去:

func main() { app := NewApp() err := wails.Run(&options.App{ Title: "AI 助手", Width: 1024, Height: 768, OnStartup: app.startup, Bind: []interface{}{ app, }, }) if err != nil { fmt.Println(err) } }

构建之后,前端就能这样调用:

import { GetAppVersion, SaveConversation } from '../wailsjs/go/main/App' const version = await GetAppVersion() await SaveConversation('对话标题', '对话内容')

注意wailsjs这个目录是 Wails 自动生成的,不要手动改,每次构建会覆盖。TypeScript 的类型定义也在里面,所以前端调用是有类型提示的,这点体验很好。

3.2 流式输出的三种实现思路对比

AI 应用必须做流式,否则用户要盯着空白等好几秒。Wails 的绑定方法是一次性返回的,没法直接返回一个流。我试过三种方案:

方案实现方式优点缺点
轮询后端把 token 存内存,前端定时拉实现简单延迟高,浪费资源
事件推送后端用runtime.EventsEmit推给前端实时性好,官方支持需要管理事件名和生命周期
WebSocket后端起本地 WS 服务灵活多开一个端口,增加复杂度

实测下来事件推送是最合适的。Wails 内置了runtime.EventsEmit,后端可以主动往前端发事件,前端用EventsOn监听。这套机制底层复用了 WebView 的消息通道,不需要额外开端口,也不需要轮询。

3.3 用 EventsEmit 实现逐字输出

后端这边,把流式请求的每个 token 通过事件发出去:

package main import ( "bufio" "context" "encoding/json" "net/http" "strings" "github.com/wailsapp/wails/v2/pkg/runtime" ) type StreamChunk struct { RequestID string `json:"requestId"` Content string `json:"content"` Done bool `json:"done"` Error string `json:"error,omitempty"` } func (a *App) StreamChat(requestID string, prompt string) { go func() { ctx, cancel := context.WithCancel(a.ctx) defer cancel() // 把 cancel 存起来,供停止生成时调用 a.registerCancel(requestID, cancel) req, _ := http.NewRequestWithContext(ctx, "POST", "https://api.example.com/v1/chat/completions", strings.NewReader(buildRequestBody(prompt))) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+a.getAPIKey()) resp, err := http.DefaultClient.Do(req) if err != nil { runtime.EventsEmit(a.ctx, "stream:chunk", StreamChunk{ RequestID: requestID, Error: err.Error(), Done: true, }) return } defer resp.Body.Close() 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]" { runtime.EventsEmit(a.ctx, "stream:chunk", StreamChunk{ RequestID: requestID, Done: true, }) return } var parsed map[string]interface{} if err := json.Unmarshal([]byte(data), &parsed); err != nil { continue } // 从 parsed 里提取 delta content,这里省略具体解析 content := extractContent(parsed) runtime.EventsEmit(a.ctx, "stream:chunk", StreamChunk{ RequestID: requestID, Content: content, }) } }() }

前端监听这个事件,按requestID过滤出属于当前对话的 chunk:

import { EventsOn } from '../wailsjs/runtime/runtime' import { StreamChat } from '../wailsjs/go/main/App' const requestId = crypto.randomUUID() EventsOn('stream:chunk', (chunk: StreamChunk) => { if (chunk.requestId !== requestId) return if (chunk.error) { handleError(chunk.error) return } if (chunk.done) { finishStreaming() return } appendToMessage(chunk.content) }) await StreamChat(requestId, userInput)

这里requestID的设计很关键。因为事件是全局广播的,如果用户同时开了多个对话窗口,或者快速连续发消息,没有 requestID 过滤就会串台。我一开始没加这个,测试时快速发两条消息,回复内容直接混在一起了。

3.4 停止生成:context 取消的正确姿势

用户点"停止"按钮时,前端调一个StopStream(requestID),后端找到对应的 cancel 函数执行:

type App struct { ctx context.Context cancels map[string]context.CancelFunc cancelMu sync.Mutex } func (a *App) registerCancel(id string, cancel context.CancelFunc) { a.cancelMu.Lock() defer a.cancelMu.Unlock() a.cancels[id] = cancel } func (a *App) StopStream(requestID string) { a.cancelMu.Lock() cancel, ok := a.cancels[requestID] a.cancelMu.Unlock() if ok { cancel() } }

cancel 之后,http.DefaultClient.Do会立刻返回context canceled错误,scanner 循环退出,goroutine 正常结束。这里要注意:cancel 之后不要再往事件通道发数据,否则前端可能收到一个已经结束的请求的残留 chunk。我在 scanner 循环里加了一个ctx.Err() != nil的判断,一旦 context 被取消就直接 return。

4. 前端界面:Vue3 做对话流的几个关键设计

前端这块,Vue3 的 Composition API 配合响应式系统,做对话界面很自然,但有几个细节处理不好会很难受。

4.1 消息列表的响应式更新性能

对话消息一多,如果每条消息的每个字符更新都触发整个列表重渲染,界面会卡。我的做法是把"正在流式输出的那条消息"单独用一个 ref 管理,历史消息用普通数组:

const messages = ref<Message[]>([]) const streamingContent = ref('') const isStreaming = ref(false) function appendToMessage(content: string) { streamingContent.value += content } function finishStreaming() { messages.value.push({ role: 'assistant', content: streamingContent.value, }) streamingContent.value = '' isStreaming.value = false }

模板里历史消息用v-for渲染,流式消息单独渲染在列表末尾。这样每次 token 更新只影响一个 DOM 节点,不会触发整个列表的 diff。实测下来,即使对话有上百条历史消息,流式输出依然流畅。

4.2 自动滚动到底部的正确实现

聊天界面需要新消息出现时自动滚到底部。直接用scrollTop = scrollHeight有个问题:如果用户手动往上翻看历史,新消息来了会把他强行拽回底部,体验很差。正确做法是判断用户当前是否在底部附近:

const listRef = ref<HTMLElement>() function isNearBottom(): boolean { const el = listRef.value if (!el) return true return el.scrollHeight - el.scrollTop - el.clientHeight < 80 } function scrollToBottom() { const el = listRef.value if (!el) return el.scrollTop = el.scrollHeight } // 在 appendToMessage 里 function appendToMessage(content: string) { const shouldScroll = isNearBottom() streamingContent.value += content if (shouldScroll) { nextTick(scrollToBottom) } }

80 像素这个阈值是我试出来的,太小了用户稍微往上滚一点就被拽回去,太大了用户滚到中间还以为是底部。你可以根据自己的行高调整。

4.3 Markdown 渲染与代码高亮

AI 返回的内容通常是 Markdown 格式,需要渲染。我用的是markdown-it配合highlight.js:

npm install markdown-it highlight.js
import MarkdownIt from 'markdown-it' import hljs from 'highlight.js' import 'highlight.js/styles/github-dark.css' const md = new MarkdownIt({ highlight: (str, lang) => { if (lang && hljs.getLanguage(lang)) { try { return hljs.highlight(str, { language: lang }).value } catch (_) {} } return '' }, })

这里有个性能陷阱:流式输出时每个 token 都重新渲染整个 Markdown,如果消息很长,会明显卡顿。我的优化是流式过程中先用纯文本显示,等done之后再一次性渲染 Markdown。用户看到的是文字在逐字出现,结束后格式突然变漂亮,这个体验反而比边流边渲染更流畅。

注意:markdown-it默认不转义 HTML,如果直接把用户输入或模型输出塞进去,存在 XSS 风险。桌面应用虽然风险相对低,但如果内容来自不可信来源,建议开启html: false或者用 DOMPurify 过滤。

5. 打包分发:从开发模式到用户手里

开发时wails dev跑得好好的,打包出来一堆问题,这是最考验人的阶段。

5.1 构建命令与产物结构

wails build -platform windows/amd64 -clean

产物在build/bin目录下。Windows 是一个 exe,macOS 是 app bundle,Linux 是二进制文件。-clean会清掉之前的缓存,避免一些诡异的构建问题。如果要打多个平台,Wails 支持交叉编译,但 macOS 的包必须在 macOS 上打(因为需要签名工具链)。

5.2 前端资源路径问题

开发模式下前端资源走 Vite devserver,打包后走的是嵌入到二进制里的静态资源。如果你的代码里用了绝对路径引用图片或其他资源,打包后会 404。正确做法是用相对路径,或者把资源放在frontend/public目录下,Vite 会自动处理。

我踩过一个坑:在 CSS 里用url('/assets/bg.png')引用背景图,开发时正常,打包后白屏。改成url('./assets/bg.png')或者用import引入就好了。

5.3 API Key 的安全存储

AI 应用需要存 API Key,绝对不能明文写在代码或配置文件里。我的做法是用 Go 侧的系统密钥库,Windows 用 DPAPI,macOS 用 Keychain,Linux 用 Secret Service。有个现成的库github.com/zalando/go-keyring可以跨平台:

import "github.com/zalando/go-keyring" func (a *App) SaveAPIKey(key string) error { return keyring.Set("my-ai-app", "api-key", key) } func (a *App) GetAPIKey() (string, error) { return keyring.Get("my-ai-app", "api-key") }

前端只负责传 key,存储和读取都在 Go 侧完成,前端拿不到明文。这样即使有人反编译前端代码,也拿不到 key。

5.4 自动更新怎么做

Wails 本身不带自动更新,需要自己实现。我的方案是:应用启动时请求一个版本检查接口,对比当前版本和最新版本,如果有更新就下载新版本到临时目录,然后替换当前可执行文件并重启。Windows 上替换正在运行的 exe 需要先重命名旧文件,这个逻辑要小心处理。如果嫌麻烦,也可以用wails-updater这类社区库,但要注意维护状态。

6. 那些文档不会告诉你的实战坑

这部分是我真正想分享的,都是打包发给用户之后才暴露出来的问题。

6.1 中文输入法在 WebView 里的诡异行为

Windows 上 WebView2 对中文输入法的支持有个已知问题:在输入框里用拼音输入时,候选词框的位置可能偏移,或者输入过程中触发input事件导致内容错乱。我的处理是在输入框上监听compositionstart和compositionend,在组合输入期间不处理input事件:

let isComposing = false function onCompositionStart() { isComposing = true } function onCompositionEnd(e: CompositionEvent) { isComposing = false handleInput(e.target as HTMLTextAreaElement) } function onInput(e: Event) { if (isComposing) return handleInput(e.target as HTMLTextAreaElement) }

这个问题在 macOS 上不明显,但 Windows 用户占比高,必须处理。

6.2 多显示器下的窗口位置

用户把窗口拖到副屏,关闭应用,下次打开时窗口可能跑到屏幕外。Wails 的窗口位置恢复需要自己存。我的做法是在OnBeforeClose里把窗口位置和大小存到本地配置,启动时读取并设置。但要注意:如果用户拔掉了副屏,存的位置可能超出当前屏幕范围,需要做边界检查,超出就回退到主屏居中。

6.3 打包后体积优化的几个手段

默认打包出来的体积还有压缩空间。几个有效的手段:

  • 用-ldflags "-s -w"去掉调试信息,能省几 MB
  • 前端构建开启压缩和 tree-shaking,Vite 默认就做了
  • 图片资源用 WebP 格式,比 PNG 小很多
  • 如果用了大的依赖库,检查是否能按需引入

我优化前是 18MB,优化后压到 13MB 左右。对于桌面应用来说,这个体积已经很能打了。

6.4 错误日志的收集

用户反馈"应用闪退了",你如果没有日志,根本无从查起。我在 Go 侧用log包把关键操作和错误写到用户目录下的日志文件,前端捕获的异常也通过绑定方法传给后端记录。日志按天切割,保留最近七天。这个投入在排查线上问题时回报极高。

func (a *App) LogError(module string, message string) { logPath := filepath.Join(os.UserHomeDir(), ".my-ai-app", "logs") os.MkdirAll(logPath, 0755) f, _ := os.OpenFile( filepath.Join(logPath, time.Now().Format("2006-01-02")+".log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) defer f.Close() fmt.Fprintf(f, "[%s] [%s] %s\n", time.Now().Format(time.RFC3339), module, message) }

7. 关于 Wails 选型的一点个人判断

用下来这大半年,我对 Wails 的定位有了比较清晰的认识。它不是要取代 Electron,两者面向的场景不同。如果你的团队是前端主导、需要用到大量 Node.js 生态的库、对安装包体积不敏感,Electron 依然是稳妥选择。但如果你的团队有 Go 背景、应用的核心逻辑在后端(比如需要处理大量并发、文件 IO、系统调用),Wails 能让你用一套语言搞定前后端,开发效率和运行效率都更好。

Wails v2 目前的短板主要在生态上,社区插件和现成组件比 Electron 少很多,很多东西要自己写。但核心功能已经足够稳定,我做的这个 AI 客户端从开发到上线用了不到两周,中间遇到的大部分问题都能在官方文档和 GitHub issues 里找到答案。v3 据说在路上了,会改进多窗口和移动端支持,值得期待。

如果你正准备动手,我的建议是先用官方模板跑通一个最小可用的版本,把绑定、事件、打包这三条链路走通,再往上叠业务逻辑。不要一上来就设计复杂的架构,Wails 的项目结构本身就很清晰,顺着它的约定来,比自创一套要省心得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询