首页 / 文章

谁应该负责控制UI行为?在HTML、CSS和JavaScript之间如何选择。

一个用于判断何时应使用原生HTML和CSS功能(如对话框、弹出层、:has()选择器以及容器查询)来替代JavaScript,以及何时不应如此的框架。

发布者:REACTAPP.TOP阅读约 15 分钟3416 词

来源: Not Everything Needs JavaScript Anymore

前端开发者会下意识地使用 JavaScript。需要打开菜单?那就添加状态;需要处理点击事件?那就绑定监听器;组件必须适应其宽度?那就测量它;工具提示需要显示在按钮旁边?那就读取坐标;需要滚动效果?那就监听滚动事件并更新样式。现代框架让这一切变得简单:React 有状态和效应机制,Angular 有信号系统,Vue 有引用和响应式功能,而 Svelte 则能让更新代码极为简洁。这些都没有错,JavaScript 也是浏览器从单纯的文档查看器转变为应用平台的重要原因之一。然而 HTML 和 CSS 的功能也在不断扩展,逐渐与这些任务产生重叠。本文将解释为何会出现这种情况,介绍目前可用于处理常见交互的原生机制,并给出一个实用规则,帮助你判断某个功能应由哪一层来负责。

行为模式。

为何平台会不断吸收应用程序的功能

如果JavaScript已经能够解决几乎所有的UI问题,那么平台为何还要继续发展?并非因为JavaScript失败了,而是因为浏览器逐渐学会了处理那些过去每个应用程序都需要独立解决的难题。每当这种情况发生时,开发者就会多一种选择,而更值得探讨的问题则是哪种选择带来的长期成本最低。

每一次变更都在争夺下一帧的渲染机会

回顾一下页面内容发生变化后浏览器会做些什么会有所帮助。简化的渲染流程如下:

JavaScript
    ↓
Style calculation
    ↓
Layout
    ↓
Paint
    ↓
Composite

真正的引擎要复杂得多。并非每次更新都会经过所有阶段,有些操作会在主线程之外执行,合成操作也可以单独进行,还有某些动画会避免反复触发布局或绘制。不过,这个简化模型中的一个核心观点依然成立:浏览器用于生成每一帧的时间是有限的。脚本执行、输入处理、样式计算和渲染都需要共享这部分时间。要处理的任务越多,浏览器就需要更长的时间才能做出响应。如需详细了解各类操作所需的成本,请参阅我们关于重排、重绘和合成的解析。

在配备快速CPU、充足内存且网络连接良好的开发机上,这一点很容易被忽视。但在旧手机上以省电模式运行同一应用,同时打开多个应用后,细微的延迟就会显现出来:动画会掉帧,滚动变得迟缓,按钮的反应时间也长到几乎让人觉得无法正常使用。

性能优化并不仅仅是让现有代码运行得更快。通常最好的优化方式是避免创建浏览器根本不需要你生成的代码。

这关乎责任归属,而非HTML与JavaScript的较量

很容易将这一讨论简化为一句口号:

HTML + CSS = good
JavaScript   = bad

那种说法毫无道理。JavaScript能够解决HTML和CSS根本无法处理的问题。协作编辑依赖于应用逻辑,而实时仪表板则需要保持数据同步。复杂的拖放功能、域名规则、交互式图表、状态机以及多步骤工作流并不会因为新选择器的出现就消失。

真正值得重新考虑的情况其实要简单得多。比如你需要显示或隐藏某些额外信息,脚本就可以用来控制它的显示与隐藏:

button.addEventListener('click', () => {
  panel.hidden = !panel.hidden;
});

那完全是有效的代码。不过HTML可以直接实现信息展示组件。注意,代码片段中闭合标签后的多余是拼写错误,应当删除:

<details>
  <summary>Advanced settings</summary>
  <p>...</p>
</details>h

真正的区别不在于行数,而在于谁来控制行为。使用脚本时,两者之间的关系是这样的:

Application owns the behavior

使用 <details> 后,情况变为:

Browser owns the behavior

当浏览器负责某种行为时,它同时也承担起了那些原本需要手动记忆的细节:键盘支持、向辅助技术展示的展开状态,以及页面间的行为一致性。这一区别的实际重要性往往超出初看时的想象。

为何 HTML 没有止步于段落和表单

从理论上讲,HTML 早就可以停止发展了。标题、段落、表单、按钮、链接、图片以及通用容器就已经足以构建任何内容,而更复杂的功能则交由脚本处理。

不过,试想一下那个世界会是怎样的景象。每个应用程序都会以略有不同的方式重新设计所有的展示、对话框、弹出窗口以及表单行为。各种库似乎在标准化这些模式,框架则将这些库封装起来,而每个访问者都会下载另一种浏览器本可以原生理解的实现方式。

一旦某种模式随处可见,内置的原语就开始发挥其价值。从这个角度看,现代 HTML 并没有变成应用程序框架;它正在逐步将几乎所有应用都需反复构建的交互逻辑进行编码。

没有应用程序状态的弹出窗口

以一个小型设置面板为例。组件的实现通常从一个状态原语开始,比如本例中的信号,或是其他框架中的类似概念:

const open = signal(false);

随后需求会不断累积:

button click
    ↓
change state
    ↓
show overlay
    ↓
handle outside click
    ↓
handle Escape
    ↓
manage stacking

有时产品确实需要所有这些功能。对于较简单的情况,HTML 现已提供了 Popover API。按钮通过 popovertarget 指向带有 popover 属性的元素,浏览器会负责切换显示与隐藏:

<button popovertarget="settings">
  Settings
</button>

<div id="settings" popover>
  ...
</div>

在默认的 popover(自动)模式下,浏览器还会处理简单的关闭操作——当用户点击弹出框外部或按下 Escape 键时就会关闭它,并将弹出框显示在最上层,因此无需手动处理 z-index 堆叠问题。过去完全由应用程序代码实现的功能,现在有了原生的解决方案。

这并不意味着永远不能创建自定义下拉菜单。具备远程搜索、虚拟化、多选功能以及自定义键盘处理的自动完成组件则是完全不同的问题。关键在于,简单的弹出框不再需要从应用程序状态中生成。

基于真实元素生成的对话框

模态框是个很好的示例,因为在不正确实现之前它们看起来很简单。最初的版本只是一个标志位:

open = true;

随后真正的问题出现了:

  • 模态框背后的内容会怎样?
  • 打开时焦点会指向何处?
  • 能否通过键盘关闭它?
  • 关闭时会发生什么,焦点又会回到哪里?
  • 它是否会显示在所有内容之上,处于最上层?
  • 背景层应该是什么样子、有什么功能?

多年来,许多实现都是从通用的 <div> 开始,然后手动构建所有功能。如今 HTML 提供了专门的元素:

<dialog>
  ...
</dialog>

有一个实用细节值得了解:模态框功能是通过调用 showModal() 打开该元素实现的。此调用会将对话框置于最上层,使页面其余部分无法交互,可通过 Esc 键关闭,并提供一个 ::backdrop 伪元素用于样式设置。而使用 show() 或 open 属性打开则会产生非模态对话框。

原生元素无法满足所有的模态窗口需求,实际应用中仍可能在其周围添加额外逻辑。但出发点有所不同:无需再自行定义对话框的概念,因为浏览器已经知晓其含义。这体现了现代平台的普遍规律:原生元素并不会消除应用程序逻辑,只是减少了开发者需要自行处理的逻辑量。

能响应DOM状态的CSS

承担任务的并非只有HTML层,CSS现在也能表达过去需要脚本才能实现的关系。

以某个表单字段为例,当其输入内容无效时,其容器需要被赋予特定样式。过去可能需要通过脚本检测输入内容并同步为父元素添加类名,而现代CSS往往可以直接表达这种关系:

.field:has(input:invalid) {
  /* invalid state */
}

:has() 能改变 CSS 对文档的了解程度。长期以来人们一直希望它能成为缺失的父级选择器,但其带来的架构层面的好处比名称本身更为重要:某些状态同步不再需要通过 JavaScript 来实现。DOM 已经存储了这些信息,CSS 可以直接对其作出响应。无需担心有哪个类会被遗忘而未更新,也无需清理任何监听器。

能响应自身所在区域的组件

传统上,响应式设计依赖于媒体查询:

@media (max-width: 700px) {
  ...
}

当关注点在于视口大小时,这种方法效果很好。然而组件往往还关心其他因素。想象同一张卡片在两个不同位置显示的场景:

Main content
┌───────────────────────────────┐
│            Card               │
└───────────────────────────────┘

Sidebar
┌─────────────┐
│    Card     │
└─────────────┘

在两种情况下,视口大小是相同的;但卡片可用的空间却不同。传统的可复用组件系统通过测量代码来解决这个问题:观察元素、读取其宽度、添加相应类,每当布局发生变化时就重复此操作。

容器查询让CSS能够直接回答真正的问题,即该组件实际上有多少可用空间。只需将某个祖先元素标记为容器,然后针对其尺寸编写规则即可:

.card-container {
  container-type: inline-size;
}
@container (width < 400px) {
  .card {
    /* compact layout */
  }
}

这不仅仅是新的响应式语法,更是职责的重新分配。浏览器已经计算出了布局,CSS可以直接使用该结果,无需应用程序代码再次进行测量。我们在容器查询与媒体查询的对比中探讨了它们在设计系统中应何时使用。

定位机制决定了平台的成长方式

在按钮上添加工具提示看似是件简单的事,但真正动手做时就会发现问题不少。按钮到底应该放在哪里?工具提示的尺寸该多大?下方是否有足够空间,还是需要将其显示在上方?当页面滚动、视口大小改变,或是布局中的其他元素移动了按钮位置时,又会怎样?一个简单的工具提示很快就会变成一个几何定位问题。

正因如此,才有专门的浮动UI库出现,它们能够很好地解决这一棘手问题。CSS现在也加入了锚点定位功能,允许以声明式方式将某个元素相对于另一个元素进行定位。在撰写本文时,各浏览器的支持程度还不均衡,因此在使用该功能前请查看最新的兼容性数据,并准备相应的备用方案。

最重要的是CSS是否能在一夜之间取代所有定位库,而是平台认为定位技术已足够普遍,值得拥有更低层次的实现机制。这正是平台的常规发展模式:问题首先出现在应用程序中,相同的问题反复出现后便催生出各种库,最终平台会从中吸取经验。

动画与声明式API的价值

在性能讨论中,动画是一个较为复杂的领域。有一种常见的简化说法是:CSS 动画在 GPU 上运行,而 JavaScript 动画在 CPU 上运行。但实际上情况更为复杂。CSS 动画仍然需要消耗资源,JavaScript 也能实现非常流畅的动画效果,而且不同 CSS 属性的性能开销差异很大。改变尺寸可能会触发布局重排,其他属性则需要重新绘制,而 transform 和 opacity 通常对渲染引擎更为友好。

因此,有意义的区分标准并非 CSS 与 JavaScript 的对立,而是每次更改会带来多大的性能开销,以及你为浏览器安排这些任务的自由度有多大。

滚动驱动的动画体现了这种变化。传统的滚动效果通常是这样的循环:

scroll event
    ↓
JavaScript calculation
    ↓
update styles
    ↓
repeat

现在,某些此类关系可以直接告知渲染引擎。无需反复让浏览器计算数值、应用该数值然后再重复操作,只需说明动画是沿着滚动时间轴推进的。这属于完全不同的 API 类别,浏览器可以掌控更多的实现机制并进行优化。与锚点定位一样,需在目标浏览器中确认其支持情况。

移除 JavaScript 带来的好处远不止减少字节数

通常人们会用千字节来讨论 JavaScript 代码的规模,这一点很重要,因为每个脚本在发挥实际作用之前都必须经过这一处理流程:

downloaded
    ↓
parsed
    ↓
compiled
    ↓
executed

但应用程序代码的生命周期在首次运行之后还会持续很长时间。用 JavaScript 实现的功能往往还会带来:

state
event listeners
synchronization
cleanup
framework lifecycle
hydration
tests

现在想象一下,如果有一种原生原语允许你删除这种组合会怎样:

state
+
outside-click listener
+
Escape handler
+
DOM synchronization

被移除的代码可能只有几百字节,但架构上的变化却是实实在在的。你的应用程序不再保持那种行为模式。需要维护的状态变量减少了,可能被遗忘而未移除的监听器也少了,必须与状态保持同步的DOM关系减少了,还需要从头重建的交互场景也减少了——而且这些重建后的版本往往在可访问性方面比原生版本更差。通常,平台功能最大的优势并非速度,而是你将控制权交还给浏览器。

平台没有最终版本

一个根深蒂固的误解可以解释为何开发者会忽视这些变化:他们将“HTML5”视为HTML发展到第五版后就基本停止发展的产物。实际上并非如此。目前并没有正在开发的HTML6,也没有需要等待的HTML7。HTML被视为一项持续发展的标准

CSS也遵循类似的模式。许多开发者学习的是“CSS3”,但CSS已不再以一个大的版本包形式存在。各个独立模块按各自的节奏发展,正因如此,我们才会看到这些名称,而并不存在一个统称的“CSS6”:

Selectors Level 4
CSS Color Level 5
CSS Cascade Level

示例中的最后一条内容被截断了;层叠与继承模块也有各自的层级编号。由于各模块相互独立,网格布局可以独立发展而不必等待选择器,颜色处理也与字体处理分开进行,容器查询也在没有特定CSS4里程碑的情况下被引入。在互联网的规模下,渐进式发展是唯一合理的途径。

JavaScript也在不断进步

这种状况同样适用于该编程语言。其标准化规范为ECMAScript;JavaScript则是开发者用来指代那些根据该规范由引擎实现的编程语言的名称。正因如此,技术进展是通过ECMAScript版本来描述的,而非以编号形式的“JavaScript”版本,而且新的功能会持续出现。

因此,这个组合并不会让平台陷入停滞:

HTML5
+
CSS3
+
JavaScript

这三层技术都在持续发展。ECMAScript新增了更完善的原始数据类型,CSS则承担了更多布局、样式及交互功能,而HTML则具备了应用程序过去需要自行实现的语义与行为。它们之间的界限会随时间发生变化,因此多年前学到的平台相关知识需要定期更新。

平台知识比框架选择更具持久性

这些特性存在于任何框架之下。无论组件是用Angular、React、Vue、Svelte还是纯DOM代码构建的,容器查询的功能都是一样的。:has()也不关心DOM是如何生成的。原生<dialog>在框架中依然属于对话框,而弹出窗口则是浏览器的固有功能,无论其由什么方式呈现。

正因如此,平台相关知识具有异常强的持久性。框架技能可以应用到使用该框架的项目中;而平台技能则几乎能应用于所有网页项目。这并非贬低框架的价值,因为框架确实能够解决重大的架构问题以及开发者体验方面的难题。了解框架底层的实现机制,能在不需要抽象层时为你提供更多选择,同样重要的是,它还能让你明白抽象层究竟起到了什么作用。如果你需要具体替代方案的清单,我们可以参考那些可替代常见UI库的原生HTML功能的相关介绍,其中会逐项进行说明。

何时不应选择原生方案

在兴冲冲行动之前,请注意:虽然可以用 HTML 或 CSS 创建某些功能,但并不意味着就应当这么做。试想这样一个下拉菜单所需的功能:

  • 异步搜索;
  • 数百个虚拟选项;
  • 多选功能;
  • 复杂的键盘导航;
  • 定制化的过滤规则;
  • 从服务器加载选项;
  • 确保选择内容与应用状态保持同步。

简单的弹出框根本无法解决这些问题,而为了避开 JavaScript 而扭曲设计只会让架构变得更糟,而非更好。

CSS 也应面对现实。它确实承担着实际工作:布局和渲染并非免费,庞大的 DOM 结构仍会消耗大量资源,不当选择的动画效果也会表现不佳。

因此,目标并非在技术上可行时就使用 HTML 和 CSS。一个更有用的原则是:

应使用最底层的技术来完整实现实际需求。

有时是 HTML,有时是 CSS,有时是 JavaScript,而大多数优秀的应用程序会结合这三种技术。应用这一规则的一个简单方法是为以下问题依次提问:是否有语义化元素或属性可以表达该行为?如果没有,CSS 能否从 DOM 已有的状态中推导出该行为?只有当两个问题的答案都是“否”,或者需求超出了原生技术的支持范围时,才应将该行为实现到应用程序代码中。

最简单的情况应采用最简单的解决方案

复杂的功能仍需依赖 JavaScript,这并无不妥。但围绕这些功能,大多数应用都会存在数百种细微的交互:可展开的区域、轻量级覆盖层、能根据位置自适应的卡片、无效字段的样式处理、简单的过渡效果,以及一个元素对另一个元素的跟踪。每当有原生元素、属性或简短的 CSS 规则能够处理其中某一种交互时,你的应用就完全不必再自行实现它了。

JavaScript 能解决这些问题并不值得惊叹,因为它几乎能搞定任何事情。真正有价值的是还有其他选择存在。

更新思维模式

许多开发者是通过清晰的职责分工来了解网页技术的:

HTML → structure
CSS → presentation
JavaScript → behavior

这确实是一个不错的学习模型,但如今的浏览器功能远比它所展示的更为强大。HTML现在能够承载行为逻辑,CSS可以响应DOM和布局中的复杂关系,而语言本身也在不断新增功能。即便使用习惯没有改变,技术平台仍在持续演进。

许多过去的习惯被当作既定事实:叠加元素需要应用状态、定位需要脚本支持、组件只能响应视口断点、滚动动画需要监听器。这些在曾经或许都是正确的,但如今已不再始终适用。

正因如此,跟上HTML和CSS的发展并非只是为了掌握巧妙的语法。新的浏览器功能可能会消除某些依赖、事件监听器、状态数据、ResizeObserver,或是那些原本仅用于实现浏览器现已能原生处理的功能的代码。

核心要点

  • 浏览器不断采用那些应用程序反复实现的功能;每次采用都会为你提供成本更低的解决方案。
  • 帧时间是一个有限的资源,因此避免不必要的操作往往是最高效的优化方式。
  • 从所有权角度考虑:<details>、Popover API以及带有showModal()的<dialog>都让浏览器来掌控相关行为,包括那些原本需要你自行维护的无障碍功能细节。
  • :has()和容器查询能让CSS响应DOM状态及组件大小的变化,而无需额外的同步或测量代码。
  • 锚点定位与滚动驱动的动画方向一致,但在依赖它们之前请先确认当前浏览器的支持情况。
  • 原生方案并不一定就更好:复杂的组件仍应使用JavaScript实现,CSS处理也有其实际成本。
  • 在添加新的状态变量、钩子、信号、指令、监听器或依赖项之前,先确认平台是否已具备相应功能,然后选择最底层且能满足需求的方案来使用。