从 1.3.0 到 1.4.7:@radix-ui/react-slider 版本演进中的 RSC 兼容、表单联动与 Thumb 架构重构

原创2026-10-02 17:43:491,928 阅读
文章标签:前端UI组件设计系统可访问性

从 1.3.0 到 1.4.7:@radix-ui/react-slider 版本演进中的 RSC 兼容、表单联动与 Thumb 架构重构

这篇技术指南以 packages/react/slider/CHANGELOG.md 为主体骨架,结合 packages/react/slider/src/slider.tsx、packages/react/slider/src/slider.test.tsx 与 Storybook 示例源码,逐条拆解 @radix-ui/react-slider 自 1.3.0 至 1.4.7 的关键变更:React Server Components(RSC)兼容性策略、表单重置与隐藏原生输入、键盘步进的取整修正、onValueCommit 的边界修复,以及 ThumbProvider / ThumbTrigger / BubbleInput 三件套的 unstable API 拆分。读完你将理解这些版本号背后的真实实现逻辑,并能据此判断升级到某个版本的具体收益与风险。

版本脉络一览

@radix-ui/react-slider 当前版本为 1.4.7,其 CHANGELOG 记录了从 1.3.0 到 1.4.7 共 14 个版本。整体演进主线清晰:

版本 核心主题
1.4.7 回退破坏性变更,恢复 React Server Components 兼容性
1.4.6 依赖滚动更新
1.4.5 通过 CI 重新发布,为包附加来源证明(provenance)
1.4.4 修复跨 Thumb 拖拽时 onValueCommit 缺失;改进 tree-shaking
1.4.3 修复表单重置后的值同步;修复键盘步进跳过有效值的问题
1.4.2 修复 React 19 中因 composed ref 回调引用不稳定导致的无限重渲染循环
1.4.1 修复多包共同消费时的 Duplicate index signature 类型错误
1.4.0 新增 unstable ThumbProvider / ThumbTrigger / BubbleInput;引入 focusVisible;修复滚动上下文与小 step 场景
1.3.x 依赖更新、ComponentRef 替换废弃 ElementRef、内部 BubbleInput 统一基于 Primitive

依赖层面,包在 1.4.7 时共依赖 11 个内部包(见 package.json):@radix-ui/number、@radix-ui/primitive、@radix-ui/react-collection、@radix-ui/react-compose-refs、@radix-ui/react-context、@radix-ui/react-direction、@radix-ui/react-primitive、@radix-ui/react-use-controllable-state、@radix-ui/react-use-layout-effect、@radix-ui/react-use-previous、@radix-ui/react-use-size。这些依赖的版本号在每次发版中同步滚动,构成了 Slider 组件的基础设施。

1.4.7:RSC 兼容性的回退决策

1.4.7 的变更在工程策略上很有代表性:"Reverted breaking changes that caused compatibility issues with React Server Components." 即在上一版本中引入的某些破坏性变更,因导致 RSC 兼容性问题而被整体回退,同时将 11 个依赖包全部升级到最新版本。

从源码看,Slider 包的入口文件 packages/react/slider/src/index.ts 首行即为 'use client';,这是 RSC 架构中客户端组件边界的显式声明。Slider 依赖 useState、useEffect、Pointer 事件捕获等浏览器运行时能力,因此它必须作为客户端组件暴露;任何破坏这一边界的变更都会波及服务端渲染场景。仓库中专门维护了 RSC 兼容性测试 scripts/rsc-compatibility.rsc.test.ts,可以推断此类回退正是以这类测试为验收标准。

1.4.4:onValueCommit 边界修复与 tree-shaking 改造

onValueCommit:仅在"值发生变化"时提交

1.4.4 修复了一个具体 bug:拖动一个 Thumb 越过另一个 Thumb 时,onValueCommit 没有被调用。

从源码看,提交逻辑位于 slider.tsx 的 handleSlideEnd:

function handleSlideEnd() {
  const hasChanged = String(values) !== String(valuesBeforeSlideStartRef.current);
  if (hasChanged) onValueCommit(values);
}

这里的判断依赖 valuesBeforeSlideStartRef——它在 onPointerDown 时记录拖动前的值快照(见 slider.tsx),拖动结束时与当前值比较,只有真实变化才触发 onValueCommit。

对应的回归测试位于 slider.test.tsx:

  • 右 Thumb(值 107)拖到左 Thumb(值 105)位置、两者重叠时,onValueCommit 应被调用且参数为 [105, 105];
  • 按下并原地释放(值未变)时,onValueCommit 不应被调用。

测试中还模拟了 jsdom 未实现的 setPointerCapture / releasePointerCapture / hasPointerCapture 三个方法,印证了底层滑动交互完全基于 Pointer Events 捕获机制实现(见 slider.tsx)。

tree-shaking:/* @__PURE__ */ 与命名渲染函数

同一版本还改进了包体积优化:所有组件部分标记 /* @__PURE__ */ 注释,并将 Component.displayName = ... 赋值改为命名渲染函数。原因在 CHANGELOG 中说明得很清楚——displayName 赋值会阻止部分打包器进行死代码消除。

在 slider.tsx 中可以看到每个导出组件的写法:

const Slider = /* @__PURE__ */ React.forwardRef<SliderElement, SliderProps>(
  // blank line to reduce diff noise
  function Slider(props: ScopedProps<SliderProps>, forwardedRef) {
    ...
  },
);

SliderTrack、SliderRange、SliderThumb、SliderThumbTrigger、SliderBubbleInput、SliderImpl 等全部遵循这一模式。这与 1.3.5 的 ElementRef → ComponentRef 替换一脉相承,都属于"为现代打包器和 TS 类型系统让路"的工程演进。

1.4.3:表单重置联动与键盘步进取整

表单重置:恢复初始值

1.4.3 修复了表单控制组件在关联表单被 reset 时值未更新的问题,涉及 RadioGroup、Slider、Select、Switch 四个组件。

Slider 侧的实现位于 slider.tsx:挂载时找到关联表单(通过 form prop 指定的外部表单 id,或最近的 <form> 祖先),注册 reset 事件监听器,把值恢复为初始快照:

const initialValuesRef = React.useRef(values);
React.useEffect(() => {
  const associatedForm = form
    ? control?.ownerDocument.getElementById(form)
    : control?.closest('form');
  if (associatedForm instanceof HTMLFormElement) {
    const reset = () => setValues(initialValuesRef.current);
    associatedForm.addEventListener('reset', reset);
    return () => associatedForm.removeEventListener('reset', reset);
  }
}, [control, form, setValues]);

测试覆盖了四种组合:slider.test.tsx 中的"表单内重置"(uncontrolled 恢复 defaultValue、controlled 恢复初始 value)与"外部表单关联重置"(通过 form="slider-reset-form" 指向外部表单)。注意 initialValuesRef 捕获的是初始渲染时的值,因此 controlled 用法下重置会回到"初始 state",而不是"当前 prop",这一点在文档(storybook 的 WithinFormReset 示例)中被明确演示。

键盘步进:对齐 step 网格

第二个修复针对键盘步进:当当前值不在 step 网格上时(例如 defaultValue 不是 min + n*step),方向键步进会跳过或产生无效值。修复后步进会吸附到移动方向上的下一个网格对齐值,与原生 <input type="range"> 的行为保持一致。

实现核心在 slider.tsx 的 getNextStepValue:

const stepsFromMin = (value - min) / step;
const nearestSteps = Math.round(stepsFromMin);
const isAligned =
  roundValue(nearestSteps * step + min, decimalCount) === roundValue(value, decimalCount);

let nextSteps: number;
if (isAligned) {
  nextSteps = nearestSteps + multiplier * direction;
} else if (direction > 0) {
  nextSteps = Math.ceil(stepsFromMin);
} else {
  nextSteps = Math.floor(stepsFromMin);
}
return roundValue(nextSteps * step + min, decimalCount);

关键点:值已对齐时按 multiplier * direction 正常步进;未对齐时向移动方向取整到最近的网格点(向上 Math.ceil、向下 Math.floor),保证既不错过步骤也不会停在非网格值上。multiplier 用于 PageUp/PageDown(10 倍步进)以及 Shift+方向键跳步(见 slider.tsx)。

回归测试 when the value is off the step grid(slider.test.tsx):defaultValue={[49000]}、min={1000}、max={100000}、step={5000} 时,按 ArrowRight 应到 51000(而非跳过),再按一次到 56000;按 ArrowLeft 应到 46000。

1.4.2:React 19 下 composed ref 的稳定性

1.4.2 修复了 React 19 中因不稳定的 composed ref 回调引用导致的无限重渲染循环。这是因为 useComposedRefs 每次渲染都产生新的回调函数引用,在 StrictMode 或新版本 React 的并发渲染下可能触发额外渲染。

仓库提供了专门的回归测试工具与用例:internal/test-utils/ref-stability.tsx 导出的 assertStableComposedRef 在 slider.test.tsx 中被用于验证 Root 与 Thumb 的 composed ref 保持稳定引用:

it('keeps a stable composed ref on the root', () => {
  assertStableComposedRef((ref) => (
    <Slider.Root ref={ref} defaultValue={[50]}>
      ...
    </Slider.Root>
  ));
});

Root 与 Thumb 的 ref 合并路径分别在 slider.tsx(useComposedRefs(forwardedRef, setControl))与 slider.tsx(useComposedRefs(forwardedRef, onThumbChange))。这类 ref 稳定性修复对使用 React 19 的团队是升级到 1.4.2+ 的直接理由。

1.4.1 与 1.3.5:类型层面的清理

  • 1.4.1 修复了多个包共同消费时出现的 Duplicate index signature TypeScript 编译错误,属于跨包类型声明的兼容性修复。
  • 1.3.5 用 ComponentRef 替换已废弃的 ElementRef(对应 issue #3426)。在 slider.tsx 中可以看到 React.ComponentRef<typeof Primitive.span>、React.ComponentRef<typeof Primitive.input> 等写法贯穿全部组件,类型系统的现代化为 1.4.1 的类型修复奠定了基础。

1.4.0:Thumb 架构重构与 unstable API

1.4.0 是本 CHANGELOG 中变更最集中的版本,包含五项内容:

1. ThumbProvider / ThumbTrigger / BubbleInput 三件套

此前 SliderThumb 是一个隐式渲染隐藏原生 <input>(用于表单提交)的单一组件。1.4.0 将其拆分为三个新 parts:

  • SliderThumbProvider(内部名 SliderThumbProvider):建立每个 Thumb 的上下文,计算 index、value、percent、size、isFormControl、解析 name 等(见 slider.tsx);
  • SliderThumbTrigger:渲染真正可拖拽、可聚焦的 role="slider" 元素(见 slider.tsx);
  • SliderBubbleInput:渲染隐藏的 Primitive.input,仅用于把值携带进 FormData(见 slider.tsx)。

SliderThumb 仍是三者默认组合后的快捷组件(slider.tsx):当处于表单控制场景时自动追加 SliderBubbleInput。三个新 parts 在源码中直接以本名导出,并在包入口 index.ts 中以 unstable_ 前缀暴露(如 unstable_SliderThumbProvider、unstable_SliderThumbTrigger、unstable_SliderBubbleInput)。

解耦后的典型用法(来自 slider.stories.tsx 的 Parts 示例):

<Slider.Root defaultValue={[data.price.min, data.price.max]}>
  <Slider.Track>
    <Slider.Range />
  </Slider.Track>

  <Slider.unstable_ThumbProvider name="price[min]">
    <Slider.unstable_ThumbTrigger className={styles.thumb} />
    <Slider.unstable_BubbleInput />
  </Slider.unstable_ThumbProvider>

  <Slider.unstable_ThumbProvider name="price[max]">
    <Slider.unstable_ThumbTrigger className={styles.thumb} />
    <Slider.unstable_BubbleInput />
  </Slider.unstable_ThumbProvider>
</Slider.Root>

这允许消费者把 bubble input 与 thumb 彻底解耦(例如渲染到别处或单独定制),而不必依赖 SliderThumb 的隐式行为。对应测试分别覆盖了 Slider.ThumbTrigger 与 Slider.BubbleInput 的 props 透传与 asChild 转发(slider.test.tsx)。

关于 BubbleInput 有两个值得注意的实现细节(slider.tsx):

  • 刻意不用 type="hidden":否则包裹它的表单将无法通过 FormData API 读取其值;
  • 刻意不设置 value 属性:通过原生 value setter 编程式赋值并派发冒泡的 input 事件,让 React 不会把它当作重复更新而吞掉。

2. focusVisible:键盘与非键盘交互的样式区分

新增的 focusVisible 允许对非键盘交互(指针拖动)的 Thumb 进行编程式焦点管理。源码声明了全局 FocusOptions.focusVisible 扩展(slider.tsx),并在两处使用:

  • 键盘步进(Home/End/方向键)时 isKeyboardInteractionRef.current = true,随后以 focus({ preventScroll: true, focusVisible: true }) 聚焦 Thumb(slider.tsx);
  • 指针交互时以 focusVisible: false 聚焦,避免拖动后出现焦点环(slider.tsx)。

这让开发者可以放心依赖 :focus-visible 渐进增强样式:只有键盘到达 Thumb 时才显示焦点指示。

3. 可滚动上下文中的焦点修复

修复了 Slider 处于可滚动容器中时的焦点问题。Storybook 的 InScrollableContext 示例(slider.stories.tsx)演示了将 Slider 放入 overflowX: 'scroll' 容器中的场景,配合 preventScroll: true 避免聚焦时页面跳动。

4. 极小 step 值下的响应性

修复了 step 非常小时 Thumb 无响应的问题。这对应 slider.tsx 中 getDecimalCount / roundValue 对科学计数法(如 1e-7)的特殊处理。回归测试(slider.test.tsx)验证了 step={1e-7} 与 step={1.5e-7} 的逐步精确累加,这正是 1.4.0 该修复的延续验证。

5. repository.directory 元数据

为所有 package.json 添加了 repository.directory 字段(本包值为 packages/react/slider),便于工具链与发布系统定位子包在 monorepo 中的位置。

1.3.x:依赖滚动与 Primitive 统一

1.3.0 完成了一项架构统一:所有带内部 bubble inputs 的表单控件(RadioGroup、Slider、Select、Switch 等)默认改用 Radix 的 Primitive 组件渲染隐藏输入,以便未来直接暴露这些组件供用户精确控制。这一点在 1.4.0 的 BubbleInput 拆分中真正落地。从 slider.tsx 可以看到 SliderBubbleInput 确实渲染自 Primitive.input。

1.3.1 至 1.3.6 则以依赖版本滚动为主(react-use-controllable-state、react-collection、react-primitive、react-context 等),并完成 ComponentRef 替换。

版本升级决策参考

综合 CHANGELOG 与源码,给出一条务实的升级路径建议:

  • 使用 React 19 或 SSR 环境:务必落在 1.4.7(RSC 兼容)与 1.4.2+(composed ref 稳定性);
  • 依赖表单提交与重置:需要 1.4.3+ 获取 reset 联动修复,并理解 SliderThumb 会隐式渲染 BubbleInput(name 在 Root 或 Thumb 上均可设置,多 Thumb 时 Root 的 name 自动附加 <a href="https://link.gitcode.com/i/b3e689d83534b37171e5934ba39fee79" target="_blank">] 后缀,参见 [slider.tsx);
  • 多 Thumb 范围选择器:需要 1.4.0+ 才能使用 preserveThumbOrder(默认 false,允许 Thumb 交叉互换;开启后约束在相邻 Thumb 边界内并兼容 minStepsBetweenThumbs,实现见 slider.tsx,测试见 slider.test.tsx)以及 minStepsBetweenThumbs;
  • 追求包体积:1.4.4+ 的 tree-shaking 改进能让打包器按需剔除未使用的组件部分;
  • 自定义 Thumb 的表单输入:1.4.0+ 可消费 unstable_ThumbProvider / ThumbTrigger / BubbleInput 三件套,但需注意 API 仍处于 unstable 状态,升级时可能变动。

说明:本仓库为只读镜像,以上所有源码与测试均可直接查阅:slider.tsx、slider.test.tsx、index.ts、package.json、Storybook 示例 slider.stories.tsx 及其样式 slider.stories.module.css、SSR 测试页 apps/ssr-testing/app/slider/page.tsx。

登录后查看全文
primitives