ScaleSlider 组件实现
需求分析
功能需求
- ✅ 支持纵向和横向两种方向
- ✅ 显示刻度线(主刻度和次刻度)
- ✅ 实时显示当前值(带单位)
- ✅ 箭头指向滑块位置
- ✅ 值显示框跟随滑块移动
- ✅ 支持鼠标拖拽和点击跳转
- ✅ 受控和非受控模式
- ✅ 禁用状态支持
交互需求
- ✅ 点击轨道跳转到目标位置
- ✅ 拖拽滑块平滑移动
- ✅ 值实时更新
- ✅ 过渡动画(点击时平滑,拖拽时即时)
- ✅ 悬停效果
视觉需求
css
纵向布局:
刻度 轨道 箭头+值
═══ ║ ← [1.60mm]
═══ ●
═══ ║
横向布局:
刻度 ═══ ══ ═══
轨道 ●═════
箭头 ↑
值 [50%]
设计思路
1. 组件结构设计
初始方案(V1)
typescript
<Container>
<ValueDisplay /> // 左侧/上方
<SliderWrap>
<Scales />
<Track />
</SliderWrap>
</Container>
问题:
- ❌ 值显示位置固定,不跟随滑块
- ❌ 布局不够灵活
改进方案(V2)
typescript
<Container>
<ValueDisplay /> // 在另一侧
<SliderWrap>
<Scales />
<Track />
<ValueWrapper /> // 跟随滑块
</SliderWrap>
</Container>
问题:
- ❌ ValueWrapper 嵌套过深
- ❌ 鼠标事件复杂,容易误触发
最终方案(V3 - CSS Grid)
typescript
<Container> // Grid 布局
<ScalesContainer /> // 独立区域
<TrackContainer /> // 独立区域
<ValueContainer /> // 独立区域
</Container>
优势:
- ✅ 三个区域完全独立
- ✅ 鼠标事件精确隔离
- ✅ 定位清晰简单
2. 布局方案对比
方案 A:Flexbox + Padding
css
.sliderWrap {
padding-right: 100px; /* 为值显示预留空间 */
}
.valueWrapper {
position: absolute;
left: calc(100% - 90px);
}
问题:
- ❌ padding 区域仍会捕获鼠标事件
- ❌ 定位复杂,容易出错
方案 B:CSS Grid(最终选择)
css
.container {
display: grid;
grid-template-columns: auto 8px auto; /* 纵向 */
grid-template-rows: auto 8px auto; /* 横向 */
}
优势:
- ✅ 每个区域独立,互不干扰
- ✅ 宽度/高度自动计算
- ✅ 响应式友好
实现过程
阶段 1:基础滑动条(V1)
代码实现
typescript
export function ScaleSlider({ orientation = 'vertical', ...props }) {
const [value, setValue] = useState(defaultValue)
const trackRef = useRef<HTMLDivElement>(null)
const handleMouseDown = (e: React.MouseEvent) => {
// 计算点击位置
const rect = trackRef.current.getBoundingClientRect()
const percent = orientation === 'vertical'
? (rect.bottom - e.clientY) / rect.height
: (e.clientX - rect.left) / rect.width
const newValue = min + percent * (max - min)
setValue(newValue)
}
return (
<div>
<div ref={trackRef} onMouseDown={handleMouseDown}>
<div className="fill" style={{ height: `${percentage}%` }} />
<div className="thumb" style={{ bottom: `${percentage}%` }} />
</div>
</div>
)
}
实现效果
- ✅ 点击跳转
- ✅ 基础拖拽
- ❌ 没有刻度
- ❌ 没有值显示
阶段 2:添加刻度和值显示(V2)
代码实现
typescript
// 生成刻度
const scales = Array.from({ length: scaleCount + 1 }, (_, i) => ({
position: (i / scaleCount) * 100,
isMain: i % 2 === 0,
}))
return (
<div className={styles.container}>
<div className={styles.valueDisplay}>
{formatValue(value)}
</div>
<div className={styles.sliderWrap}>
{/* 刻度 */}
<div className={styles.scales}>
{scales.map((scale, i) => (
<div key={i} className={styles.scale}
style={{ bottom: `${scale.position}%` }} />
))}
</div>
{/* 轨道 */}
<div ref={trackRef} className={styles.track}>
<div className={styles.fill} />
<div className={styles.thumb} />
</div>
</div>
</div>
)
CSS 实现
css
.container {
display: flex;
flex-direction: row; /* 纵向 */
gap: 12px;
}
.scales {
position: absolute;
right: calc(100% + 4px);
}
.scale {
position: absolute;
width: 6px;
height: 1px;
bottom: X%;
}
实现效果
- ✅ 刻度显示正确
- ✅ 值显示在左侧
- ❌ 值不跟随滑块
- ❌ 没有箭头指示
阶段 3:值显示跟随滑块(V3)
代码改进
typescript
// 将 valueWrapper 移到 sliderWrap 内部
<div className={styles.sliderWrap}>
<div className={styles.scales}>...</div>
<div ref={trackRef} className={styles.track}>...</div>
{/* 值显示跟随滑块 */}
<div className={styles.valueWrapper}
style={{ bottom: `calc(${percentage}% - 0.5rem)` }}>
<svg>箭头</svg>
<div className={styles.valueDisplay}>
{formatValue(value)}
</div>
</div>
</div>
CSS 定位
css
.sliderWrap {
position: relative;
padding-right: 100px; /* 为值显示预留空间 */
}
.valueWrapper {
position: absolute;
left: calc(100% - 90px);
bottom: calc(X% - 0.5rem); /* 跟随滑块 */
}
实现效果
- ✅ 值跟随滑块位置
- ✅ 箭头指向滑块
- ❌ 出现严重 Bug
遇到的问题与解决方案
❌ 问题 1:纵向滑块鼠标事件异常
问题描述
现象:鼠标在纵向滑块附近移动(不点击),滑块也会跟随移动
影响:组件完全无法使用,交互体验极差
触发条件:只在纵向布局出现,横向布局正常
问题排查
Step 1:检查事件绑定
typescript
// ✅ 事件绑定正确
<div ref={trackRef} onMouseDown={handleMouseDown}>
Step 2:检查拖拽逻辑
typescript
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (isDraggingRef.current) { // ✅ 有拖拽状态判断
updateValue(e.clientX, e.clientY)
}
}
// ...
}, [])
Step 3:检查 DOM 结构
html
<!-- ❌ 发现问题:valueWrapper 嵌套在 sliderWrap 内 -->
<div class="sliderWrap" style="padding-right: 100px">
<div class="track"></div>
<div class="valueWrapper"></div> <!-- 可能捕获事件 -->
</div>
Step 4:检查 CSS 布局
css
/* ❌ 发现问题:padding 导致可交互区域过大 */
.sliderWrap {
padding-right: 100px; /* 这个区域可能捕获事件 */
}
Step 5:添加调试代码
typescript
const handleMouseDown = (e: React.MouseEvent) => {
console.log('MouseDown triggered')
console.log('Target:', e.target)
console.log('CurrentTarget:', e.currentTarget)
}
// 发现:有时 e.target 不是 track 元素
根本原因分析
scss
问题 1:DOM 嵌套层级过深
Container
└── sliderWrap (padding-right: 100px)
├── scales
├── track ← 应该只有这里响应
└── valueWrapper ← 嵌套在内部,可能干扰事件
问题 2:可交互区域不明确
═══ ║ [值]
└── track (8px)
└────────────────────┘
padding (100px)
↑ 这个区域可能误触发
问题 3:pointer-events 控制不精确
.sliderWrap { } // 没有禁用
.track { } // 响应事件
.valueWrapper { } // 没有明确禁用
✅ 解决方案演进
方案 1:添加 pointer-events(失败)
css
.sliderWrap {
pointer-events: none;
}
.track {
pointer-events: auto;
}
.valueWrapper {
pointer-events: none;
}
结果:
- ❌ 仍然有问题
- ❌ 原因:valueWrapper 仍在 sliderWrap 内部
方案 2:调整 DOM 结构(部分成功)
typescript
<Container>
<div className={styles.sliderWrap}>
<Scales />
<Track />
</div>
<div className={styles.valueWrapper}> {/* 移到外部 */}
...
</div>
</Container>
结果:
- ✅ 减少了误触发
- ❌ 仍有边缘情况
- ❌ 定位复杂
方案 3:CSS Grid 重构(最终成功)
核心思路:三个区域完全独立
typescript
<Container> // Grid 布局
<ScalesContainer /> // 区域 1:刻度(不可交互)
<TrackContainer /> // 区域 2:轨道(唯一可交互)
<ValueContainer /> // 区域 3:值显示(不可交互)
</Container>
CSS Grid 配置
css
.container {
display: grid;
position: relative;
}
/* 纵向:三列布局 */
.vertical {
grid-template-columns: auto 8px auto;
/* 刻度(auto) | 轨道(8px) | 值显示(auto) */
}
/* 横向:三行布局 */
.horizontal {
grid-template-rows: auto 8px auto;
/* 刻度(auto) | 轨道(8px) | 值显示(auto) */
}
精确控制交互区域
css
/* ✅ 只有轨道响应鼠标 */
.trackContainer {
pointer-events: auto;
width: 8px; /* 纵向 */
height: 8px; /* 横向 */
}
/* ❌ 其他区域不响应 */
.scalesContainer,
.valueContainer,
.thumb,
.fill {
pointer-events: none;
}
视觉对比
scss
修正前(有问题):
┌────────────────────────────────┐
│ sliderWrap (可能误触) │
│ ┌────┐ ┌──────────┐ │
│ │轨道│ │ 值显示 │ │
│ └────┘ └──────────┘ │
│ 8px padding 100px │
└────────────────────────────────┘
修正后(正确):
┌──────┬────┬──────────┐
│ 刻度 │轨道│ 值显示 │
│(no) │YES│ (no) │
│ │8px│ │
└──────┴────┴──────────┘
❌ 问题 2:值显示定位复杂
问题描述
需求:值显示框要跟随滑块位置
难点:同时要保持在固定区域内
解决方案:双层定位
typescript
// 外层容器:跟随滑块位置
<div className={styles.valueContainer}
style={{ bottom: `${percentage}%` }}>
// 内层内容:在容器中居中
<div className={styles.valueContent}>
<Arrow />
<ValueDisplay />
</div>
</div>
css
/* 外层:跟随滑块 */
.valueContainer {
position: relative;
bottom: X%; /* 动态值 */
}
/* 内层:居中对齐 */
.valueContent {
position: absolute;
bottom: 0;
transform: translateY(50%); /* 垂直居中 */
}
效果对比
css
单层定位(复杂):
.valueWrapper {
position: absolute;
left: calc(100% - 90px); // 横向固定
bottom: calc(X% - 0.5rem); // 纵向跟随
transform: translateY(50%); // 居中
}
双层定位(清晰):
.valueContainer {
bottom: X%; // 跟随滑块
}
.valueContent {
transform: translateY(50%); // 居中
}
❌ 问题 3:拖拽性能优化
问题描述
css
现象:拖拽时有轻微延迟或卡顿
原因:CSS transition 在拖拽时不应该生效
解决方案:动态禁用过渡
typescript
const [isDragging, setIsDragging] = useState(false)
const fillClasses = `${styles.fill} ${
isDragging ? styles.fillNoDrag : ''
}`
css
.fill {
transition: height 0.15s ease, width 0.15s ease;
}
/* 拖拽时禁用过渡 */
.fillNoDrag {
transition: none !important;
}
.container:active .valueContainer {
transition: none !important;
}
效果对比
ini
点击跳转(平滑):
时间 0ms: ● [50%]
时间 75ms: ● [60%] ← 平滑过渡
时间 150ms: ● [70%]
拖拽移动(即时):
时间 0ms: ● [50%]
时间 1ms: ● [70%] ← 立即跟随
最终架构
1. 组件结构
arduino
ScaleSlider
├── types.ts // TypeScript 类型定义
├── ScaleSlider.tsx // 组件主逻辑
├── ScaleSlider.module.css // 样式文件
└── index.ts // 导出
2. DOM 结构
html
<div class="container vertical"> <!-- Grid 容器 -->
<!-- 区域 1:刻度(pointer-events: none) -->
<div class="scalesContainer">
<div class="scale scaleMain" style="bottom: 0%"></div>
<div class="scale scaleMinor" style="bottom: 10%"></div>
<div class="scale scaleMain" style="bottom: 20%"></div>
...
</div>
<!-- 区域 2:轨道(pointer-events: auto) -->
<div class="trackContainer" onMouseDown={...}>
<div class="track">
<div class="fill" style="height: 60%"></div>
<div class="thumb" style="bottom: 60%"></div>
</div>
</div>
<!-- 区域 3:值显示(pointer-events: none) -->
<div class="valueContainer" style="bottom: 60%">
<div class="valueContent">
<svg class="arrow">←</svg>
<div class="valueDisplay">1.60mm</div>
</div>
</div>
</div>
3. CSS Grid 布局
css
/* 纵向布局 */
.vertical {
display: grid;
grid-template-columns:
auto /* 刻度区域(自适应宽度) */
8px /* 轨道区域(固定 8px) */
auto; /* 值显示区域(自适应宽度) */
gap: 8px;
}
/* 横向布局 */
.horizontal {
display: grid;
grid-template-rows:
auto /* 刻度区域(自适应高度) */
8px /* 轨道区域(固定 8px) */
auto; /* 值显示区域(自适应高度) */
gap: 8px;
}
4. 交互区域示意图
scss
纵向滑块:
┌──────────┬────┬──────────┐
│ 刻度区 │轨道│ 值显示 │
│ (14px) │8px │ (100px) │
│ │ │ │
│ ═══ │ │ │
│ ═══ │ │ │
│ ═══ │ ║ │ ← 值 │
│ ═══ │ ● │ │ ← 跟随滑块
│ ═══ │ ║ │ │
│ ═══ │ │ │
│ │ │ │
│ 不响应 │响应│ 不响应 │
└──────────┴────┴──────────┘
技术总结
1. 核心技术要点
✅ CSS Grid 布局
css
优势:
- 区域完全独立
- 自动计算尺寸
- 响应式友好
- 代码简洁
适用场景:
- 需要精确控制区域边界
- 需要独立控制交互行为
- 需要灵活的响应式布局
✅ pointer-events 精确控制
css
核心策略:
1. 容器默认 pointer-events: none
2. 只有交互区域 pointer-events: auto
3. 其他元素明确 pointer-events: none
防止误触发:
- 刻度不响应
- 滑块不响应(通过轨道控制)
- 值显示不响应
✅ 双层定位策略
typescript
外层:控制位置(跟随滑块)
<div style={{ bottom: `${percentage}%` }}>
内层:控制对齐(居中)
<div style={{ transform: 'translateY(50%)' }}>
...
</div>
</div>
✅ 受控/非受控模式
typescript
const isControlled = controlledValue !== undefined
const value = isControlled ? controlledValue : internalValue
if (!isControlled) {
setInternalValue(newValue)
}
onChange?.(newValue) // 总是触发回调
2. 性能优化
动画优化
typescript
// 点击时:使用 CSS transition
<div className={styles.fill} />
// 拖拽时:禁用 transition
<div className={`${styles.fill} ${styles.fillNoDrag}`} />
事件优化
typescript
// 使用 ref 存储拖拽状态,避免闭包问题
const isDraggingRef = useRef(false)
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (isDraggingRef.current) { // 直接读取 ref
updateValue(e.clientX, e.clientY)
}
}
// ...
}, []) // 空依赖数组
计算优化
typescript
// 预计算刻度位置
const scales = useMemo(() =>
Array.from({ length: scaleCount + 1 }, (_, i) => ({
position: (i / scaleCount) * 100,
isMain: i % 2 === 0,
})),
[scaleCount]
)
3. 关键经验教训
❌ 避免的坑
- 过度嵌套 DOM
typescript
// ❌ 错误
<div class="wrapper">
<div class="inner">
<div class="track"></div>
<div class="value"></div> <!-- 嵌套过深 -->
</div>
</div>
// ✅ 正确
<div class="container">
<div class="track"></div>
<div class="value"></div> <!-- 扁平化 -->
</div>
- 不明确的交互区域
css
/* ❌ 错误 */
.wrapper {
padding: 100px; /* 大面积可能误触 */
}
/* ✅ 正确 */
.trackContainer {
width: 8px; /* 精确宽度 */
pointer-events: auto;
}
- 忽视 pointer-events
css
/* ❌ 错误:没有明确禁用 */
.thumb { }
/* ✅ 正确:明确禁用 */
.thumb {
pointer-events: none;
}
✅ 最佳实践
- 用 Grid 代替复杂的 Flex + Position
- 精确控制每个元素的 pointer-events
- 双层定位处理跟随+居中
- 用 ref 管理事件状态,避免闭包
- 动态控制过渡动画
4. 可扩展性设计
支持的功能扩展
typescript
// ✅ 自定义刻度渲染
renderScale?: (value: number, isMain: boolean) => ReactNode
// ✅ 自定义值显示
renderValue?: (value: number) => ReactNode
// ✅ 范围滑动条(双滑块)
type?: 'single' | 'range'
// ✅ 垂直文字(纵向布局)
valueOrientation?: 'horizontal' | 'vertical'
// ✅ 触摸支持
onTouchStart, onTouchMove, onTouchEnd
// ✅ 键盘控制
onKeyDown: (e) => {
if (e.key === 'ArrowUp') setValue(v => v + step)
if (e.key === 'ArrowDown') setValue(v => v - step)
}
完整示例
基础使用
typescript
<ScaleSlider
value={layerHeight}
onChange={setLayerHeight}
min={0}
max={3}
step={0.1}
unit="mm"
precision={2}
orientation="vertical"
scaleCount={10}
/>
高级配置
typescript
<ScaleSlider
value={temperature}
onChange={setTemperature}
min={-20}
max={100}
step={1}
unit="°C"
precision={1}
orientation="horizontal"
scaleCount={12}
showValue={true}
showArrow={true}
disabled={false}
size={300}
/>
总结
核心突破
-
使用 CSS Grid 解决布局隔离问题
- 三个区域完全独立
- 交互区域精确可控
-
pointer-events 精确控制
- 只有轨道响应鼠标
- 消除所有误触发
-
双层定位策略
- 外层跟随滑块
- 内层居中对齐
技术价值
- ✅ 可复用的组件架构
- ✅ 清晰的代码结构
- ✅ 良好的性能表现
- ✅ 完善的交互体验
- ✅ 易于扩展和维护
适用场景
- ✅ 3D 打印参数调节
- ✅ 音量/亮度控制
- ✅ 温度/压力调节
- ✅ 任何需要精确刻度的滑动条
最终成果:一个生产级的刻度滑动条组件! 🎉