当前位置: 首页 > 科技观察

【翻译】理解Node.js事件驱动机制

时间:2023-03-14 21:07:25 科技观察

是学习Node.js必须要理解的内容之一。本文主要涉及EventEmitter的使用以及一些异步情况的处理。比较基础,值得一读。大多数Node.js对象都依赖EventEmitter模块来监听和响应事件,例如我们常用的HTTP请求、响应和流。constEventEmitter=require('事件');事件驱动机制最简单的形式是Node.js中非常流行的回调函数,比如fs.readFile。在回调函数形式中,每次触发事件时都会触发回调。我们先来探讨一下最基本的方法。准备好后给我打电话,节点!很久以前,js中没有对Promise的原生支持,async/await只是一个遥不可及的梦想,回调函数是处理异步问题最原始的方式。回调本质上是传递给其他函数的函数。在JavaScript中,函数是类对象,这使得回调成为可能。需要明确的是,代码中存在回调并不意味着异步调用。可以同步或异步调用回调。比如这里有一个宿主函数fileSize,它接受一个回调函数cb,可以通过条件判断来同步或异步调用回调函数:functionfileSize(fileName,cb){if(typeoffileName!=='string'){//Syncreturncb(newTypeError('argumentsshouldbestring'));}fs.stat(fileName,(err,stats)=>{if(err){//Asyncreturncb(err);}//Asynccb(null,stats.size);});}这其实是一个反例,这样写往往会出现一些意想不到的错误,在设计宿主函数时,应该尽可能使用相同的风格,要么一直使用同步回调,要么一直使用异步。让我们检查下一个典型的异步Node函数的简单示例,以回调方式编写:}constlines=data.toString().trim().split('\n');cb(null,lines);});};readFileAsArray函数接受两个参数:文件路径和回调函数。它读取文件内容,将其拆分为一个行数组,并将该数组作为参数传递给调用的回调函数。现在设计一个用例,假设我们在同一目录下的文件numbers.txt包含以下内容:101112131415如果我们有统计文件中奇数的需求,我们可以使用readFileAsArray来简化代码:readFileAsArray('./numbers.txt',(err,lines)=>{if(err)throwerr;constnumbers=lines.map(Number);constoddNumbers=numbers.filter(n=>n%2===1);console.log('Oddnumberscount:',oddNumbers.length);});这段代码将文件内容读入一个字符串数组,回调函数将其解析为一个数字,统计奇数个数。这是最纯粹形式的Node回调样式。回调的第一个参数要遵循错误优先的原则,err可以为空,我们需要将回调作为宿主函数的最后一个参数传递。您应该始终以这种方式设计您的功能,因为用户可能会假设。让宿主函数将回调作为其最后一个参数,并让回调函数将一个可能为空的错误对象作为其第一个参数。现代JavaScript中回调的替代方案在现代JavaScript中,我们有Promises,可用于替代异步API中的回调。回调函数需要作为宿主函数的参数传递(多个宿主回调嵌套形成回调地狱),错误和成功都只能在其中处理。Promise对象允许我们分别处理成功和错误,也允许我们链接多个异步事件。如果readFileAsArray函数支持Promises,我们可以这样使用它:readFileAsArray('./numbers.txt').then(lines=>{constnumbers=lines.map(Number);constoddNumbers=numbers.filter(n=>n%2===1);console.log('Oddnumberscount:',oddNumbers.length);}).catch(console.error);我们在宿主函数的返回值上调用一个函数来处理我们的需求,这个.then函数会将刚刚在回调版本中的行数组传递给这里的匿名函数。为了处理错误,我们向结果添加一个.catch调用,它会捕获错误并在错误发生时让我们访问它。现代JavaScript已经支持Promise对象,因此我们可以轻松地在宿主函数中使用它们。下面是支持Promise版本的readFileAsArray函数(也支持旧的回调方式):constreadFileAsArray=function(file,cb=()=>{}){returnnewPromise((resolve,reject)=>{fs.readFile(文件,函数(错误,数据){if(错误){拒绝(错误);returncb(错误);}constlines=data.toString()。trim()。split('\n');解决(行);cb(null,lines);});});};我们让这个函数返回一个Promise对象,它包装了对fs.readFile的异步调用。Promise对象公开两个参数,一个resolve函数和一个reject函数。当抛出异常时,我们可以将error传递给回调函数来处理错误,也可以使用Promise的reject函数。每当我们将数据传递给回调函数进行处理时,我们也可以使用Promise的resolve函数。在这种callback和Promise可以同时使用的情况下,我们唯一需要做的就是为callback参数设置一个默认值,防止在没有传递回调函数参数的情况下执行报错。本例中使用了一个简单的默认空函数:()=>{}。将Promises与async/await结合使用当您需要连续调用异步函数时,使用Promises将使您的代码更易于编写。不断地使用回调会让事情变得越来越复杂,最终陷入回调地狱。Promises的存在稍微改善了它,而Generators的存在也稍微改善了它。处理异步问题最好的方法就是使用异步函数,它可以让我们把异步代码当作同步代码来对待,整体上可读性更强。下面是使用异步/等待版本调用readFileAsArray的示例:asyncfunctioncountOdd(){try{constlines=awaitreadFileAsArray('./numbers');constnumbers=lines.map(Number);constoddCount=numbers.filter(n=>n%2===1).length;console.log('Oddnumberscount:',oddCount);}catch(err){console.error(err);}}countOdd();首先,我们创建了一个async函数——就是在一个普通的函数声明之前,加上一个async关键字。在async函数内部,我们调用了readFileAsArray函数,就像将其返回值赋给变量lines一样。为了真正得到readFileAsArray处理生成的行数组,我们使用关键字await。之后,我们继续执行代码,就像对readFileAsArray的调用是同步的一样。为了让代码运行起来,我们可以直接调用异步函数。这使我们的代码更简单,更易于阅读。为了处理异常,我们需要将异步调用包装在try/catch语句中。使用async/await功能,我们不必使用任何特殊的API(例如.then和.catch)。我们只是标记这个函数并用纯JavaScript编写代码。我们可以在支持使用Promises处理后续逻辑的函数上使用async/await特性。但是,它不能用于仅支持回调的异步函数(例如setTimeout)。EventEmitter模块EventEmitter是处理Node.js中对象之间通信的模块。EventEmitter是Node异步事件驱动架构的核心。Node的许多内置模块都继承自EventEmitter。它的概念其实很简单:发射器对象会发出定义好的事件,导致之前注册的所有监听该事件的函数被调用。因此,发射器对象基本上有两个主要特征:触发定义的事件注册或取消注册监听器函数为了使用EventEmitter,我们需要创建一个继承自EventEmitter的类。classMyEmitterextendsEventEmitter{}我们从EventEmitter的子类实例化的对象就是发射器对象:constmyEmitter=newMyEmitter();在这些发射器对象的生命周期中,我们可以调用emit函数来触发我们想要的任何命名触发器事件。myEmitter.emit('something-happened');emit函数的使用表示有事情发生了,让大家做自己该做的事情。这种情况通常是由某些状态变化引起的。我们可以使用on方法添加监听器函数,这些监听器函数将在每次发射器对象触发其关联事件时执行。Event!==Asynchronous首先看这个例子:constEventEmitter=require('events');classWithLogextendsEventEmitter{execute(taskFunc){console.log('Beforeexecuting');this.emit('begin');taskFunc();this.emit('end');console.log('Afterexecuting');}}constwithLog=newWithLog();withLog.on('begin',()=>console.log('Abouttoexecute'));withLog.on('end',()=>console.log('Donewithexecute'));withLog.execute(()=>console.log('***Executingtask***'));WithLog是一个事件触发器,它有一个方法——execute,它接受一个参数,即具体要处理的任务函数,并在其前后包裹log,输出其执行日志。为了查看此处将执行什么顺序,我们在两个命名事件上注册侦听器,最后执行一个简单的任务来触发事件。下面是上面程序的输出:BeforeexecutingAbouttoexecute***Executingtask***DonewithexecuteAfterexecuting这里我想确认一下上面的输出都是同步的,这段代码中没有异步成分。***行输出“Beforeexecution”开始事件被触发,输出“Abouttoexecute”并调用应该执行的任务函数,输出“Executingtask”结束事件被触发,输出“Donewithexecute”***输出“Afterexecution”就像一个普通的回调,不要假设事件意味着同步或异步代码。就像前面的回调一样,不要一提到事件就认为是异步或同步的,而是要具体分析。如果我们将异步函数传递给taskFunc会发生什么?//...withLog.execute(()=>{setImmediate(()=>{console.log('***Executingtask***')});});输出变成这样:BeforeexecutingAbouttoexecuteDonewithexecuteAfterexecuting***Executingtask***这是个问题,异步函数调用导致“Donewithexecute”和“Afterexecution”的输出不准确。要在异步函数完成后发出事件,我们需要将回调(或承诺)与基于事件的通信结合起来。下面的例子说明了这一点。使用事件而不是常规回调的一个好处是,我们可以通过定义多个侦听器对同一信号做出多种不同的反应。如果我们使用回调来做到这一点,我们必须在一个回调中编写更多的处理逻辑。事件是应用程序允许多个外部插件在应用程序核心之上构建功能的好方法。您可以将它们用作钩子来挂起一些由于状态更改而执行的程序。异步事件我们把刚才的同步代码示例改成异步的:);console.time('execute');asyncFunc(...args,(err,data)=>{if(err){returnthis.emit('error',err);}this.emit('data',data);console.timeEnd('execute');this.emit('end');});}}constwithTime=newWithTime();withTime.on('begin',()=>console.log('Abouttoexecute'));withTime.on('end',()=>console.log('Donewithexecute'));withTime.execute(fs.readFile,__filename);用WithTime类执行asyncFunc函数,调用console.time和console.timeEnd报告此asyncFunc所用的时间。它在执行前后以正确的顺序触发适当的事件,并且还会发出错误/数据事件作为处理异步调用的信号。我们传递一个异步fs.readFile函数来测试withTime发射器。我们现在可以直接通过监听data事件来处理读取的文件数据,而不用把这组处理逻辑写到fs.readFile的回调函数中。执行这段代码,我们按照预期的顺序执行了一系列的事件,得到了异步函数的执行时间,这个很重要。Abouttoexecuteexecute:4.507msDonewithexecute请注意,我们通过将回调与事件触发发射器相结合来实现这部分功能。如果asyncFunc支持Promise,我们可以使用async/await函数来做同样的事情:constdata=awaitasyncFunc(...args);this.emit('data',data);console.timeEnd('execute');this.emit('end');}catch(err){this.emit('error',err);}}}我认为这段代码比之前的回调风格代码和.then/.catch风格代码更具可读性。async/await让我们更接近JavaScript语言本身(不再需要使用.then/.catchAPI)。事件参数和错误在前面的示例中,发出了两个带有附加参数的事件。当错误事件被触发时,它会携带一个错误对象。this.emit('错误',错误);当数据事件被触发时,它会携带一个数据对象。this.emit('数据',数据);我们可以在emit函数中不断添加参数,当然第一个参数必须是事件的名称,除第一个参数外的所有参数都可以在事件中注册监听器中使用。比如处理data事件,我们注册的监听函数会访问传递给emit函数的data参数,而这个数据也是asyncFunc返回的数据。withTime.on('data',(data)=>{//dosomethingwithdata});错误事件是特殊的。在我们基于回调的示例中,如果没有使用侦听器来处理错误事件,则节点进程将退出。举一个由于参数使用不当导致程序崩溃的例子:if(err){returnthis.emit('error',err);//NotHandled}console.timeEnd('execute');});}}constwithTime=newWithTime();withTime.execute(fs.readFile,'');//BADCALLwithTime.execute(fs.readFile,__filename);第一次调用execute会触发error事件,因为没有处理错误,Node程序会崩溃:events.js:163thrower;//Unhandled'error'event^Error:ENOENT:nosuchfileordirectory,open''第二次执行调用将受此崩溃影响,可能根本无法执行。如果我们为这个错误事件注册一个监听函数来处理错误,结果就会大不相同:withTime.on('error',(err)=>{//dosomethingwitherr,forexamplelogitsomewhereconsole.log(err)});如果我们执行上面的操作会报第一次执行execute时发送的错误,但是这次node进程不会崩溃退出,其他程序的调用也可以正常完成:{Error:ENOENT:nosuchfileordirectory,open''errno:-2,code:'ENOENT',syscall:'open',path:''}execute:4.276ms需要注意的是,基于Promise的函数有些不同,它们只输出警告暂时:UnhandledPromiseRejectionWarning:Unhandledpromiserejection(rejectionid:1):Error:ENOENT:nosuchfileordirectory,open''DeprecationWarning:Unhandledpromiserejectionsaredeprecated.Inthefuture,promiserejectionsthatarenothandledwillterminatetheNode.jsprocesswithanan-zeroexitcode.另一种处理异常的方法是监听全局的uncaughtException进程事件。但是,使用此事件来全局捕获错误并不是一个好主意。关于uncaughtException,一般建议大家避免使用,但如果一定要使用,应该让进程退出:process.on('uncaughtException',(err)=>{//somethingwentunhandled.//Doanycleanupandexitanyway!console.error(err);//不要那样做。//FORCEexittheprocesstoo.process.exit(1);});但是,假设多个错误事件同时发生,这意味着上面的uncaughtException监听器会被多次触发,这可能会导致一些问题。EventEmitter模块暴露了once方法,该方法发出的信号只会调用一次监听器。因此,该方法常与uncaughtException一起使用。监听器顺序如果一个事件注册了多个监听器函数,当事件被触发时,监听器函数将按照注册的顺序被触发。//firstwithTime.on('data',(data)=>{console.log(`Length:${data.length}`);});//secondwithTime.on('data',(data)=>{console.log(`字符:${data.toString().length}`);});withTime.execute(fs.readFile,__filename);上面的代码会先输出Length信息,再输出Characters信息,执行的顺序和注册的顺序一样。如果想定义一个新的监听函数,但又想让它先执行,也可以使用prependListener方法:withTime.on('data',(data)=>{console.log(`Length:${data.length}`);});withTime.prependListener('data',(data)=>{console.log(`Characters:${data.toString().length}`);});withTime。执行(fs.readFile,__文件名);上面代码中,Charaters信息会先输出。***,可以使用removeListener函数移除一个监听函数。