React Spectrum v3 架构解析:基于 React Hooks 的三层组件抽象与 react-spectrum 的实现落地
【免费下载链接】react-spectrumA collection of libraries and tools that help you build adaptive, accessible, and robust user experiences.项目地址: https://gitcode.com/GitHub_Trending/re/react-spectrum
本文解读 React Spectrum v3 架构 RFC(rfcs/2019-v3-architecture.md),它是 react-spectrum 仓库从“单体组件库”演进为“状态 / 行为 / 主题三层可复用架构”的奠基性文档。读完后,你将理解为什么仓库中同时存在@react-stately/*、@react-aria/*与@adobe/react-spectrum三类包,每个包各承担什么职责,以及这套架构如何借助 React 16.8 引入的 Hooks,把无障碍、键盘交互、国际化等能力从主题样式中剥离出来供任意平台与主题复用。
背景动机:为什么需要一次架构重构
RFC 开篇给出了重构的核心动因,这些判断至今仍是理解整个仓库结构的钥匙:
- 自建组件库的普遍困境。许多公司从零构建自己的设计系统组件库。Web 原语只提供了 div、HTML 标签、CSS 与 JavaScript,而把无障碍(a11y)、国际化(i18n)、键盘交互等高级特性正确实现起来非常困难,多数团队没有足够资源,结果是无法访问的应用被推到生产环境,加深了“Web 应用体验不如原生”的印象。
- 渲染平台不再只有 Web。React Native、UXP(Torq Native)等平台让 React 可以渲染原生控件,且未来还有 AR/VR 等更多渲染表面。理想状态是:同一套 UI 组件(至少是公开接口)可跨平台复用,底层实现按平台替换。
- 需要一个“无样式基座”。RFC 提出:如果组件库能从一个实现了功能、逻辑、交互、无障碍、国际化但不含任何样式的基座框架起步,各公司就能用自己的方式做样式,以极小成本得到功能完备的组件库;应用也因为到处共享公开接口而具备更好的平台可移植性。
因此 RFC 要求一套可扩展的架构,把每个组件的行为与样式从核心实现中分离,让设计系统开发者能够以最小成本灵活定制样式、交互与行为,同时站在一个功能完备的基座之上。
三层架构:State Hook、Behavior Hook 与 Themed Component
RFC 的核心设计是:借助 React 16.8 的 Hooks,把每个组件最多拆分为三个可独立复用的部分。并非所有组件都具备全部三层——简单组件可能没有状态,有些组件只是其他组件的组合。
- State hook(状态钩子):跨平台共享的 React hook。接受组件的公共 props,提供状态管理,支持受控与非受控两种模式。它不渲染任何 UI,只暴露可被多个平台相关 UI 实现共享的通用状态管理。
- Behavior hook(行为钩子):为特定平台(如 Web 或 Native)提供要传给子元素的 props 的 React hook。它实现事件处理、焦点管理、无障碍、国际化等,并在需要时通过 state hook 更新组件状态,可能还持有平台相关的 UI 状态(例如用于加 class 的焦点状态)。RFC 主张每个 ARIA widget 都应有对应的行为钩子。
- Themed component(主题组件):应用实际使用的组件,负责提供实现某一特定主题(如 Spectrum)所需的 DOM 结构——正确的类名与元素。它消费行为钩子的 props 与状态钩子的 state。
仓库中的真实落地:以 ComboBox / Autocomplete 为例
RFC 用 autocomplete 的 state hook 和 combo box 的 behavior hook 做示例。在当前仓库中,这条演进脉络可以逐层对上:
1. State 层。RFC 示例中的useAutocomplete如今对应 useAutocompleteState。当前实现比 RFC 示例更聚焦:通过 useControlledState 统一处理受控/非受控(inputValue/defaultInputValue),返回inputValue、setInputValue、focusedNodeId、setFocusedNodeId。RFC 强调的“接口需要文档化并遵循语义化版本,因为 react-spectrum 之外的组件也可能使用它”在当前仓库中成立——AutocompleteProps、AutocompleteStateOptions、AutocompleteState均作为公开类型导出(见 packages/@react-stately/autocomplete/src/index.ts)。
2. Behavior 层。RFC 示例中的useComboBox如今对应 useComboBox。其函数签名useComboBox(props, state)与 RFC 描述完全一致:接受组件 props 加上 state hook 返回的状态,返回与主题无关的 props。RFC 示例返回wrapperProps / textfieldProps / buttonProps / menuProps / getMenuItemProps;当前实现返回inputProps / buttonProps / labelProps / valueProps / listBoxProps / descriptionProps / errorMessageProps等,并且内部组合了useMenuTrigger、useSelectableCollection、useKeyboard等更细粒度的行为原语,键盘处理(Enter/Tab 等)、aria-activedescendant、焦点管理均在这一层完成。包级入口 packages/@react-aria/combobox/src/index.ts 只做了对react-aria/useComboBox的再导出。
3. Theme 层。RFC 示例中的ComboBox组件如今对应 ComboBox,对外经 packages/@react-spectrum/combobox/src/index.ts 导出。当前实现印证了 RFC 的设计预期——组件本身尽量保持“小而近无状态”:状态来自useComboBoxState(react-stately),行为 props 来自useComboBox(react-aria),组件自身负责 Spectrum 主题的 DOM 结构、CSS 模块类名(combobox.css)、焦点环(FocusRing)、国际化文案(intl/combobox/*.json)以及移动端降级(MobileComboBox)。
可以看到,RFC 中“state hook → behavior hook → themed component”的数据流在真实代码里是精确兑现的:主题组件同时调用状态与行为两个钩子,把两者产出的值合并到 DOM 属性上。
State Hook 示例(RFC 原文)
RFC 给出的 autocomplete state hook 示意如下,它接受使用方组件的 props,返回状态、更新函数和通用动作,且不渲染 UI:
import { useState, useMemo } from "react"; export function useAutocomplete(props) { let [showMenu, setShowMenu] = useState(false); let [value, setValue] = useState(props.value || ""); let [selectedIndex, setSelectedIndex] = useState(null); let completions = useMemo( () => props.options.filter(option => option.toLowerCase().startsWith(value.toLowerCase()) ), [props.options, value] ); return { showMenu: showMenu && completions.length > 0, setShowMenu, toggleMenu: () => setShowMenu(!showMenu), value, setValue: value => { if (value && !showMenu) { setShowMenu(true); } setSelectedIndex(null); setValue(value); props.onChange(value); }, selectedIndex, setSelectedIndex, completions, selectItem: index => { setValue(completions[index]); setShowMenu(false); props.onChange(completions[index]); } }; }Behavior Hook 示例(RFC 原文)
行为钩子接受组件 props 与状态钩子的返回值,返回给多个子元素使用的、与主题无关的 props,实现键盘/鼠标交互与无障碍属性。RFC 中的 combobox 示例:
import { useRef } from "react"; import { useId } from "./utils"; export function useComboBox(props, autocomplete) { let id = useId(props.id); let listboxId = useId(); let textfieldRef = useRef(); let values = { ...autocomplete, id, listboxId, textfieldRef }; return { wrapperProps: getWrapperProps(values), textfieldProps: getTextfieldProps(values), buttonProps: getButtonProps(values), menuProps: getMenuProps(values), getMenuItemProps: index => getMenuItemProps(values, index) }; } function getWrapperProps({ listboxId, showMenu }) { return { role: "combobox", "aria-controls": showMenu ? listboxId : undefined, "aria-owns": showMenu ? listboxId : undefined, "aria-expanded": showMenu, "aria-haspopup": "true" }; } function getTextfieldProps({ selectedIndex, setSelectedIndex, completions, value, setValue, selectItem, listboxId, showMenu, setShowMenu, textfieldRef }) { let onKeyDown = e => { switch (e.key) { case "ArrowDown": setSelectedIndex( selectedIndex == null ? 0 : (selectedIndex + 1) % completions.length ); break; case "ArrowUp": setSelectedIndex( selectedIndex == null ? completions.length - 1 : (selectedIndex - 1 + completions.length) % completions.length ); break; case "Enter": selectItem(selectedIndex); break; case "Escape": setShowMenu(false); break; } }; return { value, ref: textfieldRef, onChange: e => setValue(e.target.value), "aria-controls": showMenu ? listboxId : undefined, "aria-autocomplete": "list", "aria-activedescendant": showMenu && selectedIndex !== null ? listboxId + "-option-" + selectedIndex : undefined, role: "textbox", autoComplete: "off", onKeyDown: onKeyDown, onBlur: () => setShowMenu(false), onFocus: () => { if (value) { setShowMenu(true); } } }; } function getButtonProps({ toggleMenu, textfieldRef }) { return { tabIndex: "-1", onMouseDown: e => e.preventDefault(), onMouseUp: e => e.preventDefault(), onClick: () => { textfieldRef.current.focus(); toggleMenu(); } }; } function getMenuProps({ listboxId }) { return { id: listboxId, role: "listbox" }; } function getMenuItemProps( { listboxId, selectedIndex, setSelectedIndex, selectItem }, index ) { return { role: "option", id: listboxId + "-option-" + index, tabIndex: selectedIndex === index ? 0 : -1, "aria-selected": selectedIndex === index, onMouseEnter: () => setSelectedIndex(index), onMouseDown: e => e.preventDefault(), onClick: () => selectItem(index) }; }值得对照的是:RFC 示例中getTextfieldProps用aria-activedescendant指向listboxId + "-option-" + index,这一 ARIA 模式在今天的 useComboBox 中依然被保留,只是项 id 改由listData.set(state, {id: menuProps.id})集中登记后动态生成,说明行为钩子输出的属性形态是这套架构最稳定的契约面。
Themed Component 示例(RFC 原文)
主题组件消费两个钩子的产物,只负责主题所需的 DOM 结构。RFC 中的 ComboBox 示例:
import {useAutocomplete} from '@react-state/autocomplete'; import {useComboBox} from '@react-aria/combo-box'; import {Textfield} from '@react-spectrum/textfield'; import {Button} from '@react-spectrum/button'; import {AutocompleteMenu} from '@react-spectrum/autocomplete'; function ComboBox(props) { let autocomplete = useAutocomplete(props); let { wrapperProps, textfieldProps, buttonProps, menuProps } = useComboBox(props, autocomplete); return ( <div {...wrapperProps} className="spectrum-InputGroup"> <Textfield {...textfieldProps} className="spectrum-InputGroup-field" /> <Button {...buttonProps} variant="field" /> <AutocompleteMenu {...menuProps} /> </div> ); }包划分与目录结构:三层各自独立发布
三层必须发布为独立 npm 包
RFC 明确指出:为了让组件的三部分都能被独立使用,它们应发布为独立的 npm 包。这样其他主题组件的作者只需依赖自己用到的代码,而不是把整个 Spectrum 特定的东西都拖进来;react-spectrum 的包则依赖 state 与 behavior 包。RFC 提出的命名结构为:
@react-state/combo-box—— state hook@react-aria/combo-box—— Web 平台的行为钩子实现@react-spectrum/combo-box—— Spectrum 主题组件
对比当前仓库可以发现,RFC 的“开放问题”最终落定的方式与提议略有出入:
- 行为层命名照 RFC 采纳为
@react-aria/*,仓库中存在 packages/@react-aria/combobox、packages/@react-aria/autocomplete 等包; - 状态层没有沿用 RFC 中“仍待讨论”的
@react-state,而是定名为@react-stately/*(如 packages/@react-stately/autocomplete、packages/@react-stately/combobox); - 主题层除
@react-spectrum/*门面包外,主库实现集中在 packages/@adobe/react-spectrum,@react-spectrum/*与@react-aria/*、@react-stately/*的门面包主要承担再导出与类型声明。
目录结构:从“两层一棵树”到当前的组织方式
RFC 提议在 react-spectrum 仓库内使用两层文件夹树,把每个组件的三部分归入同一目录,方便检索:
packages └── combo-box ├── aria │ ├── package.json │ ├── src │ │ └── useComboBox.js │ └── test │ └── useComboBox.js ├── component │ ├── package.json │ ├── src │ │ └── ComboBox.js │ └── test │ └── ComboBox.js └── state ├── package.json ├── src │ └── useAutocomplete.js └── test └── useAutocomplete.js从源码结构看,当前仓库最终没有采用这种“每组件一个目录、内嵌 aria/state/component”的布局,而是按“技术层”组织:packages/@react-stately/、packages/@react-aria/、packages/@react-spectrum/、packages/@react-types/各为扁平的按组件命名的包目录,同时用packages/react-stately/、packages/react-aria/、packages/react-aria-components/等伞形包承载实际源码(例如 combobox 的三层实现分别位于 react-stately/src/autocomplete、react-aria/src/combobox 与 @adobe/react-spectrum/src/combobox)。可以推断这一调整是权衡了包数量(仓库中@react-aria下有 50 余个包)后的结果:每组件三层各建一个目录的样板成本过高,按层分域后检索同样直接。此外仓库还新增了 RFC 未设想的@react-types/*层,承载跨层共享的纯类型定义(如SpectrumTextInputBase、AriaComboBoxProps等,见 ComboBox.tsx 的 import 列表),可以视为三层架构在 TypeScript 生态下的自然延伸。
文档、代价与兼容性
RFC 对重构的配套约束同样值得开发者留意,它们解释了仓库中大量文档与 API 面存在的合理性:
- 文档要求:虽然对应用消费的组件不必然产生 API 变更,但这是内部构建方式的重大变化——消费者可以直接在自己的自定义组件中使用 state 或 behavior hook,API 表面积大幅增加,这些接口需要文档化;同时也要有面向贡献者的架构文档,说明组件应当如何结构化。这与仓库中
packages/@react-aria/*、packages/@react-stately/*每个包都配有 README、且各行为钩子带完整 JSDoc(如useComboBox的接口注释)的现状一致。 - 代价(Drawbacks):这是对组件构建方式的重大变更,每个组件都需要大量重构,耗时耗力;架构复杂度显著上升,贡献者容易困惑——以前出 bug 只需在组件或 spectrum-css 里找,现在可能的排查点多得多,新贡献者还可能不了解或不理解这套架构。
- 向后兼容性分析:该重构不一定要求组件 API 变更,但实现细节变化很大,可能出现非预期的破坏或行为变化;且 Hooks 要求使用方升级到 React 16.8 及以上,因此必须是主版本发布。
- 替代方案:Hooks 发布前曾考虑过 HOC 与 render props,但它们样板代码多、组件嵌套深导致调试困难、难以组合与传递状态。Hooks 允许把共享状态与行为绑定到单一组件实例上而无需子类化或其他 hack,优雅地解决了这些问题。
常见问答与现状对照
RFC 的 FAQ 给出了四个问题,结合当前仓库可以这样理解:
- 为什么做这次重构?一是让 UXP 这类平台复用跨平台代码、同时提供各自的平台特定渲染;二是为开源发布铺路,让 react-spectrum 的潜在用户无需采用 Spectrum 设计就能享受主题无关的行为、无障碍、国际化等沉淀;三是把组件的各部分更好地抽象,使其更小、更易推理。
- 是否包含破坏性 API 变更?该提案不必然要求现有组件 props 的 API 变更,但因为需要 React 16.8,将随主版本发布,且该主版本中可能还有其他不相关的破坏性变更。
- 应用如何升级?升级到 React 16.8 与 React Spectrum 3.0。
- 时间线?这是重大重构,需要大量时间与资源,当时没有明确的发布时间表。
从仓库现状看,这一 RFC 提出的问题在 packages/@react-spectrum、packages/@react-aria、packages/@react-stately 的并行包体系与 rfcs/2019-v3-architecture.md 所在的 RFC 目录(同目录还有 v3 语义化元素、v3 测试、v3 主题 等姊妹 RFC)中都已得到最终答复。
小结
react-spectrum v3 架构 RFC 的核心贡献,是把“一个组件 = 一个文件”的隐式约定升级为**状态(跨平台共享)→ 行为(平台相关、主题无关)→ 主题组件(Spectrum 相关)**的显式分层,并以独立 npm 包固化各层的复用边界。对使用者而言,这意味着你可以只装@react-aria/*与@react-stately/*用任意主题构建无障碍组件,也可以只装@react-spectrum/*/@adobe/react-spectrum直接使用成品;对维护者而言,每层的接口(如useComboBox(props, state)的入参与返回 props 集合)就是跨层契约,任何一层都可以被单独测试与替换。理解这份 RFC,就是理解整个仓库包结构与命名(react-stately、react-aria、react-spectrum)由来的一把钥匙。
【免费下载链接】react-spectrumA collection of libraries and tools that help you build adaptive, accessible, and robust user experiences.项目地址: https://gitcode.com/GitHub_Trending/re/react-spectrum
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考