0%

今天看到了一个很有趣的问题,关于闭包在内存中是否真的不会被回收,所以我也来写篇小短文谈谈我的理解。

1
2
3
4
5
6
7
8
9
10
11
12
13
function getCounter() {
let a = 1;

function counter() {
a = a + 1;
return a;
}
return counter;
}

console.log(getCounter()());
console.log(getCounter()());
console.log(getCounter()());

先看一段代码。

众所周知这道题的答案是

1
2
3
2
3
4

然而其实不是!!!

这道题的正确答案是

1
2
3
2
2
2

的确,在 getCounter 函数中创建了函数 counter,而且在函数 counter 中也的的确确访问了 getCounter 中的变量 a。根据经验,此处一定会形成闭包毋庸置疑,因为 getCounter 的词法作用域被它内部的函数 counter 的词法作用域引用了。

我们也可以根据控制台的打印结果看出来这里确实形成了闭包。

问题在于执行上下文

从闭包的具体实现上来说,对于函数 counter 而言,闭包对象 Closure (getCounter) 存在于自身的 [[Scopes]] 属性中。也就是说,只要函数体 getCounter 在内存中持久存在,闭包就会持久存在。而如果函数体被回收,闭包对象同样会被回收。

在预解析阶段,函数声明会创建一个函数体,并在代码中持久存在。但是并非所有的函数体都能够持久存在。在上面的示例中,counter 函数是在 getCounter 函数的执行上下文中声明的,当执行上下文执行完毕,执行上下文就会被回收,那么在 getCounter 执行上下文中声明的 counter 函数也会被回收。所以显然由 countergetCounter 产生的闭包也会被回收,我们每次执行 getCounter()(),实际上创建了不同的闭包对象。

而我们平常见到的闭包版本,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function getCounter() {
let a = 1;

function counter() {
a = a + 1;
return a;
}
return counter;
}

const myCounter = getCounter();
console.log(myCounter());
console.log(myCounter());
console.log(myCounter());

显然对于函数 counter 的引用会一直保存在内存中,所以我们总能访问到 counter 函数。

回到开头的问题,答案就是:闭包对象并非不能被垃圾回收机制回收,具体仍然需要视情况而定。

浅拷贝

1
object.assign()

不会拷贝对象的继承属性、不会拷贝对象的不可枚举属性、可以拷贝 Symbol 类型的属性

1
2
3
4
5
let target = {};
let source = {a: {b: 1}};

Object.assign(target, source);
console.log(target);

还有 concat、slice、拓展运算符均可以实现浅拷贝。

手工实现浅拷贝:

1
2
3
4
5
6
7
8
9
10
11
12
13
const shallowClone = (target) => {
if (target instanceof object) {
const cloneTarget = Array.isArray(target) ? [] : {};
for (let key in target) {
if (target.hasOwnProperty(key)) {
cloneTarget[key] = target[key];
}
}
return cloneTarget;
}

return target;
}

深拷贝

乞丐版:JSON.stringify()

存在的问题:(可以结合之前文章的手写 JSON.stringify() 看)

  • 拷贝的对象的值中如果有函数、undefined、symbol 这几种类型,拷贝后整个键值对消失
  • 拷贝后 Date 引用类型变为字符串(调用了 toJSON)
  • 无法拷贝不可枚举的属性
  • 无法拷贝对象的原型链
  • 拷贝 RegExp 会变为空对象
  • 对象中含有 NaN、Infinity,拷贝结果会变为 null
  • 无法拷贝循环引用

手写深拷贝:

首先要了解一个方法:Object. getOwnPropertyDescriptors(),这个方法用于获得属性的特性

1
2
3
4
5
6
const person = {
name: '张三',
age: 18
}
const desc = Object.getOwnPropertyDescriptors(person);
console.log(desc);

还有一个是 Reflect.ownKeys(),它返回一个由目标对象自身的属性键组成的数组。返回值等同于 Object.getOwnPropertyNames(target).concat(Object.getOwnPropertySymbols(target))

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
const isComplexDataType = obj => ((typeof obj === 'object' || typeof obj === 'function') && obj !== null);

// WeakMap 防止内存泄漏
const deepClone = (obj, hash = new WeakMap()) => {
// 特判 Date 和 RegExp,返回一个新对象
if (obj?.constructor === Date) {
return new Date(obj);
}
if (obj?.constructor === RegExp) {
return new RegExp(obj);
}

// 处理循环引用
if (hash.has(obj)) {
return hash.get(obj);
}

// 得到属性值和属性的描述
let desc = Object.getOwnPropertyDescriptors(obj);

// 继承原型链,包括其 descriptors
let cloneObj = Object.create(Object.getPrototypeOf(obj), desc);

// 设置 hash,用于后续检测循环引用
hash.set(obj, cloneObj);

// 遍历所有键值
for (let key of Reflect.ownKeys(obj)) {
cloneObj[key] = (isComplexDataType(obj[key]) && typeof obj[key] !== 'function' ? deepClone(obj[key], hash) : obj[key]);
}
return cloneObj;
}

// 验证代码
let obj = {
num: 0,
str: 'string',
boolean: true,
unf: undefined,
nul: null,
obj: { name: 'Jack', id: 1 },
arr: [0, 1, 2],
func: function() { console.log('hello') },
date: new Date(),
reg: new RegExp('/我是正则/ig'),
[Symbol('我是 Symbol')]: 1,
}

Object.defineProperty(obj, 'innumerable', {
enumerable: false,
value: '不可枚举',
})

obj = Object.create(obj, Object.getOwnPropertyDescriptors(obj));
obj.loop = obj; // 循环引用

let cloneObj = deepClone(obj);
cloneObj.arr.push(1234);
console.log(obj);
console.log(cloneObj);

五层 & 七层

五层:物理、链路、网络、传输、应用
七层:物理、链路、网络、传输、会话、表示、应用

CDN

URI & URL

  • Uniform Resource Identifier,统一资源标识符,可以唯一标记互联网资源
  • Uniform Resource Locator,统一资源定位符,也就是地址,它是 URI 的子集

只要能唯一标识资源的就是 URI,在 URI 的基础上给出其资源的访问方式的就是 URL

HTTP 特征

  • 支持客户端-服务器模式
  • 简单快速:客户端向服务端请求只需要传送请求方法和路径。因为协议简单,所以服务器规模小所以通信速度很快
  • 灵活可拓展:HTTP 允许传输任意类型的数据对象,由 Content-Type 标记类型
  • 无连接:每次连接只处理一个请求,服务器处理完请求,并收到客户端的应答后,就断开连接
  • 无状态:没有记忆能力

HTTP 报文

HTTP 协议由三大部分组成:

  • 起始行:描述请求或响应的基本信息
  • header
  • body:实际传输的数据,不一定是纯文本,也可以是视频、图片等

header 和 body 之间会有一个空行,header 不能为空,body 可以为空

起始行

起始行包括三个字段:请求方法、URL、HTTP 版本号

HTTP 版本

HTTP 0.9

特性:

  • 只有一个请求行,没有请求头和请求体
  • 请求方法只有 GET

缺点:

  • 响应只有 HTML 文档,文件格式只局限于 ASCII 编码

HTTP 1.0

特性:

  • 引入了请求头和请求体,增加了状态码,支持多种文档类型
  • 使用短连接,每次发送数据都要经过三次握手和四次挥手,效率低
  • header 中只使用 If-Modified-Since 和 Expires 作为缓存

缺点:

  • 只提供了基本的认证,用户名和密码都没有加密
  • 不支持断点续传
  • 每个 IP 只能有一个域名
  • 在同一个 TCP 连接里面,请求顺序是固定的。服务器只有处理完一个请求的响应后,才会进行下一个请求的处理,如果前面请求的响应特别慢的话,就会造成许多请求排队等待的情况,也就是所谓的队头阻塞
  • 需要在响应头设置 Content-Length,然后浏览器再根据设置的数据大小来接收数据,对于动态生成的数据无能为力

HTTP 1.1

特性:

  • 使用摘要算法(MD5,加密不可逆,较为安全,只能通过暴力匹配破解)进行身份验证
  • 引入了 cookie
  • 默认使用持久连接,对应请求头 keep-alive
  • 新增 E-tag、If-Match、If-None-Match 等缓存
  • 支持断点续传,对应请求头 Range
  • 因为虚拟机的发展,一个 IP 支持多个域名

缺点:

  • 同时开启多条 TCP 连接时,连接之间会互相竞争带宽
  • 队头阻塞
  • TCP 的慢启动
  • 请求头重复携带

HTTP 2.0

特性:

  • 彻底的二进制协议,头和体都是二进制(HTTP 1.1 的头必须是 ASCII 编码)
  • 多路复用。在一个连接中,客户端和服务器都可以同时发送多个请求或回应,而且不需要按照顺序发送
  • 数据流概念。HTTP 2 的数据包是不按顺序发送的,同一个连接中的数据包可能来源于不同的请求,所以需要对数据包做标记,指明属于哪个请求
  • 头部压缩,因为 HTTP 无状态,每次请求都必须带上所有的信息,所以很多的请求字段都是重复的,比如 User-Agent。一模一样的内容每次请求都要携带会浪费带宽,影响速度。通过 gzip 或者 compress 压缩头后再发送。另一方面,客户端和服务端都维护一张头信息表,部分字段会存储到表中,生成一个索引,以后相同的就只发送索引,不发生字段,这也叫 HPACK 算法。
  • 允许服务器主动推送。HTTP 2 允许服务器未经请求,主动向客户端推送一些必要资源。

缺点:

因为 HTTP/2 使用了多路复用,一般来说同一域名下只需要使用一个 TCP 连接。由于多个数据流使用同一个 TCP 连接,遵守同一个流量状态控制和拥塞控制。只要一个数据流遭遇到拥塞,剩下的数据流就没法发出去,这样就导致了后面的所有数据都会被阻塞。HTTP/2 出现的这个问题是由于其使用 TCP 协议的问题,与它本身的实现其实并没有多大关系。

HTTP 3 (QUIC)

特性:

  • Quick UDP Internet Connection
  • 基于 UDP 实现了类似 TCP 的流量控制、可靠传输机制
  • 继承了 TLS
  • 使用了 HTTP 2 的多路复用,再加上使用了 UDP,真正解决了对头阻塞问题
  • 快速握手,快速启动。因为基于 UDP

缺点:

  • 服务端和客户端对 HTTP 3 的支持还不完善
  • 可能会存在安全性问题

本文是 Build Your Own React 的翻译兼阅读笔记

createElement

React 的每一个 element 包含的内容为 type 和 props。

1
2
3
4
5
6
7
const element = {
type: "h1",
props: {
title: "foo",
children: "Hello", // 一个特殊属性,通常是很多 elements 组成的数组
},
}

要把上述内容渲染为 dom,我们需要以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 创建元素
const node = document.createElement(element.type);
// 把 props 的所有属性传递给节点
node.title = element.props.title;
// 因为这里的 child 比较简单,所以我们用 textNode 代替
const textNode = document.createTextNode('');
textNode.nodeValue = element.props.children;
// 把 child 作为 node 的孩子
node.appendChild(textNode);

// 把 node 插入容器
const container = document.getElementById('root');
container.appendChild(node);

这里默认 dom 代表真实的 dom 元素,而 element 代表 react 元素

现在让我们尝试创造一个自己的 createElement。我们需要做的就是把 JSX 转换为一个 object。

1
2
3
4
5
6
7
8
9
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children,
}
}
}

比方说,

createElement(“div”) 结果是

1
2
3
4
{
type: "div",
props: { children: [] }
}

createElement(“div”, null, a) 结果是

1
2
3
4
{
"ype: "div",
props: { children: [a] }
}

createElement(“div”, null, a, b) 结果是

1
2
3
4
{
type: "div",
props: { children: [a, b] }
}

考虑到 children 其实不一定是 object 类型,我们需要为 children 再创建一个特殊类型 TEXT_ELEMENT。

1
2
3
4
5
6
7
8
9
function createTextElement(text) {
return {
type: 'TEXT_ELEMENT',
props: {
nodeValue: text,
children: [],
},
}
}

同时修改 createElement 如下:

1
2
3
4
5
6
7
8
9
10
11
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map(child => (
typeof child === 'object' ? child : createTextElement(child)
))
}
}
}

为了更有逼格而且摆脱 React 的束缚,我们要起一个很装逼的名字 ———— Didact。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const Didact = {
createElement,
}

const element = Didact.createElement(
"div", // type
{id: "foo"}, // props
// children
Didact.createElement('a', null, 'bar'),
Didact.createElement('b')
)

const container = document.getElementById('root');
ReactDOM.render(element, container);

render

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function render(element, container) {
const dom = element.type === 'TEXT_ELEMENT' ? document.createTextNode('')
: document.createElem(element.type);

// 一个函数,用于判断键值是否为 children
const isProperty = key => key !== 'children';

Object.keys(element.props).filter(isProperty)
.forEach(name => {
dom.name = element.props.name;
})

// 递归渲染
element.props.children.forEach(child => render(child, dom));

container.appendChild(dom);
}

目前前两步的代码在 codesandbox 的地址

Concurrent Mode

目前代码其实有一个很大的问题,一旦开始 render,就会不停递归直至渲染完整棵树。如果这棵树非常大的话,他就会长时间占用主线程,导致卡顿。这时候如果浏览器希望做一些更高优先级的事情,比如先去接收用户的输入,将会无法进行,直至渲染完成。

所以我们需要把渲染流程分成多个小单元,在我们渲染完成每一个小单元之后,我们可以让浏览器打断我们的渲染,只要它有别的高优先级任务需要完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let nextUnitOfWork = null; // 下一个单元是否需要渲染

function workLoop(deadline) {
let shouldYield = false; // 是否应该让路

// 当下一个单元需要被渲染,且不需要让路的时候,就继续渲染
while (nextUnitOfWork && !shouldYield) {
// performUnitOfWork 会执行当前的渲染,并返回下一个 unit
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
shouldYield = deadline.timeRemaining() < 1;
}

// 当主线程空闲的时候浏览器会执行回调函数 workLoop
requestIdleCallback(workLoop);
}

// 首次执行
requestIdleCallback(workLoop);

需要注意的是 React 现在不再使用 requestIdleCallback 了。取而代之的是 scheduler package,但这对于我们理解原理没有太大影响。

requestIdleCallback 还会给我们一个 deadline 参数,我们可以用它来检查在浏览器需要再次获得主线程的控制权之前,我们还能剩下多少时间。

Fibers

为了组织 unit 的结构我们需要一个数据结构叫做 fiber tree。

每一个元素会拥有一个 fiber,而每一个 fiber 会成为 work 的一个 unit。

比如说我们要渲染一棵如下的树:

1
2
3
4
5
6
7
8
9
10
Didact.render(
<div>
<h1>
<p />
<a />
</h1>
<h2 />
</div>,
container
)

在渲染的时候我们会创建 root fiber,并把它设置为 nextUnitOfWork。剩下我们需要在 performUnitOfWork 函数中对 fiber 做以下三件事:

  1. 把节点挂载到 dom 树
  2. 为节点的 child 创建 fiber
  3. 选择下一个 unit

使用 fiber 数据结构的目的就是为了更简单地找到下一个 unit。所以每一个 fiber 和它的第一个孩子、以及紧邻它的兄弟之间都会直接相连。如下所示:

当我们完成了 fiber 上的渲染任务,如果这个 fiber 有 child,这个 child 就会成为下一个 unit。比如说对于上面的例子,div 渲染完成后就轮到 h1 了。

如果当前 fiber 没有 child,就会把紧贴的兄弟节点作为下一个 unit。比如上面例子中的 p,它没有 child 了,所以会把兄弟 a 作为下一个 unit。

如果当前 fiber 没有 child,也没有 sibling,我们就去找它的 uncle,也就是 parent 的兄弟。比如说 a,既没有 child 也没有下一个兄弟了,只能回去找它爹 h1 的兄弟 h2。

同理,如果它爹也没有兄弟,就继续找它爹的爹,如此遍历直到我们到达 root。当到达 root 也就意味着完成了 render。

现在我们将其编写成代码:

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
function render(element, container) {
nextUnitOfWork = {
dom: container, // 初始化设置为 root
props: {
children: [element],
},
}
}

let nextUnitOfWork = null;

function workLoop(deadline) {
let shouldYield = false; // 是否应该让路

// 当下一个单元需要被渲染,且不需要让路的时候,就继续渲染
while (nextUnitOfWork && !shouldYield) {
// performUnitOfWork 会执行当前的渲染,并返回下一个 unit
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
shouldYield = deadline.timeRemaining() < 1;
}

// 当主线程空闲的时候浏览器会执行回调函数 workLoop
requestIdleCallback(workLoop);
}

requestIdleCallback(workLoop);

function performUnitOfWork(fiber) {
// 1. add dom node
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom);
}

// 2. create a new fiber for each child
const elements = fiber.props.children;
let index = 0;
let prevSibling = null;

while (index < elements.length) {
const element = elements[index];

const newFiber = {
type: element.type,
props: element.props,
parent: fiber,
dom: null,
}

// 把 fiber 挂载到树上,具体要作为 child 还是 sibling 取决于它是第一个节点还是后来的节点
if (index === 0) {
fiber.child = newFiber;
} else {
prevSibling.sibling = newFiber;
}

prevSibling = newFiber;
index++;
}

// 3. search and return the next unit of work
if (fiber.child) {
return fiber.child;
}
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = nextFiber.parent;
}
}

commitRoot

现在我们又遇到了一个新问题,现在我们每个 unit 渲染时都会把一个 dom 挂载到树上,而浏览器可以随时打断我们的渲染。这也就意味着,如果只有部分 unit 完成了渲染,用户将看到不完整的 UI。这不是我们所想要的。

所以我们需要把挂载 dom 的部分从原来的 render 代码中删除。取而代之的持续追踪 fiber 的根,我们将其命名为 wipRoot。

1
2
3
4
5
6
7
8
9
10
11
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
}
nextUnitOfWork = null;
}

let wipRoot = null;

当我们完成渲染之后,也就是没有 next unit 的时候,我们直接把整棵树挂载到 dom 上。

这一阶段我们叫做 commitRoot。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function commitRoot() {
commitWork(wipRoot.child);
wipRoot = null;
}

function commitWork(fiber) {
if (!fiber) {
return;
}
const domParent = fiber.parent.dom;
domParent.appendChild(fiber.dom);
commitWork(fiber.child);
commitWork(fiber.sibling);
}

Reconciliation 调和

目前为止我们的所有操作都是针对于添加节点到 dom 中,那么如果我们要删除或更新节点呢?

这时候我们就需要比对 fiber 中元素和当前元素的情况。

所以我们需要一个变量来存储最新 commit 的 fiber,我们将其称为 currentRoot。

我们还要给每个 fiber 提供一个候选项 alternate,这个是一个直达旧的 fiber 的 link。

我们设置一个函数 reconcileChildren,用来调和旧的 fiber 和新的 react elements。

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
function reconcileChildren(wipFiber, elements) {
let index = 0;
let oldFiber = wipFiber.alternate?.child;
let prevSibling = null;

while (index < elements.length || oldFiber !== null) {
const element = elements[index];
let newFiber = null;

if (oldFiber) {
const sameType = oldFiber && element && element.type === oldFiber.type;
}

// 新旧节点类型相同,对 element 创建新的 fiber,并且复用旧的 dom,但是用的是 element 上的 props
if (sameType) {
// 更新节点属性
newFiber = {
type: oldFiber.type, /// 复用
props: element.props, // 用新的
dom: oldFiber.dom, // 复用
parent: wipFiber,
alternate: oldFiber,
effectTag: 'UPDATE', // 这个属性会在 commit 的时候用到
}
}

// 对于需要生成新 DOM 节点的 fiber,我们标记 effectTag 为 PLACEMENT
if (element && !sameType) {
// 添加新节点
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: wipFiber,
alternate: null,
effectTag: 'PLACEMENT',
}
}

// 对于需要删除的节点,我们不会生成 fiber,而是会在 oldFiber 上添加标记。当我们 commit 整棵 fiber 树的时候,并不会遍历旧的 fiber,而是把 fiber 的变更提交上去。
if (oldFiber && !sameType) {
// 删除旧节点
oldFiber.effectTag = "DELETION";
deletions.push(oldFiber);
}
}
}

迭代整个 react elements 的同时,我们也要迭代旧的 fiber 节点,即 wipFiber.alternate。

现在我们要比较 oldFiber 和 element 之间的差异。

比较的步骤如下:

  • 新旧节点类型相同,复用旧的 dom,只修改上面的属性。
  • 节点类型不同,而且有新的 element,我们需要创建一个新的 dom 节点
  • 类型不同,且 oldFiber 存在,需要删除旧节点

React 会通过属性 key 来优化调和步骤,key 可以用来检查 elements 数组中的子组件是否仅仅只是更换了位置。

因此我们需要一个数组来保存要移除的 dom 节点。

修改 render 函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
alternate: currentRoot,
}
deletions = [];
nextUnitOfWork = wipRoot;
}

let nextUnitOfWork = null;
let currentRoot = null;
let wipRoot = null;
let deletions = null;

修改 commitWork 函数如下:

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
function commitWork(fiber) {
if (!fiber) {
return;
}
const domParent = fiber.parent.dom;

// 如果标记为 PLACEMENT,那么在其父亲节点的 DOM 节点上添加该 fiber 的 DOM。
// 如果标记为 DELETION,则删除节点
// 如果标记为 UPDATE,我们需要更新已经存在的旧 DOM 节点的属性值
if (fiber.effectTag === 'PLACEMENT' && fiber.dom !== null) {
domParent.appendChild(fiber.dom);
} else if (fiber.effectTag === "DELETION") {
domParent.removeChild(fiber.dom)
} else if (
fiber.effectTag === "UPDATE" &&
fiber.dom != null
) {
updateDom(
fiber.dom,
fiber.alternate.props,
fiber.props
);
}

commitWork(fiber.child);
commitWork(fiber.sibling);
}

下面我们实现 updateDom 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const isProperty = key => key !== "children";
const isNew = (prev, next) => key => prev[key] !== next[key];
const isGone = (prev, next) => key => !(key in next);

function updateDom(dom, prevProps, nextProps) {
// 删除旧属性
Object.keys(prevProps).filter(isProperty)
.filter(isGone(prevProps, nextProps))
.forEach(name => {
dom.name = '';
})

// 添加新属性
Object.keys(nextProps).filter(isProperty)
.filter(isNew(prevProps, nextProps))
.forEach(name => {
dom.name = nextProps.name;
})
}

有一种比较特殊的属性值是事件监听,这里假设以 on 开头的就是事件监听。

1
2
const isEvent = key => key.startsWith("on");
const isProperty = key => key !== "children" && !isEvent(key);

对于事件监听我们需要做以下处理:

1
2
3
4
5
6
7
// 移除原来的事件
Object.keys(prevProps).filter(isEvent)
.filter(key => !(key in nextProps) || isNew(prevProps, nextProps))
.forEach(name => {
const eventType = name.toLowerCase().substring(2);
dom.removeEventListener(eventType, prevProps.name);
})
1
2
3
4
5
6
7
// 添加新的事件监听
Object.keys(nextProps).filter(isEvent)
.filter(isNew(prevProps, nextProps))
.forEach(name => {
const eventType = name.toLowerCase().substring(2);
dom.addEventListener(eventType, nextProps.name);
})

Function Components

下一步我们要支持函数组件。

1
2
3
4
5
6
7
/** @jsx Didact.createElement */
function App(props) {
return <h1>Hi {props.name}</h1>
}
const element = <App name="foo" />
const container = document.getElementById("root");
Didact.render(element, container);

这个 jsx 语法应该被转换为以下的 js 语法:

1
2
3
4
5
6
7
8
9
10
11
12
function App(props) {
return Didact.createElement(
"h1",
null,
"Hi ",
props.name
)
}

const element = Didact.createElement(App, {
name: "foo",
});

函数组件与之前的语法有两个不同之处:

  • 函数组件的 fiber 没有 dom
  • 子节点由函数运行得到,而不是直接从 props 获取
1
2
3
4
5
6
7
function performUnitOfWork(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
const elements = fiber.props.children;
reconcileChildren(fiber, elements);
}

当 fiber 类型为函数时,我们使用不同的函数来进行更新。在 updateHostComponent 我们按照之前的方法更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
const isFunctionComponent = fiber.type instanceof Function;
if (isFunctionComponent) {
updateFunctionComponent(fiber);
} else {
updateHostComponent(fiber);
}

function updateHostComponent(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
reconcileChildren(fiber, fiber.props.children);
}

在函数组件中我们通过执行函数来获得 children。

1
2
3
4
function updateFunctionComponent(fiber) {
const children = [fiber.type(fiber.props)]
reconcileChildren(fiber, children)
}

对于前面的例子

1
2
3
4
function App(props) {
return <h1>Hi {props.name}</h1>
}
const element = <App name="foo" />

fiber.type 就是 App 函数,当执行函数的时候,就会返回 h1 元素。

一旦我们拿到了这个子节点,剩下的调和就跟之前一致,我们不需要修改任何东西了。

接下来修改 commitWork 函数。

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
function commitWork(fiber) {
if (!fiber) {
return
}

// 找 dom 节点的父节点的时候我们需要往上遍历 fiber 节点,直到找到有 dom 节点的 fiber 节点
let domParentFiber = fiber.parent;
while (!domParentFiber.dom) {
domParentFiber = domParentFiber.parent;
}
const domParent = domParentFiber.dom;

if (
fiber.effectTag === 'PLACEMENT' && fiber.dom !== null) {
domParent.appendChild(fiber.dom)
} else if (
fiber.effectTag === 'UPDATE' &&
fiber.dom != null
) {
updateDom(
fiber.dom,
fiber.alternate.props,
fiber.props
)
} else if (fiber.effectTag === 'DELETION') {
// 移除节点也同样需要找到该 fiber 下第一个有 dom 节点的 fiber 节点
domParent.removeChild(fiber, domParent);
}

commitWork(fiber.child)
commitWork(fiber.sibling)
}

Hooks

最后一步我们来给函数组件添加 state。我们把示例组件设置为经典的计数器。

1
2
3
4
5
6
7
8
9
10
/** @jsx Didact.createElement */
function Counter() {
const [state, setState] = Didact.useState(1)
return (
<h1 onClick={() => setState(c => c + 1)}>
Count: {state}
</h1>
)
}
const element = <Counter />
1
2
3
4
5
6
7
8
9
10
let wipFiber = null;
let hookIndex = null;

function updateFunctionComponent(fiber) {
wipFiber = fiber;
hookIndex = 0;
wipFiber.hooks = [];
const children = [fiber.type(fiber.props)];
reconcileChildren(fiber, children);
}

在对应的 fiber 上加上 hooks 数组以支持我们在同一个函数组件中多次调用 useState。然后我们记录当前 hook 的序号。

当函数组件调用 useState,我们查看 fiber 对应的 alternate 字段下的旧 fiber 是否存在旧 hook、以及hook 的序号用以记录是该组件下的第几个 useState。

如果存在旧的 hook,我们将旧的 hook 值拷贝一份到新的 hook。 如果不存在,就将 state 初始化。

然后在 fiber 上添加新 hook,hook 序号会进行自增,然后返回状态。

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
function useState(initial) {
const oldHook =
wipFiber.alternate &&
wipFiber.alternate.hooks &&
wipFiber.alternate.hooks[hookIndex];
const hook = {
state: oldHook ? oldHook.state : initial,
queue: [], // 添加一个队列,用于存储 action
};

wipFiber.hooks.push(hook);
hookIndex++;
return [hook.state];
}

// 在下一次渲染的时候,我们才会执行 action,我们把所有的 action 从旧的 hook 队列中取出,然后将其一个个调用得到新的 hook state,因此最后返回的 state 就已经是更新好的。

const actions = oldHook ? oldHook.queue : [];
actions.forEach(action => {
hook.state = action(hook.state)
});

const setState = action => {
hook.queue.push(action);
wipRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
}
nextUnitOfWork = wipRoot;
deletions = [];
}

useState 还需要返回一个可以更新状态的函数,我们定义 setState,它接收一个 action。(在 Counter 的例子中, action 是自增 state 的函数)

最终完整的 mini-react 代码链接在 https://codesandbox.io/s/didact-8-21ost

1
2
3
4
5
6
7
8
<div class="container">
<p class="draggable" draggable="true">1</p>
<p class="draggable" draggable="true">2</p>
</div>
<div class="container">
<p class="draggable" draggable="true">3</p>
<p class="draggable" draggable="true">4</p>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
body {
margin: 0;
}

.container {
background-color: #333;
padding: 1rem;
margin-top: 1rem;
}

.draggable {
padding: 1rem;
background-color: white;
border: 1px solid black;
cursor: move;
}

.draggable.dragging {
opacity: 0.5;
}
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
const draggables = document.querySelectorAll(".draggable");
const containers = document.querySelectorAll(".container");

draggables.forEach((draggable) => {
draggable.addEventListener("dragstart", () => {
// console.log("start");
draggable.classList.add("dragging");
});

draggable.addEventListener("dragend", () => {
// console.log("end");
draggable.classList.remove("dragging");
});
});

containers.forEach((container) => {
// 在框框内的时候就是 drag over
container.addEventListener("dragover", (e) => {
// console.log("over");
e.preventDefault(); // 关闭禁止 cursor
const afterElement = getDragAfterElement(container, e.clientY);
// console.log(afterElement);
const draggable = document.querySelector(".dragging");
if (!afterElement) {
container.appendChild(draggable);
} else {
container.insertBefore(draggable, afterElement);
}
});
});

function getDragAfterElement(container, y) {
const draggableElements = [
...container.querySelectorAll(".draggable:not(.dragging)"),
];

return draggableElements.reduce(
(closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
// console.log(offset);
if (offset < 0 && offset > closest.offset) {
return {
offset,
element: child,
};
} else {
return closest;
}
},
{
offset: Number.NEGATIVE_INFINITY,
}
).element;
}

题目列表来源于:
https://bigfrontend.dev/

参数定长柯里化

1
2
3
sum(1)(2)(3)(4)	// 10
sum(1, 2)(3)(4) // 10
sum(1)(2, 3)(4) // 10

实现一个函数同时可以求解上述表达式。

先要实现原函数

1
2
3
function sum(a, b, c, d) {
return a + b + c + d;
}

将其进行柯里化,目标是将它的参数展开,所以柯里化的过程实际上可以理解为一个递归展开参数的过程。展开的便捷就是当前参数个数等于函数需要的参数,不过为了确保完整性把 === 改成了 >= 而已。

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
function curry(fn, ...args) {
// fn.length 表示的是 fn 需要的参数个数
// 当前函数的参数个数大于或等于原函数时,直接执行
console.log("args", args);
if (args.length >= fn.length) {
return fn(...args);
}
// args2 是即将出现的参数
return function (...args2) {
console.log("args2", args2);
return curry(fn, ...args, ...args2);
};
}

// (1)(2, 3)
// args [],刚开始参数个数为零
// args2 [ 1 ],即将出现参数 1
// args [ 1 ],拼接后获得参数 1
// args2 [ 2, 3 ],即将出现参数 [2, 3]
// args [ 1, 2, 3 ],拼接后获得参数 [1, 2, 3],完成任务

// (1, 2)(3)
// args []
// args2 [ 1, 2 ]
// args [ 1, 2 ]
// args2 [ 3 ]
// args [ 1, 2, 3 ]

const join = (a, b, c) => {
return `${a}_${b}_${c}`;
};

const curriedJoin = curry(join);

curriedJoin(1, 2, 3); // '1_2_3'
curriedJoin(1)(2, 3); // '1_2_3'
curriedJoin(1, 2)(3); // '1_2_3'

参数不定长柯里化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function curry() {
// 接收第一次参数
const args = [...arguments];

// 接收第二次参数
const inner = function() {
args.push(...arguments);
// 递归获取剩下的所有参数
return inner;
}

inner.toString = function() {
return args.reduce((acc, cur) => acc + cur);
}

return inner;
}

实现 Array.prototype.flat()

1
2
3
4
5
6
7
8
9
function flat(arr, depth = 1) {
return depth > 0 ?
arr.reduce((prev, cur) => {
if (Array.isArray(cur)) {
return [...prev, ...flat(cur, depth - 1)];
}
return [...prev, cur];
}, []) : arr;
}

实现 throttle()

1
2
3
4
5
6
7
8
9
10
function throttle(fn, delay = 200) {
let timer = null;
return function(...args) {
if (!timer) {
timer = setTimeout(() => {
fn.apply(this, args);
timer = null;
}, delay);
}
}

实现 debounce()

1
2
3
4
5
6
7
8
9
10
11
function debounce(fn, delay = 200) {
let timer = null;
return function(...args) {
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
}
}

实现 shuffle()

Fisher-Yates shuffle 算法

每次删除一个数字,并将删除的数字移至数组末尾,即将每个被删除数字与最后一个未删除的数字进行交换。

1
2
3
4
5
6
7
8
9
10
function shuffle(arr) {
let i = arr.length;

for (let i = arr.length; i >= 0; i--) {
const j = Math.floor(Math.random() * i);
[arr[i], arr[j]] = [arr[j], arr[i]]
}

return arr;
}

解密消息

在一个字符串的二维数组中,有一个隐藏字符串。

1
2
3
I B C A L K A
D R F C A E A
G H O E L A D

可以按照如下步骤找出隐藏消息

  1. 从左上开始,向右下前进
  2. 无法前进的时候,向右上前进
  3. 无法前进的时候,向右下前进
  4. 重复 2 和 3
  5. 无法前进的时候,经过的字符就就是隐藏信息

比如上面的二维数组的话,隐藏消息就是 IROCLED

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
/**
* @param {string[][]} message
* @return {string}
*/
function decode(message) {
if (message.length === 0 || message[0].length === 0) {
return '';
}
let res = '';
let i = 0, j = 0;
let top = true; // 表示目前正在向 i 增大方向行进,对应图中的向下行进

while (j < message[0].length) {
if (top) {
res += message[i++][j++];
} else {
res += message[i--][j++];
}
if (i === message.length - 1) {
top = false;
}
if (i === 0) {
top = true;
}
}

return res;
}

第一个错误的版本

题目链接:
https://leetcode-cn.com/problems/first-bad-version/

二分基础题

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
/**
* Definition for isBadVersion()
*
* @param {integer} version number
* @return {boolean} whether the version is bad
* isBadVersion = function(version) {
* ...
* };
*/

/**
* @param {function} isBadVersion()
* @return {function}
*/
var solution = function(isBadVersion) {
/**
* @param {integer} n Total versions
* @return {integer} The first bad version
*/
return function(n) {
let l = 1, r = n;
let ans = 1;
while (l <= r) {
const mid = l + ((r - l) >> 1);
if (isBadVersion(mid)) {
// 满足条件,寻找最左满足,因此收缩右边界
r = mid - 1;
ans = mid;
} else {
l = mid + 1;
}
}

return ans;
};
};

实现 pipe()

pipe 会传入一个数组,数组中每一项是一个函数。pipe 会依次执行里面的多个函数,最后返回结果。假设每个函数都有且仅有一个参数。如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pipe([
times(2),
times(3)
])
// x * 2 * 3

pipe([
times(2),
plus(3),
times(4)
])
// (x * 2 + 3) * 4

pipe([
times(2),
subtract(3),
divide(4)
])
// (x * 2 - 3) / 4
1
2
3
4
5
6
7
8
9
10
11
/**
* @param {Array<(arg: any) => any>} funcs
* @return {(arg: any) => any}
*/
function pipe(funcs) {
return function (arg) {
return funcs.reduce((acc, func) => {
return func.call(this, acc);
}, arg);
}
}

栈实现队列

若 stack2 为空,则直接输入栈顶元素;否则,先把 stack1 的所有元素倒进 stack2 中。

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
/* you can use this Class which is bundled together with your code

class Stack {
push(element) { // add element to stack }
peek() { // get the top element }
pop() { // remove the top element}
size() { // count of element }
}
*/

class Queue {
constructor() {
this.stack1 = [];
this.stack2 = [];
}
enqueue(element) {
// add new element to the rare
this.stack1.push(element);
}
peek() {
// get the head element
if (this.stack2.length) {
return this.stack2[this.stack2.length - 1];
}
while (this.stack1.length) {
this.stack2.push(this.stack1.pop());
}
return this.stack2[this.stack2.length - 1];
}
size() {
// return count of element
return this.stack1.length + this.stack2.length;
}
dequeue() {
// remove the head element
if (this.stack2.length) {
return this.stack2.pop();
}
while (this.stack1.length) {
this.stack2.push(this.stack1.pop());
}
return this.stack2.pop();
}
}

实现 memo()

对同一个函数,当传入相同参数的时候,直接返回上一次的结果而不经过计算。要求允许传入第二个参数决定缓存 key 的生成方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* @param {Function} func
* @param {(args:[]) => string } [resolver] - cache key generator
*/
function memo(func, resolver = (...args) => args.join('_')) {
const cache = new Map();

return function(...args) {
const key = resolver(...args);
if (cache.has(key)) {
return cache.get(key);
}
const val = func.apply(this, args);
cache.set(key, val);
return val;
}
}

实现类似 jQuery 的 DOM wrapper

实现自己的 $(),只需要支持 css(propertyName: string, value: any) 即可。如下面所示:

1
2
3
4
$('#button')
.css('color', '#fff')
.css('backgroundColor', '#000')
.css('fontWeight', 'bold')
1
2
3
4
5
6
7
8
9
function $(elem) {
return {
css: function(property, value) {
elem.style[property] = value;
// 最后返回原对象,因为需要支持链式调用
return this;
}
}
}

实现基本的 Event Emitter

要求满足的条件为:

构造函数

1
const emitter = new Emitter()

支持事件订阅

1
2
3
4
5
const sub1  = emitter.subscribe('event1', callback1)
const sub2 = emitter.subscribe('event2', callback2)

// 同一个callback可以重复订阅同一个事件
const sub3 = emitter.subscribe('event1', callback1)

emit(eventName, ...args) 可以用来触发 callback

1
2
emitter.emit('event1', 1, 2);
// callback1 会被调用两次

subscribe() 返回一个含有 release() 的对象,可以用来取消订阅。

1
2
3
4
sub1.release()
sub3.release()
// 现在即使'event1'被触发,
// callback1 也不会被调用
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
class EventEmitter {
constructor() {
this.subscriptions = new Map();
}

subscribe(eventName, callback) {
if(!this.subscriptions.has(eventName)) {
this.subscriptions.set(eventName, new Set());
}
// 获取具体的事件列表
const subscriptions = this.subscriptions.get(eventName);
// 这里之所以要把 callback 放进一个 obj 里面,是因为题目允许重复订阅同一个事件
// 以 obj 作为键值,不会重复
const callbackObj = {
callback
}
subscriptions.add(callbackObj);

return {
// 取消订阅
release: () => {
subscriptions.delete(callbackObj);
if (subscriptions.size === 0) {
this.subscriptions.delete(eventName);
}
}
}
}

emit(eventName, ...args) {
const subscriptions = this.subscriptions.get(eventName);
if (subscriptions) {
subscriptions.forEach(callbackObj => {
callbackObj.callback.apply(this, args);
})
}
}
}

模拟 Map

JavaScript中有 Map,我们可以用任何 data 做 key,包括 DOM 元素。如下所示:

1
2
const map = new Map()
map.set(domNode, somedata)

如果运行的 JavaScript 不支持 Map,我们如何能让上述代码工作?

方法是使用对象来模拟一个 map。

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
class NodeStore {
constructor() {
this.nodes = {};
}
/**
* @param {Node} node
* @param {any} value
*/
set(node, value) {
node.storeKey = Symbol();
this.nodes[node.storeKey] = value;
}
/**
* @param {Node} node
* @return {any}
*/
get(node) {
return this.nodes[node.storeKey];
}

/**
* @param {Node} node
* @return {Boolean}
*/
has(node) {
return this.nodes.hasOwnProperty(node.storeKey);
}
}

在相同结构的树上寻找对应的节点

给定两个完全一样的 DOM Tree A 和 B,以及 A 中的元素 a,请找到 B 中对应的元素 b。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

/**
* @param {HTMLElement} rootA
* @param {HTMLElement} rootB - rootA and rootB are clone of each other
* @param {HTMLElement} nodeA
*/
const findCorrespondingNode = (rootA, rootB, target) => {
if (rootA === target) {
return rootB;
}

// children 是一个 DOM 的 api,表示所有的子节点
for (let i = 0; i < rootA.children.length; i++) {
const res = findCorrespondingNode(rootA.children[i], rootB.children[i], target);
// 注意这里必须要判断 res 是否存在
if (res) {
return res;
}
}
}

检测 data type

1
2
3
4
5
6
7
8
/**
* @param {any} data
* @return {string}
*/
function detectType(data) {
const res = Object.prototype.toString.call(data).slice(8, -1);
return res.toLowerCase();
}

实现 JSON.stringify()

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

/**
* @param {any} data
* @return {string}
*/
function stringify(data) {
const type = typeof data;

const undefinedOptions = ['undefined', 'function', 'symbol'];
const stringOptions = ['number', 'boolean'];

// 处理 undefined
if (undefinedOptions.includes(type)) {
return undefined;
}

// 处理 null
if (Number.isNaN(data) || data === Infinity || data === -Infinity || data === null) {
return "null";
}

// 处理 number 和 boolean
if (stringOptions.includes(type)) {
return `${data}`;
}

// 处理字符串
if (type === 'string') {
return `"${data}"`
}

// 错误处理
if (typeof data === 'bigint') {
throw new Error('stringify 无法序列化 bigint 数据类型');
}

// 剩下的就是 object 类型

// 若存在 toJSON 函数,如 Date(),直接调用
if (data.toJSON && typeof data.toJSON === 'function') {
return stringify(data.toJSON());
}

// 处理数组
if (Array.isArray(data)) {
const result = [];
data.forEach((item, index) => {
// undefined、function 以及 symbol 变为 null
if (undefinedOptions.includes(typeof item)) {
result[index] = 'null';
} else {
result[index] = stringify(item);
}
})
return "[" + result + "]";
}

// 处理普通对象
const result = [];
Object.keys(data).forEach((key, index) => {
// 键值不能为 symbol
// 值忽略 undefined、function 以及 symbol
if(typeof key !== 'symbol' && !undefinedOptions.includes(typeof data[key])) {
result.push(`"${key}":${stringify(data[key])}`);
}
})
return `{${result.join(',')}}`;
}

实现 JSON.parse()

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
function parse(str) {
if (str === '' || str[0] === "'") {
throw new Error();
}
// 特殊情况
if (str === 'null') return null;
if (str === '{}') return {};
if (str === '[]') return [];
// 判断 boolean
if (str === 'true') return true;
if (str === 'false') return false;
// 判断 number
if(+str === +str) return Number(str);
// 判断 string
if(str[0] === '"') {
return str.slice(1, -1);
}
// 判断对象
if (str[0] === '{') {
return str.slice(1, -1).split(',').reduce((acc, cur) => {
const index = cur.indexOf(':');
const key = cur.slice(0, index);
const value = cur.slice(index + 1);
acc[parse(key)] = parse(value);
return acc;
}, {})
}
// 判断数组
if (str[0] === '[') {
return str.slice(1, -1).split(',').map((value) => parse(value));
}
}

本文对应仓库地址

webpack 初体验

运行命令初始化 package.json。

1
yarn init -y

安装 webpack 和 webpack-cli。

1
yarn add webpack webpack-cli -D

根目录新建文件夹 src,里面新建文件夹 js。

js 文件夹下新建两个文件 math.js 和 foo.js。内容如下:

1
2
3
4
5
6
7
8
// math.js
export const sum = (a, b) => {
return a + b;
};

export const mul = (a, b) => {
return a * b;
};
1
2
3
4
5
6
7
8
// foo.js
function foo() {
console.log("foo");
}

module.exports = {
foo,
};

src 目录下新建文件 index.js,文件内容如下:

1
2
3
4
5
6
7
import { sum, mul } from "./js/math";
const { foo } = require("./js/foo");

console.log(sum(2, 3));
console.log(mul(2, 3));

foo();

根目录下新建 index.html,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!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>Webpack Learning</title>
</head>

<body>
<script src="./dist/main.js"></script>
</body>

</html>

运行命令打包文件。

1
npx webpack

在 dist/main.js 中,可以看到打包的结果。

在 package.json 中配置 build 运行命令。

1
"build": "npx webpack"

css-loader & style-loader

src/js 目录下新建文件 title.js,内容如下:

1
2
3
4
5
6
7
8
9
10
import "../css/title.css";

function setTitle(title) {
const h1 = document.createElement("h1");
h1.innerHTML = title;
h1.className = "title";
document.body.appendChild(h1);
}

export default setTitle;

修改 index.js 内容为:

1
2
3
import setTitle from "./js/title";

setTitle("hello world");

src 目录下新建文件夹 css,css 文件夹下新建文件 title.css,内容如下:

1
2
3
.title {
color: red;
}

在 index.html 中引入 css 文件。

运行命令 yarn build,报错,原因是缺少 loader。

安装 loader。

1
yarn add css-loader -D

根目录下新建文件 webpack.config.js,进行配置。

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

module.exports = {
entry: "./src/index.js",
output: {
filename: "[name].js",
path: path.join(__dirname, "./dist"),
},
module: {
rules: [
{
test: /\.css$/,
use: ["css-loader"],
},
],
},
};

但是样式并没有展示,因为现在只是把 css 语法识别为了 js 语法,但是还没有挂载到页面上,我们需要 style-loader 来把内容挂载到 <style> 标签上。

安装 style-loader

1
yarn add style-loader -D

修改配置文件:

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

module.exports = {
entry: "./src/index.js",
output: {
filename: "[name].js",
path: path.join(__dirname, "./dist"),
},
module: {
rules: [
{
test: /\.css$/,
use: ["style-loader", "css-loader"],
},
],
},
};

less-loader

首先安装 less。

1
yarn add less -D

配置文件修改为下面的内容。

安装 less-loader。

1
yarn add less-loader -D
1
2
3
4
5
6
7
8
9
10
rules: [
{
test: /\.css$/,
use: ["style-loader", "css-loader"],
},
{
test: /\.less$/,
use: ["style-loader", "css-loader", "less-loader"],
},
],

browserslitrc

浏览器使用比例可以查看 caniuse 官网

在 package.json 中新增内容

1
2
3
4
5
"browserslist": [
">1%",
"last 2 version",
"not dead"
]

运行命令

1
npx browserslist

可查看当前兼容的浏览器选项。

postcss-loader

postcss 用来处理 css 的兼容性问题。

先安装 postcss 和 postcss-loader

1
yarn add postcss -D

css 文件夹下新建文件 test.css,内容如下:

1
2
3
4
.title {
user-select: none;
transition: all 1s;
}

然后把文件引入 title.js 中。

修改 webpack.config.js 为:

1
2
3
4
5
6
7
8
{
test: /\.css$/,
use: ["style-loader", "css-loader", "postcss-loader"],
},
{
test: /\.less$/,
use: ["style-loader", "css-loader", "postcss-loader", "less-loader"],
},

打包以后发现没有任何效果,原因是 postcss 本身其实不具备修改 css 的功能,还需要额外的插件才行。

autoprefixer

安装插件 autoprefixer。

1
yarn add autoprefixer -D

修改配置文件为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
test: /\.css$/,
use: [
"style-loader",
"css-loader",
{
loader: "postcss-loader",
options: {
postcssOptions: {
plugins: [require("autoprefixer")],
},
},
},
],
},

postcss-preset-env

安装 postcss-preset-env。

1
yarn add postcss-preset-env -D

修改 test.css 内容为:

1
2
3
.title {
color: #12345678;
}

postcss-loader 配置的 plugins 中增加以下内容

1
require("postcss-preset-env")

打包以后颜色会以 rgba 形式展示。

避免重复配置

为了避免在 css-loader 和 less-loader 中都要进行重复的配置,postcss 还支持我们通过配置文件进行配置。

根目录下新建文件 postcss.config.js,内容为:

1
2
3
module.exports = {
plugins: [require("autoprefixer"), require("postcss-preset-env")],
};

这样我们就不需要在 webpack.config.js 中书写 postcss-loader 的 plugins 了。

1
2
3
4
5
6
7
8
{
test: /\.css$/,
use: ["style-loader", "css-loader", "postcss-loader"],
},
{
test: /\.less$/,
use: ["style-loader", "css-loader", "postcss-loader", "less-loader"],
},

filer-loader

处理 img 标签

在 src 文件夹下新建文件夹 img,里面塞入一张图片。

在 js 文件夹下新建文件 image.js,内容如下:

1
2
3
4
5
6
7
8
9
10
11
import imgSrc from "../img/ai.jpg";

function setImage() {
const div = document.createElement("div");
const img = document.createElement("img");
img.src = imgSrc;
div.appendChild(img);
document.body.appendChild(div);
}

export default setImage;

修改 index.js,内容如下:

1
2
3
import setImage from "./js/image";

setImage();

打包以后报错缺少 loader。

安装 file-loader。

1
yarn add file-loader -D

修改 webpack.config.js,添加如下内容

1
2
3
4
{
test: /\.(png|svg|gif|jpe?g)$/,
use: ["file-loader"],
},

处理 css 背景图片

修改 js/image.js 为以下内容:

1
2
3
4
5
6
7
8
9
10
11
import "../css/bg.css";

function setImage() {
const div = document.createElement("div");
const backgroundImg = document.createElement("div");
backgroundImg.className = "bg-img";
div.appendChild(backgroundImg);
document.body.appendChild(div);
}

export default setImage;

css 文件夹下新建文件 bg.css,内容如下:

1
2
3
4
5
6
.bg-img {
background-image: url(../img/ai.jpg);
border: 1px solid black;
width: 400px;
height: 400px;
}

先删除 dist 目录,再运行打包命令。此时发现 dist 目录下会出现两张图片。

其中一张是正常的,另一张点击打开以后内容如下:

里面的文本内容是一个指向我们需要的图片的导出语句。

这是因为图片是嵌在 css-loader 里面,没有被 file-loader 处理。

css-loader 会把 url 路径处理为 require 语句,而 require 语句使用时需要把 css-loader 的 esModule 属性设置为 false。

1
2
3
4
5
6
7
8
9
10
11
12
13
{
test: /\.css$/,
use: [
"style-loader",
{
loader: "css-loader",
options: {
esModule: false,
},
},
"postcss-loader",
],
},

处理以后打包问题就解决了。

图片名称和路径设置

  • [ext] 拓展名
  • [name] 文件名
  • [hash] 哈希
  • [hash:] 哈希截取长度
  • [path] 文件路径

修改 webpack.config.js 如下

1
2
3
4
5
6
7
8
9
10
11
12
{
test: /\.(png|svg|gif|jpe?g)$/,
use: [
{
loader: "file-loader",
options: {
name: "[name].[hash:6].[ext]",
outputPath: "img",
},
},
],
},

url-loader

安装 url-loader。

1
yarn add url-loader -D

把配置文件的 file-loader 修改为 url-loader,进行打包。

打包后发现页面显示正常,但是 dist 目录下没有出现 img 文件夹。

它会以 base64 形式把图片嵌入代码中。

limit

url-loader VS file-loader

  • url-loader 会把文件转换为 base64 格式,可以减少请求次数,但是会增加单次请求文件的体积,不利于首屏渲染
  • file-loader 会将资源拷贝到指定目录,分开请求
  • url-loader 可以调用 file-loader,通过设置 limit 进行阈值限制,控制文件小于多少的时候使用 url-loader

在 img 文件夹下加入一张新图片

修改 image.js 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import "../css/bg.css";
import imgSrc from "../img/ai2.jpg";

function setImage() {
const div = document.createElement("div");

const img = document.createElement("img");
img.src = imgSrc;
div.appendChild(img);

const backgroundImg = document.createElement("div");
backgroundImg.className = "bg-img";
div.appendChild(backgroundImg);

document.body.appendChild(div);
}

export default setImage;

修改 webpack.config.js 文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
test: /\.(png|svg|gif|jpe?g)$/,
use: [
{
loader: "url-loader",
options: {
name: "[name].[hash:6].[ext]",
outputPath: "img",
limit: 25 * 1024, // 即 25Kb
},
},
],
},

运行打包命令,会发现只有一张较大的图片,另一张小的图片被以 base64 的形式嵌入文件里了。

asset 处理图片

webpack5 之后不需要再使用 file-loader 和 url-loader 了。

  • asset/resource => file-loader
  • asset/inline => url-loader
  • asset => 阈值限制

修改 webpack.config.js 如下:

1
2
3
4
5
6
7
{
test: /\.(png|svg|gif|jpe?g)$/,
type: "asset/resource",
generator: {
filename: "img/[name].[hash:6][ext]",
},
},

如果需要设置 limit,则修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
{
test: /\.(png|svg|gif|jpe?g)$/,
type: "asset",
generator: {
filename: "img/[name].[hash:6][ext]",
},
parser: {
dataUrlCondition: {
maxSize: 25 * 1024,
},
},
},

asset 处理字体图标

先去 iconfont 官网下载图标。

在 src 下新建文件夹 font,里面留着下面图片中这几个文件即可。

src/js 下新建文件 font.js,内容如下:

1
2
3
4
5
6
7
8
9
10
11
function setFont() {
const div = document.createElement("div");

const span = document.createElement("span");
span.className = "iconfont icon-gift lg-icon";
div.appendChild(span);

document.body.appendChild(div);
}

export default setFont;

修改 index.js 内容如下:

1
2
3
import setFont from "./js/font";

setFont();

运行打包命令,报错,这是因为我们没有办法处理 iconfont 里面的路径。

我们直接把字体当成资源文件进行拷贝即可,因此可以使用前面所说的 asset/resource ,给 webpack.config.js 添加如下内容即可:

1
2
3
4
5
6
7
{
test: /\.(ttf|woff2?)$/,
type: "asset/resource",
generator: {
filename: "font/[name].[hash:6][ext]",
},
},

plugin VS loader

  • loader:webpack 只认识 js 和 json 文件,为了让 webpack 认识其它文件,如 css、jpg、png 等等,需要将其它类型文件转换为 js 格式,让 webpack 认识,这个转换的作用就是 loader 提供的。
  • plugin:plugin 可以做更多的事情,比如在打包开始之前做一些预处理,或者打包进行过程中做一些处理。loader 的作用时机只有当 webpack 要读取某个文件的时候,但是 plugin 的作用时机很多。

clean-webpack-plugin

安装 clean-webpack-plugin

1
yarn add clean-webpack-plugin -D

作用是每次打包之前先把 dist 目录删除。

修改 webpack.config.js 内容:

在现版本的 webpack 中,已经不需要再加入此插件了,直接设置 output 的 clean 属性为 true 即可。

html-webpack-plugin

安装 html-webpack-plugin

1
yarn add html-webpack-plugin -D

引入 HtmlWebpackPlugin 类

1
const HtmlWebpackPlugin = require("html-webpack-plugin");

引入插件:

1
2
3
new HtmlWebpackPlugin({
title: "Webpack Learning",
}),

这里打包以后产出的 js 文件是 defer 引入的。但是这样很不灵活,其实我们可以自己书写打印出的模板。

在 src 目录下新建文件夹 public。public 下新建文件 index.html。我们把 vue-cli 的 index.html 拷贝过来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html lang="">

<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">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>
<%= htmlWebpackPlugin.options.title %>
</title>
</head>

<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled.
Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>

</html>

打包以后报错说找不到 BASE_URL。webpack 可以自定义一些常量,我们在这还没有定义所以会报错。这里用到的是 webpack 内置的一个插件。

1
2
3
4
5
const { DefinePlugin } = require("webpack");

new DefinePlugin({
BASE_URL: '"./"', // 必须包裹两层引号,否则会在转译的时候以 const a = ./ 的形式出现,导致出错
}),

babel

Babel 的作用:JSX TS ES6+ => 转换为浏览器可以直接使用的语法

将 js/foo.js 内容修改如下:

1
2
3
4
5
const foo = () => {
console.log("hello babel");
};

export default foo;

修改 index.js 如下:

1
2
3
import foo from "./js/foo";

foo();

打包后,main.js 内容含有箭头函数,可能在某些浏览器无法正常显示。我们需要使用 babel 处理。

babel-loader

安装 @babel/core(核心模块)

1
yarn add @babel/core -D

安装 babel-loader

1
yarn add babel-loader -D

安装插件 @babel/preset-env -D

1
yarn add @babel/preset-env -D

修改配置文件如下:

1
2
3
4
5
6
7
8
9
10
11
{
test: /\.js$/,
use: [
{
loader: "babel-loader",
options: {
presets: ["@babel/preset-env"],
},
},
],
},

然后就可以把语法转成 ES5 了。

还可以新建文件 babel.config.js,里面写入一下配置内容:

1
2
3
module.exports = {
presets: ["@babel/preset-env"],
};

然后就能简写 webpack.config.js 的信息为:

1
2
3
4
{
test: /\.js$/,
use: ["babel-loader"],
},

polyfill

preset-env 并不能把所有的语法都转换,此时我们需要 polyfill。polyfill 即字面意思,填充,意思是填充一些旧版本没有的新语法(比如 Promise)。webpack4 会默认加入 polyfill,所以打包速度很不乐观,在 webpack5 就去掉了。

修改 index.js 内容为:

1
2
3
4
const p1 = new Promise((resolve, reject) => {
console.log("promsie");
resolve();
});

安装 core-js、regenerator-runtime,注意此处不是开发依赖。

1
yarn add core-js regenerator-runtime

然后修改 babel.config.js,内容如下:

1
2
3
4
5
6
7
8
9
10
11
module.exports = {
presets: [
[
"@babel/preset-env",
{
useBuiltIns: "entry",
corejs: 3,
},
],
],
};

在 index.js 中引入核心模块。

1
2
3
4
5
6
7
import "core-js/stable";
import "regenerator-runtime/runtime";

const p1 = new Promise((resolve, reject) => {
console.log("promsie");
resolve();
});

copy-webpack-plugin

可以进行一些资源的拷贝,如 favicon 图标。

安装 copy-webpack-plugin。

1
yarn add copy-webpack-plugin -D

引入插件:

1
const CopyWebpackPlugin = require("copy-webpack-plugin");

修改 webpack.config.js:

1
2
3
4
5
6
7
8
9
10
new CopyWebpackPlugin({
patterns: [
{
from: "public",
globOptions: {
ignore: ["**/index.html"],
},
},
],
}),

修改 index.js 内容:

1
2
import "./js/font";
import "./js/image";

webpack-dev-server

在 webpack.config.js 中添加 watch: true,如下所示。

当文件修改以后,会自动触发打包。但是这样会影响速度,因为每次保存都要重新打包一次。我们可以在本地开启一个服务器,把文件存储在内存中。

安装 webpack-dev-server。

1
yarn add webpack-dev-server -D

在 package.json 中配置命令:

1
"start": "npx webpack serve"

运行命令

1
yarn start

这样就可以实现开启本地服务器。

HMR

HMR 即 hot-module-replacement,模块热替换,也叫热更新,就是可以对页面的局部内容进行替换。

首先修改 index.js 内容为

1
console.log("HMR");

修改 public 文件夹下的 index.html,增加一个输入框,用于验证热替换是否开启。

1
2
3
4
5
6
7
8
9
10
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled.
Please enable it to continue.</strong>
</noscript>
<div id="app">
<input type="text">
</div>
<!-- built files will be auto injected -->
</body>

webpack.config.js 中加入内容:

1
2
3
4
devServer: {
hot: true,
port: 3000
},

此时事实上还没有开启热更新。还需要把热更新的选项加入配置中。因为开发中通常是使用框架进行,所以这部分意义不大,省略。

React HMR

安装 @babel/preset-react,这是一个用于转换 jsx 语法的插件。

1
yarn add @babel/preset-react -D

安装 react。

1
yarn add react react-dom

src 目录下新建文件 App.jsx,内容如下:

1
2
3
4
5
6
7
8
9
import React, { useState } from "react";
import "./App.css";

const App = () => {
const [title, setTitle] = useState("Hello World");
return <h1 className="title">{title}</h1>;
};

export default App;

修改 index.js,内容如下:

1
2
3
4
5
import App from "./App.jsx";
import React from "react";
import ReactDOM from "react-dom";

ReactDOM.render(<App />, document.getElementById("app"));

修改 babel.config.js,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
module.exports = {
presets: [
[
"@babel/preset-env",
{
useBuiltIns: "entry",
corejs: 3,
},
],
["@babel/preset-react"],
],
};

此时可以支持 React 语法,为了实现热更新,还需要引入其它插件。

安装 @pmmmwh/react-refresh-webpack-plugin 和 react-refresh

1
yarn add @pmmmwh/react-refresh-webpack-plugin react-refresh -D

在 webpack.config.js 中引入插件:

1
const ReactRefreshWebpackPlugin = require("@pmmmwh/react-refresh-webpack-plugin");
1
new ReactRefreshWebpackPlugin(),

修改 babel.config.js,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
module.exports = {
presets: [
[
"@babel/preset-env",
{
useBuiltIns: "entry",
corejs: 3,
},
],
["@babel/preset-react"],
],
plugins: [["react-refresh/babel"]],
};

此时就能实现 React 的热更新了。

devServer 其它属性设置

1
2
3
4
5
6
7
devServer: {
hot: true,
port: 3000,
compress: true,
open: true,
historyApiFallback: true,
},

compress 表示是否压缩资源(使用 gzip),open 表示是否自动打开文件,当使用 History API 时,任意的 404 响应会被替代为 index.html。

hot: ‘only’hot: true 的区别:
如果文件报错了,修改成了对的以后,hot: true 会直接刷新整个页面,而 hot: ‘only’ 不会刷新整个页面。

proxy

先开启一个 node 服务。

1
2
3
4
5
6
7
8
9
10
11
12
// 服务端 http://127.0.0.1:8000
const http = require("http");

const port = 8000;

http
.createServer((req, res) => {
res.end(JSON.stringify("hello world"));
})
.listen(port, function () {
console.log("server is listening on port " + port);
});

安装 axios

1
yarn add axios

修改 index.js 内容为:

1
2
3
4
5
6
7
8
9
10
import App from "./App.jsx";
import React from "react";
import ReactDOM from "react-dom";
import axios from "axios";

axios.get("http://127.0.0.1:8000/").then((res) => {
console.log(res.data);
});

ReactDOM.render(<App />, document.getElementById("app"));

此时请求出现跨域,需要设置代理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
devServer: {
hot: true,
port: 3000,
compress: true,
open: true,
proxy: {
"/api": {
target: "http://127.0.0.1:8000/",
// 如果请求路径是 http://127.0.0.1:8000/api/user 这种就不需要重写
// 如果请求路径是 http://127.0.0.1:8000/user 这种就需要重写
pathRewrite: { "^api": "" },
},
},
},

前端更改为:

1
2
3
axios.get("http://127.0.0.1:8000/").then((res) => {
console.log(res.data);
});

此时跨域问题得到解决。

source-map

devtool 选项控制是否需要 source-map。

source-map 是一种映射方式,可以在调试的时候定位到源代码中的位置。

设置 devtool: 'source-map',运行 yarn build,dist 目录下除了原来的文件外还会多出一个 main.js.map,然后也可以定位到错误的具体位置了。

devtool

通过 devtool 还可以进行更多的配置。

  • source-map:错误信息有行也有列,推荐使用
  • inline-source-map:直接把 map 信息塞入 main.js 中,可以减少一次请求
  • cheap-source-map:错误信息只显示行,不显示列

ts-loader 编译 ts

src 目录下新建文件 index.ts,内容如下:

1
2
3
4
5
const add = (a: number, b: number) => {
return a + b;
};

console.log(add(1, 3));

修改 webpack.config.js 文件中的 entry 为 “./src/index.ts”

执行命令

1
tsc --init

生成 tsconfig.json 文件。

安装 ts-loader。

1
yarn add ts-loader -D

安装 typescript。

1
yarn add typescript -D

修改 webpack.config.js 文件内容:

1
2
3
4
{
test: /\.ts$/,
use: ["ts-loader"],
},

babel-loader 编译 ts

此时 ts 已经可以被正确编译,但是一些新的语法没有被转换为低级的语法,可能会存在兼容性问题,所以还需要 babel-loader 进行进一步的处理。

安装 @babel/preset-typescript

1
yarn add @babel/preset-typescript -D

修改 babel.config.js,内容为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
module.exports = {
presets: [
[
"@babel/preset-env",
{
useBuiltIns: "entry",
corejs: 3,
},
],
["@babel/preset-react"],
["@babel/preset-typescript"],
],
plugins: [["react-refresh/babel"]],
};

ts-loader 如果出现数据类型错误,会在 build 的时候暴露;而 babel-loader 可以进行 polyfill。

我们可以选择使用 babel-loader,然后通过命令进行类型校验。输入命令 tsc,可以实现数据类型的校验。

也可以选择直接修改打包命令如下:

分离生产和开发环境

修改 package.json 中的脚本命令:

在根目录下新建文件夹 config,然后在 config 文件夹下新建 3 个文件 webpack.common.js、webpack.dev.js、webpack.prod.js。

安装 webpack-merge,来进行文件合并。

1
yarn add webpack-merge -D

文件内容如下:

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
// webpack.common.js
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { DefinePlugin } = require("webpack");
const CopyWebpackPlugin = require("copy-webpack-plugin");

module.exports = {
entry: "./src/index.js",
resolve: {
extensions: [".tsx", ".ts", ".jsx", ".js"],
},
module: {
rules: [
{
test: /\.css$/,
use: [
"style-loader",
{
loader: "css-loader",
options: {
esModule: false,
},
},
"postcss-loader",
],
},
{
test: /\.ts$/,
use: ["babel-loader"],
},
{
test: /\.less$/,
use: ["style-loader", "css-loader", "less-loader"],
},
{
test: /\.(png|svg|gif|jpe?g)$/,
type: "asset",
generator: {
filename: "img/[name].[hash:6][ext]",
},
parser: {
dataUrlCondition: {
maxSize: 25 * 1024,
},
},
},
{
test: /\.(ttf|woff2?)$/,
type: "asset/resource",
generator: {
filename: "font/[name].[hash:6][ext]",
},
},
{
test: /\.js$/,
use: ["babel-loader"],
},
{
test: /\.jsx$/,
use: ["babel-loader"],
},
],
},
plugins: [
new HtmlWebpackPlugin({
title: "Webpack Learning",
template: "./public/index.html",
}),
new DefinePlugin({
BASE_URL: '"./"',
}),
new CopyWebpackPlugin({
patterns: [
{
from: "public",
globOptions: {
ignore: ["**/index.html"],
},
},
],
}),
],
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// webpack.dev.js
const ReactRefreshWebpackPlugin = require("@pmmmwh/react-refresh-webpack-plugin");
const { merge } = require("webpack-merge");
const common = require("./webpack.common");

module.exports = merge(common, {
mode: "development",
devtool: "source-map",
devServer: {
hot: true,
port: 3000,
compress: true,
open: true,
},
plugins: [new ReactRefreshWebpackPlugin()],
});
1
2
3
4
5
6
7
8
9
10
11
12
13
// webpack.prod.js
const path = require("path");
const { merge } = require("webpack-merge");
const common = require("./webpack.common");

module.exports = merge(common, {
mode: "production",
output: {
filename: "[name].js",
path: path.join(__dirname, "../dist"),
clean: true,
},
});

修改 babel.config.js,区分生产和开发环境:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const isDev = process.env.NODE_ENV !== "production";

const config = {
presets: [
[
"@babel/preset-env",
{
useBuiltIns: "entry",
corejs: 3,
},
],
["@babel/preset-react"],
["@babel/preset-typescript"],
],
};

if (isDev) {
config.plugins = [["react-refresh/babel"]];
}

module.exports = config;

初始化

先初始化项目

1
yarn init -y

运行命令初始化 package.json 文件。

安装 webpack 和 webpack-cli。

1
yarn add webpack webpack-cli -D

进行 ts 配置

1
tsc --init

修改 tsconfig.json,使用 create-react-app 的设置:

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
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"baseUrl": "./src",
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}

在根目录下新建文件夹 config,书写配置信息。

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
// config/webpack.common.js
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { DefinePlugin } = require("webpack");
const CopyWebpackPlugin = require("copy-webpack-plugin");

module.exports = {
entry: "./src/index.tsx",
resolve: {
extensions: [".tsx", ".ts", ".jsx", ".js"],
},
module: {
rules: [
{
test: /\.css$/i,
use: [
"style-loader",
{
loader: "css-loader",
options: {
esModule: false,
},
},
"postcss-loader",
],
},
{
test: /\.s[ac]ss$/i,
use: [
"style-loader",
{
loader: "css-loader",
options: {
esModule: false,
},
},
"postcss-loader",
"sass-loader",
],
},
{
test: /\.tsx?$/i,
use: ["babel-loader"],
exclude: /node_modules/,
},
{
test: /\.(png|svg|gif|jpe?g)$/i,
type: "asset",
generator: {
filename: "img/[name].[hash:6][ext]",
},
parser: {
dataUrlCondition: {
maxSize: 25 * 1024,
},
},
},
{
test: /\.(ttf|woff2?)$/i,
type: "asset/resource",
generator: {
filename: "font/[name].[hash:6][ext]",
},
},
{
test: /\.jsx?$/i,
use: ["babel-loader"],
exclude: /node_modules/,
},
],
},
plugins: [
new HtmlWebpackPlugin({
title: "Webpack Learning",
template: "./public/index.html",
}),
new DefinePlugin({
BASE_URL: '"./"',
}),
new CopyWebpackPlugin({
patterns: [
{
from: "public",
globOptions: {
ignore: ["**/index.html"],
},
},
],
}),
],
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// config/webpack.dev.js
const ReactRefreshWebpackPlugin = require("@pmmmwh/react-refresh-webpack-plugin");
const { merge } = require("webpack-merge");
const common = require("./webpack.common");

module.exports = merge(common, {
mode: "development",
devtool: "source-map",
devServer: {
hot: true,
port: 3000,
compress: true,
open: true,
},
plugins: [new ReactRefreshWebpackPlugin()],
});
1
2
3
4
5
6
7
8
9
10
11
12
13
// config/webpack.prod.js
const path = require("path");
const { merge } = require("webpack-merge");
const common = require("./webpack.common");

module.exports = merge(common, {
mode: "production",
output: {
filename: "[name].js",
path: path.join(__dirname, "../dist"),
clean: true,
},
});

安装 react

1
yarn add react react-dom

安装 ts 依赖

1
yarn add @types/react @types/react-dom -D

安装 babel 相关依赖

1
yarn add babel-loader @babel/core @babel/preset-env @babel/plugin-transform-runtime @babel/preset-react @babel/preset-typescript -D

根目录新建文件 babel.config.js,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const isDev = process.env.NODE_ENV !== "production";

const config = {
presets: [
[
"@babel/preset-env",
{
useBuiltIns: "entry",
corejs: 3,
},
],
["@babel/preset-react"],
["@babel/preset-typescript"],
],
};

if (isDev) {
config.plugins = [["react-refresh/babel"]];
}

module.exports = config;

安装 loader

1
yarn add sass sass-loader css-loader postcss-loader postcss style-loader babel-loader -D

安装 plugin

1
yarn add @pmmmwh/react-refresh-webpack-plugin react-refresh copy-webpack-plugin html-webpack-plugin -D 

安装 postcss 依赖

1
yarn add postcss-preset-env autoprefixer -D

新建文件 postcss.config.js:

1
2
3
module.exports = {
plugins: [require("autoprefixer"), require("postcss-preset-env")],
};

添加 browserslist

给 package.json 添加 browserslist。

1
2
3
4
5
"browserslist": [
">1%",
"last 2 version",
"not dead"
]

安装 webpack-dev-server

1
yarn add webpack-dev-server -D

安装 webpack-merge

1
yarn add webpack-merge -D

安装 cross-env

1
yarn add cross-env -D

配置脚本命令

1
2
3
4
"scripts": {
"build": "cross-env NODE_ENV=production npx webpack --config ./config/webpack.prod.js",
"start": "cross-env NODE_ENV=development npx webpack serve --config ./config/webpack.dev.js"
}

本文代码对应仓库地址

本文主要包括以下内容配置

  • creat-react-app 创建项目
  • 配置 redux
  • 配置 react-router
  • 配置 json-server
  • 配置 axios

基本配置

首先按照这篇文章完成项目创建和基本配置,你也可以使用 webpack 进行基础配置。

把 src 文件夹下的 App.css、index.css、logo.svg 删除。

配置 styled-components / Sass / Less

1
yarn add styled-components

如果报错找不到类型文件,就执行以下命令。

1
yarn add @types/styled-components -D

修改默认样式

在根目录新建文件 globalStyle.ts,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { createGlobalStyle } from "styled-components";

export const GlobalStyle = createGlobalStyle`
html, body {
// 根据需要配置 background、line-height、font 等
}

* {
margin: 0;
padding: 0;
}

a {
text-decoration: none;
}

ul, li {
list-style: none;
}
`;

配置 react-router

安装 react-router,进行路径配置。(这里以 V6 为准)

1
yarn add react-router-dom

在 src 文件夹下新建文件夹 route,创建 index.tsx 文件。内容如下:

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
import { Routes, Route } from "react-router-dom";
import { Suspense, lazy } from "react";
import TopBar from "pages/TopBar";
import Home from "pages/Home";

const ReduxExample = lazy(() => import("pages/ReduxExample"));
const RequestExample = lazy(() => import("pages/RequestExample"));

const MyRouter = () => {
return (
<Routes>
<Route path="/" element={<TopBar />}>
<Route index element={<Home />} />
<Route path="home" element={<Home />}></Route>
<Route
path="redux"
element={
<Suspense fallback={<>Loading...</>}>
<ReduxExample />
</Suspense>
}
></Route>
<Route
path="request"
element={
<Suspense fallback={<>Loading...</>}>
<RequestExample />
</Suspense>
}
></Route>
<Route
path="*"
element={
<main style={{ padding: "1rem" }}>
<p>404 Not Found</p>
</main>
}
/>
</Route>
</Routes>
);
};

export default MyRouter;

在 src 目录下的 index.tsx 中引入 BrowserRouter

1
import { BrowserRouter } from "react-router-dom";

代码修改为

1
2
3
4
5
6
7
8
ReactDOM.render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
document.getElementById("root")
);

修改 src 目录下的 APP.tsx,引入默认样式以及路由配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
import MyRouter from "routes";
import { GlobalStyle } from "./globalStyle";

function App() {
return (
<>
<GlobalStyle></GlobalStyle>
<MyRouter />
</>
);
}

export default App;

在 src 文件夹下新建文件夹 pages,用于存储页面。

文件夹 pages 下新建文件夹 RequestExample、ReduxExample、TopBar、Home,文件内容如下。

1
2
3
4
5
6
7
8
// ReduxExample/index.tsx
import { memo } from "react";

const ReduxExample = () => {
return <h1>ReduxExample</h1>;
};

export default memo(ReduxExample);
1
2
3
4
5
6
7
8
// RequestExample/index.tsx
import { memo } from "react";

const RequestExample = () => {
return <h1>RequestExample</h1>;
};

export default memo(RequestExample);
1
2
3
4
5
6
7
8
// Home/index.tsx
import { memo } from "react";

const Home = () => {
return <h1>HomePage</h1>;
};

export default memo(Home);
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
// TopBar/index.tsx
import { memo, useEffect } from "react";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { NavBar, TopbarContainer } from "./style";

const TopBar = () => {
const { pathname } = useLocation();
const navigate = useNavigate();

useEffect(() => {
if (pathname === "/") {
navigate("/home");
}
}, [pathname, navigate]);

return (
<TopbarContainer>
<div>TopBar</div>
<NavBar>
<NavLink
to="/home"
className={({ isActive }) => (isActive ? "selected" : "unselected")}
>
Home Page
</NavLink>
<NavLink
to="/redux"
className={({ isActive }) => (isActive ? "selected" : "unselected")}
>
Redux Example
</NavLink>
<NavLink
to="/request"
className={({ isActive }) => (isActive ? "selected" : "unselected")}
>
Request Example
</NavLink>
</NavBar>
<Outlet />
</TopbarContainer>
);
};

export default memo(TopBar);

上面这段代码的 <Outlet /> 是为了能够渲染下一级的路由。因为目前 react-router-dom@6 在 ts 环境下不支持重定向,所以要先用 useEffect 强制重定向。

给 TopBar 加上一些简单的样式,方便我们查看。

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
// TopBar/style.ts
import styled from "styled-components";

export const TopbarContainer = styled.div`
.selected {
color: red;
text-decoration: underline;
cursor: pointer;
}

.unselected {
color: black;
cursor: default;
&:hover {
text-decoration: underline;
cursor: pointer;
}
}
`;

export const NavBar = styled.div`
display: flex;
flex-direction: column;
gap: 10px;
margin: 20px 0;
border: 1px solid black;
width: 100px;
`;

配置 redux

首先安装依赖,这里处理异步使用的是 redux-thunk。

1
yarn add redux redux-thunk react-redux immer

在 src 文件夹下新建文件夹 store,再在 store 文件夹中新建文件 index.ts 和 reducer.ts。内容如下:

1
2
3
4
5
6
7
8
9
10
11
// reducer.ts
import { combineReducers } from "redux";
import { reducer as reduxExampleReducer } from "../pages/ReduxExample/store/";

export interface RootState {
reduxExample: reduxExampleReducer.state;
}

export default combineReducers({
reduxExample: reduxExampleReducer.reducer,
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// index.ts
import { createStore, compose, applyMiddleware } from "redux";
import thunk from "redux-thunk";
import reducer from "./reducer";

type windowWithReduxExtension = Window &
typeof globalThis & {
__REDUX_DEVTOOLS_EXTENSION_COMPOSE__?: Function;
};

const composeEnhancers =
(window as windowWithReduxExtension).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ||
compose;

const store = createStore(reducer, composeEnhancers(applyMiddleware(thunk)));

export default store;

在 App.tsx 中注入 store。

1
2
3
4
5
6
7
8
9
10
11
12
import { Provider } from "react-redux";

function App() {
return (
<Provider store={store}>
<GlobalStyle></GlobalStyle>
<MyRouter />
</Provider>
);
}

export default App;

为了验证 Redux 配置是否正确,我们使用经典的 Counter 来验证。

在 src 下创建文件夹 components,再在 components 文件夹下新建文件夹 Counter。

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
// Counter/index.tsx
import { memo } from "react";

interface CounterProps {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
incrementAsync: () => void;
}

const Counter = ({
count,
increment,
decrement,
reset,
incrementAsync,
}: CounterProps) => {
return (
<>
<div>Count: {count}</div>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
<button onClick={reset}>Reset</button>
<button onClick={incrementAsync}>+1 (1s delay)</button>
</>
);
};

export default memo(Counter);

在 ReduxExample 目录下创建文件夹 store,在 store 文件夹下新建文件 index.ts、store.ts、constants.ts、actions.ts。

1
2
3
4
5
6
7
8
// store/constants.ts
export const INCREMENT = "INCREMENT";

export const DECREMENT = "DECREMENT";

export const RESET = "RESET";

export const INCREMENT_ASYNC = "INCREMENT_ASYNC";
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// store/actions.ts
import * as actionTypes from "./constants";
import { Dispatch } from "redux";

export const increment = () => ({ type: actionTypes.INCREMENT });

export const decrement = () => ({ type: actionTypes.DECREMENT });

export const reset = () => ({ type: actionTypes.RESET });

export const incrementAsync = () => (dispatch: Dispatch) => {
setTimeout(() => {
dispatch({
type: actionTypes.INCREMENT_ASYNC,
});
}, 1000);
};
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
// store/reducer.ts
import * as actionTypes from "./constants";
import { AnyAction } from "redux";
import { produce } from "immer";

export interface CounterState {
count: number;
}

const defaultState: CounterState = {
count: 0,
};

export const reduxExampleReducer = produce(
(state: CounterState, action: AnyAction) => {
switch (action.type) {
case actionTypes.INCREMENT:
case actionTypes.INCREMENT_ASYNC:
state.count = state.count + 1;
break;
case actionTypes.DECREMENT:
state.count = state.count - 1;
break;
case actionTypes.RESET:
state.count = 0;
break;
default:
break;
}
},
defaultState
);
1
2
3
4
5
6
// store/index.ts
import * as reducer from "./reducer";
import * as actions from "./actions";
import * as constants from "./constants";

export { reducer, actions, constants };

修改 ReduxExample,进行测试。

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
import Counter from "components/Counter";
import { memo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "store/reducer";
import { actions } from "./store";

const ReduxExample = () => {
const { count } = useSelector((state: RootState) => ({
count: state.reduxExample.count,
}));

const dispatch = useDispatch();

const increment = () => {
dispatch(actions.increment());
};

const decrement = () => {
dispatch(actions.decrement());
};

const reset = () => {
dispatch(actions.reset());
};

const incrementAsync = () => {
dispatch(actions.incrementAsync());
};

return (
<>
<h1>ReduxExample</h1>
<Counter
count={count}
increment={increment}
decrement={decrement}
reset={reset}
incrementAsync={incrementAsync}
/>
</>
);
};

export default memo(ReduxExample);

配置 json-server

json-server 是我认为的一种比较不错的 mock 数据方法。

如果还没有安装的话先全局一下安装 json-server。

1
npm install -g json-server

在根目录下新建文件夹 __mock__,然后在文件夹下新建文件 db.json。文件内容为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"subjects": {
"list": [
{
"id": 1,
"title": "html"
},
{
"id": 2,
"title": "css"
},
{
"id": 3,
"title": "js"
}
],
"code": 200
}
}

因为 3000 端口已经被我们的页面占用了,所以我们要换一个端口运行命令启动 json-server。为了便捷我们可以在 package.json 中加入新的脚本命令。

配置完成后,运行命令

1
yarn server

即可在 http://localhost:3001 开启 json-server。

在浏览器输入路径 http://localhost:3001/subjects ,可以看到 db.json 中的数据。

配置 axios

执行命令安装 axios。

1
yarn add axios

在 src 目录下新建文件夹 api,并在 api 文件夹下新建文件 config.ts、request.ts。

1
2
3
4
5
6
7
8
9
10
// api/config.ts
import axios from "axios";

export const baseUrl = "http://localhost:3001";

const axiosInstance = axios.create({
baseURL: baseUrl,
});

export { axiosInstance };
1
2
3
4
5
import { axiosInstance } from "./config";

export const getSubjectsRequest = <T=any>() => {
return axiosInstance.get<T>("/subjects");
};

编写组件 SubjectList 来验证 axios 配置。

在 components 文件夹下新建文件夹 SubjectList。

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
// SubjectList.tsx
import { memo } from "react";

interface Subject {
id: number;
title: string;
}

interface SubjectListProps {
list: Subject[];
}

const SubJectList = ({ list }: SubjectListProps) => {
return (
<ul>
{list.map((item) => (
<li key={item.id}>
{item.id} - {item.title}
</li>
))}
</ul>
);
};

export default memo(SubJectList);

在 SubjectList 文件夹下新建 store 文件夹。

1
2
// store/constants.ts
export const CHANGE_SUBJECTS = "CHANGE_SUBJECTS";
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
// store/reducer.ts
import * as actionTypes from "./constants";
import { AnyAction } from "redux";
import produce from "immer";

export interface Subject {
id: number;
title: string;
}

export interface RequestExampleState {
subjectList: Subject[];
}

const defaultState: RequestExampleState = {
subjectList: [],
};

export const requestExampleReducer = produce(
(state: RequestExampleState, action: AnyAction) => {
switch (action.type) {
case actionTypes.CHANGE_SUBJECTS:
state.subjectList = action.data;
break;
default:
break;
}
},
defaultState
);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// store/actions.ts
import * as actionTypes from "./constants";
import { getSubjectsRequest } from "api/request";
import { Dispatch } from "redux";
import { RequestExampleState } from "./reducer";

export const changeSubjectList = (data: RequestExampleState) => ({
type: actionTypes.CHANGE_SUBJECTS,
data,
});

export const getSubjectList = () => (dispatch: Dispatch) => {
getSubjectsRequest<{ list: RequestExampleState }>()
.then(({ data }) => {
const action = changeSubjectList(data.list);
dispatch(action);
})
.catch(() => {
console.log("subjects 传输错误");
});
};
1
2
3
4
5
6
// store/index.ts
import * as reducer from "./reducer";
import * as actions from "./actions";
import * as constants from "./constants";

export { reducer, actions, constants };

然后把 reducer 导入全局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/store/reducer.ts
import { combineReducers } from "redux";
import { reducer as reduxExampleReducer } from "pages/ReduxExample/store/";
import { reducer as requestExampleReducer } from "pages/RequestExample/store";

export interface RootState {
requestExample: requestExampleReducer.RequestExampleState;
reduxExample: reduxExampleReducer.CounterState;
}

export default combineReducers({
reduxExample: reduxExampleReducer.reduxExampleReducer,
requestExample: requestExampleReducer.requestExampleReducer,
});

修改 RequestExample/index.tsx,内容为:

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
import SubjectList from "components/SubjectList";
import { memo, useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "store/reducer";
import { actions } from "./store";

const RequestExample = () => {
const { list } = useSelector((state: RootState) => ({
list: state.requestExample.subjectList,
}));

const dispatch = useDispatch();

const getSubjectList = () => {
dispatch(actions.getSubjectList());
};

useEffect(() => {
if (!list.length) {
getSubjectList();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return (
<>
<h1>RequestExample</h1>
<SubjectList list={list} />
</>
);
};

export default memo(RequestExample);

完成任务!

XSS

Cross-Site Scripting,跨站脚本攻击。指攻击者通过某种方式把恶意脚本注入你写的页面。

产生原因

  • 开发者盲目相信用户提交的内容
  • 直接把用户的提交转换成了 DOM,如 document.write(xxx)elem.innerHTML = xxx

特点

  • 通常难以从 UI 上感知
  • 窃取用户信息(cookie / token)
  • 因为可以操作 js,所以可以绘制 UI,诱骗用户填写表单

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
async function submit(ctx) {
const { content, id } = ctx.request.body;
// 没有对 content 进行过滤
await db.save({
content,
id
});
}

async function render(ctx) {
const { content } = await db.query({
id: ctx.query.id
});
// 又没有对 content 进行过滤
ctx.body = `<div>${content}</div>`;
}

导致攻击:

Stored XSS 存储型

  • 恶意脚本被保存在数据库中
  • 访问页面 -> 读数据 -> 被攻击
  • 危害最大,对全部用户可见

比如说一个用户在视频中插入一个 XSS 攻击,然后某一刻脚本被启动了,此时所有的在浏览这个页面的用户都会被脚本攻击,造成信息泄露。

Reflected XSS 反射型

不涉及数据库,而是从 URL 进行攻击

把字段直接生成 HTML 字段,然后被成功攻击

Dom-based XSS

不需要服务器参与,恶意攻击的发起与执行都在浏览器完成。

Mutation XSS

利用了浏览器渲染 DOM 的特性,对于不同的浏览器执行会有区别。

代码会被渲染为:

又由于 src 属性不符合规范,然后会触发 onerror 事件,也就完成了 XSS 攻击。

CSRF

Cross-site request forgery,跨站请求伪造

特点

  • 用户不知情
  • 利用用户权限
  • 构造指定的 HTTP 请求,窃取或修改用户的敏感信息

示例

在这个例子中,用户并没有直接请求银行,但是这个请求却被成功执行了,这就是一个经典的 CSRF 攻击。

Injection 注入

SQL Injection

删库跑路示例:

其余注入:

  • CLI

因为没有过滤导致成功删除跑路

  • 读取 + 修改

以 Nginx 为例,如果用户可以读取 Nginx 的配置文件,就能把我们的网站转到另一个网站

  • SSRF Server-Side Request Forgery(严格来说不算注入)

DOS

Denial of Service,服务拒绝。通过某种方式构造特定的请求,导致服务器资源被显著消耗,来不及响应更多的请求,导致请求积压,进而引发雪崩效应。

Regex DOS

正则贪婪模式

书写正则的时候是否写 ?

这里的第一行就是贪婪模式

因为贪婪引发回溯

DDOS 分布式拒绝服务

短时间内,收到大量来自僵尸设备的请求流量,服务器不能及时完成全部的请求,导致请求堆积,进而引发雪崩效应,无法响应新的请求。

不搞复杂的,量大就完事儿

攻击特点

  • 直接访问 IP 而不是域名
  • 使用任意的 API
  • 消耗掉大量的带宽,直至耗尽

洪水攻击

攻击者发起大量的 TCP 请求,然后就会产生大量的 SYN,发送给服务器。然后服务器就会产生大量的 ACK 和 SYN 给攻击者。但是,攻击者不会返回第三次 ACK,进而导致三次握手失败,连接无法被释放,于是很快就会到达最大连接次数,所有的新请求就无法被响应。

中间人攻击

为什么中间人攻击可以成立?

  • HTTP 报文使用明文方式发送,可能被第三方窃听。
  • HTTP 报文可能被第三方拦截后修改通信内容,接收方没有办法发现报文内容的修改。
  • HTTP 还存在认证的问题,第三方可以冒充他人参与通信。

XSS 防御

  • 永远不要信任用户提交的任何内容
  • 永远不要把用户提交的内容直接转换成 DOM,而应该转换成字符串
  • 主流的框架(React & Vue)其实默认会防御 XSS 攻击

如果有需求不讲武德,必须动态生成 DOM 呢?

  • 如果要把 string 直接生成 DOM,必须要对 string 进行转义
  • 如果允许上传 SVG 文件,需要对 SVG 文件进行扫描,因为 SVG 中允许嵌套 script 标签
  • 如果允许用户自定义跳转链接,必须进行检查过滤

    像上图这样,用户可以插入 js 代码
  • 如果允许自定义样式,必须进行检查过滤

CSP

Content Security Policy

  • 允许开发者定义哪些源(域名)是安全的
  • 来自安全源的脚本可以执行,否则直接报错
  • 对于 eval 或内联的脚本直接报错

第一行:只允许同源;第二行:除了同源之外,还另外允了 domain.com

我们也可以在浏览器端进行设置:

CSRF 防御

只要我们限制请求的来源,就可以限制伪造请求。可以根据 Origin 或者 Referer 判断。

token 防御机制

  • token 必须和具体的用户绑定,才能确保不会被其它的用户所利用。
  • token 必须有过期时间,否则万一 token 泄露,之前的所有请求都可以被利用

iframe 攻击

首先在视觉上,让 button 覆盖住 iframe,然后用户就看不出来了。接着通过 button 的设置导致点击事件穿透了,然后传递给了 iframe,iframe 中的请求没有跨域,因此可以完成攻击。

可以设置 HTTP 响应头 X-Frame-Options: DENY/SAMEORIGIN (不允许加载 iframe 或只允许加载同源的 iframe)

CSRF 反模式

GET !== GET + POST

一旦被攻击,信息不单止可能泄露,甚至还会被篡改。

限制 cookie 的 domain 属性。我页面的 cookie 只能为我所用,只有同域才能使用这个 cookie,第三方服务的请求不能带上我页面的 cookie。

但是如果服务依赖于第三方的 cookie 怎么办?

比如内嵌了一个 b 站的播放器,需要识别用户的登录状态。

可以设置 SetCookie: SameSite=None; Secure;

即不限制 same site,但是必须确保 cookie 是安全的(只能通过 HTTPS 传输)。

SameSite VS 同源策略:SameSite 主要针对 cookie,同源策略 针对的是请求的资源。

防御 CSRF 的正确姿势

用 Node 做一个中间件防范攻击。

Injection 防御

SQL 注入

对 SQL 语句做一些 prepare 处理

其余注入

  • 命令不要通过 sudo 执行,不要给 root 权限
  • 拒绝像 rm 这种极其危险的行为
  • 对 URL 类型参数进行协议、域名、IP 等的限制

DOS 防御

Regex DOS

  • 避免写出贪婪的正则匹配
  • 扫描代码找出里面的所有正则,然后做正则性能测试
  • 拒绝使用用户提供的正则

DDOS

过滤:

  • 负载均衡
  • API 网关

抗量:

  • 快速自动扩容
  • 非核心服务降级

传输层防御

使用 HTTPS,其中 HTTP3 内置了 TLS

HSTS

HTTP Strict Transport Security,HTTO 严格传输安全协议。

HSTS 的作用是强制客户端(如浏览器)使用 HTTPS 与服务器创建连接。服务器开启 HSTS 的方法是,当客户端通过 HTTPS 发出请求时,在服务器返回的超文本传输协议(HTTP)响应头中包含 Strict-Transport-Security 字段。非加密传输时设置的 HSTS 字段无效。
比如,https://example.com/ 的响应头含有 Strict-Transport-Security: max-age=31536000; includeSubDomains。这意味着两点:

  • 在接下来的 31536000 秒(即一年)中,浏览器向 example.com 或其子域名发送 HTTP 请求时,必须采用 HTTPS 来发起连接。比如,用户点击超链接或在地址栏输入 http://www.example.com/ ,浏览器应当自动将 http 转写成 https,然后直接向 https://www.example.com/ 发送请求。
  • 在接下来的一年中,如果 example.com 服务器发送的 TLS 证书无效,用户不能忽略浏览器警告继续访问网站。

SRI

Subresource Integrity,子资源完整性。

Web 性能优化中很重要的一点是加快请求完成速度,让可缓存的资源走 CDN 是最通用的做法。CDN 服务提供商通过分布在各地的节点,让用户从最近的节点加载内容,大幅提升速度。但是 CDN 的安全性一直是一个风险点:对于网站来说,让请求从第三方服务器经过,由第三方响应,安全方面肯定不如自己服务器可控。

我们知道 CSP(Content Security Policy) 的外链白名单机制可以在现代浏览器下减小 XSS 风险。但针对 CDN 内容被篡改而导致的 XSS,CSP 并不能防范,因为网站所使用的 CDN 域名,肯定在 CSP 白名单之中。这时候,SRI 就应运而生了。

它通过对比 hash 值,来确保文件的安全性,可以在一定程度上防范 XSS 攻击。