目录
- 摘要
- [WebView 是什么?](#WebView 是什么?)
-
- 不同平台的WebView实现
-
- [Android:Android System WebView](#Android:Android System WebView)
- iOS:WKWebView
- Windows:WebView2
- macOS:WKWebView
- [扩展:WebKit 是什么?](#扩展:WebKit 是什么?)
-
- [WebKit 包含什么?](#WebKit 包含什么?)
- [WebKit 和 Chromium 的关系](#WebKit 和 Chromium 的关系)
- [WebView Bridge:连接 Web 与 Native 的通信机制](#WebView Bridge:连接 Web 与 Native 的通信机制)
- [Go GUI 技术选型空间](#Go GUI 技术选型空间)
-
- 性能对比
- [Wails:Go 生态中的 WebView 桌面应用框架](#Wails:Go 生态中的 WebView 桌面应用框架)
- [用 Go + WebView 构建一个轻量级桌面框架](#用 Go + WebView 构建一个轻量级桌面框架)
-
- github.com/webview/webview_go
-
- 生命周期管理
- 原生窗口控制
- 页面加载
- JavaScript执行
- [JavaScript 与 Go 通信](#JavaScript 与 Go 通信)
- [方案一:基于 HTTP 的 WebView 应用](#方案一:基于 HTTP 的 WebView 应用)
- [方案二:基于 JSON-RPC 2.0 的通信模型](#方案二:基于 JSON-RPC 2.0 的通信模型)
- [方案三:基于 Bind 的 Native Bridge](#方案三:基于 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 负责让应用拥有"打开网页的能力"。
传统浏览器:
text
用户
↓
Chrome / Safari / Edge
↓
浏览器内核
↓
HTML / CSS / JavaScript
而 WebView:
text
桌面应用 / 移动应用
↓
WebView
↓
浏览器渲染引擎
↓
HTML / CSS / JavaScript
WebView 通常复用操作系统已有的浏览器内核,因此不同平台使用的实现并不相同。
不同平台的WebView实现
Android:Android System WebView
Android 平台使用基于 Chromium 的 Android System WebView。
它由 Google 维护,底层使用 Chromium 渲染引擎,因此:
- 支持现代 HTML5 标准
- 使用 Chromium 的 JavaScript 引擎 V8
- 与 Chrome 保持较高兼容性
架构:
text
Android App
↓
Android System WebView
↓
Chromium Engine
↓
HTML/CSS/JavaScript
iOS:WKWebView
iOS 使用苹果提供的 WKWebView。
它基于 Safari 的 WebKit 引擎:
text
iOS App
↓
WKWebView
↓
WebKit Engine
↓
HTML/CSS/JavaScript
相比早期的 UIWebView,WKWebView:
- 性能更高
- 内存管理更合理
- JavaScript 执行更快
目前 iOS 应用内嵌网页基本都使用 WKWebView。
Windows:WebView2
Windows 现代 WebView 方案是 Microsoft Edge WebView2。
它基于 Chromium 版本的 Edge:
text
Windows App
↓
WebView2
↓
Edge Chromium Engine
↓
HTML/CSS/JavaScript
WebView2 替代了过去:
- IE WebBrowser 控件
- EdgeHTML WebView
目前是 Windows 桌面应用嵌入 Web 内容的推荐方案。
macOS:WKWebView
macOS 同样使用 WKWebView:
text
macOS App
↓
WKWebView
↓
Safari WebKit Engine
↓
HTML/CSS/JavaScript
它与 Safari 使用相同的 WebKit 技术栈,因此:
- 页面渲染行为接近 Safari
- 无需额外安装浏览器内核
- 系统负责维护 WebKit 更新
虽然 WebView 和浏览器都能渲染网页,但它们的定位不同。
| 浏览器 | WebView | |
|---|---|---|
| 用户入口 | 用户主动打开 | 应用内部使用 |
| 窗口管理 | 浏览器控制 | 应用控制 |
| 地址栏 | 有 | 无 |
| 多标签页 | 支持 | 通常没有 |
| 原生交互 | 有限 | 提供接口 |
| 生命周期 | 浏览器管理 | 应用管理 |
因此:
WebView 并不是"打开一个浏览器窗口",而是"把浏览器渲染能力作为应用的一部分"。
这个问题非常适合放进你的博客里,因为很多人会把 WebKit、Safari、WKWebView、Chromium、WebView2 搞混。
简单说:
WebKit 是一个浏览器渲染引擎(Browser Engine),不是浏览器。
扩展:WebKit 是什么?
WebKit 是一个开源的浏览器引擎,负责把:
text
HTML + CSS + JavaScript
↓
渲染页面
转换成人能看到的网页。
它主要包含几个核心部分:
text
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树
- 页面布局
- 渲染
例如:
html<div>Hello</div>WebCore 会把它变成:
DOM Tree div | Text("Hello") -
JavaScriptCore(JSC)
负责执行 JavaScript。
类似 Chromium 里的:
Chromium: V8 Safari/WebKit: JavaScriptCore例如:
javascriptconsole.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:
swift
let webView = WKWebView()
webView.load(...)
实际上调用的是系统 WebKit。
WebView2 又是什么?
类似:
Windows App
|
WebView2
|
Edge Chromium
|
Blink + V8
区别:
| WKWebView | WebView2 | |
|---|---|---|
| 平台 | Apple | Windows |
| 引擎 | WebKit | Chromium |
| JS引擎 | JavaScriptCore | V8 |
| 浏览器基础 | Safari | Edge |
所以:
webview/webview
↓
Windows调用WebView2
macOS调用WKWebView
Linux调用WebKitGTK
WebView Bridge:连接 Web 与 Native 的通信机制

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

在了解 WebView 的基础能力之后,还需要介绍一个 Go 生态中较为成熟的桌面应用框架------Wails。
如果说 webview 这类库解决的是"如何在 Go 程序中打开一个 WebView 窗口",那么 Wails 解决的是"如何围绕 WebView 构建一个完整的桌面应用"。
Wails 将前端技术和 Go 后端结合起来,前端负责 UI 展示,Go 负责业务逻辑和系统能力调用。整体架构如下:
text
Frontend
(Vue / React / Svelte)
|
|
Wails Runtime
|
|
Go
|
|
Native System API
这种架构和 Electron 的思路类似,都是通过 Web 技术构建用户界面,再通过 Native Runtime 提供桌面能力。不过两者最大的区别在于运行时选择:
Electron 会将完整的 Chromium 和 Node.js 一起打包,而 Wails 选择使用操作系统提供的 WebView:
text
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 通信方式:
text
方案一:
JavaScript
|
HTTP
|
Go
方案二:
JavaScript
|
JSON-RPC 2.0
|
Go
方案三:
JavaScript
|
Native Binding
|
Go
三种方案分别代表了 WebView 桌面应用从简单通信到现代框架 Bridge 模型的发展过程。
本项目主要使用两个核心技术:
WebView 负责提供桌面窗口,并加载 HTML/CSS/JavaScript 页面。
这里使用 Go 社区常用的 WebView 库:
text
github.com/webview/webview_go

该库是WebView库的一个Go绑定。
Go embed桌面应用和普通 Web 应用最大的区别之一,是最终希望用户只需要运行一个程序。
传统 Web 项目:
text
application
frontend/
index.html
app.js
style.css
运行时需要额外携带前端文件。
但是桌面程序通常希望:
text
app.exe
即可运行。Go 通过 embed 包提供了资源嵌入能力,可以在编译阶段将前端资源打包进 Go 二进制文件。
例如:
go
import "embed"
//go:embed frontend/*
var frontend embed.FS
编译后:
text
app.exe
包含:
├── Go Runtime
├── WebView逻辑
├── index.html
├── JavaScript
└── CSS
运行时:
text
Go
|
embed.FS
|
WebView
|
加载HTML页面
这样就可以实现类似 Wails 的效果:
前端资源和 Go 程序一起发布。
本章 Demo 会采用如下结构:
text
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对应第一种实现:
text
JS → HTTP → Go
用于理解最基础的 WebView 应用模型。
rpc对应第二种实现:
text
JS → JSON-RPC → Go
通过协议抽象方法调用。
bridge对应第三种实现:
text
JS → Native Binding → Go
模拟现代桌面框架中的 Bridge 机制。
github.com/webview/webview_go
github.com/webview/webview 提供了一个轻量级 WebView 封装,其核心接口 WebView 并不复杂,只有十几个方法。
go
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 能力 |
这些方法主要分为五类:
text
WebView
├── 生命周期管理
│ ├── Run()
│ ├── Terminate()
│ ├── Destroy()
│ └── Dispatch()
│
├── 原生窗口控制
│ ├── Window()
│ ├── SetTitle()
│ └── SetSize()
│
├── 页面加载
│ ├── Navigate()
│ └── SetHtml()
│
├── JavaScript执行
│ ├── Init()
│ └── Eval()
│
└── JS ↔ Native通信
├── Bind()
└── Unbind()
生命周期管理
WebView 本质上仍然是一个桌面 GUI 程序,因此需要维护自己的事件循环。
go
Run()
Run() 会启动 WebView 的主事件循环。
类似:
- Windows GUI 的消息循环;
- Qt 的
QApplication.exec(); - Fyne 的
Run()。
调用之后:
text
用户操作
↓
操作系统事件
↓
WebView事件循环
↓
页面更新
例如:
go
w := webview.New(false)
w.Navigate(
"http://localhost:8080",
)
w.Run()
程序会一直阻塞在 Run(),直到事件循环结束。
go
Terminate()
用于主动停止事件循环。
例如:
go
go func(){
time.Sleep(time.Second)
w.Terminate()
}()
可以从后台 goroutine 调用,因此适合:
- 后台任务完成后关闭窗口;
- 程序退出;
- 更新失败退出。
调用关系:
text
Background Goroutine
↓
Terminate()
↓
Run()退出
go
Destroy()
负责释放 WebView 资源并关闭窗口。
通常:
go
defer w.Destroy()
完整生命周期:
text
New()
↓
Run()
↓
Terminate()
↓
Destroy()
go
Dispatch(func())
用于将任务发送到 UI 主线程执行。
因为大部分 GUI 框架都有线程限制:
text
后台线程
❌
直接修改窗口
后台线程
↓
Dispatch
↓
UI线程
↓
修改窗口
例如:
go
w.Dispatch(func(){
w.SetTitle("Updated")
})
原生窗口控制
虽然 WebView 页面使用 HTML/CSS,但是外层仍然是真正的系统窗口。
go
Window() unsafe.Pointer
返回底层窗口句柄。
不同平台:
| 平台 | 返回 |
|---|---|
| Windows | HWND |
| macOS | NSWindow |
| Linux GTK | GtkWindow |
例如 Windows:
text
Go
↓
HWND
↓
Win32 API
可以进一步实现:
- 窗口透明;
- 自定义边框;
- 原生菜单。
go
SetTitle(title string)
修改窗口标题。
例如:
go
w.SetTitle(
"Mini Wails"
)
对应:
text
Windows窗口标题栏
go
SetSize(
width,
height,
hint,
)
设置窗口大小。
例如:
go
w.SetSize(
800,
600,
webview.HintNone,
)
其中 Hint 可以控制:
- 固定大小;
- 最小尺寸;
- 最大尺寸。
页面加载
WebView 提供两种加载方式。
go
Navigate(url string)
加载 URL。
例如:
go
w.Navigate(
"http://localhost:8080",
)
这是 HTTP 架构使用的方法:
text
Go HTTP Server
↓
localhost:8080
↓
WebView
也支持:
go
w.Navigate(
"data:text/html,<h1>Hello</h1>",
)
直接加载 HTML。
go
SetHtml(html string)
直接设置 HTML。
例如:
go
w.SetHtml(`
<h1>Hello WebView</h1>
`)
区别:
| 方法 | 方式 |
|---|---|
| Navigate | 加载URL |
| SetHtml | 直接注入HTML |
简单 Demo 可以使用:
text
Go
↓
SetHtml
↓
页面
复杂项目通常使用:
text
go:embed
↓
HTTP Server
↓
Navigate
JavaScript执行
WebView 不仅可以显示网页,还可以控制页面中的 JavaScript。
go
Init(js string)
初始化时注入 JavaScript。
例如:
go
w.Init(`
window.version="1.0"
`)
特点:
它会在:
text
页面加载
↓
window.onload之前
执行。
适合:
- 注入全局变量;
- 初始化 Bridge;
- 修改环境。
go
Eval(js string)
Go 主动执行 JS。
例如:
go
w.Eval(`
document.title="Hello"
`)
通信方向:
text
Go
↓
JavaScript
但是:Eval() 不返回结果。
如果需要返回值,需要使用:
text
Bind / RPC
JavaScript 与 Go 通信
这是 WebView 框架最核心的能力。
go
Bind(name string, f interface{})
将 Go 函数暴露给 JavaScript。
例如Go:
go
func Hello(name string) string {
return "Hello "+name
}
w.Bind(
"hello",
Hello,
)
前端:
javascript
hello("Tom")
实际调用:
text
JavaScript
↓
Bridge
↓
Go函数
↓
返回结果
这就是 Wails、Tauri 等框架的核心机制。
go
Unbind(name string)
移除之前绑定的方法。
例如:
go
w.Unbind("hello")
方案一:基于 HTTP 的 WebView 应用
第一种方案也是最容易理解的方式。
WebView 本质上只是一个浏览器,因此可以直接加载一个本地 HTTP 服务:
text
Frontend
|
| HTTP Request
|
Go HTTP Server
Go 负责:
- 启动 HTTP Server;
- 提供 API;
- 返回数据;
- 执行业务逻辑。
这种方式实际上就是传统 Web 前后端分离架构,只不过:
text
浏览器
变成:
text
WebView
优点:
- 实现简单;
- 调试方便;
- 技术栈成熟。
缺点:
- 通信链路较长;
- 存在 HTTP 协议开销;
- 不够符合桌面应用模型。
index.html:
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
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:
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
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
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 窗口:
bash
go build -ldflags="-H windowsgui" -o demo.exe .
或者也可以:
bash
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,远程过程调用)模型。
text
JavaScript
call("saveFile")
↓
JSON-RPC Request
↓
Go Method
↓
JSON-RPC Response
例如前端:
javascript
invoke(
"saveFile",
{
path:"test.txt"
}
)
转换:
json
{
"jsonrpc":"2.0",
"method":"saveFile",
"params":{
"path":"test.txt"
},
"id":1
}
Go 接收到请求后:
go
func SaveFile(path string){
// save file
}
执行完成后返回:
json
{
"jsonrpc":"2.0",
"result":"success",
"id":1
}
这种方式已经接近现代桌面框架内部的调用模型。
main.go
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,而是通过方法名映射的方式,将前端调用抽象为类似本地函数调用的形式:
text
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 中跨运行时调用的本质。
后续将进一步探索更贴近桌面框架实现方式的方案:
text
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:
go
type App struct{}
func (a *App) Greet(name string) string {
return "Hello " + name
}
自动生成:
javascript
await App.Greet("Tom")
调用链:
text
JavaScript
|
|
Generated Binding
|
|
Go Method
这种方式最大的特点是:
前端不再关心通信协议。
调用 Native 方法和调用普通 JavaScript 函数一样。
最终体验:
javascript
await FileDialog.Open()
而不是:
javascript
fetch("/api/file-dialog")
这也是 Wails、Tauri 等框架追求的开发体验。
webview_go 提供了:Bind(name string, f interface{}) error 方法它可以将 Go 函数绑定到 JavaScript 全局对象中。
main.go
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:
go
async function callGo() {
const result =
await window.hello(
"Tom"
);
document
.getElementById(
"result"
)
.innerText = result;
}
编译好后,就可以测试使用Bridge的方式通信了:

你说得对,我刚才又犯了同一个问题:把本来应该作为博客正文的内容拆成了很多短标题、短段落,阅读体验更像笔记,不像一篇技术文章。
你的博客风格应该是:
- 保留完整技术逻辑;
- 段落展开解释;
- 标题适量;
- 不要一句话一个段落;
- 不要为了强调而大量拆分。
小结
在前面的实现中,我们采用了 go embed + HTTP Server 的方式加载 WebView 页面。
整体架构如下:
text
Go Application
|
v
HTTP Server
|
v
http://127.0.0.1:8080
|
v
WebView
具体流程是:首先通过 Go 的 embed 功能,将前端项目中的 HTML、CSS、JavaScript 等静态资源编译进 Go 二进制文件,然后启动一个本地 HTTP Server,通过 HTTP 协议向 WebView 提供页面资源。
代码结构大致如下:
text
frontend/
|
| go:embed
v
embed.FS
|
v
HTTP File Server
|
v
WebView 加载页面
这种方式实现简单,非常适合作为 WebView 应用的入门方案。因为 WebView 本身就是浏览器内核,它天然理解 HTTP 协议,因此只需要提供一个 URL 即可加载页面。
不过,从架构角度来看,这种方式本质上是在桌面程序内部额外启动了一个 Web 服务:
text
Application
├── WebView
│
└── HTTP Server
也就是说,一个桌面应用同时运行了一个客户端和一个服务器。对于 Demo 或小型工具而言,这没有任何问题,但对于成熟的桌面框架来说,还存在进一步优化空间。
Wails、Tauri 等现代 WebView 框架通常不会启动 localhost HTTP Server,而是采用 Custom Protocol(自定义协议)的方式加载资源。
例如:
text
wails://index.html
tauri://index.html
app://index.html
此时 WebView 请求的不是一个 HTTP 地址,而是一个由应用自己定义的虚拟协议。
整体流程变为:
text
WebView
请求:
app://index.html
|
v
Custom Protocol Handler
|
v
embed.FS
|
v
返回 HTML/CSS/JS
也就是说,WebView 发起资源请求后,应用层直接拦截这个请求,然后从内嵌文件系统中读取对应资源并返回。
整个过程中:
- 没有 TCP 连接;
- 没有 HTTP Server;
- 没有端口占用。
应用本身直接成为资源提供方。
Wails v3 大概实现在哪里?源码:
重点目录:
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 到这个地址。
这个方案能跑,但我意识到两个问题:
- 前端资源的加载不应该走真实的 HTTP 端口 ------ 占用端口、暴露服务,不够优雅
- 前后端的通信桥梁(bridge)不应该走 HTTP ------ 应该像 RPC 一样直接调用
我试着用 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 定义就明白了:
go
type iCoreWebView2Vtbl struct {
_IUnknownVtbl // COM IUnknown 三件套
GetSettings ComProc
GetSource ComProc
Navigate ComProc // 页面导航
NavigateToString ComProc // 直接渲染 HTML 字符串
// ...
AddWebResourceRequestedFilter ComProc // ← 请求拦截!这是关键
// ...
AddHostObjectToScript ComProc
// ...
}
每个 ComProc 就是一个 COM 虚函数指针,调用方式是直接 syscall:
go
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)),
)
// ...
}
对比一下两者的差异:
| webview_go | Wails 自己的 webview2 包 | |
|---|---|---|
| 底层机制 | 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):
go
var baseURL = url.URL{
Scheme: "http",
Host: "wails.localhost", // 不是真实服务器
}
窗口启动时直接 Navigate 到这个地址(v3/pkg/application/webview_window_windows.go):
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。真正的魔法在于 请求拦截 ------ 请求根本不会到达网络层。
go
// 第一步:注册全局请求拦截器(拦截所有请求)
chromium.AddWebResourceRequestedFilter("*", edge.COREWEBVIEW2_WEB_RESOURCE_CONTEXT_ALL)
// 第二步:设置回调
chromium.WebResourceRequestedCallback = w.processRequest
当 WebView2 尝试加载 http://wails.localhost/index.html 时,请求在发出之前就被 processRequest 拦截了:
go
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:
| 平台 | baseURL | 拦截机制 |
|---|---|---|
| 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):
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 中定义了统一的调用函数:
typescript
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):
typescript
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,但它的局限在于:
- 每个方法要单独
Bind(),无法批量反射注册 - 没有 middleware,不能加 context 传递、错误类型区分、调用取消等高级特性
- 各平台的 webview 原生绑定 API 差异巨大,统一封装成本高
Wails 选择 HTTP fetch 的方式,是因为 fetch() 是所有 webview 平台都支持的标准 Web API,而且请求拦截在每个平台上只需实现一次。这样 RPC 层的 JS 代码就完全跨平台了。
回到最初的问题。为什么 webview_go 做不到这些? webview_go 暴露的 API 只有:
go
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()--- 跳转 URLSetHtml()--- 直接设置 HTML 字符串Bind()--- 绑定单个函数Eval()--- 执行 JS
它没有暴露 WebResourceRequested(请求拦截)这个底层能力。
没有请求拦截,就意味着:
- 你无法在进程内拦截 webview 的资源请求,所以必须开一个真实的 HTTP 服务来伺服前端资源
- 你无法拦截 fetch 请求,所以前后端通信只能走
Bind()或真实 HTTP
而 Wails 自己用 syscall.Syscall 直接调用 WebView2 的 COM 接口,拿到了完整的底层控制权。
| 我的 webview_go demo | Wails v3 | |
|---|---|---|
| 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 不同。