普通视图

发现新文章,点击刷新页面。
昨天 — 2025年12月4日首页

ScaleSlider 组件实现

作者 Syron
2025年12月4日 18:01

需求分析

功能需求

  • ✅ 支持纵向和横向两种方向
  • ✅ 显示刻度线(主刻度和次刻度)
  • ✅ 实时显示当前值(带单位)
  • ✅ 箭头指向滑块位置
  • ✅ 值显示框跟随滑块移动
  • ✅ 支持鼠标拖拽和点击跳转
  • ✅ 受控和非受控模式
  • ✅ 禁用状态支持

交互需求

  • ✅ 点击轨道跳转到目标位置
  • ✅ 拖拽滑块平滑移动
  • ✅ 值实时更新
  • ✅ 过渡动画(点击时平滑,拖拽时即时)
  • ✅ 悬停效果

视觉需求

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)
         ↑ 这个区域可能误触发

问题 3pointer-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. 关键经验教训

❌ 避免的坑

  1. 过度嵌套 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>
  1. 不明确的交互区域
css
/* ❌ 错误 */
.wrapper {
  padding: 100px; /* 大面积可能误触 */
}

/* ✅ 正确 */
.trackContainer {
  width: 8px; /* 精确宽度 */
  pointer-events: auto;
}
  1. 忽视 pointer-events
css
/* ❌ 错误:没有明确禁用 */
.thumb { }

/* ✅ 正确:明确禁用 */
.thumb {
  pointer-events: none;
}

✅ 最佳实践

  1. 用 Grid 代替复杂的 Flex + Position
  2. 精确控制每个元素的 pointer-events
  3. 双层定位处理跟随+居中
  4. 用 ref 管理事件状态,避免闭包
  5. 动态控制过渡动画

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}
/>

总结

核心突破

  1. 使用 CSS Grid 解决布局隔离问题

    • 三个区域完全独立
    • 交互区域精确可控
  2. pointer-events 精确控制

    • 只有轨道响应鼠标
    • 消除所有误触发
  3. 双层定位策略

    • 外层跟随滑块
    • 内层居中对齐

技术价值

  • ✅ 可复用的组件架构
  • ✅ 清晰的代码结构
  • ✅ 良好的性能表现
  • ✅ 完善的交互体验
  • ✅ 易于扩展和维护

适用场景

  • ✅ 3D 打印参数调节
  • ✅ 音量/亮度控制
  • ✅ 温度/压力调节
  • ✅ 任何需要精确刻度的滑动条

最终成果:一个生产级的刻度滑动条组件!  🎉

❌
❌