欢迎光临
我们一直在努力

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

目录

  • 摘要
  • 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
用户入口 用户主动打开 应用内部使用
窗口管理 浏览器控制 应用控制
地址栏 有 无
多标签页 支持 通常没有
原生交互 有限 提供接口
生命周期 浏览器管理 应用管理

因此:

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

    区别:

    WKWebViewWebView2
    平台 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 技术选型空间

    技术路线框架/技术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

    架构:

    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 就不是最佳选择。

    下面给一个偏工程实践的对比。

    技术路线代表框架渲染方式启动速度内存占用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)

    架构:

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

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

  • 前端资源的加载不应该走真实的 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 定义就明白了:

    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)),
    )
    // …
    }

    对比一下两者的差异:

    webview_goWails 自己的 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):

    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:

    平台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):

    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,但它的局限在于:

  • 每个方法要单独 Bind(),无法批量反射注册
  • 没有 middleware,不能加 context 传递、错误类型区分、调用取消等高级特性
  • 各平台的 webview 原生绑定 API 差异巨大,统一封装成本高
  • 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_go demoWails 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 不同。

    赞(0)
    未经允许不得转载:171主机测评 » 从 Web 页面到桌面应用:用 Go + WebView 构建一个轻量级桌面框架
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址