Fork me on GitHub

第三方图片资源403问题的一种解决方案

有时候如果我们项目的接口数据是后端通过爬虫抓取的第三方平台内容,而第三方平台对图片资源做了防盗链保护处理。那么好多图片就会资源请求失败返回 403。

第三方平台怎么处理图片资源保护的?

服务端一般使用 Referer 请求头识别访问来源,然后处理资源访问。

image-20200405134347749

Referer 是什么东西?

扩展参考:http://www.ruanyifeng.com/blog/2019/06/http-referer.html

1
Referer 是 HTTP 请求头的一部分,当浏览器向 Web 服务器发送请求的时候,一般会带上 Referer,它包含了当前请求资源的来源页面的地址。服务端一般使用 Referer 请求头识别访问来源,可能会以此进行统计分析、日志记录以及缓存优化等。

需要注意的是 referer 实际上是 “referrer” 误拼写。参见 HTTP referer on Wikipedia (HTTP referer 在维基百科上的条目)来获取更详细的信息。

怎么解决?

不要发送 referrer ,对方服务端就不知道你从哪来的了,姑且认为是你是自己人吧。

如何设置不发送 referrer?

<a><area><img><iframe><script> 或者 <link> 元素上的 referrerpolicy 属性为其设置独立的请求策略,例如:

1
<img src="http://……" referrerpolicy="no-referrer" />

或者直接在 HTMl 页面头中通过 meta 属性全局配置:

1
<meta name="referrer" content="no-referrer" />

Node.js

Node.js

一、Node.js 基础

1、Node.js 是什么

Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行环境,用于后端 开发。通俗来讲,Node.js 就是一款应用程序、一款软件,它可以运行 JavaScript 。

JavaScript 有两种运行环境:浏览器和 Node.js。在浏览器中 JavaScript 通过调用浏览器内置的 DOM、BOM 这样的 API 函数(接口)操作 DOM 和 BOM,并被浏览器的 JavaScript 解析引擎(例如 Chrome 的 V8 引擎)所解析运行。同样在 Node.js 中也是这样,只不过他们各自的内置 API 不同

2、Node.js 可以做什么

二、计算机基础知识

1、计算机基本组成

image-20230411101544829

计算机由硬件和软件两大部分组成。硬件主要有以下部分:

CPU:中央处理器,计算机最核心的配件,负责所有的计算、逻辑处理

内存:用来存储数据。程序、运行的游戏、打开的浏览器都要加载到内存中才能运行,程序读取的数据、计算的结果也都在内存中,内存的大小决定了能加载的东西的多少。特点是读写速度较快,断电丢失数据

硬盘:用来存储数据。特点是读写数据较慢,断电不丢失数据

主板:存放在内存中数据需要被 CPU 读取,CPU 计算完成后,还要把数据写入到内存中,然而 CPU 不能直接插在内存上,这就需要主板出马了,主板上很多个插槽,CPU 和内存都是插在主板上,主板的芯片组和总线解决了 CPU 和内存之间的通讯问题,芯片组控制数据传输的流转,决定数据从哪里流向哪里,总线是实际数据传输的告诉公里,总线速度决定了数据的传输速度

显卡:显卡里有 GPU 图形处理器,主要负责图形渲染,它将处理完的信号传递给显示器,最终由显示器呈现画面。使用图形界面操作系统的计算机,显卡是必不可少的。现在的主板都带了内置的显卡,如果想玩游戏、做图形渲染,一般需要一张单独的显卡,插在主板上

计算机软件分为系统软件和应用软件两大类。系统软件是指各类操作系统,如 Windows、Linux、UNIX、MacOS 等。操作系统本质上也是一种应用程序,用来管理和调度硬件资源。比如操作系统可以决定 CPU 处理哪些程序,比如操作系统可以与硬盘交互,读写文件。可以还包括操作系统的补丁程序及硬件驱动程序都属于系统类软件。应用程序是指用户可以使用的各种程序设计语言,以及各种程序设计语言编制的应用程序的集合。

2、程序运行的基本流程

当我们打开一个程序的时候,首先系统会将程序从硬盘中载入到内存中,再交由 CPU 处理。CPU 处理完之后如果遇到图形信号将其交由显卡做进一步处理,最终呈现图像。遇到声音信号交由声卡处理最终呈现声音。

3、进程与线程

进程:可以理解为程序的一次执行过程。比如打开资源管理器,我们可以看到计算机正在执行的进程

image-20230411104135960

线程:线程是一个进程中执行的一个执行流。一个线程一定是属于某个进程的。一个进程可以分成很多个线程。可以将进程理解为一个完整的大的项目,而线程是这个项目中的某一个功能模块

三、内置模块 Buffer(缓冲器)

1、概念

Buffer 是一个类似于数组的对象 ,用于表示固定长度的字节序列。 Buffer 本质是一段内存空间,专门用来处理 二进制数据

image-20230411104858494

2、特点

  • Buffer 大小固定且无法调整
  • Buffer 性能较好,可以直接对计算机内存进行操作
  • 每个元素的大小为 1 字节(byte)

3、使用

Buffer.alloc 创建 Buffer

image-20230411105148681

Buffer.allocUnsafe 创建 Buffer

image-20230411105210601

Buffer.from 创建 Buffer

image-20230411105231269

toString 方法将 Buffer 转为字符串

image-20230411105335016

toString 默认是按照 utf-8 编码方式进行转换的

四、内置模块 fs 文件系统

fs 全称为 file system ,称为文件系统 ,是 Node.js 中的内置模块,可以对计算机中的磁盘进行操作

1、文件写入

文件写入就是将数据保存到文件中,我们可以使用如下几个方法来实现该效果

image-20230411105732751

writeFile 异步写入

语法: fs.writeFile(file, data[, options], callback)

参数说明:

  • file 文件路径
  • data 待写入的数据
  • options 选项设置 (可选)
  • callback 写入 err 回调

返回值: undefined

代码示例:

1
2
3
4
5
6
7
8
9
10
11
// require 是 Node.js 环境中的'全局'变量,用来导入模块
const fs = require("fs");
//将 『三人行,必有我师焉。』 写入到当前文件夹下的『座右铭.txt』文件中
fs.writeFile("./座右铭.txt", "三人行,必有我师焉。", (err) => {
//如果写入失败,则回调函数调用时,会传入错误对象,如写入成功,会传入 null
if (err) {
console.log(err);
return;
}
console.log("写入成功");
});

writeFileSync 同步写入

语法: fs.writeFileSync(file, data[, options])

参数与 fs.writeFile 大体一致,只是没有 callback 参数

返回值: undefined

代码示例:

1
2
3
4
5
try {
fs.writeFileSync("./座右铭.txt", "三人行,必有我师焉。");
} catch (e) {
console.log(e);
}

注意 writeFile 和 writeFileSync 会将文件中原有的内容覆盖掉,因此有了追加写入

appendFile / appendFileSync 追加写入

appendFile 作用是在文件尾部追加内容,appendFile 语法与 writeFile 语法完全相同

代码示例:

1
2
3
4
5
fs.appendFile("./座右铭.txt", "择其善者而从之,其不善者而改之。", (err) => {
if (err) throw err;
console.log("追加成功");
});
fs.appendFileSync("./座右铭.txt", "\r\n温故而知新, 可以为师矣");

createWriteStream 流式写入

语法: fs.createWriteStream(path[, options])

返回值: Object

代码示例:

1
2
3
4
5
6
let ws = fs.createWriteStream("./观书有感.txt");
ws.write("半亩方塘一鉴开\r\n");
ws.write("天光云影共徘徊\r\n");
ws.write("问渠那得清如许\r\n");
ws.write("为有源头活水来\r\n");
ws.end();

程序打开一个文件是需要消耗资源的 ,流式写入可以减少打开关闭文件的次数。 流式写入方式适用于 大文件写入或者频繁写入 的场景, writeFile 适合于 写入频率较低的场景

写入文件的场景

文件写入 在计算机中是一个非常常见的操作,下面的场景都用到了文件写入

  • 下载文件
  • 安装软件
  • 保存程序日志,如 Git
  • 编辑器保存文件
  • 视频录制

当 需要持久化保存数据 的时候,应该想到 文件写入

2、文件读取

文件读取顾名思义,就是通过程序从文件中取出其中的数据,我们可以使用如下几种方式:

image-20230411111625646

readFile 异步读取

语法: fs.readFile(path[, options], callback)

返回值: undefined

代码示例:

1
2
3
4
5
6
7
8
9
10
//导入 fs 模块
const fs = require("fs");
fs.readFile("./座右铭.txt", (err, data) => {
if (err) throw err;
console.log(data);
});
fs.readFile("./座右铭.txt", "utf-8", (err, data) => {
if (err) throw err;
console.log(data);
});

readFileSync 同步读取

语法: fs.readFileSync(path[, options])

返回值: string | Buffer

代码示例:

1
2
let data = fs.readFileSync("./座右铭.txt");
let data2 = fs.readFileSync("./座右铭.txt", "utf-8");

createReadStream 流式读取

语法: fs.createReadStream(path[, options])

返回值: Object

代码示例:

1
2
3
4
5
6
7
8
9
10
11
//创建读取流对象
let rs = fs.createReadStream("./观书有感.txt");
//每次取出 64k 数据后执行一次 data 回调
rs.on("data", (data) => {
console.log(data);
console.log(data.length);
});
//读取完毕后, 执行 end 回调
rs.on("end", () => {
console.log("读取完成");
});

读取文件应用场景

  • 电脑开机
  • 程序运行
  • 编辑器打开文件
  • 查看图片
  • 播放视频
  • 播放音乐
  • Git 查看日志
  • 上传文件
  • 查看聊天记录

3、文件移动与重命名

在 Node.js 中,我们可以使用 rename 或 renameSync 来移动或重命名文件或文件夹

语法:fs.rename(oldPath, newPath, callback)

fs.renameSync(oldPath, newPath)

代码示例:

1
2
3
4
5
fs.rename("./观书有感.txt", "./论语/观书有感.txt", (err) => {
if (err) throw err;
console.log("移动完成");
});
fs.renameSync("./座右铭.txt", "./论语/我的座右铭.txt");

4、文件删除

在 Node.js 中,我们可以使用 unlink 或 unlinkSync 来删除文件

语法:

fs.unlink(path, callback)

fs.unlinkSync(path)

代码示例:

1
2
3
4
5
6
const fs = require("fs");
fs.unlink("./test.txt", (err) => {
if (err) throw err;
console.log("删除成功");
});
fs.unlinkSync("./test2.txt");

5、文件夹操作

借助 Node.js 的能力,我们可以对文件夹进行 创建 、 读取 、 删除 等操作

image-20230411112549100

mkdir 创建文件夹

在 Node.js 中,我们可以使用 mkdir 或 mkdirSync 来创建文件夹

语法:fs.mkdir(path[, options], callback)

fs.mkdirSync(path[, options])

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
//异步创建文件夹
fs.mkdir("./page", (err) => {
if (err) throw err;
console.log("创建成功");
});
//递归异步创建
fs.mkdir("./1/2/3", { recursive: true }, (err) => {
if (err) throw err;
console.log("递归创建成功");
});
//递归同步创建文件夹
fs.mkdirSync("./x/y/z", { recursive: true });

readdir 读取文件夹

在 Node.js 中,我们可以使用 readdir 或 readdirSync 来读取文件夹

语法:fs.readdir(path[, options], callback)

fs.readdirSync(path[, options])

示例代码:

1
2
3
4
5
6
7
8
//异步读取
fs.readdir("./论语", (err, data) => {
if (err) throw err;
console.log(data);
});
//同步读取
let data = fs.readdirSync("./论语");
console.log(data);

rmdir 删除文件夹

在 Node.js 中,我们可以使用 rmdir 或 rmdirSync 来删除文件夹

语法: fs.rmdir(path[, options], callback)

fs.rmdirSync(path[, options])

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//异步删除文件夹
fs.rmdir("./page", (err) => {
if (err) throw err;
console.log("删除成功");
});
//异步递归删除文件夹
fs.rmdir("./1", { recursive: true }, (err) => {
if (err) {
console.log(err);
}
console.log("递归删除");
});
//同步递归删除文件夹
fs.rmdirSync("./x", { recursive: true });

6、路径问题

fs 模块对资源进行操作时,路径的写法有两种:

相对路径

  • ./座右铭.txt 当前目录下的座右铭.txt
  • 座右铭.txt 等效于上面的写法
  • ../座右铭.txt 当前目录的上一级目录中的座右铭.txt

绝对路径

  • D:/Program Files windows 系统下的绝对路径
  • /usr/bin Linux 系统下的绝对路径

相对路径中所谓的 当前目录 ,指的是 命令行的工作目录 ,而并非是文件的所在目录。所以当命令行的工作目录与文件所在目录不一致时,会出现一些 BUG。因此可以使用 dirname 与文件名拼接成绝对路径。dirname 与 require 类似,都是 Node.js 环境中的’全局’变量。**dirname 保存着 当前文件所在目录的绝对路径。使用 fs 模块的时候,尽量使用 **dirname 将路径转化为绝对路径,这样可以避免相对路径产生的 Bug

五、内置模块 path

path 模块提供了操作路径的功能,几个较为常用的 API:

image-20230411140339021

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const path = require("path");
//获取路径分隔符
console.log(path.sep);
//拼接绝对路径
console.log(path.resolve(__dirname, "test"));
//解析路径
let pathname = "D:/program file/nodejs/node.exe";
console.log(path.parse(pathname));
//获取路径基础名称
console.log(path.basename(pathname));
//获取路径的目录名
console.log(path.dirname(pathname));
//获取路径的扩展名
console.log(path.extname(pathname));

六、http

1、http 协议

HTTP(hypertext transport protocol)协议,中文叫超文本传输协议。是一种基于 TCP/IP 的应用层通信协议,它详细规定了 浏览器 和万维网 服务器 之间互相通信的规则

协议中主要规定了两个方面的内容

  • 客户端:用来向服务器发送数据,可以被称之为请求报文
  • 服务端:向客户端返回数据,可以被称之为响应报文

请求报文的组成

  • 请求行
  • 请求头
  • 空行
  • 请求体

HTTP 的请求行

  • 请求方法(get、post、put、delete 等)
  • 请求 URL(统一资源定位器)
  • HTTP 协议版本号

HTTP 请求头

常见的请求头有:

image-20230411141601604

HTTP 的请求体

请求体内容的格式是非常灵活的, (可以是空)==> GET 请求, (也可以是字符串,还可以是 JSON)===> POST 请求

例如:

  • 字符串:keywords=手机&price=2000
  • JSON:{“keywords”:”手机”,”price”:2000}

响应报文的组成

响应行

HTTP/1.1 200 OK

  • HTTP/1.1:HTTP 协议版本号
  • 200:响应状态码
  • OK:响应状态描述

响应头

  • Cache-Control:缓存控制 private 私有的,只允许客户端缓存数据
  • Connection: 链接设置
  • Content-Type:text/html;charset=utf-8 设置响应体的数据类型以及字符集,响应体为 html,字符集 utf-8
  • Content-Length:响应体的长度,单位为字节

空行

响应体

响应体内容的类型是非常灵活的,常见的类型有 HTML、CSS、JS、图片、JSON

2、IP 地址

IP 地址就是接入互联网的每台计算机的唯一地址,用来标识接入互联网的设备。通常用“点分十进制”表示成(a.b.c.d)的形式,其中,a,b,c,d 都是 0~255 之间的十进制整数。例如:用 点分十进表示的 IP 地址 (192.168.1.1)

共享 IP

为了更好的利用 IP 地址,节省资源。以家庭为例,手机、电脑、打印机、电视连接到同一路由器,路由器为他们分配各自的 IP,这就形成了一个局域网(私网),局域网中的设备之间可以互相通信。为了能够访问外部资源,还需接入到互联网才行。这样一个家庭中的所有设备就可以通过一个公网(广域网)IP 访问互联网了,实现了 IP 共享

image-20230411143956542

此外,还有一类 IP 称为本机回环 IP 地址,比如 127.0.0.1,访问这个 IP 地址实际上就是访问本机,等价于 loacalhost

image-20230411144603448

端口号

在一台电脑中,可以运行成百上千个 web 服务。每个 web 服 务都对应一个唯一的端口号。客户端发送过来的 网络请求,通过端口号,可以 被准确地交给对应的 web 服务进行处理

域名和域名服务器

尽管 IP 地址能够唯一地标记网络上的计算机,但 IP 地址是一长串数字, 不直观,而且不便于记忆,于是人们又发明了另一套 字符型的地址方案,即所谓的域名(Domain Name)地址。 IP 地址和域名是一一对应的关系,这就好比人名和身份证 ID 一样。这份对应关系存放在一种叫做域名服务器(DNS,Domain name server)的电脑中。使用者只需通过好记的域名访问对应的服务器即可,对应的转换工作由域名服务器 实现。因此,域名服务器就是提供 IP 地址和域名之间的转换服务的服务器

3、创建 http 服务

使用 nodejs 创建 HTTP 服务

1
2
3
4
5
6
7
8
9
10
11
12
//1. 导入 http 模块
const http = require("http");
//2. 创建服务对象 create 创建 server 服务
// request 意为请求. 是对请求报文的封装对象, 通过 request 对象可以获得请求报文的数据
// response 意为响应. 是对响应报文的封装对象, 通过 response 对象可以设置响应报文
const server = http.createServer((request, response) => {
response.end("Hello HTTP server");
});
//3. 监听端口, 启动服务
server.listen(9000, () => {
console.log("服务已经启动, 端口 9000 监听中...");
});

注意事项

响应内容中文乱码的解决办法

1
response.setHeader("content-type", "text/html;charset=utf-8");

HTTP 协议默认端口是 80 。HTTPS 协议的默认端口是 443, HTTP 服务开发常用端口有 3000, 8080,8090,9000 等

获取 HTTP 请求报文

想要获取请求的数据,需要通过 request 对象

image-20230411145259166

注意事项:

  • request.url 只能获取路径以及查询字符串(端口号以后的内容),无法获取 URL 中的域名以及协议的内容
  • request.headers 将请求信息转化成一个对象,并将属性名都转化成了『小写』
  • 关于路径:如果访问网站的时候,只填写了 IP 地址或者是域名信息,此时请求的路径为『 / 』
  • 关于 favicon.ico:这个请求是属于浏览器自动发送的请求

设置 HTTP 响应报文

image-20230411145717348

练习

搭建 HTTP 服务,响应一个 4 行 3 列的表格,并且要求表格有 隔行换色效果 ,且 点击 单元格能 高亮显示

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
//导入 http 模块
const http = require("http");
//创建服务对象
const server = http.createServer((request, response) => {
response.end(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
td{
padding: 20px 40px;
}
table tr:nth-child(odd){
background: #aef;
}
table tr:nth-child(even){
background: #fcb;
}
table, td{
border-collapse: collapse;
}
</style>
</head>
<body>
<table border="1">
<tr><td></td><td></td><td></td></tr>
<tr><td></td><td></td><td></td></tr>
<tr><td></td><td></td><td></td></tr>
<tr><td></td><td></td><td></td></tr>
</table>
<script>
//获取所有的 td
let tds = document.querySelectorAll('td');
//遍历
tds.forEach(item => {
item.onclick = function(){
this.style.background = '#222';
}
})
</script>
</body>
</html>
`); //设置响应体
});
//监听端口, 启动服务
server.listen(9000, () => {
console.log("服务已经启动....");
});

网页资源的基本加载过程

网页资源的加载都是循序渐进的,首先获取 HTML 的内容, 然后解析 HTML 在发送其他资源的请求,如 CSS,Javascript,图片等

静态资源服务

静态资源是指 内容长时间不发生改变的资源 ,例如图片,视频,CSS 文件,JS 文件,HTML 文件,字体文 件等

动态资源是指 内容经常更新的资源 ,例如百度首页,网易首页,京东搜索列表页面等

网站根目录或静态资源目录

HTTP 服务在哪个文件夹中寻找静态资源,那个文件夹就是 静态资源目录 ,也称之为 网站根目录

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
require("http")
.createServer((request, response) => {
//获取请求的方法已经路径
let { url, method } = request;
//判断请求方式以及请求路径
if (method == "GET" && url == "/index.html") {
//需要响应文件中的内容
let data = require("fs").readFileSync(__dirname + "/index.html");
response.end(data);
} else if (method == "GET" && url == "/css/app.css") {
//需要响应文件中的内容
let data = require("fs").readFileSync(__dirname + "/public/css/app.css");
response.end(data);
} else if (method == "GET" && url == "/js/app.js") {
//需要响应文件中的内容
let data = require("fs").readFileSync(__dirname + "/public/js/app.js");
response.end(data);
} else {
//404响应
response.statusCode = 404;
response.end("<h1>404 Not Found</h1>");
}
})
.listen(80, () => {
console.log("80端口正在启动中....");
});

上面这段代码根据路径和方法相应不同的资源,显然这种方式不够完美,我们需要更简洁的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
require("http")
.createServer((request, response) => {
//获取请求的方法已经路径
let { url, method } = request;
//文件夹路径
let rootDir = __dirname + "/public";
//拼接文件路径
let filePath = rootDir + url;
//读取文件内容
fs.readFile(filePath, (err, data) => {
//判断
if (err) {
//如果出现错误,响应404状态码
response.statusCode = 404;
response.end("<h1>404 Not Found</h1>");
} else {
//响应文件内容
response.end(data);
}
});
})
.listen(80, () => {
console.log("80端口正在启动中....");
});

通过这种方法可以根据请求路径动态响应资源

设置资源类型(mime 类型)

媒体类型(通常称为 Multipurpose Internet Mail Extensions 或 MIME 类型 )是一种标准,用来表示文档、 文件或字节流的性质和格式。

HTTP 服务可以设置响应头 Content-Type 来表明响应体的 MIME 类型,浏览器会根据该类型决定如何处理 资源

下面是常见文件对应的 mime 类型

image-20230411150517785

GET 和 POST

  • GET 主要用来获取数据,POST 主要用来提交数据
  • GET 带参数请求是将参数缀到 URL 之后,在地址栏中输入 url 访问网站就是 GET 请求, POST 带参数请求是将参数放到请求体中
  • POST 请求相对 GET 安全一些,因为在浏览器中参数会暴露在地址栏
  • GET 请求大小有限制,一般为 2K,而 POST 请求则没有大小限制

以下均为 GET 请求:

  • 在地址栏直接输入 url 访问
  • 点击 a 链接
  • link 标签引入 css
  • script 标签引入 js
  • img 标签引入图片
  • form 标签中的 method 为 get (不区分大小写)
  • ajax 中的 get 请求

以下均为 POST 请求:

  • form 标签中的 method 为 post(不区分大小写)
  • AJAX 的 post 请求

七、模块化

模块化是指解决一个复杂问题时,自顶向下逐层把系统划分成若干模块的过 程。对于整个系统来说,模块是可组 合、分解和更换的单元 编程领域中的模块化,就是遵守固定的规则,把一个大文件拆成独立并互相 依赖的多个小模块

把代码进行模块化拆分的好处

  • 防止命名冲突
  • 高复用性
  • 高维护性

common.js 暴露数据

  • module.exports = value
  • exports.name = value

注意

  • module.exports 可以暴露 任意 数据
  • 不能使用 exports = value 的形式暴露数据。exports = module.exports = {} ,require 返回的永远是目标模块中 module.exports 的值(因为 exports 和 module.exports 是对象,存的是引用。如果直接使用 exports = value 赋值,相当于将 exports 与原来 module.exports 的链接断开了 )

image-20230411154508882

common.js 导入(引入、加载)模块

在模块中使用 require 传入文件路径即可引入文件

1
const test = require("./me.js");

require 使用的一些注意事项:

  • 对于自定义模块,导入时路径建议写相对路径 ,且不能省略 ./ 和 ../
  • js 和 json 文件导入时可以不用写后缀,c/c++编写的 node 扩展文件也可以不写后缀,但是一 般用不到
  • 如果导入的路径是个文件夹,则会 首先 检测该文件夹下 package.json 文件中 main 属性对应 的文件。如果存在则导入,反之如果文件不存在会报错。如果 main 属性不存在,或者 package.json 不存在,则会尝试导入文件夹下的 index.js 和 index.json 。如果还是没找到,就会报错
  • 导入 node.js 内置模块时,直接 require 模块的名字即可,无需加路径

八、包管理工具

包管理工具是一个通用的概念,很多编程语言都有包管理工具

npm 全称 Node Package Manager ,翻译为中文意思是『Node 的包管理工具』。它是 node.js 官方内置的包管理工具

npm 初始化

npm init 命令的作用是将文件夹初始化为一个『包』, 交互式创建 package.json 文件。package.json 是包的配置文件,每个包都必须要有 package.json。也可以使用 npm init -y 或者 npm init –yes 极速创建 package.json。下面是一个 package.json 的示例

1
2
3
4
5
6
7
8
9
10
11
12
{
"name": "1-npm", #包的名字
"version": "1.0.0", #包的版本
"description": "", #包的描述
"main": "index.js", #包的入口文件
"scripts": { #脚本配置
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "", #作者
"license": "ISC" #开源证书
}

nodemodules 文件夹用来存放所有已安装到项目中的包。注意:今后在项目开发中,一定要把 nodemodules 文件夹,添加 到 .gitignore 忽略文件中

package-lock.json 配置文件用来记录 node_modules 目录下的每一个包 的下载信息,例如包的名字、版本号、下载地址等

生产依赖与开发依赖

我们可以在安装时设置选项来区分 依赖的类型 ,目前分为两类:

image-20230412111619541

全局安装

我们可以执行安装选项 -g 进行全局安装

1
npm i -g nodemon

全局安装完成之后就可以在命令行的任何位置运行 nodemon 命令

环境变量 Path

Path 是操作系统的一个环境变量,可以设置一些文件夹的路径,在当前工作目录下找不到可执行文件 时,就会在环境变量 Path 的目录中挨个的查找,如果找到则执行,如果没有找到就会报错

image-20230412112453900

如果希望某个程序在任何工作目录下都能正常运行,可以将该程序的所在目录配置到环境 变量 Path 中

安装包依赖

1
2
npm i
npm install

安装指定版本的包

1
2
3
4
## 格式
npm i <包名@版本号>
## 示例
npm i jquery@1.11.2

删除依赖

1
2
3
4
5
## 局部删除
npm remove uniq
npm r uniq
## 全局删除
npm remove -g nodemon

yarn

yarn 是由 Facebook 在 2016 年推出的新的 Javascript 包管理工具,官方网址:https://yarnpkg.com/

yarn 官方宣称的一些特点

  • 速度超快:yarn 缓存了每个下载过的包,所以再次使用时无需重复下载。 同时利用并行下载以最大 化资源利用率,因此安装速度更快
  • 超级安全:在执行代码之前,yarn 会通过算法校验每个安装包的完整性
  • 超级可靠:使用详细、简洁的锁文件格式和明确的安装算法,yarn 能够保证在不同系统上无差异的 工作

yarn 常用命令

image-20230412120849488

nvm

nvm 全称 Node Version Manager 顾名思义它是用来管理 node 版本的工具,方便切换不同版本的 Node.js

nvm 的使用非常的简单,跟 npm 的使用方法类似

首先先下载 nvm,下载地址 https://github.com/coreybutler/nvm-windows/releases,选择 nvm-setup.exe 下载即可

常用命令

image-20230412121724550

九、express 框架

Express 是基于 Node.js 平台,快速、开放、极简的 Web 开发框架http://www.expressjs.com.cn/

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

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

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

1、express 使用

express 本身是一个 npm 包,所以可以通过 npm 安装

1
npm i express

express 初体验

1
2
3
4
5
6
7
8
9
10
11
12
//1. 导入 express
const express = require("express");
//2. 创建应用对象
const app = express();
//3. 创建路由规则
app.get("/home", (req, res) => {
res.send("hello express server");
});
//4. 监听端口 启动服务
app.listen(3000, () => {
console.log("服务已经启动, 端口监听为 3000...");
});

在浏览器访问 http://127.0.0.1:3000/home

2、express 路由

官方定义: 路由确定了应用程序如何响应客户端对特定端点的请求

路由的使用

一个路由的组成有请求方法 , 路径和回调函数 组成

express 中提供了一系列方法,可以很方便的使用路由,使用格式如下:

app.(path,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
//导入 express
const express = require("express");
//创建应用对象
const app = express();
//创建 get 路由
app.get("/home", (req, res) => {
res.send("网站首页");
});
//首页路由
app.get("/", (req, res) => {
res.send("我才是真正的首页");
});
//创建 post 路由
app.post("/login", (req, res) => {
res.send("登录成功");
});
//匹配所有的请求方法
app.all("/search", (req, res) => {
res.send("1 秒钟为您找到相关结果约 100,000,000 个");
});
//自定义 404 路由
app.all("*", (req, res) => {
res.send("<h1>404 Not Found</h1>");
});
//监听端口 启动服务
app.listen(3000, () => {
console.log("服务已经启动, 端口监听为 3000");
});

路由的匹配过程

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

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

注意

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

模块化路由

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

  • 创建路由模块对应的 .js 文件
  • 调用 express.Router() 函数创建路由对象
  • 向路由对象上挂载具体的路由
  • 使用 module.exports 向外共享路由对象
  • 使用 app.use() 函数注册路由模块
1
2
3
4
5
6
7
8
9
10
11
12
13
//1. 导入 express
const express = require("express");
//2. 创建路由器对象
const router = express.Router();
//3. 在 router 对象身上添加路由
router.get("/", (req, res) => {
res.send("首页");
});
router.get("/cart", (req, res) => {
res.send("购物车");
});
//4. 暴露
module.exports = router;

express 中的 Router 是一个完整的中间件和路由系统,可以看做是一个小型的 app 对象

导入使用路由

1
2
3
4
5
6
7
8
9
const express = require("express");
const app = express();
//5.引入子路由文件
const homeRouter = require("./routes/homeRouter");
//6.设置和使用中间件
app.use(homeRouter);
app.listen(3000, () => {
console.log("3000 端口启动....");
});

为路由模块添加前缀

1
2
3
4
//1、导入路由模块
const userRouter = require("./router/user.js");
//2、使用app.use()注册路由模块,并添加统一的访问前缀 /api
app.use("/api", userRouter);

获取 URL 中的查询参数

通过 req.query 对象,可以访问到客户端通过查询字符串的形式,发送到 服务器的参数:

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

获取 URL 中的动态参数

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

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

express 响应设置

express 框架封装了一些 API 来方便给客户端响应数据,并且兼容原生 HTTP 模块的获取方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//获取请求的路由规则
app.get("/response", (req, res) => {
//1. express 中设置响应的方式兼容 HTTP 模块的方式
res.statusCode = 404;
res.statusMessage = "xxx";
res.setHeader("abc", "xyz");
res.write("响应体");
res.end("xxx");
//2. express 的响应方法
res.status(500); //设置响应状态码
res.set("xxx", "yyy"); //设置响应头
res.send("中文响应不乱码"); //设置响应体
//连贯操作
res.status(404).set("xxx", "yyy").send("你好朋友");
//3. 其他响应
res.redirect("http://atguigu.com"); //重定向
res.download("./package.json"); //下载响应
res.json(); //响应 JSON
res.sendFile(__dirname + "/home.html"); //响应文件内容
});

注意,使用 res.send()发送的中文并没有乱码,这是因为 express 自动在响应头添加了res.setHeader(‘content-type’,’text/html;charset=utf-8’);

3、express 中间件

中间件(Middleware)本质是一个回调函数

中间件函数 可以像路由回调一样访问 请求对象(request) , 响应对象(response)

中间件的作用 就是 使用函数封装公共操作,简化代码

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

image-20230412153639589

注意:中间件函数的形参列表中,必须包含 next 参数。而路由处理函数中 只包含 req 和 res。**next 函数是实现多个中间件连续调用的关键,它表示把流转关系转交给下 一个中间件或路由

定义全局中间件

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

声明中间件函数

1
2
3
4
5
6
let recordMiddleware = function (request, response, next) {
//实现功能代码
//.....
//执行next函数(当如果希望执行完中间件函数之后,仍然继续执行路由中的回调函数,必须调用next)
next();
};

应用中间件

通过调用 app.use(中间件函数),即可定义一个全局生效的中间件

1
app.use(recordMiddleware);

声明时可以直接将匿名函数传递给 use

1
2
3
4
app.use(function (request, response, next) {
console.log("定义第一个中间件");
next();
});

定义多个全局中间件

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

局部生效的中间件(路由中间件)

如果只需要对某一些路由进行功能封装 ,则就需要路由中间件。路由中间件不使用 app.use() 定义,调用格式如下:

1
2
3
app.get("/路径", `中间件函数`, (request, response) => {});
// 使用多个路由中间件
app.get("/路径", `中间件函数1`, `中间件函数2`, (request, response) => {});

中间件的 几个使用注意事项

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

Express 内置的中间件

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

  • express.static 快速托管静态资源的内置中间件,例如: HTML 文件、 图片、CSS 样式等(无兼容性)
  • express.json 解析 JSON 格式的请求体数据(有兼容性,仅在 4.16.0+ 版本中可用)
  • express.urlencoded 解析 URL-encoded 格式的请求体数据(有兼容性, 仅在 4.16.0+ 版本中可用)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//引入express框架
const express = require("express");
//创建服务对象
const app = express();
// 配置解析application/json格式数据的内置中间件
app.use(express.json());
// 配置解析application/x-www-form-urlencoded格式数据的内置中间件
app.use(express.urlencoded({ extended: false }));
//静态资源中间件的设置,将当前文件夹下的public目录作为网站的根目录
app.use(express.static("./public")); //当然这个目录中都是一些静态资源
//如果访问的内容经常变化,还是需要设置路由
//但是,在这里有一个问题,如果public目录下有index.html文件,单独也有index.html的路由,
//则谁书写在前,优先执行谁
app.get("/index.html", (request, response) => {
response.send("首页");
});
//监听端口
app.listen(3000, () => {
console.log("3000 端口启动....");
});

4、防盗链

我们在访问第三方资源(如图片)的时候,有时可能会出现资源 403、404 的情况,这是因为服务端做了防盗链处理,只允许指定的域名访问资源

服务端一般使用 Referer 请求头识别访问来源,然后处理资源访问。

image-20230413085043838

Referer 是 HTTP 请求头的一部分,当浏览器向 Web 服务器发送请求的时候,一般会带上 Referer,它包含了当前请求资源的来源页面的地址。服务端一般使用 Referer 请求头识别访问来源,可能会以此进行统计分析、日志记录以及缓存优化等。

实现防盗链

在服务端只需通过中间件即可实现防盗链

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
app.use((req, res, next) => {
//检测请求头中的referer是否为127.0.0.1
//获取referer
let referer = req.get("referer");
if (referer) {
//实例化
let url = new URL(referer);
//获取hostname
let hostname = url.hostname;
//判断
if (hostname !== "127.0.0.1") {
//响应404
res.status(404).send("<h1>404 Not Found</h1>");
return;
}
}
next();
});

如果第三方设置了防盗链,我们又想访问该怎么做呢

设置不发送 referrer 就行了

<a><area><img><iframe><script> 或者 <link> 元素上的 referrerpolicy 属性为其设置独立的请求策略,例如:

1
<img src="http://……" referrerpolicy="no-referrer" />

或者直接在 HTMl 页面头中通过 meta 属性全局配置:

1
<meta name="referrer" content="no-referrer" />

扩展参考:http://www.ruanyifeng.com/blog/2019/06/http-referer.html

5、EJS 模板引擎

模板引擎是分离用户界面和业务数据 的一种技术

EJS 是一个高效的 Javascript 的模板引擎。官网: https://ejs.co/

中文站:https://ejs.bootcss.com/

EJS 使用

下载安装 EJS

1
npm i ejs --save

代码示例

1
2
3
4
5
6
7
8
9
//1.引入ejs
const ejs = require('ejs');
//2.定义数据
let person = ['张三','李四','王二麻子'];
//3.ejs解析模板返回结构
//<%= %> 是ejs解析内容的标记,作用是输出当前表达式的执行结构
let html = ejs.render(‘<%= person.join(",") %>’, {person:person});
//4.输出结果
console.log(html);

render 渲染函数会将其第一个参数中<% %>中的值替换为第二个参数(也就是数据)进行解析渲染

EJS 常用语法

执行 JS 代码(可以认为里面是 js 语句,如条件语句、循环语句等)

1
<% code %>

输出转义的数据到模板上(可以认为里面是 js 表达式)

1
<%= code %>

实际上,我们通常将需要渲染的界面和数据分离开来

新建 test.ejs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<h2>我爱你<%=name%></h2>
<hr />
<h4><%=word%></h4>
</body>
</html>

主文件中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const express = require("express");
const ejs = require("ejs");
const fs = require("fs");
const app = express();
const name = "中国";
const word = "亲爱的母亲";
const str = fs.readFileSync(__dirname + "/test.ejs").toString();
const result = ejs.render(str, { name: name, word: word });
app.get("/", (req, res) => {
res.send(result);
});
app.listen(3000, () => {
console.log("服务器启动了...");
});

EJS 列表渲染

test.ejs 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<ul>
<%name.forEach(item=>{%>
<li><%=item%></li>
<%})%>
</ul>
</body>
</html>

EJS 条件渲染

test.ejs 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<%if(isLogin){%>
<span>欢迎回来</span>
<%}else{%> <button>登录</button><button>注册</button> <%}%>
</body>
</html>

express 中使用 EJS

在之前我们使用 fs 去手动读取 ejs 中的数据并交给 ejs.render 函数去渲染。但在 express 中我们有更简便的方法,无需手动读取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const express = require("express");
const path = require("path");
const ejs = require("ejs");
const app = express();
//将express中的模板引擎设置为ejs
app.set("view engine", "ejs");
//设置模板文件的存放位置
app.set("views", path.resolve(__dirname, "./views"));
app.get("/", (req, res) => {
const title = "人生得意须尽欢";
res.render("test", { title });
});
app.listen(3000, () => {
console.log("服务器启动了...");
});

6、express-generator

express-generator 是一款官方推荐的 Express 应用程序生成器,通过它可以快速创建一个 Express 应用骨架

可以通过 npx(包含在 Node.js8.2.0 及更高版本中)命令来运行 Express 应用程序生成器

1
$ npx express-generator

对于较老的 Node 版本,可以通过 npm 将 Express 应用程序生成器安装到全局环境中并使用

1
2
$ npm install -g express-generator
$ express

使用如下命令就可以快速创建一个 Express 应用骨架。其中-e 表示添加 EJS 模板引擎支持,dirname 是要创建的文件夹名称

1
$ express -e dirname

文件上传处理

使用如下命令快速创建应用骨架

1
$ express -e file

在 file/routes/index.js 中新增两个头像上传的路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const express = require("express");
const router = express.Router();

/* GET home page. */
router.get("/", function (req, res, next) {
res.render("index", { title: "Express" });
});
router.get("/portrait", function (req, res, next) {
res.render("portrait");
});
router.post("/portrait", function (req, res, next) {
res.send("111");
});
module.exports = router;

对应的在 views 文件下创建 portrait.ejs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<!-- 对于文件上传,必须将enctype属性设置为multipart/form-data -->
<form action="/portrait" method="post" enctype="multipart/form-data">
<input type="text" name="username" placeholder="用户名" />
<input type="file" name="portrait" placeholder="上传头像" />
<hr />
<button>上传</button>
</form>
</body>
</html>

接下来需要使用一个包叫 formidable 对上传的文件作进一步处理

1
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
const express = require("express");
//导入formidable
const formidable = require("formidable");
const router = express.Router();

/* GET home page. */
router.get("/", function (req, res, next) {
res.render("index", { title: "Express" });
});
router.get("/portrait", function (req, res, next) {
res.render("portrait");
});
router.post("/portrait", function (req, res, next) {
const form = formidable({ multiples: true });
form.parse(req, (err, fields, files) => {
if (err) {
next(err);
return;
}
res.json({ fields, files });
});
});
module.exports = router;

查看浏览器,可以看到 fields 里面存放的是一般表单的数据,比如 text、radio、select、checkbox 这种类型。而 files 里面保存的是 file 类型的数据

image-20230413151158065

一般拿到客户端上传的文件之后,可以将其保存在静态资源目录下以方便用户访问。并将访问的路径保存在数据库里,并且返回给用户。

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

/* GET home page. */
router.get("/", function (req, res, next) {
res.render("index", { title: "Express" });
});
router.get("/portrait", function (req, res, next) {
res.render("portrait");
});
router.post("/portrait", function (req, res, next) {
const form = formidable({
multiples: true,
//设置文件上传保存的路径
uploadDir: __dirname + "/../public/images",
//保持文件后缀
keepExtensions: true,
});
form.parse(req, (err, fields, files) => {
if (err) {
next(err);
return;
}
//通过files.portrait.newFilename拼接保存文件上传的路径
let url = "/images/" + files.portrait.newFilename;
res.send(url);
});
});
module.exports = router;

7、账单案例

使用 express-generator 快速生成一个应用骨架 accounts

在 routes/index.js 中新增/account 和/account/create 路由中间件

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
const express = require("express");
const router = express.Router();

/* GET home page. */
router.get("/", function (req, res, next) {
res.render("index", { title: "Express" });
});
// 账单列表页
router.get("/account", function (req, res, next) {
const accounts = [
{
time: "2023-04-01",
title: "吃饭",
type: "-1",
account: 36,
},
{
time: "2023-04-20",
title: "发工资",
type: "1",
account: 15000,
},
];
res.render("list", { accounts });
});
//新增记录页
router.get("/account/create", function (req, res, next) {
res.render("create");
});
//处理新增记录
router.post("/account", function (req, res, next) {
res.send("提交成功");
});
module.exports = router;

并在 views 中新建 list.ejs 和 create.ejs,这样账单的基本静态页面就搭建起来了

list.ejs

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<link
href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/3.3.7/css/bootstrap.css"
rel="stylesheet"
/>
<style>
label {
font-weight: normal;
}
.panel-body .glyphicon-remove {
display: none;
}
.panel-body:hover .glyphicon-remove {
display: inline-block;
}
</style>
</head>
<body>
<div class="container">
<div class="row">
<div class="col-xs-12 col-lg-8 col-lg-offset-2">
<h2>记账本</h2>
<hr />
<div class="accounts">
<% accounts.forEach(item => { %>
<div
class="panel <%= item.type==='-1' ? 'panel-danger' : 'panel-success' %>"
>
<div class="panel-heading"><%= item.time %></div>
<div class="panel-body">
<div class="col-xs-6"><%= item.title %></div>
<div class="col-xs-2 text-center">
<span
class="label <%= item.type==='-1' ? 'label-warning' : 'label-success' %>"
><%= item.type==='-1' ? '支出' : '收入' %></span
>
</div>
<div class="col-xs-2 text-right"><%= item.account %> 元</div>
<div class="col-xs-2 text-right">
<a href="/account/<%= item.id %>">
<span
class="glyphicon glyphicon-remove"
aria-hidden="true"
></span>
</a>
</div>
</div>
</div>
<% }) %>
</div>
</div>
</div>
</div>
</body>
</html>

create.ejs

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>添加记录</title>
<link href="/css/bootstrap.css" rel="stylesheet" />
<link href="/css/bootstrap-datepicker.css" rel="stylesheet" />
</head>
<body>
<div class="container">
<div class="row">
<div class="col-xs-12 col-lg-8 col-lg-offset-2">
<h2>添加记录</h2>
<hr />
<form method="post" action="/account">
<div class="form-group">
<label for="item">事项</label>
<input name="title" type="text" class="form-control" id="item" />
</div>
<div class="form-group">
<label for="time">时间</label>
<input name="time" type="text" class="form-control" id="time" />
</div>
<div class="form-group">
<label for="type">类型</label>
<select name="type" class="form-control" id="type">
<option value="-1">支出</option>
<option value="1">收入</option>
</select>
</div>
<div class="form-group">
<label for="account">金额</label>
<input
name="account"
type="text"
class="form-control"
id="account"
/>
</div>

<div class="form-group">
<label for="remarks">备注</label>
<textarea
name="remarks"
class="form-control"
id="remarks"
></textarea>
</div>
<hr />
<button type="submit" class="btn btn-primary btn-block">
添加
</button>
</form>
</div>
</div>
</div>
<script src="/js/jquery.min.js"></script>
<script src="/js/bootstrap.min.js"></script>
<script src="/js/bootstrap-datepicker.min.js"></script>
<script src="/js/bootstrap-datepicker.zh-CN.min.js"></script>
<script src="/js/main.js"></script>
</body>
</html>

注意:需要将 ejs 文件中引用到的 css 和 js 文件放在静态资源 public 目录下,并将 ejs 文件中的路径设置为绝对路径

十、MongoDB

1、MongoDB 基础知识

MongoDB 是一个基于分布式文件存储的数据库,官方地址 https://www.mongodb.com/

数据库(DataBase)是按照数据结构来组织、存储和管理数据的 应用程序

数据库的主要作用就是 管理数据 ,对数据进行 增(c)、删(d)、改(u)、查(r)

相比于纯文件管理数据,数据库管理数据有如下特点:

  • 速度更快
  • 扩展性更强
  • 安全性更强

市面上的数据库有很多种,最常见的数据库有如下几个:

  • MySQL 数据库(目前使用最广泛、流行度最高的开源免费数据库;Community + Enterprise)
  • Oracle 数据库(收费)
  • SQL Server 数据库(收费)
  • Mongodb 数据库(Community + Enterprise)

其中,MySQL、Oracle、SQL Server 属于传统型数据库(又叫做:关系型数据库 或 SQL 数据库),这三者的 设计理念相同,用法比较类似

而 Mongodb 属于新型数据库(又叫做:非关系型数据库 或 NoSQL 数据库),它在一定程度上弥补了传统型 数据库的缺陷

Mongodb 中有三个重要概念需要掌握

  • 数据库(database) 数据库是一个数据仓库,数据库服务下可以创建很多数据库,数据库中可以存 放很多集合
  • 集合(collection) 集合类似于 JS 中的数组,在集合中可以存放很多文档 文档(document)
  • 文档是数据库中的最小单位,类似于 JS 中的对象

Mongodb 下载地址: https://www.mongodb.com/try/download/community

配置步骤如下:

  1. 将压缩包移动到 C:\Program Files 下,然后解压
  2. 创建 C:\data\db 目录,mongodb 会将数据默认保存在这个文件夹
  3. 以 mongodb 中 bin 目录作为工作目录,启动命令行
  4. 运行命令 mongod

此时 Mongodb 的数据库服务已经启动了

重新打开一个以 mongodb 中 bin 目录作为工作目录的终端,使用 mongo 命令连接本机的 mongodb 服务

注意: 为了方便后续方便使用 mongod 命令,可以将 bin 目录配置到环境变量 Path 中 千万不要选中服务端窗口的内容 ,选中会停止服务,可以 敲回车 取消选中

2、命令行交互

数据库命令

显示所有的数据库

1
show dbs

切换到指定的数据库,如果数据库不存在会自动创建数据库

1
use 数据库名

显示当前所在的数据库

1
db

删除当前数据库

1
2
use 库名
db.dropDatabase()

集合命令

创建集合

1
db.createCollection('集合名称')

显示当前数据库中的所有集合

1
show collections

删除某个集合

1
db.集合名.drop()

重命名集合

1
db.集合名.renameCollection('newName')

文档命令

插入文档

1
db.集合名.insert(文档对象)

查询文档

1
db.集合名.find(查询条件)

_id 是 mongodb 自动生成的唯一编号,用来唯一标识文档

更新文档

1
2
db.集合名.update(查询条件,新的文档)
db.集合名.update({name:'张三'},{$set:{age:19}})

直接使用 db.集合名.update(查询条件,新的文档)这种方式,会覆盖式更新,因此可以使用第二种方式精确(局部)更新

删除文档

1
db.集合名.remove(查询条件)

3、Mongoose

Mongoose 是一个对象文档模型库,官网 http://www.mongoosejs.net/

它的本质就是一个包,方便使用代码操作 mongodb 数据库。取代我们之前在终端中使用 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
//1. 安装 mongoose
//2. 导入 mongoose
const mongoose = require("mongoose");
//3. 连接数据库,27017是mongodb的默认端口号,可以省略。如果bilibili数据库不存在则会自动创建
// mongodb是MongoDB的连接协议,正如我们之前http服务使用http协议一样,MongoDB数据库服务使用mongodb协议
mongoose.connect("mongodb://127.0.0.1:27017/bilibili");
//4. 设置连接回调
//连接成功
mongoose.connection.on("open", () => {
console.log("连接成功");
//5. 创建文档结构对象
let BookSchema = new mongoose.Schema({
title: String,
author: String,
price: Number,
});
//6. 创建文档模型对象
let BookModel = mongoose.model("book", BookSchema);
//7. 插入文档
BookModel.create({
title: "西游记",
author: "吴承恩",
price: 19.9,
})
.then((data) => console.log(data))
.catch((err) => console.log(err));
});
//连接出错
mongoose.connection.on("error", () => {
console.log("连接出错~~");
});
//连接关闭
mongoose.connection.on("close", () => {
console.log("连接关闭");
});

字段类型

文档结构可选的常用字段类型列表

image-20230414103504943

字段值验证

Mongoose 有一些内建验证器,可以对字段值进行验证

必填项

1
2
3
4
title: {
type: String,
required: true // 设置必填项
}

默认值

1
2
3
4
author: {
type: String,
default: '匿名' //默认值
}

枚举值

1
2
3
4
gender: {
type: String,
enum: ['男','女'] //设置的值必须是数组中的
}

唯一值

1
2
3
4
username: {
type: String,
unique: true
}

unique 需要 重建集合 才能有效果

Mongoose 操作数据库的基本操作:增加(create),删除(delete),修改(update),查(read)

增加

插入一条

1
2
3
4
5
6
7
BookModel.create({
title: "西游记",
author: "吴承恩",
price: 19.9,
})
.then((data) => console.log(data))
.catch((err) => console.log(err));

批量插入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//批量插入
BookModel.insertMany([
{
title: "红楼梦",
author: "曹雪芹",
price: 29.9,
},
{
title: "水浒传",
author: "施耐庵",
price: 39.9,
},
{
title: "三国演义",
author: "罗贯中",
price: 49.9,
},
])
.then((data) => console.log(data))
.catch((err) => console.log(err));

删除

删除一条数据

1
2
3
4
//删除一条
BookModel.deleteOne({ title: "红楼梦" })
.then((data) => console.log(data))
.catch((err) => console.log(err));

批量删除

1
2
3
4
//批量删除
BookModel.deleteMany({ author: "罗贯中" })
.then((data) => console.log(data))
.catch((err) => console.log(err));

更新

更新一条数据

1
2
3
4
//更新一条
BookModel.updateOne({ author: "罗贯中" }, { price: 9.9 })
.then((data) => console.log(data))
.catch((err) => console.log(err));

批量更新数据

同上

查询

查询一条数据

1
2
3
4
5
6
7
BookModel.findOne({ name: "三国演义" })
.then((data) => console.log(data))
.catch((err) => console.log(err));
//根据id查询
BookModel.findById({ _id: "6438c46df163eb812a612bb5" })
.then((data) => console.log(data))
.catch((err) => console.log(err));

批量查询数据

1
2
3
4
5
6
7
8
//不加条件查询
BookModel.find()
.then((data) => console.log(data))
.catch((err) => console.log(err));
//加条件查询
BookModel.find({ author: "余华" })
.then((data) => console.log(data))
.catch((err) => console.log(err));

条件控制

Mongoose 中主要有运算符、 逻辑运算、正则匹配三中条件控制

运算符

在 mongodb 不能 > < >= <= !== 等运算符,需要使用替代符号

  • > 使用 $gt
  • < 使用 $lt
  • = 使用 $gte
  • <= 使用 $lte
  • !== 使用 $ne
1
2
3
BookModel.find({ price: { $gt: 60 } })
.then((data) => console.log(data))
.catch((err) => console.log(err));

逻辑运算

$or 逻辑或

1
2
3
BookModel.find({ $or: [{ author: "罗贯中" }, { price: 59.8 }] })
.then((data) => console.log(data))
.catch((err) => console.log(err));

$and 逻辑与

1
2
3
BookModel.find({ $and: [{ price: { $gt: 20 } }, { price: { $lt: 30 } }] })
.then((data) => console.log(data))
.catch((err) => console.log(err));

正则匹配

条件中可以直接使用 JS 的正则语法,通过正则可以进行模糊查询

1
2
3
BookModel.find({ name: /三/ })
.then((data) => console.log(data))
.catch((err) => console.log(err));

个性化读取

字段筛选

1
2
3
4
5
6
7
//0:不要的字段
//1:要的字段
BookModel.find()
.select({ name: 1, author: 1 })
.exec()
.then((data) => console.log(data))
.catch((err) => console.log(err));

将筛选字段的条件放在 select 函数中,值为 1 表示需要筛选出来,为 0 表示该字段不需要。回调函数放在 exec 中。当然 find 函数中也可以写入筛选条件

数据排序

1
2
3
4
5
BookModel.find()
.sort({ price: 1 })
.exec()
.then((data) => console.log(data))
.catch((err) => console.log(err));

sort 函数函数中参数名表示排序的字段名,值为 1 表示升序,-1 表示降序

数据截取

1
2
3
4
5
BookModel.find()
.skip(3)
.limit(2)
.then((data) => console.log(data))
.catch((err) => console.log(err));

skip 表示跳过前 3 个,limit 表示取 2 个,因此最终取值结果为第 4 个至第 5 个

图形化管理工具

我们可以使用图形化的管理工具来对 Mongodb 进行交互

Mongoose 模块化

随着业务的增多,我们可以将 Mongoose 操作分模块管理

主要思路如下:

将连接成功之后的回调函数里面的操作单独拿出来放在 index.js,剩下的内容以函数的形式进行封装放在 db 目录下的 index.js,并通过 module.exports 暴露出去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
module.exports = function (success, err) {
//1. 安装 mongoose
//2. 导入 mongoose
const mongoose = require("mongoose");
//3. 连接数据库,27017是mongodb的默认端口号,可以省略。如果bilibili数据库不存在则会自动创建
// mongodb是MongoDB的连接协议,正如我们之前http服务使用http协议一样,MongoDB数据库服务使用mongodb协议
mongoose.connect("mongodb://127.0.0.1:27017/bilibili");
//4. 设置连接回调
//连接成功
mongoose.connection.on("open", () => {
success();
});
//连接出错
mongoose.connection.on("error", () => {
err();
});
//连接关闭
mongoose.connection.on("close", () => {
console.log("连接关闭");
});
};

index.js 引入 db/index.js,并将连接成功之后的回调函数里面的操作作为 success 函数参数传递过去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
module.exports = function (success, err) {
//1. 安装 mongoose
//2. 导入 mongoose
const mongoose = require("mongoose");
//3. 连接数据库,27017是mongodb的默认端口号,可以省略。如果bilibili数据库不存在则会自动创建
// mongodb是MongoDB的连接协议,正如我们之前http服务使用http协议一样,MongoDB数据库服务使用mongodb协议
mongoose.connect("mongodb://127.0.0.1:27017/bilibili");
//4. 设置连接回调
//连接成功
mongoose.connection.on("open", () => {
success();
});
//连接出错
mongoose.connection.on("error", () => {
err();
});
//连接关闭
mongoose.connection.on("close", () => {
console.log("连接关闭");
});
};

由于随着业务的增多,后面可能会有越来越多的文档,因此我们可以将创建文档的代码继续拆分出去。在 models 目录下新建 BookModles.js 和 MovieModles.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//导入 mongoose
const mongoose = require("mongoose");
//创建文档的结构对象
//设置集合中文档的属性以及属性值的类型
let BookSchema = new mongoose.Schema({
name: String,
author: String,
price: Number,
});

//创建模型对象 对文档操作的封装对象
let BookModel = mongoose.model("books", BookSchema);

//暴露模型对象
module.exports = BookModel;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//导入 mongoose
const mongoose = require("mongoose");

// 创建文档结构
const MovieSchema = new mongoose.Schema({
title: String,
director: String,
});

//创建模型对象
const MovieModel = mongoose.model("movie", MovieSchema);

//暴露
module.exports = MovieModel;

index.js 主文件中引入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
//导入 db 文件
const db = require("./db/db");
//导入 mongoose
const mongoose = require("mongoose");
//导入 BookModel
const BookModel = require("./models/BookModel");

// 调用函数
db(
() => {
//7. 新增
BookModel.create(
{
name: "西游记",
author: "吴承恩",
price: 19.9,
},
(err, data) => {
//判断是否有错误
if (err) {
console.log(err);
return;
}
//如果没有出错, 则输出插入后的文档对象
console.log(data);
//8. 关闭数据库连接 (项目运行过程中, 不会添加该代码)
mongoose.disconnect();
}
);
},
() => {
console.log("连接失败...");
}
);

接下来 db 目录下的 index.js 中我们连接的数据库地址和集合名称是写死的,我们可以新建一个 config/config.js 文件单独进行配置

1
2
3
4
5
6
//配置文件
module.exports = {
DBHOST: "127.0.0.1",
DBPORT: 27017,
DBNAME: "bilibili",
};

db 下的 index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
*
* @param {*} success 数据库连接成功的回调
* @param {*} error 数据库连接失败的回调
*/
module.exports = function (success, error) {
//1. 安装 mongoose
//2. 导入 mongoose
const mongoose = require("mongoose");
//导入 配置文件
const { DBHOST, DBPORT, DBNAME } = require("../config/config.js");

//设置 strictQuery 为 true
mongoose.set("strictQuery", true);

//3. 连接 mongodb 服务 数据库的名称
mongoose.connect(`mongodb://${DBHOST}:${DBPORT}/${DBNAME}`);

//4. 设置回调
// 设置连接成功的回调 once 一次 事件回调函数只执行一次
mongoose.connection.once("open", () => {
success();
});

// 设置连接错误的回调
mongoose.connection.on("error", () => {
error();
});

//设置连接关闭的回调
mongoose.connection.on("close", () => {
console.log("连接关闭");
});
};

接下来可以对之前做过的账单案例进行优化了。将数据库的操作应用到案例中

十一、接口

接口是 前后端通信的桥梁

简单理解:一个接口就是 服务中的一个路由规则 ,根据请求响应结果

一般情况下,接口返回的都是 json 格式

RESTful API

RESTful API 是一种特殊风格的接口,主要特点有如下几个:

  • URL 中的路径表示 资源 ,路径中不能有 动词 ,例如 create , delete , update 等这些都不能有
  • 操作资源要与 HTTP 请求方法对应
  • 操作结果要与 HTTP 响应状态码对应

规则示例:

image-20230418150122166

扩展阅读:https://www.ruanyifeng.com/blog/2014/05/restful_api.html

基于 RESTful API 我们可以给之前的账单案例添加 API 接口

在 routes 目录下新建 web 文件夹(专门用来存放路由文件),将原来 routes 下的 index.js 文件放到 web 文件夹下。在 routes 目录下新建 api 文件夹(专门用来存放对外的 api 接口文件),并将 index.js 文件中的内容复制一份,方便我们进行改造

在 app.js 中引入 account.js 接口文件,并配置中间件,这样外部就可以通过/api/xxx 来访问接口了

1
2
const accountRouter = require("./routes/api/account");
app.use("/api", accountRouter);

接下来我们对 account.js 进行改造即可

获取账单列表接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
router.get("/account", checkToken, function (req, res, next) {
AccountModel.find()
.sort({ time: -1 })
.exec()
.then((data) =>
res.json({
code: "0000",
msg: "读取成功",
data: data,
})
)
.catch(() => {
res.json({
code: "1001",
msg: "读取失败",
data: null,
});
});
});

添加账单记录接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
router.post("/account", checkToken, function (req, res, next) {
AccountModel.create(req.body)
.then((data) => {
res.json({
code: "0000",
msg: "新增成功",
data: data,
});
})
.catch(() => {
res.json({
code: "1002",
msg: "新增失败",
data: null,
});
});
});

删除账单记录接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
router.delete("/account/:id", checkToken, function (req, res, next) {
AccountModel.deleteOne({ _id: req.params.id })
.then(() => {
res.json({
code: "0000",
msg: "删除成功",
data: null,
});
})
.catch(() => {
res.json({
code: "1003",
msg: "删除失败",
data: null,
});
});
});

获取单条账单记录接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//获取单条记录
router.get("/account/:id", checkToken, function (req, res, next) {
AccountModel.find({ _id: req.params.id })
.then((data) => {
res.json({
code: "0000",
msg: "获取成功",
data: data,
});
})
.catch(() => {
res.json({
code: "1004",
msg: "获取失败",
data: null,
});
});
});

更新账单接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
router.patch("/account/:id", checkToken, function (req, res, next) {
AccountModel.updateOne({ _id: req.params.id }, req.body)
.then((data) => {
res.json({
code: "0000",
msg: "更新成功",
data: data,
});
})
.catch(() => {
res.json({
code: "1005",
msg: "更新失败",
data: null,
});
});
});

注意 patch 是局部更新,而 put 是覆盖式更新

以上接口均可以通过接口测试工具去测试是否正常。

在之前的 res 响应中,我们都是通过 res.render 去响应一个页面。而接口则是只需响应一个 json(一般情况下)格式的数据即可。由前端拿到数据后去做进一步的处理,最终呈现页面。

十二、会话控制

1、Web 开发模式

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

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

服务端渲染的传统 Web 开发模式:服务器发送给客户端的 HTML 页面,是在服务器通过字符串的拼接,动态生成的(或者模板引擎 ejs)。因此,客户端不需要使用 Ajax 这样的技术额外请求页面的数据

服务端渲染的优缺点

优点:

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

缺点:

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

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

前后端分离的优缺点

优点:

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

缺点:

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

如何选择 Web 开发模式

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

  • 比如企业级网站,主要功能是展示而没有复杂的交互,并且需要良好的 SEO,则这时我们就需要使用服务器端渲染
  • 而类似后台管理项目,交互性比较强,不需要考虑 SEO,那么就可以使用前后端分离的开发模式

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

HTTP 是一种无状态的协议。所谓 HTTP 协议的无状态性,指的是客户端的每次 HTTP 请求都是独立的,连续多个请求之间没有直接的关系,服务器不会主动保留每次 HTTP 请求的状态。因此它没有办法区分多次的请求是否来自于同一个客户端, 无法区分用户 而产品中又大量存在的这样的需求,所以我们需要通过 会话控制 来解决该问题

常见的会话控制技术有三种:

  • cookie
  • session
  • token

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

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

2、Cookie

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

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

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

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

image-20230419094654761

Cookie 就像我们去办某个店的会员,当我们成为会员之后,店子会给我们发一张会员卡。后续我们出示会员卡来店即可认证我们的身份

express 中可以使用 cookie-parser 操作 cookie

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
const express = require("express");
//1. 安装 cookie-parser npm i cookie-parser
//2. 引入 cookieParser 包
const cookieParser = require("cookie-parser");
const app = express();
//3. 设置 cookieParser 中间件
app.use(cookieParser());
//4-1 设置 cookie
app.get("/set-cookie", (request, response) => {
// 不带时效性
response.cookie("username", "wangwu");
// 带时效性
response.cookie("email", "23123456@qq.com", { maxAge: 5 * 60 * 1000 });
//响应
response.send("Cookie的设置");
});
//4-2 读取 cookie
app.get("/get-cookie", (request, response) => {
//读取 cookie
console.log(request.cookies);
//响应体
response.send("Cookie的读取");
});
//4-3 删除cookie
app.get("/delete-cookie", (request, response) => {
//删除
response.clearCookie("username");
//响应
response.send("cookie 的清除");
});
//4. 启动服务
app.listen(3000, () => {
console.log("服务已经启动....");
});

3、Session

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

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

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

image-20230419095403744

express 中可以使用 express-session 对 session 进行操作

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
const express = require("express");
//1. 安装包 npm i express-session connect-mongo
//2. 引入 express-session connect-mongo
const session = require("express-session");
const MongoStore = require("connect-mongo");
const app = express();
//3. 设置 session 的中间件
app.use(
session({
name: "sid", //设置cookie的name,默认值是:connect.sid
secret: "atguigu", //参与加密的字符串(又称签名)
saveUninitialized: false, //是否为每次请求都设置一个cookie用来存储session的id
resave: true, //是否在每次请求时重新保存session
store: MongoStore.create({
mongoUrl: "mongodb://127.0.0.1:27017/project", //数据库的连接配置
}),
cookie: {
httpOnly: true, // 开启后前端无法通过 JS 操作
maxAge: 1000 * 300, // 这一条 是控制 sessionID 的过期时间的!!!
},
})
);
//创建 session
app.get("/login", (req, res) => {
//设置session
req.session.username = "zhangsan";
req.session.email = "zhangsan@qq.com";
res.send("登录成功");
});
//获取 session
app.get("/home", (req, res) => {
console.log("session的信息");
console.log(req.session.username);
if (req.session.username) {
res.send(`你好 ${req.session.username}`);
} else {
res.send("登录 注册");
}
});
//销毁 session
app.get("/logout", (req, res) => {
//销毁session
// res.send('设置session');
req.session.destroy(() => {
res.send("成功退出");
});
});
app.listen(3000, () => {
console.log("服务已经启动, 端口 " + 3000 + " 监听中...");
});

session 和 cookie 的区别

  • 存储的位置。cookie:浏览器端,session:服务端
  • 安全性。cookie 是以明文的方式存放在客户端的,安全性相对较低。session 存放于服务器中,所以安全性 相对 较好
  • 网络传输量。cookie 设置内容过多会增大报文体积, 会影响传输效率。session 数据存储在服务器,只是通过 cookie 传递 id,所以不影响传输效率
  • 存储限制。浏览器限制单个 cookie 保存的数据不能超过 4K ,且单个域名下的存储数量也有限制。session 数据存储在服务器中,所以没有这些限制

4、完善账单案例

接下来我们可以对账单案例添加权限认证的功能

在 views 目录下新建登录和注册页面 reg.ejs 和 login.ejs

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
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>注册</title>
<link href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/3.3.7/css/bootstrap.css" rel="stylesheet" />
</head>

<body>
<div class="container">
<div class="row">
<div class="col-xs-12 col-md-8 col-md-offset-2 col-lg-4 col-lg-offset-4">
<h2>注册</h2>
<hr />
<form method="post" action="/reg">
<div class="form-group">
<label for="item">用户名</label>
<input name="username" type="text" class="form-control" id="item" />
</div>
<div class="form-group">
<label for="time">密码</label>
<input name="password" type="password" class="form-control" id="time" />
</div>
<hr>
<button type="submit" class="btn btn-primary btn-block">注册</button>
</form>
</div>
</div>
</div>
</body>

</html>

在 routes/web 目录下新建 user.js 用来存放用户登录、注册、退出相关的路由,并配置路由规则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
var express = require("express");
// 引入md5对密码进行加密
const md5 = require("md5");
var router = express.Router();
const UserModel = require("../../Models/UserModel");
//注册页面
router.get("/reg", (req, res) => {
res.render("reg");
});
// 提交注册表单
router.post("/reg", (req, res) => {
// 使用md5对密码进行加密,存储到数据库中
UserModel.create({ ...req.body, password: md5(req.body.password) })
.then(() => {
res.render("success", { msg: "注册成功", url: "/login" });
})
.catch(() => {
res.render("error");
});
});
//登录页面
router.get("/login", (req, res) => {
res.render("login");
});
//提交登录表单
router.post("/login", (req, res) => {
const { username, password } = req.body;
// 用户提交的账号密码跟数据库存储的账号密码进行比对
UserModel.findOne({ username: username, password: md5(password) })
.then((data) => {
if (!data) return res.render("error");
res.render("success", { msg: "登录成功", url: "/account" });
})
.catch(() => {
res.render("error");
});
});
module.exports = router;

Models 目录下新建 UserModel.js 用来存放用户登录的账户密码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const mongoose = require("mongoose");
//创建文档结构对象
let UserSchema = new mongoose.Schema({
username: {
type: String,
required: true,
},
password: {
type: String,
required: true,
},
});
//6. 创建文档模型对象
let UserModel = mongoose.model("user", UserSchema);
module.exports = UserModel;

最后 app.js 引入并注册新建的路由规则,这样一个简单的登录注册功能就实现了

接下来我们需要进行 Session 身份认证

在 app.js 中设置 session 的中间件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 引入 express-session connect-mongo
const session = require("express-session");
const MongoStore = require("connect-mongo");
//设置 session 的中间件
app.use(
session({
name: "sid", //设置cookie的name,默认值是:connect.sid
secret: "iloveyou", //参与加密的字符串(又称签名)
saveUninitialized: false, //是否为每次请求都设置一个cookie用来存储session的id
resave: true, //是否在每次请求时重新保存session
store: MongoStore.create({
mongoUrl: "mongodb://127.0.0.1:27017/bilibili", //数据库的连接配置
}),
cookie: {
httpOnly: true, // 开启后前端无法通过 JS 操作
maxAge: 1000 * 60 * 60 * 24, // 这一条 是控制 sessionID 的过期时间的!!!
},
})
);

登录成功后写入 session

user.js 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//提交登录表单
router.post("/login", (req, res) => {
const { username, password } = req.body;
UserModel.findOne({ username: username, password: md5(password) })
.then((data) => {
if (!data) return res.render("error");
// 登录成功写入session
req.session.username = username;
res.render("success", { msg: "登录成功", url: "/account" });
})
.catch(() => {
res.render("error");
});
});

但是截至到现在,我们只是做了 session 写入,并没有做 session 验证。此时仍然可以通过浏览器地址栏输入路径直接访问账单列表页、并对其进行新增和删除操作。因此我们还需要一个 session 验证的中间件

新建文件夹 MiddleWare,并在此文件夹下新建 sessionLogin.js

1
2
3
4
5
// 设置页面访问session验证的中间件
module.exports = function (req, res, next) {
if (!req.session.username) return res.redirect("/login");
next();
};

在 routes/web/index.js 引入上述中间件并为需要的页面单独添加路由中间件即可。此时,若我们没有登录,直接通过浏览器地址栏输入账单页面的 url 是无法访问的

5、CSRF

CSRF(Cross Site Request Forgery,跨站域请求伪造),也被称为 “One Click Attack” 或者 Session Riding,通常缩写为 CSRF 或者 XSRF

原理

image-20230419141638423

前面我们提到,不同域名下的 Cookie 各自独立,每当客户端发起请求时,会自动把当前域名下所有未过期的 Cookie 一同发送到服务器。然而相同域名和协议下不同端口之间的 cookie 是共享的。这是因为 cookie 存储在客户端的浏览器中,而不是在服务器中,因此不同的端口只是访问同一个服务器的不同入口,它们共享同一个 cookie 存储空间

CSRF 攻击就是利用了浏览器在同一域名下共享 cookie 的特性,从而让攻击者能够携带合法用户的 cookie 发送恶意请求,冒充合法用户执行一些操作

更多 CSRF 攻击知识请参考https://blog.csdn.net/weixin_44211968/article/details/124703525

6、JWT 认证机制

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

token 是服务端生成并返回给 HTTP 客户端的一串加密字符串, token 中保存着 用户信息

token 的特点

  • 服务端压力更小(数据存储在客户端)
  • 相对更安全(数据加密、可以避免 CSRF(跨站请求伪造))
  • 扩展性更强(服务间可以共享、增加服务节点更简单)

JWT

JWT(JSON Web Token )是目前最流行的跨域认证解决方案,可用于基于 token 的身份验证

JWT 的工作原理

image-20230419144115403

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

JWT 的使用方式

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

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

1
Authorization:Bearer <token>

express 中使用 jsonwebtoken 包来操作 token

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//导入 jsonwebtokan
const jwt = require("jsonwebtoken");
//创建 token
// jwt.sign(数据, 加密字符串, 配置对象)
let token = jwt.sign(
{
username: "zhangsan",
},
"iloveyou",
{
expiresIn: 60, //过期时间 单位是 秒
}
);
//解析 token
jwt.verify(token, "atguigu", (err, data) => {
if (err) {
console.log("校验失败~~");
return;
}
console.log(data);
});

更多 JWT 相关知识请参考https://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html

完善账单案例

接下来我们可以使用 JWT 认证机制完善账单案例的 API 接口了。之前我们所写的接口,能够直接去访问。现在我们希望必须在 token 检验通过后才能访问接口

在 routes/api 下新建 auth.js。配置登录的 api 接口,登录成功设置 token

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
const express = require("express");
const md5 = require("md5");
const UserModel = require("../../Models/UserModel");
//导入 jsonwebtokan
const jwt = require("jsonwebtoken");
const router = express.Router();
//提交登录表单
router.post("/login", (req, res) => {
const { username, password } = req.body;
UserModel.findOne({ username: username, password: md5(password) })
.then((data) => {
if (!data) {
res.json({
code: "1006",
msg: "用户名或密码错误",
data: null,
});
return;
}
// 登录成功创建token
// jwt.sign(数据, 加密字符串, 配置对象)
let token = jwt.sign(
{
username: "username",
},
"iloveyou",
{
expiresIn: 60 * 60 * 24, //过期时间 单位是 秒
}
);
res.json({
code: "0000",
msg: "登录成功",
data: token,
});
})
.catch(() => {
res.json({
code: "1007",
msg: "登录失败",
data: null,
});
});
});
module.exports = router;

接下来在 MiddleWare 目录下新建 checkToken.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//导入 jwt
const jwt = require("jsonwebtoken");
module.exports = (req, res, next) => {
const token = req.get("token");
if (!token) {
res.json({
code: "1008",
msg: "token缺失",
data: null,
});
return;
}
//校验token
jwt.verify(token, "iloveyou", (err, data) => {
if (err) {
res.json({
code: "1008",
msg: "token验证失败",
data: null,
});
return;
}
next();
});
};

最后在 api/account.js 的 api 路由规则中使用该路由中间件即可。在 APIFOX 中测试,此时必须在 headers 中添加 token 才能访问 API 接口

十二、项目上线部署

1、购买云服务器

以阿里云为例,购买成功后

image-20230420104617976

2、连接云服务器

  1. 复制我们上面购买的云服务器的公网 IP 地址
  2. 桌面-开始菜单-搜索远程桌面连接
  3. 输入复制的公网 IP 地址连接云服务器

image-20230420104851357

3、安装软件

在云服务器上安装 git、node、MongoDB

4、启动项目

  1. 在云服务器 C 盘新建 www 文件夹
  2. 在 www 文件夹下运行终端命令,将项目从远程仓库克隆下来
  3. 安装项目依赖
  4. 启动项目(确保 MongoDB 的服务也已经启动)

image-20230420105536526

image-20230420105554036

在云服务器的浏览器地址栏输入http://127.0.0.1:3000,我们会发现此时项目已经启动了。而且,我们在其他任何一台接入互联网的电脑上输入云服务器的公网IP,都可以访问到我们的项目

5、域名购买与解析

阿里云购买域名

image-20230420110507274

购买完成,打开域名控制台,等待购买的域名状态审核通过后点击解析,将云服务器的 IP 地址与域名做映射关联

image-20230420110719462

添加记录,记录值就是云服务器的 IP 地址

image-20230420110946503

配置完成后,即可通过主机记录对应的域名访问我们的项目了

当然,除了 Nodejs server,我们还可以将项目部署在 Nginx、Apache、Tomcat、IIS 等 web 服务器上。他们都可以向外提供 web 服务

实现图片上传预览和裁切功能

一、纯客户端实现上传图片预览

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>客户端图片上传预览示例</title>
<style>
.img-wrap {
width: 200px;
height: 200px;
border: 1px solid #ccc;
}

img {
max-width: 100%;
}
</style>
</head>
<body>
<h1>客户端图片上传预览示例</h1>
<div class="img-wrap">
<img src="" alt="" id="img" />
</div>
<br />
<!-- <button>头像</button> -->
<input hidden type="file" id="file" onchange="onFileChange()" />
<script>
const img = document.querySelector("#img");
const file = document.querySelector("#file");
// const btn = document.querySelector("button");
// const ipt = document.querySelector("input");
function onFileChange() {
// 得到 file-input 的文件对象
const fileObj = file.files[0];
const data = window.URL.createObjectURL(fileObj);
img.src = data;
}
// btn.addEventListener("click", () => ipt.click());
</script>
</body>
</html>

而在实际的项目中,file 类型的 input 框基本上都是隐藏的,通过另外一个显示的元素的某些事件(比如点击事件)以代码的方式手动触发 input 的点击事件。也就是注释部分。

二、图片裁切

1、安装 cropperjs 插件

1
npm install cropperjs

2、创建图片裁剪组件

update-photo.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
<template>
<div class="update-photo">
<img class="img" :src="img" ref="img" />

<div class="toolbar">
<div class="cancel" @click="$emit('close')">取消</div>
<div class="confirm" @click="onConfirm">完成</div>
</div>
</div>
</template>

<script>
import "cropperjs/dist/cropper.css";
import Cropper from "cropperjs";
import { updateUserPhoto } from "@/api/user";

export default {
name: "UpdatePhoto",
components: {},
props: {
img: {
type: [String, Object],
required: true,
},
},
data() {
return {
cropper: null,
};
},
computed: {},
watch: {},
created() {},
mounted() {
const image = this.$refs.img;
this.cropper = new Cropper(image, {
viewMode: 1,
dragMode: "move",
aspectRatio: 1,
// autoCropArea: 1,
cropBoxMovable: false,
cropBoxResizable: false,
background: false,
});
},
methods: {
onConfirm() {
// 基于服务端的裁切使用 getData 方法获取裁切参数
// console.log(this.cropper.getData())

// 纯客户端的裁切使用 getCroppedCanvas 获取裁切的文件对象
this.cropper.getCroppedCanvas().toBlob((blob) => {
this.updateUserPhoto(blob);
});
},

async updateUserPhoto(blob) {
this.$toast.loading({
message: "保存中...",
forbidClick: true, // 禁止背景点击
duration: 0, // 持续展示
});
try {
// 错误的用法
// 如果接口要求 Content-Type 是 application/json
// 则传递普通 JavaScript 对象
// updateUserPhoto({
// photo: blob
// })

// 如果接口要求 Content-Type 是 multipart/form-data
// 则你必须传递 FormData 对象
const formData = new FormData();
formData.append("photo", blob);

const { data } = await updateUserPhoto(formData);

// 关闭弹出层
this.$emit("close");

// 更新视图
this.$emit("update-photo", data.data.photo);

// 提示成功
this.$toast.success("更新成功");
} catch (err) {
this.$toast.fail("更新失败");
}
},
},
};
</script>

<style scoped lang="less">
.update-photo {
background-color: #000;
height: 100%;
.toolbar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: space-between;
.cancel,
.confirm {
width: 90px;
height: 90px;
font-size: 30px;
display: flex;
justify-content: center;
align-items: center;
color: #fff;
}
}
}
.img {
display: block;
max-width: 100%;
}
</style>

可以看到,图片裁剪组件从父组件接收传递过来的图片地址。在 mounted 根据图片 dom 创建一个 cropper 对象,里面的配置参数我们可以根据需要填写。当点击完成时,使用 getCroppedCanvas 获取裁切的文件对象,并将该对象传递给后端。

3、父组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
<template>
<van-cell
class="photo-cell"
title="头像"
is-link
center
@click="$refs.file.click()"
>
<van-image class="avatar" fit="cover" round :src="user.photo" />
</van-cell>
<van-popup
v-model="isUpdatePhotoShow"
position="bottom"
style="height: 100%;"
>
<update-photo
v-if="isUpdatePhotoShow"
:img="img"
@close="isUpdatePhotoShow = false"
@update-photo="user.photo = $event"
/>
</van-popup>
</template>
<script>
import { getUserProfile } from "@/api/user";
import UpdatePhoto from "./components/update-photo";
export default {
name: "UserProfile",
components: {
UpdatePhoto,
},
props: {},
data() {
return {
user: {}, // 个人信息
isUpdatePhotoShow: false,
img: null, // 预览的图片
};
},
computed: {},
watch: {},
created() {
this.loadUserProfile();
},
mounted() {},
methods: {
async loadUserProfile() {
try {
const { data } = await getUserProfile();
this.user = data.data;
} catch (err) {
this.$toast("数据获取失败");
}
},

onFileChange() {
// 获取文件对象
const file = this.$refs.file.files[0];

// 基于文章对象获取 blob 数据
this.img = window.URL.createObjectURL(file);

// 展示预览图片弹出层
this.isUpdatePhotoShow = true;

// file-input 如果选了同一个文件不会触发 change 事件
// 解决办法就是每次使用完毕,把它的 value 清空
this.$refs.file.value = "";
},
},
};
</script>

我的前端学习资源汇总

本篇本章是想将我前端之路上的各种资源,包括视频、博客、网站、我自己电脑常用的软件进行归纳整理备份。虽然谷歌浏览器相关的已经和我的谷歌账号绑定了,vscode 相关的也和 github 账号绑定了。但还是手动备份一份心里踏实一些。

一、常用软件

浏览器:Chrome、Edge、Firefox

Chrome 浏览器插件:Vue.js devtools、iGG 谷歌学术助手、RSS Reader Extension

代码编辑器:VSCode

VSCode 插件:any-rule、Atom One Dark Theme、Auto Rename Tag、ChatGPT 中文版、Chinese(Simplified)(简体中文)、Code Runner、Codeif、Easy LESS、Error Lens、ESLint、HTML CSS Support、JavaScript(ES6)code snippets、jQuery Code Snippets、Live Server、Material Icon Theme、open in browser、Prettier-Code formatter、px to rem&rpx&vw(cssrem)、vscode-icons、Vue Language Features (Volar)、VueHelper、Vetur

VSCode 字体:Fira Code

VSCode 中 settings.json 设置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
{
"editor.formatOnSave": true,
"security.workspace.trust.untrustedFiles": "open",
"workbench.iconTheme": "material-icon-theme",
"liveServer.settings.donotShowInfoMsg": true,
"workbench.colorCustomizations": {
"editor.lineHighlightBackground": "#1073cf2d",
"editor.lineHighlightBorder": "#9fced11f"
},
"editor.wordWrap": "off",
"diffEditor.wordWrap": "off",
"editor.guides.bracketPairs": false,
"liveServer.settings.CustomBrowser": "chrome",
"code-runner.runInTerminal": true,
"vsicons.dontShowNewVersionMessage": true,
"emmet.triggerExpansionOnTab": true,
"explorer.confirmDelete": false,
"editor.inlayHints.enabled": "on",
"less.compile": {
"out": "../css/"
},
"cssrem.rootFontSize": 80,
"cssrem.vwDesign": 1920,
"vetur.completion.scaffoldSnippetSources": {
"workspace": "💼",
"user": "🗒️",
"vetur": "✌"
},
"emmet.syntaxProfiles": {
"vue-html": "html",
"vue": "html"
},
//vue自动补全
"files.associations": {
"*.vue": "vue",
"*.ejs": "html",
"*.js": "javascript"
},
"vetur.experimental.templateInterpolationService": true,
"vetur.validation.interpolation": false,
"workbench.tree.renderIndentGuides": "always",
"workbench.tree.indent": 18,
"workbench.preferredHighContrastLightColorTheme": "Default Dark+",
"workbench.preferredLightColorTheme": "Default Dark+",
"eslint.enable": true,
"eslint.run": "onType",
"eslint.options": {
"extensions": [".js", ".vue", ".jsx", ".tsx"]
},
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"workbench.colorTheme": "Atom One Dark",
"editor.tokenColorCustomizations": {
"comments": {
// 设置字体样式 加粗 下划线 斜体等
"fontStyle": "italic",
// 设置字体颜色
"foreground": "#4CAEE2"
}, // 注释
"keywords": "#0a0", // 关键字
"variables": "#f00", // 变量名
"strings": "#e2d75dbd", // 字符串
"functions": "#5b99fcc9", // 函数名
"numbers": "#AE81FF" // 数字
},
"prettier.trailingComma": "es5",
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"editor.fontLigatures": true,
"editor.fontVariations": false,
"settingsSync.ignoredSettings": ["terminal.integrated.fontFamily"],
"editor.fontFamily": "Fira Code",
"http.proxyAuthorization": null,
"vetur.format.defaultFormatter.js": "prettier-eslint",
"vetur.format.defaultFormatterOptions": {
"prettier": {
"singleQuote": true, //用单引号
"semi": false //不加分号
}
},
"editor.inlineSuggest.enabled": true
}

文档笔记:Typora

插件/应用:uTools、PxCook 像素大厨

截图工具:snipaste

翻译工具:有道词典

VPN:目前用的红海

API 接口测试工具:Postman、ApiFox

画图工具:DiagramDesigner、draw.io

JSON 格式化工具:Notepad++、Notepad–

github 加速工具:FastGithub、SwitchHosts

数据库管理工具:Navicat

版本控制:git

运行环境:Node.js(自带 npm)、NVM 切换 node 版本、nodemon

查看 npm 包:https://www.npmjs.com/

Web 开发框架:Express

前端框架:Vue

二、常用网站

Vue:https://cn.vuejs.org/

ElementUI:https://element.eleme.cn/#/zh-CN

MDN:https://developer.mozilla.org/zh-CN/

GitHub:https://github.com/

稀土掘金:https://juejin.cn/

书栈网:https://www.bookstack.cn/

Vant:https://vant-ui.github.io/vant/?#/zh-CN

NutUI:https://nutui.jd.com/#/

查询兼容性:https://caniuse.com/

在线工具:https://tool.lu/

lodash 工具库:https://www.lodashjs.com/

字体图标:https://www.iconfont.cn/

腾讯云:https://cloud.tencent.com/

三、学习资源

前端入门:

pink 老师前端入门教程:https://www.bilibili.com/video/BV14J4114768

ES6: https://es6.ruanyifeng.com/

Vue2+Vue3 尚硅谷张天禹老师:https://www.bilibili.com/video/BV1Zy4y1K7SH/?spm_id_from=333.337.search-card.all.click

Vue3+TS 快速上手:https://24kcs.github.io/vue3_study/

四、常用插件

移动端 REM 适配:

移动端 vw 适配:postcss-px-to-viewport

网络请求:axios

时间处理:

文章样式处理:github-markdown-css

图片裁切处理:cropper.js

后台前端解决方案:vue-element-admin

Excel 导入导出:js-xlsx

图片地址生成二维码:qrcode

打印:vue-print-nb

数据持久化:vuex-persistedstate

当后端返回数据中有大数字时的解决方案

JavaScript 能够准确表示的整数范围在-2^532^53之间(不含两个端点),超过这个范围,无法精确表示这个值,这使得 JavaScript 不适合进行科学和金融方面的精确计算。因此如果后端返回的数据中有大数字,前端不加以处理就会导致数据不能正常处理。

1
2
3
4
5
6
7
Math.pow(2, 53); // 9007199254740992

9007199254740992; // 9007199254740992
9007199254740993; // 9007199254740992

Math.pow(2, 53) === Math.pow(2, 53) + 1;
// true

上面代码中,超出 2 的 53 次方之后,一个数就不精确了。
ES6 引入了Number.MAX_SAFE_INTEGERNumber.MIN_SAFE_INTEGER这两个常量,用来表示这个范围的上下限。

1
2
3
4
5
6
7
8
9
Number.MAX_SAFE_INTEGER === Math.pow(2, 53) - 1;
// true
Number.MAX_SAFE_INTEGER === 9007199254740991;
// true

Number.MIN_SAFE_INTEGER === -Number.MAX_SAFE_INTEGER;
// true
Number.MIN_SAFE_INTEGER === -9007199254740991;
// true

上面代码中,可以看到 JavaScript 能够精确表示的极限。

后端返回的数据一般都是 JSON 格式的字符串

1
"{ \"id\": 9007199254740995, \"name\": \"Jack\", \"age\": 18 }"

如果这个字符不做任何处理,你能方便的获取到字符串中的指定数据吗?非常麻烦。所以我们要把它转换为 JavaScript 对象来使用就很方便了。

幸运的是 axios 为了方便我们使用数据,它会在内部使用 JSON.parse() 把后端返回的数据转为 JavaScript 对象。

1
2
// { id: 9007199254740996, name: 'Jack', age: 18 }
JSON.parse('{ "id": 9007199254740995, "name": "Jack", "age": 18 }');

可以看到,超出安全整数范围的 id 无法精确表示,这个问题并不是 axios 的错。

了解了什么是大整数的概念,接下来的问题是如何解决?

json-bigint 是一个第三方包,它可以帮我们很好的处理这个问题。

使用它的第一步就是把它安装到你的项目中。

1
npm i json-bigint

下面是使用它的一个简单示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const jsonStr = '{ "art_id": 1245953273786007552 }';

console.log(JSON.parse(jsonStr)); // 1245953273786007600
// JSON.stringify()

// JSONBig 可以处理数据中超出 JavaScript 安全整数范围的问题
console.log(JSONBig.parse(jsonStr)); // 把 JSON 格式的字符串转为 JavaScript 对象

// 使用的时候需要把 BigNumber 类型的数据转为字符串来使用
console.log(JSONBig.parse(jsonStr).art_id.toString()); // 1245953273786007552

console.log(JSON.stringify(JSONBig.parse(jsonStr)));

console.log(JSONBig.stringify(JSONBig.parse(jsonStr))); // 把 JavaScript 对象 转为 JSON 格式的字符串转

image.png

json-bigint 会把超出 JS 安全整数范围的数字转为一个 BigNumber 类型的对象,对象数据是它内部的一个算法处理之后的,我们要做的就是在使用的时候转为字符串来使用。

通过 Axios 请求得到的数据都是 Axios 处理(JSON.parse)之后的,我们应该在 Axios 执行处理之前手动使用 json-bigint 来解析处理。Axios 提供了自定义处理原始后端返回数据的 API:transformResponse

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
import axios from "axios";

import jsonBig from "json-bigint";

var json = '{ "value" : 9223372036854775807, "v2": 123 }';

console.log(jsonBig.parse(json));

const request = axios.create({
baseURL: "http://ttapi.research.itcast.cn/", // 接口基础路径

// transformResponse 允许自定义原始的响应数据(字符串)
transformResponse: [
function (data) {
try {
// 如果转换成功则返回转换的数据结果
return jsonBig.parse(data);
} catch (err) {
// 如果转换失败,则包装为统一数据格式并返回
return {
data,
};
}
},
],
});

export default request;

扩展:ES2020 BigInt

ES2020 引入了一种新的数据类型 BigInt(大整数),来解决这个问题。BigInt 只用来表示整数,没有位数的限制,任何位数的整数都可以精确表示。

参考链接:

两种移动端适配方案

一、lib-flexible+postcss-pxtorem

在相当长一段时间里,这两个插件搭配都是解决移动端布局的神器,lib-flexible是阿里手淘系开源的一个库,用于设置 font-size,同时处理一些窗口缩放的问题。直到今天,这个方案仍是解决移动端布局的主流方案。

下面我们分别将这两个工具配置到项目中完成 REM 适配。

1.1使用 lib-flexible 动态设置 REM 基准值(html 标签的字体大小)

1、安装

1
2
# yarn add amfe-flexible
npm i amfe-flexible

2、然后在 main.js 中加载执行该模块

1
import "amfe-flexible";

最后测试:在浏览器中切换不同的手机设备尺寸,观察 html 标签 font-size 的变化。

1.2使用 postcss-pxtorempx 转为 rem

1、安装

1
2
3
# yarn add -D postcss-pxtorem
# -D 是 --save-dev 的简写
npm install postcss-pxtorem -D

2、然后在项目根目录中创建 .postcssrc.js 文件

1
2
3
4
5
6
7
8
9
10
11
module.exports = {
plugins: {
autoprefixer: {
browsers: ["Android >= 4.0", "iOS >= 8"],
},
"postcss-pxtorem": {
rootValue: 37.5,
propList: ["*"],
},
},
};

3、配置完毕,重新启动服务

最后测试:刷新浏览器页面,审查元素的样式查看是否已将 px 转换为 rem

需要注意的是:

  • 该插件**不能转换行内样式中的 px**,例如 <div style="width: 200px;"></div>

1.3 关于 .postcssrc.js 配置文件

1
2
3
4
5
6
7
8
9
10
11
module.exports = {
plugins: {
autoprefixer: {
browsers: ["Android >= 4.0", "iOS >= 8"],
},
"postcss-pxtorem": {
rootValue: 37.5,
propList: ["*"],
},
},
};

.postcssrc.js 是 PostCSS 的配置文件。

(1)PostCSS 介绍

PostCSS 是一个处理 CSS 的处理工具,本身功能比较单一,它主要负责解析 CSS 代码,再交由插件来进行处理,它的插件体系非常强大,所能进行的操作是多种多样的,例如:

目前 PostCSS 已经有 200 多个功能各异的插件。开发人员也可以根据项目的需要,开发出自己的 PostCSS 插件。

PostCSS 一般不单独使用,而是与已有的构建工具进行集成。

Vue CLI 默认集成了 PostCSS,并且默认开启了 autoprefixer 插件。

Vue CLI 内部使用了 PostCSS。

你可以通过 .postcssrc 或任何 postcss-load-config 支持的配置源来配置 PostCSS。也可以通过 vue.config.js 中的 css.loaderOptions.postcss 配置 postcss-loader

我们默认开启了 autoprefixer。如果要配置目标浏览器,可使用 package.jsonbrowserslist 字段。

(2)Autoprefixer 插件的配置

image-20200319104557718

autoprefixer 是一个自动添加浏览器前缀的 PostCss 插件,browsers 用来配置兼容的浏览器版本信息,但是写在这里的话会引起编译器警告。

1
2
3
4
5
6
7
8
9
10
11
Replace Autoprefixer browsers option to Browserslist config.
Use browserslist key in package.json or .browserslistrc file.

Using browsers option can cause errors. Browserslist config
can be used for Babel, Autoprefixer, postcss-normalize and other tools.

If you really need to use option, rename it to overrideBrowserslist.

Learn more at:
https://github.com/browserslist/browserslist#readme
https://twitter.com/browserslist

警告意思就是说你应该将 browsers 选项写到 package.json.browserlistrc 文件中。

1
2
3
4
5
6
[Android]
>= 4.0

[iOS]
>= 8

具体语法请参考这里

(3)postcss-pxtorem 插件的配置

image-20200319105610557

  • rootValue:表示根元素字体大小,它会根据根元素大小进行单位转换
  • propList 用来设定可以从 px 转为 rem 的属性
    • 例如 * 就是所有属性都要转换,width 就是仅转换 width 属性

rootValue 应该如何设置呢?

1
2
如果你使用的是基于 lib-flexable 的 REM 适配方案,则应该设置为你的设计稿的十分之一。
例如设计稿是 750 宽,则应该设置为 75。

大多数设计稿的原型都是以 iphone6 为原型,iphone6 设备的宽是 750,我们的设计稿也是这样。

但是 Vant 建议设置为 37.5,为什么呢?

1
因为 Vant 是基于 375 写的,所以如果你设置为 75 的话,Vant 的样式就小了一半。

所以如果设置为 37.5 的话,Vant 的样式是没有问题的,但是我们在测量设计稿的时候都必须除 2 才能使用,否则就会变得很大。

这样做其实也没有问题,但是有没有更好的办法呢?我就想实现测量多少写多少(不用换算)。于是聪明的你就想,可以不可以这样来做?

  • 如果是 Vant 的样式,就把 rootValue 设置为 37.5 来转换
  • 如果是我们的样式,就按照 75 的 rootValue 来转换

通过查阅文档我们可以看到 rootValue 支持两种参数类型:

  • 数字:固定值
  • 函数:动态计算返回
    • postcss-pxtorem 处理每个 CSS 文件的时候都会来调用这个函数
    • 它会把被处理的 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
/**
* PostCSS 配置文件
*/

module.exports = {
// 配置要使用的 PostCSS 插件
plugins: {
// 配置使用 autoprefixer 插件
// 作用:生成浏览器 CSS 样式规则前缀
// VueCLI 内部已经配置了 autoprefixer 插件
// 所以又配置了一次,所以产生冲突了
// 'autoprefixer': { // autoprefixer 插件的配置
// // 配置要兼容到的环境信息
// browsers: ['Android >= 4.0', 'iOS >= 8']
// },

// 配置使用 postcss-pxtorem 插件
// 作用:把 px 转为 rem
"postcss-pxtorem": {
rootValue({ file }) {
return file.indexOf("vant") !== -1 ? 37.5 : 75;
},
propList: ["*"],
},
},
};

配置完毕,把服务重启一下

二、postcss-px-to-viewport

在最近的一个移动端项目中,我使用第一种方案遇到了一些问题,导致我不能将 px 转化为 rem。于是我又找到了另外一种方案

postcss-px-to-viewport是一款优秀的插件,它将 px 转换成视口单位 vw https://github.com/evrone/postcss-px-to-viewport/blob/master/README_CN.md#%E9%85%8D%E7%BD%AE%E5%8F%82%E6%95%B0

2.1 安装

1
$ npm install postcss-px-to-viewport --save-dev

或者

1
$ yarn add -D postcss-px-to-viewport

2.2 在项目根目录下添加.postcssrc.js文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
module.exports = {
plugins: {
"postcss-px-to-viewport": {
// 配置详情可见 https://github.com/evrone/postcss-px-to-viewport/blob/master/README_CN.md#%E9%85%8D%E7%BD%AE%E5%8F%82%E6%95%B0
unitToConvert: "px",
viewportWidth: 750,
unitPrecision: 5,
propList: ["*"],
viewportUnit: "vw",
fontViewportUnit: "vw",
selectorBlackList: [],
minPixelValue: 1,
mediaQuery: false,
replace: true,
exclude: [/node_modules/],
include: undefined,
landscape: false,
landscapeUnit: "vw",
landscapeWidth: 1920,
},
},
};

最后重启项目即可

移动端搜索栏实现

一、搜索栏常用功能

一般搜索栏会有搜索联想提示功能、搜索历史和搜索结果功能。因此我们可以将整个组件拆分成搜索联想建议、搜索结果、搜索历史记录三个组件

二、创建父组件并配置路由

1、创建 src/views/search/index.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
<template>
<div class="search-container">
<!-- 搜索栏 -->
<!--
Tips: 在 van-search 外层增加 form 标签,且 action 不为空,即可在 iOS 输入法中显示搜索按钮
-->
<form action="/">
<van-search
v-model="searchText"
show-action
placeholder="请输入搜索关键词"
background="#3296fa"
@search="onSearch"
@cancel="onCancel"
/>
</form>
<!-- /搜索栏 -->

<!-- 搜索历史记录 -->
<search-history />
<!-- /搜索历史记录 -->

<!-- 联想建议 -->
<search-suggestion />
<!-- /联想建议 -->

<!-- 历史记录 -->
<search-result />
<!-- /历史记录 -->
</div>
</template>

<script>
import SearchHistory from "./components/search-history";
import SearchSuggestion from "./components/search-suggestion";
import SearchResult from "./components/search-result";

export default {
name: "SearchIndex",
components: {
SearchHistory,
SearchSuggestion,
SearchResult,
},
props: {},
data() {
return {
searchText: "",
};
},
computed: {},
watch: {},
created() {},
mounted() {},
methods: {
onSearch(val) {
console.log(val);
},
onCancel() {
this.$router.back();
},
},
};
</script>

<style scoped lang="less">
.search-container {
.van-search__action {
color: #fff;
}
}
</style>

2、然后把搜索页面的路由配置到根组件路由(一级路由)

1
2
3
4
{
path: '/search',
omponent: Search
}

三、搜索历史记录子组件

创建 src/views/search/components/search-history.vue

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
<template>
<div class="search-history">
<van-cell title="搜索历史">
<span>全部删除</span>
<span>完成</span>
<van-icon name="delete" />
</van-cell>
<van-cell title="hello">
<van-icon name="close" />
</van-cell>
<van-cell title="hello">
<van-icon name="close" />
</van-cell>
<van-cell title="hello">
<van-icon name="close" />
</van-cell>
<van-cell title="hello">
<van-icon name="close" />
</van-cell>
</div>
</template>

<script>
export default {
name: "SearchHistory",
components: {},
props: {},
data() {
return {};
},
computed: {},
watch: {},
created() {},
mounted() {},
methods: {},
};
</script>

<style scoped lang="less"></style>

四、搜索联想建议子组件

创建 src/views/search/components/search-suggestion.vue

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
<template>
<div class="search-suggestion">
<van-cell title="黑马程序员..." icon="search"></van-cell>
<van-cell title="黑马程序员..." icon="search"></van-cell>
<van-cell title="黑马程序员..." icon="search"></van-cell>
<van-cell title="黑马程序员..." icon="search"></van-cell>
<van-cell title="黑马程序员..." icon="search"></van-cell>
</div>
</template>

<script>
export default {
name: "SearchSuggestion",
components: {},
props: {},
data() {
return {};
},
computed: {},
watch: {},
created() {},
mounted() {},
methods: {},
};
</script>

<style scoped lang="less"></style>

五、搜索结果子组件

创建 src/views/search/components/search-result.vue

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
<template>
<div class="search-result">
<van-list
v-model="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
>
<van-cell v-for="item in list" :key="item" :title="item" />
</van-list>
</div>
</template>

<script>
export default {
name: "SearchResult",
components: {},
props: {},
data() {
return {
list: [],
loading: false,
finished: false,
};
},
computed: {},
watch: {},
created() {},
mounted() {},
methods: {
onLoad() {
// 异步更新数据
// setTimeout 仅做示例,真实场景中一般为 ajax 请求
setTimeout(() => {
for (let i = 0; i < 10; i++) {
this.list.push(this.list.length + 1);
}

// 加载状态结束
this.loading = false;

// 数据全部加载完成
if (this.list.length >= 40) {
this.finished = true;
}
}, 1000);
},
},
};
</script>

<style scoped lang="less"></style>

六、处理页面显示状态

我们可以定义一个 isResultShow 变量来控制搜索结果的显示与否。当用户点击确定或者选中某条搜索历史记录时展示搜索结果。而联想建议和搜索历史记录则可以通过搜索栏是否有值来互斥的显示。

1、在 data 中添加数据用来控制搜索结果的显示状态

1
2
3
4
data () {
...
isResultShow: false
}

2、在模板中绑定条件渲染

1
2
3
4
5
6
7
8
9
10
11
<!-- 搜索结果 -->
<search-result v-if="isResultShow" />
<!-- /搜索结果 -->

<!-- 联想建议 -->
<search-suggestion v-else-if="searchText" />
<!-- /联想建议 -->

<!-- 搜索历史记录 -->
<search-history v-else />
<!-- /搜索历史记录 -->

七、搜索联想建议

基本思路:

  • 当搜索框输入内容的时候,请求加载联想建议的数据
  • 将请求得到的结果绑定到模板中

基本功能

一、将父组件中搜索框输入的内容传给联想建议子组件

二、在子组件中监视搜索框输入内容的变化,如果变化则请求获取联想建议数据

三、将获取到的联想建议数据展示到列表中

防抖优化

1、安装 lodash

1
2
# yarn add lodash
npm i lodash

2、防抖处理

1
2
// lodash 支持按需加载,有利于打包结果优化
import { debounce } from "lodash";

不建议下面这样使用,因为这样会加载整个模块。

1
2
import _ from "lodash";
_.debounce();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// debounce 函数
// 参数1:函数
// 参数2:防抖时间
// 返回值:防抖之后的函数,和参数1功能是一样的
onSearchInput: debounce(async function () {
const searchContent = this.searchContent
if (!searchContent) {
return
}

// 1. 请求获取数据
const { data } = await getSuggestions(searchContent)

// 2. 将数据添加到组件实例中
this.suggestions = data.data.options

// 3. 模板绑定
}, 200),

PC 端的联想搜索则可以借助 elementPlus 的 Autocomplete 自动补全输入框组件轻易实现

八、搜索关键字高亮

如何将字符串中的指定字符在网页中高亮展示?

1
"Hello World";

将需要高亮的字符包裹 HTML 标签,为其单独设置颜色。

1
"Hello <span style="color: red">World</span>"

在 Vue 中如何渲染带有 HTML 标签的字符串?

1
2
3
4
5
data () {
return {
htmlStr: 'Hello <span style="color: red">World</span>'
}
}
1
2
<div>{{ htmlStr }}</div>
<div v-html="htmlStr"></div>

![image-20200112154732044](E:\web 前端开发\02-配套课件\005- Vue.js 基础到高级项目实战\阶段五:社交媒体-头条项目资料\前端 vue 移动端项目资料\vue 移动端项目第一天\讲义\讲义\assets\image-20200112154732044.png)

如何把字符串中指定字符统一替换为高亮(包裹了 HTML)的字符?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const str = "Hello World";

// 结果:<span style="color: red">Hello</span> World
"Hello World".replace("Hello", '<span style="color: red">Hello</span>');

// 需要注意的是,replace 方法的字符串匹配只能替换第1个满足的字符
// <span style="color: red">Hello</span> World Hello abc
"Hello World Hello abc".replace(
"Hello",
'<span style="color: red">Hello</span>'
);

// 如果想要全文替换,使用正则表达式
// g 全局
// i 忽略大小写
// <span style="color: red">Hello</span> World <span style="color: red">Hello</span> abc
"Hello World Hello abc".replace(
/Hello/gi,
'<span style="color: red">Hello</span>'
);

一个小扩展:使用字符串的 split 结合数组的 join 方法实现高亮

1
2
3
4
5
6
7
var str = "hello world 你好 hello";

// ["", " world 你好 ", ""]
const arr = str.split("hello");

// "<span>hello</span> world 你好 <span>hello</span>"
arr.join("<span>hello</span>");

下面是具体的处理。

1、在 methods 中添加一个方法处理高亮

1
2
3
4
5
6
7
8
9
10
11
12
// 参数 source: 原始字符串
// 参数 keyword: 需要高亮的关键词
// 返回值:替换之后的高亮字符串
highlight (source, keyword) {
// /searchContent/ 正则表达式中的一切内容都会当做字符串使用
// 这里可以 new RegExp 方式根据字符串创建一个正则表达式
// RegExp 是原生 JavaScript 的内置构造函数
// 参数1:字符串,注意,这里不要加 //
// 参数2:匹配模式,g 全局,i 忽略大小写
const reg = new RegExp(keyword, 'gi')
return source.replace(reg, `<span style="color: #3296fa">${keyword}</span>`)
},

2、然后在联想建议列表项中绑定调用

1
2
3
4
5
6
7
8
9
10
11
12
<!-- 联想建议 -->
<van-cell-group v-else-if="searchContent">
<van-cell
icon="search"
v-for="(item, index) in suggestions"
:key="index"
@click="onSearch(item)"
>
<div slot="title" v-html="highlight(item, searchContent)"></div>
</van-cell>
</van-cell-group>
<!-- /联想建议 -->

九、搜索结果

思路:

  • 找到数据接口
  • 请求获取数据
  • 将数据展示到模板中

一、获取搜索关键字

1、声明接收父组件中的搜索框输入的内容

1
2
3
4
5
6
props: {
q: {
type: String,
require: true
}
},

2、在父组件给子组件传递数据

1
2
3
<!-- 搜索结果 -->
<search-result v-if="isResultShow" :q="searchText" />
<!-- /搜索结果 -->

最后在调试工具中查看确认是否接收到 props 数据。

![image-20200112162223915](E:\web 前端开发\02-配套课件\005- Vue.js 基础到高级项目实战\阶段五:社交媒体-头条项目资料\前端 vue 移动端项目资料\vue 移动端项目第一天\讲义\讲义\assets\image-20200112162223915.png)

二、请求获取数据

1、在 api/serach.js 添加封装获取搜索结果的请求方法

1
2
3
4
5
6
7
8
9
10
/**
* 获取搜索结果
*/
export function getSearch(params) {
return request({
method: "GET",
url: "/app/v1_0/search",
params,
});
}

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
+ import { getSearch } from '@/api/search'

export default {
name: 'SearchResult',
components: {},
props: {
q: {
type: String,
require: true
}
},
data () {
return {
list: [],
loading: false,
finished: false,
+ page: 1,
+ perPage: 20
}
},
computed: {},
watch: {},
created () {},
mounted () {},
methods: {
+++ async onLoad () {
// 1. 请求获取数据
const { data } = await getSearch({
page: this.page, // 页码
per_page: this.perPage, // 每页大小
q: this.q // 搜索关键字
})

// 2. 将数据添加到列表中
const { results } = data.data
this.list.push(...results)

// 3. 设置加载状态结束
this.loading = false

// 4. 判断数据是否加载完毕
if (results.length) {
this.page++ // 更新获取下一页数据的页码
} else {
this.finished = true // 没有数据了,将加载状态设置结束,不再 onLoad
}
}
}
}

三、最后,模板绑定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<van-list
v-model="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
>
<van-cell
+
v-for="(article, index) in list"
+
:key="index"
+
:title="article.title"
/>
</van-list>

十、搜索历史记录

添加历史记录

当发生搜索的时候我们才需要记录历史记录。

1、在 data 中添加一个数据用来存储历史记录

1
2
3
4
5
6
data () {
return {
...
searchHistories: []
}
}

2、在触发搜索的时候,记录历史记录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
onSearch (val) {
// 更新文本框内容
this.searchText = val

// 存储搜索历史记录
// 要求:不要有重复历史记录、最新的排在最前面
const index = this.searchHistories.indexOf(val)
if (index !== -1) {
this.searchHistories.splice(index, 1)
}
this.searchHistories.unshift(val)

// 渲染搜索结果
this.isResultShow = true
},

展示历史记录

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- 历史记录 -->
<van-cell-group v-else>
<van-cell title="历史记录">
<van-icon name="delete" />
<span>全部删除</span>
&nbsp;&nbsp;
<span>完成</span>
</van-cell>
<van-cell :title="item" v-for="(item, index) in searchHistories" :key="index">
<van-icon name="close"></van-icon>
</van-cell>
</van-cell-group>
<!-- /历史记录 -->

删除历史记录

基本思路:

  • 给历史记录中的每一项注册点击事件
  • 在处理函数中判断
    • 如果是删除状态,则执行删除操作
    • 如果是非删除状态,则执行搜索操作

一、处理删除相关元素的展示状态

1、在 data 中添加一个数据用来控制删除相关元素的显示状态

1
2
3
4
5
6
data () {
return {
...
isDeleteShow: false
}
}

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
<!-- 历史记录 -->
<van-cell-group v-else>
<van-cell title="历史记录">
<template v-if="isDeleteShow">
<span @click="searchHistories = []">全部删除</span>
&nbsp;&nbsp;
<span @click="isDeleteShow = false">完成</span>
</template>
<van-icon v-else name="delete" @click="isDeleteShow = true"></van-icon>
</van-cell>
<van-cell
:title="item"
v-for="(item, index) in searchHistories"
:key="index"
@click="onSearch(item)"
>
<van-icon
v-show="isDeleteShow"
name="close"
@click="searchHistories.splice(index, 1)"
></van-icon>
</van-cell>
</van-cell-group>
<!-- /历史记录 -->

二、处理删除操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- 历史记录 -->
<van-cell-group v-else>
<van-cell title="历史记录">
<template v-if="isDeleteShow">
+ <span @click="searchHistories = []">全部删除</span>
&nbsp;&nbsp;
<span @click="isDeleteShow = false">完成</span>
</template>
<van-icon v-else name="delete" @click="isDeleteShow = true" />
</van-cell>
<van-cell
:title="item"
v-for="(item, index) in searchHistories"
:key="index"
+
@click="onHistoryClick(item, index)"
>
<van-icon v-show="isDeleteShow" name="close"></van-icon>
</van-cell>
</van-cell-group>
<!-- /历史记录 -->
1
2
3
4
5
6
7
8
9
onHistoryClick (item, index) {
// 如果是删除状态,则执行删除操作
if (this.isDeleteShow) {
this.searchHistories.splice(index, 1)
} else {
// 否则执行搜索操作
this.onSearch(item)
}
}

数据持久化

1、利用 watch 监视统一存储数据

1
2
3
4
5
6
watch: {
searchHistories (val) {
// 同步到本地存储
setItem('serach-histories', val)
}
},

2、初始化的时候从本地存储获取数据

1
2
3
4
5
6
data () {
return {
...
searchHistories: getItem('serach-histories') || [],
}
}

ES6补充

更多请参考

阮一峰老师 ES6 入门教程https://es6.ruanyifeng.com/

MDN 官方文档https://developer.mozilla.org/zh-CN/

一、作用域

1、作用域链

作用域链本质上是底层的变量查找机制。在函数被执行时,会优先查找当前函数作用域中查找变量,如果当前作用域查找不到则会依次逐级查找父级作用域直到全局作用域。这种嵌套关系的作用域串联起来就形成了作用域链

2、JS 垃圾回收机制

垃圾回收机制(Garbage Collection) 简称 GC

JS 中内存的分配和回收都是自动完成的,内存在不使用的时候会被垃圾回收器自动回收

虽然垃圾回收器会帮我们自动回收内存,但我们仍有必要了解 JS 的内存管理机制。它可以帮助我们理解内存泄漏(内存无法被回收)

不再用到的内存,没有及时释放,就叫做内存泄漏

内存的生命周期

JS 环境中分配的内存, 一般有如下生命周期:

  1. 内存分配:当我们声明变量、函数、对象的时候,系统会自动为他们分配内存

  2. 内存使用:即读写内存,也就是使用变量、函数等

  3. 内存回收:使用完毕,由垃圾回收自动回收不再使用的内存

说明:

全局变量一般不会回收(关闭页面回收);

一般情况下局部变量的值, 不用了, 会被自动回收掉

两种常见的浏览器垃圾回收算法

引用计数法

IE 采用的引用计数算法, 定义“内存不再使用”,就是看一个对象是否有指向它的引用,没有引用了就回收对象

具体步骤:

  1. 跟踪记录被引用的次数

  2. 如果被引用了一次,那么就记录次数 1,多次引用会累加

  3. 如果减少一个引用就减 1

  4. 当引用次数是 0 时 ,则释放内存

它存在一个致命的问题:嵌套引用(循环引用)

如果两个对象相互引用,尽管他们已不再使用,垃圾回收器不会进行回收,导致内存泄露。

1
2
3
4
5
6
7
8
function fn() {
let o1 = {};
let o2 = {};
o1.a = o2;
o2.a = o1;
return "引用计数无法回收";
}
fn();

正常情况下,上述代码中的变量 o1、o2 在函数 fn 执行完毕之后就应该被回收掉,但是根据引用计数法,虽然函数 fn 已经执行完成,但是由于 o1、o2 中存在着相互引用的关系,因此实际上并不会被回收,这就造成了内存泄漏。于是就有了标记清除法

标记清除法

现代的浏览器已经不再使用引用计数算法了

现代浏览器通用的大多是基于标记清除算法的某些改进算法,总体思想都是一致的。

核心:

  1. 标记清除算法将“不再使用的对象”定义为“无法达到的对象”。

  2. 就是从根部(在 JS 中就是全局对象)出发定时扫描内存中的对象。 凡是能从根部到达的对象,都是还需要使用的。

  3. 那些无法由根部出发触及到的对象被标记为不再使用,稍后进行回收。

image-20230510142831343

同样上述的代码,如果用标记清除法,在函数 fn 执行完毕之后,函数作用域里面的变量,从全局对象开始已经访问不到了,因此会被回收掉

3、闭包

MDN 官方解释:一个函数对周围状态的引用捆绑在一起,内层函数中访问到其外层函数的作用域

从代码结构上看,闭包 = 内层函数 + 外层函数的变量。

1
2
3
4
5
6
7
8
function outer() {
const a = 100;
function inner() {
console.log(a);
}
inner();
}
outer();

闭包作用:封闭数据,提供操作,外部也可以访问函数内部的变量

闭包的基本形式

1
2
3
4
5
6
7
8
function outer() {
const a = 100;
return function inner() {
console.log(a);
};
}
const fun = outer();
fun();

闭包应用:实现数据的私有

比如,我们要做个统计函数调用次数,函数调用一次,就++

1
2
3
4
5
6
7
let i = 1;
function count() {
i++;
console.log(`函数被调用了${i}次`);
}
count(); // 2
count(); // 3

但是这样定义的全局变量 i 很容易被修改,一旦修改,统计的函数调用次数就不准确了。可以通过闭包解决这个问题

1
2
3
4
5
6
7
8
9
10
function count() {
let i = 1;
return function fn() {
i++;
console.log(`函数被调用了${i}次`);
};
}
const fun = count();
fun(); // 2
fun(); // 3

这样实现了数据私有,无法直接修改 count

二、深入对象

创建对象的三种方式

  • 利用对象字面量创建对象

  • 利用 new Object 创建对象

  • 利用构造函数创建对象

1、构造函数

构造函数 :是一种特殊的函数,主要用来初始化对象

使用场景:常规的 {…} 语法允许创建一个对象。比如我们创建了佩奇的对象,继续创建乔治的对象还需要重新写一 遍。此时可以通过构造函数,它就像一个模子一样,能帮助我们快速创建多个类似的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const xiaoming = {
name: '小明',
age: 18,
gender: '男'
}
const xiaohong = {
name: '小红',
age: 17,
gender: '女'
}
// 这种类似的结构,我们可以通过构造函数来创建
// 声明一个"人类"的构造函数
function Person(name,age,gender) {
this.name = name
this.age = age
this.gender = gender
}
// 通过实例化来批量生产“人类”
const xiaoming = new Person('小明'18'男')
const xiaohong = new Person('小红'18'女')

构造函数本质上也函数,不过一般情况下:

  • 它们的命名以大写字母开头,以表明这是一个构造函数

  • 它们只能由 “new” 操作符来执行

new Object()、new Date()这些都是内置的构造函数

构造函数的实例化执行过程

  1. 创建新对象

    2.构造函数 this 指向新对象

    3.执行构造函数代码,修改 this,添加新的属性

    4.返回新对象

实例成员&静态成员

实例成员:通过构造函数创建的对象称为实例对象,实例对象中的属性和方法称为实例成员。

1
2
3
4
5
6
7
8
9
10
11
function Person(name, age, gender) {
// 构造函数中直接声明的这些方法和属性就是实例成员
this.name = name;
this.age = age;
this.gender = gender;
}
// 直接为构造函数添加的这些方法和属性就是静态成员
Person.eyes = 2;
Person.say = function () {
console.log("会说话");
};

请注意:

  • 构造函数创建的实例对象彼此独立互不影响
  • 一般公共特征的属性或方法静态成员设置为静态成员
  • 静态成员方法中的 this 指向构造函数本身

2、内置构造函数

引用类型:Object,Array,RegExp,Date 等

包装类型:String,Number,Boolean 等

数组常见实例方法-核心方法

image-20230511084102099

reduce 语法

image-20230511084301258

求和

1
2
const arr = [1, 2, 3, 4, 5];
const result = arr.reduce((prev, current) => prev + current);

数组常见实例方法-其他方法

  1. 实例方法 join 数组元素拼接为字符串,返回字符串(重点)
  2. 实例方法 find 查找元素, 返回符合测试条件的第一个数组元素值,如果没有符合条件的则返回 undefined(重点)
  3. 实例方法every 检测数组所有元素是否都符合指定条件,如果所有元素都通过检测返回 true,否则返回 false(重点)
  4. 实例方法some 检测数组中的元素是否满足指定条件 如果数组中有元素满足条件返回 true,否则返回 false
  5. 实例方法 concat 合并两个数组,返回生成新数组
  6. 实例方法 sort 对原数组单元值排序
  7. 实例方法 splice 删除或替换原数组单元
  8. 实例方法slice返回一个新的数组对象,这一对象是一个由 begin 和 end 决定的原数组的浅拷贝(包括 begin,不包括 end)(此截取方法字符串也有)
  9. 实例方法 reverse 反转数组
  10. 实例方法 findIndex 返回数组中满足提供的测试函数的第一个元素的索引。若没有找到对应元素则返回 -1
  11. 实例方法includes用来判断一个数组是否包含一个指定的值,根据情况,如果包含则返回 true,否则返回 false
  12. 实例方法push添加元素到数组末尾
  13. 实例方法pop(弹出)删除数组末尾的元素
  14. 实例方法shift删除数组头部元素
  15. 实例方法unshift添加元素到数组的头部
  16. 实例方法indexOf返回数组中第一次出现给定元素的下标,如果不存在则返回 -1
  17. 实例方法fill用一个固定值填充一个数组中从起始索引到终止索引内的全部元素。不包括终止索引

数组常见静态方法

  1. Array.from():对一个伪数组或可迭代对象创建一个新的,浅拷贝的数组实例
  2. Array.isArray():用于确定传递的值是否是一个 Array

String 常见实例方法

在 JavaScript 中的字符串、数值、布尔具有对象的使用特征,如具有属性和方法。这是因为它们是 JavaScript 底层使用 Object 构造函数“包装”来的

String 常见实例方法

  1. 实例属性 length 用来获取字符串的度长(重点)
  2. 实例方法 split('分隔符') 用来将字符串拆分成数组(重点)
  3. 实例方法 substring(需要截取的第一个字符的索引[,结束的索引号]) 用于字符串截取(重点)
  4. 实例方法 startsWith(检测字符串[, 检测位置索引号]) 检测是否以某字符开头(重点)
  5. 实例方法 includes(搜索的字符串[, 检测位置索引号]) 判断一个字符串是否包含在另一个字符串中,根据情况返回 true 或 false(重点)
  6. 实例方法 toUpperCase 用于将字母转换成大写
  7. 实例方法 toLowerCase 用于将就转换成小写
  8. 实例方法 indexOf 检测是否包含某字符
  9. 实例方法 endsWith 检测是否以某字符结尾
  10. 实例方法 replace 用于替换字符串,支持正则匹配
  11. 实例方法 match 用于查找字符串,支持正则匹配
  12. 实例方法trim从字符串的两端清除空格,返回一个新的字符串,而不修改原始字符串
  13. 实例方法slice 方法提取某个字符串的一部分,并返回一个新的字符串,且不会改动原字符串

Number 常见方法

  1. 实例方法toFixed设置保留小数位的长度
  2. 静态方法 Number.parseInt() 方法依据指定基数,解析字符串并返回一个整数。
  3. 静态方法Number.parseFloat() 方法可以把一个字符串解析成浮点数

Date 常见方法

  1. 实例方法toLocaleString方法返回该日期对象的字符串
  2. 实例方法toLocaleDateString 方法返回指定日期对象日期部分的字符串
  3. 实例方法getDate根据本地时间,返回一个指定的 Date 对象为一个月中的哪一日(1-31
  4. 实例方法getDay根据本地时间,返回一个指定的 Date 对象是在一周中的第几天(0-6),0 表示星期天
  5. 实例方法getFullYear根据本地时间,返回一个指定的 Date 对象的完整年份(四位数年份)
  6. 实例方法getHours根据本地时间,返回一个指定的 Date 对象的小时(023
  7. 实例方法getMinutes根据本地时间,返回一个指定的 Date 对象的分钟数(059
  8. 实例方法getMonth根据本地时间,返回一个指定的 Date 对象的月份(011),0 表示一年中的第一月
  9. 实例方法getSeconds根据本地时间,返回一个指定的 Date 对象的秒数(059)
  10. 实例方法getTime返回一个数值,表示从 1970 年 1 月 1 日 0 时 0 分 0 秒(UTC,即协调世界时)距离该 Date 对象所代表时间的毫秒数(时间戳)
  11. 静态方法Date.now()返回自 1970-1-1 00:00:00 UTC(世界标准时间)至今所经过的毫秒数(时间戳)

Math 常见方法

  1. 静态方法Math.floor(x)返回小于一个数的最大整数,即一个数向下取整后的值

  2. 静态方法Math.ceil(x)返回大于一个数的最大整数,即一个数向上取整后的值

  3. 静态方法Math.max(x,y,...)返回零到多个数值中最大值

  4. 静态方法Math.min(x,y,...)返回零到多个数值中最小值

  5. 静态方法Math.pow(x,y)返回一个数的 y 次幂

  6. 静态方法Math.random()返回一个 0 到 1 之间的伪随机数

  7. 静态方法Math.round(x)返回四舍五入后的整数

三、面向对象

1、面向对象的编程思想

面向过程编程

面向过程就是分析出解决问题所需要的步骤,然后用函数把这些步骤一步一步实现,使用的时候再一个一个的依次调用

优点:性能比面向对象高,适合跟硬件联系很紧密的东西,例如单片机就采用的面向过程编程

缺点:没有面向对象易维护、易复用、易扩展

面向对象编程 (oop)

面向对象是把事务分解成为一个个对象,然后由对象之间分工与合作

在面向对象程序开发思想中,每一个对象都是功能中心,具有明确分工。

面向对象编程具有灵活、代码可复用、容易维护和开发的优点,更适合多人合作的大型软件项目

面向对象的特性:

  • 封装性
  • 继承性
  • 多态性

优点:易维护、易复用、易扩展,由于面向对象有封装 、继承、多态性的特性,可以设计出低耦合的系统,使系统更加灵活、更加易于维护

缺点:性能比面向过程低

2、封装

JavaScript 中可以通过构造函数实现面向对象的封装

将相同的结构封装在构造函数中,通过 this 指向实现数据的共享。并且通过构造函数创造出来的实例对象之间彼此独立、互不影响

1
2
3
4
5
6
7
8
9
10
11
function Person(name, age, gender) {
this.name = name;
this.age = age;
this.gender = gender;
this.sing = function () {
console.log("说话");
};
}
const ldh = new Person("刘德华", 18, "男");
const zxy = new Person("张学友", 20, "男");
console.log(ldh.sing === zxy.sing); // false

然而,由于构造函数创造出来的实例对象之间彼此独立、互不影响。我们会发现ldh.singzxy.sing并不相等,这不是我们想要的,因为实际上每个实例对象(比如ldhzxy)之间的sing方法应该是相同的,他们实现的是相同的功能,因此没必要单独给它们各自分配内存。

image-20230511101913432

原型

使用原型对象就可以实现方法的共享

  • 构造函数通过原型分配的函数是所有实例对象所共享的
  • JavaScript 规定,每一个构造函数都有一个 prototype 属性,指向另一个对象,所以我们也称为原型对象
  • 这个对象可以挂载函数,对象实例化不会多次创建原型上的函数,节约内存
  • 我们可以把那些不变的方法,直接定义在 prototype 对象上,这样所有对象的实例就可以共享这些方法
  • 构造函数和原型对象中的 this 都指向实例化的对象
1
2
3
4
5
6
7
8
9
10
11
function Person(name, age, gender) {
this.name = name;
this.age = age;
this.gender = gender;
}
Person.prototype.sing = function () {
console.log("说话");
};
const ldh = new Person("刘德华", 18, "男");
const zxy = new Person("张学友", 20, "男");
console.log(ldh.sing === zxy.sing); // true

constructor 属性

每个原型对象里面都有个 constructor 属性(constructor 构造函数)

该属性指向该原型对象的构造函数

使用场景:

如果有多个对象的方法,我们可以给原型对象采取对象形式赋值. 但是这样就会覆盖构造函数原型对象原来的内容,这样修改后的原型对象 constructor 就不再指向当前构造函数了 。也就是说正常情况下,原型对象上是存在着 constructor 属性的,现在我们想往原型对象上挂载多个方法,我们不想一个一个的添加,于是我们采用直接赋值的方式。但是我们通过赋值的形式往原型对象上挂载方法后,把原来的原型对象上的 constructor 属性覆盖掉了。此时,我们可以在修改后的原型对象中,添加一个 constructor 指向原来的构造函数。

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
function Person(name, age, gender) {
this.name = name;
this.age = age;
this.gender = gender;
}
// 多个方法挂载到原型对象上,为了方便不想一个一个的挂载
Person.prototype.sing = function () {
console.log("说话");
};
Person.prototype.dance = function () {
console.log("跳舞");
};
// 于是直接给prototype属性赋值
Person.prototype = {
sing: function () {
console.log("说话");
},
dance: function () {
console.log("跳舞");
},
};
// 但是这样,原本原型对象prototype上的constructor 属性就被赋值给覆盖掉了
Person.prototype = {
// 因此利用constructor,手动指回Person构造函数
constructor: Person,
sing: function () {
console.log("说话");
},
dance: function () {
console.log("跳舞");
},
};
const ldh = new Person("刘德华", 18, "男");
const zxy = new Person("张学友", 20, "男");
console.log(ldh.sing === zxy.sing); // true

但是当我们把原本放在构造函数上的一些公共属性或方法放到原型对象上后,实例对象如何访问到原型对象上的公共属性或方法呢?

proto

对象都会有一个属性 proto 指向构造函数的 prototype 原型对象,之所以我们对象可以使用构造函数 prototype 原型对象的属性和方法,就是因为对象有 proto 原型的存在

image-20230511105014332

3、原型继承

继承是面向对象编程的另一个特征,通过继承进一步提升代码封装的程度,JavaScript 中大多是借助原型对象实现继承的特性

1
2
3
4
5
6
7
8
9
10
11
12
// 定义一个中国人的构造函数
function Chinese() {
this.header = 1;
this.eyes = 2;
this.language = "chinese";
}
// 定义一个日本人的构造函数
function Japanese() {
this.header = 1;
this.eyes = 2;
this.language = "japanese";
}

我们发现,不管是中国人还是日本人,他们都属于人类。因此可以将人类的一些公共特征抽取出来,单独封装成一个人类的构造函数,让中国人和日本人共享这个构造函数的属性和方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 封装一个人类的构造函数
function Human() {
this.header = 1;
this.eyes = 2;
}
// 中国人的构造函数
function Chinese() {
this.language = "chinese";
}
// 日本人的构造函数
function Japanese() {
this.language = "japanese";
}
// 通过改变中国人和日本人的原型对象指向到Human,以此继承Human上的方法和属性
Chinese.prototype = Human;
// 不要忘了constructor 指回Chinese
Chinese.prototype.constructor = Chinese;
// 同理日本人
Japanese.prototype = Human;
Japanese.prototype.constructor = Japanese;

但是此时会有一个问题,当我们向单独给 Chinese 添加一个属性或者方法时,我们会发现 Japanese 也被自动添加了这个属性或方法。并且由于Japanese.prototype.constructor是后来定义的,它将Chinese.prototype.constructor = Chinese覆盖掉了,因此Chinese.prototype.constructor竟然也指向了Japanese

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
// 封装一个人类的构造函数
function Human() {
this.header = 1;
this.eyes = 2;
}
// 中国人的构造函数
function Chinese() {
this.language = "chinese";
}
// 日本人的构造函数
function Japanese() {
this.language = "japanese";
}
// 通过改变中国人和日本人的原型对象指向到Human,以此继承Human上的方法和属性
Chinese.prototype = Human;
// 不要忘了constructor 指回Chinese
Chinese.prototype.constructor = Chinese;
// 同理日本人
Japanese.prototype = Human;
Japanese.prototype.constructor = Japanese;
Chinese.prototype.smoking = function () {
console.log("抽烟");
};
const xiaoming = new Chinese();
const guitian = new Japanese();
console.log(xiaoming, guitian);

image-20230511111944145

这是因为Chinese.prototypeJapanese.prototype都是一个指向了构造函数Human的地址,通过Chinese.prototype.smoking修改了Human的值,Japanese.prototype也会受到影响。因此我们可以将Chinese.prototypeJapanese.prototype各自指向一个 Human 的实例对象,这样它们就互不影响了!

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
// 封装一个人类的构造函数
function Human() {
this.header = 1;
this.eyes = 2;
}
// 中国人的构造函数
function Chinese() {
this.language = "chinese";
}
// 日本人的构造函数
function Japanese() {
this.language = "japanese";
}
// 通过改变中国人和日本人的原型对象指向到Human,以此继承Human上的方法和属性
Chinese.prototype = new Human();
// 不要忘了constructor 指回Chinese
Chinese.prototype.constructor = Chinese;
// 同理日本人
Japanese.prototype = new Human();
Japanese.prototype.constructor = Japanese;
Chinese.prototype.smoking = function () {
console.log("抽烟");
};
const xiaoming = new Chinese();
const guitian = new Japanese();
console.log(xiaoming, guitian);

image-20230511115553081

原型链

基于原型对象的继承使得不同构造函数的原型对象关联在一起,并且这种关联的关系是一种链状的结构,我们将原型对 象的链状结构关系称为原型链

image-20230511115754927

原型链-查找规则

当访问一个对象的属性(包括方法)时,首先查找这个对象自身有没有该属性。

如果没有就查找它的原型(也就是 proto指向的 prototype 原型对象)

如果还没有就查找原型对象的原型(Object 的原型对象)

依此类推一直找到 Object 为止(null)

proto对象原型的意义就在于为对象成员查找机制提供一个方向,或者说一条路线

可以使用 instanceof 运算符检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上

4、深浅拷贝

开发中我们经常需要复制一个引用类型。如果直接赋值,当我们修改一个值时,另一个值也变了

浅拷贝和深拷贝只针对引用类型

浅拷贝常见方法:

  • 拷贝对象:Object.assgin() / 展开运算符
  • 拷贝数组:Array.prototype.concat() 或者展开运算符

但是如果引用数据类型里面出现了嵌套引用数据类型的情况,深拷贝就又会出现上述问题,这时需要深拷贝

深拷贝常见方法:

  • 通过递归实现深拷贝
  • lodash 库 cloneDeep 函数
  • 通过 JSON.stringify()实现

递归

如果一个函数在内部调用其本身,那么这个函数就是递归函数

由于递归很容易发生“栈溢出”错误(stack overflow),所以必须要加退出条件 return

利用递归函数实现 setTimeout 模拟 setInterval 效果

1
2
3
4
5
function getTime() {
console.log(new Date().toLocaleString());
setTimeout(getTime, 1000);
}
getTime();

利用递归函数实现基本的深拷贝

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function deepCopy(newData, oldData) {
// 遍历原对象
for (let k in oldData) {
// 如果是数组
if (oldData[k] instanceof Array) {
newData[k] = [];
deepCopy(newData[k], oldData[k]);
} else if (oldData[k] instanceof Object) {
// 如果是对象
newData[k] = {};
deepCopy(newData[k], oldData[k]);
} else {
// 基本数据类型直接赋值
newData[k] = oldData[k];
}
}
}

通过 JSON.stringify()实现深拷贝

1
2
3
4
5
6
7
8
9
10
11
const obj = {
name: "佩奇",
age: 18,
hobby: ["抽烟", "喝酒", "烫头"],
family: {
sister: "乔治",
father: "猪爸爸",
mother: "猪妈妈",
},
};
const obj2 = JSON.parse(JSON.stringify(obj));

JSON.stringify()可以将 JS 对象转化为 JSON 字符串,JSON 字符串就是一个字符串,就不存在所谓的地址引用了

5、异常处理

throw 抛异常

异常处理是指预估代码执行过程中可能发生的错误,然后最大程度的避免错误的发生导致整个程序无法继续运行

1
2
3
4
5
6
7
8
9
10
11
function counter(x, y) {
if (!x || !y) {
// throw '参数不能为空!'
throw new Error("参数不能为空!");
console.log(111);
}
console.log(222);
return x + y;
}
counter();
console.log(333);

image-20230511144023536

  • throw 抛出异常信息,整个程序也会终止执行
  • throw 后面跟的是错误提示信息,可以直接跟字符串
  • Error 对象配合 throw 使用,能够设置更详细的错误信息

try /catch 捕获异常

可以通过 try / catch 捕获错误信息(浏览器提供的错误信息)

1
2
3
4
5
6
7
8
9
10
11
12
13
function fn() {
try {
const p = document.querySelector(".p");
p.style.color = "red";
} catch (error) {
console.log(error.message);
} finally {
console.log("finally执行了!");
}
console.log("我是函数fn中的最后打印");
}
fn();
console.log("我是函数fn外的打印");
  • 将预估可能发生错误的代码写在 try 代码段中
  • 如果 try 代码段中出现错误后,会执行 catch 代码段,并截获到错误信息
  • finally 不管是否有错误,都会执行
  • 使用 try / catch 捕获错误信息,发生异常后程序不会终止执行

image-20230511145301739

debugger

可以在代码中用 debugger 打断点调试程序,它和我们在控制台打断点的效果是一样的

6、this

this 指向

  • 普通函数中的 this:指向 window
  • 构造函数、原型对象中的 this:指向实例化对象
  • 对象中的方法中的 this:指向该对象
  • 定时器、延时器中的 this:指向 window
  • 事件处理函数中的 this:指向事件源
  • 箭头函数中的 this:实际上箭头函数中并不存在 this,箭头函数中的 this 指向上层作用域的 this,如果上层作用域也没有 this,则一级一级向上查找

简单来说,this 总是指向调用者。我们所说的 this 指向是按作用域划分的

改变 this 指向

JavaScript 中还允许指定函数中 this 的指向,有 3 个方法可以动态指定普通函数中 this 的指向

call()

语法:

image-20230511152012252

  • thisArg:在 fun 函数运行时指定的 this 值
  • arg1,arg2:函数 fun 正常的参数
  • 返回值就是函数的返回值,因为它就是调用函数(当一个函数调用了 call 方法时,这个函数也被调用执行了)

apply()

语法:

image-20230511152310061

  • thisArg:在 fun 函数运行时指定的 this 值
  • argsArray:函数 fun 正常的参数,但是在 apply 中必须包含在数组里面
  • 因为 apply 的参数主要跟数组有关系,因此在涉及到数组处理时,应该想到 apply 比如使用 Math.max() 求数组的最大值
  • 返回值就是函数的返回值,因为它就是调用函数当一个函数调用了 apply 方法时,这个函数也被调用执行了)
1
2
3
4
// 求数组最大值
const arr = [1, 3, 5, 7, 9];
// console.log(Math.max(...arr))
console.log(Math.max.apply(null, arr));

bind()

语法:

image-20230511153919522

  • thisArg:在 fun 函数运行时指定的 this 值
  • arg1,arg2:函数 fun 具体的参数
  • 返回由指定的 this 值和初始化参数改造的原函数拷贝 (新函数),并不调用原函数
  • 因此当我们只是想改变 this 指向,并且不想调用这个函数的时候,可以使用 bind,比如改变定时器内部的 this 指向

路由复用时页面不刷新显示的解决办法

今天在项目中新增了很多修改功能。需要复用之前的路由

image-20240313150329705

这是路由文件,其中的每个路由地址对应着一个菜单栏

image-20240313150624228

现在修改功能需要复用新发起的路由,在路由的 query 参数携带相应的参数,方便在修改页面中复显数据

image-20240313151115776

但是如果我直接在声明周期函数里获取修改的数据会不生效。原因是当修改和新发起复用同一个路由时,vue 考虑到效率问题,并没有重新销毁再重建组件,它们使用的是同一个组件。这也导致了当我在别的页面点击修改想跳转到修改页面时,我在声明周期函数里写的获取修改数据的代码并不会生效,页面仍是一片空白。这个问题也在小兔鲜项目中遇到过。因此手动 watch 一下

image-20240313151854119

文件导入导出功能

一、安装

1
2
npm install file-saver --save
npm install xlsx --save

二、创建工具函数

为了使用方便,可以在 src/utils 下封装一个工具文件 Export2Excel.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
/* eslint-disable */
import { saveAs } from "file-saver";
import * as XLSX from "xlsx";

function generateArray(table) {
var out = [];
var rows = table.querySelectorAll("tr");
var ranges = [];
for (var R = 0; R < rows.length; ++R) {
var outRow = [];
var row = rows[R];
var columns = row.querySelectorAll("td");
for (var C = 0; C < columns.length; ++C) {
var cell = columns[C];
var colspan = cell.getAttribute("colspan");
var rowspan = cell.getAttribute("rowspan");
var cellValue = cell.innerText;
if (cellValue !== "" && cellValue == +cellValue) cellValue = +cellValue;

//Skip ranges
ranges.forEach(function (range) {
if (
R >= range.s.r &&
R <= range.e.r &&
outRow.length >= range.s.c &&
outRow.length <= range.e.c
) {
for (var i = 0; i <= range.e.c - range.s.c; ++i) outRow.push(null);
}
});

//Handle Row Span
if (rowspan || colspan) {
rowspan = rowspan || 1;
colspan = colspan || 1;
ranges.push({
s: {
r: R,
c: outRow.length,
},
e: {
r: R + rowspan - 1,
c: outRow.length + colspan - 1,
},
});
}

//Handle Value
outRow.push(cellValue !== "" ? cellValue : null);

//Handle Colspan
if (colspan) for (var k = 0; k < colspan - 1; ++k) outRow.push(null);
}
out.push(outRow);
}
return [out, ranges];
}

function datenum(v, date1904) {
if (date1904) v += 1462;
var epoch = Date.parse(v);
return (epoch - new Date(Date.UTC(1899, 11, 30))) / (24 * 60 * 60 * 1000);
}

function sheet_from_array_of_arrays(data, opts) {
var ws = {};
var range = {
s: {
c: 10000000,
r: 10000000,
},
e: {
c: 0,
r: 0,
},
};
for (var R = 0; R != data.length; ++R) {
for (var C = 0; C != data[R].length; ++C) {
if (range.s.r > R) range.s.r = R;
if (range.s.c > C) range.s.c = C;
if (range.e.r < R) range.e.r = R;
if (range.e.c < C) range.e.c = C;
var cell = {
v: data[R][C],
};
if (cell.v == null) continue;
var cell_ref = XLSX.utils.encode_cell({
c: C,
r: R,
});

if (typeof cell.v === "number") cell.t = "n";
else if (typeof cell.v === "boolean") cell.t = "b";
else if (cell.v instanceof Date) {
cell.t = "n";
cell.z = XLSX.SSF._table[14];
cell.v = datenum(cell.v);
} else cell.t = "s";

ws[cell_ref] = cell;
}
}
if (range.s.c < 10000000) ws["!ref"] = XLSX.utils.encode_range(range);
return ws;
}

function Workbook() {
if (!(this instanceof Workbook)) return new Workbook();
this.SheetNames = [];
this.Sheets = {};
}

function s2ab(s) {
var buf = new ArrayBuffer(s.length);
var view = new Uint8Array(buf);
for (var i = 0; i != s.length; ++i) view[i] = s.charCodeAt(i) & 0xff;
return buf;
}

export function export_table_to_excel(id) {
var theTable = document.getElementById(id);
var oo = generateArray(theTable);
var ranges = oo[1];

/* original data */
var data = oo[0];
var ws_name = "SheetJS";

var wb = new Workbook(),
ws = sheet_from_array_of_arrays(data);

/* add ranges to worksheet */
// ws['!cols'] = ['apple', 'banan'];
ws["!merges"] = ranges;

/* add worksheet to workbook */
wb.SheetNames.push(ws_name);
wb.Sheets[ws_name] = ws;

var wbout = XLSX.write(wb, {
bookType: "xlsx",
bookSST: false,
type: "binary",
});

saveAs(
new Blob([s2ab(wbout)], {
type: "application/octet-stream",
}),
"test.xlsx"
);
}

export function export_json_to_excel({
multiHeader = [],
header,
data,
filename,
merges = [],
autoWidth = true,
bookType = "xlsx",
} = {}) {
/* original data */
filename = filename || "excel-list";
data = [...data];
data.unshift(header);

for (let i = multiHeader.length - 1; i > -1; i--) {
data.unshift(multiHeader[i]);
}

var ws_name = "SheetJS";
var wb = new Workbook(),
ws = sheet_from_array_of_arrays(data);

if (merges.length > 0) {
if (!ws["!merges"]) ws["!merges"] = [];
merges.forEach((item) => {
ws["!merges"].push(XLSX.utils.decode_range(item));
});
}

if (autoWidth) {
/*设置worksheet每列的最大宽度*/
const colWidth = data.map((row) =>
row.map((val) => {
/*先判断是否为null/undefined*/
if (val == null) {
return {
wch: 10,
};
} else if (val.toString().charCodeAt(0) > 255) {
/*再判断是否为中文*/
return {
wch: val.toString().length * 2,
};
} else {
return {
wch: val.toString().length,
};
}
})
);
/*以第一行为初始值*/
let result = colWidth[0];
for (let i = 1; i < colWidth.length; i++) {
for (let j = 0; j < colWidth[i].length; j++) {
if (result[j]["wch"] < colWidth[i][j]["wch"]) {
result[j]["wch"] = colWidth[i][j]["wch"];
}
}
}
ws["!cols"] = result;
}

/* add worksheet to workbook */
wb.SheetNames.push(ws_name);
wb.Sheets[ws_name] = ws;

var wbout = XLSX.write(wb, {
bookType: bookType,
bookSST: false,
type: "binary",
});
saveAs(
new Blob([s2ab(wbout)], {
type: "application/octet-stream",
}),
`${filename}.${bookType}`
);
}

三、导出功能

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
<template>
<el-button type="primary" @click="exportTable">导出</el-button>
</template>
<script setup>
// 导出功能
// 用户菜单表头与数据字段对应关系
const userMenuHeader = {
流程模板: "wf_type_name",
审批编号: "apply_id",
发起人: "staff_name",
审批流程标题: "apply_title",
审批金额: "apply_fee",
发起时间: "create_date",
流程状态: "proc_state_name",
当前环节: "node_name",
处理人: "username",
};
const exportTable = () => {
// 做操作
const excelData = [];
// 这里后端返回的数据不能直接用作excel的data,不然浏览器会卡死,必须先将数据转化为表格需要的[[]]的形式
tableData.value.forEach((item) => {
const arr = [];
for (const key in userMenuHeader) {
arr.push(item[userMenuHeader[key]]);
}
excelData.push(arr);
});

// 懒加载
try {
import("@/utils/Export2Excel").then(async (excel) => {
excel.export_json_to_excel({
header: Object.keys(userMenuHeader),
data: excelData,
filename: "费用冲调审批流程查询清单",
autoWidth: true,
bookType: "xlsx",
});
});
} catch {
ElMessage.error("导出失败,请重试!");
}
};
</script>

四、导入功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
<template>
<el-upload
ref="uploadRef"
class="upload-demo"
v-model:file-list="accList"
:on-remove="handleRemove"
:on-change="handleChangeBusiness"
:http-request="upload"
action="#"
:auto-upload="false"
style="display: flex; margin-bottom: 10px"
>
<template #trigger>
<el-button type="primary" :icon="Document" style="margin-right: 150px"
>选择文件</el-button
>
</template>
<template #tip v-if="!accList.length">
<div class="el-upload__tip" style="margin: 10px 0 0 -445px">
未选择任何文件
</div>
</template>
<el-button
class="ml-3"
:icon="Upload"
type="success"
@click="getServByAccNbrImport"
style="margin-top: 2px"
>
批量导入费用争议数据
</el-button>
<el-button type="primary" :icon="Download"
><el-link href="/approve/static/费用争议导入模板.xls"
>下载附件模板</el-link
></el-button
>
</el-upload>
</template>
<script setup>
// 批量上传
// 上传的文件
const accList = ref([]);
const businessFileList = ref([]);
const accountDetailList = ref([]); // 这是账期明细
// 进出物品对应关系
const businessRelation = {
0: "bz",
1: "phone_number",
2: "spje",
3: "serv_id",
4: "acct_id",
5: "cust_name",
6: "acct_name",
7: "sqr",
8: "sqrdh",
9: "sqyy",
10: "sqyy_name",
11: "oper_type",
12: "oper_type_name",
13: "ctlx_id",
14: "ctlx",
15: "phone_local",
};
const accountDetailRelation = {
0: "phone_number",
1: "fee_month",
2: "charge",
3: "fee_acct_type",
4: "fee_acct_type_name",
};
const handleChangeBusiness = (file) => {
const reader = new FileReader();
reader.onload = async (e) => {
const data = e.target.result;
const workbook = XLSX.read(data, { type: "array" });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const json = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
json.forEach((item) => {
const obj = {};
for (const key in businessRelation) {
obj[businessRelation[key]] = item[key];
}
businessFileList.value.push(obj);
});
// 对businessFileList去重
businessFileList.value = [...new Set(businessFileList.value)];
// 把这个数据传给后端,获取到后端返回的数据,渲染列表
// 因为表格中可能会有空白的数据,上传上来的数据就会有undefined,所以要过滤掉
businessFileList.value = businessFileList.value.filter((item) => item);
// 因为批量上传的没有acc_nbr,所以要把phone_number赋值给acc_nbr
businessFileList.value.forEach((item) => {
item.acc_nbr = item.phone_number;
});
const sheetName1 = workbook.SheetNames[1];
const worksheet1 = workbook.Sheets[sheetName1];
const json1 = XLSX.utils.sheet_to_json(worksheet1, { header: 1 });
json1.forEach((item) => {
const obj = {};
for (const key in accountDetailRelation) {
obj[accountDetailRelation[key]] = item[key];
}
accountDetailList.value.push(obj);
});
// 对accountDetailList去重
accountDetailList.value = [...new Set(accountDetailList.value)];
accountDetailList.value = accountDetailList.value.filter((item) => item);
// 将accountDetailList与businessFileList关联
businessFileList.value.forEach((item) => {
item.fee_list_month = accountDetailList.value.filter((item2) => {
return item.phone_number === item2.phone_number;
});
});
};

reader.readAsArrayBuffer(file.raw);
};
const getServByAccNbrImport = async () => {
if (businessFileList.value.length === 0) {
ElMessage.error("请先选择文件");
return;
}
if (businessFileList.value.length > 2000) {
ElMessage.error(
"上传的数据超过2000条,可能会导致查询时间过长,请分批次上传!"
);
return;
}
applyForm.feeListData = businessFileList.value;
};
</script>

最终的效果是这样的:可以看到,每一行数据下面关联了一个子表

image-20240313093547041

  • Copyrights © 2023-2025 congtianfeng
  • 访问人数: | 浏览次数: