阅读视图

发现新文章,点击刷新页面。

Promise 与异步编程

Promise 是 JavaScript 中的一个重要概念,与前端的工作更是息息相关。因此本文将整理一下 Promise 在日常工作中的应用。

目录

概念

MDN | 使用 Promise 中我们能学习到 Promise 的基础使用与错误处理、组合等概念,可以将 Promise 的特点概括为:

  • Promise 对象有三种状态,且状态一旦改变就不会再变。其值记录在内部属性 [[PromiseState]] 中:
    • pending: 进行中
    • fulfilled: 已成功
    • rejected: 已失败
  • 主要用于异步计算,并且可以将异步操作队列化 (链式调用),按照期望的顺序执行,返回符合预期的结果。
  • 可以在对象之间传递和操作 Promise,帮助我们处理队列
  • 链式调用的写法更简洁,可以避免回调地狱

在现实工作中,当我们使用 Promise 时更多是对请求的管理,由于不同请求或任务的异步性。因此我们会根据不同的使用场景处理 Promise 的调度。

async/await

async/await 是基于 Promise 的一种语法糖,使得异步代码的编写和理解更加像同步代码。

当一个函数前通过 async 关键字声明,那这个函数的返回值一定会返回一个 Promise,即便函数返回的值与 Promise 无关,也会隐式包装为 Promise 对象:

1
2
3
4
5
6
async function getAge() {
return 18;
}

getAge().then(age => console.log(`age: ${age}`))
// age: 18

await

await 操作符通常和 async 是配套使用的,它会等待 Promise 并拆开 Promise 包装直接取到里面的值。当它处于 await 状态时,Promise 还处于 ``,后续的代码将不会被执行,因此看起来像是 “同步” 的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function delayResolve(x, timeout = 2000) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(x);
}, timeout);
});
}

async function main() {
const x = await delayResolve(2, 2000);
console.log(x);

const y = await delayResolve(1, 1000);
console.log(y);
}

main();
// 2
// 1

错误处理

async/await 的错误处理通过是通过 try..catch 来捕获错误。当然,我们也会根据实际业务的需求来 catch 我们真正需要处理的问题。

1
2
3
4
5
6
7
8
9
try {
const response = await axios.get('https://example.com/user');

// 处理响应数据
console.log('User data fetched:', response.data);
} catch (error) {
console.error('Error response:', error.response);
// 做其他错误处理
}

学习了前文的基础概念后,我们可以更近一步的探讨 Promise 的使用。

Promise 串联

Promise 串联一般是指多个 Promise 操作按顺序执行,其中每个操作的开始通常依赖于前一个操作的完成。这种串行执行的一个典型场景是在第一个异步操作完成后,其结果被用作第二个异步操作的输入,依此类推。

考虑以下场景:加载系统时,需要优先读取用户数据,同时需要用户的数据去读取用户的订单的信息,再需要两者信息生成用户报告。因此这是一个存在前后依赖的场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function fetchUserInfo(userId) {
return axios.get(`/api/users/${userId}`);
}

function fetchUserOrders(userId) {
return axios.get(`/api/orders/${userId}`);
}

function generateReport(userInfo, orders) {
// 根据用户信息和订单生成报告
return {
userName: userInfo.name,
totalOrders: orders.length,
// ...其他报告数据
};
}

常规处理方法

处理串联请求无非有两种方法:

方法 1: 链式调用 .then()

在这种方法中,我们利用 .then() 的链式调用来处理每个异步任务。这种方式的优点是每个步骤都明确且连贯,但可能导致所谓的“回调地狱”,尤其是在处理多个串联的异步操作时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const userId = '12345'; // 假设已知的用户ID

fetchUserInfo(userId)
.then(response => {
const userInfo = response.data;
return fetchUserOrders(userInfo.id); // 使用用户ID获取订单
})
.then(response => {
const orders = response.data;
return generateReport(userInfo, orders); // 生成报告
})
.then(report => {
console.log('用户报告:', report);
})
.catch(error => {
console.error('在处理请求时发生错误:', error);
});

方法 2: 使用 async/await

async/await 提供了一种更加直观、类似同步的方式来处理异步操作。它使代码更易于阅读和维护,特别是在处理复杂的异步逻辑时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async function getUserReport(userId) {
try {
const userInfoResponse = await fetchUserInfo(userId);
const userInfo = userInfoResponse.data;

const userOrdersResponse = await fetchUserOrders(userInfo.id);
const orders = userOrdersResponse.data;

const report = generateReport(userInfo, orders);
console.log('用户报告:', report);
} catch (error) {
console.error('在处理请求时发生错误:', error);
}
}

const userId = '12345'; // 假设已知的用户ID
getUserReport(userId);

在这个示例中,使用 async/await 使得代码的逻辑更加清晰和直观,减少了代码的嵌套深度,使错误处理变得简单。

串联自动化

以上是日常工作中最常见的需求.但这里我们还可以发散一下思维,考虑更复杂的情况:

现在有一个数组,数组内有 10 个或更多的异步函数,每个函数都依赖前一个异步函数的返回值需要做处理。在这种请求多了的特殊情况下我们手动维护会显得很冗余,因此可以通过循环来简化逻辑:

方法 1: 通过数组方法 reduce 组合

1
2
3
4
5
6
7
8
9
10
11
const processFunctions = [processStep1, processStep2, processStep3, ...];

processFunctions.reduce((previousPromise, currentFunction) => {
return previousPromise.then(result => currentFunction(result));
}, Promise.resolve(initialValue))
.then(finalResult => {
console.log('最终结果:', finalResult);
})
.catch(error => {
console.error('处理过程中发生错误:', error);
});

方法 2: 循环体和 async/await 的结合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
async function handleSequentialTasks(tasks, initialResult) {
let result = initialResult;

try {
for (const task of tasks) {
result = await task(result);
}
console.log('最终结果:', result);
} catch (error) {
console.error('处理过程中发生错误:', error);
}
}

const tasks = [task1, task2, task3, ...];
handleSequentialTasks(tasks, initialValue);

Promise 并发

并发(Concurrency)在编程中是指多个任务在同一时间段内启动、执行,但不一定同时完成。在 JavaScript 的 Promise 中,并发通常涉及同时开始多个异步操作,并根据它们何时解决(fulfilled)或被拒绝(rejected)来进行相应的处理。

Promise 的并发会比串联的场景更复杂。Promise 对象提供了几个静态方法来处理并发情况,让开发者可以根据不同的使用场景选择合适的方法:

Promise.all(iterable)

Promise.all 静态方法接受一个 Promise 可迭代对象作为输入,当传入的数组中每个都被 resolve 后返回一个 Promise。若任意一个 Promise 被 reject 后就 reject。

1
2
3
4
5
6
7
8
9
10
11
const promise1 = fetch('https://example.com/api/data1');
const promise2 = fetch('https://example.com/api/data2');

Promise.all([promise1, promise2])
.then(([data1, data2]) => {
console.log('所有数据已加载:', data1, data2);
})
.catch(error => {
console.error('加载数据时发生错误:', error);
});

Promise.allSettled(iterable)

Promise.allSettled 方法同样接受一个 Promise 的可迭代对象。不同于 Promise.all,这个方法等待所有传入的 Promise 都被解决(无论是 fulfilled 或 rejected),然后返回一个 Promise,它解决为一个数组,每个数组元素代表对应的 Promise 的结果。这使得无论成功还是失败,你都可以得到每个 Promise 的结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
Promise.allSettled([
Promise.resolve(33),
new Promise((resolve) => setTimeout(() => resolve(66), 0)),
99,
Promise.reject(new Error("an error")),
]).then((values) => console.log(values));

// [
// { status: 'fulfilled', value: 33 },
// { status: 'fulfilled', value: 66 },
// { status: 'fulfilled', value: 99 },
// { status: 'rejected', reason: Error: an error }
// ]

Promise.race(iterable)

Promise.race 方法接受一个 Promise 的可迭代对象,但与 Promise.allPromise.allSettled 不同,它不等待所有的 Promise 都被解决。相反,Promise.race 返回一个 Promise,它解决或被拒绝取决于传入的迭代对象中哪个 Promise 最先解决或被拒绝。

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
function sleep(time, value, state) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (state === "fulfill") {
return resolve(value);
} else {
return reject(new Error(value));
}
}, time);
});
}

const p1 = sleep(500, "one", "fulfill");
const p2 = sleep(100, "two", "fulfill");

Promise.race([p1, p2]).then((value) => {
console.log(value); // "two"
// Both fulfill, but p2 is faster
});

const p3 = sleep(100, "three", "fulfill");
const p4 = sleep(500, "four", "reject");

Promise.race([p3, p4]).then(
(value) => {
console.log(value); // "three"
// p3 is faster, so it fulfills
},
(error) => {
// Not called
},
);

const p5 = sleep(500, "five", "fulfill");
const p6 = sleep(100, "six", "reject");

Promise.race([p5, p6]).then(
(value) => {
// Not called
},
(error) => {
console.error(error.message); // "six"
// p6 is faster, so it rejects
},
);

Promise.any(iterable)

Promise.any 接受一个 Promise 的可迭代对象,并返回一个 Promise。它解决为迭代对象中第一个被解决的 Promise 的结果。如果所有的 Promise 都被拒绝,Promise.any 会返回一个 AggregateError 实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const promise1 = new Promise((resolve, reject) => {
setTimeout(resolve, 500, "one");
});

const promise2 = new Promise((resolve, reject) => {
setTimeout(reject, 100, "two");
});

Promise.race([promise1, promise2])
.then((value) => {
console.log("succeeded with value:", value);
})
.catch((reason) => {
// Only promise1 is fulfilled, but promise2 is faster
console.error("failed with reason:", reason);
});
// failed with reason: two

控制批次

JavaScript 默认提供的并发处理函数很方便我们根据业务场景的不同来处理请求,但显然我们工作中所遇到的需求得考虑更复杂的情况,还需要进一步的封装和扩展我们的 API。

在服务器端编程,我们经常遇到需要批量处理数据的场景。例如,批量修改数据库中的用户数据。在这种情况下,由于数据库操作的性能限制或者 API 调用限制,我们不能直接一口气修改全部,因为短时间内发出太多的请求数据库也会处理不来导致应用性能下降。因此,我们需要一种方法来限制同时进行的操作任务数,以保证程序的效率和稳定性。

我们代入实际业务场景:假设有一个社区组织了一次大型户外活动,活动吸引了大量参与者进行在线报名和付费。由于突发情况(比如恶劣天气或其他不可抗力因素),活动不得不取消。这时,组织者需要对所有已付费的参与者进行退款。

活动组织者发起「解散活动」后,服务端接收到请求后当然也不能一次性全部执行退款的操作啦,毕竟一场活动说不定有上千人。因此我们需要分批次去处理。

在上述社区活动退款的例子中,服务器端处理退款请求的一个有效方法是实施分批次并发控制。这种方法不仅保护了后端服务免受过载,还确保了整个退款过程的可管理性和可靠性。

分批次处理时有以下关键问题需要考虑:

  1. 批次大小:确定每个批次中处理的退款请求数量。这个数字应基于服务器的处理能力和支付网关的限制来确定。
  2. 批次间隔:设置每个批次之间的时间间隔。这有助于避免短时间内发出过多请求,从而减轻对数据库和支付网关的压力。
  3. 错误处理:在处理退款请求时,应妥善处理可能发生的错误,并确保能够重新尝试失败的退款操作。

简易版并发控制

将所有待处理的异步任务(如退款请求)存放在一个 tasks 数组中,在调用并发请求前将 tasks 数组分割成多个小批次,每个批次包含固定数量的请求。每当前一个批次处理完后,才处理下一个批次的请求,直到所有批次的请求都被处理完毕:

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
// 假设这些是返回 Promise 的函数
const tasks = [task1, task2, task3, ...];

// 分割任务数组为批次
function splitIntoBatches(tasks, batchSize) {
let batches = [];
for (let i = 0; i < tasks.length; i += batchSize) {
batches.push(tasks.slice(i, i + batchSize));
}
return batches;
}

// 处理单个批次的函数
function processBatch(batch) {
return Promise.all(batch.map(task => task()));
}

// 控制并发的主函数
async function processTasksInBatches(tasks, batchSize) {
const batches = splitIntoBatches(tasks, batchSize);

for (const batch of batches) {
await processBatch(batch);
// 可以在这里加入日志记录或其他处理
console.log('一个批次处理完毕');
}

console.log('所有批次处理完毕');
}

// 调用主函数,假设每批次处理 10 个任务
processTasksInBatches(tasks, 10);

这种写法实现的并发简单易懂,也易于维护,在一些并发压力不大,比较简单的业务场景来看是足够了。

但如果我们将这种处理方式放在时序图上进行分析,就能发现服务器可能有能力处理更多的并发任务,而这种方法可能没有充分利用可用资源。每个批次开始前会依赖于上一个批次中请求响应时间最慢的那一个,因此我们还可以进一步考虑优化并发实现方案。

动态任务队列

在之前的 “控制批次” 方法中,我们发现固定处理批次的局限性,尤其是在并发任务数量较大时可能导致的资源利用不足。为了解决这个问题,我们可以考虑采用一种更灵活的方法:维护一个动态的任务队列来处理异步请求:

  • 任务队列:创建一个任务队列,其中包含所有待处理的异步任务。
  • 动态出队和入队:当队列中的任务完成时,它会被移出队列,同时根据当前的系统负载和任务处理能力,从待处理任务列表中拉取新的任务进入队列。
  • 并发数控制:设置一个最大并发数,确保任何时候处理中的任务数量不会超过这个限制。

我们封装一个函数,提供 concurrency 参数作为并发限制:

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
function parallelLimit(tasks, {concurrency = 10}) {
const results = [];
const executing = new Set();

let currentlyRunning = 0;
let currentIndex = 0;

return new Promise((resolve) => {
const next = () => {
if (currentIndex < tasks.length) {
// 取出记录数,准备执行
const index = currentIndex;
const task = tasks[index];

currentIndex += 1
currentlyRunning += 1;

const resultPromise = task().then((result) => {
// 任务执行完毕,更新运行数、保存结果
currentlyRunning -= 1;
results[index] = result;
executing.delete(resultPromise);

// 开启下一个任务
next();
});

executing.add(resultPromise);

// 当前运行的任务数小于限制并且还有任务未开始时,继续添加任务
if (currentlyRunning < concurrency && currentIndex < tasks.length) {
next();
}
} else if (currentlyRunning === 0) {
// 所有任务都已完成
resolve(results);
}
};

// 初始化
for (let i = 0; i < Math.min(concurrency, tasks.length); i += 1) {
next();
}
});
}

该函数会在初始阶段会按照并发数先同步执行指定任务数,若某个任务执行完毕后,在执行完毕的回调中会唤醒下一个任务,直至任务队列执行完毕。

以下添加一些测试数据用于测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
function asyncTask(id) {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`任务 ${id} 完成`);
resolve(`结果 ${id}`);
}, Math.random() * 2000);
});
}
const taskArray = Array.from({ length: 10 }, (_, i) => () => asyncTask(i + 1));

parallelLimit(taskArray, {concurrency: 3}).then((results) => {
console.log('所有任务完成:', results);
});

第三方库

在实际的项目开发中,特别是面临复杂的并发处理需求时,我们更多会考虑使用成熟的第三库来处理业务问题,它们具备更完善的测试用例来检验边界情况。

处理并发的热门库有 RxJSp-mapasync.js

  • RxJS 是一个以响应式编程为核心的库,竟然搭配 Angular 在网页端搭配使用,提供了丰富的操作符和方法来处理异步事件和数据流。
  • p-mapasync.js 包的体积更小,更适合在服务端中使用。p-map 专注于提供并发控制功能,而 async.js 提供包括并发控制、队列管理等广泛的异步处理模式,功能会更全。

笔者在 Node.js 环境下只需要处理并发问题,故用的 p-map 会更多一些。下面简要介绍 p-map 的使用:

1
2
3
4
5
6
7
8
9
import pMap from 'p-map';

const list = Array.from({ length: 10 }, (_, i) => i)

pMap(list, asyncTask, { concurrency: 3 })
.then((results) => {
console.log('所有任务完成:', results);
});

p-map源码实现很精简,建议想深入复习并发的同学去阅读其底层代码的实现作为参考思路。

总结

在本文中,我们首先回顾了 Promise 的基本概念及其在 JavaScript 异步编程中的常用方法。通过这个基础,我们能够更好地理解如何有效地处理和组织异步代码。

随后,我们深入到并发处理的实际应用场景,探讨了如何根据具体需求选择合适的并发实现策略。我们讨论了从简单的批次控制到更复杂的动态任务队列的不同方法,展示了在不同场景下优化异步任务处理的多种可能性。

但值得注意的是,我们自行实现的并发控制工具在没有做足测试用例测试时,可能不适合直接应用于生产环境。在实际的项目开发中,选择成熟且持续维护的第三方库往往是更安全和高效的选择。比如笔者选择的 p-map 稳定性和可靠性相比上文简单实现的版本将会更好。


参考资料

数据结构实践

本篇将根据自考实践要求对「数据结构」一科进行简要的复习,代码实现使用 C++ 语言实现。

实践

已知 Q 是一个非空队列,S 是一个空栈。编写算法,仅用队列和栈的 ADT 函数和少量工作变量,将队列 Q 的所有元素逆置。

栈的基本 ADT 函数有:

  1. 置空栈。函数原型为: void MakeEmpty(SqStack s);
  2. 元素e入栈。函数原型为: void Push(SqStack s,ElemType e);
  3. 出栈,返回栈顶元素。函数原型为: ElemType pop(SqStack s);
  4. 判断栈是否为空。函数原型为: int isEmpty(SqStack s);

队列的基本ADT函数有:

  1. 元素e入队。函数原型为:void enQueue(Queue q,ElemType e);
  2. 出队,返回队头元素。函数原型为:ElemType deQueue(Queue q);(3)(3)判断队是否为空。函数原型为:int isEmpty(Queue q);

题目要求:

  1. 编程实现队列和栈的ADT函数
  2. 仅用队列和栈的ADT函数和少量工作变量,编写将队列Q的所有元素逆置的函数
  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
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
// 栈的基本 ADT 函数有:

// 1. 置空栈。函数原型为: `void MakeEmpty(SqStack s);`
// 2. 元素e入栈。函数原型为: `void Push(SqStack s,ElemType e);`
// 3. 出栈,返回栈顶元素。函数原型为: `ElemType pop(SqStack s);`
// 4. 判断栈是否为空。函数原型为: `int isEmpty(SqStack s);`
#include <iostream>

using namespace std;

#define StackSize 10
typedef int ElemType;

// 栈结构
class SqStack {
private:
ElemType data[StackSize];
int top;
public:
SqStack(): top(-1) {}

// 1. 置空栈
void makeEmpty() {
this->top = -1;
}

// 2. 元素e入栈
void push(ElemType e) {
if (this->isFull()) {
std::cout << "栈满" << std::endl;
return;
}

this->data[++this->top] = e;
}

// 3. 出栈,返回栈顶元素
ElemType pop() {
if (this->isEmpty()) {
std::cout << "栈空" << std::endl;
return -1;
}

return this->data[this->top--];
}

// 4. 判断栈是否为空
bool isEmpty() {
return this->top == -1;
}

// 5. 栈满
int isFull() {
return this->top == StackSize;
}
};

// 队列的基本ADT函数有:

// (1)元素e入队。函数原型为:void enQueue(Queue q,ElemType e);
// (2)出队,返回队头元素。函数原型为:ElemType deQueue(Queue q);(
// (3)判断队是否为空。函数原型为:int isEmpty(Queue q);
#define QueueSize 10

// 队列结构
class Queue {
private:
ElemType data[QueueSize];
int front, real;
public:
Queue(): front(0), real(0) {}

// 队列是否已满
int isQueueFull() {
return (this->real + 1) % QueueSize == this->front;
}

// 元素e入队
void enQueue(ElemType e) {
if (isQueueFull()) {
std::cout << "队列满" << std::endl;
return;
}

this->data[this->real] = e;
// 循环意义下的 +1
this->real = (this->real + 1) % QueueSize;
}

// 出队列
ElemType deQueue() {
if (this->isEmpty()) {
std::cout << "队列空" << std::endl;
return -1;
}

ElemType e = this->data[this->front];
this->front = (this->front + 1) % QueueSize;

return e;
}

// 判断队列是否为空
int isEmpty() {
return this->front == this->real;
}
};

// 队列元素倒序, 这里注意要用 & 取引用才有副作用
void reverseQueue(Queue &q) {
SqStack s;
int val;

while (!q.isEmpty()) {
val = q.deQueue();
s.push(val);
}

while (!s.isEmpty()) {
val = s.pop();
q.enQueue(val);
}
}

// (1) 编程实现队列和栈的ADT函数
// (2) 仅用队列和栈的ADT函数和少量工作变量,编写将队列Q的所有元素逆置的函数。
// (3) 测试该函数
int main() {
cout << "准备测试 stack 数据结构" << endl;
SqStack s;

cout << "[stack] 1. test SqStack.push" << endl;
int testData1[] = {109, 108, 107};
for (int i = 0; i < 3; i++) {
s.push(testData1[i]);
}

cout << "[stack] 2. test SqStack.isEmpty: " << (s.isEmpty() ? "" : "非") << "空栈" << endl;
cout << "[stack] 3. test SqStack.pop: " << s.pop() << endl;
cout << "[stack] 4. test SqStack.makeEmpty" << endl;
s.makeEmpty();

cout << "[stack] 5. check stack now is empty: " << (s.isEmpty() ? "" : "非") << "空栈" << endl;
cout << "============================================" << endl;

cout << "准备测试 queue 数据结构" << endl;
Queue q;

cout << "[queue] 1. test SqStack.push" << endl;
for (int i = 0; i < 3; i++) {
q.enQueue(testData1[i]);
}

cout << "[queue] 2. test Queue.isEmpty: " << (q.isEmpty() ? "空队列" : "非空队列") << endl;
while (!q.isEmpty()) {
cout << "[queue] 3. test Queue.pop: " << q.deQueue() << endl;
}

cout << "[queue] 4. check queue now is empty: " << (q.isEmpty() ? "空队列" : "非空队列") << endl;
cout << endl << endl;
cout << "============================================" << endl;

cout << "仅用队列和栈的ADT函数和少量工作变量,编写将队列Q的所有元素逆置的函数。" << endl;
const int reverseTestData[] = {11,12,13,14,15};
for (int i = 0; i < 5; i++) {

q.enQueue(reverseTestData[i]);
}
reverseQueue(q);

while(!q.isEmpty()) {
cout << "reverseQueue deQueue: " << q.deQueue() << endl;
}

return 0;
}

排序

选择排序

基本思想: 每一趟在待排序的记录中选出关键字最小的记录,依次存放在已排好序的记录序列的最后,直到全部排序完为止。

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

#include <iostream>
using namespace std;

void SelectSort(int arr[], int n) {
int k;
for (int i = 0; i < n; i++) {
k = i;
for (int j = i + 1; j < n; j++) {
if (arr[j] < arr[k]) {
k = j;
}
}

if (k != i) {
swap(arr[i], arr[k]);
int temp = arr[i];
arr[i] = arr[k];
arr[k] = temp;
}
}
}

int main() {
int arr[] = {4, 3, 2, 9, 8, 6, 7, 1, 5, 10};
int n = sizeof(arr) / sizeof(arr[0]);
cout << sizeof(arr[0]) << "\n";

SelectSort(arr, n);

for (int i = 0; i < n; i++) {
cout << arr[i] << " ";
}
}

插入排序

基本思想: 每次将一个待排序的记录按其关键字的大小插入到前面已经排序好的文件中的适当位置,直到全部记录插入完位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void InsertSort(int arr[], int n) {
int i, j, tmp;
// 对顺序表做直接插入排序
for(i = 1; i < n; i++) {
// 当前值比上一个值小,则交换位置
if (arr[i] < arr[i - 1]) {

tmp = arr[i];
// 对有序区逐项向后 diff,寻找合适的插入位置
for(j = i - 1; j >= 0 && tmp < arr[j]; j--) {
arr[j + 1] = arr[j];
}
arr[j + 1] = tmp;
}
}
}

冒泡排序

冒泡排序的基本思想是:通过相邻元素之间的比较和交换,使娇小的元素逐渐从底部移向顶部,就像水底下气泡一样逐渐向上冒泡。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void BubbleSort(int *arr, int n) {
int i, j, flag, temp;
for (i = 0; i < n; i++) {
flag = 0;

// 从右向左对比
for (j = n - 1; j >= i; j--) {
if (arr[j] < arr[j - 1]) {
swap(arr[j], arr[j - 1]);
// temp = arr[j];
// arr[j] = arr[j - 1];
// arr[j - 1] = temp;
flag = 1;
}
}

if (flag == 0) return;
}
}

MySQL 实践

由于自考的实践考核要求有需要用到 mysql 进行考核,故记录一下在 mac 环境下试手的笔记。

初始环境

首先在 mysql 官网中下载你想要的版本。可以直接下载 dmg 安装包,按照安装指示一步一步安装,并设置 mysql 的密码。

下载完毕后,一般情况下直接通过命令行使用 mysql 命令会找不到对应的命令:

1
2
➜  ~ mysql -v
zsh: command not found: mysql

因此需要对当前的命令行工具配置对应的环境变量,比如笔者使用的是 zsh,则打开 ~/.zshrc 文件添加以下配置:

1
export PATH=${PATH}:/usr/local/mysql/bin/

若使用 bash 的用户同理,直接在 ~/.bashrc 添加相同代码。添加完毕后通过 source 命令重新加载对应的环境变量: source ~/.zshrc

接着就可以在命令行直接使用 mysql 了。输入 mysql -u root -p 登录 mysql,密码是在安装阶段时设置的密码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
➜  ~ mysql -u root -p
Enter password:
Welcome to the MySQL monitor. Commands end with ; or \g.
Your MySQL connection id is 13
Server version: 8.0.29 MySQL Community Server - GPL

Copyright (c) 2000, 2022, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql>

数据库操作

DATABASE 可以不区分大小写,但只能要么全小写,要么全大写。一般会将这些参数用大写写出。

创建数据库

1
2
3
-- 还可以通过 DEFAULT CHARACTER SET 选项设置默认的编码集
mysql> CREATE DATABASE DANNY_DATABASE;
Query OK, 1 row affected (0.01 sec)

查看现有的数据库

1
2
3
4
5
6
7
8
9
10
11
mysql> SHOW DATABASES;
+----------------------------+
| Database |
+----------------------------+
| information_schema |
| DANNY_DATABASE |
| mysql |
| performance_schema |
| sys |
+----------------------------+
6 rows in set (0.00 sec)

切换到指定数据库

1
mysql> USE DANNY_DATABASE

数据库的查看与删除

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
-- 创建数据库: 准备稍后移除的数据库
mysql> CREATE DATABASE DANNY_DATABASE_WAIT_DELETE;
Query OK, 1 row affected (0.01 sec)

mysql> SHOW DATABASES;
+----------------------------+
| Database |
+----------------------------+
| information_schema |
| DANNY_DATABASE |
| DANNY_DATABASE_WAIT_DELETE |
| mysql |
| performance_schema |
| sys |
+----------------------------+
6 rows in set (0.00 sec)

-- 删除数据库
mysql> DROP DATABASE DANNY_DATABASE_WAIT_DELETE;
Query OK, 0 rows affected (0.02 sec)

mysql> SHOW DATABASES;
+--------------------+
| Database |
+--------------------+
| information_schema |
| DANNY_DATABASE |
| mysql |
| performance_schema |
| sys |
+--------------------+
5 rows in set (0.00 sec)

查看当前使用的数据库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- 未选择的情况下
mysql> SELECT DATABASE();
+----------------+
| DATABASE() |
+----------------+
| null |
+----------------+
1 row in set (0.00 sec)

-- 切换指定数据库
use DANNY_DATABASE;

mysql> SELECT DATABASE();
+----------------+
| DATABASE() |
+----------------+
| danny_database |
+----------------+
1 row in set (0.00 sec)

数据表操作

创建数据表

1
2
3
4
5
6
7
8
9
10
-- 创建名为 customers 的数据表
mysql> CREATE TABLE IF NOT EXISTS customers(
-> cust_id INT NOT NULL AUTO_INCREMENT,
-> cust_name CHAR(50) NOT NULL,
-> cust_sex CHAR(1) NOT NULL DEFAULT 0,
-> cust_address CHAR(50) NULL,
-> cust_contact CHAR(50) NULL,
-> PRIMARY KEY(cust_id)
-> );
Query OK, 0 rows affected (0.11 sec)

其中 IF NOT EXISTS 参数是可选的,它的意思为若 customers 表不存在则创建它。

查看数据表与表列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
-- 查看当前用户在当前数据库中可以访问的数据表
mysql> SHOW TABLES;
+--------------------------+
| Tables_in_danny_database |
+--------------------------+
| customers |
+--------------------------+
1 rows in set (0.00 sec)

-- 查看指定数据表中列的信息
-- DESC customers; 等价于如下命令
mysql> SHOW COLUMNS from customers;
+--------------+-------------+------+-----+-----------+----------------+
| Field | Type | Null | Key | Default | Extra |
+--------------+-------------+------+-----+-----------+----------------+
| cust_id | int(11) | NO | PRI | NULL | auto_increment |
| cust_name | char(50) | NO | | NULL | |
| cust_sex | char(1) | NO | | 0 | |
| cust_address | char(50) | YES | | NULL | |
| cust_contact | char(50) | YES | | NULL | |
+--------------+-------------+------+-----+-----------+----------------+
5 rows in set (0.00 sec)

删除数据表

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
-- 添加一个数据表用于演示删除
mysql> CREATE TABLE IF NOT EXISTS customers_1(
-> cust_id INT NOT NULL AUTO_INCREMENT,
-> cust_name CHAR(50) NOT NULL,
-> cust_sex CHAR(1) NOT NULL DEFAULT 0,
-> cust_address CHAR(50) NULL,
-> cust_contact CHAR(50) NULL,
-> PRIMARY KEY(cust_id)
-> );
Query OK, 0 rows affected (0.11 sec)

-- 查看当前的数据表
mysql> SHOW tables;
+--------------------------+
| Tables_in_danny_database |
+--------------------------+
| customers |
| customers_1 |
+--------------------------+
2 rows in set (0.00 sec)

-- 删除指定数据表
mysql> DROP TABLES customers_1;
Query OK, 0 rows affected (0.02 sec)

mysql> SHOW tables;
+--------------------------+
| Tables_in_danny_database |
+--------------------------+
| customers |
+--------------------------+
1 row in set (0.00 sec)

数据表添加新列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- 插入新列
mysql> alter TABLE customers
-> ADD COLUMN cust_city char(10) NOT NULL DEFAULT 'guangzhou' AFTER cust_sex;
Query OK, 0 rows affected (0.06 sec)
Records: 0 Duplicates: 0 Warnings: 0

-- 确认表列状态
mysql> SHOW COLUMNS from customers;
+--------------+-------------+------+-----+-----------+----------------+
| Field | Type | Null | Key | Default | Extra |
+--------------+-------------+------+-----+-----------+----------------+
| cust_id | int(11) | NO | PRI | NULL | auto_increment |
| cust_name | char(50) | NO | | NULL | |
| cust_sex | char(1) | NO | | 0 | |
| cust_city | char(10) | NO | | guangzhou | |
| cust_address | char(50) | YES | | NULL | |
| cust_contact | char(50) | YES | | NULL | |
+--------------+-------------+------+-----+-----------+----------------+
6 rows in set (0.00 sec)

数据表修改表列

修改整列: 将列名 cust_sex 修改 sex,并修改默认值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
mysql> alter TABLE customers
-> CHANGE COLUMN cust_sex sex char(1) NULL DEFAULT 'M';
Query OK, 0 rows affected (0.04 sec)
Records: 0 Duplicates: 0 Warnings: 0

mysql> SHOW COLUMNS from customers;
+--------------+-------------+------+-----+-----------+----------------+
| Field | Type | Null | Key | Default | Extra |
+--------------+-------------+------+-----+-----------+----------------+
| cust_id | int(11) | NO | PRI | NULL | auto_increment |
| cust_name | char(50) | YES | | NULL | |
| sex | char(1) | YES | | M | |
| cust_city | char(10) | NO | | guangzhou | |
| cust_address | char(50) | YES | | NULL | |
| cust_contact | char(50) | YES | | NULL | |
+--------------+-------------+------+-----+-----------+----------------+
6 rows in set (0.00 sec)

仅修改列的类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
mysql> ALTER TABLE customers
-> MODIFY COLUMN cust_address varchar(50);
Query OK, 0 rows affected (0.06 sec)
Records: 0 Duplicates: 0 Warnings: 0

mysql> show COLUMNS from customers;
+--------------+-------------+------+-----+-----------+----------------+
| Field | Type | Null | Key | Default | Extra |
+--------------+-------------+------+-----+-----------+----------------+
| cust_id | int(11) | NO | PRI | NULL | auto_increment |
| cust_name | char(50) | YES | | NULL | |
| sex | char(1) | YES | | M | |
| cust_city | char(10) | NO | | guangzhou | |
| cust_address | varchar(50) | YES | | NULL | |
| cust_contact | char(50) | YES | | NULL | |
+--------------+-------------+------+-----+-----------+----------------+
6 rows in set (0.00 sec)

修改指定列的指定字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
mysql> ALTER TABLE customers
-> ALTER COLUMN cust_city SET DEFAULT 'shenzhen';
Query OK, 0 rows affected (0.03 sec)
Records: 0 Duplicates: 0 Warnings: 0

mysql> SHOW COLUMNS from customers;
+--------------+-------------+------+-----+----------+----------------+
| Field | Type | Null | Key | Default | Extra |
+--------------+-------------+------+-----+----------+----------------+
| cust_id | int(11) | NO | PRI | NULL | auto_increment |
| cust_name | char(50) | YES | | NULL | |
| sex | char(1) | YES | | M | |
| cust_city | char(10) | NO | | shenzhen | |
| cust_address | varchar(50) | YES | | NULL | |
| cust_contact | char(50) | YES | | NULL | |
+--------------+-------------+------+-----+----------+----------------+
6 rows in set (0.00 sec)

移除数据表列: 移除 cust_contact 数据表项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
mysql> ALTER TABLE danny_database.customers
-> DROP COLUMN cust_contact;
Query OK, 0 rows affected (0.04 sec)
Records: 0 Duplicates: 0 Warnings: 0

mysql> SHOW COLUMNS from customers;
+--------------+-------------+------+-----+----------+----------------+
| Field | Type | Null | Key | Default | Extra |
+--------------+-------------+------+-----+----------+----------------+
| cust_id | int(11) | NO | PRI | NULL | auto_increment |
| cust_name | char(50) | YES | | NULL | |
| sex | char(1) | YES | | M | |
| cust_city | char(10) | NO | | shenzhen | |
| cust_address | varchar(50) | YES | | NULL | |
+--------------+-------------+------+-----+----------+----------------+
5 rows in set (0.00 sec)

数据项操作

添加数据

默认情况下在命令行中 mysql 是不能直接插入中文的,这个跟字符集有关。可输入下面命令修改数据库或表的字符集:

1
2
3
4
5
6

-- 设置名为 danny_database 的数据库字符集
ALTER DATABASE danny_database character SET utf8;

-- 设置名为 customers 的数据库表字符集 (Tip: 若数据库已经被设置为 utf8, 则无需再设置表的字符集)
ALTER TABLE customers convert to character SET utf8;

为数据表插入数据,显式设置字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
mysql> INSERT INTO danny_database.customers(cust_id, cust_name, sex, cust_address)
-> VALUES(901, '张三', DEFAULT, '广州市');
Query OK, 1 row affected (0.02 sec)

mysql> INSERT INTO danny_database.customers(cust_id, cust_name, sex, cust_address)
-> VALUES(0, '李四', DEFAULT, '广州市');
Query OK, 1 row affected (0.01 sec)

mysql> select * from customers;
+---------+-----------+------+-----------+--------------+
| cust_id | cust_name | sex | cust_city | cust_address |
+---------+-----------+------+-----------+--------------+
| 901 | 张三 | M | shenzhen | 广州市 |
| 902 | 李四 | M | shenzhen | 广州市 |
+---------+-----------+------+-----------+--------------+
2 rows in set (0.00 sec)

由于 cust_id 是自增的,因此可以将此字段的值设置为 0 或 NULL 会自动自增。上例 “李四” 的 cust_id 在创建后就被自增为 902。

还可以通过 SET 语句设置部分值:

1
2
mysql> INSERT INTO danny_database.customers SET cust_name='王五', cust_address='武汉市', sex=DEFAULT;
Query OK, 1 row affected (0.00 sec)

查询数据

可通过 SELECT 语句查询数据:

1
2
3
4
5
6
7
8
9
mysql> SELECT * FROM customers;
+---------+-----------+------+-----------+--------------+
| cust_id | cust_name | sex | cust_city | cust_address |
+---------+-----------+------+-----------+--------------+
| 901 | 张三 | M | shenzhen | 广州市 |
| 902 | 李四 | M | shenzhen | 广州市 |
| 903 | 王五 | M | shenzhen | 武汉市 |
+---------+-----------+------+-----------+--------------+
3 rows in set (0.00 sec)

仅展示指定字段:

1
2
3
4
5
6
7
8
+---------+-----------+------+
| cust_id | cust_name | sex |
+---------+-----------+------+
| 901 | 张三 | M |
| 902 | 李四 | M |
| 903 | 王五 | M |
+---------+-----------+------+
3 rows in set (0.00 sec)

通过 WHERE 子句设置查询条件,筛选出符合查询条件的数据:

1
2
3
4
5
6
7
8
9
mysql> SELECT cust_id,cust_name,cust_address FROM customers
-> WHERE cust_address="广州市";
+---------+-----------+--------------+
| cust_id | cust_name | cust_address |
+---------+-----------+--------------+
| 901 | 张三 | 广州市 |
| 902 | 李四 | 广州市 |
+---------+-----------+--------------+
2 rows in set (0.00 sec)

删除数据

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
-- 添加几项测试数据
mysql> INSERT INTO danny_database.customers(cust_id, cust_name, sex, cust_address)
-> VALUES(1, 'test1', DEFAULT, '深圳市');
Query OK, 1 row affected (0.02 sec)

mysql> select * from customers;
+---------+-----------+------+-----------+--------------+
| cust_id | cust_name | sex | cust_city | cust_address |
+---------+-----------+------+-----------+--------------+
| 1 | test1 | M | shenzhen | 深圳市 |
| 901 | 张三 | M | shenzhen | 广州市 |
| 902 | 李四 | M | shenzhen | 广州市 |
| 903 | 王五 | M | shenzhen | 武汉市 |
+---------+-----------+------+-----------+--------------+
4 rows in set (0.00 sec)

-- 删除表数据
mysql> DELETE FROM customers
-> WHERE cust_id=1;
Query OK, 1 row affected (0.02 sec)


mysql> select * from customers;
+---------+-----------+------+-----------+--------------+
| cust_id | cust_name | sex | cust_city | cust_address |
+---------+-----------+------+-----------+--------------+
| 901 | 张三 | M | shenzhen | 广州市 |
| 902 | 李四 | M | shenzhen | 广州市 |
| 903 | 王五 | M | shenzhen | 武汉市 |
+---------+-----------+------+-----------+--------------+

更新数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 更新数据
mysql> UPDATE customers SET cust_address="深圳市" WHERE cust_name="李四";
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0

mysql> SELECT * FROM customers;
+---------+-----------+------+-----------+--------------+
| cust_id | cust_name | sex | cust_city | cust_address |
+---------+-----------+------+-----------+--------------+
| 901 | 张三 | M | shenzhen | 广州市 |
| 902 | 李四 | M | shenzhen | 深圳市 |
| 903 | 王五 | M | shenzhen | 武汉市 |
+---------+-----------+------+-----------+--------------+
3 rows in set (0.00 sec)

实践

以一个 eShop 的需求为例做个简单的测试吧。

创建 eshop 数据库

在 MySQL 中创建一个名为 eshop 的数据库,选择字符集为 utf8mb4

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
mysql> CREATE DATABASE IF NOT EXISTS eshop DEFAULT CHARACTER SET utf8mb4;
Query OK, 1 row affected (0.01 sec)

mysql> SHOW DATABASES;
+--------------------+
| Database |
+--------------------+
| information_schema |
| DANNY_DATABASE |
| eshop |
| mysql |
| performance_schema |
| sys |
+--------------------+
6 rows in set (0.01 sec)

-- 切换数据库
mysql> use eshop;
Database changed

创建数据表及相关记录

相关表信息如下

表名:用户(t_user)

字段名类型大小
用户ID (id)自增类型
姓名 (user_name)文本50,非空
联系电话 (phone_no)文本20,非空

表名:商品(product)

字段名类型大小
商品ID(id)自增类型
商品名称(product_name)文本50,非空
价格(price)数值类型(整数位9位,小数位2位),非空

表名:购物车 (shopping_cart)

字段名类型大小
用户id(user_id)整数非空,主键,参考用户表主键
商品id(product_id)整数非空,主键,参考商品表主键
商品数量(quantity)整数非空
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
-- 用户表
mysql> CREATE TABLE IF NOT EXISTS t_user(
-> `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
-> `user_name` CHAR(50) NOT NULL,
-> `phone_no` CHAR(20) NOT NULL
-> );
Query OK, 0 rows affected (0.06 sec)

-- 商品表
mysql> CREATE TABLE IF NOT EXISTS product(
-> `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
-> `product_name` CHAR(50) NOT NULL,
-> `price` DOUBLE(9, 2)
-> );
Query OK, 0 rows affected (0.06 sec)

-- 购物车
mysql> CREATE TABLE IF NOT EXISTS shopping_cart(
-> `user_id` INT NOT NULL,
-> `product_id` INT NOT NULL,
-> `quantity` INT NOT NULL,
-> PRIMARY KEY(`user_id`, `product_id`)
-> );
Query OK, 0 rows affected (0.05 sec)

-- 查看数据表
mysql> show tables;
+-----------------+
| Tables_in_eshop |
+-----------------+
| product |
| shopping_cart |
| t_user |
+-----------------+
3 rows in set (0.00 sec)

录入用户数据

用户信息

1
2
3
4
1;张三; 13333333333;
2;李四; 13666666666
3;王五; 13888888888
4;赵六; 13999999999

商品信息

1
2
3
1; C++程序设计教程; 45.5
2; 数据结构; 33.7
3; 操作系统; 51

购物车

1
2
3
4
1; 1; 5
1; 2; 3
2; 3; 6
2; 4; 8

录入数据:

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
-- 插入用户表数据
mysql> INSERT INTO t_user
-> (id, user_name, phone_no)
-> VALUES
-> (1, '张三', '13333333333'),
-> (2, '李四', '13666666666'),
-> (3, '王五', '13888888888'),
-> (4, '赵六', '13999999999');
Query OK, 4 rows affected (0.02 sec)
Records: 4 Duplicates: 0 Warnings: 0

mysql> SELECT * FROM t_user;
+----+-----------+-------------+
| id | user_name | phone_no |
+----+-----------+-------------+
| 1 | 张三 | 13333333333 |
| 2 | 李四 | 13666666666 |
| 3 | 王五 | 13888888888 |
| 4 | 赵六 | 13999999999 |
+----+-----------+-------------+
4 rows in set (0.00 sec)

-- 插入「商品信息」
mysql> INSERT INTO product
-> (id, product_name, price)
-> VALUES
-> (1, 'C++程序设计教程', 45.5),
-> (2, '数据结构', 33.7),
-> (3, '操作系统', 51);
Query OK, 3 rows affected (0.01 sec)
Records: 3 Duplicates: 0 Warnings: 0

mysql> SELECT * FROM product;
+----+-----------------------+-------+
| id | product_name | price |
+----+-----------------------+-------+
| 1 | C++程序设计教程 | 45.50 |
| 2 | 数据结构 | 33.70 |
| 3 | 操作系统 | 51.00 |
+----+-----------------------+-------+
3 rows in set (0.00 sec)

-- 插入购物车
mysql> INSERT INTO shopping_cart
-> (user_id, product_id, quantity)
-> VALUES
-> (1, 1, 5),
-> (1, 2, 3),
-> (2, 3, 6),
-> (2, 4, 8);
Query OK, 4 rows affected (0.01 sec)
Records: 4 Duplicates: 0 Warnings: 0

mysql> SELECT * FROM shopping_cart;
+---------+------------+----------+
| user_id | product_id | quantity |
+---------+------------+----------+
| 1 | 1 | 5 |
| 1 | 2 | 3 |
| 2 | 3 | 6 |
| 2 | 4 | 8 |
+---------+------------+----------+
4 rows in set (0.00 sec)

数据的查询与更新

使用 SQL 语句列出「张三」购买商品清单信息,以购买数量升序排列:

1
2
3
4
5
6
7
8
9
10
11
mysql> SELECT u.user_name, p.product_name, u.phone_no, p.price, s.quantity FROM t_user u, product p, shopping_cart s
-> WHERE u.user_name="张三" AND u.id = s.user_id AND p.id = s.product_id
-> ORDER BY quantity asc
-> LIMIT 100;
+-----------+-----------------------+-------------+-------+----------+
| user_name | product_name | phone_no | price | quantity |
+-----------+-----------------------+-------------+-------+----------+
| 张三 | 数据结构 | 13333333333 | 33.70 | 3 |
| 张三 | C++程序设计教程 | 13333333333 | 45.50 | 5 |
+-----------+-----------------------+-------------+-------+----------+
2 rows in set (0.01 sec)

使用 SQL 语句选出李四购买商品的总价:

1
2
3
4
5
6
7
8
9
mysql> SELECT u.user_name, p.product_name, p.price, s.quantity, p.price*s.quantity AS total_price FROM t_user u, product p, shopping_cart s
-> WHERE u.user_name="李四" AND u.id = s.user_id AND p.id = s.product_id
-> LIMIT 100;
+-----------+--------------+-------+----------+-------------+
| user_name | product_name | price | quantity | total_price |
+-----------+--------------+-------+----------+-------------+
| 李四 | 操作系统 | 51.00 | 6 | 306.00 |
+-----------+--------------+-------+----------+-------------+
1 row in set (0.00 sec)

使用 SQL 语句列出购买数量排前两位的商品名称:

1
2
3
4
5
6
7
8
9
10
11
mysql> SELECT p.product_name, p.price, s.quantity FROM product p, shopping_cart s
-> WHERE p.id = s.product_id
-> ORDER BY quantity desc
-> LIMIT 2;
+-----------------------+-------+----------+
| product_name | price | quantity |
+-----------------------+-------+----------+
| 操作系统 | 51.00 | 6 |
| C++程序设计教程 | 45.50 | 5 |
+-----------------------+-------+----------+
2 rows in set (0.00 sec)

忘记密码

若忘记数据库密码后可通过 mysqld_safe 来修改密码:

  1. 在系统偏好设置中关闭 mysql 服务

  2. 打开终端,输入命令:

    1
    2
    ➜  ~ cd /usr/local/mysql/bin
    ➜ ~ sudo su
  3. 命令行变成以 sh-3.2# 开头后继续输入命令:

    1
    2
    3
    4
    sh-3.2# ./mysqld_safe --skip-grant-tables &

    mysqld_safe Logging to '/usr/local/mysql/data/DannydeMBP.err'.
    mysqld_safe Starting mysqld daemon with databases from /usr/local/mysql/data
  4. 新开个命令行窗口,进入 mysql:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    ➜  ~ /usr/local/mysql/bin/mysql

    Enter password:
    Welcome to the MySQL monitor. Commands end with ; or \g.
    Your MySQL connection id is 30
    Server version: 5.7.31

    Copyright (c) 2000, 2020, Oracle and/or its affiliates. All rights reserved.

    Oracle is a registered trademark of Oracle Corporation and/or its
    affiliates. Other names may be trademarks of their respective
    owners.

    Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

    mysql>
    mysql> use mysql

    Reading table information for completion of table and column names
    You can turn off this feature to get a quicker startup with -A

    Database changed
  5. 更新密码

    1
    2
    3
    4
    mysql> update user set authentication_string=password('admin') where Host='localhost' and User='root';

    Query OK, 1 row affected, 1 warning (0.01 sec)
    Rows matched: 1 Changed: 1 Warnings: 1
  6. 输入 exit 命令退出 mysql,查出 mysqld_safe 进程号并杀掉:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    mysql> exit
    Bye

    ➜ ~ ps -ax | grep mysql
    8553 ttys004 0:00.03 /bin/sh ./mysqld_safe --skip-grant-tables
    8623 ttys004 0:00.92 /usr/local/mysql-5.7.31-macos10.14-x86_64/bin/mysqld --basedir=/usr/local/mysql-5.7.31-macos10.14-x86_64 --datadir=/usr/local/mysql-5.7.31-macos10.14-x86_64/data --plugin-dir=/usr/local/mysql-5.7.31-macos10.14-x86_64/lib/plugin --user=mysql --skip-grant-tables --log-error=host-3-187.can.danny1.network.err --pid-file=host-3-187.can.danny1.network.pid

    # 杀掉 mysql 的进程
    ➜ ~ kill -9 8553
    ➜ ~ kill -9 8623
  7. 此时返回系统偏好设置中看到 mysql 被关闭后就算正确退出了。接着继续输入 mysql -u root -p 命令连接数据库,再输入刚才修改的密码即可。


参考资料

组件通信: EventBus 的原理解析与应用

在开发复杂的单页面应用时,我们经常会遇到一个问题:如何高效地在组件或模块之间进行通信?这里,EventBus(事件总线)就派上了用场。简单来说,EventBus 是一种设计模式,它允许不同组件或模块之间通过事件来通信,而无需直接引用彼此。

EventBus 是传统的组件通信解决方案,下面我们将讲解 EventBus 跨组件通信的原理、实现方式以及该如何使用。

原理解析

EventBus 的核心在于提供一个中央机制,允许不同的组件或模块相互通信,而不必直接引用对方。它是一种典型的发布-订阅(pub-sub)模式,这是一种广泛使用的设计模式,用于解耦发送者和接收者。

在这个模式中,EventBus 充当了一个中介的角色:它允许组件订阅那些它们感兴趣的事件,并在这些事件发生时接收通知。同样,当某个事件发生时,比如用户的一个动作或者数据的变化,EventBus 负责将这一消息广播给所有订阅了该事件的组件。

它基于三个核心操作:注册事件(on(event, callback))、触发事件(emit(event, ...args))、以及移除事件(off(event, callback))。因此,EventBus 的基本代码可以看到:

1
2
3
4
5
6
7
8
9
10
11
12
13
class EventBus {
on(event, callback) {
// 注册事件监听器
}

emit(event, ...args) {
// 触发事件
}

off(event, callback) {
// 移除事件监听器
}
}

显然,我们需要有一个私有变量来储存用户的函数,此时为类添加 events 属性。events 属性是一个对象映射,其中每个属性表示一个事件名称,对应的值是一个回调函数的数组,这个数组存储了所有订阅了该事件的回调函数。

1
2
3
4
class EventBus {
private events: Record<string, Function[]> = {};
// ...
}

当用户执行订阅事件 on 时,回调函数会被添加到相应事件名称的数组中。这样,同一个事件可以被不同组件或模块订阅,而每个订阅者的回调函数都会被正确地保存在事件队列中。最后,当触发事件 emit 时,事件队列中的每个回调函数都会被执行,实现了事件的触发和通知功能。若已经没有订阅需求,则可以通过 off 移除已经订阅的事件。

代码实现

接下来我们按照前文所述完善我们的代码实现:

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
class EventBus {
// 事件存储对象,用于保存不同事件的回调函数
private events: Record<string, Function[]> = {};

/**
* 注册事件监听器
* @param eventName - 事件名称
* @param callback - 回调函数,当事件触发时执行
* @returns this - 返回 EventBus 实例,支持链式调用
*/
public on(eventName: string, callback: Function): this {
// 检查回调函数是否为函数类型
if (typeof callback !== "function") {
throw new Error("EventBus 'on' method expects a callback function.");
}

// 如果事件不存在,创建一个空数组用于存储回调函数
if (!this.events[eventName]) {
this.events[eventName] = [];
}

// 将回调函数添加到事件的回调函数列表中
this.events[eventName].push(callback);

// 支持链式调用
return this;
}

/**
* 触发事件
* @param eventName - 要触发的事件名称
* @param args - 传递给回调函数的参数
* @returns this - 返回 EventBus 实例,支持链式调用
*/
public emit(eventName: string, ...args: any[]): this {
// 获取事件对应的回调函数列表
const callbacks = this.events[eventName];
if (callbacks) {
// 遍历执行每个回调函数,并传递参数
callbacks.forEach((callback) => callback(...args));
}

// 支持链式调用
return this;
}

/**
* 移除事件监听器
* @param event - 要移除的事件名称或事件名称数组
* @param callback - 要移除的回调函数(可选)
* @returns this - 返回 EventBus 实例,支持链式调用
*/
public off(event?: string | string[], callback?: Function): this {
// 清空所有事件监听器
if (!event || (Array.isArray(event) && !event.length)) {
this.events = {};
return this;
}

// 处理事件数组
if (Array.isArray(event)) {
event.forEach((e) => this.off(e, callback));
return this;
}

// 如果没有提供回调函数,则删除该事件的所有监听器
if (!callback) {
delete this.events[event];
return this;
}

// 移除特定的回调函数
const callbacks = this.events[event];
if (callbacks) {
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}

// 支持链式调用
return this;
}
}

当涉及到一次性的事件监听需求时,我们可以进一步扩展 EventBus,以支持一次性事件监听。允许用户在某个事件触发后,自动移除事件监听器,以确保回调函数只执行一次:

1
2
3
4
5
6
7
8
9
10
11
12
13
class EventBus {
// other code ...
public once(eventName: string, callback: Function): this {
const onceWrapper = (...args: any[]) => {
this.off(eventName, onceWrapper);
callback(...args);
};

this.on(eventName, onceWrapper);

return this;
}
}

使用方式

我们将类的封装到 event-bus.ts 中,通过模块的来管理:

1
2
3
export class EventBus {
// ...
}

我们现在已经封装好了一个类,若我们像使用则需要实例化。此处再文件内直接实例化一个类:

1
2
// 创建 EventBus 实例并导出
export const eventBus = new EventBus();

这样使用时可以提供两种方式:

  1. 引入已经实例化的 eventBus

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    import { eventBus } from './event-bus';

    // 订阅事件
    eventBus.on('eventName', callback);

    // 触发事件
    eventBus.emit('eventName', data);

    // 移除事件
    eventBus.off('eventName', callback);
  2. 需要多个独立的事件总线实例时,或者希望在不同模块或组件之间使用不同的事件总线时,可以选择额外实例化 eventBus。这样做的目的可能是为了隔离命名的冲突、组件与模块逻辑隔离等原因。

    1
    2
    3
    4
    5
    6
    // events.ts
    import { EventBus } from './event-bus';

    // 创建独立的事件总线实例
    export const eventBusA = new EventBus();
    export const eventBusB = new EventBus();
    1
    2
    3
    4
    5
    6
    7
    8
    9
    import {eventBusA, eventBusB} from './events'

    // 在不同模块或组件中使用不同的事件总线
    eventBusA.on('eventA', callbackA);
    eventBusB.on('eventB', callbackB);

    // 触发不同事件总线上的事件
    eventBusA.emit('eventA', dataA);
    eventBusB.emit('eventB', dataB);

以下是 CodeSandbox 的演示代码:

总结

在本文中,我们深入探讨了 EventBus 的原理,了解了它是如何工作的。我们学习了它的核心操作。除了本文所提及的实现方式,有时候在生产项目中,为了确保代码的可靠性,我们可以考虑使用成熟的第三方库,例如 mitttiny-emitter

这些库已经经过广泛的测试和使用,可以提供稳定和可靠的 EventBus 功能。

Redux 食用指南

Redux 是一个强大的状态管理框架,被广泛用于管理应用程序的状态。它的设计理念是让状态的更新可预测和透明。本文将简要探讨 Redux 的核心机制和实际应用。

在 Redux 中,有一个状态对象负责应用程序的整个状态.Redux store 是应用程序状态的唯一真实来源

如果应用程序想要更新状态,只能通过 Redux store 执行,单向数据流可以更轻松地对应用程序中的状态进行监测管理。

Redux store 是一个保存和管理应用程序状态的 state,使用 Redux 对象中的 createStore() 来创建一个 redux store,此方法将 reducer 函数作为必需参数.

1
2
3
const reducer = (state = 5) => state;

const store = Redux.createStore(reducer);

获取数据

Redux store 对象提供了几种允许你与之交互的方法,可以使用 getState() 方法检索 Redux store 对象中保存的当前的 state

1
2
3
4
5
6
const store = Redux.createStore(
(state = 5) => state
);

// 更改此行下方的代码
const currentState = store.getState();

更新状态

由于 Redux 是一个状态管理框架,因此更新状态是其核心任务之一。在 Redux 中,所有状态更新都由 dispatch action 触发,action 只是一个 JavaScript 对象,其中包含有关已发生的 action 事件的信息。

Redux store 接收这些 action 对象,然后更新相应的状态。action 对象中必须要带有 type 属性,reducer 才能根据 type 进行区分处理。
action 除了 type 属性外,还可以附带数据给 reducer 做相应的处理,这个数据是可选的。

我们可以将 Redux action 视为信使,将有关应用程序中发生的事件信息提供给 Redux store,然后 store 根据发生的 action 进行状态的更新。

reducer

reducer 将 state 和 action 作为参数,并且它总是返回一个新的 state。这是 reducer 的唯一的作用,它不应有任何其他的作用。比如它不应调用 API 接口,也不应存在任何潜在的副作用。reducer 只是一个接受状态和动作,然后返回新状态的纯函数

在 reducer 中一般通过 switch 进行判断 action 的类型,做不同的处理。

订阅事件

store.subscribe() 可以订阅 store 的数据变化,它接收一个回调函数作为参数。当 store 数据更新时会调用该回调函数。

模块划分

当应用程序的状态开始变得越来越复杂时,将状态划分为多个部分可能是个更好的选择。我们可以考虑将不同的模块进行划分,Login 作为一个模块,Account 作为另一个模块。

但对 state 进行模块划分也不能破坏 redux 中将数据存入简单 state 的原则。因此可以生成多个 reducer, 再将它们合并到 root reducer 中。

redux 提供了 combineReducers() 函数对 reducer 进行合并。它接收一个对象作为参数,对象中的 key/value 别分对应着 module name 和相对应的 reducer 函数。

1
2
3
4
5
6
7

const rootReducer = Redux.combineReducers({
counter: counterReducer,
auth: authReducer
})

const store = Redux.createStore(rootReducer);

异步

redux 本身是不能直接处理异步操作,因此需要引入中间件来处理这些问题。在 createStore 时,还可以传入第二个可选参数,这个参数就是传递给 redux 的中间件函数。

Redux 提供了 applyMiddleware() 来创建一个中间件,一般处理 redux 异步的中间件有 redux-thunkredux-saga 等。

redux-thunk

redux-thunk 允许 action 创建函数返回一个函数而不是一个 action 对象。这个返回的函数接收 dispatchgetState 作为参数,允许直接进行异步操作和状态的分发。

例如,一个异步获取数据的 thunk 可能如下所示:

1
2
3
4
5
6
7
8
9
function fetchData() {
return (dispatch, getState) => {
// 异步操作
fetch('some-api-url')
.then(response => response.json())
.then(data => dispatch({ type: 'FETCH_DATA_SUCCESS', payload: data }))
.catch(error => dispatch({ type: 'FETCH_DATA_ERROR', error }));
};
}

redux-saga

redux-saga 是一个更高级的中间件,它使用 ES6 的 Generator 函数来让你以同步的方式写异步代码。saga 监听发起的 action,并决定基于这些 action 执行哪些副作用(如异步获取数据、访问浏览器缓存等)。

一个简单的 saga 可能如下所示:

1
2
3
4
5
6
7
8
function* fetchDataSaga(action) {
try {
const data = yield call(fetch, 'some-api-url');
yield put({ type: 'FETCH_DATA_SUCCESS', payload: data });
} catch (error) {
yield put({ type: 'FETCH_DATA_ERROR', error });
}
}

React 与 Redux

在 React 应用中,Redux 被用来跨组件共享状态。使用 react-redux 库可以方便地将 Redux 集成到 React 应用中。

Provider 组件

Providerreact-redux 提供的一个组件,它使 Redux store 对 React 应用中的所有组件可用。通常,我们在应用的最顶层包裹 Provider 并传入 store:

1
2
3
4
5
6
7
8
import { Provider } from 'react-redux';
import { store } from './store';

const App = () => (
<Provider store={store}>
<MyRootComponent />
</Provider>
);

connect 函数

connect 是一个高阶函数,用于将 React 组件连接到 Redux store。它接受两个参数:mapStateToPropsmapDispatchToProps,分别用于从 store 中读取状态和向 store 发起 actions。

1
2
3
4
5
6
7
8
9
10
11
import { connect } from 'react-redux';

const mapStateToProps = state => ({
items: state.items
});

const mapDispatchToProps = dispatch => ({
fetchData: () => dispatch(fetchData())
});

export default connect(mapStateToProps, mapDispatchToProps)(MyComponent);

总结

Redux 提供了一种统一、可预测的方式来管理应用程序的状态。通过使用 actions, reducers 和 store,开发者可以以一种高度解耦的方式来管理状态和 UI。

当结合异步处理和 React 集成时,Redux 成为了一个强大的工具,能够提升大型应用程序的开发和维护效率。

计算机网络原理笔记

计算机网络原理学习笔记。

目录

下面目录定位有些使用不了。若需要跳转到具体段落应使用侧边栏文章定位


计算类速览

速率与带宽

  1. 传输时延:链路发送到结束所用的时间

    1
    dt = L(分组长度) / R(链路带宽)
  2. 传播时延: 从发送端到接收端传输所需的时间

    1
    dp = D(链路长度) / V(信号传播速度)
  3. 时延带宽(乘)积:传播时延和链路带宽的乘积

    1
    G = dp(传播时延) * R(链路带宽)

TCP 报文段确认号

题目一般是主机 A 会发送两个 TCP 报文段给主机 B,其实有字节序号分别为 n1 和 n2。

  1. 算携带了多少字节: 字节数=n2-n1
  2. 接收到第一个报文段返回的确认号是 n2
  3. 如果主机 B 接收到第二个报文段后的确认号是 n3, 问第二个携带多少字节: 字节数=n3-n2
  4. 如果第一段丢失了,第二段到达了,主机 B 返回的确认号是: n1, 即要求主机 B 重传之前没有接受到的数据

汉明距离

两个等长码字之间的,对应位不同的位数,成为两个码字的汉明距离。汉明距离是两个码字进行按位异或后 1 的个数。

1
2
3
4
5
6
01100101
10011101
-------- 进行异或(^)
11111000
--------
5个1,汉明距离 = 5

循环冗余码

多项式 G(x)=x4 + x3 + 1,对位串 101100111101 进行 CRC 编码,结果为:

G(x)=x4 + x3 + 1 对应的比特位为 11001,则在待编位串后面添加 0000.

位串除 11001


计算机网络概述

常见应用的端口号

应用端口号
TCP/FTP21
SMTP25
HTTP80
POP3 服务器110

TCP/IP、OSI 参考模型

OSI模型单位
7. 应用层报文
4. 传输层数据报/报文段
3. 网络层分组/包
2. 数据链路层
1. 物理层比特流

简述OSI参数模型物理层的主要功能及该层协议规定的四个特性。

物理层的主要功能是实现比特流的透明传输,为数据链路层提供数据传输服务。

物理层协议规定的特性包括:

  1. 机械特性
  2. 电气特性
  3. 功能特性
  4. 规程特性

应用层

网络协议的三要素包括

  • 语法: 定义实体之间交换信息的格式与结构
  • 语义: 定义实体之间交换的信息中需要发送哪些控制信息,这些信息的具体含义,以及针对不同含义的控制信息,接收信息端应如何响应。
  • 时序: 定义实体之间交换信息的顺序以及如何匹配或适应彼此的速度

简述典型的HTTP请求方法及其作用

  1. GET: 读取由 URL 所标识的信息
  2. POST: 给服务器添加信息
  3. HEAD: 请求读取由 URL 所标识的信息首部,无需在相应报文中包含对象
  4. OPTION: 请求一些选项的信息
  5. PUT: 在指明的 URL 下存储一个文档

简述 POP3 协议交互过程

POP3 是邮件读取协议,可用于接收邮件。

  1. 授权阶段: 用户代理需要向邮件服务器发送用户名和口令,服务器鉴别用户身份,授权访问邮箱。
  2. 事务处理阶段: 用户代理向邮件服务器发送 POP3 命令,实现邮件读取,为邮件做删除编辑、取消邮件删除标记以及获取邮件的统计信息等操作。
  3. 更新阶段: 客户发出来 quit 命令,结束 POP3 回话,服务器删除哪些被标记为删除的邮件。

传输层

传输层核心任务:为应用进程之间提供端到端的逻辑通信服务。

TCP/IP

核心层: 传输层
网络互联层核心协议: IP 协议

简述传输层所实现的功能

实现的功能:

  1. 传输层寻址
  2. 对应用层报文进行分段和重组
  3. 对报文进行差错检测
  4. 实现进程间端到端的可靠数据传输控制
  5. 面向应用层实现复用与分解
  6. 流量控制
  7. 拥塞控制

简述传输层实现可靠数据传输的主要措施

不可靠传输信道在数据传输中可能发生:

  1. 比特差错
  2. 乱序
  3. 数据丢失
  1. 差错控制: 利用差错编码实现数据报传输过程中的比特差检测(甚至是纠正)。
  2. 确认: 「接收方」向「发送方」反馈接受状态
  3. 重传: 「发送方」重新发送「接收方」没有正确接收到的数据
  4. 序号: 确保数据按序提交
  5. 计时器: 解决数据丢失问题

简述保证网络传输可靠性的确认与重传机制的概念

  • 确认是指数据分组接受节点再收到每个分组后,要求想发送节点会送正确接受分组的确认信息。
  • 在规定时间内,如果发送节点没有接收到「接收方」返回的确认信息,就认为该数据分组发送失败,发送节点会重传该数据分组。

简述差错控制的概念以及差错控制的基本方法

差错控制就是通过差错编码技术实现对信息传输的检测,并通过某种机制进行差错纠正和处理。

差错检测的基本方法有:

  1. 检错重发
  2. 检错丢弃
  3. 前向纠错
  4. 反馈校验

简述TCP所提供的面向连接服务

在生成报文开始传送之前,TCP 客户和服务器相互交换传输层的控制信息,完成握手。在客户进程与服务器进程的套接字之间建立一条逻辑的 TCP 连接。

简述为 UDP 套接字分配端口号的两种方法

  1. 传输层自动分配: 创建一个 UDP 套接字时,传输层自动为该套接字分配一个端口号,该端口号当前未被该主机任何其他 UDP 套接字使用。
  2. 手动绑定: 在创建 UDP 套接字后,通过调用 bind 函数来绑定一个特定的端口号。

简述 UDP 提供的服务的主要特征

  1. 应用进程更容易控制发送什么数据以及什么时候发送。
  2. 无需建立连接
  3. 无连接状态
  4. 首部开销小,仅有8字节的开销

网络层

网络层提供的功能有:

  1. 连接建立
  2. 路由
  3. 转发

简述虚电路的概念及其构成要素

虚电路是源主机到目的主机的一条路径上建立的一条网络层逻辑连接,成为虚电路。

comment: 因为是逻辑连接,不是真实的电路连接,故称为虚电路

一条虚电路由 3 个要素组成:

  1. 从源主机到目的主机之间的一条路径
  2. 该路径上每条链路各有一个虚电路标记(VCID)
  3. 该路径上每台分组交互机的转发表记录虚电路标识的接续关系

虚电路交换和数据交换的主要差别

  • 虚电路网络通常由网络完成顺序控制、差错控制和流量控制等功能,向端系统提供无差错数据传送服务,而端系统则可以很简单。
  • 数据报网络的顺序控制、差错控制和流量控制等功能需要由端系统完成,网络实现的功能很简单,比如基本的路由与转发功能。

电路交换的特点和优缺点

电路交换的特点是有连接的,在通信时需要先建立电路连接,在通讯过程中独占一个信道,在通讯结束后需要拆除电路连接。

优点: 实时性高,时延和时延抖动都较小
缺点: 对于突发性数据传输,信道利用率低,且传输速率单一。

简述永久虚电路与交换虚电路的区别

永久虚电路是一种提前建立、长期使用的虚电路,虚电路的建立时间开销基本上可以忽略。
交换虚电路是根据通信需要而临时建立的虚电路,通信结束后立即拆除,虚电路的建立和拆除时间有时相对影响较大。

简述路由器输入端口接受与处理数据的过程

输入端口接受信号,还原数据链路层帧,提取 IP 数据报,根据 IP 数据报的目的 IP 地址检索路由表,决策将数据报交换到哪个输出端口


数据链路层与局域网

数据链路层提供的服务有:

  1. 组帧
  2. 链路接入
  3. 可靠交付
  4. 差错控制

帧的组成

HDLC: 帧组成:

  1. 管理帧
  2. 信息帧
  3. 无序号帧

IEEE 802.11 帧:

  1. 管理帧
  2. 控制帧
  3. 数据帧

PPP (point to point protocol) 数据帧结构:

  1. 标志(01111110)
  2. 地址(11111111)
  3. 控制(00000011)
  4. 协议
  5. 信息
  6. 校验和
  7. 标志(01111110)

==== 多路访问控制协议 ====

非坚持 csma 的基本原理

  • 若通信站有数据发送,先监听信道,若发现信道空闲,则立即发送数据(与 1-坚持 CSMA 第一步一致)
  • 若发现信道忙,则等待一个随机时间,然后再重新监听信道,尝试发送数据。
  • 若发送数据时产生冲突,则等待一个随机时间,然后重新开始监听信道,尝试发送数据。

这是个做事不太着急的协议。将上面文绉绉的描述用通俗的话来理解是:它在寝室中想要去洗澡

  1. 它会先看看有没有人在用浴室,没人在用就直接去洗澡
  2. 去洗澡时发现有人也想用了,它会礼让给其他人。自个再晚一段时间再看看还有没有人用,没人用就自个用了
  3. 如果已经有人在用浴室了,那又晚点再看看

1-坚持 csma 的基本原理

  • 若通信站有数据发送,先监听信道,若发现信道空闲,则立即发送数据(与 非坚持 CSMA 第一步一致)
  • 若发现信道忙,则继续监听信道,直至发现信道空闲,然后立即发送数据。

通俗话理解: 顾名思义,坚持不懈。如果浴室有人用了,我就守在门口。有人出来我就立马进去。

==== 局域网 ====

简述地址解析协议 ARP 的作用和基本思想

ARP 用于根据本网内目的主机默认网关的 IP 地址获取其 MAC 地址。

基本思想是: 在每一台主机中设置专用内存区域作为 ARP 高速缓存区域,储存该主机所在局域网中其他主机和路由器(默认网关)的 IP 地址与 MAC 地址之间的映射,并且要经常更新这个映射表。

ARP 在局域网中通过广播 ARP 查询报文的方式,来询问某目的站的 IP 地址对应的 MAC 地址,即知道本网内某主机的 IP 地址就能知道它的 MAC 地址。

简述虚拟局域网(VLAN)的概念以及划分方法

虚拟局域网是一种基于交换机的逻辑分隔广播域的局域网应用形式。划分方法主要有 3 种:

  1. 基于交换机端口划分
  2. 基于 MAC 地址划分
  3. 基于上层协议或地址划分

物理层

简述 CMI 码的编码规则,并画出二进制比特序列 1011010011 的 CMI 码信号波形

CMI 码的编码规则是将信息码的 0 编码为双极不归零码的 01,信息码的 1 交替编码为双极不归零码的 11 和 00。

米勒码的编码规则

P229

  1. 信息码的 1 编码为「双极非归零码」的 01 或 10(占半格)
  2. 信息码连 1 时,后面的 1 要换编码
  3. 信息码的 0 编码为 00 或 11,中间码元不跳变(占一格)
  4. 单个 0 时不跳变
  5. 多个 0 时,间隔跳变
  6. (备注): 有两极

无线与移动网络

简述 4 个 IEEE 802.11 标准具有的共同特征

  1. 都使用相同介质访问协议 CSMA/CA。
  2. 链路层帧使用相同的帧格式
  3. 都具有降低传输速率以传输更远距离的能力
  4. 都支持“基础设施模式”和“自组织模式”两种模式

简答题

每个 AS 可以通过 BGP(边界网关协议) 实现哪些功能

AS: Autonomous system, 自治系统

  1. 从相邻 AS 获取某子网的可达性信息。
  2. 向本 AS 内部的所有路由器传播跨 AS 的某子网可达性信息。
  3. 基于某子网可达性信息和 AS 策略,觉得到达该子网的最佳路由

简述数字签名应满足的要求

  1. 接收方能够确认或证实发送方的签名,但不能伪造
  2. 发送发发送签名给接受方后,就不能否认他所签发的信息
  3. 接收方对已收到的签名信息不能再否认,既有收报认证
  4. 第三者可以确认收发双方之间的消息传送,但不能伪造这一过

基础计算

十进制转二进制

十进制转二进制主要的方法是除2取余,逆序排列法

可以写一个简单的 js 函数打印每次计算的结果。例如将整数 251 转为二进制的过程是:

点击展开详细代码
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 convertToBinary(n, buffer = []) {
const a = Math.floor(n / 2)
const b = n % 2;

buffer.push(b);
console.log(`${n} / 2 = ${a}...${b}`)

if (a === 0) {
const result = buffer.reverse().join('');
console.log(`将每次取到的余数逆转排序后,最终转换后的二进制是: ${result}`)

return result;
}

return convertToBinary(a, buffer);
}

convertToBinary(521);

/**
* 521 / 2 = 260...1
* 260 / 2 = 130...0
* 130 / 2 = 65...0
* 65 / 2 = 32...1
* 32 / 2 = 16...0
* 16 / 2 = 8...0
* 8 / 2 = 4...0
* 4 / 2 = 2...0
* 2 / 2 = 1...0
* 1 / 2 = 0...1
* 将每次取到的余数逆转排序后,最终转换后的二进制是: 1000001001
*/

// js 的 toString 方法还可以将数值转为指定进制
var fn = (n, base = 2) => n.toString(base);

fn(521); // "1000001001"

2^n 速查表

2 的 N 次方速查表
次方
2^12
2^24
2^38
2^416
2^532
2^664
2^7128
2^8256
2^9512
2^101024
2^112048
2^124096
2^138192
2^1416384
2^1532768
2^1665536
2^17131072
2^18262144
2^19524288
2^201048576

子网掩码速览

类别子网掩码十进制子网掩码二进制
A255.0.0.011111111 00000000 00000000 00000000
B255.255.0.011111111 11111111 00000000 00000000
C255.255.255.011111111 11111111 11111111 00000000

通过 IP 地址与子网掩码推算出其他信息

1
2
1. 子网地址: 主机 IP 地址 & 子网掩码
2. 广播地址: 子网地址 | 子网掩码反码

Accessibility Parsing 无障碍页面分析

最近项目需要做 Accessibility 的处理,在这段时间的接触了很多无障碍相关的技术。除了基础的 ARIA 和相对单位的影响等基础知识外,本篇想介绍一下比较实用的 Accessibility Parsing tools。


Nu Html Checker 是用于检查 HTML page 是否符合语义化,捕获可能会遗漏的错误,以便进行修复。它提供输入 URL 地址、上传 HTML 文件和文字输入的形式来检查代码。

MDN 文档 为例:

可以看到在 Input 框输入 URL 后,点击 check button 得到关于 HTML 语义化的分析报告。它主要分为两个等级:

  1. Error: 被标记为 Error 的问题是因为它们是可访问性、可用性、互操作性、安全性或可维护性的潜在问题。或者因为它们可能导致性能下降,或可能导致脚本以难以解决的方式失败。
  2. Warning: 被标记为 warning 的代码意味着这种写法不够好,但对 Accessibility 的问题影响有限。

可见 MDN 文档对于 Accessibility 的支持还是很不错的。

但如果我们的 page 是使用 SPA (single-page application),也就是页面的内容是动态生成的话。那就需要等内容加载完毕后,右键保存为 HTML 文件后再上传分析,或通过开发者工具直接 copy HTML 字符串进行分析。

下面任取一个 SPA 网站,直接 copy 网站的 HTML code 做分析,步骤如下:

  1. 当 SPA page 的内容加载完毕后,打开浏览器开发者工具。
  2. Tab 选择 Elements, 右键 <html> 节点,选择 Copy -> Copy outHTML
  3. 将 Nu HTML checker 中的 Check by 选为 text input, 在 input 框粘贴刚才 copy 的 HTML code。
  4. 由于 copy 中不会把 <!DOCTYPE html> 带过来,因此我们还得加上这句。
  5. 点击 check button。

可以发现该页面可以优化的东西还有很多,但错误数量一多就有点眼花缭乱的。因此 Nu Html Checker 还提供 Message Filtering 以展示关键的信息:

当然,有时候我们主要想关注这个 page 关于 accessibility 相关的结果,不想看那么多额外的信息。此时就可以使用 WCAG Parsing Validation Filter bookmarklet 生成 accessibility 的信息简报。

该篇文档讲的东西其实很简单,无非就是它们提供了一段代码,用于提取关于 accessibility 的信息:

1
javascript:(function(){var filterStrings=["tag seen","Stray end tag","Bad start tag","violates nesting rules","Duplicate ID","Unclosed element","not allowed as child of element","unclosed elements","unquoted attribute value","Duplicate attribute","descendant of an element with the attribute"],filterRE=filterStrings.join("|"),i,nT=0,nP1=0,result,resultText,results,resultsP1={},root=document.getElementById("results");if(!root){return}results=root.getElementsByTagName("li");for(i=results.length-1;i>=0;i--){result=results[i];if(result.id.substr(0,3)==="vnu"){if(result.className!=="info"){nT=nT+1}resultText=""+result.textContent;resultText=resultText.substring(0,resultText.indexOf("."));if(resultText.match(filterRE)==null){result.style.display="none";result.className=result.className+"a11y-ignore"}else{resultsP1[resultText.substr(7)]=true;nP1=nP1+1}}}resultText="";for(i in resultsP1){if(resultsP1.hasOwnProperty(i)){resultText=i+"; "+resultText}}var str=nT+" validation errors and warnings.\n"+nP1+" errors that may impact accessibility:\n"+resultText;console.log("%c[WCAG Parsing Validation Filter bookmarklet@v4]:\n","font-weight: bold","https://labs.diginclusion.com/tools/bookmarklets/wcag-parsing-filter/\n\n"+str);alert(str)})();

以上是一段自执行的 JavaScript 代码,源代码是直接将信息通过 alert 打印出来。但我觉得不太方便复制,因此在原基础上增加了 console 的输出方式,便于复制信息。

使用方式

  1. 打开浏览器书签管理器 (bookmark manage)
  2. 如果使用的是 chrome 浏览器的话,在右上角点开 “Add new bookmark”
  3. 书签名可自己决定,URL 输入如上代码即可,保存书签
  4. 在 Nu Html Checker 解析后的结果页中,点击刚才新建的标签就能看到弹出来的结果了

上图 console 中输出的信息如下:

1
2
3
4
5
[WCAG Parsing Validation Filter bookmarklet@v4]: https://labs.diginclusion.com/tools/bookmarklets/wcag-parsing-filter/

91 validation errors and warnings.
6 errors that may impact accessibility:
Element a not allowed as child of element ul in this context; Element object not allowed as child of element ul in this context; Element style not allowed as child of element body in this context;

从这份信息简报我们可以了解到:这个 page 有 91 个 errorswarnings。其中有 6 份错误会影响 accessibility。主要的错误是因为不合理的标签嵌套所引起的错误。这份简报就已经将我们所期望了解的信息都简短的概括了出,便于我们分析。

软件工程笔记

软件工程相关笔记。


1. 绪论

简述软件危机与软件工程的概念以及提出软件工程概念的目的

  1. 软件危机: 是指软件生产率、软件质量远远不能满足社会发展的需求,成为社会、经济发展的制约因素的现象
  2. 软件工程: 是应用计算机科学理论和技术以及工程管理原则和方法,按预算和进度实现满足用户需求的软件产品的工程,或以此为研究对象的学科
  3. 提出软件工程概念的目的: 软件工程是倡导以工程的原理、原则和方法进行开发,以期解决出现的软件危机

简述软件开发的本质

不同抽象层术语之间的 “映射”,以及不同抽象层处理逻辑之间的映射。

2. 软件需求与软件需求规约

初始发现需求

初始发现需求的常见技术包括: 自悟、交谈、观察、小组会、提炼

需求规约定义

需求规约是一个软件项/产品/系统所有需求陈述的正式文档。它表述了软件产品/系统的概念模型。

它一般满足以下四点性质:

  1. 重要性和稳定性程度: 按需求的重要性和稳定性,对需求进行分级
  2. 可修改的: 在不过多影响其他需求的前提下,可以容易修改单一的需求。
  3. 完整的: 没有被遗漏的需求
  4. 一致的: 不存在互斥的需求

需求规约的三种基本形式

  1. 非形式化的需求规约
  2. 半形式化的需求规约
  3. 形式化的需求规约

3. 结构化方法

模块的内聚性

内聚是测量一个模块化系统好坏的标准之一。主要分为7种评分:

  • 功能内聚 10
  • 顺序内聚 9
  • 通信内聚 7
  • 步骤内聚 5
  • 时间内聚 3
  • 逻辑内聚 1
  • 偶然内聚 0

前三种是可以接收的,后四种要尽量避免。

模块间的耦合类型

常见的模块间耦合类型有 5 种,由强到弱有: 内容耦合、公共耦合、控制耦合、标记耦合、数据耦合。

程序流程图主要用于软件开发的哪一阶段?它的主要优缺点有哪些?

主要应用于 软件的详细设计阶段

  • 主要优点是: 对控制流程的描绘很直观,便于初学者掌握

  • 主要缺点是:

    1. 不是一种逐步求精的工具,它诱使程序员过早地考虑程序的控制流程,而不考虑程序的全局结构。
    2. 所表达的控制流,往往不受任何约束,可随意转移。从而影响甚至破坏好的系统结构
    3. 不易表达数据结构

系统流程图与数据流程图有什么区别?

  1. 系统流程图是描述系统物理模型的工具,数据流程图是描述系统逻辑模型的工具。
  2. 系统流程图系统功能的角度抽象地描述系统的各个部分及其相互之间信息流动的情况
  3. 数据流程图数据传送和加工的角度抽象地描述信息在系统中流动和数据处理的情况

演化模型的主要特征是什么?它存在什么不足?

  1. 该模型显式地把需求获取扩展到需求阶段,即为了第二个构造增量,使用了第一个构造增量来精化需求。
  2. 演化模型在一定程度上可以减少软件开发活动的满目性。

不足: 在演化模型的使用中,即便很好地理解了需求或设计,也很容易弱化需求分析阶段的工作

4. RUP、UML

图形工具的用途

  1. 类图: 可视化表达系统静态结构模型的工具。
  2. 用况图: 表达系统功能模型的图形化工具。
  3. 状态图: 显示一个状态机的图。
  4. 顺序图: 一种交互图。由一组对象以及时序组织的对象之间的关系组成。

什么是 UML? 它有什么特点?

UML: Unified Modeling Language, 统一建模语言

  1. UML 是面向对象方法,它是一种根据客体之间的关系来构造系统模型的系统化方法。
  2. UML 是一种可视化语言, 可用于规约系统的制品、构造系统的制品,建立系统制品的文档。这意味着 UML 可作为软件需求规约、设计和实现的工具。
  3. UML 给出了方法学中不同抽象层次术语以及模型表达工具。

简述泛化的概念及其约束

泛化是一般性类目(父类)和它的较为特殊的类目(子类)之间的关系,有时称为 “is-a-kind-of” 关系,UML 给出了 4 个约束:

  1. 完整
  2. 不完整
  3. 互斥
  4. 重叠

RUP 的特点之一是迭代、增量式开发,它规定了 4 个开发阶段。请简述每次迭代在各阶段的目标。

  1. 初始阶段的基本目标:获得与特定用况和平台无关的系统体系结构轮廓,已建立产品功能范围;编制初始业务示例,从业务角度指出该项目的价值,减少项目主要错误风险。
  2. 精化阶段的基本目标:捕获并描述系统的大部分需求,建立系统体系结构基线的第一个版本,主要包括用况模型和分析模型,减少次要的错误风险;到该阶段末,就能估算成本、进度,并能详细地规划构造阶段。
  3. 构造阶段的基本目标:通过演化,形成最终的系统体系结构基线,开发完整的系统,确保产品可以开始向客户交付。
  4. 移交阶段的基本目标:确保有一个实在的产品发布给用户群。

简述 RUP 和 UML 之间的关系

  1. RUP 和 UML 构成了一种特定的软件开发方法学
  2. UML 作为一种可视化建模语言,给出了表达事务和事务之间关系的基本术语,给出了多种模型的表达工具。
  3. RUP 利用 UML 的术语定义了 需求获取层系统建模层设计层实现层,并给出各层模型映射的基本活动以及相关的指导。

简述需求分析与软件设计两个阶段任务的主要区别

需求分析阶段的主要任务是定义软件的用户需求,即定义待开发的软件能做什么

软件设计阶段的主要任务是定义软件的实现细节,以满足用户需求,即研究如何实现软件

简述事务设计的基本步骤

  1. 设计准备,复审并精化系统的模型
  2. 确定事务处理中心
  3. 设计系统模块结构图的顶层和第一层
  4. 自顶向下,逐步求精

6. 软件测试

软件测试技术一般分为白盒测试技术和黑盒测试技术。

白盒测试法和黑盒测试法的区别是什么?

白盒测试法完全了解程序的结构和处理过程,这种方法按照程序内部的逻辑结构以及有关信息设计或选择测试你用例,检查程序中每条通路是否都能按照预定要求正确工作。

黑盒测试法着眼于软件的外部特征,不考虑软件的内部逻辑和内部特征,只依据程序的需求规格说明书检查是否满足功能要求,测试要在软件的接口处进行。

简述软件测试的基本步骤

  1. 单元测试: 主要检验软件设计的最小单元——模块。该测试以详细设计文档为指导,测试模块内的重要控制路径。
  2. 集成测试: 集成测试是软件组装的一个系统化技术,其目标是发现与接口有关的错误。将经过单元测试的模块构成一个满足设计要求的软件结构。
  3. 有效性测试: 目标是发现软件实现的功能与需求规格说明书不一致的地方
  4. 系统测试: 验证将软件运行于更大系统中时整个系统的有效性。

简述路径测试中几种典型的测试策略。

  1. 路径覆盖: 执行所有与可能穿过程序控制流的路径。(T 条件走一遍)
  2. 语句覆盖: 至少执行过程中所有语句一次。
  3. 分支覆盖: 至少将程序中每一个分支执行一次。
  4. 条件覆盖: 每个判定的所有可能的条件取值至少执行一次。(全取假值)
  5. 条件组合覆盖: 设计足够多的测试用例,使每个判定中的所有可能的条件取值组合至少执行一次。

简述因果图方法生成测试用例的基本步骤

  1. 通过对软件规格说明书的分析,找出一个模块的原因和结果,并给每个原因和结果赋予一个标识符。
  2. 分析原因和结果之间以及原因与原因之间对应的关系,并画出因果图
  3. 在因果图上表示一些特定的约束或限制条件
  4. 把因果图转换判定表
  5. 为判定表的每一列设计测试用例

7. 软件生存周期过程与管理

软件生存周期模型

时间顺序:

  • 瀑布模型(1970, 20世纪60年代到80年代的主要成果)
  • 演化模型
  • 螺旋模型(1988, 加入了前两者所忽略的风险分析)
  • 喷泉模型: 体现了软件创建所固有的迭代和无间隙的特征。

简述演化模型及其主要特征

演化模型主要针对事先不能完整定义需求的软件开发,在用户提出待开发系统的核心需求的基础上,软件开发人员首先开发一个核心系统并投入运行,以便用户能够有效地提出反馈,即提出精化系统能力的需求。

接着,软件开发人员根据用户反馈,实施开发的迭代过程均由需求,设计,编码、测试、集成等阶段组成。为整个系统添加一个可定制的、可管理的子集;如果在一次迭代中,有的需求不能满足用户的要求,可在下一次迭代中予以修正。

演化模型的主要特征是: 该模型显式地把需求获取扩展到需求阶段,即为了第二个构造增量使用,使用第一个构造增量来精化需求。

8. 集成化能力成熟度模型

能力等级

一种过程改善路径,该路径可使组织针对单一过程域不断改善该过程域。

  • 0 级: 未完成级
  • 1 级: 已执行级
  • 2 级: 已管理级
  • 3 级: 已定义级
  • 4 级: 已定量管理级
  • 5 级: 持续优化级

成熟度等级

一种过程改善路径,该路径可使组织针对一组过程域不断改善该过程域。

  • 1 级: 初始级
  • 2 级: 已管理级
  • 3 级: 已定义级
  • 4 级: 已定量管理级
  • 5 级: 持续优化级

能力等级和成熟度等级等级只有 1 级名称不同,2~5 是相等名称。

其他

  1. 计算机软件一般是指计算机系统中的程序及其 文档(或数据和文档)
  2. 对于单一一个需求,必须具有的基本性质: 必要的无歧义的可追踪的可测试的可测量的
  3. 需求人员通过提出问题/用户回答的方式,直接询问用户需要的初始发现需求技术是 交谈
  4. 在结构化分析方法中,表示 “数据的静态结构“ 的术语是 数据存储
  5. 为保证加入的模块没有引进新的错误,可能需要进行回归测试。
  6. CMMI 的成熟度等级和能力等级还可用于评选活动估算

JavaScript 实现二叉树

二叉树数据结构的学习与笔记。

目录

二叉树的储存结构

二叉树有两种储存方式,一种是顺序储存结构,一种是链式储存结构。

顺序储存结构就是二叉树从上至下,每层从左到右给树中节点进行编号:

1
[0,1,2,3,4,5,6]

0 是根节点,1 是根的左节点,2 是根的右节点,3 是根的左节点的左节点,4 是根的左节点的右节点…… 依照这个顺序排列下去。设 i 为顺序表中节点的索引, Qi 代表顺序表上储存的节点, n 为顺序表的长度,则可知:

  1. i = 0Qi 节点是根节点
  2. 2i+1 < n, 则索引 2i+1 上储存的是 Qi 的左节点。反之,则没有节点。
  3. 2i+2 < n, 则索引 2i+2 上储存的是 Qi 的右节点。反之,则没有节点。
  4. **Qi 的双亲节点的索引为 (i-1)/2**。比如 i=4, (i-1)/2 向下取整等于 1, 索引为 4 的双亲节点为 1

链式储存的结构大致如下:

1
2
3
4
5
6
7
8
9
10
class TreeNode {
val: number
left: TreeNode | null
right: TreeNode | null
constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
this.val = (val===undefined ? 0 : val)
this.left = (left===undefined ? null : left)
this.right = (right===undefined ? null : right)
}
}

顺序结构转链式结构

利用二叉树的性质,可以将顺序储存方式转换为对应的链式结构:

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
class TreeNode {
constructor(val, left, right) {
this.val = (val === undefined ? 0 : val)
this.left = (left === undefined ? null : left)
this.right = (right === undefined ? null : right)
}
}

function toLinkedListBinaryTree(list) {
// 临时用于储存被转换为链表的节点
const nodelist = [];

for (let i = 0; i < list.length; i++) {
const node = new TreeNode(list[i]);
nodelist.push(node);

// 根节点没有双亲节点
if (i > 0) {
// 由结论 4 可得双亲节点的索引
const parentIdx = Math.floor((i - 1) / 2);
const parent = nodelist[parentIdx];

// 当前层从左向右赋值,若左节点被赋值,则剩下右节点没有被赋值
if (parent.left) {
parent.right = node;
} else {
parent.left = node;
}
}

}

return nodelist.shift()
}

// 在 console 进行测试
cnsole.log(toLinkedListBinaryTree([0,1,2,3,4,5,6,7,8,9]));

二叉树的遍历

遍历二叉树是指沿着某条搜索路径周游二叉树,依次对树中的每个节点访问且仅访问一次。

二叉树的遍历方式可以分为递归非递归方式。遍历算法也可以分为**深度优先搜索 (Depth-First-Search,DFS)广度优先搜索 (Breadth-First Search)**。

根据二叉树的递归定义,遍历一颗非空二叉树的问题可分为三个子问题: 访问根节点 (D),遍历左子树 (L),遍历右子树 (R)。遍历的顺序可分为: DLR (前序)、LDR (中序)、LRD (后序) 和 DRL (前序)、RDL (中序)、RLD (后序)。前三种是先左后右,后三种是先右后左。一般没有提别指明的话,我们谈论二叉树的遍历,都是在讲前三种。

二叉树的前序遍历、中序遍历、后序遍历都可以通过递归方式非递归方式实现。

前序序遍历

递归形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
function preorderTraversal(root: TreeNode | null): number[] {
return postorder(root, [])
};

function postorder(root?: TreeNode, result = []): number[] {
if (!root) return result;

result.push(root.val);
postorder(root.left, result);
postorder(root.right, result);

return result;
}

中序遍历

递归形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
function inorderTraversal(root: TreeNode | null): number[] {
return inorder(root, [])
};

function inorder(root?: TreeNode, result = []): number[] {
if (!root) return result;

inorder(root.left, result);
result.push(root.val);
inorder(root.right, result);

return result;
}

后序遍历

递归形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
function postorderTraversal(root: TreeNode | null): number[] {
return postorder(root, [])
};

function postorder(root?: TreeNode, result = []): number[] {
if (!root) return result;

postorder(root.left, result);
postorder(root.right, result);
result.push(root.val);

return result;
}

层序遍历

层序遍历就是把二叉树分层,然后每一层从左到右遍历:

层序遍历二叉树很自然就能想到使用 BFS(广度优先搜索) 来遍历每层。

该算法采用一个队列来缓存二叉树的节点,若树不为空,先将二叉树根节点输出,先将根节点入队,再到循环体内出队。若根节点还有左孩子,则将左孩子也添加到队列中。若有右孩子,也将右孩子也添加到队列中。如此下去,直到队列为空:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 按层输出二叉树的值
function levelOrder(root: TreeNode | null) {
if (!root) return;

// 队列,先进先出
const queue = [root];

while (queue.length) {
// 取队首的元素
const node = queue.shift();
console.log('node --> ', node.val)

// 若有左右节点,则添加至队列
if (node.left) queue.push(node.left);
if (node.right) queue.push(node.right);
}
};

若想将每一层的值都存入数组中,则可以采用二维数组进行储存:

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
function levelOrder(root: TreeNode | null): number[][] {
if (!root) return [];

// 最终会返回的结果
const result = [];

// 队列,先进先出
const queue = [root];

while (queue.length) {
// 当前层级
const level = [];

// 当前队列的长度
const n = queue.length;

for (let i = 0; i < n; i += 1) {
const node = queue.shift();
level.push(node.val);

// 若有左右节点,则添加至队列
// 由于已经储存上一轮的节点数,因此这里不会影响 n 的值
if (node.left) queue.push(node.left);
if (node.right) queue.push(node.right);
}

result.push(level);
}

return result;
};

合并二叉树

不考虑副作用的话,可以直接将 root1 作为结果,修改 root1 的值即可。

1
2
3
4
5
6
7
8
9
function mergeTrees(root1?: TreeNode, root2?: TreeNode): TreeNode | null {
if (!root1 || !root2) return root1 || root2;

root1.val += root2.val;
root1.left = mergeTrees(root1.left, root2.left);
root1.right = mergeTrees(root1.right, root2.right);

return root1;
};

二叉排序树 (BST)

二叉排序树(Binary Sort Tree)又称二叉查找树,它是一种特殊的二叉树,它或为空树,或具有以下性质的二叉树:

  1. 它的右子树非空,则右子树上所有节点的值都大于根节点的值。
  2. 它的左子树非空,则左子树上所有节点的值都小于根节点的值。
  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
function sortedArrayToBST(nums: number[]): TreeNode | null {
let tree = null, node;

while(nums.length) {
node = new TreeNode(nums.shift())
tree = insertBST(tree, node)
}

return tree;
};

function insertBST(tree: TreeNode, node: TreeNode) {
let parent, p = tree;

while(p) {
// parent 指向 p 的双亲
parent = p;

// 要插入的节点的值小于 p 的值,赋值为左节点
// 要插入的节点的值大于 p 的值,赋值为右节点
p = node.val < p.val ? p.left : p.right;
}

if (tree == null) return node;

// console.log('p',parent.val, node.val)
if(node.val < parent.val) {
parent.left = node;
} else {
parent.right = node;
}

return tree;
}

高度平衡二叉搜索树

高度平衡二叉树是一棵满足「每个节点的左右两个子树的高度差的绝对值不超过 1」的二叉树。

Q: 给定已按升序排序的整数数组,将其构建为二叉树。

A: 因为数组已经排过序了,因此可以直接采用二分法进行构建。先去中间的元素,再向两侧递归构建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function sortedArrayToBST(nums: number[]): TreeNode | null {
return dfs(nums, 0, nums.length - 1)
};

function dfs(nums: number[], min: number, max: number): TreeNode | null {
if (min > max) return null;

// 取中间的索引,先减后加的方式可以避免索引值溢出
const mid = min + Math.floor((max - min) / 2);

// 由于是采用二分法,因此左右子树的高度差不会超过 1
const root = new TreeNode(
nums[mid],
dfs(nums, min, mid - 1),
dfs(nums, mid + 1, max)
);

return root;
}

判断指定树是否是平衡树

可以采用自底向上进行遍历,该遍历方法类似于后序遍历:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function isBalanced(root: TreeNode | null): boolean {
return height(root) !== -1;
};

function height(root?: TreeNode) {
if (!root) return 0;

const left = height(root.left);
if (left == -1) return -1;

const right = height(root.right);
if (right == -1) return -1;

// 高度差超过 1
if (Math.abs(left - right) > 1) return -1;

// 当前层 + 1
return Math.max(left, right) + 1;
}
  • 时间复杂度:O(n)O(n),其中 nn 是二叉树中的节点个数。使用自底向上的递归,每个节点的计算高度和判断是否平衡都只需要处理一次,最坏情况下需要遍历二叉树中的所有节点,因此时间复杂度是 O(n)O(n)。
  • 空间复杂度:O(n)O(n),其中 nn 是二叉树中的节点个数。空间复杂度主要取决于递归调用的层数,递归调用的层数不会超过 nn。

闭包与链式设计的使用示例

最近遇到了个按需请求数据的需求,非常适合用于讲解闭包与链式设计的例子,故来分享一下思路。

大致需求如下: 目前有个 list, list 中每项 item 都是可展开的折叠项。当展开某个折叠项时,需要根据 item 的 code 另外去取 name 的映射。考虑到列表的数据量非常大,且一次性查询过多 code 时,接口的查询效率会明显降低,故采用按需请求映射的方案。

屏蔽与本例无关的属性,瘦身后的 list 数据结构大致如下:

1
2
3
4
5
6
interface DataType {
code: string;
paymentTransaction: string[];
}

type ListType = DataType[];

我们知道大型企业中的数据会比较复杂,比较常见的一种情况是数据中有一个 id 或 code 是用于跟另一个数据项相关联的。学习过数据库的同学很容易就联想到了外键这个概念。

现在我们就要取出这些 code 发送给服务端去查询。考虑到 code 可能会有重复,因此可以将 codes 存入 Set 中,利用 Set 的特性去重。除此之外,为了使 name 映射可以被复用,每次从接口返回的 name 映射将会被缓存起来。若下次再触发事件时有对应的 key,便不再查询。

我们可以将这段逻辑抽离出来作为一个依赖收集的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const mapping = new Map();

function collectionCodes(initCodes?: string[] | Set<string>) {
const codes = new Set<string>(initCodes)

return {
append(code: string) {
if (!mapping.has(code)) {
codes.add(code);
}

return this;
},
empty() {
return !codes.size;
},
value() {
return codes;
},
}
}

collectionCodes 函数是用于收集 codes。它内部利用了闭包的特性将 codes 缓存了起来,并且在添加新的 code 之前会判断 code 在 local 的映射中是否已经存在。append 返回的 this 是经典的链式调用设计,允许多次链式添加。当本次依赖收集结束后,调用 value 方法获取最终的 codes。

可以写一些简单的 mock 数据进行尝试:

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
function handleNameMapping(data: DataType) {
const codes = collectionCodes()
.append(data.code)
.append('code-append-1')
.append('code-append-1')
.append('code-append-2');

data.paymentTransaction.forEach(code => codes.append(code));

if (codes.empty()) {
console.log('can get values from existing mapping.')
return;
}

// 如果请求的数据需要转为数组,可以 Array.from 进行转换
const list = Array.from(codes.value());
console.log('fetch data before, codes --> ', list);

// mock 获取数据后拿到 name mapping 后,存入 mapping 中的行为.
// 注意,Set 类型也可以用 forEach 方法,不一定得转为数组才可以操作
list.forEach(code => mapping.set(code, `random-name-${Math.random()}`))
}

const mockItemData = {
code: 'code-main',
paymentTransaction: [
'code-payment-4',
'code-payment-1',
'code-payment-2',
'code-payment-1',
'code-payment-3',
]
}

handleNameMapping(mockItemData);
// fetch data before, codes --> (7) ["code-main", "code-append-1", "code-append-2", "code-payment-4", "code-payment-1", "code-payment-2", "code-payment-3"]

handleNameMapping(mockItemData);
// can get values from existing mapping.

handleNameMapping 在发起请求前会做 code 收集,若本次收集中没有需要 fetch 的 code,那就避免发送无用的 HTTP 请求,从而达到了优化的目的。

最终示例的 TS 代码如下。若想直接在控制台尝试效果的话,可以通过 ts 官网中的 Playground 编译为可直接运行的 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
interface DataType {
code: string;
paymentTransaction: string[];
}

const mapping = new Map();

function collectionCodes(initCodes?: string[] | Set<string>) {
const codes = new Set<string>(initCodes);

return {
append(code: string) {
if (!mapping.has(code)) {
codes.add(code);
}

return this;
},
empty() {
return !codes.size;
},
value() {
return codes;
},
};
}

function handleNameMapping(data: DataType) {
const codes = collectionCodes()
.append(data.code)
.append('code-append-1')
.append('code-append-1')
.append('code-append-2');

data.paymentTransaction.forEach((code) => codes.append(code));

if (codes.empty()) {
console.log('can get values from existing mapping.');
return;
}

// 如果请求的数据需要转为数组,可以 Array.from 进行转换
const list = Array.from(codes.value());
console.log('fetch data before, codes --> ', list);

// mock 获取数据后拿到 name mapping 后,存入 mapping 中的行为.
// 注意,Set 类型也可以用 forEach 方法,不一定得转为数组才可以操作
list.forEach(code => mapping.set(code, `random-name-${Math.random()}`))
}

const mockItemData = {
code: 'code-main',
paymentTransaction: [
'code-payment-4',
'code-payment-1',
'code-payment-2',
'code-payment-1',
'code-payment-3',
],
};

handleNameMapping(mockItemData);
// fetch data before, codes --> (7) ["code-main", "code-append-1", "code-append-2", "code-payment-4", "code-payment-1", "code-payment-2", "code-payment-3"]

handleNameMapping(mockItemData);
// can get values from existing mapping.

本例的分析就到此结束了,虽然在本例中链式调用没有充分展示出自己的优势,但也可以作为一个设计思路用于参考。

React 知识回顾 (优化篇)

接下来对 React 性能相关的问题进行知识回顾。

完整目录概览

React 代码复用

  • Render Props
  • 高阶组件 (HOC)
  • 自定义 Hooks
  • Mixins (已被 React 废弃)

Render props

Render props 是一种在 React 组件之间共享代码的简单技术。具体的行为是:

  1. 子组件接收一个用于渲染指定视图的 prop 属性,该属性的类型是函数。
  2. 父组件在组件内部定义该函数后,将函数的引入传给子组件
  3. 子组件将组件内部 state 作为实参传给从外面传来的函数,并将函数的返回结果渲染在指定的视图区域。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 组件使用
<Mouse render={(x, y) => <span>x: {x}, y: {y}</span>} />

// 组件内部大致实现
class Mouse extends React.Component {
state = { x: 0, y: 0 };

render() {
return (
<section>
<header>头部信息</header>
<main>{this.props.render(this.state)}</main>
<footer>底部信息</footer>
</section>
);
}
}

准确来说 Render props 是一个用于告知组件需要渲染什么内容的函数属性。props 的命名可以由自己定义,比如用于在内容区域渲染的 prop 名可以叫 render,同时还可以再接收一个 renderHead 的 prop 用于渲染头部的信息。

高阶函数、高阶组件分别是什么?

高阶函数就是接收其它函数作为参数的函数就称之为高阶函数,像数组的 mapsortfilter 都是高阶函数。

高阶组件(Higher-order component, HOC) 是 React 用于复用组件逻辑的一种高级技巧。它具体的行为是:

函数接收一个组件作为参数,在函数体内定义一个新组件,新组件内编写可复用的逻辑并应用到参数组件中。最后再将新组件作为函数的返回值 return 出去。
redux 中的 connect 函数就是一个高阶组件。

React 性能优化

  1. 对比 props/state 新旧值的变化来决定是否渲染组件,参见:父组件在执行 render 时会不会触发子组件的 render 事件?如果会该怎么避免?
  2. 列表渲染时每项添加唯一的 key。参见:渲染列表为啥要用 key?
  3. 定时器、DOM 事件等在组件销毁时一同销毁,从而避免内存泄露。
  4. 代码分割,使用异步组件。
  5. Hooks 使用 useMemo 缓存上一次计算的结果,避免重复计算值。

父组件在执行 render 时会不会触发子组件的 render 事件?如果会该怎么避免?

如果父组件渲染后,子组件接收的 props 也跟着发生了改变,那么默认情况下会触发子组件的渲染。

若子组件接受的 props 没有发生改变,那就得判断子组件的状况。

如果子组件是继承于 Component 声明的组件,并且没有使用 shouldComponentUpdate 做避免重复渲染的处理,那么子组件会触发 render 事件。

为了避免重复渲染,类组件可以使用 shouldComponentUpdate 来决定是否进行渲染。也可以将继承于 Component 组件改为继承 PureComponment,该组件会浅对比 Props 是否进行改变,从而决定是否渲染组件。

如果是函数组件,可以通过 React.memo 来对函数组件进行缓存。

渲染列表为啥要用 key?

渲染列表时,如果不给列表子项传 key 的话,React 将默认使用 index 作为 key,同时会在控制台发出警告。

key 在兄弟节点之间必须唯一,要避免使用数组下标 index 作为 key。因为使用数组下标作为 `key 时,若数组的顺序发生了改变,将会影响 Diffing 算法的效率。

若列表的节点是组件的话,还可能会影响组件的 state 数据。因为组件实例是基于 key 来决定是否更新与复用。当顺序发生了变化,则 key 也会相应得被修改,从而导致子组件间的数据错乱。

React 使用的 Diffing 算法是通过 tagkey 判断是否是同一个元素(sameNode)。使用唯一的 key 有助于 React 识别哪些元素发生改变,如节点添加或删除。这样有助于减少渲染次数,从而优化性能。

如果数组中的数据没有唯一的 key,可以引入 shortid 预先给数组中每项数据生成唯一的 id

1
2
3
4
5
6
7
8
9
10
const shortid = require('shortid');

function addId(data) {
return {
...data,
id: shortid.generate(),
}
}

const newList = list.map(addId);

若确定没有列表的顺序不会发生变化同时没有其他唯一的 key 来标识列表项时才能使用数组的下标。

虚拟 dom 是如何提升性能的

当组件触发更新时,虚拟 DOM 通过 Diffing 算法比对新旧节点的变化以决定是否渲染 DOM 节点,从而减少渲染提升性能。因为修改真实 DOM 所耗费的性能远比操作 JavaScript 多几倍,因此使用虚拟 DOM 在渲染性能上会高效的多。

简述 React Diffing 算法

Diffing 算法(Diffing Algorithm) 会先比较两个根元素的变化:

  1. 节点类型变化时,将会卸载原有的树而建立新树。如父节点 <div> 标签被修改为 <section> 标签,则它们自身及 children 下的节点都会被重新渲染。
  2. DOM 节点类型相同时,保留相同的 DOM 节点,仅更新发生改变的属性。
  3. 组件类型相同时,组件更新时组件实例保持不变,React 将更新组件实例的 props, 并调用生命周期 componentWillReceiveProps()componentwillupdate(),最后再调用 render。若 render 中还有子组件,将递归触发 Diff。
  4. 列表节点发生变化,列表项没有设置 key 时, 那么 Diffing 算法会逐个对比节点的变化。如果是尾部新增节点,那 Diff 算法会 Diff 到列表末尾,仅新增元素即可,不会有其他的性能损耗。若新增的数据不在数组的尾部而是在中间,那么 Diffing 算法比较到中间时判断出节点发生变化,将会丢弃后面所有节点并重新渲染。
  5. 列表节点发生变化,列表项有设置 key 时, React 可以通过 key 来匹配新旧节点间的对应关系,可以很快完成 Diff 并避免重复渲染的问题。

异步组件怎么使用?

  1. 通过动态 import() 语法对组件代码进行分割。

  2. 使用 React.lazy 函数,结合 import() 语法引入动态组件。在组件首次渲染时,会自动导入包含 MyComponent 的包。

    1
    const MyComponent = React.lazy(() => import('./MyComponent'));
  3. React.Suspense 组件中渲染 lazy 组件,同时可以使用 fallback 做优雅降级(添加 loading 效果):

    1
    2
    3
    <React.Suspense fallback={<div>Loading...</div>}>
    <MyComponent />
    </React.Suspense>
  4. 封装一个错误捕获组件(比如组件命名为 MyErrorBoundary),组件内通过生命周期 getDerivedStateFromError 捕获错误信息。当异步组件加载失败时,将捕获到错误信息处理后给用户做错误提示功能。

    1
    2
    3
    4
    5
    <MyErrorBoundary>
    <React.Suspense fallback={<div>Loading...</div>}>
    <MyComponent />
    </React.Suspense>
    </MyErrorBoundary>

JSX 是如何编译为 js 代码的?

在 React v17 之前,JSX 会被编译为 React.createElement(component, props, ...children) 函数,执行会返回 vnodevnode 通过 patch 之类的方法渲染到页面。

React v17 之后更新了 JSX 转换规则。新的 JSX 转换不会将 JSX 转换为 React.createElement,而是自动从 React 的 package 中引入新的入口函数(react/jsx-runtime)并调用。这意味着我们不用在每个组件文件中显式引入 React

怎么对组件的参数做类型约束呢?

要对组件的参数做类型约束的话,可以引入 prop-types 来配置对应的 propTypes 属性。
FlowTypesScript 则可以对整个应用做类型检查。

React 知识回顾 (使用篇)

使用 React 进行项目开发也有好几个项目了,趁着最近有空来对 React 的知识做一个简单的复盘。

完整目录概览

React 是单向数据流还是双向数据流?它还有其他特点吗?

React 是单向数据流,数据是从上向下流。它的其他主要特点时:

  • 数据驱动视图
  • 声明式编写 UI
  • 组件化开发

setState

React 通过什么方式来更新数据

React 是通过 setState 来更新数据的。调用多个 setState 不会立即更新数据,而会批量延迟更新后再将数据合并。

除了 setState 外还可以使用 forceUpdate 跳过当前组件的 shouldComponentUpdate diff,强制触发组件渲染(避免使用该方式)。

React 不能直接修改 State 吗?

  1. 直接修改 state 不会触发组件的渲染。
  2. 若直接修改 state 引用的值,在实际使用时会导致错误的值出现
  3. 修改后的 state 可能会被后续调用的 setState 覆盖

setState 是同步还是异步的?

出于性能的考虑,React 可能会把多个 setState 合并成一个调用。

React 内有个 batchUpdate(批量更新) 的机制,在 React 可以控制的区域 (如组件生命周期、React 封装的事件处理器) 设置标识位 isBatchingUpdate 来决定是否触发更新。

比如在 React 中注册的 onClick 事件或是 componentDidMount 中直接使用 setState 都是异步的。若想拿到触发更新后的值,可以给 setState 第二个参数传递一个函数,该函数在数据更新后会触发的回调函数,函数的参数就是更新后最新的值。

不受 React 控制的代码快中使用 setState 是同步的,比如在 setTimeout 或是原生的事件监听器中使用。

setState 小测

输出以下结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
componentDidMount() {
this.setState({ count: this.state.count + 1 });
console.log("1 -->", this.state.count);

this.setState({ count: this.state.count + 1 });
console.log("2 -->", this.state.count);

setTimeout(() => {
this.setState({ count: this.state.count + 1 });
console.log("3 -->", this.state.count);
}, 0);

setTimeout(() => {
this.setState({ count: this.state.count + 1 });
console.log("4 -->", this.state.count);
}, 0);
}

输出结果为:

1
2
3
4
1 --> 0
2 --> 0
3 --> 2
4 --> 3

解答: 调用 setState 后不会立即更新 state,开头两次调用会被异步合并调用,因此只有一次调用。一轮事件循环结束后,调用第 3、4 次 setState。由于在 setTimeout 中调用是同步更新的,因此都能正常的叠加数据。

React 生命周期

React 的生命周期主要是指组件在特定阶段会执行的函数。以下是 class 组件的部分生命周期图谱:

从上图可以看出:React 的生命周期按照类型划分,可分为 挂载时(Mounting)、更新时(Updating)、卸载时(Unmounting) 。图中的生命周期函数效果如下:

constructor (构造函数)

  • 触发条件: 组件初始化时
  • 是否可以使用 setState: X
  • 使用场景: 初始化 state 或者对方法绑定 this。在构造函数中便于自动化测试。

static getDerivedStateFromProps

Tips: 不常用方法

  • 触发条件: 调用 render 函数之前
  • 是否可以使用 setState: X
  • 函数行为: 函数可以返回一个对象用于更新组件内部的 state 数据,若返回 null 则什么都不更新。
  • 使用场景: 用于 state 依赖 props 的情况,也就是状态派生。值得注意的是派生 state 会导致代码冗余,并使组件难以维护。

shouldComponentUpdate

Tips: 不常用方法

  • 触发条件: 当 props/state 发生变化
  • 是否可以使用 setState: X
  • 函数行为: 函数的返回值决定组件是否触发 render,返回值为 true 则触发渲染,反之则阻止渲染。(组件内不写该函数的话,则调用默认函数。默认函数只会返回 true,即只要 props/state 发生变化,就更新组件)
  • 使用场景: 组件的性能优化,仅仅是浅比较 props 和 state 的变化的话,可以使用内置的 PureComponent 来代替 Component 组件。

render

  • 触发条件: 渲染组件时
  • 是否可以使用 setState: X
  • 函数行为: 函数的返回值决定视图的渲染效果
  • 使用场景: class 组件中唯一必须要实现的生命周期函数。

getSnapshotBeforeUpdate

Tips: 不常用方法

  • 触发条件: 在最近一次渲染输出(提交到 DOM 节点)之前调用
  • 是否可以使用 setState: X
  • 函数行为: 函数的返回值将传入给 componentDidUpdate 第三个参数中。若只实现了该函数,但没有使用 componentDidUpdate 的话,React 将会在控制台抛出警告
  • 使用场景: 可以在组件发生更改之前从 DOM 中捕获一些信息(例如,列表的滚动位置)

componentDidMount

  • 触发条件: 组件挂载后(插入 DOM 树中)立即调用,该函数只会被触发一次
  • 是否可以使用 setState: Y (可以直接调用,但会触发额外渲染)
  • 使用场景: 从网络请求中获取数据、订阅事件等

componentDidUpdate

  • 触发条件: 组件更新完毕后(首次渲染不会触发)
  • 是否可以使用 setState: Y (更新语句须放在条件语句中,不然可能会造成死循环)
  • 使用场景: 对比新旧值的变化,进而判断是否需要发送网络请求。比如监听路由的变化

componentWillUnmount

  • 触发条件: 组件卸载及销毁之前直接调用
  • 是否可以使用 setState: X
  • 使用场景: 清除 timer,取消网络请求或清除在 componentDidMount 中创建的订阅等

生命周期阶段

针对 React 生命周期中函数的调用顺序,笔者写了一个简易的 Demo 用于演示: React 生命周期示例

React 组件挂载阶段先后会触发 constuctorstatic getDerivedStateFromPropsrendercomponentDidMount 函数。若 render 函数内还有子组件存在的话,则会进一步递归:

1
2
3
4
5
6
7
8
9
10
[Parent]: constuctor
[Parent]: static getDerivedStateFromProps
[Parent]: render
[Children]: constuctor
[Children]: static getDerivedStateFromProps
[Children]: render
[Children]: componentDidMount
[Children]: 挂载阶段结束!
[Parent]: componentDidMount
[Parent]: 挂载阶段结束!

React 组件更新阶段主要是组件的 props 或 state 发生变化时触发。若组件内还有子组件,则子组件会判断是否也需要触发更新。默认情况下 component 组件是只要父组件发生了变化,子组件也会跟着变化。以下是更新父组件 state 数据时所触发的生命周期函数:

1
2
3
4
5
6
7
8
9
10
11
12
[Parent]: static getDerivedStateFromProps
[Parent]: shouldComponentUpdate
[Parent]: render
[Children]: static getDerivedStateFromProps
[Children]: shouldComponentUpdate
[Children]: render
[Children]: getSnapshotBeforeUpdate
[Parent]: getSnapshotBeforeUpdate
[Children]: componentDidUpdate
[Children]: 更新阶段结束!
[Parent]: componentDidUpdate
[Parent]: 更新阶段结束!

值得注意的是: 在本例 Demo 中没有给子组件传参,但子组件也触发了渲染。但从应用的角度上考虑,既然你子组件没有需要更新的东西,那就没有必要触发渲染吧?

因此 Component 组件上可以使用 shouldComponentUpdate 或者将 Component 组件替换为 PureComponment 组件来做优化。在生命周期图中也可以看到: shouldComponentUpdate 返回 false 时,将不再继续触发下面的函数。

有时你可能在某些情况下想主动触发渲染而又不被 shouldComponentUpdate 阻止渲染该怎么办呢?可以使用 force­Update() 跳过 shouldComponentUpdate 的 diff,进而渲染视图。(需要使用强制渲染的场景较少,一般不推荐这种方式进行开发)

React 组件销毁阶段也没啥好说的了。父组件先触发销毁前的函数,再逐层向下触发:

1
2
3
4
[Parent]: componentWillUnmount
[Parent]: 卸载阶段结束!
[Children]: componentWillUnmount
[Children]: 卸载阶段结束!

其他生命周期

除了上图比较常见的生命周期外,还有一些过时的 API 就没有额外介绍了。因为它们可能在未来的版本会被移除:

上图没有给出错误处理的情况,以下信息作为补充: 当渲染过程,生命周期,或子组件的构造函数中抛出错误时,会调用如下方法:

React 组件通信

  1. 父组件通过 props 给子组件传递数据。子组件通过触发父组件提供的回调函数来给父组件传递消息或数据
  2. React.Context 可以跨层级组件共享数据
  3. 自定义事件
  4. 引入 Redux/Mobx 之类的状态管理器

React.Context 怎么使用

Context 可以共享对于组件树而言是全局的数据,比如全局主题、首选语言等。使用方式如下:

  1. React.createContext 函数用于生成 Context 对象。可以在创建时给 Context 设置默认值:

    1
    const ThemeContext = React.createContext('light');
  2. Context 对象中有一个 Provider(提供者) 组件,Provider 组件接受一个 value 属性用以将数据传递给消费组件。

    1
    2
    3
    <ThemeContext.Provider value="dark">
    <page />
    </ThemeContext.Provider>
  3. 获取 Context 提供的值可以通过 contextType 或者 Consumer(消费者) 组件中获取。contextType 只能用于类组件,并且只能挂载一个 Context

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class MyClass extends React.Component {
    componentDidMount() {
    let value = this.context;
    /* 在组件挂载完成后,使用 MyContext 的值执行一些有副作用的操作 */
    }
    render() {
    let value = this.context;
    /* 基于 MyContext 的值进行渲染 */
    }
    }
    MyClass.contextType = MyContext;

    若想给组件挂载多个 Context, 或者在函数组件内使用 Context 可以使用 Consumer 组件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <ThemeContext.Consumer>
    {theme => (
    <UserContext.Consumer>
    {user => (
    <ProfilePage user={user} theme={theme} />
    )}
    </UserContext.Consumer>
    )}
    </ThemeContext.Consumer>

Context 通常适用于传递较为简单的数据信息,若数据太过复杂,还是需要引入状态管理(Redux/Mbox)。

函数组件是什么?与类组件有什么区别?

函数组件本质上是一个纯函数,它接受 props 属性,最后返回 JSX。

与类组件的差别在于: 它没有实例、不能通过 extends 继承于其他方法、也没有生命周期和 state。以前函数组件常作为无状态组件,React 16.8+ 可以引入 Hooks 为函数组件支持状态和副作用操作。

Hooks

Hook vs class

类组件的不足:

  • 状态逻辑复用难,缺少复用机制。渲染属性和高阶组件导致层级冗余。
  • 组件趋向复杂难以维护。生命周期函数混杂不相干逻辑,相干逻辑分散在不同生命周期中。
  • this 指向令人困扰。内联函数过度创建新句柄,类成员函数不能保证 this。

Hooks 的优点:

  • 自定义 Hook 方便复用状态逻辑
  • 副作用的关注点分离
  • 函数组件没有 this 问题

Hooks 现有的不足:

  • 不能完全取代 class 组件的生命周期,部分不常用的生命周期暂时没有实现。
  • Hooks 的运作方式带来了一定的学习成本,需要转换现有的编程思维,增加了心智负担。

Hooks 的使用

描述 Hooks 有哪些常用的方法和大致用途

  1. useState: 使函数组件支持设置 state 数据,可用于代替类组件的 constructor 函数。

  2. useEffect: 使函数组件支持操作副作用 (effect) 的能力,Hook 第二个参数是 effect 的依赖项。当依赖项是空时,effect 函数仅会在组件挂载后执行一遍。若有一个或多个依赖项时,只要任意一个依赖项发生变化,就会触发 effect 函数的执行。effect 函数里可以做一些如获取页面数据、订阅事件等操作。

    除此之外,useEffect 还可以返回一个函数用于做清除操作,这个清除操作时可选的。常用于清理订阅事件、DOM 事件等。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 绑定 DOM 事件
    useEffect(() => {
    document.addEventListener('click', handleClick);

    // useEffect 回调函数的返回值是函数的话,当组件卸载时会执行该函数
    // 若没有需要清除的东西,则可以忽略这一步骤
    return () => {
    document.removeEventListener('click', handleClick);
    };
    }, [handleClick]);
  3. useLayoutEffect: useEffect 的 effect 执行的时机是在浏览器完成布局和绘制之后会延迟调用。若想要 DOM 变更的同时同步执行 effect 的话可以使用 useLayoutEffect。它们之间只是执行的时机不同,其他都一样。

  4. useContext: 接收一个 Context 对象,并返回 Context 的当前值。相当于类组件的 static contextType = MyContext

  5. useReduceruseState 的代替方案,它的工作方式有点类似于 Redux,通过函数来操作 state。适合 state 逻辑较为复杂且包含多个子值,或是新的 state 依赖于旧的 state 的场景。

  6. useMemo 主要用于性能优化,它可以缓存变量的值,避免每次组件更新后都需要重复计算值。

  7. useCallbck 用于缓存函数,避免函数被重复创建,它是 useMemo 的语法糖。useCallback(fn, deps) 的效果相当于是 useMemo(() => fn, deps)

Hook 之间的一些差异

  1. React.memo 与 React.useMemo

    memo 针对一个组件的渲染是否重复执行,useMemo 定义一段函数逻辑是否重复执行。

  2. React.useMemo 与 React.useCallback

    useMemo(() => fn) 返回的是一个函数,将等同于 useCallback(fn)

  3. React.useStatus 与 React.useRef

    React.useStatus 相当于类的 stateReact.useRef 相当于类的内部属性。前者参与渲染,后者的修改不会触发渲染。

自定义 Hook 的使用

自定义 Hook 的命名规则是以 use 开头的函数,比如 useLocalStorage 就符合自定义 Hook 的命名规范。
使用自定义 Hook 的场景有很多,如表单处理、动画、订阅声明、定时器等等可复用的逻辑都能通过自定义 Hook 来抽象实现。

在自定义 Hook 中,可以使用 Hooks 函数将可复用的逻辑和功能提取出来,并将内部的 state 或操作的方法从自定义 Hook 函数中返回出来。函数组件使用时就可以像调用普通函数一祥调用自定义 Hook 函数, 并将自定义 Hook 返回的 state 和操作方法通过解构保存到变量中。

下面是 useLocalStorage 的实现,它将 state 同步到本地存储,以使其在页面刷新后保持不变。 用法与 useState 相似,不同之处在于我们传入了本地存储键,以便我们可以在页面加载时默认为该值,而不是指定的初始值。

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
import { useState } from 'react';

// Usage
function App() {
// Similar to useState but first arg is key to the value in local storage.
const [name, setName] = useLocalStorage('name', 'Bob');

return (
<div>
<input
type="text"
placeholder="Enter your name"
value={name}
onChange={e => setName(e.target.value)}
/>
</div>
);
}

// Hook
function useLocalStorage(key, initialValue) {
// State to store our value
// Pass initial state function to useState so logic is only executed once
const [storedValue, setStoredValue] = useState(() => {
try {
// Get from local storage by key
const item = window.localStorage.getItem(key);
// Parse stored json or if none return initialValue
return item ? JSON.parse(item) : initialValue;
} catch (error) {
// If error also return initialValue
console.log(error);
return initialValue;
}
});

// Return a wrapped version of useState's setter function that ...
// ... persists the new value to localStorage.
const setValue = value => {
try {
// Allow value to be a function so we have same API as useState
const valueToStore =
value instanceof Function ? value(storedValue) : value;
// Save state
setStoredValue(valueToStore);
// Save to local storage
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
// A more advanced implementation would handle the error case
console.log(error);
}
};

return [storedValue, setValue];
}

注意: 自定义 Hook 函数在定义时,也可以使用另一个自定义 Hook 函数。

Hook 使用约束

  1. 只能在函数组件最顶层调用 Hook,不能在循环、条件判断或子函数中调用。
  2. 只能在函数组件或者是自定义 Hook 函数中调用,普通的 js 函数不能使用。

class 组件与 Hook 之间的映射与转换

函数组件相比 class 组件会缺少很多功能,但大多可以通过 Hook 的方式来实现。

生命周期

  • constructor:class 组件的构造函数一般是用于初始化 state 数据或是给事件绑定 this 指向的。函数组件内没有 this 指向的问题,因此可以忽略。而 state 可以通过 useState/useReducer 来实现。

  • getDerivedStateFromPropsgetDerivedStateFromProps 一般用于在组件 props 发生变化时派生 state。Hooks 实现同等效果如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    function ScrollView({row}) {
    const [isScrollingDown, setIsScrollingDown] = useState(false);
    const [prevRow, setPrevRow] = useState(null);

    if (row !== prevRow) {
    // Row 自上次渲染以来发生过改变。更新 isScrollingDown。
    setIsScrollingDown(prevRow !== null && row > prevRow);
    setPrevRow(row);
    }

    return `Scrolling down: ${isScrollingDown}`;
    }
  • shouldComponentUpdate: 使用 React.memo 应用到函数组件中后,当 props 发生变化时,会对 props 的新旧值进行前对比,相当于是 PureComponent 的功能。如果你还想自己定义比较函数的话,可以给 React.memo 的第二个参数传一个函数,若函数返回 true 则跳过更新。

    1
    2
    3
    const Button = React.memo((props) => {
    return <button>{props.text}</button>
    });
  • render: 函数组件本身就是一个 render 函数。

  • componentDidMount / componentDidUpdate / componentWillUnmount:

    useEffect 第二个参数的依赖项为空时,相当于 componentDidMount,组件挂载后只会执行一次。每个 useEffect 返回的函数相当于是 componentWillUnmount 同等效果的操作。若有依赖,则 effect 函数相当于是 componentDidUpdate

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // 没有依赖项,仅执行一次
    useEffect(() => {
    const subscription = props.source.subscribe();

    // 相当于 componentWillUnmount
    return () => {
    subscription.unsubscribe();
    };
    }, []);

    // 若有依赖项,相当于 componentDidUpdate
    // 当 page 发生变化时会触发 effect 函数
    useEffect(() => {
    fetchList({ page });
    }, [page]);

Hooks 没有实现的生命周期钩子

  • getSnapshotBeforeUpdate
  • getDerivedStateFromError
  • componentDidCatch

转换实例变量

使用 useRef 设置可变数据。

强制更新 Hook 组件

设置一个没有实际作用state,然后强制更新 state 的值触发渲染。

1
2
3
4
5
6
7
8
9
10
const Todo = () => {
// 使用 useState,用随机数据更新也行
const [ignored, forceUpdate] = useReducer(x => x + 1, 0);

function handleClick() {
forceUpdate();
}

return <button click={handleClick}>强制更新组件</button>
}

获取旧的 props 和 state

可以通过 useRef 来保存数据,因为渲染时不会覆盖掉可变数据。

1
2
3
4
5
6
7
8
9
10
11
12
function Counter() {
const [count, setCount] = useState(0);

const prevCountRef = useRef();
useEffect(() => {
prevCountRef.current = count;
}, []);

const prevCount = prevCountRef.current;

return <h1>Now: {count}, before: {prevCount}</h1>;
}

受控组件与非受控组件的区别

受控组件主要是指表单的值受到 state 的控制,它需要自行监听 onChange 事件来更新 state

由于受控组件每次都要编写事件处理器才能更新 state 数据、可能会有点麻烦,React 提供另一种代替方案是非受控组件

非受控组件将真实数据储存在 DOM 节点中,它可以为表单项设置默认值,不需要手动更新数据。当需要用到表单数据时再通过 ref 从 DOM 节点中取出数据即可。

注意: 多数情况下React 推荐编写受控组件。

扩展资料: 受控和非受控制使用场景的选择

Portals 是什么?

Portals 就像个传送门,它可以将子节点渲染到存在于父组件以外的 DOM 节点的方案。

比如 Dialog 是一个全局组件,按照传统渲染组件的方式,Dialog 可能会受到其容器 css 的影响。因此可以使用 Portals 让组件在视觉上渲染到 <body> 中,使其样式不受 overflow: hiddenz-index 的影响。

Hexo 常见问题解决方案

记录 Hexo 升级或使用时遇到的问题和一些解决方案。

TypeError: config._d.getTime is not a function

经过排查,本次发生错误是由 hexo-related-popular-posts 引发,在该库源码中使用 moment 初始化 list.date 导致了错误。 list.date 通过打印值可以看到是一个 moment 对象,但这个 moment 对象并不规范或者说可能在某处修改了这个 moment 对象的值。

moment 内部初始化有一段逻辑是:

1
this._d = new Date(config._d != null ? config._d.getTime() : NaN);

这个 config 就是 moment(list.date) 传入的 list.date 的值。config._d 是一个时间类型的字符串,并不是 Date 类型,因此没有 getTime 的方法。

临时解决方法有两种,一是将 theme/next/_config.yml 中的 related_posts.params.isDate 设为 false,也就是推荐列表中不展示时间。

二是修改源码,做一层错误处理。从 node_modules 中打开文件(\node_modules\hexo-related-popular-posts\lib\list-json.js), 在编辑器中查找以下代码:

1
2
3
if (inOptions.isDate && list.date != '') {
ret.date = moment(list.date).format(config.date_format || 'YYYY-MM-DD')
}

修改为:

1
2
3
4
5
6
7
if (inOptions.isDate && list.date != '') {
try {
ret.date = moment(list.date).format(config.date_format || 'YYYY-MM-DD')
} catch(ex) {
ret.date = moment(list.date._d).format(config.date_format || 'YYYY-MM-DD')
}
}

上述只是临时的解决方案,由于不好确定是哪一方的原因,也不想继续耗费太多精力在上面。

错误日志如下,以供参考:

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
Unhandled rejection Template render error: (C:\Users\user\Desktop\project\blog\themes\next\layout\post.njk)
Template render error: (C:\Users\user\Desktop\project\blog\themes\next\layout\post.njk)
Template render error: (C:\Users\user\Desktop\project\blog\themes\next\layout\post.njk) [Line 19, Column 14]
Template render error: (C:\Users\user\Desktop\project\blog\themes\next\layout\post.njk)
Template render error: (C:\Users\user\Desktop\project\blog\themes\next\layout\_partials\head\head-unique.njk) [Line 10, Column 23]
Template render error: (C:\Users\user\Desktop\project\blog\themes\next\layout\post.njk) [Line 3, Column 3]
Template render error: (C:\Users\user\Desktop\project\blog\themes\next\layout\post.njk)
Template render error: (C:\Users\user\Desktop\project\blog\themes\next\layout\_partials\header\index.njk) [Line 6, Column 15]
Template render error: (C:\Users\user\Desktop\project\blog\themes\next\layout\post.njk)
Template render error: (C:\Users\user\Desktop\project\blog\themes\next\layout\_partials\header\sub-menu.njk) [Line 2, Column 29]
Template render error: (C:\Users\user\Desktop\project\blog\themes\next\layout\post.njk)
Template render error: (C:\Users\user\Desktop\project\blog\themes\next\layout\_partials\header\sub-menu.njk)
Template render error: (C:\Users\user\Desktop\project\blog\themes\next\layout\post.njk) [Line 5, Column 3]
Template render error: (C:\Users\user\Desktop\project\blog\themes\next\layout\post.njk) [Line 9, Column 12]
Template render error: (C:\Users\user\Desktop\project\blog\themes\next\layout\_macro\post.njk) [Line 214, Column 16]
Template render error: (C:\Users\user\Desktop\project\blog\themes\next\layout\_partials\post\post-related.njk)
TypeError: config._d.getTime is not a function
at Object._prettifyError (C:\Users\user\Desktop\project\blog\node_modules\nunjucks\src\lib.js:36:11)
at C:\Users\user\Desktop\project\blog\node_modules\nunjucks\src\environment.js:561:19
at Template.root [as rootRenderFunc] (eval at _compile (C:\Users\user\Desktop\project\blog\node_modules\nunjucks\src\environment.js:631:18), <anonymous>:45:3)
at Template.render (C:\Users\user\Desktop\project\blog\node_modules\nunjucks\src\environment.js:550:10)
at C:\Users\user\Desktop\project\blog\themes\next\scripts\renderer.js:35:29
at _View._compiled.locals [as _compiled] (C:\Users\user\Desktop\project\blog\node_modules\hexo\lib\theme\view.js:136:50)
at _View.render (C:\Users\user\Desktop\project\blog\node_modules\hexo\lib\theme\view.js:39:17)
at C:\Users\user\Desktop\project\blog\node_modules\hexo\lib\hexo\index.js:64:21
at tryCatcher (C:\Users\user\Desktop\project\blog\node_modules\bluebird\js\release\util.js:16:23)
at C:\Users\user\Desktop\project\blog\node_modules\bluebird\js\release\method.js:15:34
at RouteStream._read (C:\Users\user\Desktop\project\blog\node_modules\hexo\lib\hexo\router.js:47:5)
at RouteStream.Readable.read (_stream_readable.js:470:10)
at resume_ (_stream_readable.js:949:12)
at process._tickCallback (internal/process/next_tick.js:63:19)

React vs Vue

在项目架构时选择合适的前端框架是至关重要的。React 和 Vue 都是流行的选择,但它们在灵活性、易用性和性能方面各有特点。本文旨在深入比较这两个框架,让我们在开发前选择技术架构有个参考。

React

React 在处理复杂业务时显示出较高的灵活性。它提供多样的技术方案选择,适用于需要高度自定义的场景。React 的特点包括:

  • 组件名称需要以大写字母开头。
  • 使用 JSX 语法,组件内需要包裹一个元素,可以使用Fragment作为占位符。
  • 响应式设计,主要关注数据。
  • 事件绑定采用驼峰命名方式。
  • 不允许直接修改 state,以保持性能。
  • 构造函数中接受参数。
  • 单向数据流,专注于视图层和数据渲染。
  • 有助于自动化测试。
  • state 或 props 改变时,render 函数会重新执行。
  • 使用虚拟 DOM 来减少真实 DOM 操作,提升性能。
  • 跨端应用实现,例如 React Native。

但它的缺点也很明显:

  • 学习曲线较陡: JSX 和组件生命周期等概念对新手而言可能较难掌握。
  • 只关注视图层: 需要与其他库结合使用以构建完整的解决方案。但 react 的生态非常丰富,甚至会有多种不同的变成风格,社区中没有一个统一认可的解决方案,这会让不熟悉 react 生态的新用户看的眼花缭乱。

Vue

Vue 提供了丰富的 API,使功能实现变得简单。它适合于快速开发和较少复杂度的项目。Vue 的特点包括:

  • 易学性,提供了详尽的文档和指导。尤其作者是国人,也提供了友好的中文文档支持。
  • 更简洁的模板语法糖,如 v-bind 和 v-model。
  • 详细的错误提示和开发工具,使调试更加方便。
  • 数据双向绑定,简化了表单输入和数据展示。
  • 更轻量级,适合小型到中型项目。
  • 提供了过渡效果和动画的集成支持。
  • 可以更方便地集成到现有的项目中。
  • 提供了类似于 React 的虚拟 DOM 和组件系统。
  • 相比 react 生态的复杂, vue 官方提供了整套最基础的 web 开发架构所需的生态。当官方的提供的库无法满足需求后可以允许你去用其他第三方库,相当于起步阶段减少了选择的烦恼。对新手会比较友好。

然而,Vue 也有它的局限性:

  • 规模限制: 对于非常大型和复杂的应用,Vue 可能不如 React 灵活。
  • 过度依赖单文件组件: 可能导致项目结构和组织方式较为单一

总结

React 和 Vue 各有所长,选择哪一个取决于特定项目的需求、开发团队的技能和偏好。React 更适合需要高度灵活和可扩展性的大型应用,而 Vue 在快速开发和简单性方面表现更佳,适合新手和中小型项目。理解每个框架的优缺点有助于做出更合适的选择。

webpack + Travis CI 自动部署项目应用

我们知道 Github Pages 是 Github 免费提供给用户展示页面的一项服务。当我们完成项目开发后,想将页面部署到 Github Pages 时,该要怎么操作呢?

可以在 GitHub 的储存库设置中设置用于展示页面的分支,该分支只保留构建后的静态资源,也就是源码与编译后的静态资源分离。按照传统的做法是:手动运行编译命令,编译后再复制到指定分支中。这样操作很繁琐,但使用 Travis CI 持续集成服务之后就可以不用操心这些事了。

概念

既然我们要使用 Travis CI,首先得搞清楚人家具体是干嘛的吧?

Travis CI 是一个 **持续集成(Continuous integration, CI)**。它与 git 相耦合,每当有 commit 提交时,它将自动触发构建与测试。若运行结果符合预期,才将新代码集成到 主流(mainline) 中,这样使应用更加健壮。

值得注意的是,Travis CI 提倡每次 commit 都是独立较小的改动,而不是突然提交一大堆代码。因为这有助于后续构建失败时可以回退到正常的版本。

运行构建时,Travis CI 将 GitHub 存储库克隆到全新的虚拟环境中,并执行一系列任务来构建和测试代码。如果这些任务中的一项或多项失败,则将构建视为已损坏。如果所有任务均未失败,则认为构建已通过,Travis CI 会将代码部署到 Web 服务器或应用程序主机中(在本文中是指 Github Pages 服务)。

准备

在使用之前,需要准备一个 Github 的账号对 Travis CI 进行授权。

  1. 接着通过 Github 的账号登录 Travis CI,点击 SIGN IN WITH GITHUB
  2. 点击后会被重定向到 Github 进行授权。
  3. 授权后,若是第一次登录的话会被重定向至引导页:
  4. 点击引导页第一步的按钮,使用 GitHub Apps 激活储存库。可以选择给全部储存库都激活,也可以激活指定储存库。本文以 <username>.github.io 为例:

    注意: 这个 username 是你自己的 Github 用户名。笔者的 usernameanran758 那储存库的名字就为 **anran758.github.io**。

  5. 激活后会被重定向到设置页,点击待部署的储存库右侧的 setting 按钮,跳转至 Travis CI 储存库设置页。我们需要在此页设置部署 Github Pages 时所需的环境变量:

环境变量的值需要从 Github 拿拥有部署权限的 token:

  1. 打开 Github,点击头像,再点击 Settings 进入设置页:
  2. 进入设置页面后在左侧边栏点击开发者设置:
  3. 跳转后在左侧边栏点击 Personal access tokens, 然后在头部点击 Generate new token:
  4. 填写 token 备注、权限,最后点击生成 token:
  5. 生成 token 后点击复制按钮,复制到粘贴板: 注意要妥善保管好 token,重新刷新页面后这个 token 将不会再展示出来。如果忘记了 token 的话,也只能在 token 编辑页中重新生成。这会导致所有用到该 token 的应用都要更新值。 比方说有三个应用使用了该 token,重新生成后只在一个应用更新的值,那其他两个应用不更新就无法使用了。
  6. 复制 token 后切回 Travis CI 储存库的设置页,添加环境变量:

这样我们的准备工作就完成的差不多了。

配置

在项目目录中新建文件 .travis.yml,内容如下:

/.travis.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
language: node_js
node_js:
- lts/*

install:
- yarn install # npm ci
script:
- yarn test # npm run test
- yarn build # npm run build

deploy:
provider: pages
local_dir: dist
target_branch: master
on:
branch: develop
token: $GITHUB_TOKEN
skip_cleanup: true
keep_history: true
committer_from_gh: true

由于 webpack 项目依赖 Node.js,因此语言(language) 设置为 node_js,同时还指定使用最新的 LTS Node.js 版本(lts/*)。

install 是安装部署所需的依赖项,script 则是用于运行测试或构建脚本。他们都是 Travis 的工作生命周期(Job Lifecycle)必触发的钩子(阶段)。

install 钩子若有脚本/命令运行失败的话,整个构建会停止。而 script 钩子表现则不同,当有脚本/命令运行失败后虽然构建会失败,但还会继续执行后面的脚本。如 yarn test 运行失败后会继续跑 yarn build 命令。

以下是 Travis CI 主要的阶段流程图:

graph TDA[before_install] --> B[install]A & B -.-> Z((停止构建))B --> C[before_script]C --> D[scrip]D --> E(after_success)D --> F(after_failure)E & F --> G[before_deploy]G --> H[deploy]H --> I[after_deploy]I --> J[after_script]

部署

通过 deploy 可以指定部署方式,下面将逐个介绍部署所用的选项:

provider 是部署类型。现在我们想将页面部署到 Github Pages,那就需要将 provider 设为 pages

local_dir 指定要推送到 Github Pages 的目录,默认为当前目录。webpack 默认的输出目录是 /dist,因此需要将值设为 dist。除此之外,Travis CI 默认情况下会删除构建期间创建的所有文件,因此需要设置 skip_cleanup: true 保留构建出来的 dist 目录.

on.branch 有 commit 提交的话,Travis CI 将从 on.branch 分支运行编译脚本,编译后会把 local_dir 目录强制推送到 target_branch 中。(target_branch 默认值为 gh-pages)

现在我们要部署的储存库是 <username>.github.io。这种类型的储存库有些特殊——它只能在 master 分支展示构建后的代码,而不能修改为其他分支。在 GitHub 储存库的 Settings 中的 Source 选项可以看到详细信息:

然而其他储存库则没有这种限制:

因此要部署到 <username>.github.io 储存库的话,target_branch 只能设为 master,触发编译的 on.branch 分支则可以自己定义。

其他储存库可以按照标准流程来开发:

  • develop 作为开发分支
  • master 作为主分支
  • gh-pages 作为页面展示分支

等功能开发并测试完毕后,将 develop 的代码合并到 master 分支并推送至远程。Traivis CI 检测到 matsercommit 提交后会自动运行脚本构建,构建完毕后将输出目录推送至 gh-pages 分支。

当然 Github Pages 也不是随便来一个人就可以部署的,你想要部署到储存库中首先得有该储存库的操作权限吧?token 就是证明你身份的东西。在上文中我们预先设置好了一个名为 GITHUB_TOKEN 的环境变量,此处我们可以通过 $GITHUB_TOKEN 直接取出该环境变量的值即可。

其他还有一些细节问题可以调整:比如推送构建后的代码到 target_branch 时使用的是强制推送(git push --force),如果你觉得这种强制覆盖历史记录的方式有点暴力的话,可以设置 keep_history: true 来保留提交记录。

自动部署后 commit 提交者默认是 Travis CI 的信息。也可以设置 committer_from_gh 允许 Travs CI 使用令牌所有者的个人信息来提交 commit

配置完毕后现在只需将 .travis.yml 提交到远程,Travis CI 就开始工作了:

甚至还可以在 Github commit 信息中看到编译的情况:

如果构建出问题的话,Travis CI 还会发邮件提示你:

部署成功后就可以直接通过浏览器访问啦~ 储存库部署的是 <username>.github.io 的话,访问链接为 https://<username>.github.io/。其他储存库可以访问 https://<username>.github.io/<repoName>

比如笔者的主页与博客是两个项目分离的,部署后的链接地址为 https://anran758.github.iohttps://anran758.github.io/blog

Travis CI CLI

还可以通过 Travis CI CLI 来进行操作:

按照文档的 Installation 部分安装 Travis CI CLI

安装完毕后通过命令行进入储存库目录,输入 travis -v 来检查是否安装成功。

Travis CI 有两个不同域名版本的 API,一个是 .com 新版本,.org 是旧版本的。先确定自己使用的是哪个平台,再设定它:

1
2
3
4
5
6
7
# 默认是 .org
travis endpoint
# API endpoint: https://api.travis-ci.org/

# 笔者使用的是 .com 的平台,因此需要修改默认的模式。设置 `--com` 和 `--pro` 的效果是相等的。
travis endpoint --com --set-default
# API endpoint: https://api.travis-ci.com/ (stored as default)

确定版本后输入 travis logintravis login --pro 进行登录。Mac os 系统可能会遇到 Travis Ci CLI 依赖的 ruby 版本和系统自带 ruby 有冲突:

1
2
3
4
5
6
7
8
9
10
11
travis login --com
# We need your GitHub login to identify you.
# This information will not be sent to Travis CI, only to api.github.com.
# The password will not be displayed.

# Try running with --github-token or --auto if you don't want to enter your password anyway.

# Username: anran758
# Password for anran758: ***********
# Unknown error
# for a full error report, run travis report --pro

若不想处理这些麻烦的依赖问题,可以在 Travis CI 的个人设置页 复制 access_token~/.travis/config.yml 的配置中:

1
2
3
4
# code ~/.travis/config.yml # 通过 vscode 进行修改

# 通过 vim 进行修改
vim ~/.travis/config.yml

修改 endpoints 下的 access_token 并保存后,在命令输入 travis accounts --pro 检查是否成功:

1
2
3
4
travis accounts --pro
# travis accounts --pro
# anran758 (Anran758): not subscribed, 18 repositories
# To set up a subscription, please visit travis-ci.com.

这样就登录完毕啦~ 接着在输入 travis logs 就可以查看日志:

1
2
3
4
5
# 查看最新构建的日志
travis logs

# 查看指定构建日志
travis logs 2

还可以清空指定构建的日志:

1
2
# travis logs 2 --d # -d 简短选项
travis logs 2 --delete

参考资料:

从零构建 webpack 脚手架(基础篇)

webpack 是一个现代 JavaScript 应用程序的静态模块打包工具,它对于前端工程师来说可谓是如雷贯耳,基本上现在的大型应用都是通过 webpack 进行构建的。

webpack 具有高度可配置性,它拥有非常丰富的配置。在过去一段时间内曾有人将熟练配置 webpack 的人称呼为 “webapck 工程师”。当然,这称呼只是个玩笑话,但也能从侧面了解到 webpack 配置的灵活与复杂。

为了能够熟练掌握 webpack 的使用,接下来通过几个例子循序渐进的学习如何使用 webpack。

以下 Demo 都可以在 Github 的 webpack-example 中找到对应的示例,欢迎 star~

起步

webpack@v4.0.0 开始,就可以不用再引入配置文件来打包项目。若没有提供配置的话,webpack 将按照默认规则进行打包。默认情况下 src/index 是项目的源代码入口,打包后的代码会输出到 dist/main.js 上。

首先来初始化一个项目,项目名为 getting-started

1
2
3
4
5
6
7
8
# 创建项目文件夹
mkdir getting-started

# 进入项目目录
cd getting-started

# npm 项目
npm init -y

初始化项目后,项目目录会新增一个 package.json,该文件记录了项目依赖的相关信息。若想要使用 webpack 的话需要安装它的依赖: webpack (本体)和 webpack-cli (可以在命令行操作 webpack 的工具):

1
2
3
4
5
6
# -D 和 --save-dev 选项都可以用于安装开发依赖
# npm i --save-dev webpack webpack-cli
npm i -D webpack webpack-cli

# 或者使用 yarn 安装开发依赖
yarn add -D webpack webpack-cli

接着创建 webpack 所需的默认入口文件 src/index.js 以及测试模块所用的 src/log.js 文件。此时的项目结构大致如下:

1
2
3
4
5
6
  .
├── package.json
+ ├── src
+ │ ├── index.js
+ │ └── log.js
└── node_modules
1
2
3
4
5
6
7
// src/log.js
export const log = (name) => console.log(`Hello ${name}!`);

// src/index.js
import { log } from './log'

log('anran758');

src/log.js 导出了一个工具函数,它负责向控制台发送消息。src/index.js 是默认的入口文件,它引入 log 函数并调用了它。

上面的代码很简单,像这种模块化的代码按照传统 <script src> 引入的话,浏览器是不能正确执行的。可以在根目录上创建一个 index.html 引入 js 脚本来测试一下:

/index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test</title>
</head>
<body>
<!-- 引入脚本 -->
<script src="./src/index.js"></script>
</body>
</html>

创建文件后,将上例代码复制到 index.html 中。保存并打开该文件,看看浏览器能否正确处理模块逻辑。不出意外的话,文件在浏览器打开后,浏览器开发者工具会抛出错误信息:

1
Uncaught SyntaxError: Cannot use import statement outside a module

言下之意就是说浏览器不能正确的解析 ES module 语句,此时 webpack 就可以派上用场啦~ 在 package.json 中的 scripts 字段中添加如下命令:

/package.json
1
2
3
4
  "scripts": {
+ "build": "webpack"
- "test": "echo \"Error: no test specified\" && exit 1"
},

在命令行输入 npm run build 调用 webpack 对当前项目进行编译,编译后的结果会输出到 dist/main.js 文件中(即便本地没有 dist 目录,它都会自动创建该目录)。输出文件后,修改 index.html 对 js 的引用:

/index.html
1
2
3
4
  <body>
+ <script src="./dist/main.js"></script>
- <script src="./src/index.js"></script>
</body>

重新刷新页面后就能看到 log 正确的输出了 Hello anran758!。点击 log 右侧的链接,可以跳转至 Source 面板,将代码格式化后可以清晰地看到编译后 js 的变化:

使用配置

当然,上例代码只不过是小试牛刀。对于正式的项目会有更复杂的需求,因此需要自定义配置。webpack 主要有两种方式接收配置:

第一种: 通过 Node.js API引入 webpack 包,在调用 webpack 函数时传入配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
const webpack = require("webpack");

const webpackConfig = {
// webpack 配置对象
}

webpack(webpackConfig, (err, stats) => {
if (err || stats.hasErrors()) {
// 在这里处理错误
}

// 处理完成
});

第二种: 通过 webpack-cli 在终端使使用 webpack 时指定配置。

1
webpack [--config webpack.config.js]

两种方法内配置都是相似的,只是调用的形式不同。本篇先使用 webpack-cli 来做示例。

webpack 接受一个特定的配置文件,配置文件要求导出一个对象、函数、Promise 或多个配置对象组成的数组。

现在将上一章的 Demo 复制一份出来,并重命名为 **getting-started-config**,在该目录下新建 webpack.config.js 文件,文件内容如下:

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

module.exports = {
// 起点或是应用程序的起点入口
entry: "./src/index",
output: {
// 编译后的输出路径
// 注意此处必须是绝对路径,不然 webpack 将会抛错(使用 Node.js 的 path 模块)
path: path.resolve(__dirname, "dist"),

// 输出 bundle 的名称
filename: "bundle.js",
}
}

上面的配置主要是定义了程序入口、编译后的文件输出目录。然后在 src/index.js 中修改一些内容用来打包后测试文件是否被正确被编译:

src/index.js
1
2
3
4
  import { log } from './log'

+ log('本节在测试配置噢');
- log('anran758');

随后在终端输入 num run build 进行编译,可以看到 dist 目录下多了个 bundle.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ npm run build
> webpack --config ./webpack.config.js

Hash: 3cd5f3bbfaf23f01de37
Version: webpack 4.43.0
Time: 117ms
Built at: 05/06/2020 1:01:37 PM
Asset Size Chunks Chunk Names
bundle.js 1010 bytes 0 [emitted] main
Entrypoint main = bundle.js
[0] ./src/index.js + 1 modules 123 bytes {0} [built]
| ./src/index.js 62 bytes [built]
| ./src/log.js 61 bytes [built]

WARNING in configuration
The 'mode' option has not been set, webpack will fallback to 'production' for this value. Set 'mode' option to 'development' or 'production' to enable defaults for each environment.
You can also set it to 'none' to disable any default behavior. Learn more: https://webpack.js.org/configuration/mode/

由于我们输出的文件名被修改了,此时还得修改 html 的引入路径。但每改一次输出目录,HTML 中的引入路径也得跟着改,这样替换的话就比较容易出纰漏。那能不能让 webpack 自动帮我们插入资源呢?答案是可以的。

Plugin

webpack 提供**插件(plugin)**的功能,它可以用于各种方式自定义 webpack 构建过程。

html-webpack-plugin 可以在运行 webpack 时自动生成一个 HTML 文件,并将打包后的 js 代码自动插入到文档中。下面来安装它:

1
npm i --D html-webpack-plugin

安装后在 webpack.config.js 中使用该插件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  const path = require('path');
+ const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
// 起点或是应用程序的起点入口
entry: "./src/index",

// 输出配置
output: {
// 编译后的输出路径
// 注意此处必须是绝对路径,不然 webpack 将会抛错(使用 Node.js 的 path 模块)
path: path.resolve(__dirname, "dist"),

// 输出 bundle 的名称
filename: "bundle.js",
},
+ plugins: [
+ new HtmlWebpackPlugin({
+ title: 'Test Configuration'
+ })
+ ],
}

重新编译后 HTML 也被输出到 dist 目录下。查看 dist/index.html 的源码可以发现:不仅源码被压缩了,同时 <script> 标签也正确的引入了 bundle.js

此时目录结构如下:

后续目录展示会将 node_modulespackage-lock.jsonyarn.lock 这种对项目架构讲解影响不大的目录省略掉..

1
2
3
4
5
6
7
8
9
10
11
.
├── dist
│ ├── bundle.js
│ ├── index.html
│ └── main.js
├── index.html
├── package.json
├── src
│ ├── index.js
│ └── log.js
└── webpack.config.js

处理完资源自动插入的问题后,还有一个问题需要我们处理:虽然 webpack 现在能自动生成 HTML 并插入脚本,但我们还得在 HTML 中写其他代码逻辑呀,总不能去改 /dist/index.html 文件吧?

这个问题也很好解决。html-webpack-plugin 在初始化实例时,传入的配置中可以加上 template 属性来指定模板。配置后直接在指定模板上进行编码就可以解决这个问题了:

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
  const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
// 起点或是应用程序的起点入口
entry: "./src/index",

// 输出配置
output: {
// 编译后的输出路径
// 注意此处必须是绝对路径,不然 webpack 将会抛错(使用 Node.js 的 path 模块)
path: path.resolve(__dirname, "dist"),

// 输出 bundle 的名称
filename: "bundle.js",
},
plugins: [
// html-webpack-plugin
// https://github.com/jantimon/html-webpack-plugin#configuration
new HtmlWebpackPlugin({
title: 'Test Configuration',
+ template: path.resolve(__dirname, "./index.html"),
})
],
}

使用模板后 html-webpack-plugin 也会自动将脚本插入到模板中。因此可以将模板中的 <script> 给去掉了。为了测试输出的文件是否使用了模板,在 <body> 内随便插入一句话,重新打包后预览输出的文件是否包含这句话:

/index.html
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 name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Test Config</title>
- <title>Test</title>
</head>
<body>
+ <p>Test Config</p>
- <script src="./dist/main.js"></script>
</body>
</html>

修改文件后,重新打包就能看到模板也被压缩输出至 /dist/index.html 了,script 标签也正常的插入了。

清理目录

现在来看编译后的目录,我们发现 dist/mian.js 这文件是使用配置之前编译出来的文件,现在我们的项目已经不再需要它了。这种历史遗留的旧文件就应该在每次编译之前就被扔进垃圾桶,只输出最新的结果。

clean-webpack-pluginrimraf 可以完成清理功能。前者是比较流行的 webpack 清除插件,后者是通用的 unix 删除命令(安装该依赖包后 windows 平台也能用)。如果仅是清理 /dist 目录下文件的话,个人是比较倾向使用 rimraf的,因为它更小更灵活。而 clean-webpack-plugin 是针对 webpack 输出做的一系列操作。

在终端安装依赖:

1
npm i -D rimraf

rimraf 的命令行的语法是: rimraf <path> [<path> ...],我们在 package.jsonscirpts 中修改 build 的命令:

/package.json
1
2
3
4
"scripts": {
+ "build": "rimraf ./dist && webpack --config ./webpack.config.js"
- "build": "webpack --config ./webpack.config.js"
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ npm run build

> rimraf ./dist && webpack --config ./webpack.config.js

Hash: 763fe4b004e1c33c6876
Version: webpack 4.43.0
Time: 342ms
Built at: 05/06/2020 2:35:49 PM
Asset Size Chunks Chunk Names
bundle.js 1010 bytes 0 [emitted] main
index.html 209 bytes [emitted]
Entrypoint main = bundle.js
[0] ./src/index.js + 1 modules 123 bytes {0} [built]
| ./src/index.js 62 bytes [built]
| ./src/log.js 61 bytes [built]

WARNING in configuration
The 'mode' option has not been set, webpack will fallback to 'production' for this value. Set 'mode' option to 'development' or 'production' to enable defaults for each environment.
You can also set it to 'none' to disable any default behavior. Learn more: https://webpack.js.org/configuration/mode/
Child HtmlWebpackCompiler:
1 asset
Entrypoint HtmlWebpackPlugin_0 = __child-HtmlWebpackPlugin_0
1 module

这样 webpack 输出的 /dist 目录始终是最新的东西。

loader

在正常的页面中,引入 css 样式表会让页面变得更美观。引入图片可以让页面内容更丰富。

然而 webpack 本体只能处理原生的 JavaScript 模块,你让它处理 css 或图片资源,它是无法直接处理的。为了处理这种问题,webpack 提供了 loader 的机制,用于对模块外的源码进行转换。

loader 一般是单独的包,我们可以在社区找到对应 loader 来处理特定的资源。在使用前通过 npm 安装到项目的开发依赖中即可。loader 可以通过配置内联Cli 这三种方式来使用。下文主要以 配置 的方式来使用。

css

往常引入 css 样式表无非就是在 html 中通过 <link> 标签引入。现在想通过 webpack 来管理依赖得需要安装对应的 loader 来处理这些事。

css-loader 可以让 webpack 可以引入 css 资源。光有让 webpack 识别 css 的能还不够。为了能将 css 资源进行导出,还要安装 mini-css-extract-plugin 插件:

现在将上一节的 Demo 复制并重名为 getting-started-loader-css。进入新的项目目录后安装依赖:

1
npm install -D css-loader mini-css-extract-plugin

在更改配置之前,为了使项目结构更清晰,咱们按照文件类型重新调整源码目录结构。将 src 下的 js 文件都放进 js 文件夹中。同时创建 /src/css/style.css 样式表。调整后的目录结构如下:

1
2
3
4
5
6
7
8
9
10
.
├── package.json
├── src
│ ├── index.html
│ ├── css
│ │ └── style.css
│ └── js
│ ├── index.js
│ └── log.js
└── webpack.config.js

现在将 Flexbox 布局用例 中结尾的 Demo 迁移到项目中,测试一下效果:

HTML 源码
/src/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
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test</title>
</head>
<body>
<div class="panels">
<div class="panel panel1">
<p class="item name">Alice</p>
<p class="item index">I</p>
<p class="item desc">Pixiv Content ID: 65843704</p>
</div>
<div class="panel panel2">
<p class="item name">Birthday</p>
<p class="item index">II</p>
<p class="item desc">Pixiv Content ID: 70487844</p>
</div>
<div class="panel panel3">
<p class="item name">Dream</p>
<p class="item index">III</p>
<p class="item desc">Pixiv Content ID: 65040104</p>
</div>
<div class="panel panel4">
<p class="item name">Daliy</p>
<p class="item index">IV</p>
<p class="item desc">Pixiv Content ID: 64702860</p>
</div>
<div class="panel panel5">
<p class="item name">Schoolyard</p>
<p class="item index">V</p>
<p class="item desc">Pixiv Content ID: 67270728</p>
</div>
</div>
</body>
</html>
CSS 源码
/src/css/style.css
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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
html {
font-family: 'helvetica neue';
font-size: 20px;
font-weight: 200;
background: #f7f7f7;
}

body,
p {
margin: 0;
}

.panels {
display: flex;
min-height: 100vh;
overflow: hidden;
}

.panel {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
color: white;
background: #ececec;
text-align: center;
box-shadow: inset 0 0 0 5px rgba(255, 255, 255, 0.1);
transition: font-size 0.7s cubic-bezier(0.61, -0.19, 0.7, -0.11),
flex 0.7s cubic-bezier(0.61, -0.19, 0.7, -0.11), background 0.2s;
font-size: 20px;
background-size: cover;
background-position: center;
cursor: pointer;
}

.panel1 {
background-color: #f4f8ea;
}

.panel2 {
background-color: #fffcdd;
}

.panel3 {
background-color: #beddcf;
}

.panel4 {
background-color: ​#c3cbd8;
}

.panel5 {
background-color: #dfe0e4;
}

.item {
flex: 1 0 auto;
display: flex;
justify-content: center;
align-items: center;
transition: transform 0.5s;
font-size: 1.6em;
font-family: 'Amatic SC', cursive;
text-shadow: 0 0 4px rgba(0, 0, 0, 0.72), 0 0 14px rgba(0, 0, 0, 0.45);
}

.name {
transform: translateY(-100%);
}

.panel .index {
font-size: 4em !important;
width: 100%;
}

.desc {
transform: translateY(100%);
}

.open-active .name,
.open-active .desc {
transform: translateY(0);
width: 100%;
}

.panel.open {
flex: 3;
font-size: 40px;
}
JavaScript 源码
/src/js/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
import { log } from './log'
import '../css/style.css';

function installEvent() {
const panels = document.querySelectorAll('.panel')

function toggleOpen() {
panels.forEach(item => {
if (item === this) return;
item.classList.remove('open')
});

this.classList.toggle('open');
}

function toggleActicon(e) {
if (e.propertyName.includes('flex-grow')) {
this.classList.toggle('open-active')
}
}

// 给每个元素注册事件
panels.forEach(panel => {
panel.addEventListener('click', toggleOpen)
panel.addEventListener('transitionend', toggleActicon)
})
}

installEvent();
log('本节在测试配置噢');

修改 webpack 配置,引入 css-loadermini-css-extract-plugin。既然已经对源码目录进行分类了,那顺便也给输出目录的文件也进行分类整理吧:

/webpack.config.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
  const path = require('path');
+ const HtmlWebpackPlugin = require('html-webpack-plugin');
+ const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
// 起点或是应用程序的起点入口
entry: "./src/js/index",

// 输出配置
output: {
// 编译后的输出路径
// 注意此处必须是绝对路径,不然 webpack 将会抛错(使用 Node.js 的 path 模块)
path: path.resolve(__dirname, "dist"),

// 输出 bundle 的名称
- filename: "bundle.js",
+ filename: "js/bundle.js",
+ },
+ module: {
+ rules: [
+ {
+ test: /\.css$/i,
+ use: [MiniCssExtractPlugin.loader, 'css-loader'],
+ },
+ ],
+ },
plugins: [
// html-webpack-plugin
// https://github.com/jantimon/html-webpack-plugin#configuration
new HtmlWebpackPlugin({
title: 'Test Configuration',
- template: path.resolve(__dirname, "./index.html"),
+ template: path.resolve(__dirname, "./src/index.html"),
+ }),
+
+ // 提取 css 到单独的文件
+ // https://github.com/webpack-contrib/mini-css-extract-plugin
+ new MiniCssExtractPlugin({
+ // 选项类似于 webpackOptions.output 中的相同选项,该选项是可选的
+ filename: 'css/index.css',
+ })
],
}

现在我们根据上面的配置来解读 loader 的使用:

在上面的配置中,**module** 规定了如何处理项目中的不同类型的模块。**rules** 是创建模块时,匹配请求的 rule (规则)数组。rule 是一个对象,其中最常见的属性就是 testuseloader

rule.test 是匹配条件,通常会给它提供一个正则表达式或是由正则表达式组成的数组。如果配置了 test 属性,那这个 rule 将匹配指定条件。比如匹配条件写为 test: /\.css$/i,这意味着给后缀为 .css 的文件使用 loader

rule.use 顾名思义就是使用,给符合匹配条件的文件使用 loader。它可以接收一个字符串,这个字符串会通过 webpack 的 resolveLoader 选项进行解析。该选项可以不配置,它内置有解析规则。比如下例中默认会从 node_modules 中查找依赖:

1
use: 'css-loader'

rule.use 还可以是应用于模块的 UseEntry 对象。UseEntry 对象内主要有 loaderoptions 两个属性:

1
2
3
4
5
6
7
8
9
// use 传入 UseEntry 类型的对象
use: {
// 必选项,要告诉 webpack 使用什么 loader
loader: 'css-loader',
// 可选项,传递给 loader 选项
options: {
modules: true
}
},

如果 UseEntry 对象内只设置 loader 属性,那它与单传的字符串的效果是一样的。而 options 是传递给 loader 的配置项,不同 loader 会提供有不同的 options。值得注意的是,如果 use 是以对象形式传入,**loader 属性是必填的,而 options 是可选的**。

rule.use 还可以是一个函数,函数形参是正在加载的模块对象参数,最终该函数要返回 UseEntry 对象或数组:

1
2
3
4
5
6
7
8
9
10
11
use: (info) => {
console.log(info);
return {
loader: 'svgo-loader',
options: {
plugins: [{
cleanupIDs: { prefix: basename(info.resource) }
}]
}
}
}

打印出函数的形参 info 可以看到该对象拥有如下属性:

  • compiler: 当前的 webpack 编译器(可以未定义)
  • issuer: 导入正在加载的模块的模块的路径
  • realResource: 始终是要加载的模块的路径
  • resource: 要加载的模块的路径,通常等于 realResource。除非在请求字符串中通过 !=! 覆盖资源名。

由此可见,使用函数方式可用于按模块更改 loader 选项。

rule.use 最常见的使用形式还是提供一个数组,数组中每项可以是字符串、UseEntry 对象、UseEntry 函数。这也是一个套娃的过程:

1
use: [{ loader: MiniCssExtractPlugin.loader }, 'css-loader'],

这里需要注意的是,**rule 中使用多个 loader 要注意其顺序。使用数组 loader 将会从右至左进行应用**。

比如上例中最先通过 css-loader 来处理 .css 文件的引入问题,再通过 MiniCssExtractPlugin.loader (Tips: 该值是 loader 的绝对路径)来提取出文件。如果反过来应用就会出问题了,webpack 都不知道如何引用 css 文件,自然提取不出东西啦。

rule.loaderrule.use 的缩写,等价于 rule.use: [{ loader }]。webpack 像这样简写的配置属性还有很多,这样做有利也有弊。对于熟手来说,提供这种简便选项可以减少配置的嵌套关系,但对新手来说,这配置有种错综复杂的感觉。

1
2
3
4
5
6
{
// 匹配文件规则
test: /\.css$/i,
// rule.use 简写形式
loader: 'css-loader'
}

接下来回归正题。重新编译 webpack,编译后的目录结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.
├── dist
│ ├── css
│ │ └── index.css
│ ├── index.html
│ └── js
│ └── bundle.js
├── package.json
├── src
│ ├── css
│ │ └── style.css
│ ├── index.html
│ └── js
│ ├── index.js
│ └── log.js
└── webpack.config.js

image

图片资源也是项目中的常见资源,引入图片资源同样需要安装 loader。处理图片资源的 loader 主要有两种,分别是 url-loaderfile-loader

file-loader

file-loader 是将 import/require() 引入的文件解析为 url,并把文件输出到输出目录中。

复制一份新 Demo 并重命名为 **getting-started-loader-images**。在安装 loader 之前先做一个小优化:

如果我们会频繁修改源码文件,修改完后又要重新编译,这个步骤实际是有点繁琐的。webpack 有个 watch 选项可以监听文件变化,若文件有修改 webpack 将自动编译(若修改的是配置文件的话,还是需要重新运行命令)。

package.jsonscript 中给 webpack 添加 -w 选项:

1
2
3
"scripts": {
"build:watch": "rimraf ./dist && webpack --config ./webpack.config.js -w"
},

接下来就可以安装依赖了:

1
npm i -D file-loader

新建一个 /src/images 文件夹,往里面添加一些图片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
  .
├── package.json
├── src
│ ├── css
│ │ └── style.css
+ │ ├── images
+ │ │ ├── 01.jpg
+ │ │ ├── 02.png
+ │ │ ├── 03.jpg
+ │ │ ├── 04.png
+ │ │ ├── 05.png
+ │ │ ├── 06.jpg
+ │ │ ├── webpack.jpg
+ │ │ └── webpack.svg
│ ├── index.html
│ └── js
│ ├── index.js
│ └── log.js
└── webpack.config.js

webpack.config.js 中配置 loader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
  rules: [
{
test: /\.html$/i,
loader: 'html-loader',
},
{
// 匹配文件规则
test: /\.css$/i,
// use 从右至左进行应用
use: [MiniCssExtractPlugin.loader, 'css-loader'],
},
+ {
+ test: /\.(png|jpe?g|gif|webp|svg)(\?.*)?$/,
+ use: {
+ loader: 'file-loader',
+ options: {
+ name: 'img/[name].[hash:8].[ext]'
+ },
+ },
+ },
],

默认情况下图片会被输出到 dist 目录中,文件名也会被更改为一长串的哈希值。为了保持目录整洁,将要被输出的图片资源都归类到 img 目录中。

可以通过设定 namepublicPath 来指定目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 直接设置 name
use: {
loader: 'file-loader',
options: {
name: 'img/[name].[hash:8].[ext]',
},
},

// 或者使用 publicPath,效果与上例等价
use: {
loader: 'file-loader',
options: {
publicPath: 'img',
name: '[name].[hash:8].[ext]',
},
},

name 属性的值可以用 / 分层。除去最末尾一层的是文件名,前面每层 / 分隔都是嵌套的文件夹。比如值为 static/img/[name].[hash:8].[ext] 最后输出的结果是:根目录创建一个 static 目录,static 内又会创建一个 img 目录,img 内输出被引用的图片资源。

由于匹配的图片资源有很多,咱们不能写死输出的文件名,不然会引发重名问题,操作系统不准这样干。这时 **占位符(placeholder)**就能排上用场了。name 中方括号包裹起来的是占位符,不同占位符会被替换成不同的信息。

比如上例中使用了三个占位符: name 是文件的名称、hash 是指定用于对文件内容进行 hash (哈希)处理的 hash 方法,后面冒号加数值代表截取 hash 的长度为 8、ext 是文件的扩展名。在文件名加入 hash 的用意是针对浏览器缓存而特意加入的。现在可以不用在意这种优化问题,未来会专门另起一篇文章讲优化的问题。

现在修改完 webapck 配置,接着再来完善上一节的 Demo。在 /src/css/styles.css 中使用 backgournd-image 引入图片:

css 引入图片资源
/src/css/style.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
/* 省略其他代码... */
.panel1 {
background-color: #f4f8ea;
background-image: url('../images/01.jpg');
}

.panel2 {
background-color: #fffcdd;
background-image: url('../images/02.png');
}

.panel3 {
background-color: #beddcf;
background-image: url('../images/03.jpg');
}

.panel4 {
background-color: ​#c3cbd8;
background-image: url('../images/04.png');
}

.panel5 {
background-color: #dfe0e4;
background-image: url('../images/05.png');
}

重新编译后的结果如下:

编译结果
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
> rimraf ./dist && webpack --config ./webpack.config.js -w


webpack is watching the files…

Hash: 398663f1f4d417d17c94
Version: webpack 4.43.0
Time: 1086ms
Built at: 05/29/2020 2:19:03 PM
Asset Size Chunks Chunk Names
css/index.css 1.72 KiB 0 [emitted] main
img/01.a8e7ddb2.jpg 170 KiB [emitted]
img/02.46713ed3.png 744 KiB [emitted] [big]
img/03.70b4bb75.jpg 529 KiB [emitted] [big]
img/04.b7d3aa38.png 368 KiB [emitted] [big]
img/05.875a8bc2.png 499 KiB [emitted] [big]
index.html 990 bytes [emitted]
js/bundle.js 1.33 KiB 0 [emitted] main
Entrypoint main = css/index.css js/bundle.js
[0] ./src/css/style.css 39 bytes {0} [built]
[1] ./src/js/index.js + 1 modules 938 bytes {0} [built]
| ./src/js/index.js 873 bytes [built]
| ./src/js/log.js 60 bytes [built]
+ 1 hidden module

WARNING in configuration
The 'mode' option has not been set, webpack will fallback to 'production' for this value. Set 'mode' option to 'development' or 'production' to enable defaults for each environment.
You can also set it to 'none' to disable any default behavior. Learn more: https://webpack.js.org/configuration/mode/

WARNING in asset size limit: The following asset(s) exceed the recommended size limit (244 KiB).
This can impact web performance.
Assets:
img/04.b7d3aa38.png (368 KiB)
img/05.875a8bc2.png (499 KiB)
img/02.46713ed3.png (744 KiB)
img/03.70b4bb75.jpg (529 KiB)

WARNING in webpack performance recommendations:
You can limit the size of your bundles by using import() or require.ensure to lazy load some parts of your application.
For more info visit https://webpack.js.org/guides/code-splitting/
Child HtmlWebpackCompiler:
1 asset
Entrypoint HtmlWebpackPlugin_0 = __child-HtmlWebpackPlugin_0
[0] ./node_modules/html-webpack-plugin/lib/loader.js!./src/index.html 1.01 KiB {0} [built]
Child mini-css-extract-plugin node_modules/css-loader/dist/cjs.js!src/css/style.css:
Entrypoint mini-css-extract-plugin = *
[0] ./node_modules/css-loader/dist/cjs.js!./src/css/style.css 3.09 KiB {0} [built]
[3] ./src/images/01.jpg 63 bytes {0} [built]
[4] ./src/images/02.png 63 bytes {0} [built]
[5] ./src/images/03.jpg 63 bytes {0} [built]
[6] ./src/images/04.png 63 bytes {0} [built]
[7] ./src/images/05.png 63 bytes {0} [built]
+ 2 hidden modules

当我们重新打开 /dist/index.html 时会发现图片并没有加载出来?查看 css 源码后发现原来是路径有问题,编译后的路径是 img/01.a8e7ddb2.jpg 这种相对路径。

由于 css 本身有一个文件夹,通过相对路径引入,那就会从 css 目录下进行查找。实际找到的是 dist/css/img/01.a8e7ddb2.jpg 这条路径。

遇到这种情况怎么办呢?我们可以给 MiniCssExtractPlugin.loader 添加 publicPath 选项用以修正路径,重新编译后就可以看到图片正确被加载了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
// 匹配文件规则
test: /\.css$/i,
// use 从右至左进行应用
use: [
{
loader: MiniCssExtractPlugin.loader,
options: {
publicPath: '../',
}
},
'css-loader'
],
},

在 js 中也可以引用文件,打开 /src/js/index.js, 在原先的基础上添加如下代码:

1
2
3
4
5
6
7
8
9
10
import img1 from '../images/06.jpg';
import img2 from '../images/webpack.jpg';
import img3 from '../images/webpack.svg';

// 省略其他代码...

log('测试图片引入~');
console.log('img1 --> ', img1);
console.log('img2 --> ', img2);
console.log('img3 --> ', img3);

重新编译后可以在 Console 面板可以看到 js 输出了文件资源的路径:

url-loader

url-loader 功能也类似于 file-loader,不同的是当文件大小(单位 byte)小于 limit 时,可以返回一个 DataURL

为什么要用 DataURL 呢?我们知道页面上每加载一个图片资源,都会发起一个 HTTP 请求。而建立 HTTP 请求的过程是需要花时间的。因此可以将文件转为 DataURL 嵌入 html/css/js 文件中,这样可以有效减少 HTTP 建立连接时所带来额外的时间开销了。同时 html/css/js 文件也可以被浏览器缓存,DataURL 被引入后也能一同被缓存。

图片转 DataURL 也有缺点,那就是编码后文本储存所占的空间比图片会更大。这其实就是传输体积与 HTTP 连接数的权衡。所以最佳做法是将小图片转为 DataURL,转换后并不会有过多体积溢出,而大尺寸图片照常引入即可。

安装 url-loader:

1
npm install url-loader -D

修改 webpack.config.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
rules: [
{
// 匹配文件规则
test: /\.css$/i,
// use 从右至左进行应用
use: [
{
loader: MiniCssExtractPlugin.loader,
options: { publicPath: '../' }
},
'css-loader'
],
},
{
test: /\.(png|jpe?g|gif|webp)(\?.*)?$/,
use: {
loader: 'url-loader',
options: {
limit: 10000,
name: 'img/[name].[hash:8].[ext]'
},
},
},
{
test: /\.(svg)(\?.*)?$/,
use: {
loader: 'file-loader',
options: {
name: 'img/[name].[hash:8].[ext]'
},
},
},
],

在上例中将 pngjpgjpeggifwebp 文件交给 url-loader 处理,而 svg 仍由 file-loader 处理。这样做的理由是: DataURL 内联 svg 会破坏 sprite 系统 (将多个 svg 合为一张使用的技术) 中使用的Fragment Identifiers,因此不将 svg 转为 DataURL

url-loader 设定匹配规则后,配置 namelimit 选项。url-loadername 选项与 file-loadername 作用是相同的,就不再累述。

limit 是指定以字节(byte) 为单位的文件最大尺寸。当文件尺寸小于等于 limit 所设的值,那文件将会被转为 DataURL。相反,若文件尺寸大于 limit 时,则使用备用 loader。默认备用 loaderfile-loader。可以设定 fallback 选项来修改备用 loader

1
2
3
4
5
6
7
8
{
loader: 'url-loader',
options: {
limit: 10000,
name: 'img/[name].[hash:8].[ext]'
fallback: 'file-loader'
}
}

limit 的选值不易过大,可以设为 10240 (10KB)或 10000,也可以根据项目实际情况进行调整。

现在来测试 limit 的效果。unix 系统可以在终端使用 ls -l 命令来查看文件信息:

1
2
3
4
5
6
7
8
9
10
11
➜  getting-started-loader-images git:(master) ✗ cd ./src/images
➜ images git:(master) ✗ ls -l
total 6144
-rwxr-xr-x 1 anran staff 173596 May 28 17:41 01.jpg
-rwxr-xr-x 1 anran staff 761560 May 28 17:41 02.png
-rwxr-xr-x 1 anran staff 542065 May 28 17:41 03.jpg
-rwxr-xr-x 1 anran staff 376562 May 28 17:41 04.png
-rwxr-xr-x 1 anran staff 510812 May 28 17:41 05.png
-rw-r--r-- 1 anran staff 760117 May 28 17:41 06.jpg
-rw-r--r--@ 1 anran staff 6943 May 30 13:54 webpack.jpg
-rw------- 1 anran staff 647 May 28 21:33 webpack.svg

从输出的信息可以看到 webpack.svg (647B) 和 webpack.jpg (6943B) 的文件尺寸都低于设定的 limit: 10000。由于 svg 文件不通过 url-loader 处理,那按照预想它将会被输出到 /dist/img 中。webpack.jpg 可以被 url-loader,那编译后应该被嵌入到 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
➜  getting-started-loader-images git:(master) ✗ npm run build

> getting-started-loader@1.0.0 build /Users/anran/project_my/webpack-example/getting-started-loader-images
> rimraf ./dist && webpack --config ./webpack.config.js

Hash: 8d2e8c8220e86d46e388
Version: webpack 4.43.0
Time: 692ms
Built at: 05/30/2020 2:08:46 PM
Asset Size Chunks Chunk Names
css/index.css 1.63 KiB 0 [emitted] main
img/01.a8e7ddb2.jpg 170 KiB [emitted]
img/02.46713ed3.png 744 KiB [emitted] [big]
img/03.70b4bb75.jpg 529 KiB [emitted] [big]
img/04.b7d3aa38.png 368 KiB [emitted] [big]
img/05.875a8bc2.png 499 KiB [emitted] [big]
img/06.5b8e9d1e.jpg 742 KiB [emitted] [big]
img/webpack.258a5471.svg 647 bytes [emitted]
index.html 990 bytes [emitted]
js/bundle.js 10.5 KiB 0 [emitted] main
Entrypoint main = css/index.css js/bundle.js
[0] ./src/css/style.css 39 bytes {0} [built]
[1] ./src/js/index.js + 4 modules 10.1 KiB {0} [built]
| ./src/js/index.js 881 bytes [built]
| ./src/js/log.js 60 bytes [built]
| ./src/images/06.jpg 63 bytes [built]
| ./src/images/webpack.jpg 9.08 KiB [built]
| ./src/images/webpack.svg 68 bytes [built]
+ 1 hidden module

WARNING in configuration
The 'mode' option has not been set, webpack will fallback to 'production' for this value. Set 'mode' option to 'development' or 'production' to enable defaults for each environment.
You can also set it to 'none' to disable any default behavior. Learn more: https://webpack.js.org/configuration/mode/

WARNING in asset size limit: The following asset(s) exceed the recommended size limit (244 KiB).
This can impact web performance.
Assets:
img/04.b7d3aa38.png (368 KiB)
img/03.70b4bb75.jpg (529 KiB)
img/05.875a8bc2.png (499 KiB)
img/02.46713ed3.png (744 KiB)
img/06.5b8e9d1e.jpg (742 KiB)

WARNING in webpack performance recommendations:
You can limit the size of your bundles by using import() or require.ensure to lazy load some parts of your application.
For more info visit https://webpack.js.org/guides/code-splitting/
Child HtmlWebpackCompiler:
1 asset
Entrypoint HtmlWebpackPlugin_0 = __child-HtmlWebpackPlugin_0
[0] ./node_modules/html-webpack-plugin/lib/loader.js!./src/index.html 1.37 KiB {0} [built]
Child mini-css-extract-plugin node_modules/css-loader/dist/cjs.js!src/css/style.css:
Entrypoint mini-css-extract-plugin = *
[0] ./node_modules/css-loader/dist/cjs.js!./src/css/style.css 2.98 KiB {0} [built]
[3] ./src/images/01.jpg 63 bytes {0} [built]
[4] ./src/images/02.png 63 bytes {0} [built]
[5] ./src/images/03.jpg 63 bytes {0} [built]
[6] ./src/images/04.png 63 bytes {0} [built]
[7] ./src/images/05.png 63 bytes {0} [built]
+ 2 hidden modules
编译后的目录
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
   .
├── dist
│ ├── css
│ │ └── index.css
│ ├── img
│ │ ├── 01.a8e7ddb2.jpg
│ │ ├── 02.46713ed3.png
│ │ ├── 03.70b4bb75.jpg
│ │ ├── 04.b7d3aa38.png
│ │ ├── 05.875a8bc2.png
│ │ ├── 06.5b8e9d1e.jpg
│ │ └── webpack.258a5471.svg
│ ├── index.html
│ └── js
│ └── bundle.js
├── package-lock.json
├── package.json
├── src
│ ├── css
│ │ └── style.css
│ ├── images
│ │ ├── 01.jpg
│ │ ├── 02.png
│ │ ├── 03.jpg
│ │ ├── 04.png
│ │ ├── 05.png
│ │ ├── 06.jpg
│ │ ├── webpack.jpg
│ │ └── webpack.svg
│ ├── index.html
│ └── js
│ ├── index.js
│ └── log.js
└── webpack.config.js

重新打开 /dist/index.html 后可以在浏览器控制台看到如下输出的信息:

HTML 资源引入

HTML 中有一种常见的情况是:在模板中通过相对路径引入图片、脚本等资源时,发现引入的资源都没有被打包进去。

为什么会发生这种情况呢?原来是 webpack 默认不会处理 html 中的资源引入。为了能使 HTML 能通过相对路径引入资源,主要有 3 种解决的方案:

lodash template

现在项目中 /src/index.html 是作为 html-webpack-plugin 的模板,在模板中可以使用 lodash template 语法(以下简称模板语法)来插入内容。语法格式为: <%= value %>

比如在 src/index.html 的模板中插入图片:

/src/index.html
1
2
3
4
5
6
<div class="panels">
<!-- 其他代码略... -->
<div class="panel panel6">
<img class="img" src="<%= require('./images/06.jpg').default %>" alt="">
</div>
</div>
/src/css/style.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
/* 为了使页面美观,再添加一些样式 */
.panel6 {
position: relative;
overflow: hidden;
background-color: #061927;
}

.panel6 .item {
position: relative;
}

.panel6 .img {
position: absolute;
height: 100%;
transform: scale(1);
transition: transform 0.4s 0.6s;
}

.panel6.open {
flex: 2;
}

.panel6.open .img {
transform: scale(1.2);
}

上例将通过 require() 函数引入图片。webpack 引入图片时默认是通过 ESModule 来引入的,因此解析的结果大致为 {default: module} 这种形式。因此后面还需要再加一个 default。这样就能正确的引入资源啦。

静态目录

第二种就是新增一个静态目录 static(或者叫 public)。

HTML 默认不是引用不了源码目录上的资源吗?那我就直接将资源输出到 dist 目录上。模板引用资源时直接引入输出后的文件不就行啦?

copy-webpack-plugin 可以完成这种迁移的功能。它将从 form 处复制文件/文件夹,复制到 to (默认是 webpack 的输出目录)中。现在来安装它:

1
npm i -D copy-webpack-plugin

新增 static 目录,并添加一些测试文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
  .
├── package.json
├── src
│ ├── css
│ │ └── style.css
│ ├── images
│ │ ├── 01.jpg
│ │ ├── 02.png
│ │ ├── 03.jpg
│ │ ├── 04.png
│ │ ├── 05.png
│ │ ├── 06.jpg
│ │ ├── webpack.jpg
│ │ └── webpack.svg
│ ├── index.html
│ ├── js
│ │ ├── index.js
│ │ └── log.js
+ │ └── static
+ │ └── images
+ │ ├── 06.jpg
+ │ ├── webpack.jpg
+ │ └── webpack.svg
└── webpack.config.js

现在将 src/static/images 的所有文件(不管代码里有没有引入这些文件)都复制到 dist/img 中。

/webpack.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
// webpack.config.js
{
plugins: [
new CopyPlugin({
patterns: [
{
from: path.resolve(__dirname, 'src/static/images'),
to: path.resolve(__dirname, 'dist/img')
},
],
}),
],
}

如果你不仅想要复制图片还想要复制其他诸如 css 样式表、js 脚本甚至是 excel 文件到输出目录的话。那可以考虑将 static 目录与 dist 目录进行合并,将 staticdist 下的目录名保持一致。

比如将 static 的下 images 文件夹更名为图片输出目录 img,这样打包后会输出到同一个目录中:

/webpack.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// webpack.config.js
{
plugins: [
new CopyPlugin({
patterns: [
// 如果只传 string 的话,那这个 string 相当于 from
// path.resolve(__dirname, 'src', 'static'),

// to 默认是 `compiler.options.output`, 也就是 dist 目录
// {
// from: path.resolve(__dirname, 'src/static'),
// to: ''
// },

// 当前配置中与上面两例等价
{
from: path.resolve(__dirname, 'src/static'),
to: path.resolve(__dirname, 'dist')
},
],
}),
],
}

若指定文件/文件夹不想复制到 dist 中,还可以使用 globOptions.ignore 来忽略:

/webpack.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// webpack.config.js
{
plugins: [
new CopyPlugin({
patterns: [
{
from: path.resolve(__dirname, 'src/static'),
to: path.resolve(__dirname, 'dist')
globOptions: {
ignore: ['/**/webpack.jpg', '/**/img/webpack.svg'],
}
},
],
}),
],
}

重新修改模板中的图片的引入的路径,使其指向输出目录的 img:

1
2
3
4
<div class="panel panel6">
<img class="img" src="./img/06.jpg" alt="">
<p class="item index">VI</p>
</div>

编译后就能看到图片正确被引用了。

html-loader

最后一种是安装 html-loader,让 webapck 可以处理 html 资源的引入。

1
npm install -D html-loader
1
2
3
4
5
6
7
rules: [
{
test: /\.html$/i,
loader: 'html-loader',
},
// 省略其他 rule...
]

配置 html-loader 后,HTML 访问相对路径的资源就由 html-loader 来进行引入。将模板中的路径改为源码相对路径:

1
2
3
4
<div class="panel panel6">
<img class="img" src="./images/06.jpg" alt="">
<p class="item index">VI</p>
</div>

在实际编译时,<img class="img" src="./images/06.jpg" alt="">src 的值会被转为 require('./images/06.jpg'),通过 webpack 引入后再将编译后的结果传入图片的 src 属性中。

此时重新编译后就可以正确引入了。但配置 html-loader 的方法会与方法二访问静态目录资源有点冲突。配置 html-loader 后就不能通过 ./../ 这种相对路径来访问资输出目录的资源了。

如果我们配置了 html-loader 的同时又还想访问静态资源怎么办呢?这时可以通过根路径 / 逐层来访问,这样 html-loader 就不会处理这种路径:

1
2
3
4
 <div class="panel panel6">
<img class="img" src="/img/06.jpg" alt="">
<p class="item index">VI</p>
</div>

现在问题又来了,若我们通过根路径来访问资源的话,那就不能单纯地打开文件来在浏览器查看效果了。因为直接打开文件到浏览器上,是通过 file:// 协议打开的。浏览器实际上访问的路径是文件的绝对地址。

比如笔者打开文件后,浏览器地址栏展示的 url 是: file:///Users/anran/project_my/webpack-example/getting-started-static-assets/dist/index.html。现在通过根路径访问资源,需要浏览器补全为完整的 URL,经过浏览器补全后绝对路径是 file:///img/06.jpg。这样路径都是错误的自然就访问不到想要的资源啦。

如果有写过 SPA(单页面应用) 项目的朋友应该很熟悉。将 SPA 项目打包后直接访问 index.html 页面是空白的,这种情况多半就是从根路径引入资源失败而引起的。

这个问题解决的办法也很简单,就是将编译后的项目部署到服务器上,直接通过服务器进行访问,问题就迎刃而解了。为什么这样就可以解决了呢?

比如笔者的网站域名是 anran758.github.io,现在将页面部署到服务器后,直接在浏览器访问 https://anran758.github.io/,实际上访问的是 /dist/index.html 文件。html 通过相对路径访问/img/06.jpg,那补全后图片的路径就是 https://anran758.github.io/img/06.jpg。这样自然就能访问资源啦。

我们不妨通过 Node.js 起一个本地服务器测试一下。在 /dist 同级目录上新建一个 server.js 脚本,添加如下代码:

/server.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const express = require('express');
const config = require('./webpack.config');

const app = express();
const PORT = 8001;

// 设置静态资源入口
app.use(express.static(config.output.path));

// 监听端口
app.listen(PORT, (err) => {
if (err) {
console.log(err);
return;
}

console.log('Listening at http://localhost:' + PORT + '\n');
})

上例脚本代码是通过 express 快速搭建一个本地服务器,将服务器静态资源入口设为 webpack.config.js 的输出目录(也就是 /dist),随后启动服务器。

express 是基于 Node.js 的 web 框架,要使用它之前需要安装依赖:

1
npm install -D express

package.json 中添加个快捷入口,并在终端运行该脚本:

1
2
3
4
5
6
{
"scripts": {
// 其他脚本..
"test:prod": "node server.js"
},
}
1
2
3
4
5
6
➜  getting-started-static-assets git:(master) ✗ npm run test:prod 

> getting-started-loader@1.0.0 test:prod /Users/anran/project_my/webpack-example/getting-started-static-assets
> node server.js

Server is running at http://localhost:8001 . Press Ctrl+C to stop.

打开 http://localhost:8001 后就能看到图片资源正确被引用了。

总结

好啦,现在 webpack 基础篇也到了尾声。我们对上述知识做一个简单的小结:

webpack 是一个静态模块打包工具,它本体虽然只支持处理 javascript 的模块,但可以通过 loader 让 webpack 完成原本它不能处理的功能。

webpack 的提供插件的功能,插件可以针对某种需求做特定处理,比如自动给 html 插入资源。

除了静态目录的文件外,我们发现 webpack 输出的文件都是有依赖关系的。为什么会这么说呢?仔细看看 webpack 处理的逻辑就能想清楚了:

webpack 从程序的入口 /src/js/index.js 开始处理,入口文件引入了 style.css,而 style.css 内又引用了图片资源。然后 HTML 再通过 webpack 插件引入模板,再将这些资源插入模板中。这就是文件的依赖关系,这些依赖关系最终会生成一个**依赖图(Dependency Graph)**。

想必看到这里的各位对 webpack 都有了个比较清晰的概念了吧?当然这只是一个开始,后面还有一些高级的概念在本文中由于篇幅的限制无法一并理清。若对笔者 webpack 的笔记感兴趣的话可以继续关注此系列的更新,下一篇将围绕开发环境进行梳理。

参考资料:

Flexbox 布局实际用例

上篇文章介绍了 flexbox 的属性与示例,本文再通过几个 flex 布局的案例来体会 flex 布局的特性带来的便利和问题~

格式化上下文

当我们给父容器设置 flex 属性后,flex 容器会在容器内创建一个新的 **flex 格式化上下文(formatting context)**。在这上下文中 floatclear 将失去作用,vertical-align 对于 flex 元素也不再会起作用。

在实际开发中,当我们使用行内元素(inlineinline-block) 时,有时候可能会看到元素之间会有一个奇怪的间隙,并且设置的字体越大间隙就越大。原来这个间隙是我们在编写源代码时标签换行导致,不换行就不会出现这种情况。

多数情况下,我们在编写代码时会习惯用编辑器对代码进行格式化,格式化后会使这些标签换行从而导致间隙。这在要求像素级还原的项目中就有点尴尬了。

以前常见的做法是在父元素设置 font-size: 0 消除间隙,再设置子元素的字体大小。这样做确实有点麻烦,因此在 flex 上下文中,这些间隙默认就会被清除。

圣杯布局

通常我们使用 flex 布局更多的是用于整体的布局设计,如:

在互联网早期,由于用户网路的限制,经常会出现 html 的内容显示出来但页面样式还没加载出来的情况,这会导致用户没能最先看到想看的东西。因此 Matthew Levine 在 2006 年提出了圣杯布局的概念,在 HTML 源代码中将用户想看的内容挪到次要内容的前面。

上例 demo 就是使用 flex 布局实现的圣杯布局,虽然在 HTML 源码里 Main 处于其他两块内容之上,但通过 order 属性可以调整元素间的顺序。

除此之外,还可以通过媒体查询(@media)做响应式页面,当屏幕宽度小于 640px 后仅需修改几项 flex 属性就可以改变布局排列的方式,十分灵活。

如果你使用过 react/vue 主流 UI 库的话,你就会发现他们使用布局容器也是 flex 布局实现的,比如 Element UIAnt Design 等。

栅格布局

栅格布局也可以通过 flex 来实现:在以下的 demo 中,HTML 源码内的各元素都是平级,通过调整 flex 属性实现了跨行或跨列的效果。

justify-content 尾列不整齐

让CSS flex布局最后一行列表左对齐的N种方法 –By 张鑫旭

多数情况下使用 justify-content 是要求子元素们散开,但尾列元素不够的时候,散开就显得很奇怪了,为此我们可以做如下处理:

动画

在 MDN Animatable CSS properties 上列出了可以使用 AnimationsTransitions 进行动画处理的属性,其中就有 flex 属性。因此还可以结合动画进行布局设计:

结束

通过以上几个案例是不是对 flex 布局的灵活有了更深的感受呢?以上 demo 大多借鉴已有的思路,如果你有什么好的想法,也可以自己动手尝试一番或分享出来~

参考资料:

Pixiv 背景图例:

  1. ちょけ | アリスミクと白うさぎ
  2. Azit | Miku
  3. ぽむ | もっと高くまで!
  4. 雨陌 | 8.31
  5. akino | つもりつもるキモチ。

Flexbox 布局入门

互联网早期实现布局是需要通过多种不同属性组合才能实现我们想要的布局。

比如常见的垂直居中,刚接触 css 的朋友看到 vertical-align: middle; 这个属性可能就会认为它就是用于垂直居中的,但实际上并没有那么简单。如果想要通过该属性来实现垂直居中,还需要其他小伙伴配合。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
.container {
width: 200px;
height: 200px;
border-radius: 6px;
text-align: center;
color: #fff;
background: #e44b27;
white-space: nowrap;
}

/* 该伪类是实现垂直居中关键 */
.container:after {
content: '';
display: inline-block;
height: 100%;
vertical-align: middle;
}
.content {
display: inline-block;
white-space: normal;
vertical-align: middle;
text-align: left;
}
1
2
3
<div class="container">
<div class="content">我想居中!</div>
</div>

这样看来,为了实现垂直居中布局,我们还得打一套组合拳才能出来才行,是不是看起来有点麻烦的样子?

W3C 在 2009 年提出的 Fiexbox(flex) 布局草案,就是针对用户界面设计优化的 CSS 盒模型。如果使用 flex 布局来实现上面的垂直居中布局的话,可以简化为:

1
2
3
4
5
6
7
8
9
10
11
12
.container {
width: 200px;
height: 200px;
border-radius: 6px;
color: #fff;
background: #e44b27;

/* 使用 flex 布局 */
display: flex;
justify-content: center;
align-items: center;
}
1
2
3
<div class="container">
<div>我想居中!</div>
</div>

修改后的代码就显得更精简了,也不需要其他小伙伴来搭把手。布局的事情就让 flex 家族自己来解决即可。


概念

应用 flex 布局的容器我们通常称为 **弹性盒子/容器(flex container)**。弹性容器可以由 display: flexdisplay: inline-flex 生成。弹性盒子的子项常称为 **弹性元素/项目(flex items)**,它以 flex 布局模型进行布局。

1
2
3
.container {
display: flex | inline-flex;
}

如果想要学习 flex 布局的工作方式,最先需要学习的是它自身的术语。下面直接引用 flex 草案中术语的介绍图:

别被原版英文术语给吓倒了,咱们翻译一下其实就很好理解了:

在术语示意图中可以看到两根轴,分别是**主轴(main axis)垂直交叉轴(cross axis)。同时标注了主轴起点(main start)终点(main end)交叉轴的起点(cross start)终点(cross end)**。

默认情况下 flex 布局是按主轴的方向进行布局的。flex 元素所占据的 主轴空间(main size) 就是 flex 元素的宽度(width)、所占据的 交叉轴空间(cross size) 就是 flex 元素的高度(height)。


flex 容器属性

flex 容器里可以通过以下几种属性来控制容器的行为:

  • flex-direction
  • flex-wrap
  • flex-flow
  • justify-content
  • align-content
  • align-items

为了更好的观察各属性的行为,笔者在 codepen 上给不同属性都写了 demo 做参考。

目前有个新规范(CSS Box Alignment Module Level 3)正处于工作草案的状态中,对一些属性添加新值,如 [first|last]? baselineself-startself-endstartendleftrightunsafe | safe

这些新值多数浏览器都没实现,为了便于演示,此处仅讲解初始版本的值。Firefox 浏览器对新值实现的比较超前,也建议通过使用 Firefox 浏览器来查看 demo。

flex-direction

flex-direction 指示内部元素如何在 flex 容器中布局。可以简单的理解为 flex 容器的布局方向。其默认值为 row,可选语法如下:

1
2
/* 常用属性 */
flex-direction: row | row-reverse | column | column-reverse;
  • row: 主轴起点和主轴终点与内容方向相同。简而言之就是内容从左到右进行布局。
  • row-reverse: 与 row 行为相同,但主轴起点和主轴终点对调了位置。
  • column: 主轴由水平方向转为垂直方向,布局从上往下排。
  • column-reverse: 主轴由水平方向转为垂直方向,布局从上往下排。

值得注意的是,全局属性 dir 的作用是指示元素的文本的方向性,该属性会受到 rowrow-reverse 的影响。

flex-wrap

flex-wrap 指定 flex 元素单行显示还是多行显示 。如果可以换行,你甚至还可以通过该属性控制行的堆叠方向。它的取值如下所示:

1
flex-wrap: nowrap(默认值) | wrap | wrap-reverse;

可以通过本例 demo 右上角的按钮来修改元素的数量,观察三个值之间的变化:

  • nowrap: flex 容器宁愿压榨元素的空间也不肯换行。甚至压缩到一定地步后还会溢出容器。
  • wrap: 若子项超过容器所容纳的宽度,则允许断行展示。
  • wrap-reverse: 和 wrap 的行为一样,只是交叉轴起点与终点互换

flex-flow

flex-flow 属性是 flex-directionflex-wrap 的简写。这个没啥好说的,也就不额外写 demo 了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* 语法 */
flex-flow: <flex-direction> || <flex-wrap>;

/* 单独设置 flex-direction 的属性 */
flex-flow: row;
flex-flow: column;

/* 单独设置 flex-wrap 的属性 */
flex-flow: nowrap;
flex-flow: wrap;

/* 同时设置两种属性,建议按照语法顺序进行书写 */
flex-flow: row nowrap;
flex-flow: column wrap;

justify-content

justify-content 属性定义了容器主轴中各 flex 元素之间的对齐方式。这是 flex 布局中常用的属性之一。

1
2
3
justify-content: normal |
space-between | space-around | space-evenly |
center | flex-start | flex-end

在初始版本中,justify-content 的默认值为 flex-start。但在最新版本中的 chrome 浏览器被修改为了 normal

为了对比属性之间的差异,本例 demo 将元素的两侧 margin 清空:

  • normal: 排列效果等同 flex-start
  • flex-start: 默认情况是左对齐,从行首开始排列。每行第一个 flex 元素与行首对齐,同时所有后续的 flex 元素与前一个对齐。
  • flex-end: 默认情况下是右对齐,从行尾开始排列。每行最后一个 flex 元素与行尾对齐,其他元素将与后一个对齐。
  • center: 该值使元素居中对齐。
  • space-between: 首尾两端对齐,内部元素之间的间距相等。
  • space-around: 在每行上均匀分配弹性元素。相邻元素间距离相同,首尾两个元素的距离是相邻元素之间距离的一半
  • space-evenly: 主轴内各元素两侧均匀分配剩余空间。(注意此处与 space-around 的差异)

align-items

align-items 属性除了可以在 flex 布局中有效,还可以在 grid(网格) 布局中应用。在 flex 布局中它的作用是决定交叉轴的对齐方式。这也是 flex

1
2
3
4
5
/* 主流浏览器已经实现的值 */
align-items: normal | flex-start | flex-end | center | baseline | stretch

/* 新草案添加的值 */
align-items: | start | end | [ first | last ]baseline | left | right
  • normal: 在 flex 布局中 normal 的表现效果如同 stretch 一样。
  • stretch: 弹性元素被在交叉轴轴方向被拉伸到与容器相同的高度或宽度。若容器没有设置高度,则取当前行中最高元素的高度,如本例中元素 4 是第一行中最高的元素,那第一行中的高度都被拉伸到与最高元素相同的高度。第二行中最高的元素是元素 2,因此第二行高度都取至元素 2。
  • flex-start: 元素向交叉轴起点对齐。
  • flex-end: 元素向交叉轴终点对齐。
  • center: 元素在交叉轴居中。
  • baseline: 所有元素向基线对齐。侧轴起点到元素基线距离最大的元素将会于侧轴起点对齐以确定基线。在例子中放大元素 6 的 font-size, 与 center 进行对比就能看到差异了。

align-content

justify-content 是作用于主轴上,而 align-content 则是用于定义交叉轴的对齐方式。值得注意的是,若 flex 容器内只有一根轴线,该属性将不起作用

1
2
3
4
5
/* 主流浏览器已经实现的值 */
align-content: normal | space-between | space-around | space-evenly | stretch | center | flex-start | flex-end

/* 主流浏览器多数未实现的值 */
align-content: [first|last]? baseline, start, end, left, right

父容器设置了 flex 布局后,若子元素没有设定 height 属性的话,默认会将容器内的子元素进行拉伸。

为了便于观察两者的差异,笔者在 demo 中新增一列进行对比。左列的 flex 元素使用 height 属性,右列使用 min-height 属性。同时将 flex 容器高度设置为 400px:

  • normal: 像未设置值,元素处于默认位置。
  • stretch: 拉伸所有行来填满剩余空间。剩余空间平均的分配给每一行(若某元素设置了高度,那么该值对这个元素将不会起作用)。
  • flex-start: 交叉轴起点对齐。
  • flex-end: 交叉轴终点对齐。
  • center: 交叉轴居中对齐。
  • space-between: 交叉轴两端对齐,行之间间距相等
  • space-around: 交叉轴均匀对齐,行两端间距相等
  • space-evenly: 交叉轴内各元素两侧均匀分配剩余空间。

Flex Item

Flex Container(弹性容器)的一级子元素就是 Flex item(弹性元素)。以下主要应用于 Flex item 的属性。

  • flex-basis
  • flex-grow
  • flex-shrink
  • flex
  • align-self
  • order

flex-grow

flex-grow 属性用于定义元素所占有的比例,它接受一个正整数,默认值为 0

1
2
3
4
flex-grow: <number>

/* 例子: 仅接受正数的值 */
flex-grow: 1;

flex-shrink

flex-grow 相反,flex-shrink 属性处理元素收缩的问题,默认为 1,意味着元素默认会随着容器缩小而等比例缩小。当值为 0 时则不缩放。

1
2
3
4
5
6
7
flex-shrink: <number>

/* 例子: 默认缩放 */
flex-shrink: 1;

/* 例子: 使元素不缩放 */
flex-shrink: 0;

在以下 demo 中,各 flex 项目的宽高相等。当父容器有足够的空间时,元素不需要紧衣缩食,因此 flex-shrink 也没有机会表现出它的作用。

将 flex 容器尺寸调小后可以发现,flex-shrink 的值越大,元素被压榨的空间越多。

flex-basis

flex-basis 指定了 flex 元素在主轴空间(main size)所占的初始大小。

1
flex-basis:  <'width'>

当一个元素同时被设置了 flex-basis (值为 auto 除外)和 width 属性时,flex-basis 具有更高的优先级。

W3C 鼓励使用 flex 简写属性(下一小节进行秒速)来控制灵活性,而不是直接使用 flex-basis 属性。因为简写属性 flex 可以正确地重置任何未指定的属性以适应常见的用途。

flex

flex 属性是 flex-growflex-shrinkflex-basis 的简写,规定了弹性元素如何伸缩以适应 flex 容器中的可用空间,默认值为 0 1 auto

1
flex: none | [ <'flex-grow'> <'flex-shrink'>? || <'flex-basis'> ]

flex 属性可以指定 1 个,2 个或 3 个值。

单值语法: 值必须为以下其中之一:

  • 一个无单位数(<number>): 它会被当作 <flex-grow> 的值。
  • 一个有效的宽度(width)值: 它会被当作 <flex-basis>的值。
  • 关键字 noneautoinitial

双值语法: 第一个值必须为一个无单位数,并且它会被当作 <flex-grow> 的值。第二个值必须为以下之一:

  • 一个无单位数:它会被当作 <flex-shrink> 的值。
  • 一个有效的宽度值: 它会被当作 <flex-basis> 的值。

三值语法:

  • 第一个值必须为一个无单位数,并且它会被当作 <flex-grow> 的值。
  • 第二个值必须为一个无单位数,并且它会被当作 <flex-shrink> 的值。
  • 第三个值必须为一个有效的宽度值, 并且它会被当作 <flex-basis> 的值。

这个属性没啥好演示的,其实就是之前介绍的三个属性的组合:

align-self

align-self 属性在 flex 布局中作用于单个 flex 元素上,它将控制指定元素在交叉轴上的位置。

1
2
3
4
align-self: auto | normal | stretch | center | flex-start | flex-end;

/* 多数浏览器未实现的功能 */
align-self: start | end | self-start | self-end | [first | last]? baseline;
  • auto: 设置为父元素的 align-items 值,如果该元素没有父元素的话,就设置为 stretch
  • normal: 在 flex 布局中,相当于 stretch 的效果。
  • stretch: flex 元素将会基于容器的宽和高,按照自身 margin box 的 cross-size 拉伸。
  • center: 使项目在交叉轴中居中。
  • flex-start: flex 元素会对齐到 cross-axis 的首端。
  • flex-end: flex 元素会对齐到 cross-axis 的尾端。

order

order 属性用于设置指定 flex 元素在容器中的顺序。容器中的 flex 元素按升序值排序,若值相同则按其源代码出现的顺序进行排序,默认值为 0。它接受一个整数值(integer),如 -203 等。

1
order: <integer>

我们可以操作下面的 demo 来控制元素的顺序,比如将第三项元素通过 order 在移动到第一位。


兼容性

要将学到的新东西应用到实际项目中就不得不考虑其兼容性了。通过 caniuse 我们可以看到:flex 布局经过多年的发展,主流浏览器都已经对 flex 布局基本模块都实现完毕了。

PC 端需要考虑的是要不要兼容 IE,移动端最低兼容为 ios 3.2+Android 2.1+。如果你需要开发微信小程序,那么小程序官方就推荐使用 flex 布局。

早期 flex 布局是通过 display: box; 来申明,这是使用了旧的规范,后来该值被 flex 给替换掉了。还有一些很低版本的浏览器或许还需要添加浏览器前缀才能使用 flex 布局。因此你在某处看到如下代码也不用感到奇怪,这是开发者在给布局做兼容呢:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.flex-center {
display: -webkit-box;
display: -webkit-flex;
display: -moz-box;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-webkit-justify-content: center;
-moz-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-box-align: center;
-webkit-align-items: center;
-moz-box-align: center;
-ms-flex-align: center;
align-items: center;
}

但如果要我们在开发时手动写这种兼容好像不是很靠谱,兼容又冗余。所幸现在的前端开发都会使用脚手架,这些脚手架一般都会内置 postcssautoprefix 之类的插件来帮助我们完成这些事。

还有一些朋友可能会说,我们老项目还是得要兼容 IE 8+ 呀,是不是意味着跟 flex 布局无缘了?其实不是的,github 上有一个叫 flexibilitypolyfill 可以让 IE8 + 也实现 flex 布局效果.


结束

本篇介绍了 flex 布局该如何使用、各属性的作用与效果,下一篇再详细讲讲 flex 布局在实际工作中的妙用~

参考资料:

  1. CSS Flexible Box Layout Module Level 1
  2. CSS Box Alignment Module Level 3
  3. Flex Item

Hexo blog 的升级与同步方案

前一篇我们介绍了如何使用 Hexo 框架及 Next 主题搭建博客。这次来聊聊如何安全的更新博客与主题的版本。

next theme


早期写博客时笔者就有考虑过使用 git 来做版本控制,那时 github 私人仓库还没有免费开放,国内虽然有 coding 和码云这些平台有开放少量的私人仓库,但由于懒得折腾就选了最方便同步的 OneDrive(因为它只需将文件夹移入就可以实现跨设备共享)。

后来笔者因为工作的原因,需要在多设备中频繁切换,这种简单同步方式就会暴露出一些问题。比如说,在设备 A 想对博客做一些自定义的修改,其中可能会动到依赖,但此时设备 B 的文件正在同步,那这样可能会导致文件不一致的问题。可能会将旧的文件重新同步过来,这可能会导致程序报错,还不易于排查。

冲突文件合并失败会额外添加如 index-anran758's MacBook Pro.js 之类的同名文件,并且发生冲突时是隐式的,你甚至不知道发生了冲突,这种体验使用不太友好。

因此 OneDrive 的同步方式适用于改动不会太大的文件。


如果你对 git 版本控制比较熟悉的话,那可以通过 git 对 blog 进行版本控制。

使用源码托管平台的话就如上文所说主要有这么几种选择:

国内的 gitee(码云)coding 是一个不错的选择,代码的上传于下载速度也比较可观。国外可以使用 github,github 的私人仓库是今年才开放无限制免费创建仓库数量的,缺点由于众所周知的问题,有时可能拉代码速度较慢。

笔者使用的是 github 作为源码托管,下文将要介绍的方法对于 git 仓库是通用,因此根据自身的喜好选择对应的平台。

博客托管

托管 blog 源码的步骤如下:

  1. 找到对应的平台,创建私人仓库(注意是 Private,不要将自己的私人配置也开源咯)。

  2. 仓库创建完毕后,得到仓库的地址。打开命令行,进入 /blog 目录下并输入命令:

    1
    2
    3
    4
    5
    6
    # 初始化 git 项目
    git init

    # 添加一个名为 origin 的 remote
    # your_repo_path 是你创建仓库得到的仓库地址
    git remote add origin your_repo_path
  3. 由于 /theme/next 本身也是一个仓库,git 无法提交嵌套仓库的文件夹,因此需要在 .gitignore 添加配置,忽略该文件夹

    1
    2
    # 其他忽略规则...
    themes/next/
  4. 提交代码

    1
    2
    3
    4
    5
    6
    # 提交代码
    git add .
    git commit -m "new: blog 数据开始进行版本控制"

    # 设置上游(-u)并推送至远程的 master 分支
    git push -u origin master
  5. 这样我们就完成了博客的源码托管。

主题托管

Next theme 官网介绍的安装方式如下:

1
2
3
4
5
# 进入 blog 目录
cd blog

# 言下之意就是将该库克隆到 themes 目录下的 next 文件夹中
git clone https://github.com/theme-next/hexo-theme-next themes/next

Next theme 7.0+ 版本中,主题嵌入了检查版本更新的代码,每当运行本地服务器时,都会进行检查版本号的更新。当有新的版本发布时会在命令行输出警告:

1
2
3
WARN  Your theme NexT is outdated. Current version: v7.4.2, latest version: v7.5.0
WARN Visit https://github.com/theme-next/hexo-theme-next/releases for more information.

这时你想体验 Next 的新特性的话可能会有点麻烦,因为原先我们在旧版本上修改了配置,或添加了一些自定义的布局。这将会造成代码冲突。

因此我们需要独立开两条分支:

  • master 分支是官方发布的正式版本,我们不去修改 master 分支的中的任何文件。
  • 另一条是我们自己创建的新分支,笔者命名为 customize, 言下之意为该分支含有我们自定义的修改,包括私人配置等。

除此之外,由于主题配置文件(theme/next/_config.yml)中含有某些应用的 appid 或者 secret,这些配置不应该被其他人随意看到以防冒名滥用。因此我们应该将该项目额外添加一个 remote 来保存我们的私人配置。 具体操作如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 此时已经下载到了主题文件夹

# 创建并切换新分支
git checkout -B customize

# 进行主题配置或其他修改操作

# 提交改动(未推送)
git add .
git commit -m "chg: 修改为自定义配置"

# 添加一个名为 userRepo(名字可以自己定义,只要自己能搞清是哪个来源即可) 的新 remote,
git remote add userRepo git@github.com:anran758/hexo-xxx-next.git

# 设置上游(即以后使用 git pull/status 时默认拉取 userRepo 源的 customize 分支),并推送指定 remote
git push -u userRepo customize

如此就完成了代码的追踪,以后使用 next 主题就不是从 hexo-theme-next 中获取了,而是我们自己的私人仓库 hexo-xxx-next 中获取,安装方式是一样的。

版本升级

Next

前文说过我们将源码托管的需求之一就是为了解决代码合并的问题,为了体验新版本的特性,我们需要将新版本的代码合并进我们的分支:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 从 origin/master 获取最新版本的代码
# 理论上我们不修改 master 分支的代码不会发生冲突
git fetch origin
git pull --no-commit origin master

# 切换至 customize 分支
git checkout customize

# 检查本地是否有文件改动,有的话需要进行 commit 提交或者使用 git stash 藏起来
git status

# 合并代码
git merge master

我们最起码修改过 _config.yml,因此会发生冲突也不奇怪,有冲突咱们就解决冲突。

如果你使用 vscode 进行编码,侧边栏有一个源代码管理,打开它可以看到冲突的文件。

打开冲突的文件,判断冲突项确定要保留(删除)的代码,解决冲突后,提交到缓存区(git add .(file))。缓冲区有本次升级所涉及的代码,可以大致预览一下本次的更新都做了什么事

1
2
3
4
5
6
7
8
9
10
11
12
13
# 将缓冲区的文件提交至 commit
git commit -m "Merge release v(version) into customize branch"

# 提交代码
git push
# Counting objects: 99, done.
# Delta compression using up to 4 threads.
# Compressing objects: 100% (57/57), done.
# Writing objects: 100% (99/99), 12.86 KiB | 346.00 KiB/s, done.
# Total 99 (delta 71), reused 64 (delta 42)
# remote: Resolving deltas: 100% (71/71), completed with 41 local objects.
# To github.com:anran758/hexo-xxx-next.git
# 4a70c18..54805a2 customize -> customize

升级完后运行本地服务器最后会输出一条:

1
INFO  Congratulations! Your are using the latest version of theme NexT.

Hexo

若最新版本的 Hexo 引入了你想要的新功能,你想更新 Hexo 版本的话,首先确定版本号变动的是哪一位。

package.json 的版本号格式是数字由点分隔,如 主版本号.功能版本号.补丁版本号。若更新是主(大)版本号的话,则需要先修改 dependencies 依赖中 hexo 的主版本号,再输入 npm update

以下是 hexo@v3 更新为 hexo@v4 的示例:

1
2
3
4
5
6
7
{
// ...
"dependencies": {
+ "hexo": "^4.0.0",
- "hexo": "^3.9.0",
}
}

命令行输入:

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
$ npx hexo -v
hexo: 3.9.0
hexo-cli: 2.0.0
os: Darwin 17.7.0 darwin x64
node: 12.13.1
v8: 7.7.299.13-node.16
uv: 1.33.1
zlib: 1.2.11
brotli: 1.0.7
ares: 1.15.0
modules: 72
nghttp2: 1.39.2
napi: 5
llhttp: 1.1.4
http_parser: 2.8.0
openssl: 1.1.1d
cldr: 35.1
icu: 64.2
tz: 2019c
unicode: 12.1

$ npm update
+ hexo@4.2.0
added 71 packages from 90 contributors, updated 14 packages and moved 5 packages in 12.513s

$ npx hexo -v
hexo: 4.2.0
hexo-cli: 3.1.0
os: Darwin 17.7.0 darwin x64
node: 12.13.1
v8: 7.7.299.13-node.16
uv: 1.33.1
zlib: 1.2.11
brotli: 1.0.7
ares: 1.15.0
modules: 72
nghttp2: 1.39.2
napi: 5
llhttp: 1.1.4
http_parser: 2.8.0
openssl: 1.1.1d
cldr: 35.1
icu: 64.2
tz: 2019c
unicode: 12.1

若只是后面两位版本号有变更的话,仅需输入 npm update 即可。

总结

单单从升级版本来合并代码的角度来看,实际上本地 commit 也可以做这种事,将 commit 储存在本地(.git)中不提交远端也是没有问题的,OneDrive也可以完成同步。

但从安全和可调试的角度来看,OneDrive的同步方式存在一定风险(懒的代价)。使用 git 版本控制可以清晰看到每一次提交的修改,不会多出奇奇怪怪的东西。必要的时候还可以进行回滚,相对来说更安全。但这种方案需要使用者了解一定的 git 知识。

从操作步骤来看,使用的 git 同步方案会产生多个仓库,这些仓库一般是拥有权限的人才能查看(修改)源码。比如完成了本文中两个仓库源码同步后,在另一台设备初次同步的步骤是:

  1. 通过 git clone 下载 blog 本体。
  2. 通过 git clone 下载私人仓库 next theme/theme 目录下。
  3. 进入两个仓库内安装对应的依赖

以上可以在 blog 项目下的 package.json 设置 scripts,通过一条命令来完成这些事。

由此我们可以看到,相比 OneDrive 的懒人方案,git 方案的操作步骤会更繁琐。更新方式也从自动更新变成手动更新。

两者种方案各有利弊,具体采用什么方案就看朋友们的习惯啦~


本文涉及到的 git 命令都是可以在 git 速查方案 查找相应的解释。

❌