Node 基础 Day 1

107 阅读3分钟

从头开始重新学习 Node.js, 这次参考的是非常经典并且全面的视频材料,覆盖 Node.js 的方方面面。

1. 课程的内容清单如下

  1. 基础介绍
  2. Js 基本语法
  3. Node.js 基础
  4. 高效开发
  5. 使用 Express
  6. 模板引擎
  7. MVC 架构
  8. 高级路由和模块
  9. MYSQL 相关
  10. 使用 Sequelize
  11. NOSQL 以及 MongoDB
  12. 使用 Mongoose
  13. Session 和 Cookie
  14. 鉴权
  15. 发送邮件
  16. 鉴权深入
  17. 用户输入校验
  18. 错误处理
  19. 文件上传和下载
  20. 分页
  21. 异步请求
  22. 处理支付
  23. REST API
  24. REST API 高级
  25. 使用 async-await
  26. Websocket 以及 Socket.io
  27. GraphQL 相关
  28. 部署
  29. Web 服务器
  30. 总结

2. 基础介绍

本小节主要是一些基础知识以及基本的介绍。

2.1 什么是 REPL

  1. Read: 读取用户的输入
  2. Eval: 评估用户的输入
  3. Print: 将输入打印出来
  4. Loop: 等待下一次输入

2.2 http 和 https

  1. http: hyper text transfer protocol -- 超文本传输协议
  2. 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

15132da0-7dff-442c-b891-93fdd7556984.jpg

3.3 单线程、事件循环和阻塞代码

88f62a9f-457f-48dc-a2c6-250c3ed75209.jpg

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 文件,这通常涉及到两个知识点:

  1. 如何获取正确的文件
  2. 如何方整个文件的内容

对于第一点,我们使用内置模块 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 中使用相对路径,我们希望能够准确的获得项目的根目录路径。因此遵循以下的实践:

  1. 在项目的根目录下创建 util 目录并在其中创建 path.js 文件。mkdir util && touch util/path.js
  2. 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:

  1. 浏览器不一定获得了访问这个文件的权限,我们必须将这个文件所在的静态资源目录挂载到路由系统中。
  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, 它们的特点如下:

  1. Pug: Use minimal HTML and custom template language.
  2. Handlebars: Use normal HTML and custom template language.
  3. 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:*\""
  },