Vue和React还在变快。
响应式调度、静态节点跳过、编译期优化、memoization和DOM提交都在改进,基准测试也能看出差距。
但我在ERP、HIS、报表设计器、低代码平台、IDE和数据工作台这类系统里看到的,常常是另一回事:
框架升级了,局部测试更快了,用户真正感受到的滚动、切换、拖动和连续录入却没有同比变快。
这里说的"慢",主要是滚动、切换、拖动和连续录入这些前端交互,不包括接口、数据库和首屏加载。
框架优化当然有效。问题在于,我们常用的框架指标只覆盖了完整交互中的一段。当复杂组件成为系统主体后,这一段即使明显变快,也未必足以改变用户的感受。
一、框架指标看到的是局部链路
这里所说的"框架指标",指组件Render耗时、更新次数、VNode协调、DOM Commit和编译优化命中等框架层数据,不包括INP、浏览器完整Performance Trace或业务自定义的端到端计时。
Vue官方性能指南区分页面加载性能与更新性能,并建议结合Chrome DevTools分析;React Profiler记录组件树的Render耗时和Commit时间点。这些数据各有明确范围。把框架局部数据直接当作整个系统的性能,结论自然会出现偏差。
Vue和React最直接控制的更新路径大致是:
text
状态变化
→ 组件调度和渲染
→ 生成目标界面描述
→ 协调变化
→ 提交DOM
对于内容页面、普通表单和轻量列表,这条链路往往就是一次更新的主体,所以框架指标通常能反映用户体验。
但复杂工作站中的一次交互,可能要经过:
text
业务状态变化
→ Vue或React更新
→ 复杂组件内部状态和几何计算
→ DOM与CSS布局
→ 读取实际尺寸
→ 修正虚拟区、滚动、选区和编辑器
→ 再次布局
→ Paint与Composite
→ 用户看到最终结果
假设一次操作耗时100毫秒,其中框架更新占20毫秒。框架把自己的部分优化一半,总耗时也只是从100毫秒降到90毫秒。这10毫秒是真实收益,但如果剩余成本主要落在复杂组件计算、布局、测量和绘制上,用户很难获得"快了一倍"的感受。
所以我并不怀疑框架指标,而是怀疑它在复杂工作站里还能代表多少完整体验。
框架部分即使优化一半,完整操作也只会按它在总耗时中的占比受益。
剩下的时间花在哪里?先看复杂工作站和普通内容页面的一个根本差别。
二、DOM提交了,复杂组件还没忙完
以内容为主的页面通常顺着文档流展开:
text
内容
→ 浏览器根据文档流自然排版
→ 页面尺寸随内容展开
ERP、HIS、IDE、报表设计器和数据工作台则相反:先有一个确定的可用视口,再由工具栏、侧栏、状态栏和中央工作区分配有限空间。
text
固定视口
→ 各工作区分配受限尺寸
→ 复杂组件在约束内布局、滚动和编辑
前一种页面主要依靠浏览器完成排版。后一种页面里,表格、设计器和工作区还要维护自己的行列、面板、滚动、选区和编辑器几何。麻烦往往出在内部模型与浏览器布局结果的持续协调上。
提交DOM不等于一次交互结束
组件最终有多大,并不由组件自己决定。父容器、Flex或Grid分配、内容换行、字体、滚动条和相邻元素都会参与计算。复杂组件在渲染前经常不知道自己最终能拿到多少空间。
比如,中间表格的高度取决于工具栏、查询区和分页栏。国际化文字多出一行,或者权限变化后多了两个按钮,都可能挤掉表格的一部分空间。表格只能先渲染,再读取真实尺寸,最后修正虚拟区和滚动位置。本文把这种过程称为反馈式布局。
因此,复杂组件里经常能看到这样的代码:
ts
nextTick(() => {
setTimeout(() => {
table.doLayout();
}, 0);
});
这段代码在复杂表格项目里并不少见:先等框架提交DOM,再把重算推迟到后续任务。需要注意,setTimeout(0)并不保证浏览器已经绘制;随后的尺寸读取仍可能触发布局。成熟组件通常会结合ResizeObserver、requestAnimationFrame、缓存和批处理来控制这类协调。
无论具体写法如何,这里都存在两个互相依赖的层次:组件内部模型保存行列、滚动和编辑位置,浏览器给出最终DOM尺寸,组件库负责把两个结果重新对齐。
CSS计算是确定的,但布局影响不会自动止于组件边界
CSS规则是确定的,结果却依赖完整上下文。只看一个组件的代码,往往推不出它最后占多少像素。
影响结果的至少有三类东西:
- 级联、继承、自定义属性和全局主题会跨越组件代码边界;
- 百分比、相对单位、绝对定位、Flex和Grid依赖包含块或同级项目;
- 内容换行、内在尺寸、滚动条、字体和缩放会参与最终尺寸协商。
JavaScript中的组件边界不会自动变成CSS布局边界。两个组件在代码上互不依赖,放进同一个Flex、Grid或滚动容器后,几何上仍然会互相影响。
例如,工具栏中的一段文字因为字体、国际化或主题间距发生换行,工具栏高度随之增加;中央工作区获得的高度减少,表格出现滚动条;滚动条又改变表格的可用宽度,引起列宽、文本换行和编辑器位置重新计算。最后看到的可能只是"表格错了一点",真正的起点却在另一个组件。
我更愿意把它叫作布局影响的"传染":
text
祖先样式或相邻组件变化
→ 容器重新分配空间
→ 后代组件可用尺寸变化
→ 组件内部几何模型失效
→ 测量、修正和再次布局
→ 错位、闪动或滚动位置偏移
代码上的组件边界,并不自动成为样式边界和布局影响边界。
浏览器没有随机布局,只是参与计算的上下文比组件边界大得多。在内容页面中,这种机制非常自然;到了尺寸受限、组件密集的工作站里,它会增加局部推理和故障定位的难度。
Web平台提供了contain等隔离机制,可以限制布局、绘制或尺寸影响的传播。但隔离需要显式建立,更强的尺寸隔离还要求组件提供明确尺寸或占位信息,也会牺牲一部分自适应语义。
因此,框架完成DOM提交时,复杂组件未必已经拿到可以稳定使用的几何结果。这里的"提交之后"是架构边界,不是说浏览器流水线总会严格按几个互斥阶段顺序执行;尺寸读取也可能在JavaScript执行过程中触发同步布局。
三、DataGrid里的另一套几何
把前面的约束放进DataGrid,问题会更具体。表格不只要"把单元格渲染出来",还得处理:
- 表头、表体、冻结区和滚动区的同步;
- 虚拟区、可变行高、列宽与滚动条补偿;
- 多级表头、合并、分组和主从结构;
- 选区、拖动列宽与单元格编辑器定位。
Popup也要处理展开方向、视口边界、父容器裁剪、Portal以及滚动缩放后的重新定位。单独看每一项都不神秘,放在同一个高频交互里,就已经接近一套GUI布局系统。
组件库实际上在浏览器布局系统之上,又维护了一套自己的几何:内部模型决定行列、滚动和编辑位置,DOM与CSS给出实际像素,组件再把结果同步回来。协调一旦进入滚动、拖动和编辑等高频路径,就容易出现卡顿、抖动和难以复现的边界问题。
成熟实现可以显著降低协调成本,但依赖真实像素的功能仍然需要同步两套几何。
AG Grid还在框架之外做了什么
AG Grid很有代表性。它计算自动列宽时,会把已经渲染的单元格克隆到临时容器,挂入视口后读取offsetWidth;处理自动行高时,也要读取元素的offsetHeight,遇到节点尚未进入DOM或高度暂时为零,还会进行有限次重试。
这不是Vue或React的渲染优化,而是表格自己的尺寸测量和调度。AG Grid把行列模型、虚拟化、滚动、编辑和刷新组织在内部,再通过缓存、批处理和受控重算压低协调成本。即便实现已经很成熟,自动尺寸仍要等浏览器给出真实像素。
从应用侧看,AG Grid是一个Vue或React组件;真正支撑虚拟化、尺寸缓存、滚动同步和编辑会话的,却主要是它自己的内部系统。框架负责接入,复杂能力由组件自己完成。
这些实现说明跨边界协调真实存在,但它是不是具体页面的主要瓶颈,仍要看完整Trace和端到端数据。
四、状态驱动遇到连续交互
Vue和React最重要的价值之一,是让开发者描述当前状态下界面应该是什么样,再由框架完成更新。普通业务页面因此简单了很多。
状态驱动不等于虚拟DOM
状态驱动是一种编程模型,虚拟DOM只是保存和协调界面描述的一种实现。Vue使用编译器辅助的虚拟DOM;React官方文档更常用Render和Commit描述更新阶段。两者都把界面看作当前状态的投影。
这不要求所有变化都先写进响应式状态。焦点、滚动、选区和IME可以由DOM持有,Vue和React也提供Ref、Effect等命令式出口。不过,需要在下次渲染中重建的视觉结果,通常仍要从Props、响应式数据或其他渲染输入中推导,否则直接修改的结果可能被下一次提交覆盖。
连续交互通常还需要声明式状态之外的运行时对象
状态很适合描述结果:
text
saving = true
→ 禁用保存按钮
→ 显示加载状态
专业GUI里还有另一类问题:一件事会持续一段时间。
text
开始拖动
→ 捕获指针
→ 连续更新位置
→ 判断停靠目标
→ 必要时自动滚动
→ 提交或取消
→ 释放捕获和临时资源
拖动有明确的开始、持续、结束和清理。状态可以描述当前阶段和最终结果,但指针捕获、自动滚动、临时资源、提交和取消通常还需要一个会话对象保存连续上下文。否则,dragging、activeItem、dropTarget、previewRect和滚动方向会分散到事件、watch或Effect、生命周期、Ref、DOM测量和清理逻辑中。
状态多不是原罪,显式状态机也能把流程写得很严谨。真正费脑的是:原本连续的一条因果链被摊进多个回调和时间点,排查时还得重新把顺序拼回来。同时,用户交互的时间与框架调度、DOM提交和浏览器布局的时间还要不断对齐。
拖动、框选、连续编辑和焦点移动希望立即推进;声明式渲染可能先合并状态更新,稍后再提交DOM。过程一旦依赖真实尺寸,就需要通过nextTick、Ref、Effect或布局测量重新连接两者。
状态本身没有问题;困难来自一条连续因果链被分散到多个机制和时间点。
这不是说Ref、Effect或直接操作焦点不该使用。相反,它们正是框架留下的必要出口。只是当一个组件大量依赖这些出口和自定义调度时,它的内部已经承担了GUI运行时的一部分职责。
这类成本很难通过"状态变化到DOM提交"的指标完整呈现。Profiler已经记录了Commit时间点,拖动会话、焦点修正、尺寸回读和编辑器同步却可能还在继续。React Profiler中的actualDuration主要是组件Render耗时,commitTime是提交时间戳,都不是最终稳定帧的耗时。
五、编译优化的边界
Vue的Template编译器可以识别静态节点、稳定Fragment和可跳过子树;React Compiler也会在构建期自动记忆组件和Hook中的部分计算,减少不必要的重复Render。它们优化的重点仍在前面那条状态到界面提交的链路。
DataGrid的结构常由运行时配置决定:单元格类型、多级表头、动态编辑器、插槽、合并区域、分组和虚拟区都可能变化,能在编译期确定的信息自然更少。使用渲染函数不等于失去框架优化,但缓存、更新范围和内部调度更多要由组件作者掌握。
还有一个更直接的边界:编译器无法提前知道元素最后获得多少像素。自动列宽、动态行高、Popup和编辑器仍要在运行时使用Ref、布局Effect、ResizeObserver、requestAnimationFrame或尺寸读取,再修正虚拟区和滚动位置。
编译器解决"怎样少做一些Render和提交",几何协调解决"提交后实际拿到了多少空间"。两者不是同一个问题。
六、减少反馈,需要改变运行时边界
前面的分析最终指向运行时边界。要把性能测到最终稳定帧,就得继续观察框架提交之外的工作。
对于专业GUI,我最关心四件事:
- 让布局、滚动、命中测试和编辑器共享同一份几何结果;
- 将同一帧中的布局与绘制请求合并,并限制失效范围;
- 让拖动、编辑、焦点和Popup等连续过程拥有稳定的会话对象;
- 同时记录布局、绘制和合成成本,而不是在DOM提交处停止计时。
DOM方案可以通过明确尺寸、CSS Containment、更严格的分层和成熟的内部调度减少反馈。另一条路,是把控件几何和绘制从逐控件DOM映射中抽离,交给自有运行时管理。
这条路没有免费午餐。浏览器原本承担的许多能力,现在要由运行时自己接住:
| 能力 | DOM环境通常提供的基础 | 自有GUI运行时需要承担的工作 |
|---|---|---|
| 文本布局 | 浏览器排版、换行和基线 | 字体测量、换行与基线对齐 |
| 输入与IME | 原生input、textarea |
输入桥接、候选窗与组合输入同步 |
| 可访问性 | 语义DOM与浏览器辅助能力 | 语义树、DOM镜像或其他辅助通道 |
| 焦点与键盘 | 浏览器焦点模型和Tab顺序 | 焦点管理、焦点域与键盘导航 |
| 选择与剪贴板 | 原生选择和剪贴板行为 | 自定义选区、复制粘贴与系统桥接 |
| 调试 | DOM、CSS和性能面板 | 控件树、几何边界和运行时性能工具 |
如果复杂控件只占少数页面,或者成熟DOM组件已经够用,就没有必要自建GUI运行时。只有当固定视口、高密度控件、几何敏感和连续交互成为系统常态,并且团队愿意长期承担上表中的工作时,这条路才值得评估。
一个具体实现样本:ds-ui
ds-ui是我们沿第二条路做的实现。它的出发点不是"Canvas天然比DOM快",而是把原来分散在复杂组件、DOM和CSS之间的布局、几何、交互和调度,尽量收回同一个运行时。
ds-ui的GUI控件树由RenderObject构成。Canvas仍然由DOM承载,中文IME、剪贴板和文本输入也仍需要浏览器输入桥接;区别在于,窗口、布局容器、菜单、输入控件和DataGrid不再与DOM节点一一对应。当前主要管线是:
text
状态或可用尺寸变化
→ markNeedsLayout / markNeedsPaint
→ FrameScheduler合并同一帧请求
→ performLayout(constraints)确定Size和Offset
→ 绘制脏图层
→ composite
这里会区分布局失效、绘制失效和几何变化。布局、滚动、命中测试、选区与编辑器定位共享同一份内部几何,运行时也能继续记录layout、paint和composite。控件内容和业务数据仍然可以状态驱动;拖动、编辑和焦点等瞬态过程则交给运行时对象。
图中比较的是反馈边界,不代表两种架构在所有场景下的性能。
测量、布局和绘制一样都不会消失。我们想减少的是组件模型、控件DOM树、CSS布局与浏览器几何之间的一部分往返反馈。
具体实现可以在ds-ui官网和DataGrid在线演示中查看。
七、回到最终稳定帧
回到最初的问题。Vue和React的框架指标主要回答:
框架把一次状态变化转换成界面提交需要多长时间?
用户感受到的却是:
从输入发生,到布局、滚动、焦点、编辑器和绘制全部稳定,需要多长时间?
在内容页面和轻量表单中,这两个问题通常比较接近。到了尺寸受限、几何敏感、持续交互的工作区,差距就可能很大。框架越来越快,而主要成本可能原本就在组件库内部的GUI几何系统,或者已经转移到它与浏览器布局之间的同步边界。
所以,除了组件更新时间、VNode协调和DOM提交,我还会关心:
- 从用户输入到最终稳定帧的延迟;
- 连续滚动、拖动和录入过程中的帧耗时;
- 一次操作触发的布局、尺寸回读和二次修正次数;
- 焦点、Popup、选区和编辑器完成同步的时间;
- 页面长期驻留后的内存与调度成本。
这里的"最终稳定帧"不是一个现成的浏览器指标,需要结合具体业务定义。INP适合观察离散交互到下一次绘制;连续滚动、拖动和长编辑会话还要结合帧耗时、掉帧、Long Animation Frame、Performance Trace和自定义埋点。
对于内容网站、普通信息系统、轻量表单,以及重视SEO和原生可访问性的页面,DOM、CSS、Vue和React仍然是自然的选择。
ERP、HIS、报表设计器、IDE、复杂DataGrid和专业编辑器面对的是另一组约束。当这些复杂控件从少数例外变成系统主体时,把布局、输入、焦点、虚拟化、渲染和调度收进同一个GUI运行时,才值得单独评估。
ds-ui正在沿这个方向推进,DataGrid是主要验证场景:一边是大数据量下的稳定渲染,一边是接近商业C/S表格控件的连续编辑。最后仍然要用端到端数据说话。Canvas或自有运行时只是选择,不是性能结论。