从头开始重新学习 Node.js, 这次参考的是非常经典并且全面的视频材料,覆盖 Node.js 的方方面面。
1. 课程的内容清单如下
- 基础介绍
- Js 基本语法
- Node.js 基础
- 高效开发
- 使用 Express
- 模板引擎
- MVC 架构
- 高级路由和模块
- MYSQL 相关
- 使用 Sequelize
- NOSQL 以及 MongoDB
- 使用 Mongoose
- Session 和 Cookie
- 鉴权
- 发送邮件
- 鉴权深入
- 用户输入校验
- 错误处理
- 文件上传和下载
- 分页
- 异步请求
- 处理支付
- REST API
- REST API 高级
- 使用 async-await
- Websocket 以及 Socket.io
- GraphQL 相关
- 部署
- Web 服务器
- 总结
2. 基础介绍
本小节主要是一些基础知识以及基本的介绍。
2.1 什么是 REPL
- Read: 读取用户的输入
- Eval: 评估用户的输入
- Print: 将输入打印出来
- Loop: 等待下一次输入
2.2 http 和 https
- http: hyper text transfer protocol -- 超文本传输协议
- https: hyper text transfer protocol secure -- 超文本安全传输协议
3. Node 基础
本小节主要是一些 node 相关的基础知识。
3.1 流式数据和 Buffer
网络数据都是流式数据,我们需要使用 node 中的 Buffer 结构体去接受这些数据。
var http = require("http");
var fs = require("fs");
var server = http.createServer(function (req, res) {
console.log("req.method:", req.method);
if (req.url === "/create-user" && req.method === "GET") {
// 初始化一个数组来存储接收到的数据块
var body_1 = [];
// 接收数据块
req.on("data", function (chunk) {
body_1.push(chunk);
});
// 数据接收完毕
return req.on("end", () => {
// 合并数据块并转换为字符串
var parsedBody = Buffer.concat(body_1).toString();
// 假设传递的信息格式为 key=value
var message = parsedBody.split("=")[1];
// 将消息写入文件
fs.writeFile("message.txt", message, () => {
// // 设置响应头和状态码
res.statusCode = 302;
// res.setHeader('Location', '/');
res.setHeader("Content-Type", "text/html");
res.setHeader("Access-Control-Allow-Origin", "*"); // 处理跨域问题
// 发送响应体并结束响应
res.end(
"<html><head><title>My First Page</title></head><body><h1>Hello from my Node.js Server!</h1></body></html>"
);
});
});
} else if (req.url === "/create-user") {
res.end(
"<html><head><title>My First Page</title></head><body><h1>Error!</h1></body></html>"
);
}
});
// 监听9999端口
server.listen(9999, function () {
console.log("Server is running on port 9999");
});
注意事项: res.end 只能调用一次。
3.2 Node 中的 EventLoop
3.3 单线程、事件循环和阻塞代码
Js 代码是非阻塞的,原因是它使用了【回调函数】和【事件驱动】。
NodeJS 环境中的代码什么时候结束执行?
如果 node 发现 no work to do, 那么就会停止运行。但是如果我们在 node 中使用 http 等模块开始监听,则永远有工作要做,所以 node 会一直执行下去。
3.4 module.exports 和 exports
module.exports.handler = requestHandler;
module.exports.someText = "Some text";
exports.handler = requestHandler;
exports.someText = "Some hard coded text";
3.5 设置调试配置
新建 .vscode/lauch.json
输入下面的内容:
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"skipFiles": ["<node_internals>/**"],
"program": "${workspaceFolder}\\app.js",
"restart": true,
"runtimeExecutable": "nodemon",
"console": "integratedTerminal"
}
]
}
你可以在调试环境下随时打印或者修改当前的数据。
4. Express -- Don't re-invent the Wheel
出了 express 还有如下的框架:
Vanilla Node.js
Adonis.js
Koa
Sailsjs
简单的示例:
const http = require("http");
const express = require("express");
const app = express();
const server = http.createServer(app);
server.listen(3000);
这里使用 http.createServer
的时候将 app 作为参数传入,这是可以的,但是如果直接使用 app.listen 这个过程就会被封装起来,我们也无需在代码中显式的引入 http 模块。下面是其源码:
app.listen = function listen() {
var server = http.createServer(this);
return server.listen.apply(server, arguments);
};
路由处理本身使用的就是中间件
如下代码所示,处理路由的就是中间件并且一个路由可以被处理多次,直到 res.send 被调用。
const express = require("express");
const app = express();
// 这是第一个中间件,对所有请求都会执行
app.use("/", (req, res, next) => {
console.log("This always runs!");
next();
});
// 这是第二个中间件,只有在 '/add-product' 路径上才会执行
app.use("/add-product", (req, res, next) => {
console.log("In another middleware on /add-product!");
res.send('<h1>The "Add Product" Page</h1>');
});
// 这是第三个中间件,它注册在第一个中间件之后,对所有请求都会执行
// 注意:这个中间件会覆盖第一个中间件的 res.send,因为 Express 会停止调用后续中间件
// 当一个中间件发送了响应(res.send)之后
app.use("/", (req, res, next) => {
console.log("In another middleware on all routes!");
res.send("<h1>Hello from Express!</h1>");
});
// 启动服务器监听 3000 端口
app.listen(3000, () => {
console.log("Server is running on http://localhost:3000");
});
上述代码中,路由的处理顺序和其在代码中被定义的顺序保持一致,并且每一个路由都可以被处理多次,如上面的 '/'
可以匹配上任何路由,例如:/a /b /add-product /a/b/c
等任何,所以我们将其放在第一个来处理任何一个路由,并打印一些信息。同时我们在打印完毕之后使用 next
将其交给其它中间件处理,如果不使用 next 则就到此为止了,所以如果你不打算使用 next 那么在此之前需要使用 send
将响应返回给浏览器。
给路由合适的位置
由于一个路由的处理是有顺序的,并且可以被多次处理,因此好的实践为:
- 如果一个中间件打算处理所有的路由,那么将其放在最上面
- 然后是处理每一个特定路由的中间件
- 最后是用来兜底的中间件,一般来说如果输入任意的地址,大概率会返回 404,这个功能就是兜底中间件实现的。
const express = require("express");
const bodyParser = require("body-parser"); // 确保 body-parser 模块被引入
const app = express();
// 在最上面挂载处理所有路由的中间件
app.use((req, res, next) => {
console.log(
`Currently, the path is ${req.path}, and the method is ${req.method}.`
);
next();
});
// bodyparse 中间件本质上和上面那个是一致的
app.use(bodyParser.urlencoded({ extended: false }));
// 挂载子级路由:注意子级路由应该在一级路由之前挂载
app.use("/admin", adminRoutes);
// 挂载一级路由
app.use(shopRoutes);
// 挂载错误页兜底,处理路由不匹配的情况
app.use((req, res, next) => {
res.status(404).send("<h1>Page not found</h1>");
});
app.listen(3000, () => {
console.log("Server is running on http://localhost:3000");
});
返回 html 文件
如果使用 express 返回前端需要的 html 文件,这通常涉及到两个知识点:
- 如何获取正确的文件
- 如何方整个文件的内容
对于第一点,我们使用内置模块 path 找到特定位置,使用 path 上的 join api, 注意在使用的时候尽量不要用 /src/views/shop.html
这种,而是应该分开写:
router.get("/", (reg, res, next) => {
res.sendFile(path.join(dirname, "views", "shop.html")); // res.sendFile(path.join( dirname,../','views','shop.html'));
});
对于第二点,我们无需使用 fs 读取文件的内容,因为 express 给我们提供了封装好的 api, 名为:sendFile
这个方法接受一个文件名。
**知识点:**path.join 是平台无关的,它能识别 windows 中的 '\\'
或者 linux 中的 '/'
.
有了上面的知识,那么我们就可以将 404 页面先写在一个 html 文件中,然后直接返回这个文件即可。
app.use((req,res,next)=>{
res.status(404).sendFile(path.join(__dirname, 'views','404.html'));
});
更好的 path 实践
我们一般不会在 path.join 中使用相对路径,我们希望能够准确的获得项目的根目录路径。因此遵循以下的实践:
- 在项目的根目录下创建 util 目录并在其中创建 path.js 文件。
mkdir util && touch util/path.js
- path.js 向外暴露计算好的项目的根目录的位置:
const path = require("path");
module.exports = path.dirname(process.mainModule.filename); // 这里的 process 是全局变量
然后在需要的地方使用 const rootDir = require('../util/path');
引入,然后我们就可以从根目录出发寻找相应的目标路径了。
静态资源
在使用 Web 服务器或 Web 框架时,我们通过定义路由来管理 Web 应用的 URL 访问权限,确保用户只能访问预设的路径,并通过 404 错误页面优雅地处理不存在的 URL 请求。这种机制不仅维护了 Web 应用的安全性,也使得 URL 设计有序。
在开发过程中,HTML 文件经常通过基于文件系统的路径引用 CSS、JavaScript 和图像资源。这些路径在本地开发环境中有效,但在部署到服务器上时,需要一种机制来保持这种引用的有效性,而不必为每个资源单独设置路由。这就引入了静态资源服务的概念。
静态资源服务是一种高效的路由批量挂载机制,它允许服务器将整个目录的文件映射到 Web 应用的 URL 结构中,而不需要为每个文件单独配置路由。这样,开发者在编写 HTML 时可以继续使用基于文件目录的路径引用资源,就像在本地开发环境中一样。服务器将这些路径透明地转换为对静态资源的访问,使得资源的管理和访问变得直观和高效。
这种方法不仅简化了路由设置的过程,而且保持了 Web 应用 URL 设计的整洁和安全性。开发者可以专注于功能开发,而不必担心静态资源的服务器配置。最终,静态资源服务确保了从开发到部署的一致性,提高了开发效率,并增强了 Web 应用的可维护性和扩展性。
举例说明
假如在开发服务器的时候,我们部分文件目录如下所示,其中 public 提供了一些 css js img 文件,因此又称为静态文件目录:
project-root/
│
├── public/
│ └── css/
│ └── main.css
│
└── views/
└── shop.html
那么在开发的时候,为了测试 shop.html 中的内容,我们通常使用的就是文件系统目录。比如:
<link rel="stylesheet" href="../public/css/main.css" >
在上线的时候这样写是不对的,原因有 2:
- 浏览器不一定获得了访问这个文件的权限,我们必须将这个文件所在的静态资源目录挂载到路由系统中。
- 挂载完静态资源目录之后
"../public/css/main.css"
是不正确的。
那么我们需要做两件事,第一件事情就是挂载这个静态资源目录,第二件事就是将路径改正确。
express.static()
express 提供了一个中间件,这个中间件是 express.static
方法的返回值,express.static
方法接受一个路径作为参数,这个参数表示静态资源目录的地址:
const rootDir = require('../util/path');
app.use(express.static(path.join(rootDir, 'public')));
这样做了之后,就为 public/
中的所有文件批量创建了路由,这个时候我们访问 http://host:port/css/main.css
就会被映射到文件系统中的 rootDir/public/css/main.css
上面去。因此 shop.html
中的引用路径应该改成 <link rel="stylesheet" href="/css/main.css" >
.
但是,有的时候我们不仅只有一个静态资源目录,为了防止子文件名称冲突,也为了便于管理,我们通常会给静态资源目录给一个额外的 token:
const rootDir = require('../util/path');
app.use('public', express.static(path.join( dirname, 'public')))
上述代码中的 token 就是 public
,这个时候我们访问 http://host:port/public/css/main.css
就会被映射到文件系统中的 rootDir/public/css/main.css
上面去。此时 shop.html
中的引用路径应该改成 <link rel="stylesheet" href="/public/css/main.css" >
5. 模板引擎
本节我们按顺序介绍三种模板引擎:Pug Handlebars EJS
, 它们的特点如下:
- Pug: Use minimal HTML and custom template language.
- Handlebars: Use normal HTML and custom template language.
- EJS: Use normal HTML and plain avaScript in your templates.
安装这些依赖:npm install --save ejs pug express-handlebars
设置模板引擎以及模板文件目录
当我们安装 pug 第三方包之后,我们可以直接使用 'pug'
这个字符串作为模板引擎的引用:
const app = express();
app.set('view engine', 'pug'); // 设置 pug 作为模板引擎
app.set('views', 'views'); // 设置 views/ 目录作为视图模板目录
使用模板引擎
三种模板引擎的使用方式是一致的,用到的都是 res.render
方法:
const router = express.Router();
router.get('/', (req, res, next)=> {
res.render('shop', { prods: [{title: 'first one'}], docTitle: 'shop' });
});
第一个参数 'shop'
表示的其实是视图文件夹 views/
中的 views/shop.pug
文件,这里 views/
被 app.set('views', 'views');
已经设置了,而后缀 .pug
则是被 app.set('view engine', 'pug');
设定了。
第二个参数 { prods: [{title: 'first one'}], docTitle: 'shop' }
实际上是我们向模板传递的动态参数。
我们如何使用传递的动态参数:
title #{docTitle}
切换模板引擎
我们可以切换模板引擎,刚才我们使用的 hug 现在拟使用 handlebars. 它们之间有一些细微的差别,前者引擎名称被定死了,为:pug
而后者在这方面灵活度大一些:
const app =express();
app.engine('hbs', expressHbs());
app.set('view engine','hbs');
app.set('views','views');
上述代码中,通过执行 expressHbs()
得到一个引擎示例然后挂载到 app 上,挂载名称的自由度大一些,也可以为 hbs1
. 然后使用这个新挂载的引擎 app.set('view engine','hbs1');
,最后就是指定视图模板目录的位置了:
const router = express.Router();
router.get('/', (req, res, next)=> {
res.render('shop', { prods: [{title: 'first one'}], docTitle: 'shop' });
});
这个时候渲染的文件为:views/shop.hbs1
注意这里是 hbs1
.
我们通过下面的方法在模板中使用动态数据:
<title>{{ docTitle }}</title>
在 hbs 中,我们不能运行 js 代码,例如在 hbs 文件中,dynamicArr.length
就是非法的。
6. 阶段性成果
下面是一个 demo 的所有代码,可根据此还原出当前的进度。
项目文件结构
.
|-- app.ts
|-- package.json
|-- public
| `-- css
| `-- main.css
|-- tsconfig.json
|-- util
| `-- path.ts
`-- views
|-- 404.pug
`-- shop.pug
package.json
{
"name": "n1",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start:build": "tsc app.ts --resolveJsonModule -w",
"start:run": "nodemon app.js",
"start": "concurrently \"npm run start:*\""
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"concurrently": "^8.2.2",
"typescript": "^5.5.4"
},
"dependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.5.1",
"ejs": "^3.1.10",
"express": "^4.19.2",
"express-handlebars": "^8.0.1",
"pug": "^3.0.3"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"moduleResolution": "node10",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}
app.ts
import express = require('express');
import path = require('path');
import { rootDir } from './util/path';
const app = express();
app.set('view engine', 'pug');
app.set('views', 'views');
app.use('/public', express.static(path.join(rootDir, 'public')))
app.use('/', (req, res, next) => {
console.log(`${req.path} -- ${req.method})`);
next();
})
app.get('/', (req, res, next) => {
res.render('shop', { timeStamp: new Date().toISOString() });
})
app.use('/', (req, res, next) => {
res.render('404', { timeStamp: new Date().toISOString() });
})
app.listen(9876, () => {
console.log('server runs at 9876...')
})
views/404.pug
doctype html
html(lang='en')
head
meta(charset='UTF-8')
title Page Not Found
meta(name='viewport', content='width=device-width, initial-scale=1.0')
link(rel='stylesheet', href='public/css/main.css')
body
h1 404 - Page Not Found
p The page you are looking for does not exist.
p #[span.timestamp The timestamp is: #{timeStamp}]
views/shop.pug
doctype html
html(lang='en')
head
meta(charset='UTF-8')
title Page Not Found
meta(name='viewport', content='width=device-width, initial-scale=1.0')
link(rel='stylesheet', href='public/css/main.css')
body
h1 Welcome to shop!
p #[span.timestamp The timestamp is: #{timeStamp}]
util/path.ts
import path = require('path');
export const rootDir = path.dirname(process.mainModule?.filename!);
public/css/main.css
h1 {
background-color: red;
}
.vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"skipFiles": ["<node_internals>/**"],
"program": "${workspaceFolder}\\app.js",
"restart": true,
"runtimeExecutable": "nodemon",
"console": "integratedTerminal"
}
]
}
.vscode/settings.json
{
"workbench.colorCustomizations": {
"activityBar.background": "#142A64",
"titleBar.activeBackground": "#1C3A8C",
"titleBar.activeForeground": "#F9FAFE"
}
}
改进
注意到我们的 package.json 中的脚本中写的是 tsc app.ts --resolveJsonModule -w
实际上如果在 tsc 命令后面添加任意的参数都会使 tsconfig.json
失效,因此想要配置生效我们必须只能使用 tsc
.
然后我们在, tsconfig.json
中添加:
"noEmitOnError": false,
"outDir": "./dist"
并修改脚本为:
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start:build": "tsc",
"start:run": "nodemon dist/app.js",
"start": "concurrently \"npm run start:*\""
},