前阵子一个朋友找我聊,说想把自己折腾了好几个月的一个 Web AI 聊天项目变成桌面应用,不想每次开个客户端还要先开浏览器,更不想让用户去处理各种环境依赖。我直接给他推荐了 Wails v2,用 Go 做后端,Vue3 做前端,一套代码打出一个绿色可执行文件,Windows 上双击就能跑,Mac 和 Linux 也分别能出对应包。他问了我一句:这玩意儿成熟吗?答案是,我已经拿它做了好几个真正在生产环境跑起来的工具,其中就包括一个接入大模型 API 的 AI 助手桌面端。这篇文章我就把整个实战过程拆开讲一遍,从技术选型到前后端通信,从流式对话到打包分发,最后附上我踩过的那些坑。适合已经熟悉基础前端开发、想低成本进入桌面应用领域,或者正在纠结 Electron/Tauri/Wails 怎么选的朋友。
1. 为什么选 Wails:打破桌面应用的刻板印象
1.1 桌面应用的技术选型对比
在开始写代码之前,先想清楚一个问题:桌面应用的技术栈到底该怎么选?很多人第一反应是 Electron,毕竟生态成熟,VS Code、Slack 都在用。但它的问题也很明显:打包体积动辄上百 MB,内存占用随随便便几百 MB,而且每个应用还要带一个 Chromium,CPU 和电池寿命都遭罪。Tauri 是近几年比较火的替方案,用 Rust 写后端,体积小性能好,但 Rust 的学习曲线对很多前端转桌面的开发者来说并不友好。Wails 则比较特殊,它把后端换成了 Go,前端依然用 Web 技术栈,但最终产物使用的是系统原生窗口,体积和内存占用都远低于 Electron,而且不需要像 Tauri 那样装一堆系统底层的编译工具链。
我实测过同一个功能的项目,Electron 打包后大概 150 MB,Tauri 大约 8 MB,Wails 大约 12 MB。Wails 比 Tauri 略大一点,但相对于 Electron 已经算是非常轻量了。内存占用上,空窗口 Electron 约为 90 MB,Wails 大概在 30 MB 上下,Tauri 则更低。如果你只需要一个桌面壳子来做工具类应用,Wails 的性价比确实相当突出。
还有一个容易被忽略的点:Go 在服务端的生态非常成熟,如果你公司内部有一些现有的 Go 服务或者工具库,接进 Wails 后端几乎不需要做任何额外适配。相比之下,Rust 后端的 Tauri 虽然也很好,但团队如果没有相应的 Rust 经验,上手成本会高出一截。这一点对我这个“后端写 Go,前端写 Vue”的组合来说,几乎是量身定做。
1.2 Wails 的核心原理与优势
Wails 的执行思路其实很简单:启动时,Go 后端启动一个本地 HTTP 服务,在系统原生窗口里加载前端构建后的静态文件。我们在 Go 里定义一些方法,绑定到前端上下文,前端就能通过window.go这个全局对象去调用。这个方法在前端和 Go 之间的通信走的是 WebSocket,数据格式是 JSON,所以效率很高,几乎感觉不到延迟。
它的核心优势主要体现在三方面。
第一,不用嵌入整个浏览器引擎。Wails 在 Windows 上使用 WebView2(基于 Chromium 但由系统安装),在 macOS 上使用 WKWebView,在 Linux 上使用 WebKitGTK。也就是说,它把渲染引擎交给系统托管,你的应用只需要代码和资源,不用担心把整个浏览器打进包。
第二,Go 后端和前端分工明确。你可以把 AI 调用的逻辑、密钥管理、本地文件读写、甚至对接系统 API 全部放在 Go 层,前端只负责交互和展示。这样做的好处是安全系数会高一个档次,因为调用大模型需要的密钥可以留在 Go 端,不会像纯 Web 项目一样直接暴露在浏览器调试工具里。
第三,开发体验非常顺滑。Wails v2 自带一个wails dev命令,它会把前端和后端一起管理,前端配置了热更新,改动 Vue 文件后页面自动刷新,Go 代码变更后也会自动重建并重启。这个开发循环比我以前用 Electron 配合 Vite 手动配代理要舒服得多。
1.3 什么样的人适合用 Wails
如果你的产品是一个工具类、效率类、或者企业内部的桌面应用,比如文档转换工具、API 调试器、聊天客户端、数据看板,Wails 是非常合适的。它不太适合的是图形渲染特别重的大型应用,比如视频剪辑、3D 编辑器,毕竟系统 WebView 的渲染能力比原生图形库还是有差距。另外,如果你的目标平台是老旧的 Windows 7,WebView2 默认装不了,需要单独处理运行时,这时候就得慎重考虑。不过目前在 Windows 10/11 和最新版 macOS/Linux 上,Wails 表现得都非常稳定。
我自己用下来感觉,Wails 最适合“我从今天开始就要做一个能交付的工具”这种场景。你不需要为桌面化花太多心思,把精力集中在业务逻辑上,效果往往比硬啃跨平台框架好得多。
2. 项目环境准备与初始化
2.1 安装 Go 和 Wails CLI
环境这块其实没有太多惊喜,但有几个版本注意点值得记录一下。首先安装 Go,我建议直接下最新的稳定版,我当时用的是 Go 1.22,Wails v2 对 Go 的版本要求比较宽松,1.18+ 就可以。Windows 上我习惯用 Chocolatey 安装,macOS 上用 Homebrew,Linux 上直接解压 tar.gz 并配置 PATH。
安装完 Go 之后,执行:
go install github.com/wailsapp/wails/v2/cmd/wails@latest这个命令会帮你完整安装 Wails CLI。安装完成后,跑一下wails doctor来验证环境。它会把缺失的依赖都列出来,比如 Windows 上有没有装 WebView2、macOS 上有没有装 Xcode Command Line Tools、Linux 上有没有安装 gtk3 和 webkit2gtk。我第一次在 Linux 的纯净服务器上装的时候,就是忘了先装这些库,结果wails dev起不来。简单来说,Ubuntu/Debian 可以这样补:
sudo apt install build-essential libgtk-3-dev libwebkit2gtk-4.1-dev别小看这一步,很多新手卡在环境上,半天没有进度,最后发现只是少了一个 libwebkit 包。
2.2 创建项目与目录结构
Wails v2 提供了一个非常顺手的脚手架命令:
wails init -n my-ai-assistant -t vue-ts这条命令会创建一个名叫my-ai-assistant的项目目录,模板使用 Vue3 + TypeScript。生成的结构是这样:
my-ai-assistant/ ├── frontend/ ├── main.go ├── app.go ├── wails.json ├── go.mod └── build/frontend是一个标准的 Vite + Vue3 + TS 项目,main.go是入口,app.go用来定义暴露给前端的 Go 方法,build目录里放着平台相关的图标和打包配置。
我通常会先把app.go里的Greet方法清理掉,这其实就是模板自带的一个例子。然后按自己的业务重新规划方法名,比如ChatService、ConfigManager、FileStore等等。保持 Go 端代码结构清晰,后面扩展功能会轻松很多。
2.3 把 Vue3 前端跑起来
进入frontend目录,装好依赖后,直接跑wails dev。这时候 Wails 会先编译 Go,然后启动 Vite dev server,并打开一个原生窗口。你在代码里改前端的文字,窗口里会实时刷新;改 Go 端的绑定方法,则需要对整个窗口进行重启。
有一个我一开始没注意到的小细节:wails dev会生成一个frontend/dist临时文件夹,如果你对这个目录里的文件做了手动调整,可能会被覆盖。所以我建议前端代码都放在frontend/src,不要动dist。这个目录纯粹是构建产物,不需要提交到版本库。
同时,Vite 的base配置要注意,Wails 模板已经帮你配置好了base: './',千万别自作主张改成/,不然打包后静态资源路径会全部错乱,窗口里只能看到一个白屏。这个是所有从纯 Web 开发转过来的同学最容易踩的坑。
3. 前后端通信:打通 Go 与 Vue3 的任督二脉
3.1 Wails 的绑定机制
Wails 之所以用起来舒服,核心在于它把 Go 方法直接暴露给了前端。比如你在app.go里定义一个结构体:
type App struct { ctx context.Context } func (a *App) startup(ctx context.Context) { a.ctx = ctx } func (a *App) GetUserInfo(id int) (map[string]interface{}, error) { // 模拟从数据库或本地缓存里拿数据 return map[string]interface{}{ "id": id, "name": "测试用户", }, nil }在main.go创建窗口时,通过Bind字段把这个结构体绑定:
err := wails.Run(&options.App{ Title: "My AI Assistant", Width: 1024, Height: 768, Assets: assets, OnStartup: func(ctx context.Context) { app.startup(ctx) }, Bind: []interface{}{ app, }, })此时前端就能通过window.go.main.App.GetUserInfo(1)这个路径调用方法。命名规则是:命名空间对应包名,然后是结构体名,最后是方法名。如果 Go 方法返回(T, error)形式的返回值,Wails 会把 error 信息一并处理,前端拿到的 result 里会有一个error字段可以判断是否成功。
这个过程底层做了很多工作,包括把 Go 方法签名注册到前端、自动生成类型定义、处理并发调用等等。也就是说,你在 Go 里可以大胆地用 goroutine 去处理耗时任务,Wails 帮你管理调用状态和回调。
3.2 在 Vue3 中调用 Go 方法
我在 Vue3 这边习惯的做法是把所有 Go 调用统一封装到一个模块里,而不是在组件里到处写window.go。好处是以后如果换成其他桥接方案,只需要改一个文件。比如我建了一个bridge.ts:
const go = (window as any).go; export const getGoApi = () => { if (!go) { throw new Error('Wails bridge not found'); } return { getUserInfo: (id: number) => go.main.App.GetUserInfo(id), }; };在组件里使用时,直接await getGoApi().getUserInfo(1)。这里有个非常容易被忽略的点:Wails 的 Go 方法返回的是一个 Promise,但如果你在 Go 方法里返回了error,前端这边 Promise 并不会 reject,而是会在返回值里带一个error字段。因此我在封装时都会习惯性地判断res.error,再决定是否 throw。我一开始照搬浏览器 fetch 的习惯写,结果所有的错误处理都失效了,排查了半天才发现这个坑。
另外,如果你的方法需要传入复杂的对象,比如一个用户配置,尽量用struct接收而不是多个零散的参数。前端调用时传对象,Wails 会自动做 JSON 序列化和反序列化。我在最初设计聊天接口时,就是直接传了message: string、history: []、temperature: number等四个参数,结果前端不好维护,后来改成传一个ChatRequest对象,一切都清爽了。
3.3 从 Go 主动推送事件到前端
很多桌面应用的业务场景并不只是“前端请求、后端响应”,还需要后端主动向窗口推送状态。比如 AI 请求完成时,窗口图标要做个闪烁;或者后端在后台处理一个文件,需要实时上报进度。Wails 提供了事件系统,后端可以通过runtime.EventsEmit发出事件:
import "github.com/wailsapp/wails/v2/pkg/runtime" func (a *App) StartBackgroundTask() { for i := 0; i <= 100; i += 10 { runtime.EventsEmit(a.ctx, "task:progress", map[string]int{"percent": i}) time.Sleep(time.Second) } }前端在 Vue3 里监听:
import { onMounted, onUnmounted } from 'vue'; const handleProgress = (event: any) => { console.log('进度', event.data.percent); }; onMounted(() => { (window as any).runtime.EventsOn('task:progress', handleProgress); }); onUnmounted(() => { (window as any).runtime.EventsOff('task:progress'); });需要特别注意的是EventsOn一定记得用EventsOff解绑,不然组件销毁后事件监听还留在那里,很容易触发重复调用。我在做聊天流式输出时,就用事件系统把 Go 端接收到的 AI 增量内容推给前端,实现了边说边显示的效果。这里流畅度很高,因为 Wails 的底层是 WebSocket,事件推送基本是毫秒级。
4. 接入 AI 能力:实现真正的 AI 聊天功能
4.1 选择 AI 接口与 SDK 封装
这个项目的核心就是把一个能聊天的 AI 助手搬上桌面。在 Go 后端,我选择直接调用大模型服务商提供的 HTTP 接口。最简单的做法是使用 OpenAI 兼容格式的 API,这样未来换模型比较方便。Go 标准库自带net/http,不需要额外引入大型 SDK,我习惯写一个轻量的aiClient包。
下面是核心请求结构:
type chatRequest struct { Model string `json:"model"` Messages []message `json:"messages"` Stream bool `json:"stream"` } type message struct { Role string `json:"role"` Content string `json:"content"` }然后通过http.Client发 POST 请求,拿到响应结果。这里有个重要经验:永远把调用模型的密钥放在 Go 端,不要传给前端。在纯 Web 项目里,前端代码都是公开的,密钥很容易被扒出来。但 Wails 桌面应用中 Go 代码是编译成二进制的,相对难提取。当然,如果有人逆向还是能拿到内存里的值,但绝大多数场景已经够安全。
如果你的 AI 服务商要求请求头里带Authorization,那就更简单了。Go 后端负责加鉴权头,前端只需要传用户消息即可。我还建议把 API 地址和模型名也在 Go 端配置,前端通过一个GetConfig()方法获取,这样将来换模型时不用重新打包前端,只改配置文件就行。
4.2 实现流式对话与上下文管理
AI 对话最影响体验的地方就是流式输出。如果等所有响应生成完毕再一次性显示,用户会感觉应用“卡住了”。我在 Go 端把接口设置为"stream": true,然后读取响应体中的 SSE(Server-Sent Events)数据流,每接收到一段文本就往前端发一个事件。
具体在 Go 里,我是这样处理的:
func (a *App) StreamChat(userMessage string) { // 组装 messages 发送到模型 API req := buildChatRequest(userMessage, a.history) resp, err := a.client.Do(req) if err != nil { runtime.EventsEmit(a.ctx, "chat:error", err.Error()) return } defer resp.Body.Close() scanner := bufio.NewScanner(resp.Body) for scanner.Scan() { line := scanner.Text() if !strings.HasPrefix(line, "data:") { continue } data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) if data == "[DONE]" { break } // 解析 JSON,取出 delta.content var chunk map[string]interface{} json.Unmarshal([]byte(data), &chunk) // 提取 content,然后: runtime.EventsEmit(a.ctx, "chat:token", content) } }这里要注意几个坑。第一,SSE 数据流的每一行可能被拆分成多条消息,需要用bufio.Scanner逐行读取,而且有些服务商的换行符可能是\r\n,Scanner 默认会处理,但如果读到空行导致中断,建议自建一个strings.Split并判断长度。第二,模型返回的delta.content可能为空,比如只传了 role 而没有内容,这种情况必须直接跳过,否则前端会把空字符串渲染出来。第三,流式接口千万不要用json.NewDecoder一次性解码整个 body,因为 body 一直在追加内容,解码器读到 EOF 才结束,相当于又等整段返回。
上下文管理方面,我采用的是最简单的滑动窗口策略,在 Go 内存中维护一个[]message,每次请求都把用户消息和 AI 回复追加进去,但只保留最近 20 条。这样既能保证对话连贯,又不会让 context 无限膨胀导致请求 token 超限和延迟升高。如果你的目标场景是长文档问答,后续可以引入向量数据库做外挂知识库,但这里先保持轻量。
4.3 处理内容安全与合规
在这个项目里,我只是个工具开发者,所以我的处理方式是:在 Go 端对收到的用户输入和模型输出做一次通用、基础的合规检查。比如对明显的违法违规词汇做关键字拦截,对涉黄涉暴力的内容直接过滤。但更严谨的做法是使用服务商自带的内容审核接口,或者在本机装一个可配置的敏感词库表,前端不做任何屏蔽,把检查逻辑完全放在 Go 层。
有一个经验想分享:很多人担心接 AI 会碰到“边界”问题,但其实桌面应用有一点好处,就是你可以完全控制 Go 后端逻辑,随时可以增加审核策略。比如在发送请求前先跑一遍本地敏感词检测,如果命中就不向 AI 发送;在拿到返回结果后再跑一遍检测,命中就替换成一句“抱歉,该内容无法展示”。这样虽然不能保证百分之百拦住所有违规内容,但对于自用或内部工具来说已经能规避大部分风险。
安全这块另一个值得注意的点是历史记录管理。AI 对话应用一定会涉及用户隐私。我在工程里让所有对话记录都保存在本地 SQLite 里,并且用系统权限控制文件访问,不经过第三方云存储。如果你想把记录同步到云端,建议先做用户授权和加密,不要在未确认的情况下自动上传用户输入内容。这是桌面应用天然的责任边界。
4.4 交互体验优化:markdown 渲染、代码高亮、语音输入等
纯文本输出只是最基础的形态。实际做完一轮流式对话之后,你会发现 AI 回复里经常带代码块和表格,如果前端只用<p>标签渲染,非常影响阅读。这里我用 Vue3 的v-html加一个轻量级 markdown 渲染库,比如markdown-it,再配合highlight.js做代码高亮。核心代码如下:
<template> <div class="message-content" v-html="renderMarkdown(content)"></div> </template> <script setup lang="ts"> import MarkdownIt from 'markdown-it'; import hljs from 'highlight.js'; const md = new MarkdownIt({ highlight: (str, lang) => { if (lang && hljs.getLanguage(lang)) { return hljs.highlight(str, { language: lang }).value; } return ''; }, }); const renderMarkdown = (raw: string) => md.render(raw); </script>我建议把渲染逻辑放在components的一个MarkdownBlock组件里,因为富文本渲染稍有不慎就会引入 XSS 风险,需要自己写一个sanitize步骤。Wails 应用的窗口毕竟是本地的,风险比浏览器低,但最好还是用成熟的dompurify做一下清洗,尤其是从网络返回的内容,保持警惕总没错。
语音输入是提升 AI 助手实用性的一个大杀器。我在 Go 端调用了本机的麦克风 API,先把语音录下来,再通过当前服务商的语音识别接口转成文本,最后走正常的对话链路。如果你的语音识别服务商是独立的,可以在 Go 端把它封装成transcribe(filePath string) (string, error),前端只管传一个本地音频文件路径。做完这一步,整个应用的“智能”感觉会立刻上一个台阶。
5. 打包与分发:让应用真正“能用”
5.1 图标与资源处理
如果你只在开发模式下跑,那无所谓;一旦要发给别人用,应用图标必须处理。在 Windows 上,Wails 需要的是.ico文件,macOS 需要.icns文件,Linux 则直接用 PNG。大多数前端设计稿出来的都是 PNG,所以你需要做一次格式转换。
Wails 贴心地提供了wails generate icon命令:
wails generate icon -input app.png它会自动生成所有平台需要的尺寸和格式,放在build目录里。这里有一个坑:如果你的图标包含透明通道,生成 Windows ICO 时可能边缘会有杂边,建议先把源图标周围留一点 padding。否则窗口标题栏和任务栏图标边缘会有糊感。
如果后续还想改应用名称、公司信息、版本号,Windows 上主要改build/windows/installer里的配置文件,macOS 上则是build/darwin/Info.plist。Wails 模板默认给齐全,但很多人在打包后才想起来改,只能重新走一遍构建流程,所以建议项目一开始就自定义这些信息。
5.2 交叉编译与平台分发
Wails v2 构建时分别跑wails build即可。它会把前端构建好、嵌入 Go 二进制,然后输出平台对应的可执行文件。Windows 上直接生成.exe;macOS 上如果你需要.app,要再跑wails build -platform darwin -clean,输出之后拖到 Finder 里做成应用程序包。Linux 上默认生成一个二进制文件,可以用electron-builder类似的工具帮它做成 deb/rpm,但 Wails 自带的比较简单,我一般直接用wails build -platform linux生成可执行文件,然后用系统的dpkg-deb打包。
这里最需要注意的是交叉编译。如果你在 Windows 上开发,想给 Mac 或者 Linux 出包,Wails 需要你安装对应的交叉编译工具链,否则无法顺利编译。我实际测试下来,最省事的方式是在目标系统上做构建,比如 Mac 上的包就拿到 Mac 上构建,Linux 上的包在 Linux 的 CI 服务器上构建。你可以用 GitHub Actions 或者 GitLab CI 做矩阵构建,分别在 windows-latest、macos-latest、ubuntu-latest 上跑wails build,然后自动上传产物。这种办法远比本地交叉编译稳定。
另外,Windows 上如果用户机器没有安装 WebView2 运行时,程序会直接白屏。Wails v2 配置好的安装器里会包含 WebView2 的常青版引导,一般会自动安装,但如果你的用户环境是离线内网,需要手动下载离线安装包。我在部署企业内部工具时就遇到过这个情况,最后要求 IT 部门统一推送 WebView2 Bootstrapper 才解决。
5.3 自动更新与日志
做桌面应用最容易被忽略但最终一定会被cue到的功能,就是自动更新。Wails 本身没有内置完善的上线更新机制,但你可以自己在 Go 端写一个版本检查。基本思路是:启动时请求你的官网或 GitHub Releases 的接口,比对版本号,如果有新版本,通过runtime.EventsEmit通知前端渲染一个“发现新版本”的浮层,然后用户点击后下载安装包。
我只在内部使用场景里实现了一个“下载更新包后静默解压”的简化版本。如果你的应用涉及正式发布,建议去调研 wails 社区里的go-update方案,结合打包机制做更可靠的全量更新。
日志是个容易被忽略但实际排查问题时的救命稻草。Wails 默认没有把 Go 程序的fmt.Println显示出来,也不会写系统日志。我习惯在 Go 端自建一个简单的日志包,把日志写到os.UserConfigDir()下的logs目录里。比如:
func writeLog(level, msg string) { logPath := filepath.Join(os.UserConfigDir(), "my-ai-assistant", "logs") os.MkdirAll(logPath, 0755) f, _ := os.OpenFile(filepath.Join(logPath, "app.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) defer f.Close() f.WriteString(fmt.Sprintf("[%s] %s\n", level, msg)) }前端那边可以用浏览器 DevTools 查看console.log的报错,但 Go 端的信息你是看不到的,所以一定要主动把关键步骤的日志落盘。尤其是接 AI 接口时,模型请求和响应体的原始 dump 值得保存,因为很多问题根本没法从前端排查。
6. 常见问题与排查技巧实录
6.1 Go 方法无法调用
这是一个高频问题。现象是前端调用window.go.main.App.XXX()时返回 undefined,或者控制台提示方法不存在。原因通常有三个:
- 结构体没有在
Bind里绑定。你只在app.go里定义了方法,但没把它加进wails.Run的Bind列表。 - 方法是私有的(小写开头)。Wails 只能绑定导出方法,必须以大写字母开头。
- 方法的返回类型不符。Wails 要求方法必须是可序列化的,返回值最好用基础类型、struct 或
map[string]interface{}。如果你返回了一个含有chan或func字段的对象,序列化会失败,方法也会注册失败。
我建议在app.go中每一个绑定方法都加上error返回值,因为 Wails 的绑定机制对 error 有着天然的支持,能让你排查问题更容易。还有一个小技巧:在 Go 端跑wails dev时,命令行里会打印注册了哪些方法,如果发现有Failed to bind,就说明那边出了问题。
6.2 前端静态资源加载失败
如果你启动了wails dev后窗口是一片空白,先打开浏览器的 DevTools(在窗口里右键——检查),看 Console 的网络标签有没有资源 404。最常见的原因是 Vite 的base没有被设置成相对路径。第二个原因是你用了某些前端路由插件,比如 Vue Router 的 history 模式,刷新会出现白屏。
因为 Wails 最终加载的是本地静态文件,它没有真正的服务端,无法支援 history 模式的路由刷新。要么改用 hash 模式,要么在路由前进后退时采用window.location.hash方式。我在这边吃了大亏,一开始用 Vue Router 的 createWebHistory,结果每次点击刷新窗口都直接空白,后来切回 createWebHashHistory 就稳定了。
6.3 窗口白屏与刷新问题
还有一种白屏,不是静态资源路径的问题,而是 WebView2 本身的缓存。Windows 上如果你改了图标、重命名了可执行文件,可能在更新后仍然加载旧的缓存页面。解决办法是在main.go里设置Options时指定WindowStartState: options.Maximised或添加ForceSafariWebView之类的调试选项没有用,真正有效的是在OnStartup里执行runtime.WindowReload(ctx)。
但更常见的源头是你在开发模式下改了frontend目录里 dist 资源但没有重新构建。记住:Wails 运行wails dev会让 Vite 在内存里热更新,但如果手动改了dist,你可能需要重启wails dev才能生效。这个坑新人踩一次基本就记住了。
6.4 内存与性能优化
Wails 应用内存占用虽然比 Electron 低,但如果你不做任何优化,还是会看着本地 WebView 的内存缓慢增长。尤其是 AI 聊天流式输出时,markdown-it渲染大段文本很容易卡顿。我这边做了三件事来缓解:
- 控制 DOM 数量。聊天记录只渲染最近 50 条,更早的自动滚动到虚拟列表,或者直接丢弃 innerHTML,改用简单文本缓存。
- 流式输出节流。Go 端推送事件可以每 30ms 合并一次,减少前端渲染频率。我在 Go 里用一个简单的定时批量发送,每次收到多个 token 后攒一个小数组再 emit,前端渲染压力骤减。
- 用 Memoization。Vue3 的
computed配合props使用,避免每个 token 到达都对整个组件重新渲染。只在content改变时才重新生成 markdown。
如果你发现 CPU 占用飙升,大概率是渲染频繁。还可以把“发送中”状态和“流式输出中”状态独立开,不要让打字指示器也跟着一起刷新。
我可以这样分享一个经验:Wails 的事件系统非常强大,但在高并发推送时一定要控制频率。我曾经一口气把整个 AI 回复做成几千个独立事件推送,前端直接卡死。改成节流合并后,哪怕一篇文章 3000 字,渲染依然顺滑。
7. 写在最后:我的一些实操体会
动手做这个 AI 桌面应用之前,我原本也纠结过是不是该用 Electron,毕竟资料多、社区大。但真正写完这个项目后,我的体会很明确:如果你的目标是做一个轻量、可快速交付的本地工具,Wails v2 的体验是超出预期的。它让你不用被迫接受一个“其实装了一整个浏览器”的臃肿产物,又能毫无隔阂地复用你已有的 Vue3 和 Go 技能。
最后再分享一个小技巧:当你用wails build构建正式包之前,记得运行wails build -clean,把以前的构建缓存清掉。我第一次就是没清理,打包出来的 exe 还在用旧的前端静态资源,导致 UI 改了一堆却毫无变化。清掉之后立刻好了。
如果你也准备把自己的 AI 想法做成一个真正的桌面端产品,我很建议你从 Wails v2 开始试水。花一个周末把环境搭好、跑通一个带流式对话的例子,后面的事就会顺利很多。你会发现自己既在用前端的舒适区做交敖,又享受 Go 的简洁和性能,这个组合值得认真对待。