从 1.3.0 到 1.4.7:@radix-ui/react-slider 版本演进中的 RSC 兼容、表单联动与 Thumb 架构重构
从 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 signatureTypeScript 编译错误,属于跨包类型声明的兼容性修复。 - 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。