
1. 为什么我最终选了 Wails v2 而不是 Electron1.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 的横向对比选型阶段我把三个方案都跑了一遍数据如下维度ElectronTauriWails v2安装包体积Windows80-120MB5-10MB6-12MB空闲内存占用250-400MB50-80MB50-90MB后端语言Node.jsRustGo前端支持任意任意任意学习曲线低高Rust中Go生态成熟度极高中中系统 WebView 依赖无有有Tauri 的体积和内存表现确实好但 Rust 的学习成本对我来说太高了。我 Go 写了五六年用 Wails 可以做到当天上手当天出原型。而且 Wails 的绑定机制比 Tauri 的 command 系统更直观Go 那边写个方法前端直接调不需要额外定义 schema。Electron 的优势在于生态但如果你做的是工具类应用不需要复杂的 Node.js 原生模块Wails 完全够用而且用户体验好太多。注意Wails 依赖系统 WebViewWindows 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/wailslatest装完之后执行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 SupportLinux 是~/.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 classchat-container div classmessage-list reflistRef ChatMessage v-formsg in messages :keymsg.id :messagemsg / /div ChatInput sendhandleSend :disabledloading / /div /template输入框这边有个细节用户按Enter发送按ShiftEnter换行。这个交互在桌面端很常见实现起来也简单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.0Ubuntu 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 -wLinux 编译失败WebKitGTK 版本不匹配加对应 build tag5.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 用户体验细节窗口关闭时如果有未保存的对话弹确认框支持快捷键CtrlN新建会话CtrlK清空当前对话消息支持复制、重新生成、删除主题跟随系统支持手动切换深色/浅色这些细节看起来小但用户感知很强。我客户反馈最多的就是“用起来跟原生应用一样”这其实就是 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 代码自动刷新前端省得手动操作。