ScaleSlider 组件实现

需求分析

功能需求

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

交互需求

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

视觉需求

css 复制代码
css
纵向布局:
  刻度    轨道    箭头+值
  ═══     ║      ← [1.60mm]
  ═══     ●      
  ═══     ║      
  
横向布局:
  刻度   ═══ ══ ═══
  轨道   ●═════
  箭头      ↑
  值      [50%]

设计思路

1. 组件结构设计

初始方案(V1)

xml 复制代码
typescript
<Container>
  <ValueDisplay />  // 左侧/上方
  <SliderWrap>
    <Scales />
    <Track />
  </SliderWrap>
</Container>

问题:

  • ❌ 值显示位置固定,不跟随滑块
  • ❌ 布局不够灵活

改进方案(V2)

xml 复制代码
typescript
<Container>
  <ValueDisplay />  // 在另一侧
  <SliderWrap>
    <Scales />
    <Track />
    <ValueWrapper />  // 跟随滑块
  </SliderWrap>
</Container>

问题:

  • ❌ ValueWrapper 嵌套过深
  • ❌ 鼠标事件复杂,容易误触发

最终方案(V3 - CSS Grid)

javascript 复制代码
typescript
<Container> // Grid 布局
  <ScalesContainer />    // 独立区域
  <TrackContainer />     // 独立区域
  <ValueContainer />     // 独立区域
</Container>

优势:

  • ✅ 三个区域完全独立
  • ✅ 鼠标事件精确隔离
  • ✅ 定位清晰简单

2. 布局方案对比

方案 A:Flexbox + Padding

css 复制代码
css
.sliderWrap {
  padding-right: 100px; /* 为值显示预留空间 */
}

.valueWrapper {
  position: absolute;
  left: calc(100% - 90px);
}

问题:

  • ❌ padding 区域仍会捕获鼠标事件
  • ❌ 定位复杂,容易出错

方案 B:CSS Grid(最终选择)

arduino 复制代码
css
.container {
  display: grid;
  grid-template-columns: auto 8px auto; /* 纵向 */
  grid-template-rows: auto 8px auto;    /* 横向 */
}

优势:

  • ✅ 每个区域独立,互不干扰
  • ✅ 宽度/高度自动计算
  • ✅ 响应式友好

实现过程

阶段 1:基础滑动条(V1)

代码实现

javascript 复制代码
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)

代码实现

javascript 复制代码
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 复制代码
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)

代码改进

xml 复制代码
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 复制代码
css
.sliderWrap {
  position: relative;
  padding-right: 100px; /* 为值显示预留空间 */
}

.valueWrapper {
  position: absolute;
  left: calc(100% - 90px);
  bottom: calc(X% - 0.5rem); /* 跟随滑块 */
}

实现效果

  • ✅ 值跟随滑块位置
  • ✅ 箭头指向滑块
  • 出现严重 Bug

遇到的问题与解决方案

❌ 问题 1:纵向滑块鼠标事件异常

问题描述

复制代码
现象:鼠标在纵向滑块附近移动(不点击),滑块也会跟随移动
影响:组件完全无法使用,交互体验极差
触发条件:只在纵向布局出现,横向布局正常

问题排查

Step 1:检查事件绑定

ini 复制代码
typescript
// ✅ 事件绑定正确
<div ref={trackRef} onMouseDown={handleMouseDown}>

Step 2:检查拖拽逻辑

scss 复制代码
typescript
useEffect(() => {
  const handleMouseMove = (e: MouseEvent) => {
    if (isDraggingRef.current) { // ✅ 有拖拽状态判断
      updateValue(e.clientX, e.clientY)
    }
  }
  // ...
}, [])

Step 3:检查 DOM 结构

xml 复制代码
html
<!-- ❌ 发现问题:valueWrapper 嵌套在 sliderWrap 内 -->
<div class="sliderWrap" style="padding-right: 100px">
  <div class="track"></div>
  <div class="valueWrapper"></div> <!-- 可能捕获事件 -->
</div>

Step 4:检查 CSS 布局

css 复制代码
css
/* ❌ 发现问题:padding 导致可交互区域过大 */
.sliderWrap {
  padding-right: 100px; /* 这个区域可能捕获事件 */
}

Step 5:添加调试代码

javascript 复制代码
typescript
const handleMouseDown = (e: React.MouseEvent) => {
  console.log('MouseDown triggered')
  console.log('Target:', e.target)
  console.log('CurrentTarget:', e.currentTarget)
}

// 发现:有时 e.target 不是 track 元素

根本原因分析

scss 复制代码
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 复制代码
css
.sliderWrap {
  pointer-events: none;
}

.track {
  pointer-events: auto;
}

.valueWrapper {
  pointer-events: none;
}

结果:

  • ❌ 仍然有问题
  • ❌ 原因:valueWrapper 仍在 sliderWrap 内部

方案 2:调整 DOM 结构(部分成功)

xml 复制代码
typescript
<Container>
  <div className={styles.sliderWrap}>
    <Scales />
    <Track />
  </div>
  <div className={styles.valueWrapper}> {/* 移到外部 */}
    ...
  </div>
</Container>

结果:

  • ✅ 减少了误触发
  • ❌ 仍有边缘情况
  • ❌ 定位复杂

方案 3:CSS Grid 重构(最终成功)

核心思路:三个区域完全独立

javascript 复制代码
typescript
<Container> // Grid 布局
  <ScalesContainer />    // 区域 1:刻度(不可交互)
  <TrackContainer />     // 区域 2:轨道(唯一可交互)
  <ValueContainer />     // 区域 3:值显示(不可交互)
</Container>

CSS Grid 配置

arduino 复制代码
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 复制代码
css
/* ✅ 只有轨道响应鼠标 */
.trackContainer {
  pointer-events: auto;
  width: 8px;  /* 纵向 */
  height: 8px; /* 横向 */
}

/* ❌ 其他区域不响应 */
.scalesContainer,
.valueContainer,
.thumb,
.fill {
  pointer-events: none;
}

视觉对比

scss 复制代码
scss
修正前(有问题):
┌────────────────────────────────┐
│ sliderWrap (可能误触)           │
│  ┌────┐  ┌──────────┐         │
│  │轨道│  │ 值显示    │         │
│  └────┘  └──────────┘         │
│  8px      padding 100px        │
└────────────────────────────────┘

修正后(正确):
┌──────┬────┬──────────┐
│ 刻度  │轨道│  值显示   │
│(no)  │YES│  (no)    │
│      │8px│          │
└──────┴────┴──────────┘

❌ 问题 2:值显示定位复杂

问题描述

复制代码
需求:值显示框要跟随滑块位置
难点:同时要保持在固定区域内

解决方案:双层定位

javascript 复制代码
typescript
// 外层容器:跟随滑块位置
<div className={styles.valueContainer} 
     style={{ bottom: `${percentage}%` }}>
  
  // 内层内容:在容器中居中
  <div className={styles.valueContent}>
    <Arrow />
    <ValueDisplay />
  </div>
</div>
css 复制代码
css
/* 外层:跟随滑块 */
.valueContainer {
  position: relative;
  bottom: X%; /* 动态值 */
}

/* 内层:居中对齐 */
.valueContent {
  position: absolute;
  bottom: 0;
  transform: translateY(50%); /* 垂直居中 */
}

效果对比

css 复制代码
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
现象:拖拽时有轻微延迟或卡顿
原因:CSS transition 在拖拽时不应该生效

解决方案:动态禁用过渡

javascript 复制代码
typescript
const [isDragging, setIsDragging] = useState(false)

const fillClasses = `${styles.fill} ${
  isDragging ? styles.fillNoDrag : ''
}`
css 复制代码
css
.fill {
  transition: height 0.15s ease, width 0.15s ease;
}

/* 拖拽时禁用过渡 */
.fillNoDrag {
  transition: none !important;
}

.container:active .valueContainer {
  transition: none !important;
}

效果对比

ini 复制代码
ini
点击跳转(平滑):
时间 0ms:    ●  [50%]
时间 75ms:      ●  [60%]  ← 平滑过渡
时间 150ms:        ●  [70%]

拖拽移动(即时):
时间 0ms:    ●  [50%]
时间 1ms:        ●  [70%]  ← 立即跟随

最终架构

1. 组件结构

arduino 复制代码
arduino
ScaleSlider
├── types.ts           // TypeScript 类型定义
├── ScaleSlider.tsx    // 组件主逻辑
├── ScaleSlider.module.css  // 样式文件
└── index.ts           // 导出

2. DOM 结构

xml 复制代码
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 复制代码
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 复制代码
scss
纵向滑块:
┌──────────┬────┬──────────┐
│  刻度区   │轨道│  值显示   │
│ (14px)   │8px │ (100px)  │
│          │    │          │
│   ═══    │    │          │
│   ═══    │    │          │
│   ═══    │ ║  │  ← 值    │
│   ═══    │ ●  │          │ ← 跟随滑块
│   ═══    │ ║  │          │
│   ═══    │    │          │
│          │    │          │
│ 不响应   │响应│  不响应   │
└──────────┴────┴──────────┘

技术总结

1. 核心技术要点

✅ CSS Grid 布局

diff 复制代码
css
优势:
- 区域完全独立
- 自动计算尺寸
- 响应式友好
- 代码简洁

适用场景:
- 需要精确控制区域边界
- 需要独立控制交互行为
- 需要灵活的响应式布局

✅ pointer-events 精确控制

markdown 复制代码
css
核心策略:
1. 容器默认 pointer-events: none
2. 只有交互区域 pointer-events: auto
3. 其他元素明确 pointer-events: none

防止误触发:
- 刻度不响应
- 滑块不响应(通过轨道控制)
- 值显示不响应

✅ 双层定位策略

css 复制代码
typescript
外层:控制位置(跟随滑块)
<div style={{ bottom: `${percentage}%` }}>
  内层:控制对齐(居中)
  <div style={{ transform: 'translateY(50%)' }}>
    ...
  </div>
</div>

✅ 受控/非受控模式

csharp 复制代码
typescript
const isControlled = controlledValue !== undefined
const value = isControlled ? controlledValue : internalValue

if (!isControlled) {
  setInternalValue(newValue)
}
onChange?.(newValue) // 总是触发回调

2. 性能优化

动画优化

ini 复制代码
typescript
// 点击时:使用 CSS transition
<div className={styles.fill} />

// 拖拽时:禁用 transition
<div className={`${styles.fill} ${styles.fillNoDrag}`} />

事件优化

scss 复制代码
typescript
// 使用 ref 存储拖拽状态,避免闭包问题
const isDraggingRef = useRef(false)

useEffect(() => {
  const handleMouseMove = (e: MouseEvent) => {
    if (isDraggingRef.current) { // 直接读取 ref
      updateValue(e.clientX, e.clientY)
    }
  }
  // ...
}, []) // 空依赖数组

计算优化

javascript 复制代码
typescript
// 预计算刻度位置
const scales = useMemo(() => 
  Array.from({ length: scaleCount + 1 }, (_, i) => ({
    position: (i / scaleCount) * 100,
    isMain: i % 2 === 0,
  })),
  [scaleCount]
)

3. 关键经验教训

❌ 避免的坑

  1. 过度嵌套 DOM
javascript 复制代码
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 复制代码
css
/* ❌ 错误 */
.wrapper {
  padding: 100px; /* 大面积可能误触 */
}

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

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

✅ 最佳实践

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

4. 可扩展性设计

支持的功能扩展

typescript 复制代码
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)
}

完整示例

基础使用

ini 复制代码
typescript
<ScaleSlider
  value={layerHeight}
  onChange={setLayerHeight}
  min={0}
  max={3}
  step={0.1}
  unit="mm"
  precision={2}
  orientation="vertical"
  scaleCount={10}
/>

高级配置

ini 复制代码
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 打印参数调节
  • ✅ 音量/亮度控制
  • ✅ 温度/压力调节
  • ✅ 任何需要精确刻度的滑动条

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

相关推荐
熊猫_豆豆2 小时前
一个模拟四轴飞行器在随机气流扰动下悬停飞行的交互式3D仿真网页,包含飞行器建模与PID控制算法
javascript·3d·html·四轴无人机模拟飞行
来恩10034 小时前
jQuery选择器
前端·javascript·jquery
前端繁华如梦4 小时前
树上挂苹果还是挂玻璃球?Three.js 程序化果实的完整实现指南
前端·javascript
CDwenhuohuo4 小时前
优惠券组件直接用 uview plus
前端·javascript·vue.js
川冰ICE5 小时前
TypeScript装饰器与元编程实战
前端·javascript·typescript
AI砖家5 小时前
Vue3组件传参大全,各种传参方式的对比
前端·javascript·vue.js
希望永不加班5 小时前
var局部变量类型推断的利弊
java·服务器·前端·javascript·html
threelab5 小时前
Three.js 3D 地图可视化 | 三维可视化 / AI 提示词
前端·javascript·人工智能·3d·着色器
失眠的咕噜6 小时前
PDA 安卓设备上传多张图片
android·前端·javascript
掰头战士6 小时前
深入了解JS原型及原型继承链机制
javascript