我最早习惯的是 WinForms 一类桌面 GUI 开发,后来逐渐转向 Vue,在 Web 上开发企业应用。
刚开始,我把这件事理解成一次普通的技术栈迁移:
从 C# 转到 JavaScript,从 WinForms 转到 Vue。
真正做下来以后,我才意识到,这并不只是换了编程语言和框架,而是在两套不同的 UI 开发模型之间切换。
WinForms 面向的是桌面 GUI。开发者直接使用 Button、TextBox、DataGrid、Window 和 DockPanel 这样的控件,通过对象、属性、方法和事件组织应用。
Vue 面向的是 Web。开发者使用状态描述页面,再由响应式系统、组件和 DOM 完成界面更新。
这两种模型没有简单的先进与落后之分。Vue 让 Web 开发变得高效、灵活,也让团队更容易招聘、协作和快速交付。对于企业官网、内容平台、电商前台、用户门户和普通后台页面,我仍然会优先选择 Vue 和成熟的 Web 组件库。
但当我开始开发 ERP、财务系统、临床信息系统、电子病历编辑器、报表设计器以及高密度业务工作台时,我越来越明显地感到:这些系统虽然运行在浏览器里,使用方式却更接近桌面软件。
我真正不适应的不是 JavaScript,也不是 Vue 的语法。
我不适应的是,原本应该由 GUI 框架承担的复杂度,开始越来越多地落到业务开发人员身上。
一、运行在浏览器里,不等于它只是一张网页
现代 Web 开发经常把不同类型的产品放进同一个分类中。
企业官网是 Web。
电商商城是 Web。
ERP 是 Web。
医院临床系统也是 Web。
但它们面对的并不是同一种 UI 问题。
内容型网站通常以阅读和信息浏览为主:
- 页面按区块组织
- 用户沿着自然文档流浏览
- 点击链接进入另一个页面
- 填写相对独立的表单
- 需要适配手机、平板和桌面端
- 关注 SEO、可访问性和加载速度
DOM 和 CSS 非常适合这类产品。内容本身就是页面结构,浏览器已经提供了成熟的排版、语义和交互能力。
而 ERP、财务、临床系统和专业设计工具,通常呈现出另一种形态:
- 用户长时间停留在同一个工作区
- 一个屏幕中同时展示大量信息
- 工具栏、导航树、属性面板和主工作区需要协同
- 大量使用表格、树、表单、窗口和弹层
- 强依赖键盘、焦点、选区和连续编辑
- 页面之间需要保留未完成的状态
- 对空间利用率和操作节奏要求很高
例如在一套临床信息系统中,医生可能需要同时查看患者基本信息、医嘱、检验结果、检查报告、病历文书、诊断和过敏史,并在多个患者和多项业务之间频繁切换。
患者列表首先是一个独立工作页,医生从病区患者中定位对象,再打开并保留对应的就诊工作台:
进入患者后,患者信息、业务页签、筛选、编辑表格和临时选择器会共同存在于同一个持续工作的界面中:
这些内容不是为了让医生"阅读一张网页",而是为了让他连续完成专业工作。
所以,Web 的交付方式与网页式的 UI 模型并不是一回事。一个应用可以通过浏览器部署、更新和访问,同时仍然需要桌面 GUI 的信息密度、交互连续性和工作区组织能力。
二、我怀念的不是 WinForms,而是 GUI 的确定性
在 WinForms 中,一个按钮就是一个明确的控件对象。
var button = new Button
{
Text = "保存",
Width = 100,
Height = 32
};
button.Click += (_, _) => Save();
toolPanel.Controls.Add(button);
我创建了一个对象,这个对象就是界面中的控件。
我可以保存它的引用,读取它的状态,调用它的方法,监听它的事件,也可以把它放进另一个容器。组件、布局、事件、焦点和生命周期属于同一个 GUI 世界。
到了 Vue 中,同样的按钮可以写得非常简洁:
<Button :disabled="saving" :loading="saving" @click="handleSave" />
这种声明式写法很适合状态驱动的页面。开发者描述当前状态下界面应该是什么样子,框架负责完成依赖收集、更新调度和 DOM 修改。
在普通页面中,这种方式比手动操作界面对象高效得多。
但对于复杂企业软件,我经常需要的不只是"状态变化以后页面应该长什么样",还需要一个可以被明确调用、具有稳定行为和生命周期的专业控件。
以前使用 DevExpress 时,我很少需要考虑一张表格内部如何虚拟化、编辑器如何复用、键盘导航怎样建立、焦点怎样移动。开发人员面对的是一张已经完成这些工作的 DataGrid,然后把精力放在业务数据、校验规则和操作流程上。
我怀念的不是某个陈旧 API,也不是把 Web 写回桌面程序。
我怀念的是这种确定性:
开发者操作的是业务控件,框架承担控件内部的复杂度。
三、响应式适合描述结果,但不适合承载所有过程
Vue 的响应式系统是它最有价值的能力之一。
修改状态,界面自动更新。用户资料、商品列表、查询页面和普通表单,本来就可以自然地理解为数据状态的视觉结果。
但专业软件中也有很多具有明确开始、持续和结束的过程,例如拖拽、框选、连续编辑和窗口交互。它们当然都可以在 Vue、React 和 DOM 中实现,区别不在于"能不能做",而在于开发者需要通过什么方式表达和追踪这个过程。
如果一个原本线性的交互被拆成多份状态、监听和组件间副作用,开发者排查问题时就需要重新拼出它的因果关系。对于这类场景,允许业务代码直接表达事件流,并不比声明式落后,反而更贴近过程本身。
一个更常见、也更贴近业务开发的例子,是只在特定流程中出现的临时窗口。
这里说的不是只有一段提示文字的确认框。ElMessageBox.confirm() 之类的通用方法已经可以很好地解决这种需求,没有必要为此重新建立一套 GUI 模型。
更典型的是临床系统中的"检验申请保存并提交"。医生点击提交后,需要在临时窗口中再次核对患者和检验项目,补充主诉、临床诊断与标本信息,决定是否加急,校验通过后才真正提交。这不是一个通用 Confirm,而是一个只在特定业务分支中出现的完整表单。
在 Vue 中可以把表单草稿和校验全部封装进弹窗组件,页面只保留一个当前申请状态。即使按照这种更精简的方式编写,弹窗仍然需要成为页面组件结构的一部分:
vue
<script setup lang="ts">
import { shallowRef } from "vue";
const pendingInspection = shallowRef<InspectionOrder | null>(null);
function openInspectionSubmit(order: InspectionOrder) {
pendingInspection.value = order;
}
async function handleInspectionSubmit(draft: InspectionSubmitDraft) {
if (!pendingInspection.value) return;
await submitInspectionOrder(pendingInspection.value.id, draft);
pendingInspection.value = null;
}
</script>
<template>
<InspectionOrderEditor @submit="openInspectionSubmit" />
<InspectionSubmitDialog v-if="pendingInspection" :order="pendingInspection" @submit="handleInspectionSubmit" @cancel="pendingInspection = null" />
</template>
pendingInspection 为 null 时,弹窗实例并没有挂载。但为了支持这个偶发流程,页面仍然需要把它纳入自己的状态模型、事件处理和组件结构。React 中通过 state 和条件 JSX 渲染同类业务窗口,也存在相同的结构。
对于页面的主要布局,这种声明方式很自然;但随着低频业务窗口不断增加,开发人员阅读一个页面时,也需要同时理解这些当前并未发生的分支。问题不是多写了几行代码,而是临时流程被提升成了页面长期需要承担的结构和状态。
在 ds-ui 中,页面可以在提交动作发生时才创建本次草稿和业务窗口,并在同一段逻辑中等待结果:
ts
const submitButton = new RenderButton({
label: "保存并提交",
variant: "primary",
onClick: async () => {
const order = currentOrder;
const draft = createInspectionSubmitDraft(order);
const dialog = new InspectionSubmitWindow({
patient: currentPatient,
order,
draft,
});
const accepted = await context.services.showModalWindow(dialog);
if (!accepted) return;
await submitInspectionOrder(order.id, draft);
},
});
这里的 InspectionSubmitWindow 是业务项目基于 ds-ui 封装的独立窗口控件,而不是框架内置的通用确认框。它内部可以包含患者与检验项目核对、主诉、临床诊断、标本类型、标本说明、加急标记和提交校验,但这些细节属于窗口自身,不需要散落在打开它的页面中。
这里更接近 WinForms 的对象式、命令式控件模型:需要时创建,交给窗口服务显示,关闭后返回结果。取消时,本次局部 draft 随过程结束而丢弃;确认时,业务逻辑使用它继续提交。临时状态不需要被提升为页面级常驻状态,窗口也不需要进入页面的主要组件结构。
这种写法带来的价值首先是更低的心智负担。开发人员审查代码或排查问题时,可以从按钮事件或业务命令出发,沿着"创建窗口---等待结果---继续提交"的事件流和业务流,很快找到对应逻辑,不需要在页面状态、组件声明和多组回调之间来回跳转。
业务窗口仍然可以独立组合、校验和测试,并不是把所有代码都堆进 click 事件。页面只保留流程,窗口负责自己的字段和规则,框架负责模态关系、焦点、关闭和释放,三者的边界非常明确。
Vue 和 React 也可以通过 Modal Service、Portal 或动态挂载形成类似 API。真正的差别是,在 ds-ui 中,这不是绕开页面模型的补充技巧,而是 Window、Dialog 和临时工具共同遵循的一等生命周期模型。
在这个具体场景里,更先进的不是 Canvas 绘制,而是更接近业务意图的抽象:Vue 把临时业务窗口作为页面结构的一部分声明;ds-ui 把它作为一次按需发生、可以等待结果的业务过程。
四、组件越来越多,业务不一定越来越清楚
Vue 的组件化是正确而重要的能力。合理的组件边界可以隔离状态、复用实现,也可以缩小一次更新影响的范围。
问题在于,组件化有时会退化成把一个页面拆成更多文件。
PatientPage
├─ PatientToolbar
├─ PatientInfo
├─ OrderList
├─ MedicalRecord
├─ InspectionPanel
└─ EditDialog
文件看起来更加整齐,但这些组件可能仍然通过大量 Props、Emit、Ref 和 Store 紧密协作。
一次保存操作可能需要从 Dialog 发出事件,由 Page 调用接口,再修改 Store,随后 List、Toolbar 和 Dialog 分别响应变化。系统并不是没有耦合,而是把直接调用关系转换成了事件和状态传播关系。
复杂页面继续拆分以后,组件边界还会同时承担:
- 更新范围
- 状态归属
- 生命周期
- 数据传递
- 事件传递
- 代码组织
拆得太粗,一处状态变化可能影响更大的组件子树;拆得太细,页面又会产生大量实例、依赖、传递关系和生命周期。
为了避免逐层传递,可以使用 Store、provide/inject、Composable 或事件总线。这些都有适用场景,但工具越来越多,并不意味着业务结构一定更容易理解。
真正的组件化应该意味着明确的职责、稳定的接口、可预测的行为和独立的生命周期,而不是简单地把同一个业务过程分散到更多文件中。
我更希望复杂控件在内部横向拆分职责,例如将数据模型、视口、绘制、编辑和交互分别放在同一层级的模块中;业务代码则继续面对一个完整、稳定的控件,而不是为了获得更新精度不断增加业务组件的嵌套深度。
五、能实现,不等于应该让每个业务项目重新实现
上一篇文章发布以后,有人提到,大表格可以使用虚拟滚动和视图回收,没有看到使用 Canvas 的必要性;也有人把重新建立布局、事件和焦点系统理解为重复造轮子。
这些疑问是合理的,但我并不想证明"只有 Canvas 才能做大表格"。DOM 虚拟滚动可以解决许多问题,成熟的 VTable 也使用 Canvas 并只绘制可视区域。如果项目只缺一张大表格,现有组件已经满足需求,直接使用它就是更合理的选择。
我更关心的是,业务开发人员拿到的究竟是一项还需要继续组装的底层技术,还是一个可以直接进入业务流程的完整控件。虚拟化、编辑器复用、键盘导航、焦点恢复和刷新调度都应该由框架内部处理,而不是成为每个医嘱、财务或 ERP 页面开发者必须理解的前置知识。
因此,下面这张图描述的是框架内部如何控制大数据量,而不是业务开发人员应当如何写页面。对使用者来说,这些复杂度最好根本不需要出现。
六、为什么最终不是一张表格,而是一套完整 GUI 框架
一旦临时窗口被建模成按需发生的业务过程,问题就不再只是怎样画一张表格。打开窗口之前可能来自按钮、菜单或快捷键;窗口内部需要输入、选择、校验和焦点移动;关闭以后还要把结果交还给页面、命令或另一个窗口。
Button、Input、DataGrid、Tree、Menu 和 Window 如果各自使用不同的事件、焦点、弹层和生命周期规则,业务开发人员最终仍然需要在页面里把它们重新粘合起来。只有这些控件共享同一种对象模型和运行机制,"沿着事件流和业务流阅读代码"才可能成为整套应用的一致体验。
因此,ds-ui 需要统一的组件关系、布局、事件、焦点、输入、弹层、页面生命周期、刷新和诊断。完整 GUI 框架的意义,不是组件数量更多,而是这些能力不再以互不相关的插件形式暴露给业务项目。
对于只有局部复杂区域的项目,在 Vue 页面中嵌入专业表格或 Canvas 区域完全合理。但如果产品主体就是长期驻留的工作台、表格、窗口和编辑器,同时维护两套组件树、两套焦点规则和两套生命周期,未必比使用一套完整 GUI 运行时更简单。
它要提供的不只是一些看起来风格统一的组件,而是一种统一的开发模型:
Application
↓
Window / Page / Workspace
↓
Component Tree
↓
Layout / Event / Focus / Input
↓
Render
业务开发者面对的是 Application、Page、DataGrid、Tree、Dialog 和 Editor。至于布局怎样计算、事件怎样路由、可视内容怎样更新,应该由框架负责。
七、Canvas 是实现手段,也决定了它的边界
选择 Canvas 并不代表问题自动消失。浏览器原本提供的许多能力需要由框架重新承接,自研运行时也不天然更快。
Canvas 让我可以围绕专业 GUI 建立统一的组件和运行机制,但绘制、缓存、调度和资源释放都应该被框架封装起来。业务开发人员面对的仍然应当是 Button、DataGrid、Tree、Window 和 Editor,而不是 Canvas API。浏览器已经成熟的输入法和剪贴板等能力,也继续由框架内部复用。
技术最终是为开发服务的。如果使用 ds-ui 反而要求业务开发人员先学习绘制、虚拟化和帧调度,它就没有完成自己的工作。
同样,这套模型并不适合所有 Web 应用。如果产品主要是内容展示、信息浏览、普通表单和页面跳转,Vue、React、DOM 和 CSS 已经提供了更成熟的方式,普通增删改查后台也没有必要使用 ds-ui。
Canvas 的可访问性需要额外建设,因此它目前也不适合门户、内容网站和面向大众用户的普通前台产品。
它更适合高信息密度、长时间运行、强键盘操作、复杂表格和编辑器、多页面状态保留以及多面板协同的专业软件。它的价值和代价来自同一个选择:放弃一部分浏览器原生 UI 能力,换取一套更可控、更统一的专业 GUI 运行时。
八、从 WinForms 到 Vue,我真正想重新建立的是什么
回头看,从 WinForms 转到 Vue,我真正不适应的不是语法,也不是所谓的前后端思维。
我明明在开发一套长期运行、持续交互的 GUI 软件,却需要让业务结构不断迁就页面、组件和状态传播关系。
在 WinForms 中,一个控件就是一个控件,一次调用通常意味着一个明确行为,焦点、窗口层级和控件树属于同一套系统。
在 Vue 中,这些能力都可以实现。但当页面越来越复杂,开发人员需要理解的组件边界、响应式依赖、状态传播、DOM 行为和第三方组件细节也会越来越多。
我想重新建立的,不是 WinForms 的旧 API,也不是一个披着桌面外观的网页。
我想重新建立的是一种完整的 GUI 开发体验:
组件、布局、事件、焦点、输入和生命周期属于同一个世界;框架承担底层复杂度,业务开发人员专注于业务。
在高信息密度、长时间运行、强交互的专业软件中,我认为完整 GUI 运行时是一种比普通 Vue 页面模型更先进的抽象。先进的不是 Canvas 绘图 API,而是业务控件具有稳定对象身份,临时界面可以按需发生,事件流可以直接追踪,焦点、窗口和生命周期也由同一个运行时管理。
Vue 仍然是大量 Web 产品更合适的选择。ds-ui 所解决的是另一类问题:让那些通过浏览器交付、使用方式却已经更接近专业桌面软件的应用,拥有一套统一、直接、可预测的开发基础。