Node.js学习笔记

Node.js学习笔记

小叶子

封面作者:雨里

请先阅读JavaScript学习笔记

内容并非完全线性, 如有不解可暂时跳过

⭐Node.js

Node.js 是一个基于 Chrome V8 引擎(用 C++ 编写)的跨平台 JavaScript 运行环境, 用于开发服务器端桌面端的应用程序, 例如 VSCode 就是基于基于 Node.jsElectron 框架开发的

Node.js 提供了一些核心模块, 用于处理网络请求 (httphttps)、文件操作 (fsfs/promises)、路径操作 (path)、系统信息 (os) 等, 也可以通过 npm 安装第三方模块

  • documentwindowXMLHttpRequest 等对象在 Node.js 中是不存在的
  • consolesetTimeoutsetInterval 等对象在 Node.js 中是存在的
  • Node.js 中的顶级对象是 global, 也可以通过 globalThis 访问
  • 推荐将 node 内置模块写成 node:xxx 而不是 xxx
  • 推荐在 package.json 中设置 "type": "module",并使用 ESM 替代 CommonJS

安装运行

官网 下载安装包并安装即可; 也可以使用 NVM 进行安装

命令作用
node -v查看版本
node进入交互模式
node xxx.js运行文件
node --watch xxx.js运行并监视文件变化
Node.js 22.0.0 以上版本支持
node --run xxx运行 package.json 中的 xxx 脚本
Node.js 22.0.0 以上版本支持

命令行工具

Windows 中, 可以使用 cmdPowerShell 打开命令行工具

  • 命令由命令名称和参数组成, 例如 node -vnode 是命令名称, -v 是参数
  • 参数可以没有, 也可以有多个, 相当于函数的参数

VSCode 中有内置的终端, 可以直接使用

常用命令

命令作用示例
cd切换目录cd d:: 切换到 D
cd ..: 返回上一级目录
cd xxxcd ./xxx: 切换到当前目录下的 xxx 目录
dir查看目录dir: 查看当前目录下的文件和文件夹
dir -s: 会展开所有子目录
cls清屏清除当前命令行窗口的所有内容
ctrl + c退出当前命令输入 dir -s 后, 按 ctrl + c 可以停止输出

NVM

NVMNode.js 的版本管理工具, 可以用于安装、切换、卸载 Node.js 的不同版本; 可以在Github 上下载安装包(左侧是 Windows 版本链接)

命令作用
nvm list查看已安装的 Node.js 版本
nvm install x.x.x安装指定版本的 Node.js, 版本可以是 latest
nvm use x.x.x切换到指定版本的 Node.js
nvm uninstall x.x.x卸载指定版本的 Node.js
nvm on开启 NVM
nvm off关闭 NVM

Buffer

BufferNode.js 中的一个全局对象, 类似于 Array, 但长度固定且不可调整, 用于处理二进制数据, 直接操作内存所以性能较好, 每个元素占用一个字节 1 byte 或 8 bit

由于 JavaScript 语言自身只有字符串数据类型, 没有二进制数据类型, 所以 Node.js 提供了 Buffer 对象来处理二进制数据

创建

方法作用
Buffer.alloc(size)创建一个指定大小的 Buffer, 并用 0 填充
Buffer.allocUnsafe(size)创建一个指定大小的 Buffer, 不会初始化
速度更快, 但可能包含旧的内存数据
Buffer.from(str[, encoding])创建一个包含 str 字符串的 Buffer
Buffer.from(arr)创建一个包含 arr 数组的 Buffer
1
2
3
4
5
6
// 创建
let buf1 = Buffer.alloc(10) // <Buffer 00 00 00 00 00 00 00 00 00 00>
let buf2 = Buffer.allocUnsafe(10) // <Buffer 00 00 00 00 00 00 00 00 00 00>
let buf3 = Buffer.from('hello world') // <Buffer 68 65 6c 6c 6f 20 77 6f 72 6c 64>
let buf4 = Buffer.from([1, 2, 3]) // <Buffer 01 02 03>
// 默认 UTF-8 编码, 打印时 16 进制显示

JavaScriptnumber 类型用 0x... 表示十六进制数, 用 0b... 表示二进制数

属性和方法

属性或方法作用
buf.length返回 buf 的长度(字节数)
buf[index]返回 buf 中指定位置的字节, 类似于数组
buf.write(string
[, offset[, length]][, encoding])
string 写入 buf
offset 开始, 最多写入 length 个字节
如果 string 长度大于 buf 的长度, 会截断
buf.toString(
[encoding[, start[, end]]])
返回 buf 的字符串形式
startend
buf.toJSON()返回 bufJSON 对象
buf.slice([start[, end]])返回 buf 的一个片段
startend
buf.copy(target
[, tStart[, sStart[, sEnd]]])
buf 的一部分复制到 target
sStartsEnd, 从 tStart 开始写入

encoding 默认为 utf-8; [] 表示可选参数, 后同

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 定义一个 Buffer
let buf = Buffer.alloc(11)

// 写入
buf.write('hello world')

// 读取
console.log(buf.length) // 11
console.log(buf[0]) // 104
console.log(buf.slice(0, 5).toString()) // hello
console.log(buf.toString('base64')) // aGVsbG8gd29ybGQ=

// 复制
let newBuf = Buffer.alloc(5)
buf.copy(newBuf, 0, 0, 5)
console.log(newBuf.toString()) // hello

常见问题

  • 一个字节的 Buffer 可以存储 256 / 11111111 种不同的值, 即 0-255
  • 如果试图存入一个超过 255 的值, 则只会保留二进制的后 8
  • buf[0] = 256 会变成 0, 因为 256 的二进制是 100000000
  • UTF-8 编码中, 一个中文字符占 3 个字节, 一个英文字符占 1 个字节
  • ASCII 编码中, 一个中文字符只占 2 个字节
计算机相关基础知识

计算机组成

  • 计算机主要由 CPU内存硬盘 等组成
  • CPU 用于计算; 移动端一般叫 SOC, 因为还集成了显卡和基带等模块
  • 内存 用于存储数据, 速度很快, 但断电后数据丢失
  • 硬盘 用于存储数据, 速度较慢, 断电后数据不丢失
  • 主板 用于连接各个部件
  • 显卡 用于处理图形数据, 并输出到显示器, 可以是独立的, 也可以集成在 CPU
  • 操作系统 用于管理硬件和软件, 提供用户界面, 调度资源; 例如 WindowsLinux

进程和线程

  • 进程 是程序的一次执行, 是资源分配的基本单位
  • 线程 是进程的一个执行流, 是 CPU 调度的基本单位
  • 一个进程可以包含多个线程
  • 例如打开两个 Chrome 窗口, 就是两个进程; 一个 Chrome 窗口中的多个标签页或 WebWorker 就是多个线程

process

processNode.js 中的一个全局对象, 用于获取 Node.js 进程的信息, 提供了一些方法用于控制 Node.js 进程

属性或方法作用
process.on('exit', callback)在进程退出时执行回调函数
process.on('beforeExit', callback)在进程退出前执行回调函数
process.exit([code])退出进程, code 默认为 0
process.upTime()返回 Node.js 进程运行的时间
process.memoryUsage()返回 Node.js 进程的内存使用情况
process.cwd()返回 Node.js 进程的当前工作目录

模块化

老版本 Node.js 中的模块化是基于 CommonJS 规范的, 每个文件就是一个模块, 模块内部的变量和函数默认是私有的, 需要通过 module.exports 导出, 通过 require 引入

  • CommonJS 是一个早期模块化规范, 用于 JavaScript 语言
  • exportimportES6 中的模块化规范, 用于 JavaScript 语言; Node.js 也支持 ES6 的模块化规范, 但需要在 package.json 中设置 type 字段为 module

导出

  • module.exportsNode.js 中的一个全局对象, 用于导出模块
  • exportsmodule.exports 的一个引用, 可以直接使用 exports.xxx 导出
  • 如果直接赋值 exports 本身, 而不是添加属性或方法, 会导出空对象, 即 module.exports 对象
1
2
3
4
5
6
7
8
9
10
module.exports = {
a: 1,
b: function () {
console.log(this.a)
}
}
exports.c = 2
exports.d = function () {
console.log(this.c)
}

导入

  • requireNode.js 中的一个全局函数, 用于引入模块
  • require 会返回被引入模块的 module.exports 对象
  • require 中的相对路径不会受工作目录影响, 而是相对于当前文件
1
2
3
4
5
const obj = require('./xxx.js')
console.log(obj.a) // 1
obj.b() // 1
console.log(obj.c) // 2
obj.d() // 2
  • 引入 Node.js 内置模块或 npm 安装的包时, 不需要写路径, 直接写模块名即可
  • 引入除 .js.json.node 以外的拓展名的文件, 如 .txt 时, 会按照 .js 的方式解析
  • 通过 require 和解构赋值可以方便地引入 JSON 文件及其内特定变量
  • 引入自定义模块的过程: 将路径解析为绝对路径 → 检测缓存中是否有该模块 → [读取文件内容] → [编译执行文件内容] → [将文件内容放入缓存] → 返回 module.exports 对象

对于 require('./xxx') 的情况

1
2
3
4
5
6
7
8
9
10
11
// 不写后缀时
const anotherObj = require('./xxx')

// 如果 xxx 不是文件夹
// 会先找 xxx.js, 如果没有再找 xxx.json, 如果还没有再找 xxx.node
// 都没有时, 找 xxx(无拓展名)并按 .js 的方式解析

// 如果 xxx 是文件夹
// 先检查 xxx/package.json 中的 main 字段(内容是一个文件路径)
// 如果没有 main 则找 xxx/index.js → xxx/index.json → xxx/index.node
// 如果还没有, 或者 main 指向的文件不存在, 则报错

fs

fsfile system 的缩写, 用于与硬盘交互, 提供了文件的读写、删除、重命名等功能; 要使用 fs 等模块, 需要先引入

1
const fs = require('node:fs')

文件路径

路径类型说明
./xxxxxx相对路径相对于命令行的工作目录
../xxx相对路径相对于命令行的工作目录的上一级目录
/xxx绝对路径相对于文件所在盘符的根目录
D:/xxx绝对路径D 盘的 xxx 目录(部分 C 盘目录需要管理员权限)
__dirname绝对路径表示当前文件所在目录的绝对路径
__filename绝对路径表示当前文件的绝对路径
  • 可以把 __dirname__filename 看作是 Node.js 中的全局变量
  • 网站的根目录可以利用 __dirnamepath 模块拼接得到, 例如 path.join(__dirname, '/../public')
  • 若设置了 "type": "module",则应使用 import.meta.dirnameimport.meta.filename 替代 __dirname__filename
1
2
3
4
// 直接用 ./ 可能达不到预期效果
fs.readFile('./me.txt', e => null)
// 可以使用 __dirname
fs.readFile(__dirname + '/me.txt', e => null)

网页路径

本部分不属于 fs 模块, 为便于理解路径, 写在此处

路径类型说明
https://xxx.com/xxx绝对路径https 协议的 xxx.com 域名的 xxx 路径
//xxx.com/xxx绝对路径当前协议的 xxx.com 域名的 xxx 路径
/xxx绝对路径当前协议的当前域名的 xxx 路径
./xxxxxx相对路径相对于当前网页的路径
../xxx相对路径相对于当前网页上一级目录的路径

浏览器在发送相对路径的请求时, 会自动在当前域名后拼接路径; 如果 ../ 超出了根目录, 会被忽略; 如 https://xxx.com/xxx 下的 ../../ 会被解析为 https://xxx.com/ 而不是 https://404 Not Found

文件写入

方法作用
fs.writeFile(path, data[, options], callback)异步将 data 写入到文件 path
fs.appendFile(path, data[, options], callback)异步追加 data 到文件 path
fs.writeFileSync(path, data[, options])同步写入文件
fs.appendFileSync(path, data[, options])同步追加文件
fs.createWriteStream(path[, options])创建一个流式写入对象
频繁写入时不会多次开闭文件

const ws = fs.createWriteStream(xxx) 为例

流式写入对象方法作用
ws.write(data[, encoding][, callback])写入数据
ws.close([callback])关闭流, 可以不写(脚本结束时会自动关闭)
  • options: 一个对象, 用于设置编码、模式等
  • callback: 回调函数, 用于处理结果; 写入完成后调用, 参数为一个错误对象, 如果没错误则为 null
  • 默认情况下, 如果文件不存在, 会创建文件
  • 同步写入没有回调函数, 直接返回结果(值同回调函数形参)
  • 写入的内容不再能用 HTML<br>&nbsp; 等标签, 而是需要使用 \n\t 等转义字符
  • \n: 换行符; \t: 制表符(Tab 键); \r: 回车符; \b: 退格符; \\: 反斜杠; \': 单引号; \": 双引号
1
2
3
4
5
6
7
8
// 引入 fs 模块
const fs = require('fs')

// 写入文件
fs.writeFile('me.txt', 'Hi, I\'m xiaoyezi.\n', e => e ? console.log('写入失败: ' + e) : console.log('写入成功')

// 追加文件
fs.appendFile('me.txt', 'I\'m a psychology student.', e => e ? console.log('追加失败: ' + e) : console.log('追加成功'))
options 参数
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
{
encoding: 'utf-8', // 编码, 默认 'utf-8'
// 'utf-8': 万国码, 支持所有字符
// 'ascii': 仅支持 `0-127` 的字符
// 'base64': 用于编码二进制数据
// 'binary': 二进制数据
mode: 0o666, // 权限, 默认 0o666
// 0o000: 文件不可读不可写不可执行
// 0o111: 文件可执行
// 0o222: 文件可写
// 0o444: 文件可读
// 0o666: 文件可读可写
// 0o777: 文件可读可写可执行
flag: 'w' // 打开文件的方式, 默认 'w'
// 'w': 写入
// 'a': 追加
// 'r': 读取
// 'r+': 读取并写入
// 'w+': 写入并读取
// 'a+': 追加并读取
// 'wx': 类似 'w', 但如果文件存在则失败
// 'ax': 类似 'a', 但如果文件存在则失败
}

// 其实写入和添加本质上是一样的
fs.writeFile('me.txt', 'xxx', { flag: 'a' }, e => null)
// 等价于
fs.appendFile('me.txt', 'xxx', e => null)

Node.js 中的同步与异步类似于 JavaScript, 但其异步代码不是由浏览器开启新线程执行, 而是由 Node.jslibuv 模块负责调度

文件读取

方法作用
fs.readFile(path[, options], callback)异步读取文件
fs.readFileSync(path[, options])同步读取文件, 无回调函数, 直接返回数据
fs.createReadStream(path[, options])创建一个流式读取对象
用于分块地读取文件

const rs = fs.createReadStream(xxx) 为例

流式读取对象方法作用
rs.on('data', callback)读取出一块数据后执行, 回调函数的形参是读取的数据
rs.on('end', callback)读取完成后执行, 回调函数没有形参
rs.on('error', callback)读取出错后执行, 回调函数的形参是错误对象
rs.pipe(ws)将读取的数据写入到 ws
  • callback: 回调函数, 用于处理结果; 读取完成后调用, 有两个形参, 第一个是错误对象, 第二个是读取的数据
  • 读取的数据是 Buffer 类型, 需要使用 toString / toJSON 方法转换为字符串或 JSON 对象
  • 流式读取中, data 事件会多次触发, 每次读取的数据大小由 highWaterMark 设置决定, 默认 64KB
  • 对于大文件, 如果一次性读取, 会占用大量内存, 可能导致内存溢出, 所以需要使用流式读取
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 引入 fs 模块
const fs = require('fs')

// 复制文件
fs.readFile('me.txt', (e, data) => {
if (e) {
console.log('复制失败: ' + e)
} else {
fs.writeFile('me_copy.txt', data, e => e && console.log('复制失败: ' + e))
}
})

// 流式复制文件
const rs = fs.createReadStream('me.txt')
const ws = fs.createWriteStream('me_copy.txt')
rs.on('data', data => ws.write(data, e => e && console.log('复制出错: ' + e)))
rs.on('end', () => ws.close())
rs.on('error', e => console.log('复制出错: ' + e))
// 简便写法
rs.pipe(ws)

其他操作

方法作用
fs.rename(oldPath, newPath, callback)异步重命名文件
fs.copyFile(src, dest[, options], callback)异步复制文件
fs.rm(path[, options], callback)异步删除文件或目录
fs.mkdir(path[, options], callback)异步创建目录
fs.stat(path, callback)异步获取文件信息
回调函数第二个形参是文件信息对象
fs.readdir(path, callback)异步读取目录
回调函数第二个形参是目录下的文件名数组
fs.unlink(path, callback)异步删除文件
fs.rmdir(path, callback)异步删除目录
  • callback: 回调函数, 第一个参数都是一个错误对象, 有的还有其他参数
  • 上述方法都有同步版本, 方法名后加上 Sync 并不传入回调函数即可, 如 fs.renameSync
  • 默认不可以删除空目录或一次创建多级目录, 如果要, 需将 options 设置为 { recursive: true }
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
// 重命名文件
fs.rename('me.txt', 'me_rename.txt', e => e && console.log('重命名失败: ' + e))

// 删除文件和目录
// 文件结构: test/test.txt、test/test
fs.rm('test/test.txt', e => e && console.log('删除失败: ' + e))
fs.rm('test/test', e => e && console.log('删除失败: ' + e))
fs.rm('test', { recursive: true }, e => e && console.log('删除失败: ' + e))

// 获取文件信息
fs.stat('me.txt', (e, stats) => {
if (e) {
console.log('获取失败: ' + e)
} else {
console.log('文件大小: ' + stats.size)
console.log('是否是文件: ' + stats.isFile())
console.log('是否是目录: ' + stats.isDirectory())
console.log('创建时间: ' + stats.birthtime)
console.log('修改时间: ' + stats.mtime)
console.log('最后访问时间: ' + stats.atime)
}
})

// 读取目录
fs.readdir('.', (e, files) => e ? console.log('读取失败: ' + e) : console.log(files))

// 创建目录
fs.mkdir('test', e => e && console.log('创建失败: ' + e))
// 默认不可以递归创建目录
fs.mkdir('test/test/test', e => e && console.log('创建失败: ' + e)) // 创建失败
// 需要设置 recursive 为 true
fs.mkdir('test/test/test', { recursive: true }, e => e && console.log('创建失败: ' + e))
// 上述代码会依此创建 test、test/test、test/test/test 三个目录
文件批量重命名
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
// 引入 fs 模块
const fs = require('fs')

// 例如存在文件: 1.txt、2.txt、...、15.txt
// 将其重命名为: 001.txt、002.txt、...、015.txt

// 读取目录
fs.readdir('.', (e, files) => {
// 如果读取失败, 打印错误信息
// 否则遍历文件, 执行重命名操作
if (e) {
console.log('读取失败: ' + e)
} else {
// 定义正则表达式
const reg1 = /^\d{1}\.txt$/
const reg2 = /^\d{2}\.txt$/
// 遍历文件
files.forEach((file) => {
// 如果文件名符合正则表达式
if (reg1.test(file)) { // 符合 reg1
// 给文件名添加前导 00
fs.rename(file, '00' + file, e => e && console.log('重命名失败: ' + e))
} else if (reg2.test(file)) { // 符合 reg2
// 给文件名添加前导 0
fs.rename(file, '0' + file, e => e && console.log('重命名失败: ' + e))
}
})
}
})

// 又如存在文件: 1.txt、5.txt、...、20.txt
// 将其按大小重命名为: 001.txt、002.txt、...

// 读取目录
fs.readdir('.', (e, files) => {
// 如果读取失败, 打印错误信息
// 否则遍历文件, 执行重命名操作
if (e) {
console.log('读取失败: ' + e)
} else {
// 定义正则表达式
const reg = /^\d+/
// 遍历文件, 取得文件名数组
const num = files.map(file => file.match(reg)[0])
// 对文件名数组排序, 得到新的文件名数组
const newNum = num.sort((a, b) => a - b)
// 遍历文件名数组, 根据索引(代表排位), 重命名对应文件
newNum.forEach((n, i) => {
// 获取第 i 名的文件在 files 中的索引
const index = num.indexOf(n)
// 计算新的文件名
let newName = null
if (i <= 9) {
newName = '00' + (i + 1) + '.txt'
} else if (i <= 99) {
newName = '0' + (i + 1) + '.txt'
} else if (i <= 999) {
newName = (i + 1) + '.txt'
} else {
console.log('文件过多, 无法重命名')
return
}
// 重命名文件
fs.rename(files[index], newName, e => e && console.log('重命名失败: ' + e))
})
}
})

fs/promises

fs/promises 顾名思义是 fs 模块的 Promise 版本; 方法中的完整参数详见官方文档

1
import fs from 'node:fs/promises'
方法作用
fs.access(path)验证访问权限 (或文件存在); 成功返回 null, 失败返回错误对象
fs.appendFile(path, data)追加文件内容; data: string | Buffer
fs.copyFile(src, dest)复制文件
fs.mkdir(path[, options])创建目录; options.recursive: boolean 是否递归创建
fs.open(path, flag)打开文件, 返回 fs.FileHandle 对象; flag: r/r+/w/w+/a/a+
fs.readFile(path)读取文件内容
fs.rename(oldPath, newPath)重命名文件
fs.rm(path[, options])删除文件或目录
options.recursive: boolean 是否递归删除
options.force: boolean 忽略文件不存在带来的错误
fs.stat(path)获取文件信息, 返回 fs.Stats 对象
fs.writeFile(path, data)写入文件内容; data: string | Buffer | ...

path 可以是 stringBufferURLFileHandle 等类型

path

pathNode.js 中的一个核心模块, 提供了一些方法用于处理文件路径

1
const path = require('path')
方法作用
path.join([...paths])将所有参数拼接为一个路径
path.resolve([...paths])将所有参数拼接为一个绝对路径
path.sep操作系统的路径分隔符
path.basename(path[, ext])返回路径的最后一部分
如果 ext 存在, 则去掉 ext
path.dirname(path)返回路径的目录部分
path.extname(path)返回路径的扩展名部分
path.parse(path)返回路径对象
path.format(pathObject)返回路径字符串

利用 path 模块可以避免因为不同操作系统的路径分隔符不同而导致的问题

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
// 引入 path 模块
const path = require('path')
// 定义一个路径
const p = 'E:/media/me.png'

// 拼接路径
console.log(path.join('a', 'b', 'c')) // a\b\c
console.log(path.join(__dirname, 'a', 'b', 'c')) // D:\xxx\a\b\c
console.log(path.resolve('a', 'b', 'c')) // D:\...\a\b\c
console.log(path.resolve(__dirname, 'a', 'b', 'c')) // D:\xxx\a\b\c
// 注意: 不要在第二个及以后的参数中使用绝对路径

// 获取分隔符
console.log(path.sep) // \(Windows)

// 获取文件名
console.log(path.basename(p)) // me.png
console.log(path.basename(p, '.png')) // me
// 获取目录名
console.log(path.dirname(p)) // E:/media
// 获取扩展名
console.log(path.extname(p)) // .png

// 解析路径
const pObj = path.parse(p)
/*
{
root: 'E:/',
dir: 'E:/media',
base: 'me.png',
ext: '.png',
name: 'me'
}
*/

// 格式化路径
console.log(path.format(pObj)) // E:\media\me.png

http

httpNode.js 中的一个核心模块, 用于创建 HTTP 服务器和客户端; http 模块提供了一个 createServer 方法, 用于创建一个 HTTP 服务器对象

1
const http = require('http')
服务器对象属性或方法作用
server.listen(port
[, hostname][, backlog][, callback])
监听端口, 启动服务器
hostname: 主机名, 默认 localhost
backlog: 最大连接数, 默认 511
callback: 服务器启动成功后执行的回调函数
server.close([callback])关闭服务器, 服务器关闭后执行回调函数
server.on('request', callback)监听请求事件
创建服务器时传入回调函数与此效果相同
server.on('close', callback)监听关闭事件
本地通过 ctrl + c 关闭服务器不会触发
server.on('error', callback)监听错误事件
1
2
3
4
5
6
7
8
9
10
11
12
13
// 创建服务器
const server = http.createServer((req, res) => {
// req 是请求对象, res 是响应对象
// 当接收到 HTTP 请求时, 执行回调函数
// 也可以创建时不传入回调函数, 而是另外写 server.on('request', callback)
res.setHeader('Content-Type', 'text/html; charset=utf-8') // 设置响应头
res.write('<h1>Hi, I\'m xiaoyezi.</h1>') // 写入响应体
res.end() // 结束响应
})

// 监听端口
server.listen(23333, () => console.log('服务器已启动: http://localhost:3000'))
// windows 中的资源监视器可以查看端口占用情况

请求对象

属性或方法作用
req.url请求的路径和查询参数, 如 /xxx?prompt=xxx
new 创建的 request 对象不同, 不包含路径前的部分
req.method请求的方法
req.httpVersionHTTP 版本
req.headers请求头对象
req.headers['host']: 主机名和端口
req.headers['accept']: 接受的数据类型
req.on('data', callback)监听请求体数据
每次接收到数据时执行, 回调函数的形参是数据块
req.on('end', callback)监听请求体数据结束
req.on('error', callback)监听请求体数据错误
回调函数的形参是错误对象

以上是 noderequest 的独特属性, 其他属性和方法见JavaScript 学习笔记

响应对象

属性或方法作用
res.setHeader(name, value)设置响应头, value 可以是数组, 此时会设置多个 name 相同的响应头
res.write(data)写入响应体, 数据可以是字符串或 Buffer 对象
res.end([data])结束响应, 可以写入最后一块数据; 只能调用一次(类似于 return
res.on('finish', callback)监听响应结束事件

请求(响应)体实际上是一个可读(可写)流对象, 所以可以使用流的相关方法

简单的注册和登陆
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
// 引入 http 模块
const http = require('http')

// 创建服务器
const server = http.createServer((req, res) => {
// 设置响应头
res.setHeader('Content-Type', 'text/html; charset=utf-8')
// 创建 URL 对象
const url = new URL(req.url, 'http://localhost:23333')

// 注册
if (url.pathname === '/register' && req.method === 'GET') {
res.write('<h1>注册页面</h1>')
res.end()
}
// 登陆
else if (url.pathname === '/login' && req.method === 'GET') {
res.write('<h1>登陆页面</h1>')
res.end()
}
// 404
else {
res.statusCode = 404
res.write('<h1>页面不存在</h1>')
res.end()
}
})

// 监听端口
server.listen(23333, () => console.log('服务器已启动: http://localhost:23333'))

资源类型

Multipurpose Internet Mail Extensions, MIME 是一种互联网标准, 用于表示文档、文件、图像、音频、视频等的类型

Content-TypeHTTP 协议的一个头部字段, 用于指定响应体的数据类型; 值的格式为 type/subtype

类型说明子类型说明
text文本text/plain
text/html
text/css
text/javascript
纯文本
HTML 文档
CSS 文件
JavaScript 文件
image图片image/jpeg
image/png
image/gif
image/svg+xml
JPEG 图片
PNG 图片
GIF 图片
SVG 图片
audio音频audio/mpegMP3 音频
video视频video/mp4MP4 视频
multipart多部分multipart/form-data表单数据
application应用程序application/json
application/xml
application/pdf
application/octet-stream
application/x-www-form-urlencoded
JSON 数据
XML 数据
PDF 文件
二进制数据, 浏览器会自动下载
表单数据
  • Content-Type 的值可以包含字符集, 例如 text/html; charset=utf-8
  • 上述设置的优先级高于 HTML 中的 <meta charset="xxx"> 标签
  • CSSJavaScript 文件在执行时会自动以 HTML 的编码格式解析
  • 由于浏览器存在资源类型判断机制, 所以有时不设置 Content-Type 也可以正常显示资源

静态资源与动态资源

  • 静态资源: 不需要经过服务器处理, 直接返回给客户端的资源, 如 HTMLCSSJavaScript、图片、音视频等
  • 动态资源: 需要经过服务器处理后返回给客户端的资源, 如 PHPJSPASPServlet
简单的静态资源服务器
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
// 引入模块
const http = require('http')
const fs = require('fs')
const path = require('path')

// 创建服务器
const server = http.createServer((req, res) => {
// 判断是否为 GET 请求
if (req.method !== 'GET') {
res.statusCode = 405
res.write('<h1>405 Method Not Allowed</h1>')
res.end()
return
}
// 创建 URL 对象
const url = new URL(`https://${req.headers.host}${req.url}`)
// 获取文件路径
const filePath = url.pathname === '/' ? path.join(__dirname, '/index.html') : path.join(__dirname, url.pathname)
// 定义文件类型
const type = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.jpg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.mp3': 'audio/mpeg',
'.mp4': 'video/mp4',
'.json': 'application/json; charset=utf-8',
'.xml': 'application/xml; charset=utf-8',
'.pdf': 'application/pdf',
'.txt': 'text/plain; charset=utf-8'
}
// 读取文件
fs.readFile(filePath, (e, data) => {
if (e) {
switch (e.code) {
case 'ENOENT':
res.statusCode = 404
res.write('<h1>404 Not Found</h1>')
break
case 'EACCES':
res.statusCode = 403
res.write('<h1>403 Forbidden</h1>')
break
default:
res.statusCode = 500
res.write('<h1>500 Internal Server Error</h1>')
} else {
// 获取文件扩展名
const ext = path.extname(filePath).slice(1)
// 设置响应头
type[ext] ? res.setHeader('Content-Type', type[ext]) : res.setHeader('Content-Type', 'application/octet-stream')
// 写入响应体
res.write(data)
// 结束响应
res.end()
}
})
})

// 监听端口
server.listen(23333, () => console.log('服务器已启动: http://localhost:23333'))

跨域请求

跨域请求指请求的源和资源的源不同, 如 https://a.xxx 通常不能请求 https://b.xxx 的资源; 而 CORSCross-Origin Resource Sharing 的缩写, 指的是跨域资源共享, 用于解决跨域请求的问题

  • 跨域请求分为简单请求和非简单请求
  • 简单请求
    请求方法为 GETPOSTHEAD 之一
    请求头只包含 AcceptAccept-LanguageContent-LanguageContent-Type
    Content-Typeapplication/x-www-form-urlencodedmultipart/form-datatext/plain 之一
    客户端会直接发送请求, 再根据响应头决定是否接收响应
  • 非简单请求
    不符合上述条件的请求
    客户端会先发送一个 OPTIONS 请求, 询问服务器是否允许跨域请求
    OPTIONS 请求由客户端浏览器自动发送, 不需要, 也不能手动发送
    服务器将返回以下信息, 客户端浏览器会根据这些信息决定是否发送真正的请求
响应头说明
Access-Control-Allow-Origin允许跨域请求的源, 可以是 * 或具体的 URL, 多个 URL 用逗号隔开
Access-Control-Allow-Methods允许跨域请求的方法, 多个方法用逗号隔开, 简单请求包含的方法不需要设置
Access-Control-Allow-Headers允许跨域请求的请求头, 多个请求头用逗号隔开, 简单请求包含的请求头不需要设置
Access-Control-Allow-Credentials是否允许发送 Cookie, 默认为 false
true 时, ...Allow-Origin 不能为 *, 且请求头要包含 credentials: 'include'(针对 fetch
Access-Control-Max-AgeOPTIONS 请求的有效期, 单位为秒, Chrome 默认为 5
Access-Control-Expose-Headers允许获取的响应头, 多个响应头用逗号隔开

通常对于简单应用只需要设置 Access-Control-Allow-Origin

访问控制

HTTP 协议是无状态的, 即每次请求都是独立的, 服务器无法识别请求是否来自同一个客户端; 为了解决这个问题, 可以使用 CookieSessionToken 等技术来实现会话控制

CookieHTTP 协议的一个头部字段, 用于在客户端存储数据, 以便下次请求时发送给服务器; Cookie 保存在浏览器, 每个域名的 Cookie 是独立的(不同域名的 Cookie 不能共享)

  • Cookie 的存储形式是键值对, 如 name=xxx; age=xxx
  • 每次请求时, 浏览器会自动将 Cookie 发送给服务器, 服务器可以通过 req.headers.cookie 获取
  • 如果数据量较大, 建议使用 localStoragesessionStorage 来代替 Cookie, 见JavaScript学习笔记
命令适用对象作用
document.cookie客户端读取或设置 Cookie
fetch(url,{credentials:'include'})客户端发送请求时携带 Cookie
默认为 same-origin
res.cookie('name', 'value'
[, { options }])
服务器(express设置 Cookie
设置多个 Cookie, 多次调用即可
res.setHeader('Set-Cookie',
'name=value[; options]')
服务器(http 模块)设置 Cookie
req.clearCookie('name')服务器(express清除 Cookie
res.setHeader('Set-Cookie',
'name=; Max-Age=0')
服务器(http 模块)清除 Cookie

options

express原生说明
maxAgeMax-AgeCookie 的有效期, 单位为毫秒, 优先级高于 expires
expiresExpiresCookie 的过期时间
pathPathCookie 的路径, 只有在该路径下的请求才会发送 Cookie
domainDomainCookie 的域名, 只有在该域名下的请求才会发送 Cookie
secureSecure是否只在 HTTPS 连接中发送 Cookie, 默认为 false
httpOnlyHttpOnly是否只能通过 HTTP 协议访问 Cookie
避免用户通过 JavaScript 访问, 默认为 false

如果不设置 maxAgeexpires, Cookie 默认为会话 Cookie, 即关闭浏览器后失效

使用 cookie-parser 中间件可以方便地读取 Cookie, 它会将 Cookie 解析为对象并挂载到 req.cookies

1
2
# 安装 cookie-parser
npm i cookie-parser
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 引入模块
const express = require('express')
const cookieParser = require('cookie-parser')

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

// 使用中间件
app.use(cookieParser())

// 路由
app.get('/', (req, res) => {
// 读取 Cookie
console.log(req.cookies)
res.send('Hello, World!')
})

// 监听端口
app.listen(23333, () => console.log('服务器已启动: http://localhost:23333'))

Session

Session 是服务器端的一种会话控制技术, 用于保存用户的会话信息, 如用户的登录状态、购物车、权限等

  • Session 的原理是在客户端保存一个 SessionID, 然后在服务器端保存一个 Session 对象
  • Session IDSession 对象是唯一对应的, 用户在请求时会携带 Session ID
  • express 中, 可以使用 express-session 中间件来实现 Session 的功能
  • 设置上述中间件后, 会在 req 对象上挂载一个 session 对象, 用于读取和设置 Session
  • 还可以使用 connect-mongo 中间件将 Session 保存到 MongoDB 数据库中
  • 相比于 Cookie, Session 相对更安全, 且可以保存更多的数据(对于 Chrome 等浏览器, Cookie 的大小限制为 4KB
1
2
3
4
# 安装 express-session
npm i express-session
# 安装 connect-mongo
npm i connect-mongo
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
// 引入模块
const express = require('express')
const session = require('express-session')
const MongoStore = require('connect-mongo')

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

// 使用中间件
app.use(session({
name: 'sid', // 用于保存 Session ID 的 Cookie 的名称, 默认为 connect.sid
secret: 'xiaoyezi', // 用于加密 `Session ID` 的密钥
resave: true, // 是否每次请求都重新保存 `Session`(更新 `Session` 的有效期)
saveUninitialized: false, // 是否保存未初始化的 `Session`, 默认为 `true`(未登陆用户)
cookie: {
maxAge: 1000 * 60 * 60 * 24, // `Session` 的有效期, 单位为毫秒(数据库中的 `Session` 也会过期)
httpOnly: true, // 是否只能通过 `HTTP` 协议访问 `Cookie`, 避免用户通过 `JavaScript` 访问
secure: false // 是否只在 `HTTPS` 连接中发送 `Cookie`
},
store: MongoStore.create({ // 用于保存 `Session` 的存储器
mongoUrl: 'mongodb://localhost:27017/test'
})
}))

// 路由
app.get('/login', (req, res) => {
// 设置 Session
req.session.user = {
name: 'xiaoyezi',
age: 18
}
res.send('登陆成功')
})
app.get('/logout', (req, res) => {
// 销毁 Session
req.session.destroy(e => e && console.log('销毁失败: ' + e))
res.send('退出成功')
})
app.get('/info', (req, res) => {
// 读取 Session
console.log(req.session.user)
res.send('获取成功')
})

// 监听端口
app.listen(23333, () => console.log('服务器已启动: http://localhost:23333'))

数据库数据示例

CSRF 攻击

CSRFCross-Site Request Forgery 的缩写, 指的是跨站请求伪造, 是一种网络攻击方式, 攻击者可以利用受害者的身份向服务器发送请求, 执行一些操作, 如转账、发帖等

  • CSRF 攻击的原理是利用受害者的 Cookie, 因此可以通过设置 SameSite 属性来防御 CSRF 攻击
  • SameSite 属性是 Cookie 的一个属性, 用于指定 Cookie 是否可以跨站发送, 有三个值: StrictLaxNone
  • Strict: 只有在同源请求时才会发送 Cookie, 不同源请求时不会发送
  • Lax: 在 GET 请求和 POST 请求时都会发送 Cookie, 但是在 GET 请求中, 如果是跨站请求, 不会发送 Cookie
  • None: 无论是 GET 请求还是 POST 请求, 都会发送 Cookie, 即使是跨站请求也会发送
1
2
3
4
5
6
// 在服务端, 可以将上面的 /logout 路由改为 post 来防御 CSRF 攻击
app.post('/logout', (req, res) => {
// 销毁 Session
req.session.destroy(e => e && console.log('销毁失败: ' + e))
res.send('退出成功')
})

Token

Token 是一种无状态的会话控制技术, 是服务端生成、返回给客户端、内含用户信息的、加密的字符串

  • 用户在登陆时, 服务端在验证用户信息后生成一个 Token, 并返回给客户端
  • 客户端在请求时携带 Token(通常放在请求头中), 服务端通过解密 Token 来验证用户身份
  • Token 是加密的, 且加解密过程只会在服务端进行, 客户端无法解密; Token 还可以避免 CSRF 攻击; 所以 Token 更安全
  • Token 的存储位置是客户端, 可以是 localStoragesessionStorageCookie 等; 且不同于 Cookie, Token 不会自动发送给服务器, 需要手动设置

JWT

JWTJSON Web Token 的缩写, 是一种 Token 的标准, 用于在网络中传递声明, 通常用于身份验证

  • JWT 由三部分组成, 分别是 HeaderPayloadSignature, 用 . 分隔
  • Header 是一个 JSON 对象, 用于描述 Token 的元数据, 如 alg(加密算法)和 typJWT 类型)
  • Payload 是一个 JSON 对象, 用于存放用户信息, 如 sub(主题)、exp(过期时间)、iat(签发时间)
  • SignatureHeaderPayload 的签名, 用于验证 Token 的完整性

jsonwebtoken

jsonwebtoken 库是 JWT 的一个实现, 用于生成和验证 Token

属性或方法作用
jwt.sign(data, secretOrPrivateKey, options)生成 Token
jwt.verify(token, secretOrPublicKey, callback)验证 Token
options.expiresInToken 的有效期, 单位为秒
options.notBeforeToken 的生效时间, 单位为秒
options.audienceToken 的受众, 默认为 options.issuer
options.issuerToken 的签发者, 默认为 localhost
callback回调函数, 形参为 errdata
不写回调函数时, 直接返回 data 或抛出错误
1
2
# 安装 jsonwebtoken
npm i jsonwebtoken
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 引入模块
const jwt = require('jsonwebtoken')
// 密钥
const PRIVATE_KEY = 'myprivatekey'
// 生成 Token, 有效期为 5 秒
const token = jwt.sign({ name: 'xiaoyezi', age: 18 }, PRIVATE_KEY, { expiresIn: 5 })
console.log(token) // xxx.xxx.xxx_xxx_xxx
// 每隔 1 秒验证一次 Token
setInterval(() => {
jwt.verify(token, PRIVATE_KEY, (err, data) => {
if (err) {
console.log('token已过期')
} else {
console.log(data) // { name: 'xiaoyezi', age: 18 }
}
})
}, 1000)
// 5 秒后输出 token 已过期

可执行文件

PKG

PKG 是一个 Node.js 应用程序打包工具, 可以将 Node.js 应用程序打包成可执行文件, 无需安装 Node.js 环境即可运行

1
2
3
4
# 安装 pkg
npm i -g pkg
# 打包文件
pkg index.js
命令作用
pkg entry打包文件, 生成可执行文件
pkg entry -t target指定平台和架构, 默认所有平台和本机 node 版本
pkg entry -o output指定输出目录, 默认为当前目录
pkg entry --icon icon.ico指定可执行文件的图标, 默认为 node 图标

详见官方文档 , 未来等用到了再来补充

平台和架构

target 的格式为 nodeVersion-platform-arch

  • nodeVersion: node10node12node14node16latest
  • platform: linuxwinmacosalpine
  • arch: x64arm64

原生打包

Node.js 有一项实验性功能, 可以将 Node.js 应用程序打包成可执行文件, 无需安装 Node.js 环境即可运行, 详见官方文档

下面为 Windows 平台的示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 1. 创建一个 sea-config.json 文件
{
"main": "index.js",
"output": "sea-prep.blob"
}
# 2. 生成 node.js 的 blob 文件
node --experimental-sea-config sea-config.json
# 3. 生成可执行文件副本并命名
node -e "require('fs').copyFileSync(process.execPath, 'xxx.exe')"
# 4. 删除二进制文件签名
# 可选, 不删时忽视之后的错误即可
signtool remove /s xxx.exe
# 5. 注入 blob 文件
npx postject xxx.exe NODE_SEA_BLOB sea-prep.blob --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2
# 6. 对可执行文件进行签名
# 可选, 需要证书, 不签名也可以运行
signtool sign /fd SHA256 hello.exe
# 7. 运行可执行文件

命令行交互

inquirer 是一个 Node.js 模块, 可以用于创建交互式命令行工具, 可以用于创建一个命令行工具

1
2
# 安装 inquirer
npm i inquirer
方法作用
inquirer.prompt(questionsArray[, answersObj])获取用户的输入, 返回 Promise
new inquirer.ui.BottomBar()创建一个底部栏, 用于显示进度
outputStream.pipe(ui.log)将输出流导入底部栏
ui.log.write('msg')在底部栏中显示信息
ui.updateBottomBar('msg')更新底部栏的信息
  • inquirer 是纯 es module, 不支持 CommonJS, 需要使用 import 导入
  • inquirerprompt 方法返回一个 Promise 对象, 可以使用 await 来获取用户的输入、用 thencatch 来处理用户的输入

Questions

questionsArray 是一个数组, 数组中的每个元素都是一个 question 对象, 用于定义问题的类型、提示信息、默认值等

属性作用
type问题的类型, 如 inputconfirmpassword
name问题的名称, 用于把值存储到 answersObj.name
message问题的提示信息
choices问题的选项数组
default问题的默认值, 可以是值或函数(的返回值)
validate答案的验证函数, 应返回 truefalse
filter答案的过滤函数, 应返回过滤后的值
transformer问题的转换函数, 应返回转换后的值, 用于隐藏密码等

只有前三个是所有 type 都必须有的

type
类型作用
list选择一个选项, 需要设置 choices
rawlist选择一个选项(数字序号), 需要设置 choices
expand选择一个选项(指定序号), 需要设置 choices
choice 需要额外设置 key 作为序号
checkbox选择多个选项, 需要设置 choices
choice 可选设置 checkedtrue
confirm选择 yesno
input输入一个值
password输入一个密码
editor打开一个文本编辑器, 关闭后返回输入的值
choices

choices 是一个数组, 数组中的元素可以是一个值, 也可以是一个 choice 对象, 用于定义选项的值、显示的文本等

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
const questions = [
{
type: 'list',
name: 'color',
message: 'What is your favorite color?',
choices: ['Red', 'Green', 'Blue']
},
{
type: 'list',
name: 'food',
message: 'What is your favorite food?',
choices: [
{ name: 'Pizza', value: 'pizza' },
{ name: 'Burger', value: 'burger' },
{ name: 'Hot Dog', value: 'hot dog' }
]
},
{
type: 'expand',
name: 'drink',
message: 'What is your favorite drink?',
choices: [
{ key: 'c', name: 'Coke', value: 'coke' },
{ key: 'p', name: 'Pepsi', value: 'pepsi' },
{ key: 's', name: 'Sprite', value: 'sprite' }
]
}
]
示例

这是我的爬虫练习小程序 的一个片段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import inquirer from 'inquirer'
// ...
if (!env.USER_NAME || !env.PASSWORD || !env.DISPLAY_NAME) {
const answers = await inquirer.prompt([
{
type: 'input',
name: 'USER_NAME',
message: '请输入学号',
},
{
type: 'password',
name: 'PASSWORD',
message: '请输入数字京师密码',
},
{
type: 'input',
name: 'DISPLAY_NAME',
message: '请输入保存的文件名(默认为学号)',
},
])
env.USER_NAME = answers.USER_NAME
env.PASSWORD = answers.PASSWORD
env.DISPLAY_NAME = answers.DISPLAY_NAME || answers.USER_NAME
}

RESTful API

接口 Application Programming Interface, API 是一种用于连接不同软件、不同模块、网站前后端等的数据交换方式; 一个接口由 URL请求方法请求参数响应数据 等组成, 可以在这里 查看一个接口文档的示例, 也可以在这里 查看一些免费的接口

RESTful 是一种软件架构风格, 是一种设计 API 的方式, 可以用于创建 Web 服务; 用任何语言都可以创建 RESTful API, 只要遵循 REST 的设计风格即可:

  • URL 代表资源, 路径中不应包含动词
  • HTTP 方法应代表对资源的操作, 如 GETPOSTPUTDELETE
  • HTTP 状态码应代表操作的结果, 如 200404500
  • RESTful API 一般使用 JSON 格式来传输数据

前面说的用 GET / POST 方法来进行所有操作的设计实际上是不符合 RESTful 的设计风格的

json-server

json-server 是一个 Node.js 模块, 可以用于快速创建 RESTful API

1
2
3
4
5
6
# 安装 json-server
npm i -g json-server
# 创建一个 json 文件, 如 db.json
# 启动 json-server
json-server --watch db.json --port 23333
# 如果不指定端口, 默认端口为 3000
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// db.json
// GET localhost:23333/users 返回所有用户
// GET localhost:23333/users/1 返回 id 为 1 的用户
// POST localhost:23333/users 创建一个用户
// PUT localhost:23333/users/1 更新 id 为 1 的用户
// DELETE localhost:23333/users/1 删除 id 为 1 的用户
{
"users": [
{ "id": 1, "name": "xiaoyezi", "age": 18 },
{ "id": 2, "name": "leaf", "age": 18 }
],
"posts": [
{ "id": 1, "title": "Hello World!" }
]
}

接口测试

PostmanAPIpostAPIfox 等都是一些常用的接口测试工具, 可以用于测试接口; 其中, Postman 提供了 VScode 插件, 可以直接在 VScode 中测试接口, 推荐使用

Postman 似乎会无视 Access-Control-Allow-Origin, 始终显示返回的数据

本地域名

hosts 文件是一个没有扩展名的系统文件, 用于将域名映射到 IP 地址, 可以用于本地开发和测试

  • 访问域名时, 操作系统会先在 hosts 文件中查找, 然后再去 DNS 服务器查找
  • hosts 文件的位置是 C:\Windows\System32\drivers\etc\hosts, 可以使用记事本打开
  • hosts 文件的格式是 IP 地址、域名, 用任意数量的空格分隔, 如 127.0.0.1 bnu.edu.cn
  • 可以用 hosts 文件来屏蔽广告、防止应用在线更新等, 如 127.0.0.1 ad.xxx.com

⭐包管理工具

package 指一些特定功能源码的集合, 而包管理工具则是用于安装、卸载、更新、发布、管理包的工具, 如 pythonpipJavaMaven

npm

JavaScript 中常用的包管理工具是 npm, 它是 Node.js 自带的包管理工具, 可以用 npm -v 验证是否安装并查看版本

在操作系统层面也有包管理工具, 用于管理软件包, 如 UbuntuaptCentOSyumWindowschocolatey

国内环境加速

国内环境可以使用 cnpm, 它是 npm 的淘宝镜像(部署在阿里云), 但不能发布包; 运行 npm install -g cnpm 即可安装

如果不安装 cnpm, 也可以使用 nrm 工具来切换 npm 的镜像源至国内, 只需要运行 npm install -g nrm 安装, 然后运行 nrm use taobao 即可切换至淘宝镜像; 运行 npm config list

全局与本地

  • 全局安装: 安装在 Node.js 的安装目录下, 可以在命令行中直接使用
  • 本地安装: 安装在当前项目的 node_modules 目录下, 只能在当前项目中使用
  • 只有一些命令行工具才需要全局安装
  • windows 中, 可能需要以管理员身份运行命令行才能全局安装包
命令作用
npm install -g xxx全局安装 xxx
npm uninstall -g xxx
npm remove -g xxx
全局卸载 xxx
npm update -g xxx全局更新 xxx
npm list -g查看全局下的所有包

计算机环境变量

windows 中, 可以在 系统属性 -> 高级系统设置 -> 环境变量 中设置环境变量; 在命令行执行某个命令时, 会先在当前目录下查找, 然后在环境变量 Path 中的目录下查找(优先查找系统的环境变量, 然后查找用户的环境变量)

环境变量的作用是为了方便在任意目录下使用某些命令行工具, 如 nodenpmgit

初始化

命令作用
npm init交互式地初始化一个 package.json 文件
将当前工作目录作为一个项目(包)的根目录
npm init -y快速初始化一个 package.json 文件
使用默认值, 不需要交互
  • package.json 是一个 JSON 格式的文件, 用于描述项目的信息和依赖
  • dependencies 是项目运行时需要的依赖, 如框架、库等, 在生产和开发环境中都使用
  • devDependencies 是开发时需要的依赖, 如测试框架、打包工具等, 只在开发环境中使用
  • scripts 是一些脚本命令, 可以通过 npm run xxx 来执行
  • scripts 中的 start 是一个特殊的脚本命令, 可以直接通过 npm start 来执行
  • npm run xxx 同样有向上级目录查找的特性, 所以可以在 package.json 所在的目录的子目录中执行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"name": "xxx", // 项目名称, 不能有大写字母和中文
"version": "1.0.0", // 项目版本, 遵循语义化版本规范
"description": "xxx", // 项目描述, 可以有中文
"main": "index.js", // 入口文件
"private": true, // 是否私有, 默认为 false, 设置为 true 后不能发布包
"scripts": {
"start": "node --watch index.js"
},
"dependencies": {
"xxx": "x.x.x"
},
"devDependencies": {
"xxx": "x.x.x"
},
"author": "xxx", // 作者
"license": "MIT", // 许可证
"bin": { // 用于设置命令行工具
"xxx": "./bin/xxx.js"
}
}

设置 bin 属性后, 可以在命令行中直接使用 xxx 命令, 会执行 ./bin/xxx.js 文件 (用户全局安装后)

命令行工具

可以在 package.json 中的 bin 属性中设置命令行工具, 然后在 bin 目录下创建一个 xxx.js 文件, 用于处理命令行参数

1
2
3
4
5
// bin/hello.js

#!/usr/bin/env node

console.log('Hello, World!')
1
2
3
4
5
6
7
// package.json

{
"bin": {
"hello": "./bin/hello.js"
}
}
1
2
3
4
# 全局安装
npm i -g xxx
# 使用命令行工具
hello

安装包

可以在 npm 的官网 npmjs.com 上搜索需要的包, 然后在命令行中安装

命令作用
npm install安装 package.json 中的所有依赖
第一次运行时, 会在当前目录下创建 node_modules 目录和 package-lock.json 文件
npm install xxx
npm install --save xxx
安装 xxx 包并将其添加到 dependencies
npm install [email protected]
npm install --save [email protected]
安装指定版本的 xxx 包并将其添加到 dependencies
npm install -D xxx
npm install --save-dev xxx
安装 xxx 包并将其添加到 devDependencies
  • 上面的 install 命令可以简写为 i
  • 安装包时, 会自动安装其依赖包至 node_modules 目录下, 但不会在 package.json 中显示
  • package-lock.json 文件用于锁定依赖的版本, 防止不同环境下安装的依赖版本不一致
  • npm install 安装依赖时, 会根据 package-lock.json 文件中的版本号来安装依赖
  • require('xxx') 引入的包会优先从 ./node_modules 目录下查找, 然后查找 ../node_modules 目录, 以此类推
  • require('./node_modules/xxx') 一般与 require('xxx') 等价, 但不会向上级目录查找
  • 根据模块化一节中的知识, 导入包实质上是导入包的入口文件

node_modules 目录下的包不需要上传到 git 仓库(太多了), 所以克隆的项目需要重新安装依赖

其他

命令作用
npm uninstall xxx
npm remove xxx
卸载 xxx
npm update更新 package.json 中的所有依赖
npm update xxx更新 xxx
npm list查看当前目录下的所有包
npm info xxx查看 xxx 包的信息
  • npm uninstallnpm remove 可以简写为 npm r
  • 更新包时, 会更新 package.json 中的版本号, 然后重新安装依赖
  • 如果 package.json 中的版本号含 ^~, 会根据语义化版本规范来更新
  • 如果存在 package-lock.json 文件, 更新包时会根据 package-lock.json 文件中的版本号来更新

语义化版本规范

Semantic Versioning, 简称 SemVer, 是一种版本号规范, 用于描述包的版本号, 对于 x.y.z 形式的版本号, 有以下规定

  • x: 主版本号, 当做了不兼容的 API 修改时, 增加
  • y: 次版本号, 当做了向下兼容的功能性新增时, 增加
  • z: 修订号, 当做了向下兼容的问题修正时, 增加
  • ^x.y.z: x 不变, yz 可以更新到最新
  • ^0.y.z: 0y 不变, z 可以更新到最新
  • ~x.y.z: xy 不变, z 可以更新到最新
  • *: 任意版本

npx 包运行器

npxnpm 自带的一个包运行器, 用于运行本地安装的包, 或者直接运行远程的包

运行 npx command 会自动地在项目的 node_modules 文件夹中找到命令的正确引用, 而无需知道确切的路径, 也不需要在全局和用户路径中安装软件包; 甚至, 当找不到本地包时, npx 还会询问你是否安装它

1
2
3
4
5
# Hexo 相关命令
npx hexo clean # 清除生成的静态文件
npx hexo g # 生成静态文件
npx hexo s # 启动服务器
npx hexo d # 部署静态文件

如果不使用 npx, 则需要在 package.json 中的 scripts 中设置命令, 然后通过 npm run xxx 来执行项目中的命令

发布包

类似于 GitHub, npm 也是一个开源的包管理平台, 可以将自己的包发布到 npm

命令作用
npm login登录 npm
npm publish发布包
npm unpublish <name>撤销发布 xxx 包, 只会撤销最近的一个版本
npm logout登出 npm
npm whoami查看当前登录的用户
  • 发布包时, 会将当前目录下的 package.json 文件中的 nameversion 作为包的名称和版本号
  • 发布包时, 会将当前目录下的所有文件上传到 npm 上, 所以需要在 .gitignore 文件中忽略一些文件
  • 发布前必须先将镜像源切换至 npm 官方源, 如执行:
    npm config set registry https://registry.npmjs.org
    nrm use npm
  • 用上面的命令可能报错, 查看错误信息即可, 为避免误操作删除不该删除的包, 这里不再提供详细的命令

yarn

yarnFacebook 开发的包管理工具, 与 npm 类似, 但更快、更安全、更可靠

1
2
# 安装 yarn
npm install -g yarn
  • yarn 也可以通过 yarn config set registry https://registry.npm.taobao.org 来切换至淘宝镜像
  • yarn 用于锁定依赖的版本的文件是 yarn.lock, 与 npm 不同
  • 尽量不要同时使用 npmyarn 来安装依赖
项目命令作用
yarn init [-y]初始化一个 package.json 文件
yarn add xxx[@x.x.x]安装 xxx 包并将其添加到 dependencies
yarn add xxx[@x.x.x] --dev安装 xxx 包并将其添加到 devDependencies
yarn remove xxx卸载 xxx
yarn安装 package.json 中的所有依赖
yarn upgrade更新 package.json 中的所有依赖
yarn upgrade xxx更新 xxx
yarn list查看当前目录下的所有包
yarn xxx执行 package.json 中的 scripts 中的 xxx 脚本
简化了 run 命令, 所以注意自定义的脚本命令不能与 yarn 的命令重名
全局命令作用
yarn -v验证是否安装并查看版本
yarn global bin查看全局安装的包的路径
yarn global add xxx全局安装 xxx
yarn global remove xxx全局卸载 xxx
yarn global upgrade更新全局安装的包
yarn global list查看全局下的所有包
yarn config list查看 yarn 的配置

pnpm

pnpm 会把所有依赖安装在一个地方, 并使用符号链接的方式链接到各个项目, 节省校园网流量

1
2
# 安装 pnpm
npm install -g pnpm
pnpmnpm
pnpm install/inpm install
pnpm add [-D/-g] xxxnpm install [-D/-g] xxx
pnpm update/up [-g] [xxx]npm update [-g] [xxx]
pnpm remove/rm [-D/-g] xxxnpm uninstall [-D/-g] xxx
pnpm prune移除不需要的依赖
pnpm list [-g]npm list [-g]
pnpm [run] xxxnpm run xxx
pnpx xxxnpx xxx

如果运行有问题, 可能需要手动设置环境变量: 将 PNPM_HOME 设置为你想要的路径如 C:\Users\xxx\pnpm,然后在 Path 中添加 %PNPM_HOME%
首次安装 (如在 C 盘) 后, 如果出现在其他盘符 (如 D 盘) 上的包存放位置错误的问题, 可以先在正确的目录运行 pnpm store path 获取正确位置, 再运行 pnpm config set store-dir 刚才的正确位置 进行设置

⭐打包工具

Webpack

Webpack 是一个现代 JavaScript 应用程序的静态模块打包器 module bundler, 它主要用于打包 JavaScript 模块, 但也能够打包 CSSHTML图片字体等文件

1
2
3
4
5
6
7
# 全局安装
npm install -g webpack
# 本地安装
npm install webpack webpack-cli --save-dev
# 打包
npx webpack
npm run build # 在 package.json 中设置脚本
  • webpackWebpack 的核心包, webpack-cliWebpack 的命令行工具
  • 注意: 默认状态下, webpack 打包的入口文件是 src/index.js, 输出文件是 dist/main.js
  • ES6 模块化语法 importexport 以及 CommonJS 模块化语法 requiremodule.exports 都可以被 Webpack 解析, 但建议不要混用
  • Node.js 中的模块化语法 requiremodule.exports 不能直接在浏览器中使用, 需要通过 Webpack 打包
  • npm 包无法通过 script 标签或 import 语句直接在浏览器中使用, 也需要通过 Webpack 打包, 或者使用 CDN 引入
打包前打包后

上面的警告会在创建下面的配置文件后消失

完整配置示例
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
// package.json
{
"name": "jsdemo",
"version": "1.0.0",
"description": "",
"main": "index.js",
"private": true,
"scripts": {
"build": "cross-env NODE_ENV=production webpack --mode=production",
"start": "cross-env NODE_ENV=development webpack serve --open --mode=development"
},
"author": "leaf",
"license": "MIT",
"devDependencies": {
"cross-env": "^7.0.3",
"css-loader": "^6.10.0",
"css-minimizer-webpack-plugin": "^6.0.0",
"html-loader": "^5.0.0",
"html-webpack-plugin": "^5.6.0",
"mini-css-extract-plugin": "^2.8.0",
"style-loader": "^3.3.4",
"webpack": "^5.90.3",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.0.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
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
// webpack.config.js
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')
// loader 只需要安装, 不需要 require

module.exports = {
mode: 'production', // 模式
entry: path.resolve(__dirname, 'src/index.js'), // 入口文件路径
output: {
filename: 'bundle.js', // 输出文件名和路径
path: path.resolve(__dirname, 'dist'), // 输出文件路径
clean: true // 生成前清空输出文件夹, 默认为 false
},
plugins: [
new HtmlWebpackPlugin({ // 用于生成 HTML 文件
template: path.resolve(__dirname, 'src/index.html'), // 模板文件
filename: 'index.html' // 输出文件名和路径
}),
new MiniCssExtractPlugin({ // 用于将 CSS 文件单独打包
filename: 'index.css' // 输出文件名和路径
})
],
module: {
rules: [
{ // 用于处理 CSS 文件
test: /\.css$/i,
use: [
process.env.NODE_ENV === 'production' ? MiniCssExtractPlugin.loader : 'style-loader',
'css-loader'
]
},
{ // 用于处理图片文件
test: /\.(png|jpeg|jpg|gif|svg)$/i,
type: 'asset',
generator: {
filename: 'images/[name].[hash:6][ext]' // 输出文件名和路径
}
},
{ // 用于处理 HTML 文件
test: /\.html$/i,
use: ['html-loader']
}
]
},
optimization: {
minimizer: [
'...', // ... 表示保留默认的压缩插件
new CssMinimizerPlugin() // 用于压缩 CSS 文件
]
},
devServer: {
open: true, // 是否自动打开浏览器
port: 23333 // 端口号
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src') // 设置 src 目录的别名为 @
}
}
}

if (process.env.NODE_ENV === 'development') {
module.exports.devtool = 'inline-source-map'
} // 开发环境设置 source map

配置

Webpack 的配置文件是 webpack.config.js, 它是一个 CommonJS 模块, 返回一个配置对象; Webpack 会自动查找当前目录下的 webpack.config.js 文件

默认情况下, Webpack 不会创建 HTML 文件, 可以使用 HtmlWebpackPlugin 插件来生成 HTML 文件

1
2
# 安装插件
npm install html-webpack-plugin --save-dev
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 path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin') // 记得先安装

module.exports = {
// 模式, development / production / none
// 只有在 production 模式下, Webpack 才会自动压缩代码
mode: 'development',
// 入口文件路径, 可以是字符串、数组、对象
entry: path.resolve(__dirname, 'src/index.js'),
// 输出文件
output: {
filename: 'bundle.js', // 输出文件名
path: path.resolve(__dirname, 'dist'), // 输出文件路径
clean: true // 生成前清空输出文件夹, 默认为 false
},
// 插件
plugins: [
// HtmlWebpackPlugin 用于生成 HTML 文件
// 并在生成的 HTML 文件中自动引入打包后的 JS 文件
new HtmlWebpackPlugin({
template: path.resolve(__dirname, 'src/index.html'), // 模板文件
filename: './index.html' // 输出文件名和路径, 相对于 output.path
})
],
}
打包前打包后

打包后的 bundle.js 在加入 HTML 时会自动添加 defer 属性, 在 HTML 加载完后再执行

引入 CDN

externals 对象用于配置哪些模块不应该被 webpack 打包, 而是在运行时从环境中的某个特定环境变量获取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// webpack.config.js
module.exports = {
// ...
plugins: [
new HtmlWebpackPlugin({
CDN: process.env.NODE_ENV === 'production'
// 自定义属性, 用于判断是否使用 CDN
})
]
}

if (process.env.NODE_ENV === 'production') {
module.exports.externals = {
// key: 包名, import from xxx 的 xxx
// value: 全局变量名, 要与 CDN 引入的内容一致
// CSS 等不需要全局变量的资源的 value 可设置为 true 等内容
'swiper': 'Swiper'
}
}
1
2
3
4
5
<!-- index.html -->
<!-- 这是插件的自定义语法, 用 <% %> 执行 JavaScript 代码 -->
<% if (htmlWebpackPlugin.options.CDN) { %>
<script src="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.js"></script>
<% } %>

常用免费 CDN

多页面打包
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
// webpack.config.js
module.exports = {
// ...
entry: {
// 多个入口文件, 前面的 key 可以自定义
index: path.resolve(__dirname, 'src/index.js'),
about: path.resolve(__dirname, 'src/about.js')
},
output: {
filename: '[name].js', // 输出文件名
// [name] 为 entry 的 key
path: path.resolve(__dirname, 'dist')
},
plugins: [
// 用多个插件对象分别设置不同页面
new HtmlWebpackPlugin({
template: path.resolve(__dirname, 'src/index.html'),
filename: './index.html',
chunks: ['index'] // 指定页面使用的 JS 文件, 可以多个
}),
new HtmlWebpackPlugin({
template: path.resolve(__dirname, 'src/about.html'),
filename: './about.html',
chunks: ['about']
}),
new MiniCssExtractPlugin({
filename: './[name].css'
})
]
}
分割公共代码

splitChunks 对象用于配置 Webpack 如何将模块分割成块, 并将这些块作为单独的文件加载; 可以将公共的模块提取到一个单独的文件中, 以便多页面共享, 减少加载时间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// webpack.config.js
module.exports = {
// ...
optimization: {
splitChunks: {
chunks: 'all', // async / initial / all
cacheGroups: {
// 缓存组
commons: {
// 生成的文件名
name(module, chunks, cacheGroupKey) {
// module 是当前模块, chunks 是当前模块所属的块
const name = chunks.map(chunk => chunk.name).join('-')
// 返回文件名和路径, 相对于 output.path
return `./js/${name}`
},
chunks: 'initial', // async / initial / all
minSize: 0, // 最小文件大小
minChunks: 2, // 最小引用次数
},
}
}
}
}
集成打包 CSS

Webpack 只能处理 JavaScriptJSON 文件, 其他类型的文件需要使用加载器 loader 来处理

1
2
3
# 安装加载器
npm install css-loader --save-dev # 用于解析 CSS 文件
npm install style-loader --save-dev # 用于将 CSS 文件插入到 HTML 文件中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.css$/i, // 匹配文件的正则表达式
use: ['style-loader', 'css-loader'] // 使用的加载器
// 注意不要颠倒顺序, 因为加载器是从右向左执行的
}
]
}
}

// index.js
// 由于 webpack 只能从入口文件开始解析
// 所以需要在入口文件中引入 CSS 文件
require('./index.css')
// 与 import './index.css' 等效
打包前打包后
单独打包 CSS

上述方法会将所有的 JavaScriptCSS 文件打包到一个文件中, 但有时需要将 CSS 文件单独打包, 从而优化加载速度

1
2
3
# 安装插件
npm install mini-css-extract-plugin --save-dev # 用于将 CSS 文件单独打包
npm install css-minimizer-webpack-plugin --save-dev # 用于压缩 CSS 文件
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
// webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')

module.exports = {
module: {
rules: [
{
test: /\.css$/i,
// 使用 MiniCssExtractPlugin.loader 代替 style-loader
use: [MiniCssExtractPlugin.loader, 'css-loader']
}
]
},
plugins: [
// MiniCssExtractPlugin 用于将 CSS 文件单独打包
// 并在生成的 HTML 文件中自动引入打包后的 CSS 文件
new MiniCssExtractPlugin({
filename: './style.css' // 输出文件名和路径, 相对于 output.path
})
],
optimization: {
minimizer: [
// 如果不设置 minimizer, Webpack 会自动压缩 JS 文件
// 但设置了 minimizer 后, 需要手动添加 JS 文件的压缩插件
// 或者写上 `...` 表示保留默认的压缩插件
'...',
// CssMinimizerPlugin 用于压缩 CSS 文件
new CssMinimizerPlugin()
]
}
}

// index.js
// 注意: 仍需要在入口文件中引入 CSS 文件
require('./index.css')
打包前打包后
打包其他资源

Webpack5 之后, 一些资源文件(如图片、字体等)可以直接使用 asset 模块类型来打包, 不再需要额外的加载器 loader

模块类型说明
asset/resource用于将文件单独打包
asset/inline用于将文件转换为 base64 编码的 URL
asset/source用于将文件导出为字符串
asset将大于 8KB 的文件单独打包
小于 8KB 的文件转换为 base64 编码的 URL
  • asset/inline 适用于小图片, 可以减少 HTTP 请求
  • asset/inline 会增加图片的体积, 所以不适用于大图片
  • 打包完成后, Webpack 会自动将图片文件的路径替换为打包后的路径
  • 除了上面的 html-webpack-plugin 插件外, 还需要安装 html-loader 加载器来处理 HTML 文件, 否则 HTML 文件中的图片路径不会被替换
1
2
# 安装加载器
npm install html-loader --save-dev
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.(png|jpeg|jpg|gif|svg)$/i,
type: 'asset',
generator: {
filename: './images/[name].[hash:6][ext]' // 输出文件名和路径, 相对于 output.path
// [name]: 文件名, 如 logo
// [ext]: 文件扩展名, 如 .png
// [hash:6]: 文件哈希值, 6 位, 可以防止缓存
}
},
// 用于处理 HTML 文件
// 注意: 仍需要 html-webpack-plugin 插件来生成 HTML 文件
{
test: /\.html$/i,
use: ['html-loader']
}
]
}
}
打包前打包后

开发环境

Webpack 可以通过 webpack-dev-server 来搭建开发环境, 它是一个基于 Node.jsWeb 服务器, 可以实现热更新、代理等功能

1
2
# 安装插件
npm install webpack-dev-server --save-dev
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
// webpack.config.js
module.exports = {
mode: 'development',
// 这里非常重要, 如果还是 'production', 打包速度会很慢, 对于这个 demo:
// 'development' 一次打包时间约为 30ms, 'production' 一次打包时间则高达 1000ms
// 但如果是真正的项目, 还是需要 'production' 的, 因为会最大化地压缩代码
devServer: {
// 静态文件路径
// 所有 webpack 生成的文件实际上都是在内存中的, 而下面的配置是告诉 webpack-dev-server 从哪里读取非 webpack 生成的文件
// 原文: [webpack-dev-server] Content not from webpack is served from 'D:\Github\jsdemo\dist' directory
// 相当于网站的根目录是同时包含了 webpack 生成的文件和非 webpack 生成的文件
// 但如果两个来源的文件名相同, webpack 生成的文件会覆盖非 webpack 生成的文件
// 这里我们的 html 文件也是 webpack 生成的, 所以这里可以不写(不写时默认为 ./public)
static: path.resolve(__dirname, 'dist'),
// 是否自动打开浏览器, 默认为 false
open: true, // 或者设置为 'Google Chrome' 等
// 端口号
port: 23333
}
}

// package.json
{
"scripts": {
"start": "webpack serve --open"
}
}

webpack 命令可以用 --mode=development / --mode=production 来设置模式, 优先级高于配置文件, 从而避免反复修改配置文件

source map

如果代码有错误, 在浏览器中的报错指向的是打包后的文件, 不利于调试; source map 可以将打包后的文件映射回原始文件, 从而方便调试

1
2
3
4
// webpack.config.js
if (process.env.NODE_ENV === 'development') {
module.exports.devtool = 'inline-source-map'
} // 放在末尾

注意: 不要在生产环境中使用 source map, 因为它会暴露源码和增加文件体积

环境变量

cross-env 工具

cross-env 是一个跨平台的环境变量设置工具, 可以在 WindowsLinuxmacOS 上设置环境变量

1
2
# 安装 cross-env
npm install cross-env --save-dev
1
2
3
4
5
6
7
8
// package.json
{
"scripts": {
// 执行命令时, 会自动设置 xxx 环境变量到 process.env.xxx
"build": "cross-env NODE_ENV=production webpack --mode=production",
"start": "cross-env NODE_ENV=development webpack serve --open --mode=development"
}
}

通过环境变量优化打包配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.css$/i,
use: [
// 只在 production 模式下才将 CSS 文件单独打包
process.env.NODE_ENV === 'production' ? MiniCssExtractPlugin.loader : 'style-loader',
'css-loader'
]
}
]
}
}

如生产和开发配置差异过大, 用环境变量也很麻烦, 则可以配置两个配置文件, 分别为 webpack.config.jswebpack.config.prod.js, 然后在 package.json 中设置 scripts, 如 "build": "webpack --config webpack.config.prod.js", 这样就可以根据不同的命令来执行不同的配置文件

DefinePlugin

webpack 内置的 DefinePlugin 用于在编译时将代码中的某个环境变量替换为指定的值或表达式

如果给定的值是字符串, 需要用 JSON.stringify 来转换, 否则会被当做可执行代码(如变量名、函数名等)

1
2
3
4
5
6
7
8
9
// webpack.config.js
module.exports = {
plugins: [
new DefinePlugin({
// 将浏览器环境中的 process.env.NODE_ENV 替换为 Node.js 环境中的 process.env.NODE_ENV 的值
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV)
})
]
}
1
2
3
4
5
6
7
// index.js
// 本来在浏览器中是无法直接使用 process.env.NODE_ENV 的
// 通过 DefinePlugin, 可以在代码中直接使用 process.env.NODE_ENV
if (process.env.NODE_ENV === 'production') {
// 创建一个空函数, 用于屏蔽 console.log
console.log = function () {}
}
解析别名 alias

Webpack 可以通过 resolve.alias 来设置模块的别名, 从而简化模块的引入, 并将相对路径转换为绝对路径(更安全)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// webpack.config.js
module.exports = {
resolve: {
alias: {
// 设置 src 目录的别名为 @
'@': path.resolve(__dirname, 'src')
}
}
}

// index.js
// 通过别名引入模块
// 原本是 require('./index.css')
require('@/index.css')

Parcel

Parcel 是一个零配置的打包工具, 可以用于打包 JavaScriptCSSHTML 等文件, 相比 Webpack 等打包工具, Parcel 更加简单易用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 安装 parcel
npm i -D parcel-bundler
# 配置 package.json
{
"scripts": {
"start": "parcel",
"build": "parcel build"
},
"source": "./src/index.html" # 入口文件, js 或 html
# "main": "./dist/main.js" # 对于 js 包, parcel 会自动打包到 main 指定的文件中
}
# 启动服务(自带热更新)
npm start
# 打包文件
npm run build
命令作用
parcel [-p xxx] [entry]启动服务, -p 用于指定端口, 默认为 1234
parcel watch [entry]只监听文件变化并热替换, 不启动服务
parcel build [entry] [-d xxx]打包文件, -d 用于指定输出目录, 默认为 dist
  • entry 既可以是 HTML 文件, 也可以是 JavaScript 文件
  • Parcel 会自动解析 HTMLCSSJavaScript 等文件的依赖, 然后打包
  • Parcel 同时支持 CommonJSES6 两种模块规范
  • Parcel 原生支持 TypeScript, 不需要额外的配置

这个对于我的一些小项目来说太好用了

静态资源打包

jsPsych 中, 一些图片往往不是直接在 HTMLCSS 引用的, 所有可能不会被打包, 可以使用 parcel-reporter-static-files-copy 插件来复制静态资源

1
2
3
4
5
6
7
8
9
10
11
# 安装插件
npm i -D parcel-reporter-static-files-copy
# 配置 .parcelrc
{
"extends": ["@parcel/config-default"],
"reporters": ["...", "parcel-reporter-static-files-copy"]
}
# 创建 static 文件夹
# 将静态资源放入 static 文件夹
# 打包文件
npm run build

还有个办法, 在 HTML 里面 preload 一下图片, 然后 JavaScript 里获取 <link> 标签的 href 属性

Vite

Vite 是一个由 Vue.js 核心团队维护的下一代前端构建工具, 它主要用于快速搭建现代化的前端项目, 并提供了 VueReactPreact 等框架的插件

1
2
# 创建一个 Vite 项目 (可以选择 React 等模板)
pnpm create vite
命令描述
pnpm dev (vite)启动开发服务器
pnpm build (vite build)构建生产版本
pnpm preview (vite preview)预览生产版本
  • 使用 Vite 构建的前端项目默认入口是根目录的 index.html 文件
  • 要打包的资源通过相对路径引入即可 (如 <script src="src/main.js"></script>, import app from 'App.jsx'); 其中引入的 json 文件会被自动解析
  • /public 目录下的静态资源会被直接复制到输出目录, 通过绝对路径引入 (如 /favicon.ico)
  • 样式表除了在 HTML 文件中引入, 还可以通过 import 语句引入, 如 import 'App.css'
  • 不能被直接引用的资源, 除了放在 /public 目录下, 还可以通过 import 'xxx' 的方式引入, 如:
    import img from 'img.png': 返回路径
    import str from 'file.txt?raw': 返回字符串
    import Worker from 'worker.js?worker': 返回 Web Worker

环境变量

Vite 会默认将根目录的 .env 文件中的环境变量注入到 import.meta.env 对象中

1
2
3
4
5
.env                # 所有情况下都会加载
.env.local # 所有情况下都会加载,但会被 git 忽略
.env.[mode] # 只在指定模式下加载
.env.[mode].local # 只在指定模式下加载,但会被 git 忽略
# mode 为 development | production
  • 所有环境变量的类型都会被转换为字符串
  • 只有以 VITE_ 开头的环境变量才能被客户端代码访问
  • 可以在 HTML 文件中通过 %VITE_XXX% 的方式引用环墧变量
  • VitemodeNODE_ENV 是相对独立的, vite build 会自动设置 NODE_ENVproduction, 但可以通过 --mode development 来将 Vitemode 设置为 development
  • import.meta.env.PROD/DEV 是由 NODE_ENV 环境变量决定的; 而 import.meta.env.MODE 是由 Vitemode 决定的

配置文件

1
2
3
4
5
// vite.config.js
/** @type {import('vite').UserConfig} */
export default {
// 配置选项
}
配置选项描述默认值
root项目根目录(index.html 位置)process.cwd()
modeVite 模式取决于命令
plugins插件 undefined
publicDir静态资源目录'public'
envDir环境变量目录root
server.port
命令 --port xxx
服务器端口5173
server.open
命令 --open
启动时是否自动打开浏览器false
server.cors是否启用跨域 (允许所有)false
build.target构建目标, esnext, es2020, chrome80, safari14'modules'
build.outDir
命令 --outDir xxx
输出目录'dist'
build.minify压缩和混淆代码, false, 'terser', 'esbuild''esbuild'

分块策略

可以通过 build.rollupOptions.output.manualChunks 来手动配置分块策略, 见rollup 文档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// vite.config.js
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
// 将 antd 和 swiper 单独打包
manualChunks: {
swiper: ['swiper'],
antd: ['antd'],
}
}
}
}
})

⭐Git

Git 是一个分布式版本控制系统, 可以用于管理代码, 跟踪文件的变化, 协作开发等; 而世界上最大的代码托管平台 GitHub 就是基于 Git 的; 可以在官网 下载 Git 客户端, 并在 Git Bash 中使用命令行操作

命令作用
git -v验证是否安装并查看版本
git --help查看帮助
git reflog --oneline查看所有操作日志

大多数命令都可以在 VScodeGitHub Desktop 中图形化地执行

配置用户信息

使用 Git 前, 需要配置用户信息, 包括用户名和邮箱, 这样提交代码时才能知道是谁提交的

1
2
3
4
5
6
7
# 设置用户名
git config --global user.name "leaf"
# 设置邮箱
git config --global user.email "xxx@xxx"
# 查看配置信息
git config --list # 会打开 Vim 编辑器
git config --global --list # 查看全局配置信息

注: Git Bash 中清屏命令是 clear, 而不是 cls

创建仓库

仓库是用于存放代码的地方, 可以是本地的, 也可以是远程的, 实质上是一个隐藏的 .git 目录, 里面存放着 Git 的版本库

1
2
3
4
# 在当前目录下创建仓库
git init
# 在指定目录下创建仓库
git init xxx

仓库的三个区域

  • 工作区: 实际操作的文件夹
  • 暂存区: 用于存放暂时的改动, 位于 .git/index 文件中
  • 版本库: 存放历史记录, 位于 .git/objects 文件夹中

文件状态

  • 未跟踪 U: 未被 Git 跟踪(通常是新文件), 不在版本库中; 暂存后变为 A
  • 新添加 A: 已被 Git 跟踪(首次暂存), 未提交到版本库
  • 已修改 M: 已被 Git 跟踪, 且已被修改(不一定被暂存), 修改未提交到版本库
  • 未修改 : 已被 Git 跟踪, 且未被修改, 已提交到版本库
  • 已删除 D: 已被 Git 跟踪, 但已被删除, 删除操作未提交到版本库

文件修改和提交

Git 中, 文件的添加、修改、删除等操作都需要经过 add 命令, 将文件的改动添加到暂存区

命令作用
git add xxxxxx 文件添加到暂存区
git add .将所有改动过的文件添加到暂存区
git rm --cached xxx从暂存区移除文件
git rm xxx从工作区移除文件, 并将删除操作添加到暂存区
git status -s查看工作区和暂存区的状态
-s 表示简短输出, 结果形如 MM xxx
第一位表示暂存区, 第二位表示工作区
git ls-files查看暂存区的文件
git restore xxx将暂存区的文件覆盖到工作区
git commit -m "some message"将暂存区的文件提交到版本库
-m 表示附加的提交信息
如果不加 -m 参数, 会打开 Vim 编辑器

Git 中务必使用相对路径(相对于命令行所在的目录)

版本回退

Git 的每次提交都会生成一个 commit 及其对应的 SHA 值(作为版本号), 可以通过 SHA 值来回退到指定的 commit

命令作用
git log --oneline查看提交历史, 只显示 SHA 值和提交信息
git reset --hard xxx回退到指定的 commit, xxxSHA
git reset --soft xxx回退到指定的 commit, 但保留暂存区和工作区的改动
git reset --mixed xxx
git reset xxx
回退到指定的 commit, 但保留工作区的改动
git reset --hard HEAD放弃工作区和暂存区的所有改动

忽略文件

.gitignore 文件用于忽略不需要上传到 git 仓库的文件, 如 node_modules 目录、package-lock.json 文件等

内容作用
xxx忽略所有名为 xxx文件和目录
/xxx忽略当前目录下的名为 xxx文件和目录
/xxx/忽略当前目录下的名为 xxx目录
*.xxx忽略所有扩展名为 xxx 的文件
可以把 * 理解为除 / 之外的任意字符
/xxx/*.xxx忽略当前目录下的 xxx 目录中的所有扩展名为 xxx 的文件
不会忽略 /xxx/xxx/xxx.xxx 等子目录内的文件
/xxx/**/*.xxx忽略当前目录下的 xxx 目录中的所有扩展名为 xxx 的文件
会忽略 /xxx/xxx/xxx.xxx 等子目录内的文件
/xxx/**/?.xxx忽略当前目录下的 xxx 目录中的所有 x.xxx 文件
1.xxxa.xxx, 不会忽略 10.xxxaa.xxx
!xxx不忽略名为 xxx 的文件或目录
可以用于忽略整个目录, 但不忽略其中的某个文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 忽略 node_modules 目录
node_modules
# 忽略 package-lock.json 文件
package-lock.json
# 忽略 vscode 配置文件
.vscode
# 忽略编译结果
dist
# 忽略所有 .log 文件
*.log
# 忽略密钥文件
*.pem
*.cer
# 保留某个空文件夹
uploads/**/*.*
!.gitkeep
# 用于占位的文件一般命名为 .gitkeep

注意: .gitignore 文件只对未被 Git 跟踪的文件有效, 如果文件在之前的提交中已经被跟踪, 则需要先将其暂时移除

分支管理

Git 的分支管理是其最大的特色之一, 可以通过分支来实现多人协作、版本控制、功能开发等; 默认的分支是 mastermain

命令作用
git branch查看所有分支
git branch xxx创建分支 xxx
git checkout xxx切换到分支 xxx
暂存区和工作区的改动会转移到新分支
原分支变回最近一次提交的样子
没有更改时, 切换分支不影响两个分支的内容
git checkout -b xxx创建并切换到分支 xxx
git merge xxx将分支 xxx 合并到当前分支
git branch -d xxx删除分支 xxx
git branch -D xxx强制删除分支 xxx
git branch -m old new重命名分支 oldnew
  • 如果分支迁出后, 原分支没有新提交, 则将迁出的分支合并到原分支时, 迁出分支的所有提交会拼接到原分支, 并将 HEAD 指向合入分支的最新提交
  • 如果分支迁出后, 原分支有新提交, 则将迁出的分支合并到原分支时, Git 会尝试自动合并, 为原分支生成一个最新提交(将原分支和合入分支的最新提交合并), 如果这个最新提交里有冲突, 则需要手动解决冲突; 而合入分支的其他提交会拼接到原分支
  • 冲突会在对同一文件的同一部分进行不同的修改时产生, 尝试合并后, 原分支和合入分支的内容已合并, 但冲突部分暂时全部保留并被特殊标记, VScode 会引导我们解决冲突, 解决冲突后, 需要手动提交

远程仓库

Git 可以通过 SSHHTTPS 协议来与远程仓库通信, SSH 协议更安全, 但需要配置公钥和私钥

除了在自己的服务器上搭建 Git 仓库外, 还可以使用 GitHubGitLabGitee 等代码托管网站上的 Git 仓库

配置公钥和私钥

1
2
3
4
5
6
7
8
# 生成 SSH 密钥
ssh-keygen -t rsa -C "邮箱地址"
# 查看公钥
# 复制公钥, 粘贴到 GitHub 或 GitLab 的 SSH 设置中
cat ~/.ssh/id_rsa.pub
# 查看私钥
# 注意: 私钥不要泄露, 不要上传到网上
cat ~/.ssh/id_rsa

公钥和私钥简单来说是: 公钥加密的信息只能用私钥解密, 私钥加密的信息只能用公钥解密; 公钥用于交给要通信的对方, 私钥只有自己知道; 发信息时用对方的公钥加密, 收到信息时用自己的私钥解密

相关命令

命令作用
git remote add 远程仓库别名 远程仓库地址关联远程仓库, 别名一般取 origin
git push -u 远程仓库别名 分支名推送到远程仓库
若本地和远程分支名不一致, 写 本地分支名:远程分支名
git pull 远程仓库别名 分支名拉取远程仓库
相当于 git fetchgit merge 的合并操作
没有冲突时, 会自动合并到当前本地分支
git clone 远程仓库地址克隆远程仓库到当前目录
克隆后会自动关联远程仓库, 别名为 origin
如果要克隆到指定目录, 可以加上 ./xxx
git remote -v查看远程仓库
git remote show 远程仓库别名查看远程仓库详细信息
git branch -r查看远程分支
git branch -a查看所有分支
git push origin xxx创建远程分支(在远程仓库没有 xxx 分支时)
git push origin --delete xxx删除远程分支
git remote rm 远程仓库别名删除远程仓库关联
不会影响本地和远程仓库的内容
git remote rename old new重命名远程仓库别名

VScode 中的同步修改其实是 pullpush 的合并操作

标签管理

Git 可以给 commit 打标签, 用于标记重要的提交, 如版本号、发布日期等

命令作用
git tag查看所有标签
git show xxx查看标签 xxx 的详细信息
git tag xxx给当前 commit 打标签 xxx
git tag -a xxx -m "some message"给当前 commit 打标签 xxx, 并附加信息
git tag -d xxx删除标签 xxx
git push origin xxx推送标签 xxx 到远程仓库
git push origin --tags推送所有标签到远程仓库

⭐Express

Express 是一个基于 Node.js 平台的 Web 应用开发框架, 可以用于构建 Web 服务器、API 服务器等

1
2
3
4
5
6
# 创建项目
npm init
# 安装 Express
npm i express
# 编辑代码后启动服务器
node --watch app.js
1
2
3
4
5
6
7
8
9
10
11
12
// 导入 express 模块
const express = require('express')
// 创建应用对象
const app = express()
// 创建路由规则
app.get('/demo', (req, res) => {
res.send('Hello World!')
})
// 监听端口
app.listen(23333, () => {
console.log('Server is running at http://localhost:23333')
})

res.send()res.end() 类似, 但 res.send() 可以自动设置 Content-Type, 并且可以发送 BufferStringObjectArray

路由

Express 中的路由是指 URL 和处理函数之间的映射关系, 用于处理客户端的请求

1
app.请求方式('路径', (req, res) => xxx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// get 请求
app.get('/', (req, res) => {
res.send('这里是首页')
})
// post 请求
app.post('/post', (req, res) => {
res.send(req.body)
res.send('这里是 post 请求')
})
// all 表示所有请求方式
// * 用于匹配所有路径
// 注意: * 要放在所有路由的最后, 一般用于处理 404
app.all('*', (req, res) => {
res.send('404 Not Found')
})

路由参数

路由参数是指路径中的占位符, 用于获取客户端传递的数据; 可通过 req.params 获取路由参数对象

1
2
3
4
5
6
7
8
9
10
app.get('/user/:id', (req, res) => {
res.send(req.params)
// 访问 http://localhost:23333/user/123
// 返回 { id: '123' }
})
app.post('/user/:page.html', (req, res) => {
res.send(req.params.page)
// 访问 http://localhost:23333/user/2.html
// 返回 2
})

路由模块化

为便于维护和开发, 可以将路由规则封装到单独的模块中, 然后导入到主模块中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// routes/user.js
// 导入 express 模块
const express = require('express')
// 创建路由对象, 相当于一个小型的 app 对象
const router = express.Router()
// 创建路由规则
router.get('/login', (req, res) => {
res.send('这里是登录页面')
})
// rounter 也可以使用中间件
function middleware (req, res, next) {
console.log('这里是中间件')
next()
}
router.get('/register', middleware, (req, res) => {
res.send('这里是注册页面')
})
// 导出路由对象
module.exports = router
1
2
3
4
5
6
7
8
9
// app.js
// 导入 user 路由模块
const userRouter = require('./routes/user.js')
// 使用 user 路由模块
app.use('/user', userRouter)
// 访问 http://localhost:23333/user/login
// 返回 '这里是登录页面'
// 访问 http://localhost:23333/user/register
// 返回 '这里是注册页面'

请求对象

Express 完全兼容 Node.jshttp 模块的属性和方法, 但也提供了一些更高级的属性和方法, 还可以用第三方中间件来实现更多功能

属性或方法作用
req.method(原生)获取请求方式
req.url(原生)获取请求 URL
req.httpVersion(原生)获取 HTTP 版本
req.headers(原生)获取请求头对象
req.path(原生) 获取请求路径
req.query获取查询字符串对象
req.ip获取客户端的 IP 地址
req.get('xxx')获取请求头中的 xxx 属性
req.params获取路由参数对象
req.body获取请求体对象

解析请求体

Express 默认不解析 POST 请求的请求体, 需要使用中间件

1
2
3
4
5
6
7
8
9
10
11
12
// 解析 application/x-www-form-urlencoded 格式的请求体
app.use(express.urlencoded({ extended: false }))
// 解析 application/json 格式的请求体
app.use(express.json())

// 访问请求体
app.post('/post', (req, res) => {
res.send(`你好, ${req.body.name}`)
// 访问 http://localhost:23333/post
// 发送 { name: 'leaf', age: 18 }
// 返回 '你好, leaf'
})

解析文件

formidable 是一个用于解析 form 表单的包, 可以用于解析 form 表单的请求体, 包括图片、文件等

1
2
# 安装 formidable
npm i formidable
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 导入 formidable 模块
const formidable = require('formidable')

// post 请求
app.post('/upload', (req, res) => {
// 创建表单解析对象
const form = formidable({
multiples: true // 是否解析多个文件, 默认为 false
uploadDir: path.resolve(__dirname, '../public/upload') // 上传文件的保存路径, 默认为系统临时目录
})
// 解析请求体
form.parse(req, (err, fields, files) => {
// fields 为表单中的非文件字段的对象
// files 为表单中的文件字段的对象
if (err) {
res.status(500).send('Internal Server Error')
return
}
// 访问 http://localhost:23333/upload 上传文件
// 文件会保存到 public/upload 目录下
res.send('上传成功')
})
})

注意: 上传的文件不会自动删除, 需要手动进行管理

options
属性作用默认值
encoding设置编码utf-8
uploadDir设置上传文件的保存路径os.tmpdir()
keepExtensions是否保留文件扩展名false
allowEmptyFiles是否允许上传空文件false
minFileSize设置上传文件的最小大小(字节)1
maxFiles设置上传文件的最大数量Infinity
maxFileSize设置上传文件的最大大小(字节)200 * 1024 * 1024
maxTotalFileSize设置上传文件的最大总大小(字节)options.maxFileSize
maxFields设置非文件字段的最大数量1000
maxFieldsSize设置非文件字段的最大大小(字节)20 * 1024 * 1024
hashAlgorithm设置文件的哈希算法false
fileWriteStreamHandler设置文件写入流的处理函数null
filenamenewFilename 的处理函数
形如 `(field, file) => ‘xxx’
undefined
filter文件过滤函数(field, file) => true
files 对象

files 对象是一个键值对, 键是表单中的文件字段名, 值是一个对象, 包含了文件的信息

属性含义
size文件大小(字节)
filepath文件的保存路径
originalFilename文件的原始名
newFilename经过 filename 的回调函数处理后的文件名
mimetype文件的 MIME 类型(Content-Type
mtime文件的最后修改时间, 是一个 Date 对象
hashAlgorithm文件的哈希算法

传入的文件数据并不会保存到 files 对象中, 而是保存到 uploadDir 目录下, 并将其路径保存到 files 对象中

响应对象

属性或方法作用
res.statusCode(原生)设置状态码, 默认为 200
res.statusMessage(原生)设置状态消息, 默认与状态码对应
res.setHeader('key', 'value')(原生)设置响应头
res.write(xxx)(原生)向响应体中写入数据
res.end([xxx])(原生)结束响应, 向客户端发送数据
res.status(404)设置状态码
res.set('key', 'value')设置响应头
res.send(xxx)向客户端发送数据, 自动设置 Content-Type
可以发送 BufferStringObjectArray
res.json(xxx)向客户端发送 JSON 数据
res.download('path')向客户端发送下载
res.redirect('path')向客户端发送重定向
res.sendFile('path')向客户端发送文件
  • 后四种方法都会自动设置 Content-Type
  • res.download()res.sendFile() 的区别在于, 前者会自动设置 Content-Disposition 头, 用于告诉浏览器以下载的方式打开文件
  • 支持链式调用, 如 res.status(200).set('x-powered-by', 'Express').send('Hello World!')

中间件

Express 中的中间件是指一个可以访问请求和响应对象的函数, 用于封装和处理请求和响应的逻辑

  • 中间件分为全局中间件和路由中间件:
    全局中间件: 任何传入请求都会先经过这个中间件
    路由中间件: 只有传入请求的路径匹配时, 才会经过这个中间件
  • 注意: 书写顺序很重要, 全局中间件要放在所有路由之前
  • 如果全局中间件在某个路由中间件之后, 那匹配这个路由的请求就不会经过全局中间件
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
// 全局中间件
// 任何传入请求都会先经过这个中间件
app.use((req, res, next) => {
// 记录所有请求的 IP 地址和 URL
const { url, ip } = req
fs.appendFileSync(path.resolve(__dirname, 'visitors.log'), `${ip} ${url}\n`)
// 继续执行后续的回调函数
// 如果不调用 next, 不会执行路由回调函数
next()
})

// 路由中间件
// 一般会事先定义
function middleware (req, res, next) {
// 判断传入请求是否含有 code=000 的查询字符串
if (req.query.code !== '000') {
// 如果没有, 则返回 403
res.status(403).send('Forbidden')
} else {
// 如果有, 则继续执行后续的回调函数
next()
}
}
// 传入请求会先经过全局中间件, 再经过路由中间件
// 最后才会执行原本的回调函数
app.get('/demo', middleware, (req, res) => {
res.send('Hello World!')
})

静态资源

Express 内置了一个静态资源中间件 express.static, 用于向客户端发送静态资源文件

  • express.static 会自动设置 Content-TypeCache-Control 等响应头
  • express.static 会自动根据请求路径去指定目录下查找文件, 如果找到了就发送, 找不到就继续执行后续的回调函数
  • 但如果传入请求的路径是一个目录(包括 /), 则会自动发送目录下的 index.html 文件
  • 路由规则一般用于处理动态资源, 而静态资源中间件用于处理静态资源
1
2
// 将 public 目录下的文件作为静态资源
app.use(express.static(path.resolve(__dirname, 'public')))

防盗链

Express 可以通过中间件来实现防盗链, 即只允许指定的域名访问资源

  • 每一个发出的请求都会携带 Referer 头, 用于告诉服务器请求的来源
  • 服务器可以根据 Referer 头来判断请求的来源, 从而决定是否允许访问资源
  • 下面代码的功能与 Access-Control-Allow-Origin 类似, 但是 Access-Control-Allow-Origin 的拒绝请求的判断是在浏览器中进行的, 而下面代码的拒绝请求的判断是在服务器中进行的

Cloudflare 可以通过设置 Hotlink Protection 来实现防盗链, 无需在代码中实现

1
2
3
4
5
6
7
8
9
10
11
12
app.use((req, res, next) => {
// 获取请求头中的 Referer 属性
const referer = req.get('Referer')
// 判断请求头中的 Referer 属性是否包含指定的域名
if (referer && referer.includes('leafyee.xyz')) {
// 如果包含, 则继续执行后续的回调函数
next()
} else {
// 如果不包含, 则返回 403
res.status(403).send('Forbidden')
}
})

EJS

模板引擎是一种将模板和数据结合起来生成 HTML 的工具, Express 可以通过模板引擎来渲染 HTML 文件; 常用的模板引擎有 EJSPugHandlebars

随着 ReactVue 等前端框架的兴起, 前后端分离的开发模式越来越流行, 模板引擎的使用也越来越少

1
2
# 安装 EJS
npm i ejs
1
2
3
4
5
6
7
8
9
10
// 设置模板引擎
app.set('view engine', 'ejs') // 无需手动导入 ejs 模块
// 设置模板目录
app.set('views', path.resolve(__dirname, 'views'))
// 渲染模板
app.get('/demo', (req, res) => {
// 渲染 views/index.ejs
// 并发送渲染后的 HTML
res.render('index', { title: 'Hello World!' })
})
1
2
3
4
5
6
7
8
9
10
11
12
<!-- views/index.ejs -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= title %></title>
</head>
<body>
<h1><%= title %></h1>
</body>
</html>

语法

EJS 是一种简单的模板引擎, 可以通过 <% %> 来插入 JavaScript 代码, 通过 <%= %> 来插入 JavaScript 表达式的值

注意: HTML 中应当使用绝对路径(如 /xxx)来引用静态资源(指向 public/xxx

1
2
3
4
5
6
7
8
9
10
11
12
// 引入 ejs 模块
const ejs = require('ejs')

// 渲染模板字符串
// 也可以把 .html 文件的内容读取到内存中, 转换成字符串后再渲染
const htmlStr = ejs.render('<h1><%= title %></h1>', { title: 'Hello World!' })
console.log(html) // <h1>Hello World!</h1>

// 渲染模板文件
const htmlFile = ejs.renderFile('views/index.ejs', { title: 'Hello World!' }, (err, html) => {
err ? console.log(err) : console.log(html) // <!DOCTYPE html>...
})
1
2
3
4
5
6
7
8
9
10
11
12
<!-- views/index.ejs -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= title %></title>
</head>
<body>
<h1><%= title %></h1>
</body>
</html>
列表渲染
1
2
3
4
5
6
<!-- views/index.ejs -->
<ul>
<% for (let i = 0; i < 5; i++) { %>
<li><%= i %></li>
<% } %>
</ul>
条件渲染
1
2
3
4
5
6
<!-- views/index.ejs -->
<% if (title === 'Hello World!') { %>
<h1><%= title %></h1>
<% } else { %>
<h1>其他标题</h1>
<% } %>
包含模板
1
2
3
4
<!-- views/index.ejs -->
<% include header.ejs %>
<h1><%= title %></h1>
<% include footer.ejs %>

Lowdb

lowdb 是一个轻量级的 JSON 数据库, 可以用于存储数据, 支持链式调用和异步操作

1
2
# 安装 lowdb
npm i lowdb
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
// 导入 lowdb 模块
import { JSONFilePreset } from 'lowdb/node'
// 注意: lowdb 是一个纯 ES 模块, 不支持 CommonJS
// 要使用上述语法导入
// 需要在 package.json 中添加 "type": "module"
// 但此时又没法使用 require 和 __dirname 等 CommonJS 内容了
// 要相应地用 import path from 'path' 来代替 require('path')
import path from 'path'
// 并通过以下方式来获取 __dirname 和 __filename
import { fileURLToPath } from 'url'
import { dirname } from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

// 创建或导入数据库
const defaultData = { names: [], ages: [] }
const db = await JSONFilePreset('db.json', defaultData)

// 更新数据库
await db.update(({ names }) => names.push('leaf'))
await db.update(({ ages }) => ages.push(18))

// 也可以先写入临时数据, 再更新数据库
db.data.names.push('xiaoyezi')
db.data.ages.push(18)
await db.write()

// 查询数据库
const names = db.data.names
const ages = db.data.ages

⭐MongoDB

MongoDB 是一个基于分布式文件存储的数据库, 由 C++ 语言编写, 旨在为 Web 应用提供可扩展的高性能数据存储解决方案

  • 服务器 Server: 一个 MongoDB 实例, 可以包含多个数据库
  • 数据库 Database: 一个数据仓库, 可以包含多个集合
  • 集合 Collection: 类似于 JavaScript 中的数组, 是一个文档的集合, 可以包含多个文档
  • 文档 Document: 类似于 JavaScript 中的对象, 是一个键值对的集合, 可以包含多个键值对
  • 一般情况下, 一个项目对应一个数据库
  • 一个集合会存储同一类型的数据, 如用户数据、商品数据等

安装 (Windows)

  1. 下载 MongoDB 安装包
  2. 安装 MongoDB, 并将 bin 目录添加到环境变量
  3. xxx 目录下创建 data/db 目录, 用于存放数据
  4. xxx 目录下创建 logs 目录, 用于存放日志
  5. xxx 目录下创建 mongod.cfg 文件, 用于配置 MongoDB 服务
  6. xxx 目录下打开命令行, 输入 mongod --config mongod.cfg 启动 MongoDB 服务
  • 如果直接运行 mongod, 会按默认配置启动 MongoDB 服务, 数据会存放在 C:\data\db 目录下、日志会直接输出到控制台
  • 注意: 通过上面的方式启动 MongoDB 服务后, 不可以选中命令行窗口内的日志内容, 否则会导致服务暂停
  • 可以用绝对路径指定配置文件, 如 mongod --config D:\Database\mongod.cfg

基础配置文件

1
2
3
4
5
systemLog: # 系统日志
destination: file # 输出方式, 可以是 file、console、syslog
path: D:\xxx\logs\mongod.log # 日志文件路径
storage: # 存储
dbPath: D:\xxx\data\db # 数据库路径

客户端命令

  • 下面的命令需要下载 mongosh 客户端来运行
  • mongosh 可以直接运行 JavaScript 代码, 如 console.dir(db.demo)
  • 要断开连接, 可以输入 exitquit, 或直接关闭 mongosh 窗口
  • 推荐使用 MongoDB Compass 图形化客户端来操作数据库(内部集成了 mongosh, 界面设计也很好看)

数据库操作

命令作用
show dbs显示所有数据库
use xxx切换到 xxx 数据库
db显示当前数据库
db.dropDatabase()删除当前数据库

集合操作

命令作用
show collections显示当前数据库的所有集合
db.createCollection('xxx')创建一个名为 xxx 的集合
db.xxx.drop()删除 xxx 集合
db.xxx.renameCollection('abc')重命名 xxx 集合为 abc

文档操作

命令作用
db.xxx.find([{ filter }])查询 xxx 集合的文档, 可以传入查询条件
查询条件如 { name: 'xiaoyezi' }
会返回所有 namexiaoyezi 的文档
db.xxx.insertOne({ data })xxx 集合插入文档
db.xxx.updateOne({ filter }, { data })更新 xxx 集合的文档
不保留除 _id 以外的其他字段
db.xxx.updateOne({ filter }, { $set: { data } })更新 xxx 集合的文档
保留除修改字段以外的其他字段
db.xxx.deleteOne({ filter })删除 xxx 集合的文档

每个文档都有一个 _id 属性, 用于唯一标识文档, 可以手动指定 _id, 也可以不指定, MongoDB 会自动为其生成一个唯一的 ObjectId

mongoose

mongoose 是一个 MongoDBNode.js 客户端, 可以用于连接 MongoDB 数据库、操作数据库、定义模型等

1
2
# 安装 mongoose
npm i mongoose
方法作用
mongoose.connect(url[, options])连接数据库
mongoose.connection数据库连接对象
mongoose.connection.once('open', () => {})监听数据库连接成功事件
mongoose.connection.on('error', () => {})监听数据库连接失败事件
mongoose.connection.on('close', () => {})监听数据库连接断开事件
mongoose.connection.close()断开数据库连接
  • 事件监听中 once 表示只监听一次, on 表示持续监听
  • 如果 open 事件使用 on 而不是 once, 则可能存在断线重连后端口重复监听的问题
  • 由于连接是异步操作, 所有对数据库的操作应写在 open 事件的回调函数中, 以保证数据库连接成功后再进行操作
  • 也可以在 async 函数中使用 await 来等待数据库连接成功后再进行操作; 此时如果需要监听事件, 应将事件监听写在连接方法之前(不过此时无需监听事件)
1
2
3
4
5
6
7
8
9
10
// 导入 mongoose 模块
const mongoose = require('mongoose')
// 连接数据库, 路径为数据库名, 若不存在则会自动创建
mongoose.connect('mongodb://localhost:27017/demo')
// 监听事件
mongoose.connection.on('open', () => console.log('数据库连接成功'))
mongoose.connection.on('error', () => console.log('数据库连接失败'))
mongoose.connection.on('close', () => console.log('数据库连接断开'))
// 断开连接
setTimeout(() => mongoose.connection.close(), 5000)

模型

模型是 Mongoose 中的一个重要概念, 每个模型对应一个集合, 可以用于定义集合的字段、类型、默认值等, 并对集合进行增删改查操作

方法作用
new mongoose.Schema({ data })创建数据模板, 返回一个 Schema 实例
mongoose.model('模型名', 数据模板, { collection: '集合名' })创建模型, 返回一个 Model 对象
如果不指定集合名, 则会自动将模型名转为小写并加上复数形式作为集合名
Model.create({ data })向集合插入文档, 返回一个 Promise
new Model({ data }).save() 效果相同
Model.find([{ filter }])查询集合的文档, 返回一个 Promise
Model.deleteOne({ filter })删除集合的一个文档, 返回一个 Promise
Model.deleteMany({ filter })删除集合的多个文档, 返回一个 Promise
Model.updateOne({ filter }, { data })更新集合的一个文档, 返回一个 Promise
Model.updateMany({ filter }, { data })更新集合的多个文档, 返回一个 Promise

更新文档时, 效果相当于命令行中的 db.xxx.update({ filter }, { $set: { data } }), 即只修改与传入数据对应的字段, 不影响其他字段

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
// 引入 mongoose
const mongoose = require('mongoose');
// 异步函数
(async () => {
try {
// 连接数据库
await mongoose.connect('mongodb://localhost:27017/test')
console.log('数据库连接成功')
// 定义模型
const User = mongoose.model('users', { name: String, age: Number })
// 创建文档
await User.create({ name: '张三', age: 18 })
console.log('文档创建成功')
// 更新文档
await User.updateOne({ name: '张三' }, { $set: { age: 20 } })
console.log('更新文档成功')
// 查询文档
const users = await User.find()
console.log('查询文档成功', users)
// 删除文档
await User.deleteOne({ name: '张三' })
console.log('删除文档成功')
// 捕获错误并打印
} catch (error) {
console.log('error', error)
}
})();

也可以把模型封装在一个模块中, 然后按需导入使用, 以便于管理和维护

数据模板

数据模板是一个 Schema 实例, 用于定义集合的字段、类型、默认值等

字段对象属性作用默认值
type字段的类型String
required字段是否必填false
default字段的默认值undefined
enum字段的枚举值, 输入值必须是其中一个undefined
unique字段的唯一性, 重建集合后生效false
match字段的正则表达式undefined
validate字段的自定义验证函数undefined
  • type 可以为 StringNumberDateBooleanArrayObjectBufferUUIDMixedObjectId
  • 可以用 Buffer 类型来存储图片, 但一般会将图片存储在服务器上, 只将图片的路径存储在数据库中
  • 只需设置 type 时, 可以直接写 key: type
  • 插入的数据中如果有模板中没有的字段, 该字段会被自动忽略
  • 永远不要相信用户的输入
1
2
3
4
5
const userSchema = new mongoose.Schema({
name: { type: String, required: true, unique: true },
age: Number,
gender: { type: String, enum: ['male', 'female', 'other'] }
})

过滤器

上面的 filter 用于筛选文档, 除了指定某字段等于某值外, 还可以指定某字段大于、小于、包含等于某值, 以及使用逻辑与、或、非等

筛选器和作用示例
{ key: value }
字段等于某值
{ name: 'xiaoyezi' }
{ key: { $gt: value } }
字段大于某值
{ age: { $gt: 18 } }
{ key: { $lt: value } }
字段小于某值
{ age: { $lt: 18 } }
{ key: { $gte: value } }
字段大于等于某值
{ age: { $gte: 18 } }
{ key: { $lte: value } }
字段小于等于某值
{ age: { $lte: 18 } }
{ key: { $in: [value1, value2] } }
字段包含某值
{ name: { $in: ['xiaoyezi', 'leaf'] } }
{ key: { $nin: [value1, value2] } }
字段不包含某值
{ name: { $nin: ['xiaoyezi', 'leaf'] } }
{ key: { $exists: true } }
字段存在
{ name: { $exists: true } }
{ key: { $exists: false } }
字段不存在
{ name: { $exists: false } }
{ key: { $regex: /pattern/ } }
{ key: new RegExp('pattern') }
{ key: /pattern/ }
字段匹配正则表达式
{ name: { $regex: /xiaoyezi/ } }
{ name: new RegExp('xiaoyezi') }
{ name: /xiaoyezi/ }
{ key: { $or: [{ filter1 }, { filter2 }] } }
逻辑
{ $or: [{ name: 'xiaoyezi' }, { age: 18 }] }
{ key: { $and: [{ filter1 }, { filter2 }] }}
逻辑
{ $and: [{ name: 'xiaoyezi' }, { age: 18 }]}
{ key: { $not: { filter } } }
逻辑
{ name: { $not: { name: 'xiaoyezi' } } }

高级读取

方法作用
Model.find([{ filter }])
.select({ name: 1, age: 0 })
查询集合的文档, 只返回值为 1 的字段
只有 _id 会默认返回, 要排除 _id 可以写 { _id: 0 }
Model.find([{ filter }])
.sort({ key: 1 })
查询集合的文档, 按 key 升序排序
1 表示按照字段升序排序, -1 表示按照字段降序排序
Model.find([{ filter }])
.limit(10)
查询集合的文档, 限制返回的文档数量
Model.find([{ filter }])
.skip(10)
查询集合的文档, 跳过前 10 个文档

上面的读取方法可以链式调用

1
2
3
4
5
6
// 按照年龄降序排序, 只返回年龄第 4-6 名的名字
const users = await User.find()
.sort({ age: -1 })
.skip(3)
.limit(3)
.select({ name: 1 })

官方驱动

MongoDB 官方提供了一个 Node.js 驱动, 可以用于连接 MongoDB 数据库、操作数据库、定义模型等

mongoose 连接远程数据库进行增删改查操作老是报错, 愤而使用官方驱动, 本节内容基于官方文档

1
2
# 安装 mongodb
npm i mongodb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 导入 mongodb 模块
const { MongoClient } = require('mongodb')
// 连接服务器
const client = new MongoClient('mongodb://localhost:27017')
// 连接数据库
const db = client.db('demo')
// 连接集合
const coll = db.collection('demo')
// 增删改查
;(async () => {
try {
await coll.insertOne({ name: 'leaf', age: 18 }) // 插入一条数据
console.log(await coll.find().toArray()) // 查询所有数据
await coll.updateOne({ name: 'leaf' }, { $set: { age: 20 } }) // 更新数据
await coll.deleteOne({ name: 'leaf' }) // 删除数据
client.close() // 关闭连接
} catch (err) {
console.error(err)
}
})()
方法作用
coll.find({ filter })查询集合的文档, promise 返回一个 Cursor 对象
coll.find().toArray()查询集合的文档, promise 返回一个数组
coll.findOne({ filter })查询集合的一个文档
coll.insertOne({ data })向集合插入一个文档
coll.insertMany([{ data }, { data }, ...])向集合插入多个文档
coll.updateOne({ filter }, { $set: { data } })更新集合的一个文档
coll.updateMany({ filter }, { $set: { data } })更新集合的多个文档
coll.updateOne({filter}, { $push: { data }})向集合的一个文档中的数组字段中添加一个元素
coll.updateMany({filter}, { $push: { data }})向集合的多个文档中的数组字段中添加一个元素
coll.replaceOne({ filter }, { data })替换集合的一个文档
coll.deleteOne({ filter })删除集合的一个文档
coll.deleteMany({ filter })删除集合的多个文档
coll.countDocuments({ filter })统计集合的文档数量
  • 以上的方法都是异步的, 返回 Promise 对象, 可以使用 await
  • 查询后的结果是一个 Cursor 对象, 可以使用 sortlimitskip 等方法来进行高级查询, 但是里面有很多额外的属性, 可以在最后使用 toArray 方法来转换成数组
  • 向数组添加元素示例: ({ name: 'leaf' }, { $push: { hobbies: 'coding' }}), 如果 hobbies 不存在, 则会自动创建一个数组字段
  • 过滤器的使用方法与 mongoose 中的一样, 不再赘述
  • 高级读取的方法也与 mongoose 中的一样, 不再赘述

批量操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
await coll.bulkWrite([
{
insertOne: {
document: {
name: 'leaf',
age: 18
}
}
},
{
deleteMany: {
filter: { age: { $lt: 20 } }
}
}
])

⭐Puppeteer

Puppeteer 是一个 Node.js 库, 提供了一组用于操纵 ChromeChromiumAPI, 可以用于爬取网页数据、生成网页截图、生成 PDF 等; 官方文档 写地很详细和易懂, 要用什么去查即可

1
2
3
4
# 安装 puppeteer
npm i puppeteer
# 或者安装不带 Chromium 的 puppeteer-core
npm i puppeteer-core

配置文件

puppeteer 可以用两种方法来进行设置:

  • 创建一个设置文件, 如 puppeteer.config.js
  • 通过环境变量来设置
  • HTTP_PROXYHTTPS_PROXYNO_PROXY 只能通过环境变量来设置
  • 如果设置项会影响 puppeteer 的安装, 则修改后需要删除和重新安装 puppeteer
  • 设置项详见官方文档
1
2
3
4
5
6
// puppeteer.config.js
module.exports = {
// 设置 puppeteer 的 chromium 缓存路径
// 默认 $HOME/.cache/puppeteer
cacheDirectory: ```~/.cache/puppeteer```
}

浏览器对象

方法作用
puppeteer.launch([settings])打开浏览器, 返回一个 Browser 对象
puppeteerCore.launch({ executablePath, ... })打开浏览器, 返回一个 Browser 对象
需要传入一个可执行文件路径
chrome/edge://version 中可查看
browser.newPage()打开一个新的页面, 返回一个 Page 对象
browser.close()关闭浏览器

浏览器设置

puppeteer.launch 方法可以传入一个配置对象, 用于设置浏览器的一些参数, 如路径、视口、用户代理等

属性作用
executablePath浏览器的可执行文件路径
puppeteer-core 需要设置
slowMo减慢操作的速度, 单位为 ms, 默认为 0
defaultViewport视口的默认大小, 如 {width:1920,height:1080}, 默认为 800x600
args浏览器的启动参数数组, 如 ['--no-sandbox']
1
2
3
4
const browser = await puppeteer.launch({
defaultViewport: { width: 1920, height: 1080 },
args: ['--window-size=1920,1080']
})

网页对象

puppeteerpage 对象是一个 Browser 对象的实例, 可以用于访问网页、操作网页、获取网页信息等

方法作用
page.goto(url
[, { waitUntil, timeout, referer }])
访问一个网页
page.evaluate(() => {})在网页中执行 JavaScript 代码
可以使用 documentwindow 等对象
page.waitForSelector(selector)等待一个元素出现在网页中
page.waitForNavigation
([{ waitUntil, timeout }])
等待页面跳转
page.waitForNetworkIdle
({ idleTime(空闲时间, 默认500ms),
concurrency(判定空闲的最大并发请求数, 默认0),
timeout })
等待网络空闲
page.type(selector, text)在一个输入框中输入文本
page.click(selector)点击一个元素
page.hover(selector)悬停在一个元素上
page.focus(selector)聚焦一个元素
page.close()关闭网页
  • 基本所有方法都是异步的, 返回 Promise 对象, 可以使用 await(如果不确定可以加个 await 看编辑器有没有提示)
  • waitUntil 可以设置为
    load: 页面的 load 事件触发时, 默认值
    domcontentloaded: 页面的 DOMContentLoaded 事件触发时
    networkidle0: 网络空闲时
    networkidle2: 网络空闲 2 秒后
  • timeout 默认为 30000, 单位为 ms, 可以设置为 0 来禁用超时
  • goto 中的 referer 会覆盖 setExtraHTTPHeaders 中的 Referer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 导入 puppeteer
const puppeteer = require('puppeteer')

;(async () => {
try {
const browser = await puppeteer.launch() // 打开浏览器
const page = await browser.newPage() // 打开一个页面
await page.goto('YOUR_SITE') // 访问一个网站
const element = await page.waitForSelector('.class-name') // 选择一个元素
await element.click() // 对元素进行操作, 比如点击一下
await browser.close() // 关闭浏览器
} catch (error) {
console.error(error)
}
})()

进程退出时, puppeteer 会自动关闭浏览器, 如果操作结束后会立即退出, 无需手动关闭浏览器; 并且, 如果 browser 是在 try 语句中创建的, 那在 catchfinally 语句中无法访问 browser 对象(因为已被垃圾回收机制回收)

截图

page.screenshot(settings) 方法可以用于截取网页的截图, 可以设置截图的路径、质量、类型等

属性作用默认值
fullPage是否截取整个网页false
path截图的保存路径undefined
quality截图的质量, 0-100
由于 png 是无损压缩, 所以该属性对其无效
undefined
type截图的类型, jpegpngwebppng

PDF

page.pdf(settings) 方法可以用于生成网页的 PDF 文件, 可以设置 PDF 的路径、格式、尺寸等

属性作用默认值
displayHeaderFooter是否显示页眉页脚false
formatPDF 的格式, A4Legalletter
height纸张的高度, 数字或字符串undefined
width纸张的宽度, 数字或字符串undefined
margin纸张的边距, 对象, 属性为数字或字符串
{top:'10mm',right:'10mm',bottom:'10mm',left: '10mm'}
undefined
outline是否生成大纲false
pageRanges生成 PDF 的页码范围''(全部)
pathPDF 的保存路径undefined
scalePDF 的缩放比例, 0.1-21

页面设置

page 对象的 setXXX 方法可以用于设置页面的一些属性, 如 userAgentviewportcookie

方法作用
page.setCookie(cookieObjA, cookieObjB, ...)设置页面的 cookie
page.setExtraHTTPHeaders({ key: value })设置页面的 HTTP
page.setUserAgent({ userAgent: 'xxx' })设置页面的 userAgent
page.setViewport({ width, height, ... })设置页面的视口
page.setJavaScriptEnabled(true)设置页面的 JavaScript 是否启用
page.setGeolocation({ latitude, longitude })设置页面的地理位置

setUserAgent 可以用来模拟不同的设备和浏览器, 默认的 userAgentHeadlessChrome, 可以设置为 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36 来模拟 Chrome 浏览器(避免被识别为爬虫)

属性作用
namecookie 的名字
valuecookie 的值
urlcookie 的域名
domaincookie 的域名
pathcookie 的路径
expirescookie 的过期时间
httpOnlycookie 是否只能通过 HTTP 协议访问
securecookie 是否只能通过 HTTPS 协议传输
sameSitecookieSameSite 属性
prioritycookie 的优先级
samePartycookie 是否只能通过同一站点访问

元素操作

ElementHandle 对象是一个 JSHandle 对象的实例, 可以用于操作网页元素, 如点击、输入、获取属性等

方法作用
page.$(selector)选择一个元素, 返回一个 ElementHandle 对象
page.$$(selector)选择多个元素, 返回一个 ElementHandle 对象的数组
page.$eval(selector, ele => null)$evaluate 的结合, 返回一个 Promise
page.$$eval(selector, eles => null)$$evaluate 的结合, 返回一个 Promise
elementHandle.click()点击一个元素
elementHandle.type('text')在一个输入框中输入文本
elementHandle.select('value')选择一个下拉框中的选项
elementHandle.focus()聚焦一个元素
elementHandle.hover()悬停在一个元素上
elementHandle.screenshot({ path })截取一个元素的截图, 保存到指定路径
  • 与真实的 DOM 元素不同, ElementHandle 对象的操作都是异步的, 返回 Promise 对象
  • 其他的方法与 DOM 元素的操作方法类似, 不再赘述
  • 以上操作也可以用 page.click(selector) 等方法来代替, 效果相同

iframe 操作

iframe 是一个 HTML 标签, 可以用于嵌入其他网页, puppeteer 可以用 frame 对象来操作 iframe 中的元素

方法作用
page.frames()返回一个 Frame 对象的数组
page.mainFrame()返回主 frame 对象
frames.find(frame => null)用数组的 find 方法来查找 iframe
frame.xxxFrame 对象的方法与 page 对象的方法基本相同
frame.childFrames()返回一个 Frame 对象的数组
用于查找 iframe 中的 iframe
  • frame 没有 screenshotpdfmainFrameframes 等方法
  • 要等待 iframe 加载完毕, 直接使用 page.waitForNetworkIdle() 即可
  • 但要等待 iframe 中的元素出现时, 需要使用 frame.waitForSelector() 方法
1
2
3
4
5
6
// 获取所有的 iframe
const frames = page.frames()
// 获取第一个 iframe
const frame = frames[0]
// 获取 url 为 'https://xxx.com' 的 iframe
const frame = frames.find(frame => frame.url() === 'https://xxx.com')

⭐Cloudflare Workers

Wrangler

Wrangler 是一个 Cloudflare Workers 的命令行工具, 可以用于创建、部署、管理 Cloudflare Workers, 详见官方文档

1
2
# 安装 wrangler
npm i -D wrangler
命令作用
npm create cloudflare初始化项目
wrangler docs打开文档
wrangler login登录 Cloudflare 账号
wrangler dev启动本地开发服务器
wrangler deploy部署项目至 Cloudflare Workers
wrangler d1 create <name>创建一个数据库
wrangler d1 info <name>查看数据库信息
wrangler d1 list查看账号的所有数据库
wrangler kv:namespace create <name>创建一个命名空间
wrangler kv:namespace list查看账号的所有命名空间
wrangler r2 bucket create <name>创建一个存储桶
wrangler r2 bucket list查看账号的所有存储桶
  • D1Cloudflare 推出的一个 Serverless 数据库, 采用 SQL 语法
  • KV 也是一个 Serverless 数据库, 数据以键值对的形式存储
  • R2Cloudflare 推出的对象存储服务, 兼容 S3 协议

wrangler.toml

wrangler.toml 是一个配置文件, 用于配置 Cloudflare Workers 的一些参数

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
name = "my-worker" # 项目名称
main = "src/index.js" # 入口文件
compatibility_date = "2022-07-12" # 兼容性日期
workers_dev = false # 是否启用 *.workers.dev 域名


# 环境变量
[vars]
NAME = "leaf" # 通过 env.NAME / import.meta.env.NAME 访问
AGE = 18 # 通过 env.AGE / import.meta.env.AGE 访问

# D1 数据库
# 用命令创建后会给出以下字段
[[d1_databases]]
binding = "<BINDING_NAME>"
database_name = "<DATABASE_NAME>"
database_id = "<DATABASE_ID>"


# KV 数据库
# 用命令创建后会给出以下字段
[[kv_namespaces]]
binding = "<BINDING_NAME1>"
id = "<NAMESPACE_ID1>"

[[kv_namespaces]]
binding = "<BINDING_NAME2>"
id = "<NAMESPACE_ID2>"


# R2 存储桶
# 用命令创建后会给出以下字段
[[r2_buckets]]
binding = "<BINDING_NAME1>"
bucket_name = "<BUCKET_NAME1>"

[[r2_buckets]]
binding = "<BINDING_NAME2>"
bucket_name = "<BUCKET_NAME2>"


# AI
[ai]
binding = "AI" # 通过 env.AI 访问


# 本地开发服务器
[dev]
port = 8787 # 本地开发服务器的端口

示例

以下是一些 worker 环境的基本代码, 点击查看更多教程

1
2
3
4
5
6
// src/index.js
export default {
async fetch(request, env, context) {
return new Response("Hello World!")
}
}

返回网页

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export default {
async fetch(request) {
const html = `
<!DOCTYPE html>
<body>
<h1>Hello World</h1>
<p>This markup was generated by a Cloudflare Worker.</p>
</body>`
return new Response(html, {
headers: {
"content-type": "text/html;charset=UTF-8"
}
})
}
}

返回 JSON

1
2
3
4
5
6
7
8
export default {
async fetch(request) {
const data = {
hello: "world",
}
return Response.json(data)
}
}

简单的代理服务器

1
2
3
4
5
6
export default {
async fetch(request) {
const remote = "https://www.google.com"
return await fetch(remote, request)
}
}

Hono

Hono 是一个跨平台的 Web 框架, 适用于 Cloudflare Workers, Node.js, Deno, Bun, Vercel 等环境; Hono 的使用方法与 Express 类似

1
2
3
4
5
# 创建一个 hono 项目
pnpm create hono xxx
deno run -A npm:create-hono xxx
bunx create-hono xxx
# 可以选择不同环境的模板
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 引入 hono
import { Hono } from 'hono'
// 创建一个 hono 实例
const app = new Hono()
// 添加一个路由
app.get('/', c => {
return c.text('Hello World!')
})

// 对于 Cloudflare Workers
export default app
// 对于 Deno
Deno.server([{ port: xxx }, ]app.fetch)
// 对于 Bun
export default app | { port: xxx, fetch: app.fetch }
// 对于 Node.js
import { server } from '@hono/node-server'
server(app | { port: xxx, fetch: app.fetch })

CORS

Hono 提供了一个 cors 中间件, 可以用于设置 CORS

1
2
3
import { cors } from 'hono/cors'
// ...
app.use('*', cors([options]))

options.origin 默认为 *, 详见官方文档

App

方法作用
new Hono()创建一个 Hono 实例
new Hono().basePath('/api')设置基础路径, 路由的 path 会自动添加在基础路径之后
app.get(path, handler)添加一个 GET 路由
app.post(path, handler)添加一个 POST 路由
app.put(path, handler)添加一个 PUT 路由
app.delete(path, handler)添加一个 DELETE 路由
app.all(path, handler)添加一个通用路由
app.use([path,] middleware)添加一个中间件
  • 路径中可以使用 * 来匹配任意字符串
  • 路径中可以使用 :key 来匹配任意字符串, 通过 c.req.param('key') 来获取参数
  • :key 可以用正则表达式来匹配, 如 :data{[0-9]+}, :title{[a-zA-Z]+}
  • 路由顺序是按照添加的顺序来的, 先添加的先匹配

Context

方法作用
c.env环境变量 (wrangler.toml 中)
c.text('text')创建文本响应
c.json({ data })创建 JSON 响应
c.html('<App />'创建 HTML 响应, 支持 JSX
c.notFound()创建 404 响应
c.redirect('url'[, status])创建重定向响应, 默认状态码为 302
c.reqHonoRequest 对象
c.res一般在中间件中使用, 如 c.res.headers.append('key', 'value')
c.set('key', 'value')一般在中间件中使用, 设置变量
c.get('key')一般在路由中使用, 获取变量

不是必须用 c.text() 等方法, 也可以自己创建 Response 对象

Request

c.req 是一个 HonoRequest 对象, 用于获取请求的一些信息

方法作用
c.req.params('key')获取路由中 :key 的值
c.req.query('key')获取查询参数
c.req.queries('key')获取查询参数数组, 适用于有多个相同参数的情况
c.req.header('key')获取请求头
c.req.parseBody()解析请求体, 返回 Promise
c.req.json()解析请求体为 JSON 格式, 返回 Promise
c.req.text()解析请求体为 text 格式, 返回 Promise
c.req.arrayBuffer()解析请求体为 ArrayBuffer 格式, 返回 Promise
c.req.path请求的路径
c.req.url请求的完整 URL
c.req.method请求的方法
c.req.raw请求的原始 Request 对象

c.req.query 会自动进行 URL 解码, 无需额外处理

MongoDB Web SDK

MongoDB 的官方 Node.js 驱动无法在 Cloudflare Workers (即使开启了 Node 兼容) 或 Web 中的 React 项目中正常使用, 但是可以使用 MongoDB Web SDK 来操作 MongoDB 数据库, 详见官方文档

注意: AtlasData ServicesRealmApp Services 是相对独立的两个产品, 其中前者是一个完整的 MongoDB 数据库服务器实例, 而后者是一个 Serverless 数据库服务

1
2
3
4
# 安装 MongoDB Web SDK
pnpm add realm-web
# 如果要在 Node.js 中使用
pnpm add realm-web node-fetch abort-controller
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 导入 MongoDB Web SDK
// 也可以通过 <script> 标签引入
import * as Realm from 'realm-web'
const { BSON: { ObjectId } } = Realm
// 初始化 Realm
// APP_ID 为 Realm 应用的 ID
const app = new Realm.App({ id: APP_ID })
// 使用 API Key 进行登录
const credentials = Realm.Credentials.apiKey(apiKey)
await app.logIn(credentials)
// 将 Atlas 中的数据库连接到 Realm
// 见 https://www.mongodb.com/docs/atlas/app-services/mongodb/#std-label-link-a-data-source
// 连接数据库
const mongo = app.currentUser.mongoClient(DATA_SOURCE_NAME)
const db = mongo.db('DATABASE_NAME')
const coll = db.collection('COLLECTION_NAME')

太麻烦了…, 还是直接用 Node.js 的驱动吧 (可以部署在 Deno Deploy 上)

  • 标题: Node.js学习笔记
  • 作者: 小叶子
  • 创建于 : 2024-02-18 09:10:27
  • 更新于 : 2024-05-20 15:13:42
  • 链接: https://blog.leafyee.xyz/2024/02/18/Node学习笔记/
  • 版权声明: 版权所有 © 小叶子,禁止转载。
评论