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
相关产品推荐
相关产品推荐

