用 WebView 给你的破旧 CLI 工具换一张现代化的脸

用 WebView 给你的破旧 CLI 工具换一张现代化的脸

MoGuQAQ Lv4

去年我写了一个网速测试小工具,Go 语言的,核心逻辑不到 400 行。它能自动检测你的 IP 地址和运营商,智能选择最优测速节点,支持单线程、多线程、多节点测速——用 goroutine 并发下载,10 秒出结果。自己用着挺爽,终端一开,回车一敲,喝口水的功夫数据就出来了。

然后有一天,室友让我帮忙测一下宿舍网速。

我打开终端跑了一下,屏幕上先是滚出 IP 地址、城市、运营商,然后是一堆 emoji 和等号围起来的节点信息,接着让我输 1/2/3/4 选模式,我输了个 2,又让我输线程数,我输了个 8,然后又开始滚……10 秒后终于停了,屏幕上出现了一大段结果,夹杂着 🎭 📍 🚀 📶 各种 emoji。

他盯着满屏的文本和符号,沉默了两秒,问了一句:”所以到底多快?”

我指着那行 📶 测速速度 : 15.31 MB/s 说:”就这个,15.31 兆每秒。”

他说:”哦,能给个截图吗?”

我截了一个终端窗口发过去。

他回了一个”…好吧”。

那个省略号我记了很久。那不是满意,那是一种”行吧我忍了”。

cli程序


一、CLI 地狱是真实存在的

做后端的同学,大概都经历过这个阶段:你写了一个很有用的工具,逻辑清晰,性能也好,就是……用起来让人想说点什么。

我这工具还是”交互型”的——不像那些指令型 CLI 要记一堆 --flag 参数,启动就跑,有菜单引导。听起来比 --help 友好多了对吧?但交互式也有交互式的痛苦,而且痛苦的方式不太一样:

痛苦一:重复信息,看三遍

启动打印一次节点信息,选完模式又打印一次,测完再打印一次结果。你仔细看终端输出——同一段”节点名称: 广东移动-深圳”在屏幕上出现了三次。每一块信息都被 ===== 和 emoji 包裹着,看起来很隆重,但信息密度极低。你想找的关键数据,被淹没在一堆装饰性的边框里。就像一份实验报告,每一页都重新写了课程名称和学号,但你真正关心的只有那几个数字。

痛苦二:结果全是文本,对比全靠心算

多节点测速跑完,终端里依次打印每个节点的速度。想比较哪个快?你在脑子里记数、排序。三个节点还能勉强记住,要是节点多一点,你得往上翻屏——哦对不起,终端缓冲区有限,前面滚过去的已经翻没了。更别提上传速度和下载速度混在一起,你得自己分辨哪个是哪个。

痛苦三:没有历史,测完即焚

今天测了 15.31 MB/s,昨天测了多少?不知道。上周呢?不知道。想看看是不是网络变差了?你得自己开个 Excel 手动记。可现实是没人会去记——终端关了,数据就没了,像从来没有测过一样。你永远只能看到”当前”这一次的结果,完全没有纵向对比的能力。

痛苦四:给非技术的人看结果,只能贴截图

终端窗口截图发给别人,对方一脸茫然。那些 🎭 📍 🚀 对我们程序员来说是信息,对别人来说是噪音。他只想知道一个数字——“网速多少?”——但他要在满屏花里胡哨的文本里找半天。更尴尬的是单位问题:你输出的是 MB/s,对方问的是兆宽带,你得在脑子里做除法再乘 8,解释半天对方还是一脸问号。

有次室友问我:”咱们宿舍的网速能跑满千兆吗?”

我说:”能,上次测了 15.31 MB/s……等等不对,那是兆字节每秒,换算一下乘 8 是 122 Mbps……嗯,没跑满千兆。”

他看了我一眼:”你能给我个图吗?”

我说:”我给你截个终端。”

他拿起水杯喝了口水,眼神飘向了窗外。

那一刻我意识到:不是我的工具不好,是我的工具没有脸。


二、想加个 GUI?你去试试就知道有多难受

“那就加个图形界面呗。”

好,来试试。

Qt:C++ 写的,Python 有 PyQt 和 PySide 绑定,功能强,跨平台。听起来很美,打开文档之后——信号与槽机制、QWidget 继承体系、布局管理器……还没开始写功能,光是搞清楚怎么放一个按钮就花了两小时。而且界面默认长相,2010 年感很强。想搞现代感的 UI,你得手写 QSS,那玩意儿像 CSS 又不太像,写起来像在用一门方言翻译另一门方言。

Tkinter:Python 标准库自带,”零成本”。但你见过它默认渲染出来的界面吗?灰色的、方方正正的按钮,在 macOS 上看起来像是从 2003 年穿越过来的。布局用 packgridplace 三套系统,新手进去像是在打麻将——规则你都懂,但总感觉差点什么。

wxWidgets:调用原生系统控件,界面还行,但绑定不活跃,社区小,遇到问题 Stack Overflow 上的答案都是 2012 年的,你不确定它还能用不能用。

这些方案有一个共同问题:你要学的东西,和你本职工作完全不重叠。你学的不是”怎么展示数据”,而是”这个 GUI 框架的私有世界观”。

换个角度想:前端现在有 React、Vue、ECharts、Tailwind CSS,生态丰富到令人发指,做出来的东西也确实好看。问题只有一个——怎么把前端页面嵌进你的程序?

这就是 WebView 要解决的问题。


三、WebView 的本质:把浏览器塞进你的程序

WebView 这个词,移动端同学比较熟悉——Android 和 iOS 里都有 WebView 组件,可以在 App 里渲染网页。

桌面端的逻辑完全一样:你的宿主程序(Go/Python/Rust)启动一个浏览器内核的渲染窗口,在这个窗口里跑 HTML + CSS + JavaScript,界面的渲染全权交给浏览器,宿主程序只负责业务逻辑和数据

webview

你可能马上想到 Electron:GitHub Desktop、VS Code、Figma……都是用 Electron 做的,本质上也是 Chromium + Node.js。为什么不用 Electron?

主要差距在这里:

对比项ElectronWebView 方案
运行时捆绑完整 Chromium + Node.js调用系统内置 WebView(Edge/WebKit)
打包体积一般 80MB 起步通常 5-20MB
内存占用较高较低
开发语言JS/TS(主进程)任意后端语言
生态极丰富视方案而定

WebView 方案调用的是操作系统自带的浏览器内核:macOS/iOS 上是 WKWebView(WebKit),Windows 上是 WebView2(Chromium 内核,Win10/11 预装),Linux 上一般用 WebKitGTK。这意味着你不需要打包一个完整的浏览器进去,包体积小很多。

代价是:不同平台的 WebView 版本和兼容性有差异,偶尔会踩到一些微妙的 CSS 或 JS API 不支持的坑。但对于内部工具来说,这完全可以接受。


四、主流方案横评:各语言怎么选

Go:webview 和 wails

Wails

Go 社区有两个主力选手。

webview 是最经典的轻量方案。接口极简,创建窗口、设置 URL、绑定 Go 函数到 JS,就这三件事。没有脚手架,没有路由,你自己决定怎么塞 HTML 进去。适合场景:小工具、内部工具、对包体积敏感的场景,或者你就是想搞清楚原理再用框架。

wails 是现代化的完整方案。它有 CLI 脚手架,跑一句 wails init 就能选模板(React、Vue、Svelte 都有),前后端分离开发,热重载,内置构建工具链。适合场景:想认真做一个桌面应用,需要复杂交互,团队里有前端同学可以配合。

1
2
3
4
5
6
7
8
9
10
11
12
# wails 初始化项目
wails init -n speedtest-ui -t react

# 目录结构
speedtest-ui/
├── main.go # 主程序入口
├── app.go # 业务逻辑,暴露给前端的方法写这里
├── frontend/ # 标准的 React 项目
│ ├── src/
│ │ └── App.jsx
│ └── package.json
└── wails.json # 项目配置

Python:pywebview

pywebview 是 Python 里最成熟的 WebView 方案,跨平台(Windows/macOS/Linux/Android),API 设计也算简洁。

1
2
3
4
5
6
7
8
9
10
11
12
import webview

def get_data():
return {"cpu": 42, "mem": 67}

if __name__ == '__main__':
window = webview.create_window(
'Monitor',
html='<h1>Hello</h1>',
js_api=get_data # 把 Python 函数暴露给 JS
)
webview.start()

适合:已经有 Python 写的业务逻辑,不想迁移语言,需要快速加个界面。

Tauri 官方有 Python 绑定的实验项目,但目前成熟度不高,生产环境建议还是 pywebview。

Rust:Tauri

Tauri 目前是整个 WebView 生态里最活跃的项目。后端用 Rust,前端随便选,性能优秀,包体积也很小。社区文档做得很好,插件生态在快速成长。

如果你会 Rust,Tauri 几乎是最优解。不会 Rust 的话,学习曲线在后端语言这侧,不划算。

Java / C++:JCEF 和 CEF

Java 可以用 JCEF(Java Chromium Embedded Framework)。优点是和 JVM 生态无缝集成,缺点是 CEF 本身需要一起分发,包体积偏大,配置也稍复杂。如果你的项目本来就是 Java 桌面应用(JavaFX 或 Swing),集成 JCEF 是合理的选择。

C++ 直接用 CEF(Chromium Embedded Framework),这是这些方案的”始祖”,Chrome 浏览器本身就是基于 CEF 衍生出来的。功能全,但配置繁琐,适合有较强工程能力的团队。


五、动手实战:用 Go + wails 做一个测速可视化面板

我们把那个纯终端的测速工具,改造成一个有界面的版本。先想清楚界面要解决什么问题:大数字卡片展示下载/上传速度——不用在文本里找数字了;多节点对比柱状图——不用心算了;测速历史折线图——测完不再即焚;节点用下拉框选——不用输数字了。

说白了,就是把 CLI 里让人痛苦的四个点,一个一个用界面解决掉。

初始化项目:

1
2
3
go install github.com/wailsapp/wails/v2/cmd/wails@latest
wails init -n speedtest-ui -t vanilla # 用原生 JS 模板,不引入框架
cd speedtest-ui

后端逻辑(app.go):

这里的关键思路是:不重写测速逻辑,直接复用 speedtest-gd 项目的 runtime 包。原来终端里调的那些方法——SelectBestNode()SingleThreadTest()MultiThreadTest()MultiNodeTest()——一个都不用改,wails 只负责把它们暴露给前端。这也是 WebView 方案最大的优势:你的后端代码是现成的,加界面只是在外面套一层壳。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
package main

import (
"context"
"speedtest-gd/global"
"speedtest-gd/runtime"
"speedtest-gd/utils"
)

// App 是 wails 暴露给前端的主结构体
type App struct {
ctx context.Context
}

func NewApp() *App {
return &App{}
}

func (a *App) startup(ctx context.Context) {
a.ctx = ctx
// 启动时初始化:加载在线节点、检测用户 IP
runtime.InitGlobal()
// 加载本地自定义节点
localAgents, _ := runtime.LoadAllLocalAgents("local")
global.GlobalApacheAgents = utils.MergeUnique(
global.GlobalApacheAgents, localAgents,
)
}

// ClientInfo 返回用户 IP、城市、运营商信息
type ClientInfo struct {
IP string `json:"ip"`
City string `json:"city"`
ISP string `json:"isp"`
}

// GetClientInfo 获取用户网络信息,前端加载时调用
func (a *App) GetClientInfo() ClientInfo {
info := global.GlobalClientInfo
return ClientInfo{
IP: info.HostIP,
City: info.City,
ISP: info.ISP,
}
}

// NodeOption 是下拉框选项
type NodeOption struct {
Name string `json:"name"`
Description string `json:"description"`
HostIP string `json:"hostIp"`
MaxSpeedGbps float64 `json:"maxSpeedGbps"`
}

// GetNodes 返回所有可选节点,前端渲染下拉框
func (a *App) GetNodes() []NodeOption {
best, _ := runtime.SelectBestNode()
nodes := make([]NodeOption, 0, len(global.GlobalApacheAgents))
for _, agent := range global.GlobalApacheAgents {
nodes = append(nodes, NodeOption{
Name: agent.Name,
Description: agent.Description,
HostIP: agent.HostIP,
MaxSpeedGbps: utils.BandwidthToGbps(agent.BandWidth),
})
}
// 把最优节点放到第一个
_ = best
return nodes
}

// TestResult 是单次测速结果
type TestResult struct {
NodeName string `json:"nodeName"`
SpeedMBps float64 `json:"speedMbps"`
DurationMs int64 `json:"durationMs"`
Threads int `json:"threads"`
TotalDataMB float64 `json:"totalDataMb"`
}

// StartTest 核心方法:前端点击"开始测速"时调用
// mode: 1=单线程 2=多线程 3=多节点
// threads: 多线程时的并发数
func (a *App) StartTest(mode int, threads int) TestResult {
bestNode, _ := runtime.SelectBestNode()

var result global.SpeedTestResult
switch mode {
case 1:
result = runtime.SingleThreadTest(*bestNode)
case 2:
result = runtime.MultiThreadTest(*bestNode, threads)
case 3:
// 多节点测速,返回第一个结果作为示例
results := runtime.MultiNodeTest(*bestNode)
if len(results) > 0 {
result = results[0]
}
}

return TestResult{
NodeName: result.NodeName,
SpeedMBps: result.SpeedKBps / 1024,
DurationMs: result.DurationMs,
Threads: result.Threads,
TotalDataMB: result.TotalData / 1024 / 1024,
}
}

前端页面(frontend/index.html):

引入 ECharts 做图表渲染,深色主题,大数字卡片 + 折线图 + 柱状图。界面分三个区域:顶部是用户信息和三个大数字卡片(下载速度、上传速度、延迟),中间是操作区(节点下拉框、模式选择、开始按钮),底部是两个图表(历史折线图和多节点对比柱状图)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>Speedtest</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f172a; color: #e2e8f0;
padding: 24px;
}
.header { margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; }
.header h1 { font-size: 20px; font-weight: 600; }
.header .user-info { font-size: 13px; color: #64748b; }
.stats-row {
display: flex; gap: 16px; margin-bottom: 24px;
}
.stat-card {
flex: 1; background: #1e293b;
border-radius: 10px; padding: 20px;
text-align: center;
}
.stat-card .label { font-size: 12px; color: #64748b; margin-bottom: 6px; }
.stat-card .value { font-size: 36px; font-weight: 700; }
.stat-card .value.download { color: #38bdf8; }
.stat-card .value.upload { color: #a78bfa; }
.stat-card .unit { font-size: 14px; color: #64748b; margin-left: 4px; }
.control-row {
display: flex; gap: 12px; margin-bottom: 24px; align-items: center;
}
.control-row select, .control-row input {
background: #1e293b; color: #e2e8f0; border: 1px solid #334155;
border-radius: 6px; padding: 8px 12px; font-size: 13px;
}
.control-row button {
background: #38bdf8; color: #0f172a; border: none;
border-radius: 6px; padding: 8px 20px; font-size: 13px;
font-weight: 600; cursor: pointer;
}
.control-row button:hover { background: #7dd3fc; }
.charts-row { display: flex; gap: 16px; }
.chart-box {
flex: 1; background: #1e293b; border-radius: 10px;
padding: 16px; height: 280px;
}
.chart-box .chart-title { font-size: 13px; color: #94a3b8; margin-bottom: 8px; }
</style>
</head>
<body>
<div class="header">
<h1>Speedtest</h1>
<div class="user-info" id="userInfo">加载中...</div>
</div>

<div class="stats-row">
<div class="stat-card">
<div class="label">下载速度</div>
<div class="value download" id="downloadVal">--<span class="unit">MB/s</span></div>
</div>
<div class="stat-card">
<div class="label">上传速度</div>
<div class="value upload" id="uploadVal">--<span class="unit">MB/s</span></div>
</div>
<div class="stat-card">
<div class="label">延迟</div>
<div class="value" id="pingVal" style="color:#4ade80">--<span class="unit">ms</span></div>
</div>
</div>

<div class="control-row">
<select id="nodeSelect"><option>自动选择最优节点</option></select>
<select id="modeSelect">
<option value="1">单线程</option>
<option value="2" selected>多线程 (8)</option>
<option value="3">多节点</option>
</select>
<button id="startBtn" onclick="runTest()">开始测速</button>
</div>

<div class="charts-row">
<div class="chart-box">
<div class="chart-title">测速历史</div>
<div id="historyChart" style="width:100%;height:240px;"></div>
</div>
<div class="chart-box">
<div class="chart-title">多节点对比</div>
<div id="compareChart" style="width:100%;height:240px;"></div>
</div>
</div>

<script type="module" src="/main.js"></script>
</body>
</html>

前端 JS 逻辑(frontend/main.js):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
import { GetClientInfo, GetNodes, StartTest } from '../wailsjs/go/main/App.js'

// 历史数据
const historyData = []
const historyLabels = []

// 初始化:加载用户信息和节点列表
async function init() {
const info = await GetClientInfo()
document.getElementById('userInfo').textContent =
`${info.ip} | ${info.city} · ${info.isp}`

const nodes = await GetNodes()
const select = document.getElementById('nodeSelect')
nodes.forEach((n, i) => {
const opt = document.createElement('option')
opt.value = i
opt.textContent = `${n.name} (${n.maxSpeedGbps.toFixed(1)} Gbps)`
select.appendChild(opt)
})
}

init()

// 初始化图表
const historyChart = echarts.init(document.getElementById('historyChart'))
const compareChart = echarts.init(document.getElementById('compareChart'))

historyChart.setOption({
animation: false,
grid: { top: 10, bottom: 30, left: 50, right: 20 },
xAxis: { type: 'category', data: [],
axisLabel: { color: '#64748b', fontSize: 11 },
axisLine: { lineStyle: { color: '#334155' } },
},
yAxis: { type: 'value',
axisLabel: { color: '#64748b', fontSize: 11, formatter: '{value}' },
splitLine: { lineStyle: { color: '#1e293b' } },
},
series: [{
name: '下载', type: 'line', smooth: true, data: [],
lineStyle: { color: '#38bdf8', width: 2 },
areaStyle: { color: 'rgba(56,189,248,0.15)' },
symbol: 'circle', symbolSize: 6,
}, {
name: '上传', type: 'line', smooth: true, data: [],
lineStyle: { color: '#a78bfa', width: 2 },
areaStyle: { color: 'rgba(167,139,250,0.15)' },
symbol: 'circle', symbolSize: 6,
}],
})

compareChart.setOption({
animation: false,
grid: { top: 10, bottom: 30, left: 80, right: 20 },
xAxis: { type: 'value',
axisLabel: { color: '#64748b', fontSize: 11, formatter: '{value}' },
splitLine: { lineStyle: { color: '#1e293b' } },
},
yAxis: { type: 'category', data: [],
axisLabel: { color: '#64748b', fontSize: 11 },
axisLine: { lineStyle: { color: '#334155' } },
},
series: [{
type: 'bar', data: [],
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#38bdf8' },
{ offset: 1, color: '#818cf8' },
]),
borderRadius: [0, 4, 4, 0],
},
barWidth: 16,
}],
})

// 核心测速逻辑
async function runTest() {
const btn = document.getElementById('startBtn')
btn.textContent = '测速中...'
btn.disabled = true

const mode = parseInt(document.getElementById('modeSelect').value)
const threads = mode === 2 ? 8 : 1

try {
const result = await StartTest(mode, threads)

// 更新大数字卡片
document.getElementById('downloadVal').innerHTML =
`${result.speedMbps.toFixed(2)}<span class="unit">MB/s</span>`

// 更新历史折线图
const timeStr = new Date().toLocaleTimeString()
historyLabels.push(timeStr)
historyData.push(result.speedMbps.toFixed(2))
if (historyLabels.length > 20) {
historyLabels.shift()
historyData.shift()
}
historyChart.setOption({
xAxis: { data: historyLabels },
series: [{ data: historyData }],
})
} catch (err) {
console.error('测速失败:', err)
} finally {
btn.textContent = '开始测速'
btn.disabled = false
}
}

speedtest

运行起来:

1
2
wails dev   # 开发模式,前端热重载
wails build # 打包成单一可执行文件

注意这里有个细节:app.go 里的 StartTest 方法是同步阻塞的——Go 侧跑完 10 秒测速,整个方法才返回。这意味着在测速期间,前端点击按钮后界面会”冻住”,因为 JS 在 await 结果。对于小工具来说,这种体验勉强能接受,但如果你想要测速过程中实时显示进度(比如每秒更新一次当前速度),就需要用到 wails 的事件机制:Go 侧用 runtime.EventsEmit() 主动向 JS 推送进度,JS 侧用 runtime.EventsOn() 监听。这个话题展开就长了,这里先点到为止。

Windows 上打包出来大约 8-12MB。和 Electron 的 80MB+ 比起来,确实瘦了不少。


六、前后端通信:JS Bridge 是怎么工作的

这是整个 WebView 方案里最值得深入理解的一块,因为它决定了你的架构怎么设计。理解了 JS Bridge,你就理解了”前端调后端”这件事在桌面应用里到底是怎么发生的。

WebView 里运行的 JS 代码,默认是个”沙盒”——它访问不了文件系统,调不了系统 API,就是个普通浏览器环境。你的测速逻辑全在 Go 那边,JS 想触发一次测速、拿回结果,中间必须有一座桥。这座桥,就是 JS Bridge。

模式一:直接函数绑定(wails 的方式)

wails 会在应用启动时,把你在 Go 结构体上标注的 public 方法,自动生成对应的 JS 包装函数,注入到前端。前端调用 StartTest(2, 8) 就像调普通异步函数一样,框架在底层替你处理了序列化和跨进程通信。你在 JS 里 await StartTest(2, 8),拿到的就是 Go 方法返回的结构体 JSON——对前端开发者来说,和调一个后端 API 没什么区别,只是不需要网络请求。

这是最舒服的开发体验,感觉不到”通信”的存在。代价是:你依赖框架的约束,方法签名有限制(参数和返回值必须是可序列化的类型)。

模式二:原生 WebView 的消息通道

如果你用 webview/webview 这样的裸库,通信是这样工作的:

1
2
3
4
5
6
7
8
9
// Go 侧:绑定一个函数,JS 调用时触发
w.Bind("startTest", func(mode int, threads int) string {
result := runSpeedTest(mode, threads)
data, _ := json.Marshal(result)
return string(data)
})

// 也可以反向:Go 主动向 JS 推送数据(比如测速进度)
w.Eval(`updateProgress(` + progressJSON + `)`)
1
2
3
// JS 侧:调用绑定的 Go 函数
const result = await window.startTest(2, 8)
const data = JSON.parse(result)

底层机制是:JS 通过 WebView 的原生接口(比如 window.webkit.messageHandlers)发消息给宿主程序,宿主程序处理后通过 EvaluateJavaScript 把结果塞回 JS 执行环境。整个过程是异步的,所以 JS 侧必须用 await

webview-message-channel

还有第三种方式:起一个 HTTP 本地服务器。Go 监听 localhost:随机端口,前端用 fetch 调用。这方案最直白,也最灵活,任何前端框架都天然支持。缺点是你需要在程序启动时分配一个可用端口,还要处理端口冲突,并且安全边界相对模糊(虽然只监听 localhost,但理论上同机器其他进程也能访问)。好处是:你可以用 Postman 调试你的后端接口,前端可以完全独立于 wails 在浏览器里开发,迭代速度更快。

三种方式没有绝对的优劣,取决于你的项目规模和团队构成。对于这个测速工具,我倾向于 localhost HTTP——因为你可以直接在浏览器里调试前端,不需要依赖 wails dev 环境,前端同学也容易介入。


七、踩过的坑,我替你们先躺了

坑一:白屏,且没有任何报错

这是 WebView 新手最容易遇到的问题,也是最让人崩溃的——窗口打开了,一片白,DevTools 打不开,不知道哪里出了问题。

常见原因:

  • HTML 文件路径写错了(尤其是打包后路径和开发时不一样)
  • 引用的 JS/CSS 文件 404,但 WebView 默认不显示加载错误
  • wails 的前端文件没有 build,直接用了开发时的 src 目录

调试方法:先在 wails dev 模式下运行(会自动开启 DevTools),确认前端没有 JS 报错。打包前先在真实浏览器里把前端跑通。

坑二:macOS 和 Windows 字体渲染差异巨大

macOS 上看起来字体很漂亮,到 Windows 上一看——糊的。原因是两个系统的字体渲染策略不同,加上 WebView 的字体抗锯齿设置也有差异。

实践中的解法:CSS 加上 -webkit-font-smoothing: antialiasedtext-rendering: optimizeLegibility,字体栈里优先写系统字体(-apple-system, BlinkMacSystemFont, Segoe UI),不要用奇怪的 Web 字体。

坑三:打包后找不到静态资源

开发时前端文件在 frontend/ 目录,wails build 之后,静态资源会被编译进二进制文件(使用 Go embed)。但如果你代码里有硬编码的路径(比如 ./frontend/assets/logo.png),打包后就 404 了。

正确做法:用 wails 提供的资源嵌入机制,所有静态资源通过 //go:embed 打包进二进制,或者在 wails.json 里配置好 frontend:dist 路径,确保 build 流程对齐。


八、选型建议:不同情况,不同答案

给不同处境的读者一个明确的答案:

你是 Go 开发者,工具性质的小项目(内部运营后台、个人效率工具):选 wails,脚手架完善,文档好,前后端协作体验接近 Web 开发,值得投入时间熟悉。

你是 Go 开发者,极简主义,只需要一个窗口显示网页 + 少量本地数据交互:选 webview/webview,几百行以内搞定,不引入额外依赖。

你是 Python 开发者,有现成的 Python 业务代码不想迁移:选 pywebview,上手成本低,能快速套上界面。

你在做一个面向最终用户的正式产品,对体积、性能、用户体验要求高,且团队有 Rust 意愿:选 Tauri,目前生态最成熟,社区最活跃,是这个赛道的标杆。

你的主语言是 Java,项目里已经有 Swing/JavaFX 代码:考虑 JCEF,但做好心理准备,接入成本不低,文档也不算友好。


九、AI 时代的悖论:CLI 回春了,但 GUI 依然绕不开

看完上面这些,你可能会觉得:所以 CLI 就是原罪,是落后的交互方式,GUI 才是正道?

不一定。

最近一年,有一个很有意思的反向趋势——CLI 程序重新火起来了。 而且这次不是因为程序员觉得 GUI 恶心,而是因为 AI。

AI 能读懂”烂”界面

想想看,你对一个终端工具吐槽最多的是什么?emoji 装饰、重复信息、排版混乱、没有可视化。但在 AI 眼里,这些根本不是问题。

一个 LLM 接收一段 stdio 输出,它不关心输出里有没有 emoji,不关心边框符号画得整不整齐,更不关心排版够不够美观。它只关心一件事:文本里有没有它需要的信息

1
2
3
4
5
📶 测速速度   : 15.31 MB/s
📶 上传速度 : 3.21 MB/s
🎭 节点名称 : 广东移动-深圳
📍 运营商 : 中国移动
🕐 延迟 : 12 ms

这堆带 emoji 的文本,在你的室友眼里是噪音,在一个 LLM 眼里却是完美的结构化数据——甚至不需要你特意设计格式,AI 的语义理解能力足够直接从自然文本里抽取出你想表达的信息。它不会因为输出格式丑而抓狂,它只会因为信息格式不确定而困惑。

所以给 AI 用的 CLI 工具,交互设计反而比给人类用的更简单:stdout 输出清晰、确定、无歧义的内容就够了,不需要颜色高亮、不需要表格对齐、不需要任何 UI 框架。越纯粹的文本,AI 解析得越准。

这就是为什么 OpenAI、Anthropic、Google 都在推”Function Calling”和”Tool Use”——AI 调 CLI 工具,本质就是一个子进程的 stdio 通信。你写了一个 Weather CLI,AI 调用 weather-cli --city 深圳,拿到 stdout 的输出文本,直接理解并回答问题。人机交互的中间层消失了——你的用户不是”人”,是 AI Agent。

当然,MCP (Model Context Protocol) 协议更进一步,它用标准化的 JSON 结构替代了纯文本输出,让 AI 和工具之间的通信更加精确和高效。

1
2
3
4
5
6
7
8
9
{
"tool": "speedtest",
"result": {
"downloadMbps": 122.48,
"uploadMbps": 25.68,
"latencyMs": 12,
"node": "广东移动-深圳"
}
}

MCP 服务器发回来的 JSON 自带 Schema,不需要 LLM 从自然文本里”猜”数据。对机器来说,这就是天生的界面。AI 不关心 MCP 服务器跑在本地的 Python 进程里,还是跑在远程的 Docker 容器里——它只关心你给的 Tool 定义里,有没有它想调用的功能,以及返回值格式是否可用。

一个 golang 写的网络测速 CLI 工具,天然就是一个 MCP 服务端的理想候选:启动快、零依赖、高性能、输出的数据可以被 AI 解析。你甚至不需要为它写 GUI——AI 就是它的”用户界面”。

但人依然活在 GUI 里

然而,这里有一个不可调和的矛盾:

AI Agent 可以毫无障碍地和 100 个 CLI 工具编排在一起,但人终归是要看屏幕的。

你的产品最终用户大概率不是 AI Agent,而是人。CEO 不会对着终端说”给我跑一下分析脚本”然后看 stdout 输出——虽然理论上可以,但现实中他不会。项目经理看报表不会用 cat report.json | jq,她要用的是一个带图表、筛选器、导出 PDF 按钮的网页。

这就形成了一个很有趣的局面:

  • 人机交互 → 仍然需要 GUI,而且需求只增不减(更好的 UX、更低的认知负担、更直观的数据呈现)
  • 机机交互(AI ↔ 工具) → CLI / MCP 足够,甚至更优(轻量、可组合、标准化)

AI和人类

所以一个完整的后端工具,现在需要有两张脸:

  1. 面向 AI 的脸:一个稳定的 CLI / MCP 接口,stdin/stdout 通信,输出结构化数据(JSON),让 AI Agent 能直接调用和消费
  2. 面向人的脸:一个 WebView 或其他形式的 GUI,把同一份数据以人类友好的方式展示出来

有趣的是,这两张脸共享同一套后端逻辑。你在外面套的 WebView 壳子和包一个 MCP Server 包装层,底层调的是同一个 runtime.SingleThreadTest()、同一个 runtime.SelectBestNode()。只不过一个的输出是 JSON,一个的输出是带 emoji 的终端文本,一个的输出是 ECharts 折线图。

1
2
3
4
5
6
7
8
9
10
11
// 同一套核心逻辑,三种输出
var result = runtime.SingleThreadTest(bestNode)

// CLI 输出:给 AI 用的结构化 JSON
fmt.Println(json.Marshal(result))

// CLI 输出:给人看的终端文本
fmt.Printf("📶 测速速度: %.2f MB/s\n", result.SpeedMBps)

// GUI 输出:前端通过 JS Bridge 拿到数据后,渲染成卡片+图表
// return result → ECharts

这样的好处是:你不需要为 AI 时代重新发明轮子。你手头那些觉得”拿不出手”的 CLI 工具,只要输出规范一点、加上 JSON 输出模式,就能被现代 AI 工作流无缝集成。而在需要展示给人类看的时候,用 WebView 给它套一件体面的外衣,让数据不再淹没在 emoji 和边框里。


写在最后

我那个测速小工具,后来用 wails 给它套了个界面——大数字卡片展示速度,下拉框选节点,一键开始测速。下次再有人问我网速,我不用截终端了,直接把应用界面截图发过去——一个白色面板,三个大数字,一目了然。

那个室友看了说:”哦,这还行。”

没有省略号了。

对后端开发者来说,WebView 方案的意义不在于”我要学前端”,而在于——你可以用你已经熟悉的前端知识片段(毕竟哪个后端没改过几行 HTML),配上你擅长的后端语言,做出一个真正能用的界面

你不需要变成前端工程师。你只需要让你的工具,不再让别人看起来那么可怜。

  • 标题: 用 WebView 给你的破旧 CLI 工具换一张现代化的脸
  • 作者: MoGuQAQ
  • 创建于 : 2026-05-24 18:19:33
  • 更新于 : 2026-05-24 18:19:33
  • 链接: https://blog.moguq.top/posts/26052401/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论