1. 为什么我最终选了 Wails v2 而不是 Electron1.1 一个真实的需求场景去年年底我接了个私活客户要一个本地跑的 AI 对话工具核心诉求很明确能调用本地模型接口、能管理对话历史、能打包成一个双击就能运行的桌面程序而且客户明确说了“不要那种装完占几百兆、打开还卡半天的东西”。我第一反应是 Electron毕竟生态成熟、资料多但掂量了一下——一个简单的 AI 对话工具为了它塞进去一整个 Chromium 加 Node 运行时安装包轻松破 150MB内存占用起步就是 200MB 往上实在有点杀鸡用牛刀。后来我把目光转向了 Wails v2。它的思路很直接用 Go 写后端逻辑用系统自带的 WebView 渲染前端界面Windows 上用 WebView2macOS 上用 WKWebViewLinux 上用 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渲染消息列表性能足够好。至于为什么不用 TauriRust 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/wailslatest这里有个坑要注意go install会把二进制装到$GOPATH/bin或者$HOME/go/bin你得确保这个目录在系统的 PATH 里。Windows 上默认是%USERPROFILE%\go\binmacOS/Linux 是$HOME/go/bin。装完之后运行wails doctor它会检查你的环境是否齐全包括 WebView2 运行时Windows、Xcode Command Line ToolsmacOS等。这个命令非常有用环境有问题它会直接告诉你缺什么。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-appmacOS 上是~/Library/Application Support/my-ai-appLinux 上是~/.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发送、ShiftEnter换行这是聊天应用的通用约定。第三是发送中的状态处理流式输出还没结束时发送按钮应该变成“停止”按钮。这些逻辑用 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_modeWAL) 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 配置格式在运行时解析并动态构造请求。这个方向我觉得挺有意思因为它把“支持新模型”这件事从“改代码重新编译”变成了“改配置重启应用”对非技术用户友好很多。后续如果要做我会优先考虑这个方案。
阅读完成 · 觉得有帮助?