从 Web 页面到桌面应用:用 Go + WebView 构建一个轻量级桌面框架

目录

  • 摘要
  • [WebView 是什么?](#WebView 是什么?)
    • 不同平台的WebView实现
    • [扩展: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 构建一个轻量级桌面框架)
  • 总结

摘要

桌面应用开发和 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 包含什么?

  1. WebCore

    负责:

    • HTML解析
    • CSS解析
    • DOM树
    • 页面布局
    • 渲染

    例如:

    html 复制代码
    <div>Hello</div>

    WebCore 会把它变成:

    复制代码
    DOM Tree
    
    div
     |
    Text("Hello")
  2. JavaScriptCore(JSC)

    负责执行 JavaScript。

    类似 Chromium 里的:

    复制代码
    Chromium:
        V8
    
    Safari/WebKit:
        JavaScriptCore

    例如:

    javascript 复制代码
    console.log("hello")

    由 JavaScriptCore 执行。

  3. 图形渲染

    负责:

    • 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 开发大致可以分为三类:

  1. 原生控件路线

    代表:

    • Fyne
    • Gio
    • Qt
    • GTK

    架构:

    text 复制代码
    Go
     |
    GUI Framework
     |
    Operating System API

    优点:

    • 接近传统桌面开发
    • 不依赖浏览器
    • 性能稳定

    缺点:

    • UI开发体验与 Web 生态差距较大
    • 复杂界面开发成本较高
  2. WebView 路线

    代表:

    • webview
    • Wails
    • Electron
    • Tauri

    架构:

    text 复制代码
    Frontend
    (HTML/CSS/JS)
            |
         WebView
            |
    Go Backend

    优点:

    • 使用成熟 Web 技术开发 UI
    • 前端生态丰富
    • UI迭代速度快

    缺点:

    • 需要处理 Web 与 Native 通信
    • 依赖浏览器运行环境
  3. Terminal UI 路线

    代表:

    • Bubble Tea
    • tview

    架构:

    text 复制代码
    Go
     |
    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绘制 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ 极高
  1. Fyne / Gio(纯 Go GUI)

    架构:

    text 复制代码
    Go
     |
    GUI Framework
     |
    GPU / OS API

    优势:

    • 没有浏览器运行时
    • 没有 JS 引擎
    • 没有 DOM
    • 没有 CSS Layout

    因此:

    • 启动速度快:
    text 复制代码
    几十毫秒级
    • 内存占用低:
    text 复制代码
    几十 MB

    非常适合:

    • 配置工具
    • 管理客户端
    • 小型桌面软件
  2. Qt / GTK

    架构:

    text 复制代码
    Go
     |
    C/C++ GUI Runtime
     |
    Native Widget

    特点:

    性能非常强。

    例如:

    • 文件管理器
    • IDE
    • 工业软件

    大量成熟桌面软件都采用这类技术路线。

    缺点:

    • Go 绑定通常不是第一公民。
  3. WebView

    架构:

    text 复制代码
    HTML/CSS/JS
            |
    Browser Engine
            |
    Native Runtime

    性能主要取决于:

    • WebView 内核
    • JavaScript 代码质量
    • DOM 复杂度

    例如:

    简单后台工具:

    text 复制代码
    按钮
    表格
    表单
    请求 API

    这类场景下:

    性能完全没问题。

    但是对于大量动画场景:

    例如:

    • 3D
    • CAD
    • 大型图表

    由于需要经过浏览器渲染流程,性能会明显下降。

  4. Electron

    架构:

    text 复制代码
    Application
     |
    Chromium
     |
    V8
     |
    HTML/CSS/JS

    优势:

    Electron 的前端性能其实很强,原因包括:

    • 使用最新 Chromium
    • GPU 加速
    • V8 JavaScript 引擎优化

    但是代价也很明显:

    text 复制代码
    一个简单应用
    ≈ 携带一个浏览器

    典型表现:

    内存占用:

    text 复制代码
    100MB+

    启动时间:

    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 大概实现在哪里?源码:

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 到这个地址。

这个方案能跑,但我意识到两个问题:

  1. 前端资源的加载不应该走真实的 HTTP 端口 ------ 占用端口、暴露服务,不够优雅
  2. 前后端的通信桥梁(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*.localhostlocalhost 一样,始终解析到 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,但它的局限在于:

  1. 每个方法要单独 Bind(),无法批量反射注册
  2. 没有 middleware,不能加 context 传递、错误类型区分、调用取消等高级特性
  3. 各平台的 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() --- 跳转 URL
  • SetHtml() --- 直接设置 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 不同。

相关推荐
李燚2 小时前
Go 项目怎么组织:DDD 4 层 vs MVC vs 脚本式
开发语言·golang·mvc·ddd·agent框架·eino
OpenTiny社区2 小时前
Coding Agent 到底是怎么工作的,它又为什么会在复杂代码库里失真?
前端·github·agent
奇奇怪怪的2 小时前
Graph RAG:知识图谱增强检索
前端
默_笙2 小时前
🌏 MCP 是 AI 界的 USB-C:一个协议让所有模型都能用上所有工具
前端·javascript
2zcode2 小时前
免费开源项目文档:基于MATLAB图像处理的啤酒瓶口缺陷检测系统设计与实现
开发语言·图像处理·matlab
以和为贵2 小时前
🔥前端也能搞懂流式输出:从 SSE 到打字机效果
前端·人工智能·架构
人工智能时代 准备好了吗2 小时前
AI回答内容进入率监测:引用识别、文本匹配与语义判断
开发语言·人工智能·python
LONGZETECH2 小时前
新能源汽车动力电池检测仿真教学系统:C/S 分层架构与数字化实训落地全解析
大数据·c语言·开发语言·人工智能·架构·系统架构·汽车
郝学胜-神的一滴2 小时前
中级OpenGL教程 013:渲染器类架构设计与逐帧渲染流程详解
开发语言·c++·unity·游戏引擎·图形渲染·opengl·unreal
阿成学长_Cain3 小时前
Linux telinit 命令详解:运行级别切换|关机重启|系统维护一站式掌握
linux·运维·前端·网络