详解如何用js实现一个网页版节拍器
本文详细解析使用Vue3和Vite开发网页版节拍器的完整过程。从需求分析、模块设计到播放逻辑实现,重点讲解了如何利用Audio对象控制音高与倍速,解决iOS音频权限问题,并通过GitHub Actions实现自动化部署。
引言
在练习尤克里里的过程中,节拍器是不可或缺的工具。受此启发,笔者尝试使用JavaScript语言独立开发一个网页版节拍器应用。
最终实现的效果可访问 ahao430.github.io/metronome/ 查看。
完整源代码已开源至 GitHub 仓库:github.com/ahao430/met…。

1. 需求分析
节拍器的核心功能是设定不同的速度和节奏进行打拍。市面上的节拍器功能各异,从简易到复杂不等,本项目的核心需求如下:
- 支持设定不同的速度,即每分钟多少拍(BPM)。
- 支持选择节拍类型,例如4/4拍、3/4拍、6/8拍等。
- 支持选择节拍的节奏型,包括一拍一下、一拍两下、一拍三下(三连音)、一拍四下、Swing风格等。许多简易节拍器缺乏此功能,本项目予以实现。
- 支持切换不同的音色,例如敲击声、鼓声、人声等。



此处拍速指的是每分钟包含的拍数。
而节拍是指以几分音符为一拍,以及每小节包含几拍。这不影响拍速,但观察各类节拍器,通常会通过视觉小点来展示:每拍一个点,第一拍为重声,后续为轻声。
节奏型决定每一拍响几下,以及这几下之前的节奏。比如这一拍响一下、响两下、响三下、响四下;如果是一个swing就是前8后16分音符的时长;也可能这个节奏型的时长是两拍,比如民谣扫弦的下----,下空下上。
2. 素材准备
本项目未使用复杂的UI设计,仅编写了基础样式。图标方面,选取了一个节拍器图标作为favicon,并截取裁剪了几个不同节奏型的图片。音频素材方面,提取了一段强弱交替的敲击声作为基础音效。
准备工作就绪,开始开发。

3. 开发实现
3.1 框架选型
本项目选用Vue3,主要因为笔者日常常用Vue2和React,希望借此机会练习Vue3,并体验Vue3结合Vite的开发流程。直接使用官方脚手架进行开发。
配置rem单位,引入amfe-flexible和postcss-px2rem-exclude插件。
UI组件库选用NutUI。
3.2 模块设计
<script setup lang="ts">
import Speed from "./components/Speed.vue";
import Rhythm from "./components/Rhythm.vue";
import Beat from "./components/Beat.vue";
import Play from "./components/Play.vue";
</script>
<template>
<p class="title">节拍器</p>
<main>
<Speed></Speed>
<div class="flex">
<Beat></Beat>
<Rhythm></Rhythm>
</div>
<Play></Play>
</main>
</template>
页面按功能模块划分为多个组件:顶部用于调节拍速,中间用于选择节拍和节奏型,底部为播放控制。
由于播放组件需要读取其他组件的设置,项目引入Pinia进行状态管理,所有数据统一存放至Store。因播放组件需获取其他组件数据,故为每个组件单独建立Store,将数据和计算逻辑封装其中。
在编写组件时遇到Vue3的一个常见陷阱:直接解构数据会失去响应性。后续使用Store数据时,改为直接使用store.xxx的方式访问。
3.3 数据结构设计
拍速、节拍、节奏型组件较为简单,通过下拉选择即可。重点在于数据结构的设计。
节拍使用数组存储,如[3,4],通过数组第一项即可获知该小节的拍数。
节奏型考虑到可能跨越多拍,采用二维数组表示:每一项代表一拍,该拍由1和0组成的数组表示。例如民谣扫弦“↓ ↓ ↓↑”(读作下空空空下空下上),表示为[[1,0,0,0],[1,0,1,1]]。
export const MIN_SPEED = 40
export const MAX_SPEED = 400
export const DEF_SPEED = 120
export const DEF_BEAT = [4,4]
export const BEAT_OPTIONS = [
[1,4],
[2,4],
[3,4],
[4,4],
[3,8],
[6,8],
[7,8],
]
export const DEF_RHYTHM = 1
export const RHYTHM_OPTIONS = [
{ id: 1, name: '♪', value: [[1]], img: './img/1.jpg', rate: 30},
{ id: 2, name: '♪♪', value: [[1,1]], img: './img/2.jpg', rate: 15},
{ id: 3, name: '三连音', value: [[1, 1, 1]], img: './img/3.jpg', rate: 10},
{ id: 4, name: '♪♪♪♪', value: [[1,1,1,1]], img: './img/4.jpg', rate: 10},
{ id: 5, name: 'swing', value: [[1, 0, 1]], img: './img/5.jpg', rate: 10},
{ id: 6, name: '民谣扫弦', value: [[1, 0, 0,0], [1,0,1,1]], img: './img/6.png', rate: 10},
{ id: 7, name: '民谣扫弦2', value: [[1, 0, 1, 1], [0,1,1,1]], img: './img/7.png', rate: 10},
]
3.4 播放逻辑
播放组件逻辑较为复杂。点击播放按钮后,首先播放一次重声,随后根据拍速、节拍和节奏型计算下一次声音的间隔,后续均按此间隔播放轻声,直至小节结束。
// 点击播放,重置节拍和节奏型计数,状态置为true,执行播放小节函数
function play() {
beatCount.value = 0
rhythmCount.value = 0
isPlaying.value = true
playBeat()
}
// 播放整个小节,节拍计数重置为0,允许播放重声,播放节奏型
function playBeat () {
if (!isPlaying.value) return false
beat = useBeatStore().beat
console.log('播放节拍:', beat)
beatCount.value = 0
heavy = true
playRhythm()
}
// 播放整个节奏型(可能多拍), 节奏型音符计数重置
function playRhythm () {
if (!isPlaying.value) return false
rhythm = useRhythmStore().rhythm.value
rhythmRate = useRhythmStore().rhythm.rate
console.log('播放节奏型:', rhythm)
rhythmNotesLen = 0
rhythmCount.value = 0
rhythm.forEach(item => {
rhythmNotesLen += item.length
})
playNote()
}
播放期间,用户可能在不暂停的情况下修改拍速、节拍或节奏型。因此,在播放音符时需动态计算拍速,并根据节奏型的音符数量计算下一个音符的timeout时间。若下一音符为1则播放,为0则跳过,并继续定时器。注意,当一个节奏型或一个小节完成后需重置计数(此处暂不处理单拍完成情况)。
// 播放单个音符位置,可能是空拍
function playNote () {
// 一个节奏型可能有多拍
speed = useSpeedStore().speed
// 调整播放倍速
player.playbackRate = Math.max(1, Math.min(10, speed / rhythmRate))
player2.playbackRate = player.playbackRate
const rhythmItemIndex = beatCount.value % rhythm.length
// 播放音频
const rhythmItem = rhythm[rhythmItemIndex]
const note = rhythmItem[rhythmCount.value]
console.log('播放音频:',
note ?
(heavy ? '重' : '轻')
: '空'
)
if (note) {
// 播放
if (heavy) {
player.currentTime = 0;
player.play()
heavy = false
} else {
player2.currentTime = 0;
player2.play()
}
}
// 计算间隔时间
const oneBeatTime = ONE_MINUTE / speed
const rhythmNoteTime = oneBeatTime / rhythmItem.length
// 定时器,播放下一个音符
timer = setTimeout(() => {
let newRhythmCount = rhythmCount.value + 1
if (newRhythmCount >= rhythmItem.length) {
if (newRhythmCount >= rhythmNotesLen) {
// 新的节奏型
newRhythmCount = 0
rhythmCount.value = newRhythmCount
} else {
// 当前节奏型新的一拍
rhythmCount.value = newRhythmCount
}
let newBeatCount = beatCount.value + 1
if (newBeatCount >= beat[0]) {
newBeatCount = 0
// 新的节拍
beatCount.value = newBeatCount
playBeat()
} else {
beatCount.value = newBeatCount
playRhythm()
}
} else {
rhythmCount.value = newRhythmCount
playNote()
}
}, rhythmNoteTime)
// 呼吸样式
if (note) {
const styleTime = rhythmNoteTime * 0.8
rhythmCircleStyle.value = `transform: scale(1.5); transition: all linear ${styleTime / 1000}s; opacity: 0.5;`
timer2 = setTimeout(() => {
rhythmCircleStyle.value = 'transform: scale(0); transition: none; opacity: 0;'
}, styleTime)
}
}
3.5 音频控制
音频播放使用了Audio对象。
const player = new Audio('./audio/beat1.mp3')
const player2 = new Audio('./audio/beat2.mp3')
// player.play()
// player.pause()
原始音频的播放速度和时长是固定的,但当拍速调快或一拍节奏型包含多个音符时,前一次播放未结束,后一次播放即开始,导致声音混淆。此时需调整播放速度,根据音符间隔时间计算倍率,修改player的playbackRate值。
然而实际测试发现,浏览器对倍率有上下限限制,超出范围会报错,且计算精度有限。此前用[1]表示一拍一下并不准确,实际应为[1,0,0,0,...],但0的数量取决于节拍。因此,直接在节奏型选项中添加rate字段,凭感觉调节倍率。
// 调整播放倍速
player.playbackRate = Math.max(1, Math.min(10, speed / rhythmRate))
player2.playbackRate = player.playbackRate
每次播放音符时重新取值,可在切换后于下一个音符修正。但若之前速度过慢,下次播放等待时间过长。改为在切换任意值时停止播放,再重新启动。
watch([
() => beatStore.beat,
() => rhythmStore.rhythm,
() => speedStore.speed
], () => {
console.log('restart')
restart()
})
3.6 动效
播放时,根据节拍数量生成n个小圆点,第几拍则点亮对应的圆点。
此外,设计了呼吸动效:每个音符播放时,都有一个圆环从播放按钮下方向外扩散开来。
// 呼吸样式
if (note) {
const styleTime = rhythmNoteTime * 0.8
rhythmCircleStyle.value = `transform: scale(1.5); transition: all linear ${styleTime / 1000}s; opacity: 0.5;`
timer2 = setTimeout(() => {
rhythmCircleStyle.value = 'transform: scale(0); transition: none; opacity: 0;'
}, styleTime)
}
3.7 大屏展示
amfe-flexible始终按屏幕宽度计算rem。由于本项目仅针对移动端设计,大屏时最好居中固定宽度展示。因此自行编写rem.js,设置最大宽度,超过最大宽度时仅按最大宽度计算rem,同时给body添加maxWidth属性。
3.8 新增人声发音
增加一个组件,支持下拉选择声音类型,目前有人声和敲击声两种。选择人声时,改为播报“1234,,2234...”等数字。
import Speech from 'speak-tts'
const speech = new Speech()
speech.init({
volume: 1,
rate: 1,
pitch: 1,
lang: 'zh-CN',
})
function playVoice () {
const voice = useVoiceStore().voice
console.log('voice: ', voice)
if (voice === 'human') {
const text = rhythmCount.value === 0 ? (beatCount.value + 1) : (rhythmCount.value + 1)
speech.speak({
text: '' + text,
queue: false
})
if (heavy) {
heavy = false
speech.setPitch(0.5)
}
} else {
if (heavy) {
player.currentTime = 0;
player.play()
heavy = false
speech.setPitch(0.5)
} else {
player2.currentTime = 0;
player2.play()
}
}
}
4. 部署
使用GitHub Pages部署项目打包文件。参考他人提供的配置文件,实现push分支后利用GitHub Actions自动部署。
在项目根目录新建.github/workflows目录,然后新建一个任意名称、.yml后缀的文件,填入配置推送即可。其中branches指定了main,视实际情况可改为master。推送后Action会自动打包main分支代码,将dist目录放到gh-pages分支根目录,并将settings/pages自动设置为gh-pages分支根目录展示。
name: CI
on:
push:
branches:
- main
jobs:
job:
name: Deployment
runs-on: macos-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Checkout
uses: actions/checkout@v3
# setup node
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 16.16.0
# setup pnpm
- name: Setup pnpm
uses: pnpm/action-setup@v2
id: pnpm-install
with:
version: 7
run_install: false
# cache
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Setup pnpm cache
uses: actions/cache@v3
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
# cache fail and install dependencies
- name: Install dependencies
if: steps.pnpm-cache.outputs.cache-hit != 'true'
run: |
pnpm install
- name: Build
run: pnpm run build
- name: upload production artifacts
uses: actions/upload-pages-artifact@v1
with:
path: dist
# deploy
- name: Deploy Page To Release
id: deployment
uses: actions/deploy-pages@v1
5. 后续工作
5.1 目前存在的问题
iOS声音问题
目前最大的问题是iOS设备上无声音。由于iOS的权限限制,只有手动点击才能播放,因此只播放了一次,后续定时器触发的播放无法执行。
目测要解决此问题,需更换平台,利用小程序或App的Native API来实现。
5.2 TODO
切换不同音效
此功能实现简单,难点在于素材获取。部分节拍器支持人声,若播放“1234,,2234”,需在播放时增加相关逻辑。人声似乎可通过API实现。
以上就是详解如何用js实现一个网页版节拍器的详细内容,更多关于js实现网页版节拍器的资料请关注本站其它相关文章!
前端大屏自适应方案:缩放与视口单位对比
2026-10-31 18:19:27
前端大文件分片下载实现:基于localForage与Vue的完整方案
2026-10-31 16:11:47
Vue3 中使用 VueUse 实现指定区域全屏阅读功能
2026-10-31 13:55:05
基于Vue与Pinia实现页面Loading动画及请求拦截处理
2026-10-31 13:54:42
Vue封装Echarts通用组件:Vue2/Vue3 JS/TS三种模板及常用配置事件详解
2026-10-31 11:38:03
