Wails v2 替代 Electron:Go + Vue3 构建轻量 AI 桌面应用实战
1. 为什么我最终选了 Wails v2 而不是 Electron
1.1 一个真实的需求场景
去年年底我接了个私活,客户要一个本地跑的 AI 对话工具,核心诉求很明确:能调用本地模型接口、能管理对话历史、能打包成一个双击就能运行的桌面程序,而且客户明确说了“不要那种装完占几百兆、打开还卡半天的东西”。我第一反应是 Electron,毕竟生态成熟、资料多,但掂量了一下——一个简单的 AI 对话工具,为了它塞进去一整个 Chromium 加 Node 运行时,安装包轻松破 150MB,内存占用起步就是 200MB 往上,实在有点杀鸡用牛刀。
后来我把目光转向了 Wails v2。它的思路很直接:用 Go 写后端逻辑,用系统自带的 WebView 渲染前端界面,Windows 上用 WebView2,macOS 上用 WKWebView,Linux 上用 WebKitGTK。这意味着什么?意味着你的安装包不用打包浏览器内核,体积能压到 10MB 以内,内存占用也低得多。对于一个 AI 桌面应用来说,后端要处理 HTTP 请求、读写本地文件、管理 SQLite 数据库,这些恰好是 Go 的强项;前端用 Vue3 做交互界面,开发体验和写网页几乎一样。这个组合在我看来是相当务实的。
1.2 Wails v2 到底解决了什么问题
很多人第一次听说 Wails 会把它当成“Go 版的 Electron”,这个理解对了一半。它们的共同点确实是“用 Web 技术写桌面界面”,但底层哲学完全不同。Electron 是把浏览器和 Node 一起打包进你的应用,好处是一致性强,坏处是重;Wails 是借用操作系统已有的 WebView 组件,好处是轻,坏处是需要处理不同平台 WebView 的差异。
Wails v2 相比 v1 最大的变化是架构重构。v1 时代前后端耦合比较紧,v2 改成了类似 RPC 的绑定机制:你在 Go 里定义结构体和方法,Wails 自动生成对应的 JavaScript/TypeScript 绑定代码,前端直接调用这些生成的方法,就像调用本地函数一样。底层通信走的是 WebView 提供的消息通道,你不需要手写任何 HTTP 服务或者 WebSocket。
对于 AI 桌面应用这个场景,Wails v2 有几个特别对胃口的能力:
- Go 后端直接调用系统能力:读写文件、执行命令、访问网络、操作数据库,全都是原生 Go 代码,没有中间层。
- 前端热重载:开发模式下改 Vue 代码即时生效,改 Go 代码会自动重新编译并刷新窗口。
- 单一二进制分发:最终产物就是一个可执行文件,前端资源被嵌入进去,用户拿到就能跑。
- 原生对话框和菜单:Wails 提供了文件选择、消息弹窗、系统托盘等 API,不用自己造轮子。
1.3 技术选型的取舍逻辑
我选 Go + Vue3 这套组合,不是因为它时髦,而是因为每个环节都有明确的理由。
Go 这边,AI 应用绕不开网络请求和并发处理。Go 的 goroutine 和 channel 让流式响应的处理变得很自然——你可以开一个 goroutine 去读 SSE 流,通过 Wails 的事件机制把每个 token 推给前端,前端实时渲染。如果用 Node 做后端,虽然也能实现,但 Go 的静态类型和编译期检查在项目变大之后优势明显。
Vue3 这边,Composition API 对于 AI 对话这种状态逻辑复杂的场景非常合适。对话列表、当前会话、流式输出状态、模型参数配置,这些状态用ref和reactive管理起来很清晰,逻辑可以抽成 composable 复用。而且 Vue3 的响应式系统配合v-for渲染消息列表,性能足够好。
至于为什么不用 Tauri(Rust + WebView),纯粹是因为我对 Go 更熟,而且 Go 的生态在服务端工具这块更顺手。技术选型没有绝对的对错,关键是匹配团队能力和项目需求。
2. 环境搭建与项目初始化
2.1 安装 Wails CLI 和依赖
Wails 的安装分两步:先装 Go,再装 Wails CLI。Go 的安装不多说,去官网下载对应平台的安装包,装完确认go version能正常输出。建议用 Go 1.21 以上版本,因为 Wails v2 的一些特性依赖较新的标准库。
装完 Go 之后,执行:
go install github.com/wailsapp/wails/v2/cmd/wails@latest这里有个坑要注意:go install会把二进制装到$GOPATH/bin或者$HOME/go/bin,你得确保这个目录在系统的 PATH 里。Windows 上默认是%USERPROFILE%\go\bin,macOS/Linux 是$HOME/go/bin。装完之后运行wails doctor,它会检查你的环境是否齐全,包括 WebView2 运行时(Windows)、Xcode Command Line Tools(macOS)等。这个命令非常有用,环境有问题它会直接告诉你缺什么。
Windows 上还需要确认 WebView2 Runtime 已安装。Win10 较新的版本和 Win11 通常自带,如果没有,去微软官网下载 Evergreen Bootstrapper 装一下就行。这个运行时是 Wails 应用能显示界面的前提,缺了它程序启动会直接报错。
2.2 创建项目骨架
环境没问题之后,用 Wails 的模板初始化项目:
wails init -n my-ai-app -t vue-ts-n指定项目名,-t指定前端模板。Wails 内置了多种模板,vue-ts就是 Vue3 + TypeScript 的组合,正好符合我们的需求。如果你想用 JavaScript 而不是 TypeScript,可以用vue模板;如果想用 React 或 Svelte,也有对应的模板。
创建完成后目录结构大概是这样:
my-ai-app/ ├── build/ # 打包相关资源(图标、配置) ├── frontend/ # Vue3 前端项目 │ ├── src/ │ ├── package.json │ └── vite.config.ts ├── app.go # 应用主逻辑 ├── main.go # 入口文件 ├── go.mod └── wails.json # Wails 项目配置main.go里定义了窗口尺寸、标题、嵌入的前端资源等;app.go是你写业务逻辑的地方,初始模板里有一个Greet方法作为示例。
2.3 前端依赖安装与开发模式启动
进入frontend目录,先装依赖:
cd frontend npm install如果你习惯用 pnpm 或 yarn,也可以,Wails 不限制包管理器。装完之后回到项目根目录,运行:
wails dev这个命令会同时启动 Vite 开发服务器和 Go 后端,并打开一个桌面窗口。前端代码改动会热更新,Go 代码改动会触发重新编译。第一次运行可能会慢一点,因为要下载 Go 模块依赖和编译。
注意:
wails dev启动的窗口和最终打包出来的应用行为基本一致,但调试信息更丰富。如果你在开发模式下遇到问题,可以先看终端输出的日志,Go 端的fmt.Println和前端 console 都会显示。
3. 核心架构设计:前后端如何协作
3.1 Wails 的绑定机制详解
Wails v2 最核心的概念是“绑定”。你在 Go 里定义一个结构体,把它注册到应用里,Wails 就会自动生成对应的 TypeScript 类型定义和调用函数。前端通过window.go对象访问这些方法,调用方式就像普通异步函数。
举个例子,假设你在app.go里定义了这样一个结构体:
type AIService struct { ctx context.Context } func (a *AIService) SendMessage(msg string) (string, error) { // 处理逻辑 return "回复内容", nil }然后在main.go里注册:
app := NewApp() aiService := &AIService{} err := wails.Run(&options.App{ Title: "AI 助手", Bind: []interface{}{ app, aiService, }, })启动wails dev后,前端就能这样调用:
const reply = await window.go.main.AIService.SendMessage("你好")注意路径window.go.main.AIService的构成:main是 Go 包名,AIService是结构体名,SendMessage是方法名。这个映射关系是自动生成的,你不需要手动维护类型定义文件。
3.2 流式响应的处理方案
AI 对话应用最关键的技术点之一是流式输出。用户发一条消息,模型逐字返回,前端要实时显示。Wails 的绑定调用是请求-响应模式,不适合直接做流式推送。这时候需要用 Wails 的事件系统。
思路是这样的:前端调用一个 Go 方法发起请求,Go 端在 goroutine 里读取流式响应,每读到一个片段就通过runtime.EventsEmit发一个事件;前端监听这个事件,把收到的内容追加到当前消息上。
Go 端代码大概长这样:
func (a *AIService) StartStream(prompt string) error { go func() { // 发起 HTTP 请求,读取 SSE 流 resp, err := http.Post(url, "application/json", body) if err != nil { runtime.EventsEmit(a.ctx, "stream:error", err.Error()) return } defer resp.Body.Close() reader := bufio.NewReader(resp.Body) for { line, err := reader.ReadString('\n') if err != nil { break } // 解析 SSE 数据 chunk := parseSSE(line) if chunk != "" { runtime.EventsEmit(a.ctx, "stream:chunk", chunk) } } runtime.EventsEmit(a.ctx, "stream:done", "") }() return nil }前端监听:
import { EventsOn } from '../wailsjs/runtime/runtime' EventsOn('stream:chunk', (chunk: string) => { currentMessage.value += chunk }) EventsOn('stream:done', () => { isStreaming.value = false })这里有个细节要注意:runtime.EventsEmit的第一个参数是 context,这个 context 必须是从 Wails 启动时传入的那个,否则事件发不出去。通常的做法是在App结构体里保存ctx,在startup生命周期回调里赋值。
3.3 数据持久化的选型
AI 对话应用需要保存对话历史、用户配置、模型参数等数据。我的选择是 SQLite,理由是:单文件、零配置、Go 有成熟的驱动(mattn/go-sqlite3或modernc.org/sqlite)、查询能力强。
如果你不想引入 CGO 依赖,可以用modernc.org/sqlite,它是纯 Go 实现的 SQLite 驱动,编译时不需要 C 编译器。代价是性能比 CGO 版本略低,但对于对话历史这种数据量完全够用。
数据库文件放在哪?Wails 提供了runtime包,但获取用户数据目录还是用 Go 标准库更直接:
import "os" import "path/filepath" func getDataDir() string { configDir, _ := os.UserConfigDir() dataDir := filepath.Join(configDir, "my-ai-app") os.MkdirAll(dataDir, 0755) return dataDir }这样在 Windows 上会存到%APPDATA%\my-ai-app,macOS 上是~/Library/Application Support/my-ai-app,Linux 上是~/.config/my-ai-app。符合各平台惯例,用户卸载时也知道去哪清理数据。
4. 前端界面实现的关键细节
4.1 对话列表的渲染与性能
AI 对话界面看起来简单,但消息多了之后性能问题就出来了。每条消息可能包含 Markdown 渲染、代码高亮、复制按钮等,如果几百条消息全部实时渲染,滚动会卡。
我的做法是分两层优化。第一层是虚拟滚动,只渲染视口内的消息。Vue3 生态里有vue-virtual-scroller这类库,但考虑到对话场景的特殊性(消息高度不固定),我用了更简单的策略:只保留最近 50 条消息在 DOM 里,更早的消息折叠成“加载更多”按钮。第二层是 Markdown 渲染的懒执行,消息不在视口内时不渲染 Markdown,等滚动到了再渲染。
代码高亮用的是highlight.js,但要注意按需引入语言包,不要全量导入,否则打包体积会膨胀。我通常只引入常用的几种:JavaScript、TypeScript、Python、Go、JSON、Bash。
4.2 输入框的交互细节
输入框有几个容易被忽略但很影响体验的细节。第一是自动高度,用户输入多行内容时输入框要跟着长高,但到一定高度后停止增长并出现滚动条。第二是快捷键,Enter发送、Shift+Enter换行,这是聊天应用的通用约定。第三是发送中的状态处理,流式输出还没结束时,发送按钮应该变成“停止”按钮。
这些逻辑用 Vue3 的 Composition API 抽成一个useChatInputcomposable 会很清晰:
export function useChatInput(onSend: (text: string) => void) { const text = ref('') const isComposing = ref(false) function handleKeydown(e: KeyboardEvent) { if (e.key === 'Enter' && !e.shiftKey && !isComposing.value) { e.preventDefault() if (text.value.trim()) { onSend(text.value) text.value = '' } } } return { text, isComposing, handleKeydown } }注意isComposing这个状态,中文输入法在选词时按 Enter 是确认候选词,不应该触发发送。通过监听compositionstart和compositionend事件来设置这个标志,能避免中文用户的误发送问题。
4.3 主题切换与样式方案
桌面应用的主题切换比网页更复杂,因为要跟随系统主题。Wails 提供了runtime.WindowSetDarkTheme之类的 API,但更简单的做法是用 CSS 变量加prefers-color-scheme媒体查询。
我通常定义两套 CSS 变量:
:root { --bg-primary: #ffffff; --text-primary: #1a1a1a; } @media (prefers-color-scheme: dark) { :root { --bg-primary: #1e1e1e; --text-primary: #e0e0e0; } }然后在组件里用var(--bg-primary)引用。这样系统主题切换时界面自动跟随,不需要写任何 JavaScript。如果用户想手动切换,再加一个>db.Exec("PRAGMA journal_mode=WAL") db.SetMaxOpenConns(1)
SetMaxOpenConns(1)强制所有操作走同一个连接,避免锁竞争。对于桌面应用的数据量,这个性能完全够用。
6.4 窗口状态保存
用户调整了窗口大小和位置,下次打开应该恢复。Wails 本身不提供这个功能,需要自己实现。思路是在窗口关闭时保存尺寸和位置到配置文件,启动时读取并设置。
Wails 的options.App里可以设置Width、Height、StartState,但位置设置需要用runtime.WindowSetPosition。关闭时的钩子可以用OnBeforeClose回调。把这些逻辑封装成一个WindowStateManager,代码不复杂但很实用。
7. 这个项目还能怎么扩展
7.1 多模型接入的抽象设计
目前只接了一个模型接口,如果想支持多个模型提供商,需要做一层抽象。我的做法是定义一个Provider接口:
type Provider interface { Name() string StreamChat(ctx context.Context, messages []Message, opts ChatOptions) (<-chan Chunk, error) ListModels() ([]Model, error) }然后为每个提供商实现这个接口。前端通过一个配置界面选择当前使用的提供商和模型。这样新增提供商时只需要加一个实现,不用改核心逻辑。
7.2 本地模型集成
如果用户想在本地跑模型,可以集成 Ollama 或类似工具的 API。Ollama 提供了 HTTP 接口,Go 端直接调用即可。好处是数据不出本地,隐私性好;代价是需要用户自己安装 Ollama 并下载模型,有一定的使用门槛。
7.3 插件系统
更进一步,可以做一个插件系统,让用户通过配置文件定义自定义的 API 端点、请求格式、响应解析规则。这样即使是不支持的标准接口,用户也能自己适配。实现上可以用 Go 的plugin包,但跨平台支持有限;更实际的做法是定义一个 JSON 配置格式,在运行时解析并动态构造请求。
这个方向我觉得挺有意思,因为它把“支持新模型”这件事从“改代码重新编译”变成了“改配置重启应用”,对非技术用户友好很多。后续如果要做,我会优先考虑这个方案。