[实现一个音乐播放器] Node 的事件机制 - EventEmitter
本节目标:【实现一个音乐播放器】一生二再生万物,一切皆事件,Node 最野性的魅力就来自于对于事件队列的精妙处理。
事件,是用户与浏览器互动过程中,最高频的一种交互机制,用户无论是鼠标点击,滚动,拖拽,还是一个表单文件上传行为,都通过事件的形式来与应用运行环境互动。事件有它的触发者,也有它的接收者或者处理者,连接这两者以及赋能二者能力的就是事件机制。
对于一个异步行为,浏览器不知道用户什么时候点击网页按钮,用户同样不知道点击按钮后浏览器什么时候给予回应,有了事件机制,这件事情就变得很容易,比如监听一个按钮的点击行为:
var btn = document.getElementById('btn')btn.addEventListener('click', function (e) {// 按钮点击事件被监听到,开始处理事务}, false)
事件如何处理不仅在浏览器需要考虑,服务器也有类似的场景,服务器既不知道一个请求什么时候会到来,请求处理程序也不知道背后的数据库查询行为什么时候成功返回,那么这些异步场景就需要一种机制来连接和通知彼此,在 Node 里面,很多操作都会触发事件,例如 net.Server 会在每一次有客户端连接到它时触发事件,又如 fs.readStream 会在文件打开时触发事件,所有具备触发事件能力的接口对象都是 events 的实例,在 Node 里面,事件能跑起来需要两个关键组成,分别就是 EventEmitter 和回调函数,前者负责生成实例,而后者负责执行特定任务。
回调函数与事件驱动
在聊 EventEmitter 之前,我们先看下回调,回调是异步编程模型里面最常见的一种方法,在 Node 里面也是如此,可以将后续逻辑封装在回调函数中作为当前函数的参数,逐层嵌套,逐层执行,最终让程序按照我们期望的方式走完流程,那么一个回调函数长什么样子呢?
function doSomeThing (thing) {console.log(thing)}function comeTo (place, cb) {const thing = '到 ' + place + ' 学习 Node'cb(thing)}comeTo('Juejin', doSomeThing)// 到 Juejin 学习 Node
可以发现回调在 JS 里面,本质上是控制权的后移,把一个提前声明好的函数以参数的形式交给当前函数,当前函数在某个时刻再调用传入这个函数,同时对这个函数可以传入一些新的参数数据,那么这个函数 cb 就是我们所说的回调函数,这个回调函数不会马上执行。如果这个回调函数结合事件来执行,当某个事件发生的时候再调用回调函数,这种函数执行的方式叫做事件驱动,所以我们常看到 Node 的一大卖点就是事件驱动(event-driven),它起一个服务器的代码,请求的接收与响应本身也是回调函数来实现:
require('http').createServer((req, res) => {res.write('Hi')res.end()}).listen(3333, '127.0.0.1', () => {})
EventEmitter 的基本用法
在 Node 里面,events 模块提供了 EventEmitter 的 Class 类,可以直接创建一个事件实例:
// events 是 Node 的 built-in 模块,它提供了 EventEmitter 类const EventEmitter = require('events')// 创建 EventEmitter 的事件实例const ee = new EventEmitter()// 为实例增加 open 事件的监听以及注册回调函数,事件名甚至可以是中文ee.on('open', (error, result) => {console.log('事件发生了,第一个监听回调函数执行')})// 为实例再增加一个 增加 open 事件的监听器ee.on('open', (error, result) => {console.log('事件发生了,第二个监听回调函数执行')})// 通过 emit 来发出事件,所有该事件队列里的回调函数都会顺序执行ee.emit('open')console.log('触发后,隔一秒再触发一次')setTimeout(() => {ee.emit('open')}, 1000)// 事件发生了,第一个监听回调函数执行// 事件发生了,第二个监听回调函数执行// 触发后,隔一秒再触发一次// 事件发生了,第一个监听回调函数执行// 事件发生了,第二个监听回调函数执行
一个事件实例上有如下的属性和方法:
- addListener(event, listener): 向事件队列后面再增加一个监听器
- emit(event, [arg1], [arg2], […]): 向事件队列触发一个事件,同时可以对该事件传过去更多的数据
- listeners(event): 返回事件队列中特定的事件监听对象
- on(event, listener): 针对一个特定的事件注册监听器,该监听器就是一个回调函数
- once(event, listener): 与 on 一样,只不过它只会执行一次,只生效一次
- removeAllListeners([event]): 移除所有指定事件的监听器,不指定的话,移除所有监听器,也就是清空事件队列
- removeListener(event, listener): 只移除特定事件监听器
- setMaxListeners(n): 设置监听器数组的最大数量,默认是 10,超过 10 个会收到 Node 的警告
定制自己的 events
如果我们在设计一款游戏,来监听一个玩家每一局干掉敌人,比如僵尸的个数,不同的个数会有不同的奖励机制,我们的代码可能会这样写:
// 声明一个玩家类class Player {// 给他初始的名字和分数constructor (name) {this.name = namethis.score = 0}// 每一局打完,统计干掉游戏目标个数,来奖励分值killed (target, number) {if (target !== 'zombie') returnif (number < 10) {this.score += 10 * number} else if (number < 20) {this.score += 8 * number} else if (number < 30) {this.score += 5 * number}console.log(`${this.name} 成功击杀 ${number} 个 ${target},总得分 ${this.score}`)}}// 创建一个玩家人物let player = new Player('Nil')// 玩了 3 局,每一局都有收获player.killed('zombie', 5)player.killed('zombie', 12)player.killed('zombie', 22)// Nil 成功击杀 5 个 zombie,总得分 50// Nil 成功击杀 12 个 zombie,总得分 146// Nil 成功击杀 22 个 zombie,总得分 256
这样的代码简单易懂,逻辑都控制在 killed 方法里面,但是扩展性不是很好,比如我想要在 killed 的时候,多做一些其他事情,我不得不去重写或者覆盖这个 killed 方法,定制程度更弱一些,如果游戏目标除了僵尸,还有吸血鬼、灵兽、虫子等等,他们的激励策略都不相同,通过几个方法来定制加分策略会更硬编码一些,那如果我们换用 events 的事件来简单实现下呢:
const EventEmitter = require('events')// 声明玩家类,让它继承 EventEmitterclass Player extends EventEmitter {constructor (name) {super()this.name = namethis.score = 0}}let player = new Player('Nil')// 每一个创建的玩家实例,都可以添加监听器// 也可以定义需要触发事件的名称,为其注册回调player.on('zombie', function (number) {if (number < 10) {this.score += 10 * number} else if (number < 20) {this.score += 8 * number} else if (number < 30) {this.score += 5 * number}console.log(`${this.name} 成功击杀 ${number} 个 zombie,总得分 ${this.score}`)})// 可以触发不同的事件类型player.emit('zombie', 5)player.emit('zombie', 12)player.emit('zombie', 22)// Nil 成功击杀 5 个 zombie,总得分 50// Nil 成功击杀 12 个 zombie,总得分 146// Nil 成功击杀 22 个 zombie,总得分 256
通过 Node 内建的 events,我们可以通过继承它来实现更灵活的类控制,给予类实例更多的控制颗粒度,即便是游戏规则变更,从代码的耦合度和维护性上看,后面这一种实现都会更轻量更灵活。
编程练习 - 命令行搜歌播放工具
能动手就不吵吵,Events 看着比较简单,我们应用到案例中感受一下,现在我们一起来开发一个工具,可以在命令行窗口中搜歌和播放歌曲,依然按照第六节的 NPM 发包流程,来创建这个项目,它的结构如下:
~ tree -L 2.├── README.md├── bin│ └── souge├── index.js├── lib│ ├── choose.js│ ├── find.js│ ├── names.js│ ├── play.js│ ├── request.js│ └── search.js├── package-lock.json└── package.json
在 package.json 中,我们增加执行的脚本路径:
"bin": {"souge": "./bin/souge"},
然后在 /bin/souge 里面,增加如下脚本代码:
#!/usr/bin/env nodeconst pkg = require('../package')const emitter = require('..')function printVersion() {console.log('souge ' + pkg.version)process.exit()}function printHelp(code) {const lines = ['',' Usage:',' souge [songName]','',' Options:',' -v, --version print the version of vc',' -h, --help display this message','',' Examples:',' $ souge Hello','']console.log(lines.join('\n'))process.exit(code || 0)}const main = async (argv) => {if (!argv || !argv.length) {printHelp(1)}let arg = argv[0]switch(arg) {case '-v':case '-V':case '--version':printVersion()breakcase '-h':case '-H':case '--help':printHelp()breakdefault:# 启动搜索逻辑,同时传入参数emitter.emit('search', arg)break}}main(process.argv.slice(2))module.exports = main
我们只需要关注 main 函数就可以了,当通过 souge hello 类似这样执行时,会把 hello 这个参数带进去,没有匹配到既有的其他参数标识,就会通过 emitter 来触发一个搜索事件,而 emitter 实例我们是从外层的 index.js 里面拿到的,所以在 index.js 里面这样写,大家跟着我的注释来看代码:
// names 是拼接歌曲名称的一个方法const names = require('./lib/names')const EventEmitter = require('events')// 声明一个继承 EventEmitter 的事件类class Emitter extends EventEmitter {}// 实例化一个事件实例const emitter = new Emitter();['search','choose','find','play'].forEach(key => {// 加载 search/choose/find/play 四个模块方法const fn = require(`./lib/${key}`)// 为 emitter 增加 4 个事件监听,key 就是模块名emitter.on(key, async function (...args) {// 在事件回调里面,调用模块方法,无脑传入事件入参const res = await fn(...args)// 执行模块方法后,再触发一个新事件 hanlder// 同时把多个参数,如 key/res 继续丢过去this.emit('handler', key, res, ...args)})})// 搜索后触发 afterSearch,它回调里面继续触发 choose 事件emitter.on('afterSearch', function (data, q) {if (!data || !data.result || !data.result.songs) {console.log(`没搜索到 ${q} 的相关结果`)return process.exit(1)}const songs = data.result.songsthis.emit('choose', songs)})// 在歌曲被选中后,它回调里面继续触发 find 事件emitter.on('afterChoose', function (answers, songs) {const arr = songs.filter((song, i) => (names(song, i) === answers.song))if (arr[0] && arr[0].id) {this.emit('find', arr[0].id)}})// 在歌曲被找到后,它回调里面触发 play 播放事件emitter.on('afterFind', function (songs) {if (songs[0] && songs[0].url) {this.emit('play', songs[0].url)}})// 监听播放,并对播放结束后继续触发 playEndemitter.on('playing', function (player) {player.on('playend', (item) => {this.emit('playEnd')})})// 收到播放结束,退出程序emitter.on('playEnd', function (player) {console.log('播放结束!')process.exit()})// 这里的 handler 精简了多个事件的判断// 为不同的事件增加了不同的触发回调emitter.on('handler', function (key, res, ...args) {switch (key) {case 'search':return this.emit('afterSearch', res, args[0])case 'choose':return this.emit('afterChoose', res, args[0])case 'find':return this.emit('afterFind', res)case 'play':return this.emit('playing', res)}})module.exports = emitter
歌曲的搜索播放主逻辑有了后,我们就可以各个击破了,首先是搜索逻辑,在 /lib/search.js 里面发一个请求,把歌曲名字带过去,开始搜索,这里借用了 imjad.cn 的搜歌 API,大家如果自己学习,也可以自行搭建其他歌曲 API 服务,有很多开源的项目可以参考:
const request = require('./request')module.exports = (name) => {const url = 'https://api.imjad.cn/cloudmusic/?type=search&search_type=1&s=' + namereturn request(url)}
这里请求模块,也就是 /lib/request.js 代码如下:
const https = require('https')module.exports = (url) => new Promise((resolve, reject) => {https.get(url, (req, res) => {let data = []req.on('data', chunk => {data.push(chunk)})req.on('end', () => {let bodytry {body = JSON.parse(data.join(''))} catch (err) {console.log('<== API 服务器可能挂了,稍后重试!==>')}resolve(body)})})})
这里的代码就很简单了,一个普通的 HTTP 请求,把收到的 Buffer 数据最终拼接后返回,在返回后,又会一路触发 afterSearch 和 choose 事件,在 choose 时候,会显示一个歌曲列表,代码也很简单:
const inquirer = require('inquirer')const names = require('./names')module.exports = (songs) => inquirer.prompt([{type: 'list',name: 'song',message: '共有 ' + songs.length + ' 个结果, 按下回车播放',choices: songs.map((i, index) => names(i, index))}])
拼接歌曲名称的代码 /lib/names.js 只有一句:
module.exports = (item, index) =>`${index + 1} ${item.name} ${item.ar[0].name} ${item.al.name}`
而 inquirer 是一个三方模块,赋予我们跟命令行窗口更友好的交互方式,通过 inquirer.prompt 我们了解到是哪一首歌曲被选中了,最后当歌曲被选中时,一路就触发 afterChoose 和 find 事件,在 find 时候,就用到了 /lib/find.js:
const request= require('./request')module.exports = async (id) => {const url = 'https://api.imjad.cn/cloudmusic/?type=song&br=128000&id=' + idconst { data } = await request(url)return data}
这里依然是个简单的请求,获取到音乐文件流数据,最后在 afterFind 里面,触发 play 事件,播放音乐文件,播放代码在 /lib/play.js 里面:
const Player = require('player')module.exports = (url) => {return new Promise((resolve, reject) => {// 实例化一个播放器,立刻启动播放const player = new Player(url)player.play()// 播放时候,触发 playingplayer.on('playing', function (item) {console.log('播放中!')resolve(player)})player.on('error', function (err) {// when error occursconsole.log('播放出错!')reject(err)})})}
这里封装了一个 Promise,包裹了 Player 的创建和播放过程,直到最终播放结束事件触发,整个过程完成,所以事件的整体触发顺序是:
- search 搜索启动
- afterSearch 搜索完成
- choose 歌曲选单
- afterChoose 选中动作触发
- find 去找寻对应歌曲
- afterFind 歌曲文件数据拿到
- play 开始播放
- playing 正在播放
- playEnd 播放结束
这样通过事件,我们就非常方便的管理了整个流程的多个状态,如果我们想要集成其他的事件,只要处理好事件触发顺序,就变得易如反掌了。
最后,我们可以把这个模块发布到 npm,大家也可以 npm i souge -g 来体验这个工具,最原始的代码在 4liang/souge,考虑到学习,未对代码做过多优化,大家可以基于此修改后,来提 PR。
