目录
- 摘要
- WebView 是什么?
-
- 不同平台的WebView实现
-
- Android:Android System WebView
- iOS:WKWebView
- Windows:WebView2
- macOS:WKWebView
- 扩展:WebKit 是什么?
-
- WebKit 包含什么?
- WebKit 和 Chromium 的关系
- WebView Bridge:连接 Web 与 Native 的通信机制
- Go GUI 技术选型空间
-
- 性能对比
- Wails:Go 生态中的 WebView 桌面应用框架
- 用 Go + WebView 构建一个轻量级桌面框架
-
- github.com/webview/webview_go
-
- 生命周期管理
- 原生窗口控制
- 页面加载
- JavaScript执行
- JavaScript 与 Go 通信
- 方案一:基于 HTTP 的 WebView 应用
- 方案二:基于 JSON-RPC 2.0 的通信模型
- 方案三:基于 Bind 的 Native Bridge
- 小结
- 总结
摘要
桌面应用开发和 Web 开发曾经长期属于两个不同的技术领域。
传统桌面应用通常依赖:
- 原生 GUI 框架
- 平台相关 API
- 专用 UI 控件体系
而 Web 应用则依赖:
- HTML
- CSS
- JavaScript
- 浏览器运行环境
两者在开发模式和技术栈上存在明显差异。
随着浏览器技术的发展,WebView 作为一种成熟的嵌入式浏览器技术,已经被广泛应用于各种客户端软件中。它允许应用程序将浏览器渲染能力集成到自身窗口中,从而同时具备 Web 技术的灵活性和桌面应用的系统能力。
真正发生变化的是:
越来越多的桌面应用开始主动选择 Web 技术作为 UI 开发方案,而不是依赖传统 GUI 技术栈。
例如:
- Visual Studio Code 使用 Web 技术构建跨平台开发环境
- Electron 将 Chromium 和 Node.js 结合,使前端技术能够开发完整桌面应用
- Wails 则探索 Go 后端与系统 WebView 的结合方式
这些实践说明:
Web 技术已经不仅仅属于浏览器,它同样可以成为现代桌面应用的 UI 层。
而 WebView,正是连接 Web UI 与原生应用能力的桥梁。
WebView 是什么?
WebView,顾名思义,就是嵌入到应用程序中的 Web 浏览器组件。
它不是一个完整的浏览器应用,而是一套由操作系统或第三方提供的网页渲染能力。开发者可以将 HTML、CSS、JavaScript 页面嵌入到自己的应用窗口中,并通过 WebView 提供的接口实现 Web 页面与原生代码之间的交互。
简单来说:
浏览器负责打开网页,而 WebView 负责让应用拥有“打开网页的能力”。
传统浏览器:
用户
↓
Chrome / Safari / Edge
↓
浏览器内核
↓
HTML / CSS / JavaScript
而 WebView:
桌面应用 / 移动应用
↓
WebView
↓
浏览器渲染引擎
↓
HTML / CSS / JavaScript
WebView 通常复用操作系统已有的浏览器内核,因此不同平台使用的实现并不相同。
不同平台的WebView实现
Android:Android System WebView
Android 平台使用基于 Chromium 的 Android System WebView。
它由 Google 维护,底层使用 Chromium 渲染引擎,因此:
- 支持现代 HTML5 标准
- 使用 Chromium 的 JavaScript 引擎 V8
- 与 Chrome 保持较高兼容性
架构:
Android App
↓
Android System WebView
↓
Chromium Engine
↓
HTML/CSS/JavaScript
iOS:WKWebView
iOS 使用苹果提供的 WKWebView。
它基于 Safari 的 WebKit 引擎:
iOS App
↓
WKWebView
↓
WebKit Engine
↓
HTML/CSS/JavaScript
相比早期的 UIWebView,WKWebView:
- 性能更高
- 内存管理更合理
- JavaScript 执行更快
目前 iOS 应用内嵌网页基本都使用 WKWebView。
Windows:WebView2
Windows 现代 WebView 方案是 Microsoft Edge WebView2。
它基于 Chromium 版本的 Edge:
Windows App
↓
WebView2
↓
Edge Chromium Engine
↓
HTML/CSS/JavaScript
WebView2 替代了过去:
- IE WebBrowser 控件
- EdgeHTML WebView
目前是 Windows 桌面应用嵌入 Web 内容的推荐方案。
macOS:WKWebView
macOS 同样使用 WKWebView:
macOS App
↓
WKWebView
↓
Safari WebKit Engine
↓
HTML/CSS/JavaScript
它与 Safari 使用相同的 WebKit 技术栈,因此:
- 页面渲染行为接近 Safari
- 无需额外安装浏览器内核
- 系统负责维护 WebKit 更新
虽然 WebView 和浏览器都能渲染网页,但它们的定位不同。
| 用户入口 | 用户主动打开 | 应用内部使用 |
| 窗口管理 | 浏览器控制 | 应用控制 |
| 地址栏 | 有 | 无 |
| 多标签页 | 支持 | 通常没有 |
| 原生交互 | 有限 | 提供接口 |
| 生命周期 | 浏览器管理 | 应用管理 |
因此:
WebView 并不是“打开一个浏览器窗口”,而是“把浏览器渲染能力作为应用的一部分”。
这个问题非常适合放进你的博客里,因为很多人会把 WebKit、Safari、WKWebView、Chromium、WebView2 搞混。
简单说:
WebKit 是一个浏览器渲染引擎(Browser Engine),不是浏览器。
扩展:WebKit 是什么?
WebKit 是一个开源的浏览器引擎,负责把:
HTML + CSS + JavaScript
↓
渲染页面
转换成人能看到的网页。
它主要包含几个核心部分:
WebKit
|
┌──────────┼──────────┐
↓ ↓ ↓
HTML解析 CSS布局 JavaScript执行
浏览器其实是由多个部分组成的,很多人认为:
Safari = WebKit
其实不准确,一个浏览器大致结构:
浏览器应用
|
├── UI层
│ 地址栏、标签页、收藏夹
|
├── 浏览器逻辑
│ 下载、历史记录、权限管理
|
└── 浏览器引擎
|
├── HTML解析
├── CSS布局
├── 页面渲染
└── JS执行
其中最后这一部分就是 WebKit 负责的。
Safari 和 WebKit 的关系,准确来说如下:
Safari 浏览器
|
↓
WebKit
|
↓
HTML/CSS/JS 渲染
-
Safari 是一个完整浏览器。
-
WebKit 是它使用的核心引擎。
WebKit 包含什么?
WebCore
负责:
- HTML解析
- CSS解析
- DOM树
- 页面布局
- 渲染
例如:
<div>Hello</div>
WebCore 会把它变成:
DOM Tree
div
|
Text("Hello")
JavaScriptCore(JSC)
负责执行 JavaScript。
类似 Chromium 里的:
Chromium:
V8
Safari/WebKit:
JavaScriptCore
例如:
console.log("hello")
由 JavaScriptCore 执行。
图形渲染
负责:
- GPU 加速
- 合成
- 动画
WebKit 和 Chromium 的关系
这是最容易混淆的地方。
现在主流浏览器主要有两个阵营:
| Chrome | Chromium Blink + V8 |
| Edge | Chromium Blink + V8 |
| Safari | WebKit + JavaScriptCore |
| Firefox | Gecko + SpiderMonkey |
早期:
WebKit
|
└── Chromium
Chromium 最开始也是基于 WebKit。
后来:
Chromium
|
└── Blink(分叉出来)
所以现在:
WebKit
|
└── Safari
Blink
|
└── Chrome / Edge / Chromium
那 WKWebView 是什么?
WKWebView:
苹果提供的 WebKit 封装控件
也就是:
你的 App
|
WKWebView
|
WebKit
|
HTML/CSS/JS
例如iOS App:
let webView = WKWebView()
webView.load(…)
实际上调用的是系统 WebKit。
WebView2 又是什么?
类似:
Windows App
|
WebView2
|
Edge Chromium
|
Blink + V8
区别:
| 平台 | Apple | Windows |
| 引擎 | WebKit | Chromium |
| JS引擎 | JavaScriptCore | V8 |
| 浏览器基础 | Safari | Edge |
所以:
webview/webview
↓
Windows调用WebView2
macOS调用WKWebView
Linux调用WebKitGTK
WebView Bridge:连接 Web 与 Native 的通信机制

WebView 桌面应用中,Bridge 是最核心的部分之一。很多人理解 WebView 时,只看到:
HTML/CSS/JS
↓
WebView窗口
但真正让 WebView 从一个网页容器变成桌面应用的关键,是中间这一层:
JS ↔ Native Bridge
WebView 内部运行的是浏览器环境,而桌面应用本身运行的是 Native Runtime,例如 Go、C++、Rust 或 Swift。两者属于不同的运行时,因此无法直接调用彼此的方法。
例如,前端代码:
function saveFile() {
// 浏览器里的 JS
}
而 Go:
func SaveFile(path string) error {
// 操作本地文件
}
JavaScript 天然无法直接调用 Go:
JS
|
❌
|
Go
所以需要一个桥接层:
JS
|
Bridge
|
Go
Bridge 本质上是一层跨运行时通信机制。浏览器中的 JavaScript 之所以不能直接访问文件系统、修改系统配置等能力,是因为浏览器需要提供沙箱环境,防止网页获得过高权限。
例如:
fs.writeFile()
这种操作在普通网页中是不允许的,否则任意网站都可能直接操作用户文件。
但是桌面应用中的 WebView 不同。Vue、React、Svelte 等前端代码通常属于应用自身,因此开发者希望它能够调用 Native 层提供的能力:
- 文件系统;
- 系统通知;
- 窗口控制;
- 剪贴板;
- 系统信息。
因此需要:
Frontend
|
↓
Bridge
|
↓
Native Capability
从底层实现来看,Bridge 可以理解为两个运行时之间的 RPC(Remote Procedure Call)。
它和微服务中的 RPC 类似:
Service A
|
RPC
|
Service B
区别在于 WebView 中通信双方变成了:
JavaScript
|
RPC
|
Native
例如 Native 提供:
func Hello(name string) string
前端调用:
hello("Tom")
实际上中间会经历:
函数调用
↓
消息序列化
↓
跨边界传输
↓
Native执行
↓
结果返回
例如:
{
"method": "Hello",
"args": [
"Tom"
]
}
Native 接收到请求后:
Hello("Tom")
然后返回:
{
"result": "Hello Tom"
}
不同框架对于 Bridge 的实现方式有所不同。
最简单的是 WebView 提供的 Message API,例如 WebView2:
window.chrome.webview.postMessage({
cmd:"save"
})
Native 收到消息:
WebMessageReceived += ...
这种方式本质上是事件通信。
另一种方式是 JavaScript 注入对象:
window.native = {
saveFile:function(){}
}
前端调用:
native.saveFile()
看起来就像调用普通 JavaScript API。
现代框架通常会进一步封装,例如 Wails 会根据 Go 方法自动生成 JavaScript Binding:
Go:
type App struct{}
func (a *App) Greet(name string) string {
return "Hello " + name
}
前端:
await Greet("Tom")
实际上:
Greet()
|
Generated JS
|
Wails Runtime
|
Go Method
Go GUI 技术选型空间
| 原生 GUI | Fyne | Go 自绘控件 | Windows/macOS/Linux | 纯 Go 实现,无需外部运行时,API 简洁 | 小工具、管理软件、跨平台桌面应用 |
| 原生 GUI | Gio | Immediate Mode UI | Windows/macOS/Linux/Android | 类似 Flutter 思路,性能好,控制力强 | 高性能 GUI、图形应用 |
| 原生 GUI | Qt + Go绑定 | 原生控件 | Windows/macOS/Linux | 成熟商业级 GUI 框架,生态庞大 | 企业软件、复杂桌面应用 |
| 原生 GUI | GTK + Go绑定 | 原生控件 | Linux/macOS/Windows | Linux 生态强,GNOME 技术栈 | Linux 桌面应用 |
| WebView | webview | HTML/CSS/JS + 系统WebView | Windows/macOS/Linux | 极轻量,只提供 WebView 能力 | 自建桌面框架、简单工具 |
| WebView | Wails | 前端框架 + WebView | Windows/macOS/Linux | Go版 Electron 思路,工程化完善 | 商业桌面应用 |
| WebView | Electron + Go后端 | Chromium + Node.js | Windows/macOS/Linux | 前端生态最强,但体积较大 | IDE、复杂客户端 |
| WebView | Tauri + Go后端 | WebView + Rust Runtime | Windows/macOS/Linux | 类似 Electron,但更轻量 | 现代桌面应用 |
| Terminal UI | Bubble Tea | 终端字符界面 | 全平台终端 | Go生态最流行TUI框架之一 | CLI工具、运维工具 |
| Terminal UI | tview | 终端控件 | 全平台终端 | 类似传统GUI组件模型 | 终端应用 |
| 游戏/图形 | Ebiten | GPU绘制 | Windows/macOS/Linux/Web | 纯 Go 游戏引擎 | 游戏、可视化程序 |
从实现方式来看,Go 的 GUI 开发大致可以分为三类:
原生控件路线
代表:
- Fyne
- Gio
- Qt
- GTK
架构:
Go
|
GUI Framework
|
Operating System API
优点:
- 接近传统桌面开发
- 不依赖浏览器
- 性能稳定
缺点:
- UI开发体验与 Web 生态差距较大
- 复杂界面开发成本较高
WebView 路线
代表:
- webview
- Wails
- Electron
- Tauri
架构:
Frontend
(HTML/CSS/JS)
|
WebView
|
Go Backend
优点:
- 使用成熟 Web 技术开发 UI
- 前端生态丰富
- UI迭代速度快
缺点:
- 需要处理 Web 与 Native 通信
- 依赖浏览器运行环境
Terminal UI 路线
代表:
- Bubble Tea
- tview
架构:
Go
|
Terminal
优点:
- 极轻量
- 适合开发者工具
缺点:
- UI能力有限
性能对比
GUI 框架的性能不能只看“渲染速度”,因为桌面应用性能通常由 UI 渲染、事件响应、内存占用、启动速度、包体积几个维度共同决定。
例如:
- 一个管理后台类软件,HTTP + WebView 可能完全够用;
- 一个 CAD、游戏、实时图形软件,WebView 就不是最佳选择。
下面给一个偏工程实践的对比。
| 原生 GUI | Fyne | Go 自绘 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 很高 |
| 原生 GUI | Gio | GPU Immediate Mode | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 极高 |
| 原生 GUI | Qt + Go绑定 | C++ 原生控件 | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | 极高 |
| 原生 GUI | GTK + Go绑定 | 原生控件 | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | 高 |
| WebView | webview/webview | 系统浏览器内核 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 高 |
| WebView | Wails | 系统WebView + 前端框架 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | 高 |
| WebView | Tauri | 系统WebView | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | 高 |
| WebView | Electron | 自带 Chromium | ⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | 中等 |
| TUI | Bubble Tea | 终端绘制 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ | 极高(特定场景) |
| 游戏/图形 | Ebiten | GPU绘制 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 极高 |
Fyne / Gio(纯 Go GUI)
架构:
Go
|
GUI Framework
|
GPU / OS API
优势:
- 没有浏览器运行时
- 没有 JS 引擎
- 没有 DOM
- 没有 CSS Layout
因此:
- 启动速度快:
几十毫秒级
- 内存占用低:
几十 MB
非常适合:
- 配置工具
- 管理客户端
- 小型桌面软件
Qt / GTK
架构:
Go
|
C/C++ GUI Runtime
|
Native Widget
特点:
性能非常强。
例如:
- 文件管理器
- IDE
- 工业软件
大量成熟桌面软件都采用这类技术路线。
缺点:
- Go 绑定通常不是第一公民。
WebView
架构:
HTML/CSS/JS
|
Browser Engine
|
Native Runtime
性能主要取决于:
- WebView 内核
- JavaScript 代码质量
- DOM 复杂度
例如:
简单后台工具:
按钮
表格
表单
请求 API
这类场景下:
性能完全没问题。
但是对于大量动画场景:
例如:
- 3D
- CAD
- 大型图表
由于需要经过浏览器渲染流程,性能会明显下降。
Electron
架构:
Application
|
Chromium
|
V8
|
HTML/CSS/JS
优势:
Electron 的前端性能其实很强,原因包括:
- 使用最新 Chromium
- GPU 加速
- V8 JavaScript 引擎优化
但是代价也很明显:
一个简单应用
≈ 携带一个浏览器
典型表现:
内存占用:
100MB+
启动时间:
秒级
Wails:Go 生态中的 WebView 桌面应用框架

在了解 WebView 的基础能力之后,还需要介绍一个 Go 生态中较为成熟的桌面应用框架——Wails。
如果说 webview 这类库解决的是“如何在 Go 程序中打开一个 WebView 窗口”,那么 Wails 解决的是“如何围绕 WebView 构建一个完整的桌面应用”。
Wails 将前端技术和 Go 后端结合起来,前端负责 UI 展示,Go 负责业务逻辑和系统能力调用。整体架构如下:
Frontend
(Vue / React / Svelte)
|
|
Wails Runtime
|
|
Go
|
|
Native System API
这种架构和 Electron 的思路类似,都是通过 Web 技术构建用户界面,再通过 Native Runtime 提供桌面能力。不过两者最大的区别在于运行时选择:
Electron 会将完整的 Chromium 和 Node.js 一起打包,而 Wails 选择使用操作系统提供的 WebView:
Windows → WebView2 (Chromium)
macOS → WKWebView (WebKit)
Linux → WebKitGTK
因此 Wails 在保持现代 Web 开发体验的同时,相比 Electron 拥有更小的应用体积和更低的资源占用。
目前 Wails v3 仍处于 Alpha 阶段。虽然官方已经完成了大量架构设计工作,并且整体 API 趋于稳定,但由于版本尚未正式发布,部分接口仍可能发生变化。
相比 Wails v2,v3 对内部架构进行了较大的调整,引入了更加模块化的设计,例如将窗口管理、事件系统、菜单、系统托盘等能力进行了统一抽象,使框架更接近一个完整的桌面应用 Runtime,而不仅仅是 WebView 封装。
不过,由于 v3 仍处于 Alpha 阶段,在实际使用时需要考虑:
- API 兼容性变化;
- 文档和生态完善程度;
- 后续升级成本。
因此,Wails v3 更适合作为学习现代桌面应用架构、探索 WebView 技术路线的工具。如果用于长期维护的生产项目,则需要根据版本稳定性进行评估。
用 Go + WebView 构建一个轻量级桌面框架
前面介绍了 WebView 的基本原理以及 JS ↔ Native Bridge 的通信机制。本章将尝试从零实现一个简化版桌面框架,通过 Go 作为 Native Runtime,WebView 作为 UI 层,逐步探索现代桌面应用框架的实现方式。
本章会基于 Go WebView 库实现一个完整 Demo,并分别探索三种前端与 Go 通信方式:
方案一:
JavaScript
|
HTTP
|
Go
方案二:
JavaScript
|
JSON-RPC 2.0
|
Go
方案三:
JavaScript
|
Native Binding
|
Go
三种方案分别代表了 WebView 桌面应用从简单通信到现代框架 Bridge 模型的发展过程。
本项目主要使用两个核心技术:
WebView 负责提供桌面窗口,并加载 HTML/CSS/JavaScript 页面。
这里使用 Go 社区常用的 WebView 库:
github.com/webview/webview_go

该库是WebView库的一个Go绑定。
Go embed桌面应用和普通 Web 应用最大的区别之一,是最终希望用户只需要运行一个程序。
传统 Web 项目:
application
frontend/
index.html
app.js
style.css
运行时需要额外携带前端文件。
但是桌面程序通常希望:
app.exe
即可运行。Go 通过 embed 包提供了资源嵌入能力,可以在编译阶段将前端资源打包进 Go 二进制文件。
例如:
import "embed"
//go:embed frontend/*
var frontend embed.FS
编译后:
app.exe
包含:
├── Go Runtime
├── WebView逻辑
├── index.html
├── JavaScript
└── CSS
运行时:
Go
|
embed.FS
|
WebView
|
加载HTML页面
这样就可以实现类似 Wails 的效果:
前端资源和 Go 程序一起发布。
本章 Demo 会采用如下结构:
mini-webview/
├── main.go # 程序入口,创建 WebView
├── frontend/ # 前端资源
│ ├── index.html
│ ├── style.css
│ └── app.js
├── server/ # HTTP通信实现
├── rpc/ # JSON-RPC 2.0实现
└── bridge/ # Native Binding实现
其中:frontend负责 WebView 加载的 UI 页面。
server对应第一种实现:
JS → HTTP → Go
用于理解最基础的 WebView 应用模型。
rpc对应第二种实现:
JS → JSON-RPC → Go
通过协议抽象方法调用。
bridge对应第三种实现:
JS → Native Binding → Go
模拟现代桌面框架中的 Bridge 机制。
github.com/webview/webview_go
github.com/webview/webview 提供了一个轻量级 WebView 封装,其核心接口 WebView 并不复杂,只有十几个方法。
type WebView interface {
// Run runs the main loop until it's terminated. After this function exits –
// you must destroy the webview.
Run()
// Terminate stops the main loop. It is safe to call this function from
// a background thread.
Terminate()
// Dispatch posts a function to be executed on the main thread. You normally
// do not need to call this function, unless you want to tweak the native
// window.
Dispatch(f func())
// Destroy destroys a webview and closes the native window.
Destroy()
// Window returns a native window handle pointer. When using GTK backend the
// pointer is GtkWindow pointer, when using Cocoa backend the pointer is
// NSWindow pointer, when using Win32 backend the pointer is HWND pointer.
Window() unsafe.Pointer
// SetTitle updates the title of the native window. Must be called from the UI
// thread.
SetTitle(title string)
// SetSize updates native window size. See Hint constants.
SetSize(w int, h int, hint Hint)
// Navigate navigates webview to the given URL. URL may be a properly encoded data.
// URI. Examples:
// w.Navigate("https://github.com/webview/webview")
// w.Navigate("data:text/html,%3Ch1%3EHello%3C%2Fh1%3E")
// w.Navigate("data:text/html;base64,PGgxPkhlbGxvPC9oMT4=")
Navigate(url string)
// SetHtml sets the webview HTML directly.
// Example: w.SetHtml(w, "<h1>Hello</h1>");
SetHtml(html string)
// Init injects JavaScript code at the initialization of the new page. Every
// time the webview will open a the new page – this initialization code will
// be executed. It is guaranteed that code is executed before window.onload.
Init(js string)
// Eval evaluates arbitrary JavaScript code. Evaluation happens asynchronously,
// also the result of the expression is ignored. Use RPC bindings if you want
// to receive notifications about the results of the evaluation.
Eval(js string)
// Bind binds a callback function so that it will appear under the given name
// as a global JavaScript function. Internally it uses webview_init().
// Callback receives a request string and a user-provided argument pointer.
// Request string is a JSON array of all the arguments passed to the
// JavaScript function.
//
// f must be a function
// f must return either value and error or just error
Bind(name string, f interface{}) error
// Removes a callback that was previously set by Bind.
Unbind(name string) error
}
| Run() | 生命周期 | 启动 WebView 主事件循环,阻塞运行直到事件循环结束 | 程序启动后保持窗口运行,是 WebView 应用的入口 |
| Terminate() | 生命周期 | 停止 WebView 主事件循环,使 Run() 返回 | 后台任务完成、收到退出信号、程序逻辑需要主动关闭窗口时调用 |
| Destroy() | 生命周期 | 销毁 WebView 实例,释放浏览器内核、窗口、GPU 等底层资源 | 程序退出时清理资源,通常配合 defer w.Destroy() 使用 |
| Dispatch(func()) | 线程调度 | 将函数提交到 UI 主线程执行 | 后台 goroutine 需要修改窗口状态、调用 UI API 时使用 |
| Window() | 原生接口 | 获取底层原生窗口句柄(Windows 为 HWND,macOS 为 NSWindow,Linux GTK 为 GtkWindow) | 调用平台原生 API,实现窗口透明、自定义样式等功能 |
| SetTitle(title) | 窗口控制 | 设置原生窗口标题 | 修改桌面应用标题栏名称 |
| SetSize(w, h, hint) | 窗口控制 | 设置原生窗口大小,并通过 Hint 控制尺寸行为 | 初始化窗口大小、限制窗口缩放等 |
| Navigate(url) | 页面加载 | 加载指定 URL 或 data: URI 页面 | 加载本地 HTTP 服务、远程页面、嵌入式页面 |
| SetHtml(html) | 页面加载 | 直接设置 WebView 的 HTML 内容 | 简单 Demo、小型页面、不需要 HTTP 服务的场景 |
| Init(js) | JavaScript 控制 | 在页面初始化阶段注入 JavaScript,执行时间早于 window.onload | 初始化前端环境、注入全局变量、准备 Bridge |
| Eval(js) | JavaScript 控制 | 由 Go 主动执行 JavaScript 代码,异步执行且不返回结果 | Go 控制页面状态,例如修改 DOM、调用前端方法 |
| Bind(name, f) | Bridge 通信 | 将 Go 函数绑定为 JavaScript 全局函数,实现 JS 调用 Go | 实现 JS ↔ Native Bridge,例如文件操作、系统 API 调用 |
| Unbind(name) | Bridge 通信 | 移除之前通过 Bind() 注册的 JavaScript 函数 | 动态关闭或注销 Native 能力 |
这些方法主要分为五类:
WebView
├── 生命周期管理
│ ├── Run()
│ ├── Terminate()
│ ├── Destroy()
│ └── Dispatch()
│
├── 原生窗口控制
│ ├── Window()
│ ├── SetTitle()
│ └── SetSize()
│
├── 页面加载
│ ├── Navigate()
│ └── SetHtml()
│
├── JavaScript执行
│ ├── Init()
│ └── Eval()
│
└── JS ↔ Native通信
├── Bind()
└── Unbind()
生命周期管理
WebView 本质上仍然是一个桌面 GUI 程序,因此需要维护自己的事件循环。
Run()
Run() 会启动 WebView 的主事件循环。
类似:
- Windows GUI 的消息循环;
- Qt 的 QApplication.exec();
- Fyne 的 Run()。
调用之后:
用户操作
↓
操作系统事件
↓
WebView事件循环
↓
页面更新
例如:
w := webview.New(false)
w.Navigate(
"http://localhost:8080",
)
w.Run()
程序会一直阻塞在 Run(),直到事件循环结束。
Terminate()
用于主动停止事件循环。
例如:
go func(){
time.Sleep(time.Second)
w.Terminate()
}()
可以从后台 goroutine 调用,因此适合:
- 后台任务完成后关闭窗口;
- 程序退出;
- 更新失败退出。
调用关系:
Background Goroutine
↓
Terminate()
↓
Run()退出
Destroy()
负责释放 WebView 资源并关闭窗口。
通常:
defer w.Destroy()
完整生命周期:
New()
↓
Run()
↓
Terminate()
↓
Destroy()
Dispatch(func())
用于将任务发送到 UI 主线程执行。
因为大部分 GUI 框架都有线程限制:
后台线程
❌
直接修改窗口
后台线程
↓
Dispatch
↓
UI线程
↓
修改窗口
例如:
w.Dispatch(func(){
w.SetTitle("Updated")
})
原生窗口控制
虽然 WebView 页面使用 HTML/CSS,但是外层仍然是真正的系统窗口。
Window() unsafe.Pointer
返回底层窗口句柄。
不同平台:
| Windows | HWND |
| macOS | NSWindow |
| Linux GTK | GtkWindow |
例如 Windows:
Go
↓
HWND
↓
Win32 API
可以进一步实现:
- 窗口透明;
- 自定义边框;
- 原生菜单。
SetTitle(title string)
修改窗口标题。
例如:
w.SetTitle(
"Mini Wails"
)
对应:
Windows窗口标题栏
SetSize(
width,
height,
hint,
)
设置窗口大小。
例如:
w.SetSize(
800,
600,
webview.HintNone,
)
其中 Hint 可以控制:
- 固定大小;
- 最小尺寸;
- 最大尺寸。
页面加载
WebView 提供两种加载方式。
Navigate(url string)
加载 URL。
例如:
w.Navigate(
"http://localhost:8080",
)
这是 HTTP 架构使用的方法:
Go HTTP Server
↓
localhost:8080
↓
WebView
也支持:
w.Navigate(
"data:text/html,<h1>Hello</h1>",
)
直接加载 HTML。
SetHtml(html string)
直接设置 HTML。
例如:
w.SetHtml(`
<h1>Hello WebView</h1>
`)
区别:
| Navigate | 加载URL |
| SetHtml | 直接注入HTML |
简单 Demo 可以使用:
Go
↓
SetHtml
↓
页面
复杂项目通常使用:
go:embed
↓
HTTP Server
↓
Navigate
JavaScript执行
WebView 不仅可以显示网页,还可以控制页面中的 JavaScript。
Init(js string)
初始化时注入 JavaScript。
例如:
w.Init(`
window.version="1.0"
`)
特点:
它会在:
页面加载
↓
window.onload之前
执行。
适合:
- 注入全局变量;
- 初始化 Bridge;
- 修改环境。
Eval(js string)
Go 主动执行 JS。
例如:
w.Eval(`
document.title="Hello"
`)
通信方向:
Go
↓
JavaScript
但是:Eval() 不返回结果。
如果需要返回值,需要使用:
Bind / RPC
JavaScript 与 Go 通信
这是 WebView 框架最核心的能力。
Bind(name string, f interface{})
将 Go 函数暴露给 JavaScript。
例如Go:
func Hello(name string) string {
return "Hello "+name
}
w.Bind(
"hello",
Hello,
)
前端:
hello("Tom")
实际调用:
JavaScript
↓
Bridge
↓
Go函数
↓
返回结果
这就是 Wails、Tauri 等框架的核心机制。
Unbind(name string)
移除之前绑定的方法。
例如:
w.Unbind("hello")
方案一:基于 HTTP 的 WebView 应用
第一种方案也是最容易理解的方式。
WebView 本质上只是一个浏览器,因此可以直接加载一个本地 HTTP 服务:
Frontend
|
| HTTP Request
|
Go HTTP Server
Go 负责:
- 启动 HTTP Server;
- 提供 API;
- 返回数据;
- 执行业务逻辑。
这种方式实际上就是传统 Web 前后端分离架构,只不过:
浏览器
变成:
WebView
优点:
- 实现简单;
- 调试方便;
- 技术栈成熟。
缺点:
- 通信链路较长;
- 存在 HTTP 协议开销;
- 不够符合桌面应用模型。
index.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>
Mini WebView
</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>
Go + WebView Demo
</h1>
<button id="helloBtn">
Call Go Server
</button>
<p id="result">
Waiting…
</p>
</div>
<script src="app.js"></script>
</body>
</html>
style.css
body {
font-family:
Arial,
sans-serif;
}
.container {
width: 400px;
margin: 100px auto;
text-align: center;
}
button {
padding: 10px 20px;
font-size: 16px;
}
#result {
margin-top: 20px;
color: #333;
}
app.js:
const button =
document.getElementById("helloBtn")
const result =
document.getElementById("result")
button.onclick = async function(){
const response =
await fetch("/api/hello")
const data =
await response.text()
result.innerText = data
}
server.go
package server
import (
"embed"
"io/fs"
"net/http"
)
func Start(webFS embed.FS) {
// fs.Sub 可以创建一个新的文件系统视图。
// 把原来的:
// /
// └── web
// ├── index.html
// ├── app.js
// └── style.css
// 变成:
// /
// ├── index.html
// ├── app.js
// └── style.css
// 以前:
// webFS.Open("web/index.html")
// 现在:
// fsys.Open("index.html")
subFS, err := fs.Sub(webFS, "frontend")
if err != nil {
panic(err)
}
mux := http.NewServeMux()
// 静态资源
mux.Handle(
"/",
http.FileServer(
http.FS(subFS),
),
)
// API接口
mux.HandleFunc(
"/api/hello",
func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello from Go"))
},
)
go func() {
err := http.ListenAndServe(
"127.0.0.1:8080",
mux,
)
if err != nil {
panic(err)
}
}()
}
main.go
package main
import (
"embed"
"tmp/server"
"github.com/webview/webview_go"
)
//go:embed frontend/*
var webFS embed.FS
func main() {
// 启动HTTP服务
server.Start(webFS)
// 创建WebView
w := webview.New(false)
defer w.Destroy()
w.SetTitle(
"Mini WebView",
)
w.SetSize(
800,
600,
webview.HintNone,
)
w.Navigate(
"http://127.0.0.1:8080",
)
w.Run()
}
如果你的程序是 GUI 应用,不希望启动时弹出黑色 CMD 窗口:
go build -ldflags="-H windowsgui" -o demo.exe .
或者也可以:
go build -trimpath -ldflags="-H windowsgui -s -w" -o miniweb.exe .
其中:
- -trimpath:去掉本地路径信息;
- -s -w:去掉调试符号,减小体积;
- -H windowsgui:隐藏控制台窗口。
编译好后,执行我们的二进制文件:

可以看到已经正确跳转到了首页,点击按钮可以显示"Hello from Go”
方案二:基于 JSON-RPC 2.0 的通信模型
第二种方案会进一步抽象通信层。
HTTP 解决的是:
如何发送请求?
但是桌面应用真正需要的是:
如何调用 Native 方法?
对于桌面应用而言,前端和后端运行在同一个进程内(或者同一个应用生命周期中),实际上并不需要完整的 HTTP 通信模型。
因此,可以引入更轻量的 RPC(Remote Procedure Call,远程过程调用)模型。
JavaScript
call("saveFile")
↓
JSON-RPC Request
↓
Go Method
↓
JSON-RPC Response
例如前端:
invoke(
"saveFile",
{
path:"test.txt"
}
)
转换:
{
"jsonrpc":"2.0",
"method":"saveFile",
"params":{
"path":"test.txt"
},
"id":1
}
Go 接收到请求后:
func SaveFile(path string){
// save file
}
执行完成后返回:
{
"jsonrpc":"2.0",
"result":"success",
"id":1
}
这种方式已经接近现代桌面框架内部的调用模型。
main.go
package main
import (
"embed"
"io/fs"
"net"
"net/http"
"github.com/sourcegraph/jsonrpc2"
"github.com/webview/webview_go"
"mini-webview-jsonrpc/rpc"
"mini-webview-jsonrpc/service"
)
//go:embed web/*
var webFS embed.FS
func main() {
staticFS, err := fs.Sub(webFS, "web")
if err != nil {
panic(err)
}
mux := http.NewServeMux()
mux.Handle("/", http.FileServer(http.FS(staticFS)))
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
panic(err)
}
go http.Serve(listener, mux)
rpcServer := rpc.NewServer()
systemService := &service.SystemService{}
rpcServer.Register(
"system.info",
systemService.Info,
)
w := webview.New(false)
defer w.Destroy()
w.SetTitle("WebView JSON-RPC Demo")
w.SetSize(900, 600, webview.HintNone)
// JS -> Go
w.Bind("rpcCall", func(request string) string {
return rpcServer.Handle(request)
})
w.Navigate(
"http://" + listener.Addr().String(),
)
w.Run()
_ = jsonrpc2.JSONRPC
}
JSON-RPC 2.0 作为一种标准化的远程调用协议,非常适合作为 WebView 与 Go Runtime 之间的通信方案。相比传统 HTTP API,它不再需要为每个接口设计独立的 URL,而是通过方法名映射的方式,将前端调用抽象为类似本地函数调用的形式:
JavaScript
|
| JSON-RPC Request
v
Go Handler
|
v
Business Method
这种设计已经具备了桌面应用通信模型的雏形,也比简单的 HTTP 通信更加贴近 RPC 框架的思想。
不过,在实际实现过程中,JSON-RPC 2.0 方案也存在一定复杂度。Go 标准库目前并没有直接提供完整的 JSON-RPC 2.0 服务端实现,需要依赖第三方库,例如 sourcegraph/jsonrpc2 等。同时,第三方库通常只负责协议层的解析与传输抽象,实际项目中仍然需要自行实现:
- 请求与响应管理;
- 方法注册与路由;
- 参数序列化与反序列化;
- 错误处理;
- 异步调用管理;
- 前端调用封装。
对于一个简单的桌面应用而言,引入完整的 JSON-RPC 层可能会增加额外的维护成本。
因此,在 Mini WebView 框架的设计中,JSON-RPC 方案更适合作为一种通信模型探索,用于理解 WebView 中跨运行时调用的本质。
后续将进一步探索更贴近桌面框架实现方式的方案:
JavaScript
|
| Native Bridge
v
Go Function
即通过 WebView 提供的 Bind 能力,将 Go 方法直接暴露给 JavaScript,使前端能够像调用本地 API 一样调用 Native 能力。这也是 Wails、Electron、Tauri 等现代桌面框架重点解决的问题。
方案三:基于 Bind 的 Native Bridge
前面的两种方案:
- HTTP:通过网络协议实现 WebView 与 Go 的通信;
- JSON-RPC 2.0:在 HTTP 之上进一步抽象调用模型;
本质上都是通过消息传递实现前端与后端之间的交互。
但是对于桌面应用而言,WebView 和 Go Runtime 本身运行在同一个应用内部,它们之间不存在不可信网络环境,也没有必要完全按照前后端分离的方式设计。
第三种方案是现代桌面框架采用的方式。
例如Wails:
type App struct{}
func (a *App) Greet(name string) string {
return "Hello " + name
}
自动生成:
await App.Greet("Tom")
调用链:
JavaScript
|
|
Generated Binding
|
|
Go Method
这种方式最大的特点是:
前端不再关心通信协议。
调用 Native 方法和调用普通 JavaScript 函数一样。
最终体验:
await FileDialog.Open()
而不是:
fetch("/api/file-dialog")
这也是 Wails、Tauri 等框架追求的开发体验。
webview_go 提供了:Bind(name string, f interface{}) error 方法它可以将 Go 函数绑定到 JavaScript 全局对象中。
main.go
package main
import (
"embed"
"tmp/server"
"github.com/webview/webview_go"
)
//go:embed frontend/*
var webFS embed.FS
func main() {
// 启动静态资源服务器
server.Start(webFS)
w := webview.New(false)
defer w.Destroy()
w.SetTitle(
"WebView Bind Demo",
)
w.SetSize(
900,
600,
webview.HintNone,
)
// Native Bridge
err := w.Bind(
"hello",
func(name string) string {
return "Hello " + name
},
)
if err != nil {
panic(err)
}
w.Navigate(
"http://127.0.0.1:8080",
)
w.Run()
}
app.js:
async function callGo() {
const result =
await window.hello(
"Tom"
);
document
.getElementById(
"result"
)
.innerText = result;
}
编译好后,就可以测试使用Bridge的方式通信了:

你说得对,我刚才又犯了同一个问题:把本来应该作为博客正文的内容拆成了很多短标题、短段落,阅读体验更像笔记,不像一篇技术文章。
你的博客风格应该是:
- 保留完整技术逻辑;
- 段落展开解释;
- 标题适量;
- 不要一句话一个段落;
- 不要为了强调而大量拆分。
小结
在前面的实现中,我们采用了 go embed + HTTP Server 的方式加载 WebView 页面。
整体架构如下:
Go Application
|
v
HTTP Server
|
v
http://127.0.0.1:8080
|
v
WebView
具体流程是:首先通过 Go 的 embed 功能,将前端项目中的 HTML、CSS、JavaScript 等静态资源编译进 Go 二进制文件,然后启动一个本地 HTTP Server,通过 HTTP 协议向 WebView 提供页面资源。
代码结构大致如下:
frontend/
|
| go:embed
v
embed.FS
|
v
HTTP File Server
|
v
WebView 加载页面
这种方式实现简单,非常适合作为 WebView 应用的入门方案。因为 WebView 本身就是浏览器内核,它天然理解 HTTP 协议,因此只需要提供一个 URL 即可加载页面。
不过,从架构角度来看,这种方式本质上是在桌面程序内部额外启动了一个 Web 服务:
Application
├── WebView
│
└── HTTP Server
也就是说,一个桌面应用同时运行了一个客户端和一个服务器。对于 Demo 或小型工具而言,这没有任何问题,但对于成熟的桌面框架来说,还存在进一步优化空间。
Wails、Tauri 等现代 WebView 框架通常不会启动 localhost HTTP Server,而是采用 Custom Protocol(自定义协议)的方式加载资源。
例如:
wails://index.html
tauri://index.html
app://index.html
此时 WebView 请求的不是一个 HTTP 地址,而是一个由应用自己定义的虚拟协议。
整体流程变为:
WebView
请求:
app://index.html
|
v
Custom Protocol Handler
|
v
embed.FS
|
v
返回 HTML/CSS/JS
也就是说,WebView 发起资源请求后,应用层直接拦截这个请求,然后从内嵌文件系统中读取对应资源并返回。
整个过程中:
- 没有 TCP 连接;
- 没有 HTTP Server;
- 没有端口占用。
应用本身直接成为资源提供方。
Wails v3 大概实现在哪里?源码:
Wails v3 GitHub Repository
重点目录:
v3/
├── internal/
│ └── assetserver/
│
├── pkg/
│ └── application/
│
└── internal/
└── runtime/
其中 assetserver 就负责:
URL
↓
asset handler
↓
embed.FS
↓
response


官方文档也明确说:
production 使用 embed.FS,运行时从内存提供 assets,不需要外部文件。
总结
最近在研究 Go 的桌面 GUI 方案,我用 go:embed + net/http + webview/webview_go 搭了一个 demo:把前端资源嵌入 Go 二进制,开一个 HTTP 服务监听 localhost:8000,再用 webview_go 打开窗口 Navigate 到这个地址。
这个方案能跑,但我意识到两个问题:
我试着用 webview_go 的 Bind() 来实现,但发现它能力太有限,搞不定。于是我去读了 Wails v3(v3.0.0-alpha.102)的源码,终于搞明白了这套架构到底是怎么实现的,以及为什么 webview_go 做不到。
Wails 完全自己写了 WebView2 的 Go 绑定,没有用 webview_go。
代码在仓库的 webview2/ 目录下,是一个独立的 Go module(github.com/wailsapp/wails/webview2)。核心做法是通过 syscall.Syscall 直接调用 WebView2 的 COM 虚函数表(vtable)。
看一下 webview2/pkg/edge/corewebview2.go 里的 vtbl 定义就明白了:
type iCoreWebView2Vtbl struct {
_IUnknownVtbl // COM IUnknown 三件套
GetSettings ComProc
GetSource ComProc
Navigate ComProc // 页面导航
NavigateToString ComProc // 直接渲染 HTML 字符串
// …
AddWebResourceRequestedFilter ComProc // ← 请求拦截!这是关键
// …
AddHostObjectToScript ComProc
// …
}
每个 ComProc 就是一个 COM 虚函数指针,调用方式是直接 syscall:
func (i *ICoreWebView2) Navigate(url string) error {
u16url, _ := windows.UTF16PtrFromString(url)
hr, _, _ := i.vtbl.Navigate.Call( // 直接 syscall 调 COM 方法
uintptr(unsafe.Pointer(i)),
uintptr(unsafe.Pointer(u16url)),
)
// …
}
对比一下两者的差异:
| 底层机制 | CGO 调用 C 的 webview 库 | 纯 Go,通过 syscall.Syscall 直接调 COM vtable |
| 暴露的 API | New(), Bind(), Navigate(), SetHtml() | WebView2 COM 接口的几乎完整映射 |
| 关键差异 | 没有请求拦截 API | 有 AddWebResourceRequestedFilter + 回调 |
这个 AddWebResourceRequestedFilter 就是整个架构的基石。
先上一张全景图:
┌──────────────────────────────────────────────────────────┐
│ 用户的 Go 代码 │
│ │
│ app := application.New(Options{ │
│ Services: []Service{&GreetService{}}, ← 注册服务 │
│ Assets: AssetOptions{Handler: …}, ← 前端资源 │
│ }) │
└──────────────┬───────────────────────────┬───────────────┘
│ │
Bindings 反射 AssetServer
提取所有导出方法 包装为 http.Handler
│ │
▼ ▼
┌──────────────────────────────────────────────────────────┐
│ WebView2 (Windows) │
│ │
│ Navigate("http://wails.localhost") ← 虚拟地址 │
│ │
│ AddWebResourceRequestedFilter("*") ← 拦截所有请求 │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ GET /index.html → AssetServer 返回前端页面 │ │
│ │ GET /wails/runtime.js → 注入 JS 运行时 │ │
│ │ POST /wails/runtime → HTTPTransport → 调用Go方法 │ │
│ └────────────────────────────────────────────────┘ │
│ │
│ JS端: │
│ fetch("/wails/runtime", { │
│ body: {object:0, method:0, args:{methodID:123}} │
│ }) │
└──────────────────────────────────────────────────────────┘
整个架构围绕两个核心问题展开。
问题一:不开 HTTP 端口,前端怎么展示?
Wails 在 Windows 上定义了一个虚拟地址(v3/internal/assetserver/assetserver_windows.go):
var baseURL = url.URL{
Scheme: "http",
Host: "wails.localhost", // 不是真实服务器
}
窗口启动时直接 Navigate 到这个地址(v3/pkg/application/webview_window_windows.go):
startURL, _ := assetserver.GetStartURL(w.parent.options.URL)
chromium.Navigate(startURL) // → http://wails.localhost
为什么 wails.localhost 不会报错?这不是 Wails 的黑科技,而是 Chromium 的标准行为。
根据 RFC 6761,*.localhost 和 localhost 一样,始终解析到 127.0.0.1 / ::1,不需要 DNS 查询,浏览器内核直接识别。WebView2 基于 Chromium,天然支持这个规则。
所以 wails.localhost 对 WebView2 来说就是一个合法的 loopback 地址,不会有任何校验错误。
虚拟地址只是让 WebView2 有一个合法的 origin。真正的魔法在于 请求拦截 —— 请求根本不会到达网络层。
// 第一步:注册全局请求拦截器(拦截所有请求)
chromium.AddWebResourceRequestedFilter("*", edge.COREWEBVIEW2_WEB_RESOURCE_CONTEXT_ALL)
// 第二步:设置回调
chromium.WebResourceRequestedCallback = w.processRequest
当 WebView2 尝试加载 http://wails.localhost/index.html 时,请求在发出之前就被 processRequest 拦截了:
func (w *windowsWebviewWindow) processRequest(
req *edge.ICoreWebView2WebResourceRequest,
args *edge.ICoreWebView2WebResourceRequestedEventArgs,
) {
uri, _ := req.GetUri()
reqUri, _ := url.ParseRequestURI(uri)
// 只处理 wails.localhost 的请求,其他的放行
if reqUri.Scheme != "http" { return }
if !strings.HasPrefix(reqUri.Host, "wails.localhost") { return }
// 交给 AssetServer(一个 http.Handler)在进程内生成响应
webviewRequest, _ := webview.NewRequest(w.chromium.Environment(), args, …)
webviewRequests <- &webViewAssetRequest{Request: webviewRequest, …}
}
AssetServer 本质上就是一个标准的 Go http.Handler,只是它不监听任何端口,而是由 WebView2 的回调来驱动。请求在进程内直接响应,零网络 IO,零端口占用。
不同平台的 webview 拦截机制不同,所以 Wails 用了不同的 scheme:
| Windows | http://wails.localhost | WebView2 COM WebResourceRequested 回调 |
| macOS | wails://localhost | WKWebView WKURLSchemeHandler(自定义 scheme) |
| Linux | wails://localhost | WebKitGTK webkit_web_context_register_uri_scheme |
| Android | https://wails.localhost | WebViewAssetLoader |
Windows 用 http:// 而非自定义 scheme,是因为 WebView2 的 WebResourceRequested 能拦截标准 HTTP 请求,用标准 scheme CORS、cookie、fetch API 都能正常工作。macOS/Linux 则使用自定义 scheme wails://,因为 WKWebView 和 WebKitGTK 提供了原生的自定义 URL scheme 注册 API。
问题二:JS 怎么调用 Go 方法(RPC 桥梁)?
Wails v3 没有使用 webview 原生的 Bind(),而是自建了一套基于 HTTP fetch 的 RPC 协议。
用户把一个 struct 的指针传给 Wails,Wails 用反射提取所有导出方法,计算 FNV hash 作为方法 ID(v3/pkg/application/bindings.go):
func (b *Bindings) Add(service Service) error {
methods, _ := getMethods(service.Instance()) // 反射获取所有导出方法
for _, method := range methods {
method.ID = hash.Fnv(method.FQN) // FQN 如 "main.GreetService.Greet"
b.boundMethods[method.FQN] = method
b.boundByID[method.ID] = method // 按 hash ID 注册
}
}
前端运行时在 v3/internal/runtime/desktop/@wailsio/runtime/src/runtime.ts 中定义了统一的调用函数:
async function runtimeCallWithID(objectID, method, windowName, args) {
let url = new URL(window.location.origin + "/wails/runtime");
let body = { object: objectID, method, args };
// 直接 fetch POST!请求会被 Go 的 WebResourceRequested 拦截
let response = await fetch(url, {
method: 'POST',
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
return response.json();
}
调用一个绑定的 Go 方法(calls.ts):
export function Call(options: CallOptions) {
const id = generateID();
// object=0 → "Call" 类型请求, method=0 → "CallBinding"
return call(CallBinding, { "call-id": id, …options });
// options 包含 methodID 或 methodName + args
}
请求被拦截后的处理链路(v3/pkg/application/transport_http.go):
JS: fetch POST /wails/runtime
│
▼
HTTPTransport.Handler() 拦截路径 "/wails/runtime"
│
▼
processBody() 解析 JSON → {object: 0, method: 0, args: {…}}
│
▼
MessageProcessor.HandleRuntimeCallWithIDs()
│ 根据 object 字段路由:
│ 0 = Call(调用绑定方法)
│ 1 = Clipboard
│ 2 = Application
│ 3 = Events
│ 6 = Window
│ …
▼
processCallMethod()
│ bindings.GetByID(options.MethodID) → 找到 BoundMethod
│ boundMethod.Call(ctx, args) → 反射调用 Go 方法
│
▼
返回 JSON 结果 → fetch 拿到 response
为什么不用 webview 原生的 Bind?
webview_go 的 Bind() 也能实现 JS 调 Go,但它的局限在于:
Wails 选择 HTTP fetch 的方式,是因为 fetch() 是所有 webview 平台都支持的标准 Web API,而且请求拦截在每个平台上只需实现一次。这样 RPC 层的 JS 代码就完全跨平台了。
回到最初的问题。为什么 webview_go 做不到这些? webview_go 暴露的 API 只有:
w := webview.New(true)
w.SetTitle("My App")
w.SetSize(800, 600, webview.HintNone)
w.Bind("sayHello", func() string { return "Hello!" })
w.Navigate("http://localhost:8000") // 只能跳到一个真实的 URL
w.Run()
它封装的是 webview/webview 这个 C 库的高层 API,而这个库的设计目标是"最小化的 webview 封装",只提供了:
- Navigate() — 跳转 URL
- SetHtml() — 直接设置 HTML 字符串
- Bind() — 绑定单个函数
- Eval() — 执行 JS
它没有暴露 WebResourceRequested(请求拦截)这个底层能力。
没有请求拦截,就意味着:
- 你无法在进程内拦截 webview 的资源请求,所以必须开一个真实的 HTTP 服务来伺服前端资源
- 你无法拦截 fetch 请求,所以前后端通信只能走 Bind() 或真实 HTTP
而 Wails 自己用 syscall.Syscall 直接调用 WebView2 的 COM 接口,拿到了完整的底层控制权。
| WebView 库 | webview/webview_go(封装 C 库) | 自己写的 WebView2 COM 绑定(纯 Go syscall) |
| 前端加载 | 真实 HTTP 服务 localhost:8000 | 虚拟地址 wails.localhost + 进程内请求拦截 |
| 方法调用 | w.Bind("fn", fn) 逐个绑定 | 反射批量注册 + 自建 fetch RPC 协议 |
| 端口占用 | 需要占用端口 | 零端口 |
| 拦截能力 | 无 | AddWebResourceRequestedFilter 全量拦截 |
核心结论:webview_go 做不到 Wails 的这套架构,不是你代码写得不对,而是它根本没暴露底层的请求拦截 API。 要实现 Wails 级别的能力,要么像 Wails 一样自己写 WebView2 COM 绑定,要么等 webview_go 上游支持更多 API。
本文基于 Wails v3.0.0-alpha.102 源码分析,主要聚焦 Windows 平台(WebView2)的实现。macOS(WKWebView)和 Linux(WebKitGTK)的原理类似,只是请求拦截的具体 API 不同。



