第 5 部分 · 对象与界面
为什么 Racket GUI 写不出“现代界面”
Racket GUI 把控件一个个钉进窗口,事件交给鼠标命中的那个控件就结束——不冒泡、不组合。所以“一个任务项”这种现代 UI 的最小单元,用
racket/gui怎么拼都拧巴。这不是你不会写,是它刻意选了另一条路。
你写过网页。一个任务列表项——标题加粗、备注浅色、日期靠右、点整行选中、鼠标悬上去高亮——在 HTML 里几乎是 Hello World。你想在 Racket 里照着来一份,装上 racket/gui,开个 frame%,往里摆几个 message%,然后就卡住了。
问题出在哪?是 panel 选得不对、回调写少了,还是这套框架压根就不是为这种界面设计的?
一个 Hello World 级别的需求
先把你要的东西拆开:
- 标题(加粗)
- 备注(浅色小字)
- 日期
- 点整行能选中
- 鼠标悬上去整行高亮
HTML 里一个 div 套三个子元素就完事了。在 racket/gui 里,很多人第一反应是先怀疑自己:是不是 panel 没选对?是不是嵌套方式不对?是不是回调写少了?
先把这个怀疑放下。你撞上的不是技术问题,是 racket/gui 的设计边界。
控件是原子,不是积木
racket/gui 是一个传统 GUI 框架,目标是快速、稳定、跨平台地封装操作系统自带的 GUI 能力。它的核心假设是:控件是一个个独立的功能部件。
button%是一个按钮message%是一段静态文字或图片——文档原话 “no user interaction”,根本不响应交互check-box%、text-field%、slider%各管一摊
每个控件都把自己定义完了。它不会“成为更大控件的一部分”,也不能被当成结构节点继续往下组合。
控件是已经定义好的功能部件,不是可以拼装的积木。
看一段最简单的代码:
#lang racket/gui
; 顶层窗口
(define f (new frame% [label "任务列表"] [width 300] [height 200]))
; panel 把子控件从上到下排好——仅此而已
(define p (new vertical-panel% [parent f]))
; 三个 message%:各自独立的静态文本,互不相干
(new message% [parent p] [label "买牛奶"])
(new message% [parent p] [label "备注:周末之前"])
(new message% [parent p] [label "2026-07-14"])
(send f show #t)
跑一下:窗口出来了,三行文字排得整整齐齐。但三个 message% 之间没有任何关系——它们只是恰好被同一个 panel 摆在了一起,而且哪一个都不响应点击。
panel 只管排位置
vertical-panel% 和 horizontal-panel% 的职责很明确:排位置、适配窗口大小。嵌套水平、垂直容器,绝大多数布局都能做出来。
但 panel 是布局容器,不是 UI 结构树。它不会做这些事:
- 管理一组子控件的整体事件
- 表达“这几个孩子是一个整体组件”
- 协调子控件之间的状态
文档里说得更直白:连更轻量的 pane% 都“只用于几何管理,不接收鼠标事件”。布局归布局,结构归结构,racket/gui 把这两件事分得很开。
panel 是布局容器,不是 UI 结构树。
事件点到谁,就归谁
事件模型是真正的分水岭。在浏览器里,你点一个按钮,事件会从按钮沿着 DOM 树一层层冒泡,父节点、祖父节点都能收到——所以你把点击监听挂在任务项容器上,点标题、点备注、点日期,都会触发。
racket/gui 不是这样。文档对鼠标事件的规定是:事件交给鼠标命中的那个窗口,鼠标在子窗口上,就归子窗口,不会传给父窗口。点 message% 上的标题,事件停在标题里——而 message% 压根不响应交互,事件直接消失。
frame% 提供了一个 on-subwindow-event 钩子,让父窗口有机会拦截发往子窗口的事件。但这是一个全局的拦截接口,不是声明式冒泡——你得自己在里面判断坐标、判断命中了哪个孩子,写起来又碎又不可组合。
事件命中的那个控件处理完就结束,没有冒泡。
视觉上一项,逻辑上是三个对象
把“任务项”的诉求和 racket/gui 的现实对到一起,问题就清楚了:
| 你想要的 | racket/gui 给你的 |
|---|---|
| 一个可点击的整体 | 三个互不相关的 message% |
| 整行 hover 高亮 | 各控件各管各的背景 |
| 选中状态自然同步 | 没有共享状态的地方可放 |
点标题不等于点备注,hover 没法统一,选中状态没有自然落脚的地方。视觉上是一件东西,逻辑上是三个对象。
你实际上在做的事情是:
用多个互不相关的控件,假装它们是一个控件。
这恰好是传统 GUI 框架最不擅长的事。
浏览器的秘密是自绘,不是 HTML
那 HTML 为什么轻松?因为 HTML 根本不是“控件系统”。
一个任务项在网页里是一棵树:
<div class="task-item">
<div class="title">标题</div>
<div class="note">备注</div>
<time>日期</time>
</div>
这棵树同时承担三件事:布局、绘制、事件传播。容器接得到子元素的事件,hover 能覆盖整块区域,状态可以沿树共享。
很多人误以为浏览器强在控件多、功能全。恰恰相反——浏览器没有“高级控件”这种东西。它最终干的就这几件事:把 DOM 和 CSS 算成几何信息,在一块画布上画出来,自己做命中测试,自己分发事件。
换句话说,浏览器是一个巨大的、自绘的 UI 引擎。这才是它能把复杂界面做得浑然天成的真正原因,而不是 HTML 标签多。
浏览器没有“高级控件”,它是一个自绘的 UI 引擎。
大家都走了同一条路
现代 UI 框架,几乎都选了和浏览器一样的路:
| 框架 | 界面的本质 |
|---|---|
| Web | DOM + 自绘渲染 |
| Flutter | Skia 自绘 + Widget 树 |
| SwiftUI | View 树 + Diff |
| Qt Quick | Scene Graph |
它们的共同特征:不依赖系统控件、不信任控件拼装、所有复杂组件都自己画出来。“自绘 + 一棵树”成了事实标准。
racket/gui 没跟上,不是力不能及,而是刻意不做。它的定位是教学工具、工具型 GUI——轻量、可维护、跨平台地包住操作系统能力。它不想成为商业级 UI 引擎,也不想内置一套动画系统、主题机制、组件框架。它把真正的自由度留给了一个类:canvas%。
canvas% 不是退路,是入口
新手的第一感觉往往是“用 canvas 是不是退而求其次”。恰恰相反。
在 racket/gui 里,普通控件调用的是系统能力;canvas% 才是 UI 引擎的入口。一旦你要一个“看起来像一个控件”的复杂结构、要统一的 hover 和选中、要自定义的绘制风格——canvas 是唯一干净的解法。
上一节那个拧巴的“任务项”,用 canvas 写是这样:
#lang racket/gui
; 一个自绘的"任务项":自己画,自己接事件
(define task-item%
(class canvas%
(super-new)
(define hovered #f)
; 鼠标事件统一进这一个方法——hover、点击都在这里
(define/override (on-event e)
(case (send e get-event-type)
[(enter) (set! hovered #t) (send this refresh)]
[(leave) (set! hovered #f) (send this refresh)]
[else #f]))
; 内容由 on-paint 决定,get-dc 拿到画布
(define/override (on-paint)
(define dc (send this get-dc))
(when hovered
(send dc set-brush "lightblue" 'solid)
(send dc draw-rectangle 0 0 (send this get-width) (send this get-height)))
(send dc draw-text "买牛奶" 10 10))))
(define f (new frame% [label "自绘任务项"] [width 300] [height 80]))
(new task-item% [parent f])
(send f show #t)
一个 canvas%,一整套 hover 逻辑在一个方法里兜住,整行怎么画自己说了算。这才是“现代界面”该有的写法——代价是你得自己画、自己管事件。
canvas% 才是 racket/gui 里真正的 UI 引擎入口。
窗口归 GUI,界面归 canvas
最后给你一个干净的切分:
- 窗口、菜单、对话框——交给
frame%、menu%、dialog% - 任务列表、卡片、侧边栏、时间线——全部当成 canvas 上画出来的东西
如果你在 racket/gui 里组合了一堆控件、手动同步状态、还想让它们看起来像一个整体——说明你已经走错路了。下一步只有一个方向:用 canvas 自己画。
Racket GUI 并不落后,它只是拒绝成为一个浏览器。当你开始用 canvas 写界面,你已经不是在“写界面”,而是在实现一个小型 UI 引擎。
📷 待补运行截图:racket/gui 拼 message% 排出的朴素列表项,与网页版现代任务项对比(
racket-gui-clunky-task-item.png)