You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React如何实现Synthesia风格钢琴按键拖尾音符效果

React实现Synthesia风格钢琴拖尾音符方案

你之前的实现问题很明确:组件挂载后立刻触发上移动画,完全没有绑定按键的按下/松开状态,也缺失了按住阶段的高度增长逻辑。整个效果可以拆成两个完全独立的阶段,两个阶段共用同一个移动速度就能保证衔接丝滑:

  • 按键按住阶段:音符底部固定在按键顶部边缘,高度随按住时长匀速增长
  • 按键松开阶段:音符停止增高,以和高度增长完全相同的速度匀速向上平移,移出视口后直接销毁即可

核心要点是高度增长和上移必须共用同一个速度值,否则两个阶段衔接的时候会出现明显的速度跳变,完全出不来Synthesia那种连贯的拖尾效果

具体实现代码

因为你用的是类组件写法,下面直接给可运行的类组件实现,不需要依赖额外第三方库:

class TrailingNote extends React.Component {
  // 统一速度配置:单位px/秒,增长、上移共用这个值
  NOTE_SPEED = 140
  // 音符向上移动的总距离,和你之前写的700px对应
  MOVE_DISTANCE = 700

  constructor(props) {
    super(props)
    this.state = {
      noteHeight: 0,
      transitionDuration: '0s'
    }
    this.rafId = null
    this.noteRef = React.createRef()
  }

  componentDidMount() {
    // 组件挂载对应按键刚按下的时刻,记录起始时间,启动高度增长循环
    this.pressStartTime = performance.now()
    this.runGrowLoop()
    // 这里绑定你实际的按键松开事件,可以是键盘keyup、鼠标mouseup或者MIDI输入的松开回调
    // 示例:window.addEventListener('keyup', this.handleRelease)
  }

  runGrowLoop = () => {
    const update = () => {
      // 计算从按下到当前帧的经过时长,换算成当前音符应该有的高度
      const elapsedSec = (performance.now() - this.pressStartTime) / 1000
      const currentHeight = elapsedSec * this.NOTE_SPEED
      this.setState({ noteHeight: currentHeight })
      this.rafId = requestAnimationFrame(update)
    }
    this.rafId = requestAnimationFrame(update)
  }

  handleRelease = () => {
    // 松开按键首先停掉高度增长的循环
    cancelAnimationFrame(this.rafId)
    // 根据当前要移动的总距离和统一速度,计算上移动画需要的时长
    const moveDuration = this.MOVE_DISTANCE / this.NOTE_SPEED
    this.setState({ transitionDuration: `${moveDuration}s` })
    // 等下一帧浏览器完成样式更新后,再触发位移动画,避免transition失效
    requestAnimationFrame(() => {
      this.noteRef.current.style.transform = `translateY(-${this.MOVE_DISTANCE}px)`
      // 动画结束后通知父组件销毁当前音符节点
      setTimeout(() => {
        this.props.onNoteComplete?.()
      }, moveDuration * 1000)
    })
  }

  componentWillUnmount() {
    // 组件卸载时清掉raf循环,避免内存泄漏
    cancelAnimationFrame(this.rafId)
  }

  render() {
    return (
      <div
        ref={this.noteRef}
        className="trailingNote"
        style={{
          height: `${this.state.noteHeight}px`,
          transition: `transform ${this.state.transitionDuration} linear`,
          position: 'absolute',
          bottom: '100%',
          left: '50%',
          transform: 'translateX(-50%)'
        }}
      />
    )
  }
}

配套的CSS去掉之前写死的transition配置,只保留基础样式即可:

.trailingNote {
  background: #000;
  width: 20px;
  transform-origin: bottom center;
}

几个关键注意点

  • 不要用CSS keyframes做按住阶段的高度增长:keyframes是固定时长的,你没法提前预判用户会按住按键多久,也没法在松开瞬间精准暂停动画,用requestAnimationFrame逐帧计算高度是最灵活可控的方案
  • 上移动画的时长不能写死:如果固定写5s的上移时长,按的时间短音符矮的时候上移速度会偏慢,按的时间长音符高的时候上移速度会偏快,必然出现衔接顿挫
  • 音符必须用绝对定位锚定在按键上边缘:否则高度增长的时候会把按键本身往下挤,布局错乱
  • 触发上移transform之前要等一帧:如果在setState更新duration之后立刻修改transform,浏览器会把两次样式修改合并成一次渲染,transition会直接失效,丢到requestAnimationFrame回调里执行就能规避这个问题

内容的提问来源于stack exchange,提问作者Sederfo

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.30 14:24:18