Node.js 开发人员最常犯的 10 个错误
已发表: 2022-03-11自从 Node.js 面世以来,它就受到了相当多的赞扬和批评。 辩论仍在继续,可能不会很快结束。 在这些辩论中我们经常忽略的是,每种编程语言和平台都会基于某些问题受到批评,这些问题是由我们使用平台的方式造成的。 不管 Node.js 让编写安全代码多么困难,以及编写高并发代码多么容易,该平台已经存在了很长一段时间,并已被用于构建大量强大而复杂的 Web 服务。 这些 Web 服务扩展性很好,并且通过在 Internet 上的持久性证明了它们的稳定性。
但是,与任何其他平台一样,Node.js 容易受到开发人员问题的影响。 其中一些错误会降低性能,而另一些错误会使 Node.js 看起来无法用于您想要实现的任何目标。 在本文中,我们将看看 Node.js 新手经常犯的十个常见错误,以及如何避免这些错误成为 Node.js 专业人士。
错误 #1:阻塞事件循环
Node.js 中的 JavaScript(就像在浏览器中一样)提供了一个单线程环境。 这意味着您的应用程序没有两个部分是并行运行的。 相反,并发是通过异步处理 I/O 绑定操作来实现的。 例如,从 Node.js 到数据库引擎的请求以获取一些文档是允许 Node.js 专注于应用程序的其他部分的原因:
// Trying to fetch an user object from the database. Node.js is free to run other parts of the code from the moment this function is invoked.. db.User.get(userId, function(err, user) { // .. until the moment the user object has been retrieved here })
然而,在连接了数千个客户端的 Node.js 实例中,一段受 CPU 限制的代码就足以阻止事件循环,让所有客户端等待。 受 CPU 限制的代码包括尝试对大型数组进行排序、运行极长的循环等等。 例如:
function sortUsersByAge(users) { users.sort(function(a, b) { return a.age < b.age ? -1 : 1 }) }
如果在一个小的“users”数组上运行,调用这个“sortUsersByAge”函数可能没问题,但是对于一个大数组,它会对整体性能产生可怕的影响。 如果这是绝对必须完成的事情,并且您确定在事件循环上不会有任何其他等待(例如,如果这是您使用 Node.js 构建的命令行工具的一部分,并且它如果整个事情同步运行都没有关系),那么这可能不是问题。 但是,在尝试一次为数千名用户提供服务的 Node.js 服务器实例中,这种模式可能是致命的。
如果从数据库中检索此用户数组,理想的解决方案是直接从数据库中获取已排序的用户。 如果事件循环被编写用于计算长期金融交易数据历史总和的循环阻塞,则可以将其推迟到某些外部工作者/队列设置,以避免占用事件循环。
如您所见,对于此类 Node.js 问题没有灵丹妙药的解决方案,而是需要单独解决每种情况。 基本思想是不在前端 Node.js 实例中进行 CPU 密集型工作——客户端同时连接的那些。
错误 #2:多次调用回调
JavaScript 一直依赖回调。 在 Web 浏览器中,事件是通过传递对(通常是匿名的)函数的引用来处理的,这些函数的作用类似于回调。 在 Node.js 中,回调曾经是代码中异步元素相互通信的唯一方式——直到引入了 Promise。 回调仍在使用中,包开发人员仍然围绕回调设计他们的 API。 与使用回调相关的一个常见 Node.js 问题是多次调用它们。 通常,包提供的用于异步执行某些操作的函数被设计为期望函数作为其最后一个参数,该参数在异步任务完成时调用:
module.exports.verifyPassword = function(user, password, done) { if(typeof password !== 'string') { done(new Error('password should be a string')) return } computeHash(password, user.passwordHashOpts, function(err, hash) { if(err) { done(err) return } done(null, hash === user.passwordHash) }) }
注意每次调用“done”时都有一个返回语句,直到最后一次。 这是因为调用回调不会自动结束当前函数的执行。 如果第一个“return”被注释掉,向这个函数传递一个非字符串密码仍然会导致“computeHash”被调用。 根据“computeHash”如何处理这种情况,“done”可能会被多次调用。 当他们传递的回调被多次调用时,从其他地方使用此函数的任何人都可能完全措手不及。
小心是避免此 Node.js 错误所需要的一切。 一些 Node.js 开发人员习惯于在每次回调调用之前添加一个 return 关键字:
if(err) { return done(err) }
在很多异步函数中,返回值几乎没有什么意义,所以这种方法往往可以很容易地避免这样的问题。
错误 #3:深度嵌套回调
深度嵌套的回调,通常被称为“回调地狱”,它本身并不是 Node.js 的问题。 但是,这可能会导致代码快速失控的问题:
function handleLogin(..., done) { db.User.get(..., function(..., user) { if(!user) { return done(null, 'failed to log in') } utils.verifyPassword(..., function(..., okay) { if(okay) { return done(null, 'failed to log in') } session.login(..., function() { done(null, 'logged in') }) }) }) }
任务越复杂,情况就越糟糕。 通过以这种方式嵌套回调,我们很容易以容易出错、难以阅读和难以维护的代码告终。 一种解决方法是将这些任务声明为小函数,然后将它们链接起来。 虽然,(可以说)最干净的解决方案之一是使用处理异步 JavaScript 模式的实用程序 Node.js 包,例如 Async.js:
function handleLogin(done) { async.waterfall([ function(done) { db.User.get(..., done) }, function(user, done) { if(!user) { return done(null, 'failed to log in') } utils.verifyPassword(..., function(..., okay) { done(null, user, okay) }) }, function(user, okay, done) { if(okay) { return done(null, 'failed to log in') } session.login(..., function() { done(null, 'logged in') }) } ], function() { // ... }) }
与“async.waterfall”类似,Async.js 还提供了许多其他函数来处理不同的异步模式。 为简洁起见,我们在这里使用了更简单的示例,但实际情况往往更糟。
错误 #4:期望回调同步运行
带有回调的异步编程可能不是 JavaScript 和 Node.js 独有的东西,但它们是其流行的原因。 对于其他编程语言,我们习惯于可预测的执行顺序,两条语句将一个接一个地执行,除非有特定的指令在语句之间跳转。 即使这样,这些通常也仅限于条件语句、循环语句和函数调用。
但是,在 JavaScript 中,使用回调,特定函数可能无法正常运行,直到它等待的任务完成。 当前函数的执行将一直运行到最后,没有任何停止:
function testTimeout() { console.log(“Begin”) setTimeout(function() { console.log(“Done!”) }, duration * 1000) console.log(“Waiting..”) }
您会注意到,调用“testTimeout”函数将首先打印“Begin”,然后打印“Waiting..”,然后是“Done!”消息。 大约一秒钟后。
回调触发后需要发生的任何事情都需要从其中调用。
错误#5:分配给“exports”,而不是“module.exports”
Node.js 将每个文件视为一个小的独立模块。 如果你的包有两个文件,可能是“a.js”和“b.js”,那么为了让“b.js”访问“a.js”的功能,“a.js”必须通过添加属性来导出它出口对象:
// a.js exports.verifyPassword = function(user, password, done) { ... }
完成后,任何需要“a.js”的人都将获得一个具有属性函数“verifyPassword”的对象:

// b.js require('a.js') // { verifyPassword: function(user, password, done) { ... } }
但是,如果我们想直接导出这个函数,而不是作为某个对象的属性呢? 我们可以覆盖导出来执行此操作,但我们不能将其视为全局变量:
// a.js module.exports = function(user, password, done) { ... }
请注意我们如何将“导出”视为模块对象的属性。 这里“module.exports”和“exports”之间的区别非常重要,并且经常是新 Node.js 开发人员感到沮丧的原因。
错误 #6:从内部回调中抛出错误
JavaScript 有异常的概念。 模仿几乎所有支持异常处理的传统语言的语法,例如 Java 和 C++,JavaScript 可以在 try-catch 块中“抛出”和捕获异常:
function slugifyUsername(username) { if(typeof username === 'string') { throw new TypeError('expected a string username, got '+(typeof username)) } // ... } try { var usernameSlug = slugifyUsername(username) } catch(e) { console.log('Oh no!') }
但是,try-catch 在异步情况下不会像您期望的那样运行。 例如,如果你想用一个大的 try-catch 块来保护一大块具有大量异步活动的代码,它不一定有效:
try { db.User.get(userId, function(err, user) { if(err) { throw err } // ... usernameSlug = slugifyUsername(user.username) // ... }) } catch(e) { console.log('Oh no!') }
如果对“db.User.get”的回调异步触发,那么包含 try-catch 块的作用域早就脱离了上下文,它仍然能够捕获从回调内部抛出的错误。
这就是在 Node.js 中以不同方式处理错误的方式,这使得在所有回调函数参数上遵循 (err, ...) 模式至关重要 - 如果发生错误,所有回调的第一个参数都应该是错误的.
错误 #7:假设 Number 是整数数据类型
JavaScript 中的数字是浮点数——没有整数数据类型。 您不会认为这是一个问题,因为不会经常遇到大到足以强调浮动限制的数字。 这正是与此相关的错误发生的时候。 由于浮点数只能将整数表示形式保存到某个值,因此在任何计算中超过该值都会立即开始搞砸。 尽管看起来很奇怪,但以下在 Node.js 中的计算结果为 true:
Math.pow(2, 53)+1 === Math.pow(2, 53)
不幸的是,JavaScript 中数字的怪癖并没有就此结束。 即使数字是浮点数,也适用于整数数据类型的运算符:
5 % 2 === 1 // true 5 >> 1 === 2 // true
然而,与算术运算符不同的是,位运算符和移位运算符仅适用于此类大“整数”数字的尾随 32 位。 例如,尝试将“Math.pow(2, 53)”移动 1 将始终计算为 0。尝试对相同的大数进行按位或 1 将计算为 1。
Math.pow(2, 53) / 2 === Math.pow(2, 52) // true Math.pow(2, 53) >> 1 === 0 // true Math.pow(2, 53) | 1 === 1 // true
您可能很少需要处理大数,但如果您这样做,有很多大整数库可以实现对大精度数的重要数学运算,例如 node-bigint。
错误 #8:忽略流式 API 的优势
假设我们想要构建一个类似代理的小型 Web 服务器,通过从另一个 Web 服务器获取内容来响应请求。 例如,我们将构建一个服务 Gravatar 图像的小型 Web 服务器:
var http = require('http') var crypto = require('crypto') http.createServer() .on('request', function(req, res) { var email = req.url.substr(req.url.lastIndexOf('/')+1) if(!email) { res.writeHead(404) return res.end() } var buf = new Buffer(1024*1024) http.get('http://www.gravatar.com/avatar/'+crypto.createHash('md5').update(email).digest('hex'), function(resp) { var size = 0 resp.on('data', function(chunk) { chunk.copy(buf, size) size += chunk.length }) .on('end', function() { res.write(buf.slice(0, size)) res.end() }) }) }) .listen(8080)
在这个 Node.js 问题的特定示例中,我们从 Gravatar 获取图像,将其读入缓冲区,然后响应请求。 考虑到 Gravatar 图像不是太大,这并不是一件坏事。 但是,想象一下,如果我们代理的内容大小是数千兆字节。 更好的方法是:
http.createServer() .on('request', function(req, res) { var email = req.url.substr(req.url.lastIndexOf('/')+1) if(!email) { res.writeHead(404) return res.end() } http.get('http://www.gravatar.com/avatar/'+crypto.createHash('md5').update(email).digest('hex'), function(resp) { resp.pipe(res) }) }) .listen(8080)
在这里,我们获取图像并简单地将响应传递给客户端。 在任何时候我们都不需要在提供之前将整个内容读入缓冲区。
错误 #9:使用 Console.log 进行调试
在 Node.js 中,“console.log”允许您将几乎所有内容打印到控制台。 将对象传递给它,它会将其打印为 JavaScript 对象字面量。 它接受任意数量的参数,并以空格分隔的方式打印它们。 开发人员可能会想用它来调试他的代码有很多原因; 但是,强烈建议您在实际代码中避免使用“console.log”。 您应该避免在整个代码中编写“console.log”来调试它,然后在不再需要它们时将它们注释掉。 相反,请使用专门为此构建的令人惊叹的库之一,例如调试。
此类软件包提供了在启动应用程序时启用和禁用某些调试行的便捷方法。 例如,使用 debug 可以通过不设置 DEBUG 环境变量来防止任何调试行被打印到终端。 使用它很简单:
// app.js var debug = require('debug')('app') debug('Hello, %s!', 'world')
要启用调试行,只需在环境变量 DEBUG 设置为“app”或“*”的情况下运行此代码:
DEBUG=app node app.js
错误 #10:不使用主管程序
无论您的 Node.js 代码是在生产环境中还是在本地开发环境中运行,一个可以编排您的程序的主管程序监视器都是非常有用的。 设计和实现现代应用程序的开发人员经常推荐的一种做法是建议您的代码应该快速失败。 如果发生意外错误,请不要尝试处理它,而是让您的程序崩溃并让主管在几秒钟内重新启动它。 监督程序的好处不仅限于重新启动崩溃的程序。 这些工具允许您在崩溃时重新启动程序,以及在某些文件更改时重新启动它们。 这使得开发 Node.js 程序的体验更加愉快。
有大量可用于 Node.js 的主管程序。 例如:
pm2
永远
节点监视器
导师
所有这些工具都各有利弊。 其中一些适合在同一台机器上处理多个应用程序,而另一些则更擅长日志管理。 但是,如果您想开始使用这样的程序,所有这些都是公平的选择。
结论
如您所知,其中一些 Node.js 问题可能会对您的程序产生破坏性影响。 当您尝试在 Node.js 中实现最简单的事情时,有些可能是令人沮丧的原因。 尽管 Node.js 让新手上手非常容易,但它仍然存在同样容易搞砸的地方。 来自其他编程语言的开发人员可能能够解决其中一些问题,但这些错误在 Node.js 新开发人员中很常见。 幸运的是,它们很容易避免。 我希望这个简短的指南能够帮助初学者在 Node.js 中编写更好的代码,并为我们所有人开发稳定高效的软件。