1. 为什么我最终选了 Wails v2 而不是 Electron
1.1 一个真实的需求场景
去年年底我接了个私活,客户想要一个本地跑的 AI 对话工具,核心诉求很明确:能调用本地大模型接口、能管理对话历史、能读取本地文件做知识库检索,最重要的是——安装包不能超过 50MB,启动要快,内存占用要低。我第一反应是 Electron,毕竟生态成熟、资料多、Vue3 集成方案一抓一大把。但试了一轮下来,一个空壳应用打包出来就 80MB 起步,跑起来内存直接飙到 300MB,客户那台 8GB 内存的老笔记本根本扛不住。
后来我把目光转向了 Wails。简单说,Wails 是一个用 Go 做后端、用前端框架做界面的桌面应用开发框架。它的思路和 Electron 类似,但底层完全不同:Electron 是把整个 Chromium 和 Node.js 运行时塞进安装包,而 Wails 在 Windows 上用的是系统自带的 WebView2,在 macOS 上用的是 WKWebView,在 Linux 上用的是 WebKitGTK。这意味着你的安装包里不需要带浏览器内核,体积自然就下来了。
我实测下来,一个带 Vue3 前端、Go 后端、包含基本 AI 对话功能的 Wails 应用,Windows 安装包可以压到 8MB 左右,启动时间在 1 秒以内,空闲内存占用 60MB 上下。这个数据对于我那个客户来说,完全是降维打击。
1.2 Wails v2 的核心架构拆解
Wails v2 的架构可以用一句话概括:Go 是宿主,前端是渲染层,两者通过绑定机制通信。
具体来说,当你执行wails dev或wails build时,Wails 会做这几件事:
- 启动一个 Go 编写的本地 HTTP 服务,但这个服务默认只监听本地回环地址,不对外暴露
- 前端资源(Vue3 打包后的 HTML/CSS/JS)由这个服务托管
- 在原生窗口中加载这个本地地址,渲染你的 Vue3 界面
- 通过 Wails 的绑定层,把 Go 结构体的方法暴露给前端 JavaScript 调用
- 前端调用 Go 方法时,实际走的是 Wails 内部的 IPC 通道,而不是普通的 HTTP 请求
这个设计的好处是:你不需要自己写 REST API,不需要处理跨域,不需要管理 token。Go 那边定义一个结构体,方法签名符合规范,Wails 自动生成对应的 JavaScript 调用代码。前端直接import { MyMethod } from '../wailsjs/go/main/App'就能用。
但这里有个坑我踩过:Wails 的绑定是基于反射的,方法参数和返回值必须是可序列化的类型。我一开始想直接传一个包含chan的结构体给前端,编译直接报错。后来改成只传必要字段的 DTO,问题解决。
1.3 和 Electron、Tauri 的横向对比
选型阶段我把三个方案都跑了一遍,数据如下:
| 维度 | Electron | Tauri | Wails v2 |
|---|---|---|---|
| 安装包体积(Windows) | 80-120MB | 5-10MB | 6-12MB |
| 空闲内存占用 | 250-400MB | 50-80MB | 50-90MB |
| 后端语言 | Node.js | Rust | Go |
| 前端支持 | 任意 | 任意 | 任意 |
| 学习曲线 | 低 | 高(Rust) | 中(Go) |
| 生态成熟度 | 极高 | 中 | 中 |
| 系统 WebView 依赖 | 无 | 有 | 有 |
Tauri 的体积和内存表现确实好,但 Rust 的学习成本对我来说太高了。我 Go 写了五六年,用 Wails 可以做到当天上手当天出原型。而且 Wails 的绑定机制比 Tauri 的 command 系统更直观,Go 那边写个方法,前端直接调,不需要额外定义 schema。
Electron 的优势在于生态,但如果你做的是工具类应用,不需要复杂的 Node.js 原生模块,Wails 完全够用,而且用户体验好太多。
注意:Wails 依赖系统 WebView,Windows 10 以下需要用户手动安装 WebView2 运行时。不过现在 Win10 和 Win11 基本都自带了,Win7 用户占比极低,可以忽略。
2. 环境搭建与项目初始化
2.1 Go 和 Node.js 的版本选择
Wails v2 对 Go 版本的要求是 1.18 以上,但我建议直接用 1.21 或更高。原因很简单:Go 1.21 引入了slog标准库日志包,还有min/max内置函数,写业务代码时能省不少事。我当前用的是 Go 1.22.3,实测稳定。
Node.js 这边,Wails v2 支持 Vite 作为前端构建工具,Vue3 项目建议用 Node 18 LTS 或 20 LTS。我试过 Node 22,也能跑,但有些老依赖会报engine警告。如果你用pnpm或yarn,记得检查 Wails 的模板是否兼容。
安装 Wails CLI 的命令很简单:
go install github.com/wailsapp/wails/v2/cmd/wails@latest装完之后执行wails doctor,它会检查你的环境是否齐全。这个命令非常有用,我第一次装的时候就是靠它发现缺了gcc和pkg-config,在 Linux 上这两个是编译 WebKitGTK 绑定的必备工具。
2.2 创建 Vue3 + TypeScript 模板
Wails 官方提供了多种前端模板,创建命令如下:
wails init -n my-ai-app -t vue-ts这个模板会生成一个标准的 Vue3 + TypeScript + Vite 项目结构。目录大致长这样:
my-ai-app/ ├── frontend/ # Vue3 前端 │ ├── src/ │ ├── wailsjs/ # Wails 自动生成的绑定代码 │ ├── package.json │ └── vite.config.ts ├── main.go # Go 入口 ├── app.go # 应用逻辑 ├── go.mod └── wails.json # Wails 配置这里有个细节值得说:wailsjs目录是自动生成的,不要手动改。每次你修改 Go 那边暴露的方法,Wails 会在wails dev时重新生成这个目录。我一开始不知道,手动改了里面的文件,结果下次编译直接被覆盖,白忙活半小时。
2.3 前端依赖安装与 Vite 配置调整
进入frontend目录,执行npm install或pnpm install。Vue3 项目默认会装vue、vue-router、pinia这些。如果你要做 AI 对话界面,建议额外装:
markdown-it:渲染 AI 返回的 Markdown 内容highlight.js:代码块高亮@vueuse/core:常用组合式函数集合
Vite 配置这边,Wails 模板已经帮你配好了base: './'和build.outDir: 'dist',一般不需要动。但如果你要用scss,记得装sass并在vite.config.ts里加上css.preprocessorOptions。
我遇到过一个坑:Vite 默认的server.port是 5173,但 Wails dev 模式下会自己起一个服务,端口可能冲突。解决办法是在wails.json里指定frontend:dev:serverUrl,或者直接让 Wails 管理端口,不要手动改 Vite 的 server 配置。
3. Go 后端核心逻辑设计
3.1 应用结构体与生命周期钩子
Wails 的应用入口在main.go,核心是wails.Run函数。你需要传入一个App结构体实例,Wails 会把它绑定到前端。
type App struct { ctx context.Context db *sql.DB } func NewApp() *App { return &App{} } func (a *App) startup(ctx context.Context) { a.ctx = ctx // 初始化数据库、加载配置等 } func (a *App) shutdown(ctx context.Context) { // 清理资源 }startup和shutdown是 Wails 的生命周期钩子,分别在应用启动和退出时调用。我一般把数据库连接、日志初始化、配置加载放在startup里,把连接关闭、临时文件清理放在shutdown里。
提示:
startup里不要做耗时太长的操作,否则窗口会白屏很久。如果确实需要加载大量数据,建议先让窗口显示出来,再用 goroutine 异步加载,通过 Wails 的事件系统通知前端。
3.2 暴露给前端的方法设计
Wails 的绑定规则很简单:App结构体上的公开方法(首字母大写)会自动暴露给前端。方法可以接受任意数量的参数,返回任意数量的值,但类型必须是可 JSON 序列化的。
我设计 AI 对话应用时,暴露了这几个核心方法:
// 发送消息给 AI,返回完整回复 func (a *App) SendMessage(sessionID string, content string) (string, error) // 流式发送消息,通过事件推送增量内容 func (a *App) SendMessageStream(sessionID string, content string) error // 获取会话列表 func (a *App) GetSessions() ([]Session, error) // 创建新会话 func (a *App) CreateSession(title string) (Session, error) // 删除会话 func (a *App) DeleteSession(sessionID string) error // 读取本地文件内容 func (a *App) ReadLocalFile(path string) (string, error)这里有个设计决策:流式输出怎么做?Wails 支持runtime.EventsEmit向前端推送事件。我的做法是SendMessageStream方法启动一个 goroutine,在 goroutine 里逐块读取 AI 接口的响应,每读一块就EventsEmit一次,前端监听对应事件并追加内容。
func (a *App) SendMessageStream(sessionID string, content string) error { go func() { stream, err := a.aiClient.ChatStream(a.ctx, content) if err != nil { runtime.EventsEmit(a.ctx, "chat:error", err.Error()) return } defer stream.Close() for { chunk, err := stream.Recv() if err == io.EOF { runtime.EventsEmit(a.ctx, "chat:done", sessionID) break } if err != nil { runtime.EventsEmit(a.ctx, "chat:error", err.Error()) break } runtime.EventsEmit(a.ctx, "chat:chunk", chunk) } }() return nil }前端监听:
import { EventsOn } from '../wailsjs/runtime/runtime' EventsOn('chat:chunk', (chunk: string) => { currentMessage.value += chunk })这个方案实测很稳,延迟在 50ms 以内,用户感知不到卡顿。
3.3 数据库选型与本地存储方案
AI 对话应用需要存会话历史、消息记录、用户配置。我选了 SQLite,原因有三:零配置、单文件、Go 驱动成熟。
Go 这边用modernc.org/sqlite这个纯 Go 实现的驱动,不需要 CGO,交叉编译方便。建表语句:
CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, title TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE );数据库文件放在os.UserConfigDir()返回的目录下,这样不同平台都能找到合适的位置。Windows 是%APPDATA%,macOS 是~/Library/Application Support,Linux 是~/.config。
注意:SQLite 默认是串行写入的,如果你有多个 goroutine 同时写,需要加锁或者用
db.SetMaxOpenConns(1)。我一开始没注意,流式写入消息时偶尔报database is locked,后来把最大连接数设为 1 就再没出现过。
4. Vue3 前端界面与交互实现
4.1 项目结构与路由设计
前端目录结构我习惯这样组织:
src/ ├── components/ # 通用组件 │ ├── ChatMessage.vue │ ├── ChatInput.vue │ └── SessionList.vue ├── views/ # 页面 │ ├── ChatView.vue │ └── SettingsView.vue ├── stores/ # Pinia 状态 │ ├── chat.ts │ └── settings.ts ├── utils/ # 工具函数 │ └── markdown.ts ├── App.vue └── main.ts路由用vue-router,两个页面:聊天页和设置页。聊天页是主界面,左侧会话列表,右侧对话区域,底部输入框。设置页放 API 地址、模型选择、主题切换这些。
4.2 聊天界面的核心交互逻辑
聊天界面的核心是消息列表的渲染和输入框的处理。消息列表用v-for渲染,每条消息根据role决定样式。AI 返回的 Markdown 内容用markdown-it渲染成 HTML,代码块用highlight.js高亮。
<template> <div class="chat-container"> <div class="message-list" ref="listRef"> <ChatMessage v-for="msg in messages" :key="msg.id" :message="msg" /> </div> <ChatInput @send="handleSend" :disabled="loading" /> </div> </template>输入框这边有个细节:用户按Enter发送,按Shift+Enter换行。这个交互在桌面端很常见,实现起来也简单:
function handleKeydown(e: KeyboardEvent) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() send() } }消息列表自动滚动到底部,用nextTick加scrollTop设置。但流式输出时,如果用户手动往上滚了,就不要强制滚动。我加了一个isUserScrolled标志,监听scroll事件判断。
4.3 流式输出的前端处理
流式输出是 AI 应用的核心体验。前端这边,我在 Pinia store 里维护一个currentStreamingMessage,收到chat:chunk事件时追加内容,收到chat:done时把这条消息固化到消息列表。
EventsOn('chat:chunk', (chunk: string) => { if (!streamingMessage.value) { streamingMessage.value = { id: Date.now().toString(), role: 'assistant', content: '' } } streamingMessage.value.content += chunk }) EventsOn('chat:done', () => { if (streamingMessage.value) { messages.value.push(streamingMessage.value) streamingMessage.value = null } loading.value = false })这里有个性能问题:如果 chunk 很密集,频繁触发 Vue 的响应式更新会导致界面卡顿。我的优化方案是用requestAnimationFrame做节流,每帧最多更新一次。
let pendingChunk = '' let rafId: number | null = null EventsOn('chat:chunk', (chunk: string) => { pendingChunk += chunk if (rafId === null) { rafId = requestAnimationFrame(() => { if (streamingMessage.value) { streamingMessage.value.content += pendingChunk } pendingChunk = '' rafId = null }) } })实测下来,这个优化让流式输出的帧率从 30fps 提升到了 60fps,肉眼可见地流畅了。
4.4 本地文件读取与知识库检索
AI 应用的一个常见需求是读取本地文件做知识库。Wails 提供了runtime.OpenFileDialog让用户选择文件,然后 Go 那边读取内容返回给前端。
func (a *App) SelectFile() (string, error) { path, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{ Title: "选择文件", Filters: []runtime.FileFilter{ {DisplayName: "文本文件", Pattern: "*.txt;*.md;*.json"}, }, }) if err != nil { return "", err } return path, nil }读取文件内容时要注意编码问题。Go 默认按 UTF-8 读取,如果文件是 GBK 编码,会乱码。我加了一个简单的编码检测,用golang.org/x/text/encoding做转换。
知识库检索这块,简单场景可以直接把文件内容拼到 prompt 里,但文件大了会超 token 限制。我的做法是做一个简单的分块和关键词匹配:把文件按段落切分,用户提问时用关键词匹配最相关的几个段落,拼到 prompt 里。这个方案虽然粗糙,但对于个人知识库场景够用了。
5. 打包、分发与踩坑记录
5.1 Windows 打包与 WebView2 处理
Windows 打包命令:
wails build -platform windows/amd64 -nsis-nsis会生成一个 NSIS 安装包,用户体验比绿色版好。但这里有个坑:如果你的应用依赖 WebView2,而用户系统没有,安装后打开会白屏。解决办法是在 NSIS 脚本里加一个检测,如果没有 WebView2 就引导用户下载。
Wails 官方提供了一个wails build -webview2 download选项,会把 WebView2 安装器打包进去,但这样安装包会大 100MB 左右。我的做法是检测系统版本,Win10 1803 以上基本都自带,直接跳过。
5.2 Linux 编译的注意事项
Linux 打包我踩的坑最多。首先,Wails 在 Linux 上依赖libgtk-3-dev和libwebkit2gtk-4.0-dev,编译前必须装好。其次,不同发行版的 WebKitGTK 版本可能不一样,Ubuntu 22.04 是 4.0,Ubuntu 24.04 是 4.1,编译参数需要调整。
# Ubuntu 22.04 sudo apt install libgtk-3-dev libwebkit2gtk-4.0-dev # Ubuntu 24.04 sudo apt install libgtk-3-dev libwebkit2gtk-4.1-dev编译命令:
wails build -platform linux/amd64 -tags webkit2_40-tags webkit2_40指定用 4.0 版本的 WebKitGTK,如果是 4.1 就用webkit2_41。这个 tag 不写的话,编译会报找不到头文件。
5.3 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 启动白屏 | WebView2 未安装 | 安装 WebView2 运行时 |
| 编译报错找不到 gtk | 缺少开发库 | 安装 libgtk-3-dev |
| 前端调用 Go 方法报 undefined | 绑定未生成 | 执行 wails dev 重新生成 |
| 流式输出卡顿 | 响应式更新过于频繁 | 用 requestAnimationFrame 节流 |
| 数据库 locked | 并发写入 | SetMaxOpenConns(1) |
| 打包体积过大 | 未开启压缩 | 加 -ldflags "-s -w" |
| Linux 编译失败 | WebKitGTK 版本不匹配 | 加对应 build tag |
5.4 我踩过的三个典型坑
第一个坑:Go 方法返回值类型不匹配。我定义了一个方法返回map[string]interface{},前端调用时一直报序列化错误。后来发现 Wails 的绑定生成器对interface{}支持不好,改成具体的 struct 就好了。
第二个坑:前端热重载失效。开发过程中改了 Vue 组件,界面没更新。排查后发现是wails.json里的frontend:dev:watcher配置被我不小心注释了。恢复后热重载正常。
第三个坑:打包后资源路径错误。开发时图片能显示,打包后 404。原因是 Vite 的base配置在开发模式是/,打包后需要是./。Wails 模板默认已经配好了,但我手动改了vite.config.ts导致覆盖。改回base: './'解决。
6. 性能优化与体验打磨
6.1 启动速度优化
Wails 应用的启动速度主要取决于 Go 初始化和前端资源加载。我的优化措施:
- 数据库连接延迟初始化,不在
startup里同步打开 - 前端路由懒加载,设置页单独打包
- 首屏只加载必要的组件,Markdown 渲染器按需引入
优化前冷启动 1.8 秒,优化后 0.9 秒,提升明显。
6.2 内存占用控制
Go 这边内存占用很低,主要开销在前端。Vue3 的响应式系统对大数据量列表有压力。我的做法是消息列表做虚拟滚动,只渲染可视区域内的消息。用vue-virtual-scroller或者自己实现一个简单的窗口化渲染。
另外,AI 返回的长文本如果直接放在响应式对象里,每次更新都会触发大量依赖追踪。我的做法是流式输出时用普通变量累积,完成后一次性赋值给响应式对象。
6.3 用户体验细节
- 窗口关闭时如果有未保存的对话,弹确认框
- 支持快捷键
Ctrl+N新建会话,Ctrl+K清空当前对话 - 消息支持复制、重新生成、删除
- 主题跟随系统,支持手动切换深色/浅色
这些细节看起来小,但用户感知很强。我客户反馈最多的就是“用起来跟原生应用一样”,这其实就是 Wails 的优势——它本来就是原生窗口,只是内容用 Web 技术渲染。
7. 这个方案适合谁,不适合谁
如果你要做的是工具类桌面应用,不需要复杂的 Node.js 原生模块,对安装包体积和内存占用有要求,团队有 Go 背景,那 Wails v2 是非常合适的选择。我实测下来,开发效率不比 Electron 低,用户体验却好一个档次。
但如果你需要用到大量 Node.js 生态的库,或者团队完全没接触过 Go,那 Electron 可能更稳妥。Tauri 适合对体积有极致要求且愿意学 Rust 的场景。
我个人的体会是:Wails 的绑定机制设计得很优雅,Go 和前端之间的通信几乎无感。流式输出、事件推送、文件对话框这些常用功能都有现成 API,不需要自己造轮子。唯一需要注意的是 Linux 平台的编译配置,不同发行版差异较大,建议在 CI 里针对 Ubuntu 22.04 和 24.04 分别构建。
最后分享一个小技巧:Wails 的wails dev模式支持前端热重载和 Go 代码热编译,但 Go 那边改了绑定方法后,需要手动触发一次前端刷新才能看到新的绑定代码。我一般在wails.json里配一个frontend:dev:reload命令,改完 Go 代码自动刷新前端,省得手动操作。