如果您是前端开发人员,能够理解 Node 仍然是一项重要的技能。 Deno 已经出现,成为另一种在浏览器外部运行 JavaScript 的方式,但使用 Node 构建的庞大工具和软件生态系统意味着它不会很快消失。
如果您主要编写在浏览器中运行的 JavaScript,并且希望更深入地了解服务器端,许多文章会告诉您 Node JavaScript 是编写服务器端代码并利用您的 JavaScript 经验的好方法。
我同意,但即使您有编写客户端 JavaScript 的经验,进入 Node.js 也会面临很多挑战。本文假设您已经安装了 Node,并且使用它来构建前端应用程序,但希望使用 Node 编写自己的 API 和工具。
有关 Node 和 npm 的初学者解释,您可以查看 Jamie Corkhill 在 Smashing Magazine 上的 “Node 入门”。
异步 JavaScript
我们不需要在浏览器上编写很多异步代码。在浏览器上使用异步代码的最常见用法是使用 fetch
(或者如果您是老手,则使用 XMLHttpRequest
)从 API 获取数据。异步代码的其他用途可能包括使用 setInterval
、setTimeout
或响应用户输入事件,但我们可以在编写 JavaScript UI 时走得更远,而无需成为异步 JavaScript 大师。
如果您使用 Node,您几乎始终会编写异步代码。从一开始,Node 就是为了利用使用异步回调的单线程事件循环而构建的。Node 团队在 2011 年发表的博文中介绍了“Node.js 从根本上促进了异步编码风格”。在 Ryan Dahl 在 2009 年宣布 Node.js 的演讲中,他谈到了全力投入异步 JavaScript 带来的性能优势。
异步优先风格是 Node 比其他尝试服务器端 JavaScript 实现(如 Netscape 的应用程序服务器 或 Narwhal)更受欢迎的部分原因。但是,如果您还没有准备好,被迫编写异步 JavaScript 可能会造成摩擦。
设置示例
假设我们要编写一个测验应用程序。我们将允许用户从多项选择题中构建测验,以测试朋友的知识。您可以在这个 GitHub 存储库 中找到我们将构建内容的更完整版本。您也可以克隆 整个前端和后端 来查看它们是如何组合在一起的,或者您可以查看这个 CodeSandbox(运行 npm run start
来启动它),并从那里了解我们正在制作的内容。

我们应用程序中的测验将包含许多问题,每个问题都将有多个答案可供选择,只有一个答案是正确的。
我们可以在 SQLite 数据库 中保存这些数据。
- 我们的数据库将包含
- 一个包含两列的测验表
- 一个整数 ID
- 一个文本标题
- 一个包含两列的测验表
- 一个包含三列的问题表
- 主体文本
- 与每个问题所属测验的 ID 相匹配的整数引用
- 一个包含两列的测验表
- 一个包含三列的问题表
- 一个包含四列的答案表
- 答案是否正确
与每个答案所属问题的 ID 相匹配的整数引用
SQLite 没有布尔数据类型,因此我们可以将答案是否正确保存在一个整数中,其中 0
表示假,1
表示真。
npm init -y
npm install sqlite3
首先,我们需要从命令行初始化 npm 并安装 sqlite3 npm 包
"type":"module"
这将创建一个 package.json
文件。让我们编辑它并添加
// migrate.js
import sqlite3 from "sqlite3";
let db = new sqlite3.Database("quiz.db");
db.serialize(function () {
// Setting up our tables:
db.run("CREATE TABLE quiz (quizid INTEGER PRIMARY KEY, title TEXT)");
db.run("CREATE TABLE question (questionid INTEGER PRIMARY KEY, body TEXT, questionquiz INTEGER, FOREIGN KEY(questionquiz) REFERENCES quiz(quizid))");
db.run("CREATE TABLE answer (answerid INTEGER PRIMARY KEY, body TEXT, iscorrect INTEGER, answerquestion INTEGER, FOREIGN KEY(answerquestion) REFERENCES question(questionid))");
// Create a quiz with an id of 0 and a title "my quiz"
db.run("INSERT INTO quiz VALUES(0,\"my quiz\")");
// Create a question with an id of 0, a question body
// and a link to the quiz using the id 0
db.run("INSERT INTO question VALUES(0,\"What is the capital of France?\", 0)");
// Create four answers with unique ids, answer bodies, an integer for whether
// they're correct or not, and a link to the first question using the id 0
db.run("INSERT INTO answer VALUES(0,\"Madrid\",0, 0)");
db.run("INSERT INTO answer VALUES(1,\"Paris\",1, 0)");
db.run("INSERT INTO answer VALUES(2,\"London\",0, 0)");
db.run("INSERT INTO answer VALUES(3,\"Amsterdam\",0, 0)");
});
db.close();
到顶层 JSON 对象。这将允许我们使用现代 ES6 模块语法。现在我们可以创建一个节点脚本,用于设置我们的表。让我们将我们的脚本称为 migrate.js
。
node migrate.js
我不会详细解释这段代码,但它创建了我们保存数据所需的表。它还会创建一个测验、一个问题和四个答案,并将所有这些存储在一个名为 quiz.db
的文件中。保存此文件后,我们可以使用以下命令从命令行运行我们的脚本
如果您愿意,可以使用像 DB Browser for SQLite 这样的工具打开数据库文件,以再次检查数据是否已创建。
更改 JavaScript 的编写方式
让我们编写一些代码来查询我们创建的数据。
// index.js
import sqlite3 from "sqlite3";
let db = new sqlite3.Database("quiz.db");
db.get(`SELECT * FROM quiz WHERE quizid = 0`, (err, row) => {
if (err) {
console.error(err.message);
}
console.log(row);
db.close();
});
创建一个新文件并将其命名为 index.js
。要访问我们的数据库,我们可以导入 sqlite3
,创建一个新的 sqlite3.Database
,并将数据库文件路径作为参数传递。在此数据库对象上,我们可以调用 get
函数,传入一个 SQL 字符串来选择我们的测验,以及一个将在控制台中记录结果的回调
运行这将打印 { quizid: 0, title: 'my quiz' }
在控制台中。
如何不使用回调
现在,让我们将这段代码包装在一个函数中,以便我们可以将 ID 作为参数传递;我们希望通过其 ID 访问任何测验。此函数将返回我们从 db
获取的数据库行对象。
// index.js
// Be warned! This code contains BUGS
import sqlite3 from "sqlite3";
function getQuiz(id) {
let db = new sqlite3.Database("quiz.db");
let result;
db.get(`SELECT * FROM quiz WHERE quizid = ?`, [id], (err, row) => {
if (err) {
return console.error(err.message);
}
db.close();
result = row;
});
return result;
}
console.log(getQuiz(0));
这里是我们开始遇到麻烦的地方。我们不能简单地将对象返回到传递给 db
的回调中并离开。这不会改变我们的外部函数返回的内容。相反,您可能会认为我们可以创建一个变量(让我们将其称为 result
)在外部函数中,并在回调中重新分配此变量。以下是如何尝试此操作
如果您运行此代码,控制台日志将打印 undefined
!发生了什么?
- 我们遇到了我们对 JavaScript 如何运行(从上到下)的预期与异步回调如何运行之间的脱节。上面的示例中的
getQuiz
函数按以下方式运行 - 我们使用
let result;
声明了result
变量。我们没有为这个变量分配任何内容,因此它的值为undefined
。 - 我们调用
db.get()
函数。我们传递一个 SQL 字符串、ID 和回调。但是我们的回调还没有运行!相反,SQLite 包在后台启动一个任务来读取quiz.db
文件。从文件系统读取需要相对较长的时间,因此此 API 允许我们的用户代码移动到下一行,而 Node.js 在后台从磁盘读取。 - 我们的函数返回
result
。由于我们的回调还没有运行,因此result
仍然包含undefined
的值。
SQLite 完成从文件系统的读取并运行我们传递的回调,关闭数据库并将行分配给 result
变量。分配此变量没有任何作用,因为函数已经返回了其结果。
传递回调
我们如何解决这个问题?在 2015 年之前,解决这个问题的方法是使用回调。我们不只将测验 ID 传递给我们的函数,而是传递测验 ID和一个回调,该回调将接收行对象作为参数。
// index.js
import sqlite3 from "sqlite3";
function getQuiz(id, callback) {
let db = new sqlite3.Database("quiz.db");
db.get(`SELECT * FROM quiz WHERE quizid = ?`, [id], (err, row) => {
if (err) {
console.error(err.message);
}
else {
callback(row);
}
db.close();
});
}
getQuiz(0,(quiz)=>{
console.log(quiz);
});
以下是其外观
就是这样。这是一个细微的差别,它迫使您更改用户代码的外观,但现在意味着我们的 console.log
在查询完成后运行。
回调地狱
首先,我将重构getQuiz
函数,使其成为更通用的get
函数,这样我们就可以传入要查询的表和列以及 ID。
不幸的是,我们无法使用(更安全的)SQL 参数来参数化表名,因此我们将改用模板字符串。在生产环境中,您需要对该字符串进行清理,以防止 SQL 注入。
function get(params, callback) {
// In production these strings should be scrubbed to prevent SQL injection
const { table, column, value } = params;
let db = new sqlite3.Database("quiz.db");
db.get(`SELECT * FROM ${table} WHERE ${column} = ${value}`, (err, row) => {
callback(err, row);
db.close();
});
}
另一个问题是,数据库读取可能会出现错误。我们的用户代码需要知道每个数据库查询是否发生错误;否则,它不应该继续查询数据。我们将使用 Node.js 的约定,将错误对象作为回调函数的第一个参数传递。然后,我们可以在继续之前检查是否有错误。
让我们用id
为2
的答案来检查它属于哪个测验。以下是如何使用回调函数实现这一操作:
// index.js
import sqlite3 from "sqlite3";
function get(params, callback) {
// In production these strings should be scrubbed to prevent SQL injection
const { table, column, value } = params;
let db = new sqlite3.Database("quiz.db");
db.get(`SELECT * FROM ${table} WHERE ${column} = ${value}`, (err, row) => {
callback(err, row);
db.close();
});
}
get({ table: "answer", column: "answerid", value: 2 }, (err, answer) => {
if (err) {
console.log(err);
} else {
get(
{ table: "question", column: "questionid", value: answer.answerquestion },
(err, question) => {
if (err) {
console.log(err);
} else {
get(
{ table: "quiz", column: "quizid", value: question.questionquiz },
(err, quiz) => {
if (err) {
console.log(err);
} else {
// This is the quiz our answer belongs to
console.log(quiz);
}
}
);
}
}
);
}
});
哇,嵌套太多了!每次从数据库获取答案后,我们都要添加两层嵌套 - 一层用于检查错误,另一层用于下一个回调函数。随着我们链式连接越来越多的异步调用,我们的代码也变得越来越深。
我们可以通过使用命名函数而不是匿名函数来部分地防止这种情况,这将使嵌套层级降低,但会使我们的代码不太简洁。我们还需要为所有这些中间函数想出名称。幸运的是,Promise 在 2015 年出现在 Node 中,用于帮助解决像这样的链式异步调用问题。
Promise
使用Promise封装异步任务,可以防止之前示例中的大量嵌套。我们可以将回调函数传递给Promise
的then
函数,而不是使用越来越深的嵌套回调函数。
首先,让我们修改get
函数,使其用Promise
封装数据库查询
// index.js
import sqlite3 from "sqlite3";
function get(params) {
// In production these strings should be scrubbed to prevent SQL injection
const { table, column, value } = params;
let db = new sqlite3.Database("quiz.db");
return new Promise(function (resolve, reject) {
db.get(`SELECT * FROM ${table} WHERE ${column} = ${value}`, (err, row) => {
if (err) {
return reject(err);
}
db.close();
resolve(row);
});
});
}
现在,用于搜索答案所属测验的代码可以像这样:
get({ table: "answer", column: "answerid", value: 2 })
.then((answer) => {
return get({
table: "question",
column: "questionid",
value: answer.answerquestion,
});
})
.then((question) => {
return get({
table: "quiz",
column: "quizid",
value: question.questionquiz,
});
})
.then((quiz) => {
console.log(quiz);
})
.catch((error) => {
console.log(error);
}
);
这是一种处理异步代码的好方法。我们不再需要为每次调用单独处理错误,而是可以使用catch
函数来处理链式函数中发生的任何错误。
我们仍然需要编写许多回调函数才能使它正常工作。幸运的是,有一个更新的 API 可以帮助我们!当 Node 7.6.0 发布时,它将 JavaScript 引擎更新为 V8 5.5,其中包括编写ES2017 async
/await
函数 的功能。
Async/Await
使用async
/await
,我们可以像编写同步代码一样编写异步代码。Sarah Drasner 有一篇关于async
/await
的很棒的博文。
当您有一个返回Promise
的函数时,可以在调用它之前使用await
关键字,它将阻止您的代码执行到下一行,直到Promise
解析。由于我们已经将get()
函数重构为返回 Promise,我们只需要更改用户代码
async function printQuizFromAnswer() {
const answer = await get({ table: "answer", column: "answerid", value: 2 });
const question = await get({
table: "question",
column: "questionid",
value: answer.answerquestion,
});
const quiz = await get({
table: "quiz",
column: "quizid",
value: question.questionquiz,
});
console.log(quiz);
}
printQuizFromAnswer();
这看起来更像我们习惯阅读的代码。就在今年,Node 发布了顶层await
。这意味着我们可以通过删除包裹get()
函数调用的printQuizFromAnswer()
函数,使这个示例更加简洁。
现在,我们有了简洁的代码,可以依次执行这些异步任务。我们还可以同时启动其他异步函数(如从文件读取或响应 HTTP 请求),同时等待这段代码运行。这就是所有异步风格的优势。
由于 Node 中有许多异步任务,例如从网络读取或访问数据库或文件系统。理解这些概念尤其重要。它也有一些学习曲线。
充分利用 SQL
有更好的方法!我们可以使用 SQL 获取所有需要的数据,这样就不必担心这些获取每个数据片段的异步调用。我们可以使用 SQL JOIN
查询来实现这一点
// index.js
import sqlite3 from "sqlite3";
function quizFromAnswer(answerid, callback) {
let db = new sqlite3.Database("quiz.db");
db.get(
`SELECT *,a.body AS answerbody, ques.body AS questionbody FROM answer a
INNER JOIN question ques ON a.answerquestion=ques.questionid
INNER JOIN quiz quiz ON ques.questionquiz = quiz.quizid
WHERE a.answerid = ?;`,
[answerid],
(err, row) => {
if (err) {
console.log(err);
}
callback(err, row);
db.close();
}
);
}
quizFromAnswer(2, (e, r) => {
console.log(r);
});
这将返回我们关于答案、问题和测验的所有需要的数据,包含在一个大型对象中。我们还将答案和问题的每个body
列重命名为answerbody
和questionbody
,以区分它们。如您所见,将更多逻辑放到数据库层可以简化您的 JavaScript(以及可能提高性能)。
如果您使用的是 SQLite 等关系型数据库,那么您将学习另一种语言,它有许多不同的功能,可以节省时间和精力,并提高性能。这为编写 Node 添加了更多需要学习的内容。
Node API 和约定
从浏览器代码切换到 Node.js 时,需要学习许多新的 Node API。
任何数据库连接和/或文件系统读取都使用浏览器中没有的 API(至少现在还没有)。我们还有新的 API 用于设置HTTP 服务器。我们可以使用OS 模块对操作系统进行检查,还可以使用Crypto 模块加密数据。此外,要从 Node 发出 HTTP 请求(我们在浏览器中一直这样做),我们没有fetch
或XMLHttpRequest
函数。相反,我们需要导入https
模块。但是,Node.js 存储库中的一个最新请求显示,Node 中的 fetch 即将到来!浏览器 API 和 Node API 之间仍然存在许多不匹配的地方。这是 Deno 试图解决的问题之一。
我们还需要了解 Node 约定,包括package.json
文件。如果使用过构建工具,大多数前端开发人员对此应该很熟悉。如果您想发布一个库,您可能不熟悉的部分是package.json
文件中的main
属性。此属性包含一个指向库入口点的路径。
还有一些约定,例如错误优先回调:Node API 将接受一个回调函数,该回调函数将错误作为第一个参数,将结果作为第二个参数。您可以在我们之前的数据库代码中看到这一点,以及下面使用readFile
函数时的示例。
import fs from 'fs';
fs.readFile('myfile.txt', 'utf8' , (err, data) => {
if (err) {
console.error(err)
return
}
console.log(data)
})
不同类型的模块
之前,我随意地指示您在package.json
文件中添加"type":"module"
,以便代码示例正常工作。Node 创建于 2009 年,创建者需要一个模块系统,但 JavaScript 规范中不存在这样的系统。他们提出了Common.js 模块来解决这个问题。2015 年,一个模块规范被引入 JavaScript,导致 Node.js 的模块系统与原生 JavaScript 模块不同。经过 Node 团队的艰苦努力,我们现在可以在 Node 中使用这些原生 JavaScript 模块。
不幸的是,这意味着许多博客文章和资源将使用旧的模块系统。这也意味着许多 npm 包将不会使用原生 JavaScript 模块,有时还会有使用原生 JavaScript 模块的库,但它们以不兼容的方式使用!
其他问题
在编写 Node 时,我们还需要考虑一些其他问题。如果您运行的是 Node 服务器,并且出现了致命异常,服务器将终止,并将停止响应任何请求。这意味着,如果您在 Node 服务器上犯了一个足够严重的错误,那么您的应用程序将对所有人失效。这与客户端 JavaScript 不同,在客户端 JavaScript 中,边缘情况会导致致命错误,但一次只影响一个用户,而且该用户可以选择刷新页面。
安全是我们应该在前端就担心的问题,包括跨站点脚本和跨站点请求伪造。但是,后端服务器的攻击面更广,存在漏洞,包括暴力破解攻击和 SQL 注入。如果您使用 Node 存储和访问人们的信息,那么您有责任保护他们的数据安全。
结论
Node 是一个利用你的 JavaScript 技能构建服务器和命令行工具的好方法。JavaScript 是一种我们习惯编写、用户友好的语言。而 Node 的异步优先特性意味着你可以快速完成并发任务。但入门时需要学习很多新东西。以下是我希望在开始之前就看到的资源。
- 异步 JavaScript (MDN)
- 理解异步等待 (Sarah Drasner)
- Node.js 简介 (Node.js 文档)
- Node 入门 (Jamie Corkhill)
- 原始 Node.js 演示 (Ryan Dahl)
- 原生 JavaScript 模块 (Node.js 文档)
如果你打算将数据存储在 SQL 数据库中,请阅读有关SQL 基础知识。
为什么你使用 SQLite 作为数据库来完成你的技术栈,而不是 MongoDB?因为 MongoDB 看起来是 Node.JS 的更明显选择。
嗨,Mark!
选择 SQLite 的主要原因是在使用它时,无需安装数据库服务器。:) 你只需使用 npm 包和文件系统。此外,了解一些 SQL 也许很有用,即使它不是 Node.js 的更明显选择。
但现在你提到了,也许如果我在本文中使用 MongoDB,这篇文章将更能代表编写节点代码的感觉。