JavaScirpt30 是 Wes Bos 推出的一个 30 天挑战。项目免费提供了 30 个视频教程、30 个挑战的起始文档和 30 个挑战解决方案源代码。目的是帮助人们用纯 JavaScript 来写东西,不借助框架和库,也不使用编译器和引用。
这道题比较有意思,挑战者需要实现一个“击鼓”的效果,界面上一共有九个鼓点,当用户在键盘上按下 ASDFGHJKL
这几个键,或者点击鼠标和触摸屏幕时,显示对应的击鼓视觉效果和播放对应的音效。
效果如下:
有兴趣的同学可以挑战一下,在码上掘金上复制这个模板 开始你的挑战。
好,如果你已经尝试过了挑战,可以看一下参考答案,看看和你的思路是否一致。
然后我们来看看,通过这个挑战,能学到什么知识。
1.键盘和pointer事件
2.按键状态和播放声音
3.声音的可视化效果
我们先来看一下HTML结构:
AclapShihatDkickFopenhatGboomHrideJsnareKtomLtink
这里我们用了三个结构,一组div表示九个按键,每个按键的data-key
值与keyCode对应,一个canvas元素用来实现音频可视化,一组audio表示播放对应的声音,同样也是以data-key
值与keyCode对应。
那么我们可以注册keydown和pointdown事件,如下:
function playSound({keyCode}) { ......
}
window.addEventListener('keydown', playSound);
document.querySelectorAll(`div[data-key]`).forEach((btn) => {btn.addEventListener('pointerdown', () => {playSound({keyCode: btn.dataset.key});});
});
这里有个小细节是,如果是keydown事件,那么event参数的keyCode属性就是对应的键值,所以直接回调playSound函数就行;如果是pointerdown事件,那么我们要把按键上的data-key
值作为参数的keyCode属性传给playSound,所以实际注册的回调就是()=> {playSound({keyCode: btn.dataset.key});}
,这里也可以使用 playSound.bind(null, {keyCode: btn.dataset.key}
。
有细心的同学可能会问,这里为什么使用pointerdown事件,而不是click或mousedown事件?
Pointer Events 是浏览器支持的一种比较新的事件类型,任意指针输入设备都可以产生该事件类型。
对于PC来说,用click或mousedown完全没问题,但是click事件在移动端设备上只支持单点,而且部分设备有300ms的延迟,mousedown事件在移动设备上要用touch event替代。而新的pointer events则没有问题,在移动设备上也可以支持多点触摸,所以当我们同时触碰敲响两个鼓点的时候,用pointer events就完全没有问题了。
接着我们实现具体的playSound函数里,按键UI和声音的播放。
let lastAudio = null;
function playSound({keyCode}) {const audio = document.querySelector(`audio[data-key="${keyCode}"]`); // 根据触发按键的键码,获取对应音频const key = document.querySelector(`[data-key="${keyCode}"]`); // 获取页面对应按钮 DIV 元素if (!audio) return; // 处理无效的按键事件key.classList.add('playing'); // 改变样式audio.currentTime = 0; // 每次播放先使音频播放进度归零audio.play(); // 播放相应音效lastAudio = audio;// 如果用transitionend快速敲击不一定触发if(key.timer) clearTimeout(key.timer);key.timer = setTimeout(() => {key.classList.remove('playing');}, 170);
}
如上面代码所示,我们在playSound中,根据keyCode获取对应的audio和key元素,要实现按键UI,只需要给它的播放状态添加一个.playing
的样式,我们在CSS中用transition实现playing的效果。
.key {...transition:all .17s;...
}
.playing {transform:scale(1.1);border-color:#ffc600;box-shadow: 0 0 10px #ffc600;
}
这里我们使用了一个0.17s,也就是170毫秒的transition效果,在170毫秒之后将.playing
状态删除。
删除.playing
状态有两个思路,一个是监听transitionend
事件,然后在事件处理中将.playing
移出,一个是像我的代码那样,干脆用setTimeout
来移除。
但实际效果看,使用transitionend
会有问题,如果我们非常快来回敲击多个鼓点,某些transitionend
事件由于样式切换太快,不会被触发,那样按键状态就没法恢复了,而使用setTimeout
则避免了这个bug。
至于声音播放则非常简单,直接调用audio元素的play方法就可以,不过要支持多次播放,所以我们需要在播放前将currentTime重置为0。
这样实现playSound函数之后,主体功能就实现完成了,接下来要实现播放声音的可视化效果。
把声音转换成视觉效果,我们需要拿到声音的采样数据,在浏览器中,可以使用AudioContext API。
document.querySelectorAll("audio").forEach((audio, i) => {const color = `hsl(${i / 9 * 360}, 100%, 50%)`audio.addEventListener('play', () => {const audioCtx = new AudioContext();const audioSrc = audioCtx.createMediaElementSource(audio);const audioAnalyser = audioCtx.createAnalyser();audioSrc.connect(audioAnalyser);audioSrc.connect(audioCtx.destination);audioAnalyser.smoothingTimeConstant = .85;audioAnalyser.fftSize = 1024;const audioBufferData = new Uint8Array(audioAnalyser.frequencyBinCount);const audioData = new AudioData(audioBufferData);tasks.push(() => {audioAnalyser.getByteFrequencyData(audioBufferData);draw(ctx, {audio, audioData, color, points: 50});});}, {once: true});
});
按照上面的代码,我们在audio元素首次播放的时候,创建一个AudioContext对象,然后通过createMediaElementSource
,用当前audio对象创建一个audioSource,再通过createAnalyer
创建一个audioAnalyser对象,把这个对象和audioSource对象连接起来。
这样我们创建一个Uint8Array的缓冲区,通过audioAnalyser.getByteFrequencyData
就可以将数据写入缓冲区,然后用缓冲区数据创建一个AudioData对象,这个对象从缓冲区中读取和处理音频数据。
class AudioData {constructor(bufferData) {this.bufferData = bufferData;}get base() {const length = this.bufferData.length;return this.bufferData.slice(0, length * 0.0625);}get low() {const length = this.bufferData.length;return this.bufferData.slice(length * 0.0625 + 1, length * 0.125);}get mid() {const length = this.bufferData.length;return this.bufferData.slice(length * 0.125 + 1, length * 0.5);}get high() {const length = this.bufferData.length;return this.bufferData.slice(length * 0.5 + 1);}static scale(data, maxSize) {const ret = [];for(let i = 0; i < data.length; i++) {const value = data[i];let percent = Math.round((value / 255) * 100) / 100;ret[i] = maxSize * percent;}return ret;}
}
根据我们的采样,缓冲数据中前半部分是基音频率(base)、低音频率(low)和中音频率(mid)的数据,后半部分是高音频率(high)数据。
我们采用中音频率数据来绘制图形:
function draw(ctx, {audio, audioData, color, points}) {const { height, width } = ctx.canvas;const data = AudioData.scale(audioData.mid, Math.min(height, width));const count = points || data.length;ctx.save();ctx.translate(0, height);ctx.scale(1, -1);ctx.fillStyle = color;const w = width / count;ctx.beginPath();ctx.moveTo(0, 0);for(let i = 0; i < count; i++) {const index = i * Math.floor(data.length / count);const e = lastAudio === audio ? 5 : 0;ctx.lineTo(i * w, e + data[index]);if(i === count - 1) {ctx.lineTo(width, e + data[index]);ctx.lineTo(width, 0);}}ctx.fill();ctx.restore();
}
最后,因为我们有9个鼓点,分别对应9个audio数据通道,所以我们以tasks数组的方式,在第一次播放每个audio的时候才将要处理的绘制动作给添加到tasks数组中:
tasks.push(() => {audioAnalyser.getByteFrequencyData(audioBufferData);draw(ctx, {audio, audioData, color, points: 50});
});
我们在绘制canvas的时候,每一帧遍历所有的tasks,对其进行绘制:
const canvas = document.querySelector('canvas');
canvas.width = document.documentElement.clientWidth;
canvas.height = document.documentElement.clientHeight;
const ctx = canvas.getContext('2d');
ctx.globalAlpha = 0.5;
const tasks = [];
const tick = () => {const { height, width } = ctx.canvas;ctx.clearRect(0, 0, width, height);tasks.forEach(task => task());window.requestAnimationFrame(tick);
}
tick();
这里还有一个小的UI细节,我们在前面实现playSound方法的时候记录下了lastAudio,也就是上次播放的audio,我们在canvas绘制的时候,保留上次播放audio至少5像素点的高度:
const e = lastAudio === audio ? 5 : 0;ctx.lineTo(i * w, e + data[index]);
这样的话,界面中,底部的横条就保持了上一次敲击鼓点的颜色值,显得更加活泼一点。
最终实现版本如下:
https://code.juejin.cn/pen/7111233570496053255
这样,这道题就完成了。通过这道题,主要我们学到了这些知识点:
1. 使用keydown和pointerdown事件来处理击鼓动作。
🎯 用pointerdown,可以让移动设备支持多点触摸,从而同时敲响多个鼓点
2. 使用transition来实现按键UI,使用audio.play来实现音频播放。
🎯 之所以不用transitionend事件来移除playing状态,是因为transitionend在快速切换元素状态时有可能不触发。
3. 使用AudioContext API来实现音频可视化效果。
🎯 最核心是通过audioAnalyser获取音频数据,然后利用该数据在canvas中绘制图形,注意多个音频通道同时绘制的处理方法。
你的思路和我一样吗?或者你有什么独特的想法?欢迎在评论区讨论。