Node.js学习笔记

学习来源:黑马程序员

学习时间:2022年3月1日

1 初始Node.js

1.1 回顾与思考

  • 浏览器中的 JavaScript 的组成部分

image-20220131230125013

  • 为什么 JavaScript 可以在浏览器中被执行

image-20220131230144731

不同的浏览器使用不同的 JavaScript 解析引擎

  1. Chrome 浏览器 => V8
  2. Firefox 浏览器 => OdinMonkey(奥丁猴)
  3. Safri 浏览器 => JSCore
  4. IE 浏览器 => Chakra(查克拉)

其中,Chrome 浏览器的 V8 解析引擎性能最好。

  • 为什么 JavaScript 可以操作 DOM 和 BOM

image-20220131230249986

每个浏览器都内置了 DOM、BOM 这样的 API 函数,因此,浏览器中的 JavaScript 才可以调用它们。

  • 浏览器中的 JavaScript 运行环境

运行环境是指代码正常运行所需的必要环境

image-20220131230348753

① V8 引擎负责解析和执行 JavaScript 代码。

② 内置 API 是由运行环境提供的特殊接口,只能在所属的运行环境中被调用。

  • JavaScript 可以做后端开发吗

可以,利用Node.js这个运行环境!

1.2 Node.js简介

什么是Node.js

Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行环境

Node.js 的官网地址: https://nodejs.org/zh-cn/

  • Node.js 中的 JavaScript 运行环境

image-20220131230715089

注意:

  1. 浏览器是 JavaScript 的前端运行环境
  2. Node.js 是 JavaScript 的后端运行环境
  3. Node.js 中无法调用 DOM 和 BOM 等浏览器内置 API。
  • Node.js 可以做什么

Node.js 作为一个 JavaScript 的运行环境,仅仅提供了基础的功能和 API。然而,基于 Node.js 提供的这些基础功能,很多强大的工具和框架如雨后春笋,层出不穷,所以学会了 Node.js ,可以让前端程序员胜任更多的工作和岗位:

① 基于 Express 框架(http://www.expressjs.com.cn/),可以快速构建 Web 应用

② 基于 Electron 框架(https://electronjs.org/),可以构建跨平台的桌面应用

③ 基于 restify 框架(http://restify.com/),可以快速构建 API 接口项目

④ 读写和操作数据库、创建实用的命令行工具辅助前端开发、etc…

1.3 安装

前往官网下载:

image-20220131231035158

  1. LTS 为长期稳定版,对于追求稳定性的企业级项目来说,推荐安装 LTS 版本的 Node.js。
  2. Current 为新特性尝鲜版,对热衷于尝试新特性的用户来说,推荐安装 Current 版本的 Node.js。但是,Current 版本中可能存在隐藏的 Bug 或安全性漏洞,因此不推荐在企业级项目中使用 Current 版本的 Node.js。

查看版本

1
node -v

image-20220131232717173

1.4 运行js文件

在目录下编写js代码,命名为1.js

1
console.log("Hello world!")

打开终端,进入到该目录下(快捷方式为:按住shift,再点击右键),输入:

1
node 1.js

image-20220131233746985

2 fs文件系统模块

2.1 概述

fs 模块是 Node.js 官方提供的、用来操作文件的模块。它提供了一系列的方法和属性,用来满足用户对文件的操作需求。

例如:

  • fs.readFile() 方法,用来读取指定文件中的内容

  • fs.writeFile() 方法,用来向指定的文件中写入内容

如果要在 JavaScript 代码中,使用 fs 模块来操作文件,则需要使用如下的方式先导入它:

1
const fs = require("fs")

2.2 读取文件

2.2.1 语法格式

使用fs.readFile()方法,可以读取指定文件中的内容,语法格式如下:

1
fs.readFile(path[, options], callback)
  • path:必选参数,字符串,表示文件的路径。
  • options:可选参数,表示以什么编码格式来读取文件。
  • callback:必选参数,文件读取完成后,通过回调函数拿到读取的结果。

2.2.2 程序示例

新建文件夹files,并新建文件test.txt,以 utf8 的编码格式,读取指定文件的内容,并打印 err 和 dataStr 的值。

1
2
3
4
5
6
7
8
9
10
// 1.导入fs模块
const fs = require("fs")
// 2.调用readFile函数
fs.readFile("./files/test.txt", "utf8", function(err, dataStr) {
// 读取成功,err为null
// 否则err的值为错误对象,dataStr的值为undefined
console.log(err)
console.log("---------")
console.log(dataStr)
})

执行结果

image-20220201000007790

失败的情况:

image-20220201000035274

  • 利用err判断是否读取成功:可以判断 err 对象是否为 null,从而知晓文件读取的结果
1
2
3
4
5
6
7
8
9
// 1.导入fs模块
const fs = require("fs")
// 2.调用readFile函数
fs.readFile("./files/test1.txt", "utf8", function(err, dataStr) {
if(err) {
return console.log("读取文件失败" + err.message)
}
console.log("读取文件成功:" + dataStr)
})

执行结果

image-20220201000443022

2.3 写入文件

2.3.1 语法格式

使用 fs.writeFile() 方法,可以向指定的文件中写入内容,语法格式如下:

1
fs.writeFile(file, data[, option], callback);
  • file:必选参数,需要指定一个文件路径的字符串,表示文件的存放路径。
  • data:必选参数,表示要写入的内容。
  • option:可选参数,表示以什么格式写入文件内容,默认值是 utf8。
  • callback:必选参数,文件写入完成后的回调函数。

2.3.2 程序示例

1
2
3
4
5
6
7
8
const fs = require("fs");

// 写入文件
fs.writeFile("./test.txt", "Hello World", function(err) {
// 如果文件写入成功,err为null
// 否则为错误对象
console.log(err);
});

执行结果:

image-20220301222354840

  • 可以判断 err 对象是否为 null,从而知晓文件写入的结果:
1
2
3
4
5
6
7
8
9
const fs = require("fs");

// 写入文件
fs.writeFile("./test.txt", "Hello World", function(err) {
if(err) {
return console.log("文件写入失败" + err.message);
}
console.log("文件写入成功");
});

2.4 路径动态拼接问题

在使用 fs 模块操作文件时,如果提供的操作路径是以 ./../ 开头的相对路径时,很容易出现路径动态拼接错误的问题。

原因:代码在运行的时候,会以执行 node 命令时所处的目录,动态拼接出被操作文件的完整路径。

问题演示

js文件和要读取的文件test.txt所在目录为F:\workplace\frontend_study\nodejs

  • js文件
1
2
3
4
5
6
7
8
const fs = require("fs");

fs.readFile("./test.txt", "utf8" ,function(err, data) {
if(err) {
return console.log("读取文件失败" + err.message);
}
console.log(data);
});

执行,读取成功:

image-20220301223622592

  • 如果cd到上一级目录F:\workplace\frontend_study\,再进行读取,会报错。

image-20220301223715574

原因在于,代码在运行的时候,会以执行 node 命令时所处的目录,动态拼接出被操作文件的完整路径。即:读取文件的路径被动态拼接为:F:\workplace\frontend_study\test.txt,所以会找不到文件。

解决方法

在使用 fs 模块操作文件时,直接提供完整的路径,不要提供./../开头的相对路径,从而防止路径动态拼接的问题。

  • __dirname:表示当前js文件所处的目录,例如当前__dirname的值为:F:\workplace\frontend_study\nodejs
1
2
3
4
5
6
7
8
9
const fs = require("fs");

// __dirname表示当前js文件所处的目录
fs.readFile(__dirname + "/test.txt", "utf8" ,function(err, data) {
if(err) {
return console.log("读取文件失败" + err.message);
}
console.log(data);
});

image-20220301224710994

2.5 实践案例

2.5.1 需求

将目录下的 index.html 页面,拆分成三个文件,分别是:

  • index.css
  • index.js
  • index.html

并且将拆分出来的 3 个文件,存放到 clock 目录中。

2.5.2 实现

步骤①:导入需要的模块并创建正则表达式

1
2
3
4
5
const fs = require("fs");
const path = require("path");
// 定义正则表达式,分别匹配<style></style>和<script></script>
const regStyle = /<style>[\s\S]*<\/style>/;
const regScript = /<script>[\s\S]*<\/script>/;

步骤②:使用 fs 模块读取需要被处理的 html 文件

1
2
3
4
5
6
7
8
9
10
// 读取文件
fs.readFile(path.join(__dirname, "./index.html"), "utf8", (err, data) => {
if(err) {
console.log("读取文件失败" + err.message);
}
// 调用三个方法,分别拆解出css,js,html
resolveCSS(data);
resolveJS(data);
resolveHTML(data);
});

步骤③:自定义 resolveCSS 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
// 定义处理css样式的方法
function resolveCSS(htmlStr) {
const r1 = regStyle.exec(htmlStr);
// 将提取出来的样式字符串,进行字符串的replace操作
const newCSS = r1[0].replace("<style>", "").replace("</style>", "");
// 写入文件
fs.writeFile(path.join(__dirname, "./clock/index.css"), newCSS, err => {
if(err) {
return console.log("写入css文件错误" + err.message);
}
console.log("写入css文件成功");
});
}

步骤④:自定义 resolveJS 方法,参见步骤③

步骤⑤:自定义 resolveHTML 方法

1
2
3
4
5
6
7
8
9
10
11
12
// 定义处理html的方法
function resolveHTML(htmlStr) {
// 将字符串调用replace,把内嵌的style和script标签替换为外联的link和script标签
const newHTML = htmlStr.replace(regStyle, '<link rel="stylesheet" href="./index.css"/>').replace(regScript, '<script src="./index.js"></script>');
// 写入文件
fs.writeFile(path.join(__dirname, "./clock/index.html"), newHTML, err => {
if(err) {
console.log("写入文件失败" + err.message);
}
console.log("写入文件成功");
});
}

执行结果:

image-20220307163257847

2.5.3 注意点

  • fs.writeFile() 方法只能用来创建文件,不能用来创建路径
  • 重复调用 fs.writeFile() 写入同一个文件,新写入的内容会覆盖之前的旧内容

3 path路径模块

3.1 概述

path 模块是 Node.js 官方提供的、用来处理路径的模块。它提供了一系列的方法和属性,用来满足用户对路径的处理需求。

例如:

  • path.join()方法,用来将多个路径片段拼接成一个完整的路径字符串
  • path.basename() 方法,用来从路径字符串中,将文件名解析出来

如果要在 JavaScript 代码中,使用 path 模块来处理路径,则需要使用如下的方式先导入它:

1
const path = require("path");

3.2 路径拼接

3.2.1 语法格式

使用 path.join() 方法,可以把多个路径片段拼接为完整的路径字符串:

1
path.join([...paths]);
  • ...paths<string>路径片段的序列
  • 返回值:<string>

3.2.2 程序示例

1
2
3
4
5
6
7
8
const path = require("path");

// 注意:../会抵消前面的一层路径 ./不会
let pathStr = path.join("/a", "/b/c", "../", "./d", "/e");
console.log(pathStr); // \a\b\d\e

let pathStr2 = path.join("/a", "/b/c", "../../", "./d", "/e");
console.log(pathStr2); // \a\d\e

注意:今后凡是涉及到路径拼接的操作,都要使用 path.join() 方法进行处理。不要直接使用 + 进行字符串的拼接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let pathStr3 = path.join(__dirname, "./test.txt");
console.log(pathStr3); // F:\workplace\frontend_study\nodejs\test.txt
fs.readFile(pathStr3, "utf8", function(err, data) {
if(err) {
console.log("文件读取失败" + err.message);
}
console.log(data);
});

// 或者
fs.readFile(path.join(__dirname, "./test.txt"), "utf8", function(err, data) {
if(err) {
console.log("文件读取失败" + err.message);
}
console.log(data);
});

3.3 获取路径中的文件名

3.3.1 语法格式

使用 path.basename() 方法,可以获取路径中的最后一部分,经常通过这个方法获取路径中的文件名,语法格式如下:

1
path.basename(path[, ext]);
  • path:必选参数,表示一个路径的字符串
  • ext:可选参数,表示文件扩展名
  • 返回: 表示路径中的最后一部分

3.3.2 程序示例

1
2
3
4
5
6
7
8
9
const path = require("path");

// 定义文件的存放路径
const fpath = "/a/b/c/index.html";
const fullname = path.basename(fpath);
console.log(fullname); // index.html

const nameWithoutExt = path.basename(fpath, ".html");
console.log(nameWithoutExt); // index

3.4 获取路径中的文件扩展名

3.4.1 语法格式

使用 path.extname() 方法,可以获取路径中的扩展名部分,语法格式如下:

1
path.extname(path);
  • path:表示一个路径的字符串
  • 返回值:返回得到的扩展名字符串

3.4.2 程序示例

1
2
3
4
5
6
const path = require("path");

const fpath = "/a/b/c/index.html";

const fext = path.extname(fpath);
console.log(fext); // .html

4 http模块

4.1 概述

什么是http模块

http 模块是 Node.js 官方提供的、用来创建 web 服务器的模块。通过 http 模块提供的 http.createServer() 方法,就能方便的把一台普通的电脑,变成一台 Web 服务器,从而对外提供 Web 资源服务。

如果要希望使用 http 模块创建 Web 服务器,则需要先导入它:

1
const http = require("http");

进一步理解 http 模块的作用

服务器和普通电脑的区别在于,服务器上安装了 web 服务器软件,例如:IIS、Apache 等。通过安装这些服务器软件,就能把一台普通的电脑变成一台 web 服务器。

在 Node.js 中,我们不需要使用 IIS、Apache 等这些第三方 web 服务器软件。因为我们可以基于 Node.js 提供的http 模块,通过几行简单的代码,就能轻松的手写一个服务器软件,从而对外提供 web 服务。

4.2 创建最基本的 web 服务器

4.2.1 基本步骤

  • 导入 http 模块
  • 创建 web 服务器实例
  • 为服务器实例绑定 request 事件,监听客户端的请求
  • 启动服务器

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 1.导入http模块
const http = require("http");

// 2.创建web服务器实例
const server = http.createServer();

// 3.为服务器实例绑定request事件,监听客户端的请求
server.on("request", function(req, res) {
// 当访问该url时,调用该回调函数
console.log("Someone visits our web server...");
});

// 4.启动服务器
server.listen(8080, function() {
// 当服务器时,调用该回调函数
console.log("server running at http://127.0.0.1:8080");
});

执行结果:

1
2
3
4
PS F:\workplace\frontend_study\nodejs> node .\10-创建最基本的web服务器.js
server running at http://127.0.0.1:8080
// 当访问http://127.0.0.1:8080时,下面的语句输出
Someone visits our web server...

4.2.2 req请求对象

只要服务器接收到了客户端的请求,就会调用通过 server.on() 为服务器绑定的 request 事件处理函数。

如果想在事件处理函数中,访问与客户端相关的数据属性,可以使用如下的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const http = require("http");

const server = http.createServer();

// req是请求对象,包含了请求端(客户端)相关的属性和数据
server.on("request", (req) => {
// 请求url
const url = req.url;
// 请求方式
const method = req.method;
// 这里使用了es6的模板字符串
const str = `Your request url is ${url}, and request method is ${method}`;
console.log(str);
});

// 4.启动服务器
server.listen(80, () => {
// 当服务器时,调用该回调函数
console.log("server running at http://127.0.0.1");
});

执行结果:

1
2
3
4
5
6
PS F:\workplace\frontend_study\nodejs> node .\11-req请求对象.js
server running at http://127.0.0.1
Your request url is /, and request method is GET

// 如果在网页中输入http://127.0.0.1/index.html
Your request url is /index.html, and request method is GET

4.2.3 res响应对象

在服务器的 request 事件处理函数中,如果想访问与服务器相关的数据属性,可以使用如下的方式:res.end(...)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const http = require("http");

const server = http.createServer();

// req是请求对象,包含了请求端(客户端)相关的属性和数据
server.on("request", (req, res) => {
// 请求url
const url = req.url;
// 请求方式
const method = req.method;
// 这里使用了es6的模板字符串
const str = `Your request url is ${url}, and request method is ${method}`;
console.log(str);

// 调用res.end(),向客户端响应一些内容
res.end(str);
});

// 4.启动服务器
server.listen(80, () => {
// 当服务器时,调用该回调函数
console.log("server running at http://127.0.0.1");
});

执行结果:

image-20220302224908933

4.2.4 解决中文乱码问题

当调用 res.end() 方法,向客户端发送中文内容的时候,会出现乱码问题,此时,需要手动设置内容的编码格式:设置响应头

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const http = require("http");

const server = http.createServer();

// req是请求对象,包含了请求端(客户端)相关的属性和数据
server.on("request", (req, res) => {
const str = `你的请求地址是 ${req.url}, 请求方式为 ${req.method}`;

// 设置响应头
res.setHeader("content-Type", "text/html; charset=utf-8");
res.end(str);
});

// 4.启动服务器
server.listen(80, () => {
// 当服务器时,调用该回调函数
console.log("server running at http://127.0.0.1");
});

执行结果:

image-20220302225340772

4.3 根据不同的 url 响应不同的 html 内容

4.3.1 实现步骤

  1. 获取请求的 url 地址
  2. 设置默认的响应内容为 404 Not found
  3. 判断用户请求的是否为 / 或 /index.html 首页
  4. 判断用户请求的是否为 /about.html 关于页面
  5. 设置 Content-Type 响应头,防止中文乱码
  6. 使用 res.end() 把内容响应给客户端

4.3.2 程序实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const http = require("http");

const server = http.createServer();

// req是请求对象,包含了请求端(客户端)相关的属性和数据
server.on("request", (req, res) => {
// 1.获取请求的url地址
const url = req.url;

// 2.设置默认的相应内容为 404 not found
let content = "<h1>404 NOT FOUND</h1>";

// 3.判断用户请求是否为 / 或 /index.html 或 /about.html
if(url === "/" || url === "/index.html") {
content = "<h1>首页</h1>";
} else if(url === "/about.html") {
content = "<h1>关于</h1>";
}

// 4.设置响应头
res.setHeader("Content-Type", "text/html; charset=utf-8");

// 5.响应
res.end(content);
});

// 4.启动服务器
server.listen(80, () => {
// 当服务器时,调用该回调函数
console.log("server running at http://127.0.0.1");
});

执行结果:

  • 首页

image-20220302230322843

  • 关于

image-20220302230338617

  • 404

image-20220302230354762

4.4 实践案例

4.4.1 需求

需求:实现clock时钟的web服务器

核心思路

把文件的实际存放路径,作为每个资源的请求 url 地址。

image-20220307163817034

4.4.2 实现

步骤①:导入需要的模块

1
2
3
const http = require("http");
const fs = require("fs");
const path = require("path");

步骤②:创建基本的 web 服务器

1
2
3
4
5
6
7
8
9
// 创建web服务器
const server = http.createServer();
server.on("request", (req, res) => {

});

server.listen(80, () => {
console.log("server running at http://127.0.0.1");
});

步骤③:将资源的请求 url 地址映射为文件的存放路径

  • 请求url的格式:/clock/index.html
1
2
3
4
5
6
server.on("request", (req, res) => {
// 获取客户端的请求url
const url = req.url;
// 把请求的url映射为具体文件的存放路径
const fpath = path.join(__dirname, url);
});

步骤④:读取文件的内容并响应给客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
server.on("request", (req, res) => {
// 获取客户端的请求url
const url = req.url;
// 把请求的url映射为具体文件的存放路径
const fpath = path.join(__dirname, url);
// 读取文件
fs.readFile(fpath, "utf8", (err, data) => {
if(err) {
return res.end("<h1>404 NOT FOUND</h1>");
}
// 读取成功,将内容响应给客户端
res.end(data);
});
});

访问:http://127.0.0.1/clock/index.html

image-20220307170010759

步骤⑤:优化资源的请求路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
server.on("request", (req, res) => {
// 获取客户端的请求url
const url = req.url;
// 把请求的url映射为具体文件的存放路径
// const fpath = path.join(__dirname, url);
let fpath = '';
// 如果用户访问的是根路径
if(url === "/") {
fpath = path.join(__dirname, "./clock/index.html");
} else {
fpath = path.join(__dirname, "./clock", url);
}

// 读取文件
fs.readFile(fpath, "utf8", (err, data) => {
if(err) {
return res.end("<h1>404 NOT FOUND</h1>");
}
// 读取成功,将内容响应给客户端
res.end(data);
});
});

5 模块化

5.1 概述

模块化是指解决一个复杂问题时,自顶向下逐层把系统划分成若干模块的过程。对于整个系统来说,模块是可组合、分解和更换的单元。

好处

  1. 提高了代码的复用性
  2. 提高了代码的可维护性
  3. 可以实现按需加载

模块化规范

模块化规范就是对代码进行模块化的拆分与组合时,需要遵守的那些规则。

例如:

  • 使用什么样的语法格式来引用模块
  • 在模块中使用什么样的语法格式向外暴露成员

5.2 Nodejs模块化

5.2.1 模块分类

Node.js 中根据模块来源的不同,将模块分为了 3 大类,分别是:

  • 内置模块(内置模块是由 Node.js 官方提供的,例如 fs、path、http 等)
  • 自定义模块(用户创建的每个 .js 文件,都是自定义模块)
  • 第三方模块(由第三方开发出来的模块,并非官方提供的内置模块,也不是用户创建的自定义模块,使用前需要先下载)

5.2.2 加载模块

使用强大的 require() 方法,可以加载需要的内置模块、用户自定义模块、第三方模块进行使用。例如:

1
2
3
4
5
6
// 加载内置模块
const fs = require("fs");
// 加载自定义模块
const custom = require("./js/custom.js");
// 加载第三方模块
const moment = require("moment");

注意:使用 require() 方法加载其它模块时,会执行被加载模块中的代码。

5.2.3 模块作用域

定义:和函数作用域类似,在自定义模块中定义的变量、方法等成员,只能在当前模块内被访问,这种模块级别的访问限制,叫做模块作用域

  • 新建模块custom.js
1
2
3
4
5
const username = "Hongyi";

function sayHello() {
console.log("Hello, I'm" + username);
}
  • 新建js文件,导入上述模块
1
2
const custom = require("./custom")
console.log(custom);

执行结果:

1
{}

说明无法访问到custom.js模块中的私有成员

模块作用域好处

  • 防止了全局变量污染的问题

5.2.4 向外共享模块作用域中的成员

① module对象
  • module对象:在每个 .js 自定义模块中都有一个 module 对象,它里面存储了和当前模块有关的信息,打印如下:
1
2
3
4
5
6
7
const username = "Hongyi";

function sayHello() {
console.log("Hello, I'm" + username);
}

console.log(module);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Module {
id: '.',
path: 'E:\\develop\\study\\frontend_study\\nodejs',
exports: {}, // exports属性,就是该模块向外暴露的内容
parent: null,
filename: 'E:\\develop\\study\\frontend_study\\nodejs\\custom.js',
loaded: false,
children: [],
paths: [
'E:\\develop\\study\\frontend_study\\nodejs\\node_modules',
'E:\\develop\\study\\frontend_study\\node_modules',
'E:\\develop\\study\\node_modules',
'E:\\develop\\node_modules',
'E:\\node_modules'
]
}

其中的exports属性(也是一个对象),就是该模块向外暴露的内容。

在自定义模块中,可以使用 module.exports 对象,将模块内的成员共享出去,供外界使用。

外界用 require() 方法导入自定义模块时,得到的就是 module.exports 所指向的对象

  • 新建自定义模块
1
2
3
4
5
6
7
8
9
// 在一个自定义模块中,默认情况下,module.exports = {}

// 向module.exports对象上挂载一个属性
module.exports.username = "Hongyi";

// 向module.exports对象上挂载一个方法
module.exports.sayHello = function() {
console.log("Hello, I'm", this.username);
}
  • 新建js文件,引入上面的模块
1
2
3
4
5
const m = require("./18-自定义模块.js");

console.log(m);
console.log(m.username);
m.sayHello();

打印结果:

1
2
3
{ username: 'Hongyi', sayHello: [Function] }
Hongyi
Hello, I'm Hongyi
② 共享成员时的注意点

使用 require() 方法导入模块时,导入的结果,永远以 module.exports 指向的对象为准

1
2
3
4
5
6
7
8
9
10
11
12
13
module.exports.username = "Hongyi";

module.exports.sayHello = function() {
console.log("Hello, I'm", this.username);
}

// 让 module.exports 指向一个新的对象
module.exports = {
nickname: "Mark",
sayHi() {
console.log("Hi, I'm", this.nickname);
}
}

引入该模块:

1
2
const m = require("./18-自定义模块.js");
console.log(m);

打印结果:

1
{ nickname: 'Mark', sayHi: [Function: sayHi] }
③ exports对象

由于 module.exports 单词写起来比较复杂,为了简化向外共享成员的代码,Node 提供了 exports 对象。默认情况下,exports 和 module.exports 指向同一个对象。最终共享的结果,还是以 module.exports 指向的对象为准。

1
console.log(exports === module.exports); // true

时刻谨记,require() 模块时,得到的永远是 module.exports 指向的对象。

④ 使用误区

为了防止混乱,建议大家不要在同一个模块中同时使用 exports 和 module.exports。

  • 情形1:
1
2
3
4
5
6
7
8
9
10
11
12
13
const username = "Hongyi";
// 向exports对象上挂载属性和方法
exports.username = username;
exports.age = 24;
exports.sayHello = function() {
console.log("Hello World");
};

// 向module.exports对象上挂载属性和方法
module.exports.nickname = "Mark";
module.exports.sayHi = function() {
console.log("Hi World");
};
1
2
const m = require("./19-exports对象");
console.log(m);

打印结果:

1
2
3
4
5
6
7
{
username: 'Hongyi',
age: 24,
sayHello: [Function],
nickname: 'Mark',
sayHi: [Function]
}
  • 情形2:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const username = "Hongyi";
// 向exports对象上挂载属性和方法
exports.username = username;
exports.age = 24;
exports.sayHello = function() {
console.log("Hello World");
};

module.exports = {
nickname: "Mark",
sayHi() {
console.log("Hi World");
}
};

打印结果:

1
2
3
4
{ 
nickname: 'Mark',
sayHi: [Function: sayHi]
}

5.2.5 模块化规范

Node.js 遵循了 CommonJS 模块化规范,CommonJS 规定了模块的特性和各模块之间如何相互依赖。

CommonJS 规定:

  1. 每个模块内部,module 变量代表当前模块。
  2. module 变量是一个对象,它的 exports 属性(即 module.exports)是对外的接口。
  3. 加载某个模块,其实是加载该模块的 module.exports 属性。require() 方法用于加载模块。

5.3 npm与包

5.3.1 什么是包

Node.js 中的第三方模块又叫做包。

不同于 Node.js 中的内置模块与自定义模块,包是由第三方个人或团队开发出来的,免费供所有人使用。

注意:Node.js 中的包都是免费且开源的,不需要付费即可免费下载使用。

为什么需要包

由于 Node.js 的内置模块仅提供了一些底层的 API,导致在基于内置模块进行项目开发的时,效率很低。

包是基于内置模块封装出来的,提供了更高级、更方便的 API,极大的提高了开发效率。

包和内置模块之间的关系,类似于 jQuery 和 浏览器内置 API 之间的关系。

从哪里下载包

国外有一家 IT 公司,叫做 npm, Inc. 这家公司旗下有一个非常著名的网站: https://www.npmjs.com/ ,它是全球最大的包共享平台

到目前位置,全球约 1100 多万的开发人员,通过这个包共享平台,开发并共享了超过 120 多万个包 供我们使用。

npm, Inc. 公司提供了一个地址为 https://registry.npmjs.org/ 的服务器,来对外共享所有的包,我们可以从这个服务器上下载自己所需要的包。

注意:

如何下载包

npm, Inc. 公司提供了一个包管理工具,我们可以使用这个包管理工具,从 https://registry.npmjs.org/ 服务器把需要的包下载到本地使用。

这个包管理工具的名字叫做 Node Package Manager(简称 npm 包管理工具),这个包管理工具随着 Node.js 的安装包一起被安装到了用户的电脑上。

大家可以在终端中执行 npm -v 命令,来查看自己电脑上所安装的 npm 包管理工具的版本号:

1
npm -v

5.3.2 npm初体验

需求:格式化时间

① 传统做法
  • 自定义格式化时间的模块
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 定义格式化时间的方法
function dateFormat(dtStr) {
const dt = new Date(dtStr);

const y = dt.getFullYear();
const m = padZero(dt.getMonth() + 1);
const d = padZero(dt.getDate());

const hh = padZero(dt.getHours());
const mm = padZero(dt.getMinutes());
const ss = padZero(dt.getSeconds());

return `${y}-${m}-${d} ${hh}:${mm}:${ss}`;
}

// 定义补零的函数
function padZero(n) {
return n > 9 ? n : "0" + n;
}

module.exports = {
dateFormat
};
  • 引入该模块即可
1
2
3
4
5
6
// 导入自定义模块
const TIME = require("./20-dateFormat");

const dt = new Date();

console.log(TIME.dateFormat(dt));

打印结果:

1
2022-03-08 19:21:06
② 导入包
  • 使用 npm 包管理工具,在项目中安装格式化时间的包 moment
1
2
3
npm install moment
# 简化写法
npm i moment
  • 初次装包完成后,在项目文件夹下多一个叫做 node_modules 的文件夹和 package-lock.json 的配置文件。

    • node_modules 文件夹用来存放所有已安装到项目中的包。require() 导入第三方包时,就是从这个目录中查找并加载包。
    • package-lock.json 配置文件用来记录 node_modules 目录下的每一个包的下载信息,例如包的名字、版本号、下载地址等。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      {
      "requires": true,
      "lockfileVersion": 1,
      "dependencies": {
      "moment": {
      "version": "2.29.1",
      "resolved": "https://registry.npmmirror.com/moment/-/moment-2.29.1.tgz",
      "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ=="
      }
      }
      }
    • 注意:程序员不要手动修改 node_modules 或 package-lock.json 文件中的任何代码,npm 包管理工具会自动维护它们。

  • 引入该模块,参考 moment 的官方 API 文档对时间进行格式化

1
2
const moment = require("moment");
console.log(moment().format("YYYY-MM-DD HH:mm:ss"));
③ 安装指定版本的包

默认情况下,使用 npm install 命令安装包的时候,会自动安装最新版本的包。如果需要安装指定版本的包,可以在包名之后,通过 @ 符号指定具体的版本,例如:

1
npm i moment@2.29.1
④ 包的语义化版本规范

包的版本号是以“点分十进制”形式进行定义的,总共有三位数字,例如 2.24.0

其中每一位数字所代表的的含义如下:

  • 第1位数字:大版本

  • 第2位数字:功能版本

  • 第3位数字:Bug修复版本

版本号提升的规则:只要前面的版本号增长了,则后面的版本号归零。

5.3.3 包管理配置文件

npm 规定,在项目根目录中,必须提供一个叫做 package.json 的包管理配置文件。用来记录与项目有关的一些配置信息。例如

  • 项目的名称、版本号、描述等
  • 项目中都用到了哪些包
  • 哪些包只在开发期间会用到
  • 那些包在开发和部署时都需要用到
① 多人协作的问题

image-20220308194315178

整个项目的体积是 30.4M;第三方包的体积是 28.8M;项目源代码的体积 1.6M

遇到的问题:第三方包的体积过大,不方便团队成员之间共享项目源代码。

解决方案

解决方案:共享时剔除node_modules

② package.json配置文件

在项目根目录中,创建一个叫做 package.json 的配置文件,即可用来记录项目中安装了哪些包。从而方便剔除 node_modules 目录之后,在团队成员之间共享项目的源代码。

注意:今后在项目开发中,一定要把 node_modules 文件夹,添加到 .gitignore 忽略文件中。

③ 快速创建 package.json

npm 包管理工具提供了一个快捷命令,可以在执行命令时所处的目录中,快速创建 package.json 这个包管理配置文件:

1
2
3
4
5
# 作用:在执行命令所处的目录中,快速创建package.json
# 在项目开始(还没写代码)时执行
npm init --yes
# 简化写法
npm init -y

注意:

  • 上述命令只能在英文的目录下成功运行!所以,项目文件夹的名称一定要使用英文命名,不要使用中文,不能出现空格。
  • 运行 npm install 命令安装包的时候,npm 包管理工具会自动把包的名称和版本号,记录到 package.json 中。
1
2
3
4
5
6
7
8
9
10
11
12
{
"name": "code",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
  • package.json 文件中,有一个 dependencies 节点,专门用来记录使用 npm install 命令安装了哪些包。

安装了三个包之后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"name": "code",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"art-template": "^4.13.2",
"jquery": "^3.6.0",
"moment": "^2.29.1"
}
}
④ 一次性安装所有的包

当我们拿到一个剔除了 node_modules 的项目之后,需要先把所有的包下载到项目中,才能将项目运行起来。

可以运行 npm install 命令(或 npm i)一次性安装所有的依赖包:

1
npm install

执行上述命令时,npm包管理工具会先读取package.json中的dependencies节点,读取到记录的所有依赖包名称和版本号之后,包管理工具会把这些包一次性下载到项目中。

⑤ 卸载包

可以运行 npm uninstall 命令,来卸载指定的包:

1
npm uninstall moment

注意:npm uninstall 命令执行成功后,会把卸载的包,自动从 package.json 的 dependencies 中移除掉。

⑥ devDependencies节点

如果某些包只在项目开发阶段会用到,在项目上线之后不会用到,则建议把这些包记录到 devDependencies 节点中。

与之对应的,如果某些包在开发和项目上线之后都需要用到,则建议把这些包记录到 dependencies 节点中。

可以使用如下的命令,将包记录到 devDependencies 节点中:

1
2
3
npm i 包名 -D
# 完整命令
npm install 包名 --save-dev

执行命令后的package文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"name": "code",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": { // 开发和部署依赖
"art-template": "^4.13.2",
"jquery": "^3.6.0"
},
"devDependencies": { // 开发依赖
"moment": "^2.29.1",
"webpack": "^5.70.0"
}
}

5.3.4 包下载速度问题

① 原因

在使用 npm 下包的时候,默认从国外的 https://registry.npmjs.org/ 服务器进行下载,此时,网络数据的传输需要经过漫长的海底光缆,因此下包速度会很慢。

② 淘宝npm镜像服务器

淘宝在国内搭建了一个服务器,专门把国外官方服务器上的包同步到国内的服务器,然后在国内提供下包的服务,从而极大的提高了下包的速度。

image-20220308200427016

image-20220308200447850

③ 切换下包镜像源

下包的镜像源,指的就是下包的服务器地址。

1
2
3
4
5
6
7
8
# 查看当前的下包镜像源
npm config get registry

# 切换至淘宝镜像源
npm config set registry=http://registry.npm.taobao.org/

# 检查镜像源是否切换成功
npm config get registry
④ nrm

为了更方便的切换下包的镜像源,我们可以安装 nrm 这个小工具,利用 nrm 提供的终端命令,可以快速查看和切换下包的镜像源。

1
2
3
4
5
6
7
8
# 通过npm包管理器,将nrm安装为全局可用的工具
npm i nrm -g

# 查看所有可用的镜像源
nrm ls

# 将下包的镜像源切换为淘宝镜像源
nrm use taobao

打印结果:

1
2
3
4
5
6
npm ---------- https://registry.npmjs.org/
yarn --------- https://registry.yarnpkg.com/
tencent ------ https://mirrors.cloud.tencent.com/npm/
cnpm --------- https://r.cnpmjs.org/
taobao ------- https://registry.npmmirror.com/
npmMirror ---- https://skimdb.npmjs.com/registry/

5.3.5 包的分类

使用 npm 包管理工具下载的包,共分为两大类,分别是:

  1. 项目包
  2. 全局包
① 项目包

那些被安装到项目的 node_modules 目录中的包,都是项目包。

项目包又分为两类,分别是:

  • 开发依赖包(被记录到 devDependencies 节点中的包,只在开发期间会用到)
  • 核心依赖包(被记录到 dependencies 节点中的包,在开发期间和项目上线之后都会用到)
② 全局包

在执行 npm install 命令时,如果提供了 -g 参数,则会把包安装为全局包。

1
2
3
4
# 全局安装
npm i moment -g
# 全局卸载
npm uninstall moment -g

全局包会被安装到 C:\Users\用户目录\AppData\Roaming\npm\node_modules 目录下。

image-20220308201428309

注意

  • 只有工具性质的包,才有全局安装的必要性。因为它们提供了好用的终端命令。
  • 判断某个包是否需要全局安装后才能使用,可以参考官方提供的使用说明即可。

5.3.6 规范的包结构

一个规范的包,它的组成结构,必须符合以下 3 点要求:

  1. 包必须以单独的目录而存在
  2. 包的顶级目录下要必须包含 package.json 这个包管理配置文件
  3. package.json 中必须包含 nameversionmain 这三个属性,分别代表包的名字、版本号、包的入口。

5.3.7 开发属于自己的包

要求实现的功能:格式化日期;转义 HTML 中的特殊字符;还原 HTML 中的特殊字符

① 初始化包的基本结构
  • 新建 tools 文件夹,作为包的根目录
  • tools 文件夹中,新建如下三个文件:
    • package.json (包管理配置文件)
    • index.js (包的入口文件)
    • README.md (包的说明文档)
② 初始化package.json
1
2
3
4
5
6
7
8
{
"name": "hongyi-tools", // 不一定和文件夹的名称相同
"version": "1.0.0",
"main": "index.js",
"description": "提供了格式化时间,HTMLEscape的功能",
"keywords": ["hongyi", "dateFormat", "HTMLescape"],
"license": "ISC"
}
③ 定义格式化时间和处理HTML的方法

index.js中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// 定义格式化时间的方法
function dateFormat(dtStr) {
const dt = new Date(dtStr);

const y = dt.getFullYear();
const m = padZero(dt.getMonth() + 1);
const d = padZero(dt.getDate());

const hh = padZero(dt.getHours());
const mm = padZero(dt.getMinutes());
const ss = padZero(dt.getSeconds());

return `${y}-${m}-${d} ${hh}:${mm}:${ss}`;
}

// 定义补零的函数
function padZero(n) {
return n > 9 ? n : "0" + n;
}

// 转义html的方法
function htmlEscape(htmlStr) {
return htmlStr.replace(/<|>|"|&/g, (match) => {
switch(match) {
case "<":
return "&lt;"
case ">":
return "&gt;"
case '"':
return "&quot;"
case "&":
return "&amp;"
}
});
}

// 还原html的方法
function htmlUnescape(htmlStr) {
return htmlStr.replace(/&lt;|&gt;|&quot;|&amp;/g, (match) => {
switch(match) {
case "&lt;":
return "<"
case "&gt;":
return ">"
case '&quot;':
return '"'
case "&amp;":
return "&"
}
});
}

module.exports = {
dateFormat,
htmlEscape,
htmlUnescape
};
④ 模块化拆分
  • 将格式化时间的功能,拆分到 src/dateFormat.js

  • 将处理 HTML 字符串的功能,拆分到 src/htmlEscape.js

  • 在 index.js 中,导入两个模块,得到需要向外共享的方法

  • 在 index.js 中,使用 module.exports 把对应的方法共享出去

1
2
3
4
5
6
7
8
const dateFormat = require("./src/dateFormat");
const htmlEscape = require("./src/htmlEscape");

// 利用扩展运算符,展开对象中的属性并放入exports对象中
module.exports = {
...dateFormat,
...htmlEscape
};

测试:

1
2
3
4
5
6
7
8
const dateFormat = require("./tools");
const htmlEscape = require("./tools");

const date = dateFormat.dateFormat(new Date());
const htmlStr = '<h1 title="abc">这是h1标签<span>123&nbsp</span></h1>';

console.log(date);
console.log(htmlEscape.htmlEscape(htmlStr));

打印结果:

1
2
2022-03-08 22:40:20
&lt;h1 title=&quot;abc&quot;&gt;这是h1标签&lt;span&gt;123&amp;nbsp&lt;/span&gt;&lt;/h1&gt;
⑤ 编写包的说明文档

包根目录中的 README.md 文件,是包的使用说明文档。通过它,我们可以事先把包的使用说明,以 markdown 的格式写出来,方便用户参考。

README 文件中具体写什么内容,没有强制性的要求;只要能够清晰地把包的作用、用法、注意事项等描述清楚即可。

我们所创建的这个包的 README.md 文档中,会包含以下 6 项内容:

安装方式、导入方式、格式化时间、转义 HTML 中的特殊字符、还原 HTML 中的特殊字符、开源协议

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
## 安装
```
npm install hongyi-tools
​```

## 导入方式
```js
const hongyi = require("hongyi-tools);
​```

## 格式化时间
```js
const date = dateFormat.dateFormat(new Date());
console.log(date);
​```

## 转义html中的特殊字符
```js
const htmlStr = '<h1 title="abc">这是h1标签<span>123&nbsp</span></h1>';
console.log(htmlEscape.htmlEscape(htmlStr));
​```

## 开源协议
ISC

5.3.8 发布包

① 注册npm账号

网址:https://www.npmjs.com/

② 登录npm账号

npm 账号注册完成后,可以在终端中执行 npm login 命令,依次输入用户名、密码、邮箱后,即可登录成功。

1
npm login

注意:在运行 npm login 命令之前,必须先把下包的服务器地址切换为 npm 的官方服务器,否则会导致发布包失败。

1
nrm use npm
③ 发布包

将终端切换到包的根目录之后,运行 npm publish 命令,即可将包发布到 npm 上(注意:包名不能雷同)。

1
npm publish

控制台输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
npm notice
npm notice package: hongyi-tools@1.0.0
npm notice === Tarball Contents ===
npm notice 519B src/dateFormat.js
npm notice 781B src/htmlEscape.js
npm notice 239B index.js
npm notice 230B package.json
npm notice 413B README.md
npm notice === Tarball Details ===
npm notice name: hongyi-tools
npm notice version: 1.0.0
npm notice package size: 1.1 kB
npm notice unpacked size: 2.2 kB
npm notice shasum: e5f65bae915367f5336b7aff884dc89911fc661a
npm notice integrity: sha512-tJeXEAz1DaYx3[...]ImRT3h+XXSrOA==
npm notice total files: 5
npm notice
+ hongyi-tools@1.0.0

发布结果:

image-20220308230559135

④ 删除发布的包

运行 npm unpublish 包名 --force 命令,即可从 npm 删除已发布的包。

1
npm unpublish hongyi-tools --force

注意

  • npm unpublish 命令只能删除 72 小时以内发布的包
  • npm unpublish 删除的包,在 24 小时内不允许重复发布
  • 发布包的时候要慎重,尽量不要往 npm 上发布没有意义的包!

5.4 模块加载机制

5.4.1 缓存加载

优先从缓存中加载

模块在第一次加载后会被缓存。 这也意味着多次调用 require() 不会导致模块的代码被执行多次。

注意:不论是内置模块、用户自定义模块、还是第三方模块,它们都会优先从缓存中加载,从而提高模块的加载效率。

5.4.2 模块加载

内置模块的加载机制

内置模块是由 Node.js 官方提供的模块,内置模块的加载优先级最高。

例如,require(‘fs’) 始终返回内置的 fs 模块,即使在 node_modules 目录下有名字相同的包也叫做 fs。

自定义模块的加载机制

使用 require() 加载自定义模块时,必须指定以./../开头的路径标识符。在加载自定义模块时,如果没有指定 ./../这样的路径标识符,则 node 会把它当作内置模块或第三方模块进行加载。

同时,在使用 require() 导入自定义模块时,如果省略了文件的扩展名,则 Node.js 会按顺序分别尝试加载以下的文件:

  • 按照确切的文件名进行加载
  • 补全 .js 扩展名进行加载
  • 补全 json 扩展名进行加载
  • 补全 .node 扩展名进行加载
  • 加载失败,终端报错

第三方模块的加载机制

如果传递给 require() 的模块标识符不是一个内置模块,也没有以 ./../开头,则 Node.js 会从当前模块的父目录开始,尝试从 /node_modules 文件夹中加载第三方模块。

如果没有找到对应的第三方模块,则移动到再上一层父目录中,进行加载,直到文件系统的根目录。

例如,假设在 C:\Users\itheima\project\foo.js 文件里调用了 require(‘tools’),则 Node.js 会按以下顺序查找:

  • C:\Users\itheima\project\node_modules\tools
  • C:\Users\itheima\node_modules\tools
  • C:\Users\node_modules\tools
  • C:\node_modules\tools

目录作为模块

当把目录作为模块标识符,传递给 require() 进行加载的时候,有三种加载方式:

  1. 在被加载的目录下查找一个叫做 package.json 的文件,并寻找 main 属性,作为 require() 加载的入口
  2. 如果目录里没有 package.json 文件,或者 main 入口不存在或无法解析,则 Node.js 将会试图加载目录下的 index.js 文件。
  3. 如果以上两步都失败了,则 Node.js 会在终端打印错误消息,报告模块的缺失:Error: Cannot find module 'xxx'

6 Express

6.1 概述

官方给出的概念:Express 是基于 Node.js 平台,快速、开放、极简的 Web 开发框架。

通俗的理解:Express 的作用和 Node.js 内置的 http 模块类似,是专门用来创建 Web 服务器的

Express 的本质:就是一个 npm 上的第三方包,提供了快速创建 Web 服务器的便捷方法。

理解

  • 思考1:不使用 Express 能否创建 Web 服务器?
    • 答案:能,使用 Node.js 提供的原生 http 模块即可。
  • 思考2:有了 http 内置模块,为什么还有用 Express?

    • 答案:http 内置模块用起来很复杂,开发效率低;Express 是基于内置的 http 模块进一步封装出来的,能够极大的提高开发效率。
  • 思考3:http 内置模块与 Express 是什么关系?

    • 答案:类似于浏览器中 Web API 和 jQuery 的关系。后者是基于前者进一步封装出来的

作用

对于前端程序员来说,最常见的两种服务器,分别是:

  1. Web 网站服务器:专门对外提供 Web 网页资源的服务器。
  2. API 接口服务器:专门对外提供 API 接口的服务器。

使用 Express,我们可以方便、快速的创建 Web 网站的服务器或 API 接口的服务器。

6.2 基本使用

6.2.1 安装

在项目所处的目录中,运行如下的终端命令,即可将 express 安装到项目中使用:

1
npm i express@4.17.1 # 版本号可以不指定

6.2.2 创建基本的Web服务器

1
2
3
4
5
6
7
8
9
const express = require("express");

// 创建web服务器
const app = express();

// 启动服务器
app.listen(80, () => {
console.log("express server running at http://127.0.0.1");
});
① 监听请求和内容响应
  • 通过 app.get() 方法,可以监听客户端的 GET 请求

  • 通过 app.post() 方法,可以监听客户端的 POST 请求

  • 通过 res.send() 方法,可以把处理好的内容,发送给客户端
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const express = require("express");

// 创建web服务器
const app = express();

// 监听客户端的get和post请求,并响应具体内容
app.get("/user", (req, res) => {
// 向客户端响应一个对象
res.send({
name: "Hongyi",
age: 24,
gender: "男"
});
});

app.post("/user", (req, res) => {
res.send("请求成功");
});


// 启动服务器
app.listen(80, () => {
console.log("express server running at http://127.0.0.1");
});

利用postman请求即可,略。

② 获取 URL 中携带的查询参数

通过 req.params 对象,可以访问到 URL 中,通过:匹配到的动态参数:

1
2
3
4
5
6
7
app.get("/", (req, res) => {
// req.query默认是一个空对象
// 客户端使用 ?name=Hongyi&age=24这种查询字符串形式,发送到服务器的参数
// 可以通过req.query对象访问到
console.log(req.query);
res.send(req.query);
});
1
{ name: 'Hongyi', age: '24' }
③ 获取 URL 中的动态参数

通过 req.params 对象,可以访问到 URL 中,通过:匹配到的动态参数:

1
2
3
4
5
6
app.get("/user/:id/:name", (req, res) => {
// req.query默认是一个空对象
// 里面存放着通过 : 动态匹配到的参数值
console.log(req.params);
res.send(req.params);
});

例如向http://127.0.0.1/user/1/Hongyi发送请求,则打印:

1
{ id: '1', name: 'Hongyi' }

客户端获取到:

1
2
3
4
{
"id": "1",
"name": "Hongyi"
}

6.3 托管静态资源

6.3.1 express.static()

express 提供了一个非常好用的函数,叫做 express.static(),通过它,我们可以非常方便地创建一个静态资源服务器。

注意:Express 在指定的静态目录中查找文件,并对外提供资源的访问路径。因此,存放静态文件的目录名不会出现在 URL 中

例如将之前的时钟代码的文件夹clock放在当前项目文件夹下:

1
2
3
4
5
6
7
8
const express = require("express");
const app = express();

app.use(express.static("./clock"));

app.listen(80, () => {
console.log("express server running at http://127.0.0.1");
});

然后访问:注意,没有clock前缀

  • http://127.0.0.1/index.html
  • http://127.0.0.1/index.css
  • http://127.0.0.1/index.js

image-20220311123300606

6.3.2 托管多个静态资源目录

如果要托管多个静态资源目录,可以多次调用 express.static() 函数:

1
2
app.use(express.static("./clock"));
app.use(express.static("./public"));

访问静态资源文件时,express.static() 函数会根据目录的添加顺序查找所需的文件。后添加的后加载!

6.3.3 挂载路径前缀

如果希望在托管的静态资源访问路径之前,挂载路径前缀,则可以使用如下的方式:

1
2
3
app.use("/clock", express.static("./clock"));
// 或者更长的前缀
app.use("/public/clock", express.static("./clock"));

现在访问就必须按照以下路径:

  • http://127.0.0.1/clock/index.html
  • http://127.0.0.1/clock/index.js
  • http://127.0.0.1/clock/index.css

6.4 路由

6.4.1 概述

广义上来讲,路由就是映射关系。在 Express 中,路由指的是客户端的请求与服务器处理函数之间的映射关系。

Express 中的路由分 3 部分组成,分别是请求的类型METHOD、请求的 URL 地址PATH、处理函数HANDLER,格式如下:

1
app.METHOD(PATH, HANDLER)

路由的例子

例如之前的监听get和post请求,都是路由:

1
2
3
4
5
6
7
8
9
10
11
12
app.get("/user", (req, res) => {
// 向客户端响应一个对象
res.send({
name: "Hongyi",
age: 24,
gender: "男"
});
});

app.post("/user", (req, res) => {
res.send("请求成功");
});

路由的匹配过程

每当一个请求到达服务器之后,需要先经过路由的匹配,只有匹配成功之后,才会调用对应的处理函数。

在匹配时,会按照路由的顺序进行匹配,如果请求类型和请求的 URL 同时匹配成功,则 Express 会将这次请求,转交给对应的 function 函数进行处理。

image-20220311123840630

路由匹配的注意点:

  • 按照定义的先后顺序进行匹配
  • 请求类型和请求的URL同时匹配成功,才会调用对应的处理函数

6.4.2 路由使用

① 最简单的用法

在 Express 中使用路由最简单的方式,就是把路由挂载到 app 上,示例代码如下:

1
2
3
4
// 挂载路由
app.get("/", (req, res) => { res.send("请求成功"); });

app.post("/", (req, res) => { res.send("请求成功"); });
② 模块化路由

为了方便对路由进行模块化的管理,Express 不建议将路由直接挂载到 app 上,而是推荐将路由抽离为单独的模块。

将路由抽离为单独模块的步骤如下:

  1. 创建路由模块对应的 .js 文件
  2. 调用 express.Router() 函数创建路由对象
  3. 向路由对象上挂载具体的路由
  4. 使用 module.exports 向外共享路由对象
  5. 使用 app.use() 函数注册路由模块

代码示例

  • 路由模块
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 这是路由模块
const express = require("express");

// 创建路由对象
const router = express.Router()

// 挂载具体的路由
router.get("/user/list", (req, res) => {
res.send("Get user list");
});

router.post("/user/add", (req, res) => {
res.send("Add new user");
});

// 向外导出这个路由对象
module.exports = router
  • 其他模块注册路由模块
1
2
3
4
5
6
7
8
9
10
11
12
const express = require("express");
// 导入路由模块
const userRouter = require("./4-router");
const app = express();

// 注册路由模块
// use用于注册全局中间件(下面会介绍)
app.use(userRouter);

app.listen(80, () => {
console.log("express server running at http://127.0.0.1");
});

使用postman调用http://127.0.0.1/user/listhttp://127.0.0.1/user/add即可:

image-20220311124934086

为路由模块添加前缀

类似于托管静态资源时,为静态资源统一挂载访问前缀一样,路由模块添加前缀的方式也非常简单:

1
2
3
// 导入路由模块
const userRouter = require("./4-router");
app.use("/api", userRouter);

调用http://127.0.0.1/api/user/list即可

6.5 中间件

6.5.1 概述

中间件(Middleware ),特指业务流程的中间处理环节。

当一个请求到达 Express 的服务器之后,可以连续调用多个中间件,从而对这次请求进行预处理。

image-20220311125207460

Express 的中间件,本质上就是一个 function 处理函数,Express 中间件的格式如下:

image-20220311125223771

注意:中间件函数的形参列表中,必须包含 next 参数。而路由处理函数中只包含 req 和 res。

next函数的作用

next 函数是实现多个中间件连续调用的关键,它表示把流转关系转交给下一个中间件或路由

image-20220311125313139

6.5.2 中间件初体验

① 定义中间件函数

可以通过如下的方式,定义一个最简单的中间件函数:

1
2
3
4
5
6
// 定义一个中间件函数
const mw = function(req, res, next) {
console.log("这是一个中间件函数");
// 把流转关系,转交给下一个中间件或路由
next();
};
② 全局生效的中间件

客户端发起的任何请求,到达服务器之后,都会触发的中间件,叫做全局生效的中间件。

通过调用 app.use(中间件函数),即可定义一个全局生效的中间件,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const express = require("express");
const app = express();

// 定义一个中间件函数
const mw = function(req, res, next) {
console.log("这是一个中间件函数");
// 把流转关系,转交给下一个中间件或路由
next();
};

// 将mw注册为全局生效的中间件
app.use(mw);

app.get("/", (req, res) => {
console.log("调用了 / 这个路由");
res.send("Home page");
});

app.post("/user", (req, res) => {
console.log("调用了 /user 这个路由");
res.send("User page");
});


app.listen(80, () => {
console.log("express server running at http://127.0.0.1");
});

执行结果:

1
2
3
express server running at http://127.0.0.1
这是一个中间件函数
调用了 /user 这个路由

简化形式

1
2
3
4
5
6
7
8
9
10
11
// 将mw注册为全局生效的中间件
app.use(function(req, res, next) {
console.log("这是一个中间件函数");
next();
});

// 或者使用箭头函数
add.use((req, res, next) => {
// code...
next();
});
③ 中间件的作用

多个中间件之间,共享同一份 req res。基于这样的特性,我们可以在上游的中间件中,统一为 req 或 res 对象添加自定义的属性或方法,供下游的中间件或路由进行使用。

image-20220314104243198

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const express = require('express');
const app = express();

app.use((req, res, next) => {
// 获取到请求到达服务器的时间
const time = Date.now();
// 为req对象挂载自定义属性,从而把时间共享给后面的所有路由
req.startTime = time;
next();
});


app.get("/", (req, res) => {
res.send("Home page." + req.startTime);
});

app.post("/user", (req, res) => {
res.send("User page." + req.startTime);
});

app.listen(80, () => {
console.log('express server running at http://127.0.0.1');
});

执行结果:

1
Home page.1647226050588
④ 定义多个全局中间件

可以使用 app.use() 连续定义多个全局中间件。客户端请求到达服务器之后,会按照中间件定义的先后顺序依次进行调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const express = require('express');
const app = express();

app.use((req, res, next) => {
console.log("调用了第一个全局中间件");
next();
});

app.use((req, res, next) => {
console.log("调用了第二个全局中间件");
next();
});

app.use((req, res, next) => {
console.log("调用了第三个全局中间件");
next();
});

app.get("/user", (req, res) => {
res.send("User page.");
});

app.listen(80, () => {
console.log('express server running at http://127.0.0.1');
});

执行结果:

1
2
3
调用了第一个全局中间件
调用了第二个全局中间件
调用了第三个全局中间件
⑤ 局部生效的中间件

不使用 app.use() 定义的中间件,叫做局部生效的中间件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const express = require('express');
const app = express();


// 定义中间件函数
const mw = (req, res, next) => {
console.log("调用了局部生效的中间件");
next();
};

// 创建路由
// mw这个中间件只在“当前路由中生效”,这种用法属于“局部生效的中间件”
app.get("/", mw, (req, res) => {
res.send("Home page");
});

app.get("/user", (req, res) => {
res.send("User page");
});


app.listen(80, () => {
console.log('express server running at http://127.0.0.1');
});
  • 当调用/接口时,打印结果:
1
调用了局部生效的中间件
  • 当调用/user接口时,控制台没有输出

定义多个局部中间件

可以在路由中,通过如下两种等价的方式,使用多个局部中间件:中间件调用顺序是从左到右的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const express = require('express');
const app = express();

// 定义中间件函数
const mw1 = (req, res, next) => {
console.log("调用了局部生效的中间件1");
next();
};

const mw2 = (req, res, next) => {
console.log("调用了局部生效的中间件2");
next();
};

const mw3 = (req, res, next) => {
console.log("调用了局部生效的中间件3");
next();
};

// 创建路由
// 第一种形式:逗号分隔
app.get("/", mw1, mw2, mw3, (req, res) => {
res.send("Home page");
});

// 第二种形式:数组
// app.get("/", [mw1, mw2, mw3], (req, res) => {
// res.send("Home page");
// });

app.get("/user", (req, res) => {
res.send("User page");
});

app.listen(80, () => {
console.log('express server running at http://127.0.0.1');
});

执行结果:

1
2
3
调用了局部生效的中间件1
调用了局部生效的中间件2
调用了局部生效的中间件3
⑥ 注意事项
  1. 一定要在路由之前注册中间件
  2. 客户端发送过来的请求,可以连续调用多个中间件进行处理
  3. 执行完中间件的业务代码之后,不要忘记调用 next() 函数
  4. 为了防止代码逻辑混乱,调用 next() 函数后不要再写额外的代码
  5. 连续调用多个中间件时,多个中间件之间,共享 req 和 res 对象

6.5.3 中间件的分类

为了方便大家理解和记忆中间件的使用,Express 官方把常见的中间件用法,分成了 5 大类,分别是:

  1. 应用级别的中间件
  2. 路由级别的中间件
  3. 错误级别的中间件
  4. Express 内置的中间件
  5. 第三方的中间件
① 应用级别的中间件

通过 app.use()app.get()app.post() ,绑定到 app 实例上的中间件,叫做应用级别的中间件。

例如上一小节的全局和局部中间件。

② 路由级别的中间件

绑定到 express.Router() 实例上的中间件,叫做路由级别的中间件。它的用法和应用级别中间件没有任何区别。只不过,应用级别中间件是绑定到 app 实例上,路由级别中间件绑定到 router 实例上

1
2
3
4
5
6
7
8
9
10
11
12
const app = express();
// 获取Router实例
const router = express.Router();

// 路由级别的中间件
router.use((req, res, next) => {
console.log("Time: " + Date.now());
});

app.use("/", router, (req, res) => {
res.send("请求成功");
});
③ 错误级别的中间件

错误级别中间件的作用:专门用来捕获整个项目中发生的异常错误,从而防止项目异常崩溃的问题。

格式:错误级别中间件的 function 处理函数中,必须有 4 个形参,形参顺序从前到后,分别是 (err, req, res, next)

注意:错误级别的中间件,必须注册在所有路由之后!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const express = require('express');
const app = express();

app.get("/", (req, res) => {
// 人为抛出一个错误
throw new Error("服务器内部发生了错误");
res.send("Home page");
});

// 定义错误级别的中间件,捕获整个项目的异常错误,防止程序崩溃
app.use((err, req, res, next) => {
console.log("发生了错误: " + err.message);
res.send("Error: " + err.message);
});

app.listen(80, () => {
console.log('express server running at http://127.0.0.1');
});

控制台:

1
发生了错误: 服务器内部发生了错误

postman:

1
Error: 服务器内部发生了错误
④ Express内置的中间件

自 Express 4.16.0 版本开始,Express 内置了 3 个常用的中间件,极大的提高了 Express 项目的开发效率和体验:

  1. express.static 快速托管静态资源的内置中间件,例如: HTML 文件、图片、CSS 样式等(无兼容性)
  2. express.json 解析 JSON 格式的请求体数据(有兼容性,仅在 4.16.0+ 版本中可用)
  3. express.urlencoded 解析 URL-encoded 格式的请求体数据(有兼容性,仅在 4.16.0+ 版本中可用)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const express = require('express');
const app = express();

// 配置解析 application/json 格式数据的内置中间件
app.use(express.json());
// 配置解析 application/x-www-urlencoded 格式数据的内置中间件
app.use(express.urlencoded({extended: false}));

app.post("/user", (req, res) => {
// 接收请求体数据
console.log(req.body);
res.send("OK");
});

app.listen(80, () => {
console.log('express server running at http://127.0.0.1');
});
  • 发送json数据

image-20220314113223574

控制台:

1
{ name: 'Hongyi', age: 20 }
  • 发送表单数据

image-20220314113306824

控制台:

1
[Object: null prototype] { name: 'Hongyi', age: '25' }
⑤ 第三方的中间件

非 Express 官方内置的,而是由第三方开发出来的中间件,叫做第三方中间件。在项目中,大家可以按需下载并配置第三方中间件,从而提高项目的开发效率。

例如:在 express@4.16.0 之前的版本中,经常使用 body-parser 这个第三方中间件,来解析请求体数据。使用步骤如下:

  1. 运行 npm install body-parser 安装中间件
  2. 使用 require 导入中间件
  3. 调用 app.use() 注册并使用中间件

注意:Express 内置的 express.urlencoded 中间件,就是基于 body-parser 这个第三方中间件进一步封装出来的。

1
2
3
4
// 导入中间件
const parser = require("body-parser");
// 注册中间件
app.use(parser.urlencoded({ extended: false }));

6.5.4 自定义中间件

① 需求描述

自己手动模拟一个类似于 express.urlencoded 这样的中间件,来解析 POST 提交到服务器的表单数据。

② 定义中间件

使用 app.use() 来定义全局生效的中间件:

1
2
3
4
// 解析表单数据的中间件
app.use((req, res, next) => {
// 业务逻辑...
});
③ 监听req的data事件

在中间件中,需要监听 req 对象的 data 事件,来获取客户端发送到服务器的数据。

如果数据量比较大,无法一次性发送完毕,则客户端会把数据切割后,分批发送到服务器。所以 data 事件可能会触发多次,每一次触发 data 事件时,获取到数据只是完整数据的一部分,需要手动对接收到的数据进行拼接。

1
2
3
4
5
6
7
8
9
10
11
// 解析表单数据的中间件
app.use((req, res, next) => {
// str存储请求体数据
let str = '';
// 监听req的data事件
req.on("data", (chunk) => {
str += chunk;
});
// ...
next();
});
④ 监听req的end事件

当请求体数据接收完毕之后,会自动触发 req 的 end 事件。

因此,我们可以在 req 的 end 事件中,拿到并处理完整的请求体数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 解析表单数据的中间件
app.use((req, res, next) => {
// str存储请求体数据
let str = '';
// 监听req的data事件
req.on("data", (chunk) => {
str += chunk;
});
// 监听req的end事件
req.on("end", () => {
// 此时str是完整的请求体数据
console.log(str);
// ...
});
next();
});

app.post("/user", (req, res) => {
res.send("OK");
});
⑤ 使用 querystring 模块解析请求体数据

Node.js 内置了一个 querystring 模块,专门用来处理查询字符串。通过这个模块提供的 parse() 函数,可以轻松把查询字符串解析成对象的格式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 导入模块
const qs = require("querystring");

// 解析表单数据的中间件
app.use((req, res, next) => {
// other codes...
// 监听req的end事件
req.on("end", () => {
// 将字符串解析为对象
const body = qs.parse(str);
console.log(body);
});
next();
});

执行结果:

1
{ name: 'Hongyi', age: '25' }
⑥ 将解析出来的数据对象挂载为 req.body

上游的中间件和下游的中间件及路由之间,共享同一份 req 和 res。因此,我们可以将解析出来的数据,挂载为 req 的自定义属性,命名为 req.body,供下游使用。

1
2
3
4
5
6
7
8
// 监听req的end事件
req.on("end", () => {
const body = qs.parse(str);
// 挂载为req的body属性
req.body = body;
// 将外面的next放在这里
next();
});
⑦ 将自定义中间件封装为模块

为了优化代码的结构,我们可以把自定义的中间件函数,封装为独立的模块。

  • 自定义模块custom-body-parser.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 导入querystring模块
const qs = require("querystring");

// 解析表单数据的中间件
const bodyParser = (req, res, next) => {
// str存储请求体数据
let str = '';
// 监听req的data事件
req.on("data", (chunk) => {
str += chunk;
});
// 监听req的end事件
req.on("end", () => {
const body = qs.parse(str);
req.body = body;
console.log(body);
next();
});
};
// 暴露接口
module.exports = bodyParser;
  • 引入该模块,并注册为组件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const express = require('express');
const app = express();
// 导入自定义模块
const customBodyParser = require("./custom-body-parser");

// 注册中间件
app.use(customBodyParser);

app.post("/user", (req, res) => {
res.send("OK");
});

app.listen(80, () => {
console.log('express server running at http://127.0.0.1');
});

6.6 接口

6.6.1 代码示例

  • apiRouter.js路由模块
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 路由模块
const express = require("express");
const { appendFile } = require("fs");

const router = express.Router();

// 挂载路由
// GET请求
router.get("/get", (req, res) => {
// 获取查询字符串
const query = req.query;
// 响应客户端
res.send({
status: 0, // 0表示处理成功 1表示处理失败
msg: "GET请求成功",
data: query
});
});

// POST请求
router.post("/post", (req, res) => {
// 获取请求体,假设为表单数据
const body = req.body;
res.send({
status: 0,
msg: "POST请求成功",
data: body
});
});

// 导出路由模块
module.exports = router;
  • 服务端代码
1
2
3
4
5
6
7
8
9
10
11
const express = require('express');
const app = express();
// 导入路由模块
const router = require("./apiRouter");
// 注册解析请求体数据的中间件
app.use(express.urlencoded({extended: false}));
// 注册路由模块
app.use("/api", router);
app.listen(80, () => {
console.log('express server running at http://127.0.0.1');
});

6.6.2 CORS跨域资源共享

① 接口的跨域问题

刚才编写的 GET 和 POST接口,存在一个很严重的问题:不支持跨域请求。

演示

  • html代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script>
<title>Document</title>
</head>
<body>
<button id="btnGET">GET</button>
<button id="btnPOST">POST</button>
<script>
// 通过jquery发送ajax请求
$("#btnGET").on("click", () => {
$.ajax({
type: "GET",
url: "http://127.0.0.1/api/get",
data: {
name: "Hongyi",
age: 24
},
success: res => {
console.log(res);
}
})
});
$("#btnPOST").on("click", () => {
$.ajax({
type: "POST",
url: "http://127.0.0.1/api/post",
data: {
name: "Mark",
age: 36
},
success: res => {
console.log(res);
}
})
});
</script>
</body>
</html>

启动服务器,发送请求,报错:

1
Access to XMLHttpRequest at 'http://127.0.0.1/api/get?name=Hongyi&age=24' from origin 'null' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

解决接口跨域问题的方案主要有两种:

  • CORS(主流的解决方案,推荐使用)
  • JSONP(有缺陷的解决方案:只支持 GET 请求)
② 使用 cors 中间件解决跨域问题

cors 是 Express 的一个第三方中间件。通过安装和配置 cors 中间件,可以很方便地解决跨域问题。

首先安装cors模块:

1
npm i cors

服务器端,在注册路由之前,注册cors模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const express = require('express');
const app = express();
// 导入路由模块
const router = require("./apiRouter");
// 注册解析请求体数据的中间件
app.use(express.urlencoded({extended: false}));
// 在注册路由之前注册cors中间件
const cors = require("cors");
app.use(cors());
// 注册路由模块
app.use("/api", router);
app.listen(80, () => {
console.log('express server running at http://127.0.0.1');
});

执行结果:

image-20220314161305621

③ cors概念

CORS (Cross-Origin Resource Sharing,跨域资源共享)由一系列 HTTP 响应头组成,这些 HTTP 响应头决定浏览器是否阻止前端 JS 代码跨域获取资源

浏览器的同源安全策略默认会阻止网页“跨域”获取资源。但如果接口服务器配置了 CORS 相关的 HTTP 响应头,就可以解除浏览器端的跨域访问限制。

image-20220314161431596

image-20220314161437856

注意事项

  • CORS 主要在服务器端进行配置。客户端浏览器无须做任何额外的配置,即可请求开启了 CORS 的接口。
  • CORS 在浏览器中有兼容性。只有支持 XMLHttpRequest Level2 的浏览器,才能正常访问开启了 CORS 的服务端接口(例如:IE10+、Chrome4+、FireFox3.5+)。
④ cors响应头部

Access-Control-Allow-Origin

响应头部中可以携带一个 Access-Control-Allow-Origin 字段,其语法如下:

1
Access-Control-Allow-Origin: <origin> | *

其中,origin 参数的值指定了允许访问该资源的外域 URL。

例如,下面的字段值将只允许来自 http://itcast.cn 的请求:

1
res.setHeader("Access-Control-Allow-Origin", "http://itcast.cn");

如果指定了 Access-Control-Allow-Origin 字段的值为通配符 *,表示允许来自任何域的请求,示例代码如下:

1
res.setHeader("Access-Control-Allow-Origin", "*");

Access-Control-Allow-Headers

默认情况下,CORS 支持客户端向服务器发送如下的 9 个请求头:

Accept、Accept-Language、Content-Language、DPR、Downlink、Save-Data、Viewport-Width、Width 、Content-Type (值仅限于 text/plain、multipart/form-data、application/x-www-form-urlencoded 三者之一)

如果客户端向服务器发送了额外的请求头信息,则需要在服务器端,通过 Access-Control-Allow-Headers 对额外的请求头进行声明,否则这次请求会失败!

1
2
3
// 允许客户端额外向服务器发送Content-Type请求头和X-Custom-Header请求头
// 注意,多个请求头之间用逗号分隔
res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-Custom-Header");

Access-Control-Allow-Methods

默认情况下,CORS 仅支持客户端发起 GET、POST、HEAD 请求。

如果客户端希望通过 PUT、DELETE 等方式请求服务器的资源,则需要在服务器端,通过 Access-Control-Alow-Methods来指明实际请求所允许使用的 HTTP 方法。

1
2
3
4
// 只允许客户端发送POST,GET,DELETE,HEAD请求方法
res.setHeader("Access-Control-Allow-Methods", "POST, GET, DELETE, HEAD");
// 允许所有的请求方法
res.setHeader("Access-Control-Allow-Methods", "*");
⑤ cors请求分类

客户端在请求 CORS 接口时,根据请求方式和请求头的不同,可以将 CORS 的请求分为两大类,分别是:

  • 简单请求
  • 预检请求

简单请求

同时满足以下两大条件的请求,就属于简单请求:

  1. 请求方式:GET、POST、HEAD 三者之一
  2. HTTP 头部信息不超过以下几种字段:无自定义头部字段、Accept、Accept-Language、Content-Language、DPR、Downlink、Save-Data、Viewport-Width、Width 、Content-Type(只有三个值application/x-www-form-urlencoded、multipart/form-data、text/plain)

预检请求

只要符合以下任何一个条件的请求,都需要进行预检请求:

  1. 请求方式为 GET、POST、HEAD 之外的请求 Method 类型,通常为OPTION请求
  2. 请求头中包含自定义头部字段
  3. 向服务器发送了 application/json 格式的数据

在浏览器与服务器正式通信之前,浏览器会先发送 OPTION 请求进行预检,以获知服务器是否允许该实际请求,所以这一次的 OPTION 请求称为“预检请求”。服务器成功响应预检请求后,才会发送真正的请求,并且携带真实数据。

二者区别

简单请求的特点:客户端与服务器之间只会发生一次请求。

预检请求的特点:客户端与服务器之间会发生两次请求,OPTION 预检请求成功之后,才会发起真正的请求。

代码示例

  • 新增DELETE请求:
1
2
3
4
5
6
7
// DELETE请求
router.delete("/delete", (req, res) => {
res.send({
status: 0,
msg: "DELETE请求成功"
});
});
  • html页面
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script>
<title>Document</title>
</head>
<body>
<button id="btnDELETE">DELETE</button>
<script>
// 通过jquery发送ajax请求
$("#btnDELETE").on("click", () => {
$.ajax({
type: "DELETE",
url: "http://127.0.0.1/api/delete",
success: res => {
console.log(res);
}
})
});
</script>
</body>
</html>

执行结果:

image-20220314170004390

6.6.3 JSONP接口

略,详见ajax学习笔记

7 数据库

7.1 数据库基础

数据库基本知识略

7.2 在项目中操作数据库

7.2.1 步骤

  • 安装操作 MySQL 数据库的第三方模块(mysql)
  • 通过 mysql 模块连接到 MySQL 数据库
  • 通过 mysql 模块执行 SQL 语句

image-20220314170233605

7.2.2 安装与配置 mysql 模块

① 安装

mysql 模块是托管于 npm 上的第三方模块。它提供了在 Node.js 项目中连接和操作 MySQL 数据库的能力。

1
npm i mysql
② 配置

在使用 mysql 模块操作 MySQL 数据库之前,必须先对 mysql 模块进行必要的配置,主要的配置步骤如下:

1
2
3
4
5
6
7
8
const mysql = require("mysql");

const db = mysql.createPool({
host: "127.0.0.1",
user: "root",
password: "12345678",
database: "test"
});
③ 测试

调用 db.query() 函数,指定要执行的 SQL 语句,通过回调函数拿到执行的结果:

1
2
3
4
5
db.query("SELECT 1", (err, results) => {
if(err) return console.log(err.message);
// 只要能打印出 [ RowDataPacket { '1': 1 } ] ,说明连接正常
console.log(results);
});

7.2.3 使用 mysql 模块操作 MySQL 数据库

① 查询数据
1
2
3
4
5
6
const sqlStr = "select * from users";
db.query(sqlStr, (err, data) => {
if(err) return console.log(err.message);
// 执行结果为数组
console.log(data);
});

打印结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[
RowDataPacket {
id: 1,
username: 'Hongyi',
password: '123456',
status: '0'
},
RowDataPacket {
id: 2,
username: 'Admin',
password: '123456',
status: '0'
}
]
② 插入数据

需求:向 users 表中新增数据, 其中 username 为 Spider-Man,password 为 pcc321。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 准备插入数据
const user = {
username: "Spider-Man",
password: "pc123"
}
// 待执行的sql语句,?为占位符
const sqlStr = "insert into users (username, password) values (?, ?)";

// 使用数组的形式,依次为占位符指定的具体值
db.query(sqlStr, [user.username, user.password], (err, data) => {
if(err) return console.log(err.message);
// 当影响行数为1时,说明执行成功
if(data.affectedRows === 1) {
console.log("插入数据成功");
}
});

简便方式

向表中新增数据时,如果数据对象的每个属性和数据表的字段一一对应,则可以通过如下方式快速插入数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 准备插入数据
const user = {
username: "Spider-Man",
password: "pc123"
}
// 待执行的sql语句,?为占位符
const sqlStr = "insert into users set ?";

// 使用数组的形式,依次为占位符指定的具体值
db.query(sqlStr, user, (err, data) => {
if(err) return console.log(err.message);
// 当影响行数为1时,说明执行成功
if(data.affectedRows === 1) {
console.log("插入数据成功");
}
});
③ 更新数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 准备更新数据
const user = {
id: 1,
username: "Yiming",
password: "123456789"
}
// 待执行的sql语句,?为占位符
const sqlStr = "update users set username=?, password=? where id=?";

// 使用数组的形式,依次为占位符指定的具体值
db.query(sqlStr, [user.username, user.password, user.id], (err, data) => {
if(err) return console.log(err.message);
// 当影响行数为1时,说明执行成功
if(data.affectedRows === 1) {
console.log("更新数据成功");
}
});

也可以像上一小节那样,采用简便方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 准备更新数据
const user = {
id: 1,
username: "Yiming",
password: "123456789"
}
// 待执行的sql语句,?为占位符
const sqlStr = "update users set ? where id=?";

// 使用数组的形式,依次为占位符指定的具体值
db.query(sqlStr, [user, user.id], (err, data) => {
if(err) return console.log(err.message);
// 当影响行数为1时,说明执行成功
if(data.affectedRows === 1) {
console.log("更新数据成功");
}
});
④ 删除数据

⑤ 标记删除

使用 DELETE 语句,会把真正的把数据从表中删除掉。为了保险起见,推荐使用标记删除的形式,来模拟删除的动作

所谓的标记删除,就是在表中设置类似于 status 这样的状态字段,来标记当前这条数据是否被删除。当用户执行了删除的动作时,我们并没有执行 DELETE 语句把数据删除掉,而是执行了 UPDATE 语句,将这条数据对应的 status 字段标记为删除即可。

代码略。

8 前后端的身份认证

8.1 Web开发模式

目前主流的 Web 开发模式有两种,分别是:

  1. 基于服务端渲染的传统 Web 开发模式
  2. 基于前后端分离的新型 Web 开发模式

8.1.1 服务端渲染

服务端渲染的概念:服务器发送给客户端的 HTML 页面,是在服务器通过字符串的拼接,动态生成的。因此,客户端不需要使用 Ajax 这样的技术额外请求页面的数据。

代码示例

1
2
3
4
5
6
7
8
9
10
11
app.get("/index.html", (req, res) => {
// 1.要渲染的数据
const user = {
name: "Hongyi",
age: 24
};
// 2.服务器端通过字符串的拼接,动态生成html内容
const html = '<h1>姓名: ${user.name}, 年龄: ${user.age}</h1>';
// 3.把生成好的html页面响应给客户端,客户端拿到真实数据的html页面并显示在浏览器上
res.send(html);
})

优缺点

优点:

  • 前端耗时少。因为服务器端负责动态生成 HTML 内容,浏览器只需要直接渲染页面即可。尤其是移动端,更省电。
  • 有利于SEO(搜索引擎优化)。因为服务器端响应的是完整的 HTML 页面内容,所以爬虫更容易爬取获得信息,更有利于 SEO。

缺点:

  • 占用服务器端资源。即服务器端完成 HTML 页面内容的拼接,如果请求较多,会对服务器造成一定的访问压力。
  • 不利于前后端分离,开发效率低。使用服务器端渲染,则无法进行分工合作,尤其对于前端复杂度高的项目,不利于项目高效开发。

8.1.2 前后端分离

前后端分离的概念:前后端分离的开发模式,依赖于 Ajax 技术的广泛应用。简而言之,前后端分离的 Web 开发模式,就是后端只负责提供 API 接口,前端使用 Ajax 调用接口的开发模式。

优缺点

优点:

  • 开发体验好。前端专注于 UI 页面的开发,后端专注于api 的开发,且前端有更多的选择性。
  • 用户体验好。Ajax 技术的广泛应用,极大的提高了用户的体验,可以轻松实现页面的局部刷新。
  • 减轻了服务器端的渲染压力。因为页面最终是在每个用户的浏览器中生成的。

缺点:

  • 不利于 SEO。因为完整的 HTML 页面需要在客户端动态拼接完成,所以爬虫对无法爬取页面的有效信息。(解决方案:利用 Vue、React 等前端框架的 SSR (server side render)技术能够很好的解决 SEO 问题!)

8.1.3 如何选择web开发模式

不谈业务场景而盲目选择使用何种开发模式都是耍流氓。

比如企业级网站,主要功能是展示而没有复杂的交互,并且需要良好的 SEO,则这时我们就需要使用服务器端渲染;

而类似后台管理项目,交互性比较强,不需要考虑 SEO,那么就可以使用前后端分离的开发模式。

另外,具体使用何种开发模式并不是绝对的,为了同时兼顾首页的渲染速度前后端分离的开发效率,一些网站采用了首屏服务器端渲染 + 其他页面前后端分离的开发模式。

8.2 身份认证

8.2.1 概述

身份认证(Authentication)又称“身份验证”、“鉴权”,是指通过一定的手段,完成对用户身份的确认

日常生活中的身份认证随处可见,例如:高铁的验票乘车,手机的密码或指纹解锁,支付宝或微信的支付密码等。

在 Web 开发中,也涉及到用户身份的认证,例如:各大网站的手机验证码登录邮箱密码登录二维码登录等。

不同开发模式下的身份认证

对于服务端渲染和前后端分离这两种开发模式来说,分别有着不同的身份认证方案:

  • 服务端渲染推荐使用 Session 认证机制
  • 前后端分离推荐使用 JWT 认证机制

8.2.2 Session认证机制

① HTTP的无状态性

了解 HTTP 协议的无状态性是进一步学习 Session 认证机制的必要前提。

HTTP 协议的无状态性,指的是客户端的每次 HTTP 请求都是独立的,连续多个请求之间没有直接的关系,服务器不会主动保留每次 HTTP 请求的状态

image-20220314174605788

如何突破无状态性

对于超市来说,为了方便收银员在进行结算时给 VIP 用户打折,超市可以为每个 VIP 用户发放会员卡。

image-20220314174627093

注意:现实生活中的会员卡身份认证方式,在 Web 开发中的专业术语叫做 Cookie

概念

Cookie 是存储在用户浏览器中的一段不超过 4 KB 的字符串。它由一个名称(Name)、一个值(Value)和其它几个用于控制 Cookie 有效期、安全性、使用范围的可选属性组成。

不同域名下的 Cookie 各自独立,每当客户端发起请求时,会自动当前域名下所有未过期的 Cookie 一同发送到服务器。

Cookie的四大特性:自动发送、域名独立、过期时限、4KB限制

在身份验证中的作用

客户端第一次请求服务器的时候,服务器通过响应头的形式,向客户端发送一个身份认证的 Cookie,客户端会自动将 Cookie 保存在浏览器中。

随后,当客户端浏览器每次请求服务器的时候,浏览器会自动将身份认证相关的 Cookie,通过请求头的形式发送给服务器,服务器即可验明客户端的身份。

image-20220314175124399

Cookie 不具有安全性:由于 Cookie 是存储在浏览器中的,而且浏览器也提供了读写 Cookie 的 API,因此 Cookie 很容易被伪造,不具有安全性。因此不建议服务器将重要的隐私数据,通过 Cookie 的形式发送给浏览器。

image-20220314175228636

提高身份认证的安全性

为了防止客户伪造会员卡,收银员在拿到客户出示的会员卡之后,可以在收银机上进行刷卡认证。只有收银机确认存在的会员卡,才能被正常使用。

image-20220314175235359

这种“会员卡 + 刷卡认证”的设计理念,就是 Session 认证机制的精髓。

③ Session的工作原理

image-20220314175302794

④ 在 Express 中使用 Session 认证

安装

在 Express 项目中,只需要安装 express-session 中间件,即可在项目中使用 Session 认证:

1
npm i express-session

配置

express-session 中间件安装成功后,需要通过 app.use() 来注册 session 中间件:

1
2
3
4
5
6
7
8
9
10
// 导入 Session 中间件
const session = require("express-session");
// 配置 Seesion 中间件
app.use(session({
// 该属性可以为任意字符串
secret: "hongyi",
// 下面两个属性是固定写法
resave: false,
saveUninitialized: true
}));

向session中存数据

当 express-session 中间件配置成功后,即可通过 req.session 来访问和使用 session 对象,从而存储用户的关键信息。

1
2
3
4
5
6
7
8
9
10
11
12
// 登录的 API 接口
app.post('/api/login', (req, res) => {
// 判断用户提交的登录信息是否正确
if (req.body.username !== 'admin' || req.body.password !== '000000') {
return res.send({ status: 1, msg: '登录失败' })
}
// 登录成功后的用户信息,保存到 Session 中
req.session.user = req.body;
// 用户的登录状态
req.session.isLogin = true;
res.send({ status: 0, msg: '登录成功' })
})

从session中取数据

可以直接从 req.session 对象上获取之前存储的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 获取用户姓名的接口
app.get('/api/username', (req, res) => {
// 从 Session 中获取用户的名称,响应给客户端
// 判断用户是否登录
if(!req.session.isLogin) {
return res.send({ status: 1, msg: "fail"});
}
res.send({
status: 0,
msg: "success",
username: req.session.user.username
});
})

清空session

调用 req.session.destroy() 函数,即可清空服务器保存的 session 信息。

1
2
3
4
5
6
7
8
9
// 退出登录的接口
app.post('/api/logout', (req, res) => {
// 清空 Session 信息
req.session.destroy();
res.send({
status: 0,
msg: "退出登录成功"
});
})

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// 导入 express 模块
const express = require('express')
// 创建 express 的服务器实例
const app = express()

// 导入 Session 中间件
const session = require("express-session");
// 配置 Seesion 中间件
app.use(session({
// 该属性可以为任意字符串
secret: "hongyi",
// 下面两个属性是固定写法
resave: false,
saveUninitialized: true
}));

// 托管静态页面
app.use(express.static('./pages'))
// 解析 POST 提交过来的表单数据
app.use(express.urlencoded({ extended: false }))

// 登录的 API 接口
app.post('/api/login', (req, res) => {
// 判断用户提交的登录信息是否正确
if (req.body.username !== 'admin' || req.body.password !== '000000') {
return res.send({ status: 1, msg: '登录失败' })
}
// 登录成功后的用户信息,保存到 Session 中
req.session.user = req.body;
// 用户的登录状态
req.session.isLogin = true;
res.send({ status: 0, msg: '登录成功' })
})

// 获取用户姓名的接口
app.get('/api/username', (req, res) => {
// 从 Session 中获取用户的名称,响应给客户端
// 判断用户是否登录
if(!req.session.isLogin) {
return res.send({ status: 1, msg: "fail"});
}
res.send({
status: 0,
msg: "success",
username: req.session.user.username
});
})

// 退出登录的接口
app.post('/api/logout', (req, res) => {
// 清空 Session 信息
req.session.destroy();
res.send({
status: 0,
msg: "退出登录成功"
});
})

// 调用 app.listen 方法,指定端口号并启动web服务器
app.listen(80, function () {
console.log('Express server running at http://127.0.0.1:80')
})

8.2.3 JWT认证机制

① Session 认证的局限性

Session 认证机制需要配合 Cookie 才能实现。由于 Cookie 默认不支持跨域访问,所以,当涉及到前端跨域请求后端接口的时候,需要做很多额外的配置,才能实现跨域 Session 认证。

  • 当前端请求后端接口不存在跨域问题的时候,推荐使用 Session 身份认证机制。

  • 当前端需要跨域请求后端接口的时候,不推荐使用 Session 身份认证机制,推荐使用 JWT 认证机制。

② JWT概念和原理

JWT(JSON Web Token)是目前最流行跨域认证解决方案

image-20220315111853485

总结:用户的信息通过 Token 字符串的形式,保存在客户端浏览器中。服务器通过还原 Token 字符串的形式来认证用户的身份。

③ 组成部分

JWT 通常由三部分组成,分别是 Header(头部)、Payload(有效荷载)、Signature(签名)。

三者之间使用英文的.分隔,格式如下:

1
Header.Payload.Signature

其中:

  • Payload 部分才是真正的用户信息,它是用户信息经过加密之后生成的字符串。
  • Header 和 Signature 是安全性相关的部分,只是为了保证 Token 的安全性。

image-20220315112110592

④ 使用方式

客户端收到服务器返回的 JWT 之后,通常会将它储存在 localStoragesessionStorage 中。

此后,客户端每次与服务器通信,都要带上这个 JWT 的字符串,从而进行身份认证。推荐的做法是把 JWT 放在 HTTP请求头的 Authorization 字段中,格式如下:

1
Authorization: Bearer <token>
⑤ 在 Express 中使用 JWT

1安装

1
npm i jsonwebtoken express-jwt
  • jsonwebtoken 用于生成 JWT 字符串
  • express-jwt 用于将 JWT 字符串解析还原成 JSON 对象

2导入

1
2
3
// 安装并导入 JWT 相关的两个包,分别是 jsonwebtoken 和 express-jwt
const jwt = require("jsonwebtoken");
const expressJWT = require("express-jwt");

3定义secret秘钥

为了保证 JWT 字符串的安全性,防止 JWT 字符串在网络传输过程中被别人破解,我们需要专门定义一个用于加密解密的 secret 密钥。

  • 当生成 JWT 字符串的时候,需要使用 secret 密钥对用户的信息进行加密,最终得到加密好的 JWT 字符串
  • 当把 JWT 字符串解析还原成 JSON 对象的时候,需要使用 secret 密钥进行解密
1
2
// 定义 secret 密钥,建议将密钥命名为 secretKey
const secretKey = "Hongyi";

4在登录成功后生成 JWT 字符串

调用 jsonwebtoken 包提供的 sign() 方法,将用户的信息加密成 JWT 字符串,响应给客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 登录接口
app.post('/api/login', function (req, res) {
// 将 req.body 请求体中的数据,转存为 userinfo 常量
const userinfo = req.body
// 登录失败
if (userinfo.username !== 'admin' || userinfo.password !== '000000') {
return res.send({
status: 400,
message: '登录失败!'
})
}
// 登录成功
// 在登录成功之后,调用 jwt.sign() 方法生成 JWT 字符串。并通过 token 属性发送给客户端
res.send({
status: 200,
message: '登录成功!',
token: jwt.sign({username: userinfo.username}, secretKey, {expiresIn: "30s"}) // 要发送给客户端的 token 字符串
})
})
  • jwt.sign()共三个参数
    • 用户信息对象,这里携带的是用户名,也可以携带其他参数,但不要携带重要的信息,例如密码
    • 加密秘钥
    • 配置对象,例如这里是过期时间

5将 JWT 字符串还原为 JSON 对象

客户端每次在访问那些有权限接口的时候,都需要主动通过请求头中的 Authorization 字段,将 Token 字符串发送到服务器进行身份认证。

此时,服务器可以通过 express-jwt 这个中间件,自动将客户端发送过来的 Token 解析还原成 JSON 对象:

1
2
// 注册将 JWT 字符串解析还原成 JSON 对象的中间件
app.use(expressJWT({secret: secretKey, algorithms: ["HS256"]}).unless({path: [/^\/api\//]}));
  • expressJWT({secret: secretKey}):用来解析Token字符串
  • .unless({path: [/^\/api\//]}):用来指定那些接口不需要访问权限,例如登录和登出

6使用 req.user 获取用户信息

当 express-jwt 这个中间件配置成功之后,即可在那些有权限的接口中,使用 req.user 对象,来访问从 JWT 字符串中解析出来的用户信息了。

1
2
3
4
5
6
7
8
9
// 这是一个有权限的 API 接口
app.get('/admin/getinfo', function (req, res) {
// 使用 req.user 获取用户信息,并使用 data 属性将用户信息发送给客户端
res.send({
status: 200,
message: '获取用户信息成功!',
data: req.user // 要发送给客户端的用户信息
})
})
  • 利用postman向http://127.0.0.1:8888/api/login接口发送请求:

image-20220315122245765

返回的json数据为:

1
2
3
4
5
{
"status": 200,
"message": "登录成功!",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNjQ3MzE4MTA0LCJleHAiOjE2NDczMTgxMzR9.15pD_-L1XNdjdllGuQPOQHffpD0qLvroK94Y1vXlT88"
}
  • 利用postman向http://127.0.0.1:8888/admin/getinfo接口发送请求,该接口具有权限限制,需要在请求头中添加Authorization属性,值为Bearer token序列

image-20220315122613776

返回的结果:

1
2
3
4
5
6
7
8
9
{
"status": 200,
"message": "获取用户信息成功!",
"data": {
"username": "admin",
"iat": 1647318414,
"exp": 1647318444
}
}

7捕获解析 JWT 失败后产生的错误

当使用 express-jwt 解析 Token 字符串时,如果客户端发送过来的 Token 字符串过期不合法,会产生一个解析失败的错误,影响项目的正常运行。我们可以通过 Express 的错误中间件,捕获这个错误并进行相关的处理。

1
2
3
4
5
6
7
8
9
// 使用全局错误处理中间件,捕获解析 JWT 失败后产生的错误
app.use((err, req, res, next) => {
// token解析失败导致的错误
if(err.name === "UnauthorizedError") {
return res.send({ status: 401, message: "无效的Token" });
}
// 其他原因导致的错误
res.send({ status: 500, message: "未知错误" });
});

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// 导入 express 模块
const express = require('express')
// 创建 express 的服务器实例
const app = express()

// 安装并导入 JWT 相关的两个包,分别是 jsonwebtoken 和 express-jwt
const jwt = require("jsonwebtoken");
const expressJWT = require("express-jwt");

// 允许跨域资源共享
const cors = require('cors')
app.use(cors())

// 解析 post 表单数据的中间件
const bodyParser = require('body-parser')
app.use(bodyParser.urlencoded({ extended: false }))

// 定义 secret 密钥,建议将密钥命名为 secretKey
const secretKey = "Hongyi";


// 注册将 JWT 字符串解析还原成 JSON 对象的中间件
app.use(expressJWT({secret: secretKey, algorithms: ["HS256"]}).unless({path: [/^\/api\//]}));


// 登录接口
app.post('/api/login', function (req, res) {
// 将 req.body 请求体中的数据,转存为 userinfo 常量
const userinfo = req.body
// 登录失败
if (userinfo.username !== 'admin' || userinfo.password !== '000000') {
return res.send({
status: 400,
message: '登录失败!'
})
}
// 登录成功
// 在登录成功之后,调用 jwt.sign() 方法生成 JWT 字符串。并通过 token 属性发送给客户端
res.send({
status: 200,
message: '登录成功!',
token: jwt.sign({username: userinfo.username}, secretKey, {expiresIn: "30s"}) // 要发送给客户端的 token 字符串
})
})

// 这是一个有权限的 API 接口
app.get('/admin/getinfo', function (req, res) {
// 使用 req.user 获取用户信息,并使用 data 属性将用户信息发送给客户端
res.send({
status: 200,
message: '获取用户信息成功!',
data: req.user // 要发送给客户端的用户信息
})
})

// 使用全局错误处理中间件,捕获解析 JWT 失败后产生的错误
app.use((err, req, res, next) => {
// token解析失败导致的错误
if(err.name === "UnauthorizedError") {
return res.send({ status: 401, message: "无效的Token" });
}
// 其他原因导致的错误
res.send({ status: 500, message: "未知错误" });
});

// 调用 app.listen 方法,指定端口号并启动web服务器
app.listen(8888, function () {
console.log('Express server running at http://127.0.0.1:8888')
})

9 项目实践

9.1 初始化

9.1.1 创建项目

  • 新建 api_server 文件夹作为项目根目录,并在项目根目录中运行如下的命令,初始化包管理配置文件,并安装express
1
2
npm init -y
npm i express@4.17.1
  • 在项目根目录中新建 app.js 作为整个项目的入口文件,并初始化如下的代码:
1
2
3
4
5
6
7
const express = require('express');
const app = express();


app.listen(3007, () => {
console.log('api server running at http://127.0.0.1:3007');
});

9.1.2 配置cors跨域

  • 安装 cors 中间件:
1
npm i cors@2.8.5
  • app.js 中导入并配置 cors 中间件:
1
2
const cors = require("cors");
app.use(cors());

9.1.3 配置解析表单数据的中间件

通过如下的代码,配置解析 application/x-www-form-urlencoded 格式的表单数据的中间件:

1
app.use(express.urlencoded({ extended: false }))

9.1.4 初始化路由相关的文件夹

  • 在项目根目录中,新建 router 文件夹,用来存放所有的 路由 模块,路由模块中,只存放客户端的请求与处理函数之间的映射关系
  • 在项目根目录中,新建 router_handler 文件夹,用来存放所有的 路由处理函数模块,路由处理函数模块中,专门负责存放每个路由对应的处理函数

9.1.5 初始化用户路由模块

  • 在 router 文件夹中,新建 user.js 文件,作为用户的路由模块,并初始化代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const express = require("express");

const router = express.Router();

// 注册新用户
router.post("/reguser", (req, res) => {
res.send("reguser ok");
});

// 登录
router.post("/login", (req, res) => {
res.send("login ok");
});

module.exports = router;
  • app.js 中,导入并使用 用户路由模块 :
1
2
3
// 导入并注册用户路由模块
const userRouter = require("./router/user");
app.use("/api", userRouter);

9.1.6 抽离用户路由模块中的处理函数

目的:为了保证 路由模块 的纯粹性,所有的 路由处理函数 ,必须抽离到对应的 路由处理函数 模块 中。

  • /router_handler/user.js 中,使用 exports 对象,分别向外共享如下两个 路由处理函

    数:

1
2
3
4
5
6
7
8
9
10
11
// 在这里定义和用户相关的路由处理函数,供/router/user.js模块进行调用

// 注册新用户的处理函数
exports.regUser = (req, res) => {
res.send("reguser ok");
};

// 登录的处理函数
exports.login = (req, res) => {
res.send("login ok");
};
  • /router/user.js 中的代码修改为如下结构:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const express = require("express");

const router = express.Router();

// 导入用户路由处理函数模块
const userHandler = require("../router_handler/user");

// 注册新用户
router.post("/reguser", userHandler.regUser);

// 登录
router.post("/login", userHandler.login);

module.exports = router;

9.2 登录注册

9.2.1 新建 ev_users 表

test 数据库中,新建 ev_users 表。

image-20220315171017789

9.2.2 安装并配置 mysql 模块

在 API 接口项目中,需要安装并配置 mysql 这个第三方模块,来连接和操作 MySQL 数据库。

1
npm i mysql@2.18.1

在项目根目录中新建 /db/index.js 文件,在此自定义模块中创建数据库的连接对象:

1
2
3
4
5
6
7
8
9
10
const mysql = require("mysql");

const db = mysql.createPool({
host: "127.0.0.1",
user: "root",
password: "12345678",
database: "test"
});

module.exports = db;

9.2.3 注册

① 检测表单数据是否合法

判断用户名和密码是否为空。

1
2
3
4
5
6
7
8
9
// 注册新用户的处理函数
exports.regUser = (req, res) => {
const userInfo = req.body;
// 判断数据是否合法
if(!userInfo.username || !userInfo.password) {
return res.send({ status: 1, message: "用户名或密码不能为空"});
}
res.send("reguser ok");
};
② 检测用户名是否被占用
  • 导入数据库操作模块
1
const db = require("../db/index")
  • 定义sql语句,并查询
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 注册新用户的处理函数
exports.regUser = (req, res) => {
// ...

// 定义sql语句,查询用户名是否被占用
const sqlStr = "select * from ev_users where username=?";
db.query(sqlStr, userInfo.username, (err, results) => {
if(err) {
return res.send({ status: 1, message: err.message});
}
// 判断用户名是否被占用
if(results.length > 0) {
return res.send({ status: 1, message: "用户名被占用"});
}
// 后续操作
});
};
③ 加密密码

为了保证密码的安全性,不建议在数据库以 明文 的形式保存用户密码,推荐对密码进行 加密 存储

  • 措施:在当前项目中,使用 bcryptjs 对用户密码进行加密,优点:

    • 加密之后的密码,无法被逆向破解

    • 同一明文密码多次加密,得到的加密结果各不相同,保证了安全性

  • 安装

1
npm i bcryptjs@2.4.3
  • /router_handler/user.js中导入,并加密
1
2
3
4
5
6
7
8
9
10
11
12
const bcrypt = require("bcryptjs");

// 注册新用户的处理函数
exports.regUser = (req, res) => {
// ...
db.query(sqlStr, userInfo.username, (err, results) => {
// ...
// 密码加密
userInfo.passowrd = bcrypt.hashSync(userInfo.password, 10);
console.log(userInfo.passowrd);
});
};

加密结果展示(admin):

1
$2a$10$oOjqN5W6p6iP4ypSTY8jEexKrFcx4Ix9SEUx0hilXW/z6ttLKoNz2
④ 插入新用户

完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 注册新用户的处理函数
exports.regUser = (req, res) => {
const userInfo = req.body;
// 判断数据是否合法
if(!userInfo.username || !userInfo.password) {
return res.send({ status: 1, message: "用户名或密码不能为空"});
}

// 定义sql语句,查询用户名是否被占用
const sqlStr = "select * from ev_users where username=?";
db.query(sqlStr, userInfo.username, (err, results) => {
if(err) {
return res.send({ status: 1, message: err.message});
}
// 判断用户名是否被占用
if(results.length > 0) {
return res.send({ status: 1, message: "用户名被占用"});
}
// 密码加密
userInfo.passowrd = bcrypt.hashSync(userInfo.password, 10);
const sql = "insert into ev_users set ?";
db.query(sql, {username: userInfo.username, password: userInfo.passowrd}, (err, results) => {
if(err) {
return res.send({ status: 1, message: err.message});
}
if(results.affectedRows !== 1) {
return res.send({ status: 1, message: "注册用户失败"});
}
// 注册成功
res.send({ status: 0, message: "注册用户成功" })
});
});
};

9.2.3 代码优化

① 优化res.send

在处理函数中,需要多次调用 res.send() 向客户端响应 处理失败 的结果,为了简化代码,可以手动封装一个 res.cc() 函数

  • 在 app.js 中,所有路由之前,声明一个全局中间件,为 res 对象挂载一个 res.cc() 函数。
1
2
3
4
5
6
7
8
9
10
11
// 一定要在注册路由之前注册
app.use((req, res, next) => {
// 默认status = 1表示请求失败,0为请求成功
res.cc = function(err, status = 1) {
res.send({
status,
message: err instanceof Error ? err.message : err
});
};
next();
});
  • 改造后的user.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 注册新用户的处理函数
exports.regUser = (req, res) => {
const userInfo = req.body;
// 判断数据是否合法
if(!userInfo.username || !userInfo.password) {
return res.cc("用户名或密码不能为空");
}

// 定义sql语句,查询用户名是否被占用
const sqlStr = "select * from ev_users where username=?";
db.query(sqlStr, userInfo.username, (err, results) => {
if(err) {
return res.cc(err);
}
// 判断用户名是否被占用
if(results.length > 0) {
return res.cc("用户名被占用");
}
// 密码加密
userInfo.passowrd = bcrypt.hashSync(userInfo.password, 10);
const sql = "insert into ev_users set ?";
db.query(sql, {username: userInfo.username, password: userInfo.passowrd}, (err, results) => {
if(err) {
return res.cc(err);
}
if(results.affectedRows !== 1) {
return res.cc("注册用户失败");
}
// 注册成功
res.cc("注册用户成功", 0);
});
});
};
② 优化表单数据验证

表单验证的原则:前端验证为辅,后端验证为主,后端永远不要相信前端提交过来的任何内容

在实际开发中,前后端都需要对表单的数据进行合法性的验证,而且,后端做为数据合法性验证的最后一个关口,在拦截非法数据方面,起到了至关重要的作用。

单纯的使用 if...else... 的形式对数据合法性进行验证,效率低下、出错率高、维护性差。因此,推荐使用第三方数据验证模块,来降低出错率、提高验证的效率与可维护性,让后端程序员把更多的精力放在核心业务逻辑的处理上

  • 安装@hapi/joi,为表单中携带的各个数据项,定义验证规则;安装@escook/express-joi中间件,来实现自动对表单数据进行验证的功能
1
2
npm i joi
npm i @escook/express-joi
  • 新建 /schema/user.js 用户信息验证规则模块,并初始化代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const joi = require('joi')

/**
* string() 值必须是字符串
* alphanum() 值只能是包含 a-zA-Z0-9 的字符串
* min(length) 最小长度
* max(length) 最大长度
* required() 值是必填项,不能为 undefined
* pattern(正则表达式) 值必须符合正则表达式的规则
*/

// 用户名的验证规则
const username = joi.string().alphanum().min(1).max(10).required()
// 密码的验证规则
const password = joi
.string()
.pattern(/^[\S]{6,12}$/)
.required()

// 注册和登录表单的验证规则对象
exports.reg_login_schema = {
// 表示需要对 req.body 中的数据进行验证
body: {
username,
password,
},
}
  • 修改 /router/user.js 中的代码如下:
1
2
3
4
5
6
7
8
9
10
11
// 导入验证表单数据的中间件
const expressJoi = require("@escook/express-joi");
// 导入需要的验证规则对象
const {reg_login_schema} = require("../schema/user");


// 注册新用户
// 在注册新用户的路由中,声明局部中间件,对当前请求中携带的数据进行验证
// 数据验证通过后,会把这次请求流转给后面的路由处理函数
// 数据验证失败后,终止后续代码的执行,并抛出一个全局的 Error 错误,进入全局错误级别中间件中进行处理
router.post("/reguser", expressJoi(reg_login_schema), userHandler.regUser);
  • app.js中定义错误级别的中间件
1
2
3
4
5
6
7
8
const joi = require("joi");

// 定义错误级别的中间件(路由模块注册之后)
app.use((err, req, res, next) => {
// 验证失败导致的错误捕获
if(err instanceof joi.ValidationError) return res.cc(err);
res.cc("未知的错误");
})
  • /router_handler/user.js中的以下代码可以删除
1
2
3
if(!userInfo.username || !userInfo.password) {
return res.cc("用户名或密码不能为空");
}

9.2.4 登录

① 检测登录表单的数据是否合法

/router/user.js登录 的路由代码修改如下:

1
2
// 登录
router.post("/login", expressJoi(reg_login_schema), userHandler.login);
② 根据用户名查询用户的数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 登录的处理函数
exports.login = (req, res) => {
// 接收表单数据
const userInfo = req.body;
// 根据用户名查询用户信息
const sqlStr = "select * from ev_users where username=?";
// 执行sql
db.query(sqlStr, userInfo.username, (err, results) => {
if(err) return res.cc(err);
if(results.length !== 1) {
return res.cc("登陆失败");
}
// 后续操作...
res.send("....")
});
};
③ 判断用户输入的密码是否正确

核心实现思路:调用 bcrypt.compareSync(用户提交的密码, 数据库中的密码) 方法比较密码是否一致。返回值是布尔值(true 一致、false 不一致)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 登录的处理函数
exports.login = (req, res) => {
// 接收表单数据
const userInfo = req.body;
// 根据用户名查询用户信息
const sqlStr = "select * from ev_users where username=?";
// 执行sql
db.query(sqlStr, userInfo.username, (err, results) => {
if(err) return res.cc(err);
if(results.length !== 1) {
return res.cc("登陆失败");
}
// 判断密码正确
const compareResult = bcrypt.compareSync(userInfo.password, results[0].password);
if(!compareResult) return res.cc("登陆失败");
res.cc("登陆成功", 0);
});

};
④ 生成 JWT 的 Token 字符串

核心注意点:在生成 Token 字符串的时候,一定要剔除 密码头像 的值

  • 安装第三方包
1
npm i jsonwebtoken@8.5.1
  • 在项目根目录下新建配置文件config.js,配置秘钥和过期时间
1
2
3
4
5
module.exports = {
// 加密和解密的秘钥
jwtSecretKey: "Hongyi",
expiresIn: "10h"
}
  • 登录路由函数修改:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const jwt = require("jsonwebtoken");
const config = require("../config");
// 登录的处理函数
exports.login = (req, res) => {
// 接收表单数据
const userInfo = req.body;
// 根据用户名查询用户信息
const sqlStr = "select * from ev_users where username=?";
// 执行sql
db.query(sqlStr, userInfo.username, (err, results) => {
if(err) return res.cc(err);
if(results.length !== 1) {
return res.cc("登陆失败");
}
// 判断密码正确
const compareResult = bcrypt.compareSync(userInfo.password, results[0].password);
if(!compareResult) return res.cc("登陆失败");
// ------------------------------------------
// 在服务器生成token字符串
// 剔除完毕之后,user 中只保留了用户的 id, username, nickname, email 这四个属性的值
const user = {...results[0], password: "", user_pic: ""};
// 对用户的信息进行加密,生成token字符串
const tokenStr = jwt.sign(user, config.jwtSecretKey, {expiresIn: config.expiresIn});
// 将token响应给客户端
res.send({
status: 0,
message: "登陆成功",
token: "Bearer " + tokenStr
});
// -------------------------------------------
});

};

客户端接收内容:

1
2
3
4
5
{
"status": 0,
"message": "登陆成功",
"token": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJNYXJrIiwicGFzc3dvcmQiOiIiLCJuaWNrbmFtZSI6bnVsbCwiZW1haWwiOm51bGwsInVzZXJfcGljIjoiIiwiaWF0IjoxNjQ3ODUyNDgxLCJleHAiOjE2NDc4ODg0ODF9.iJPy8hJs_aUpgX2D8CzW9luFdAllgKOiem-3TN9P1Yw"
}
⑤ 配置解析 Token 的中间件
  • 安装解析 Token 的中间件:
1
npm i express-jwt@5.3.3
  • app.js 中注册路由之前,配置解析 Token 的中间件,并修改错误级别的中间件
1
2
3
4
const expressJwt = require("express-jwt");
const config = require("./config");
// 一定要在注册路由之前配置解析token的中间件
app.use(expressJwt({secret: config.jwtSecretKey, algorithms: ["HS256"]}).unless({path: [/\/api\//]}));
  • app.js 中的 错误级别中间件 里面,捕获并处理 Token 认证失败后的错误:
1
2
3
4
5
6
7
// 定义错误级别的中间件
app.use((err, req, res, next) => {
// ...
// 身份认证失败的错误捕获
if(err.name === "UnauthorizedError") return res.cc("身份认证失败");
// ...
})

9.3 个人中心

9.3.1 获取用户的基本信息

① 初始化路由模块
  • 创建 /router/userinfo.js 路由模块,并初始化如下的代码结构:
1
2
3
4
5
6
7
8
9
10
11
12
const express = require("express");

const router = express.Router();

// 挂载路由

// 获取用户基本信息
router.get("/userinfo", (req, res) => {
res.send("ok");
});

module.exports = router;
  • app.js 中导入并使用个人中心的路由模块:
1
2
3
4
// 导入用户信息路由模块
const userinfoRouter = require("./router/userinfo");
// 注意:以 /my 开头的接口,都是有权限的接口,需要进行 Token 身份认证
app.use("/my", userinfoRouter);
② 初始化路由处理函数模块
  • 创建 /router_handler/userinfo.js 路由处理函数模块,并初始化如下的代码结构:
1
2
3
4
// 获取用户基本信息的处理函数
exports.getUserInfo = (req, res) => {
res.send('ok')
}
  • 修改 /router/userinfo.js 中的代码如下:
1
2
3
4
// 导入handler模块
const userinfo_handler = require("../router_handler/userinfo");
// 获取用户基本信息
router.get("/userinfo", userinfo_handler.getUserInfo);
③ 获取用户的基本信息
  • /router_handler/userinfo.js 头部导入数据库操作模块
  • 编写sql语句并执行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const db = require("../db/index");

// 获取用户基本信息
exports.getUserInfo = (req, res) => {
const sqlStr = "select id, username, nickname, email, user_pic from ev_users where id = ?";
db.query(sqlStr, req.user.id, (err, results) => {
if(err) return res.cc(err);
if(results.length !== 1) return res.cc("获取用户信息失败");
res.send({
status: 0,
message: "获取用户信息成功",
data: results[0]
})
});
}

9.3.2 更新用户的基本信息

① 定义路由和处理函数
  • /router/userinfo.js 模块中,新增 更新用户基本信息 的路由:
1
2
// 更新用户基本信息
router.post("/userinfo", userinfo_handler.updateUserInfo);
  • /router_handler/userinfo.js 模块中,定义并向外共享 更新用户基本信息 的路由处理函数:
1
2
3
4
// 更新用户基本信息
exports.updateUserInfo = (req, res) => {
res.send("ok");
}
② 验证表单数据
  • /schema/user.js 验证规则模块中,定义 idnicknameemail 的验证规则如下,并使用 exports 向外共享如下的 验证规则对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 定义id,nickname,email的验证规则
const id = joi.number().integer().min(1).required();
const nickname = joi.string().required();
const email = joi.string().email().required();

// ...

// 更新用户基本信息表单的验证规则对象
exports.update_userinfo_schema = {
// 表示需要对 req.body 中的数据进行验证
body: {
id,
nickname,
email
},
}
  • /router/userinfo.js 模块中,导入验证数据合法性的中间件,导入需要的验证规则对象,并修改 更新用户的基本信息 的路由如下:
1
2
3
4
const expressJoi = require("@escook/express-joi");
const {update_userinfo_schema} = require("../schema/user");
// 更新用户基本信息
router.post("/userinfo", expressJoi(update_userinfo_schema), userinfo_handler.updateUserInfo);
③ 实现更新用户基本信息的功能

定义待执行的 SQL 语句并执行。

9.4 后面章节略