0%

数据结构大致包含以下几种存储结构:

  • 线性表,还可细分为顺序表、链表、栈和队列;
  • 树结构,包括普通树,二叉树,线索二叉树等;
  • 图存储结构;

下面对各种数据结构做详细讲解。

线性表

线性表结构存储的数据往往是可以依次排列的,就像小朋友手拉手,每位学生的前面和后面都仅有一个小朋友和他拉手,具备这种“一对一”关系的数据就可以使用线性表来存储。

image

例如,存储类似 {1,3,5,7,9} 这样的数据时,各元素依次排列,每个元素的前面和后边有且仅有一个元素与之相邻(除首元素和尾元素),因此可以使用线性表存储。

线性表并不是一种具体的存储结构,它包含顺序存储结构和链式存储结构,是顺序表和链表的统称。

顺序表

顺序表,简单地理解,就是常用的数组,只是换了个名字而已,例如使用顺序表存储 {1,3,5,7,9},如图 1 所示:

image

1
由于顺序表结构的底层实现借助的就是数组,因此对于初学者来说,可以把顺序表完全等价为数组,但实则不是这样。数据结构是研究数据存储方式的一门学科,它囊括的都是各种存储结构,而数组只是各种编程语言中的基本数据类型,并不属于数据结构的范畴。

链表

我们知道,使用顺序表(底层实现靠数组)时,需要提前申请一定大小的存储空间,这块存储空间的物理地址是连续的,如图 1 所示。

链表则完全不同,使用链表存储数据时,是随用随申请,因此数据的存储位置是相互分离的,换句话说,数据的存储位置是随机的。

为了给各个数据块建立“依次排列”的关系,链表给各数据块增设一个指针,每个数据块的指针都指向下一个数据块(最后一个数据块的指针指向 NULL),就如同一个个小学生都伸手去拉住下一个小学生的手,这样,看似毫无关系的数据块就建立了“依次排列”的关系,也就形成了链表,如图 2 所示:

image

栈和队列

栈和队列隶属于线性表,是特殊的线性表,因为它们对线性表中元素的进出做了明确的要求。

栈中的元素只能从线性表的一端进出(另一端封死),且要遵循“先入后出”的原则,即先进栈的元素后出栈。

image

栈结构如图 3 所示,像一个木桶,栈中含有 3 个元素,分别是 A、B 和 C,从在栈中的状态可以看出 A 最先进的栈,然后 B 进栈,最后 C 进栈。根据“先进后出”的原则,3 个元素出栈的顺序应该是:C 最先出栈,然后 B 出栈,最后才是 A 出栈。

队列中的元素只能从线性表的一端进,从另一端出,且要遵循“先入先出”的特点,即先进队列的元素也要先出队列。

image

队列结构如图 4 所示,队列中有 3 个元素,分别是 A、B 和 C,从在队列中的状态可以看出是 A 先进队列,然后 B 进,最后 C 进。根据“先进先出”的原则,3 个元素出队列的顺序应该是 A 最先出队列,然后 B 出,最后 C 出。

树存储结构

树存储结构适合存储具有“一对多”关系的数据。

image

如图 5 所示,其中张平只有一个父亲,但他却有两(多)个孩子,这就是“一对多”的关系,满足这种关系的数据可以使用树存储结构。

图存储结构

图存储结构适合存储具有“多对多”关系的数据。

image

如图 6 所示,从 V1 可以到达 V2、V3、V4,同样,从 V2、V3、V4 也可以到达 V1,这就是“多对多”的关系,满足这种关系的数据可以使用图存储结构。

反转链表

输入一个链表,反转链表后,输出新链表的表头。

例如:

输入:a->b->c->d->e

输出:a<-b<-c<-d<-e

反转链表示意图如下,链表的最后一个元素 next 指向 null。

image

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/*function ListNode(x){
this.val = x;
this.next = null;
}*/

function isEmptyObject(obj) {// 判断输入参数链表是否为空
for (var name in obj) {
return false;
}
return true;
}

function ReverseList(pHead) {
if (isEmptyObject(pHead)) {// 调用链表是否为空函数
return false;
}
var pre = null;//链表的最后一个元素
var next = null;//初始化next为null,下面再赋值
while (pHead != null) {//pHead移动,直至到链表最后一个元素,指向null,结束循环
next = pHead.next;//pHead.next值先赋给next,以免覆盖,next移动
pHead.next = pre;//改变pHead指向,给pHead.next赋值,指向null
pre = pHead;//pre移动
pHead = next;//pHead移动
}
return pre;
}

单链表是否有环

  1. 创建哈希表,不过会占⽤较⼤的空间,不是最佳⽅法.( 时间复杂度 O(n) )
1
2
3
4
5
6
7
8
9
10
function judge(list){
var set =new Set();
while(list){
if(set.has(list)){
return true
}
set.add(list)
list=list.next()
}
}
  1. 给节点添加 visited 访问标记 (时间复杂度 O(n)), 不需要额外的空间
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function LinkedList(){
var Node=function(){
this.element=element;
this.next=null;
this.visited=0; //访问标记
}
}

function judge(list){
while(list){
if(list.visited==1){
return true
}
list.visited=1
list=list.next()
}
}
  1. 快慢指针法,设定快指针 fast,慢指针 slow,每次循环快指针 fast 移动两个位置,慢指针移动⼀个位置
    (时间复杂度 O(n)) 需要额外的空间
1
2
3
4
5
6
7
8
9
10
11
function judge(list){
var fast=list.next.next,
slow=list.next;
while(fast){
if(fast===slow){
return true
}
fast=fast.next.next
slow=slow.next
}
}

迭代算法(Iteration)

递归算法(Recursion)

  • 二叉树的遍历算法

回溯算法(Backtrack)

  • 八皇后问题

深度优先(Depth First Search, DFS)

  • 全排列问题

广度优先(Breadth First Search, BFS)

类型

  • 不需要确定当前深度
  • 需要确定当前深度

动态规划(Dynamic Programming, DP)

  • 斐波那契数列
  • 爬楼梯问题
  • 背包问题
  • 最长公共子序列
  • 最优二叉搜索树

分治算法(二分法, Binary Algorithm)

  • 二分排序
  • 二分查找

贪心算法(Greedy Algorithm)

  • 霍夫曼编码

滑动窗口(Slipping Window)

双指针(Double Pointer)

位运算(Bit)

要点

  • 异或运算(xor)
  • 任何数与 0 做异或运算,结果仍为原来的数。即:
1
a⊕0=a
  • 任何数和其自身做异或运算,结果为 0。即:
1
a⊕a=0
  • 异或运算满足交换律和结合律。即:
1
2
a⊕b=b⊕a
a⊕b⊕c=a⊕(b⊕c)

自动机

  • 搜索/排序算法
  • 快速排序
  • 希尔排序
  • 插入排序
  • 拓扑排序
  • 二分排序
  • 堆排序

关于链表(List)

  • 单向链表
  • 双向链表
  • 广义表

关于树(Tree)

  • 二叉树遍历
    • 前序遍历
    • 中序遍历
    • 后序遍历
    • 层序遍历
  • 二叉搜索树
  • 红黑树
  • kd 树
  • B 树
  • 极大堆

关于图(Graph)

  • 最小生成树
  • 最短路径问题

关于栈、队列、散列表(queue, stack, hashlist)

  • 栈对于二叉树层序遍历的实现
  • 最大优先级队列

Q1 判断一个单词是否是回文?

回文是指把相同的词汇或句子,在下文中调换位置或颠倒过来,产生首尾回环的情趣,叫做回文,也叫回环。比如 mamam redivider .

很多人拿到这样的题目非常容易想到用 for 将字符串颠倒字母顺序然后匹配就行了。其实重要的考察的就是对于 reverse 的实现。其实我们可以利用现成的函数,将字符串转换成数组,这个思路很重要,我们可以拥有更多的自由度去进行字符串的一些操作。

1
2
3
function checkPalindrom(str) {
return str == str.split('').reverse().join('');
}

Q2 去掉一组整型数组重复的值

1
2
3
比如输入: [1,13,24,11,11,14,1,2]
输出: [1,13,24,11,14,2]
需要去掉重复的11 和 1 这两个元素。

这道问题出现在诸多的前端面试题中,主要考察个人对 Object 的使用,利用 key 来进行筛选。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* unique an array
**/
let unique = function(arr) {
let hashTable = {};
let data = [];
for(let i=0,l=arr.length;i<l;i++) {
if(!hashTable[arr[i]]) {
hashTable[arr[i]] = true;
data.push(arr[i]);
}
}
return data

}

module.exports = unique;

Q3 统计一个字符串出现最多的字母

给出一段英文连续的英文字符串,找出重复出现次数最多的字母

1
2
3
输入 : afjghdfraaaasdenas

输出 : a

前面出现过去重的算法,这里需要是统计重复次数。

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
function findMaxDuplicateChar(str) {
if(str.length == 1) {
return str;
}
let charObj = {};
for(let i=0;i<str.length;i++) {
if(!charObj[str.charAt(i)]) {
charObj[str.charAt(i)] = 1;
}else{
charObj[str.charAt(i)] += 1;
}
}
let maxChar = '',
maxValue = 1;
for(var k in charObj) {
if(charObj[k] >= maxValue) {
maxChar = k;
maxValue = charObj[k];
}
}
return maxChar;

}

module.exports = findMaxDuplicateChar;

Q4 排序算法

如果抽到算法题目的话,应该大多都是比较开放的题目,不限定算法的实现,但是一定要求掌握其中的几种,所以冒泡排序,这种较为基础并且便于理解记忆的算法一定需要熟记于心。冒泡排序算法就是依次比较大小,小的的大的进行位置上的交换。

1
2
3
4
5
6
7
8
9
10
11
12
13
function bubbleSort(arr) {
for(let i = 0,l=arr.length;i<l-1;i++) {
for(let j = i+1;j<l;j++) {
if(arr[i]>arr[j]) {
let tem = arr[i];
arr[i] = arr[j];
arr[j] = tem;
}
}
}
return arr;
}
module.exports = bubbleSort;

除了冒泡排序外,其实还有很多诸如 插入排序,快速排序,希尔排序等。每一种排序算法都有各自的特点。全部掌握也不需要,但是心底一定要熟悉几种算法。 比如快速排序,其效率很高,而其基本原理如图(来自 wiki):

image

算法参考某个元素值,将小于它的值,放到左数组中,大于它的值的元素就放到右数组中,然后递归进行上一次左右数组的操作,返回合并的数组就是已经排好顺序的数组了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function quickSort(arr) {

if(arr.length<=1) {
return arr;
}

let leftArr = [];
let rightArr = [];
let q = arr[0];
for(let i = 1,l=arr.length; i<l; i++) {
if(arr[i]>q) {
rightArr.push(arr[i]);
}else{
leftArr.push(arr[i]);
}
}

return [].concat(quickSort(leftArr),[q],quickSort(rightArr));
}

module.exports = quickSort;

安利大家一个学习的地址,通过动画演示算法的实现。
HTML5 Canvas Demo: Sorting Algorithms

Q5 不借助临时变量,进行两个整数的交换

1
输入 a = 2, b = 4 输出 a = 4, b =2

这种问题非常巧妙,需要大家跳出惯有的思维,利用 a , b 进行置换。

主要是利用 + - 去进行运算,类似 a = a + ( b - a) 实际上等同于最后 的 a = b;

1
2
3
4
5
6
7
8
function swap(a , b) {
b = b - a;
a = a + b;
b = a - b;
return [a,b];
}

module.exports = swap;

Q6 使用 canvas 绘制一个有限度的斐波那契数列的曲线?

image

数列长度限定在 9.

斐波那契数列,又称黄金分割数列,指的是这样一个数列:0、1、1、2、3、5、8、13、21、34、……在数学上,斐波纳契数列主要考察递归的调用。我们一般都知道定义

1
fibo[i] = fibo[i-1]+fibo[i-2];

生成斐波那契数组的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function getFibonacci(n) {
var fibarr = [];
var i = 0;
while(i<n) {
if(i<=1) {
fibarr.push(i);
}else{
fibarr.push(fibarr[i-1] + fibarr[i-2])
}
i++;
}

return fibarr;
}

剩余的工作就是利用 canvas arc 方法进行曲线绘制了

DEMO

Q7 找出下列正数组的最大差值比如:

1
2
3
输入 [10,5,11,7,8,9]

输出 6

这是通过一道题目去测试对于基本的数组的最大值的查找,很明显我们知道,最大差值肯定是一个数组中最大值与最小值的差。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function getMaxProfit(arr) {

var minPrice = arr[0];
var maxProfit = 0;

for (var i = 0; i < arr.length; i++) {
var currentPrice = arr[i];

minPrice = Math.min(minPrice, currentPrice);

var potentialProfit = currentPrice - minPrice;

maxProfit = Math.max(maxProfit, potentialProfit);
}

return maxProfit;
}

Q8 随机生成指定长度的字符串

实现一个算法,随机生成指定长度的字符串。

1
比如给定 长度 8  输出 4ldkfg9j
1
2
3
4
5
6
7
8
9
10
11
12
function randomString(n) {
let str = 'abcdefghijklmnopqrstuvwxyz9876543210';
let tmp = '',
i = 0,
l = str.length;
for (i = 0; i < n; i++) {
tmp += str.charAt(Math.floor(Math.random() * l));
}
return tmp;
}

module.exports = randomString;

Q9 实现类似 getElementsByClassName 的功能

自己实现一个函数,查找某个 DOM 节点下面的包含某个 class 的所有 DOM 节点?不允许使用原生提供的 getElementsByClassName querySelectorAll 等原生提供 DOM 查找函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function queryClassName(node, name) {
var starts = '(^|[ \n\r\t\f])',
ends = '([ \n\r\t\f]|$)';
var array = [],
regex = new RegExp(starts + name + ends),
elements = node.getElementsByTagName("*"),
length = elements.length,
i = 0,
element;

while (i < length) {
element = elements[i];
if (regex.test(element.className)) {
array.push(element);
}

i += 1;
}

return array;
}

Q10 使用 JS 实现二叉查找树(Binary Search Tree)

一般叫全部写完的概率比较少,但是重点考察你对它的理解和一些基本特点的实现。 二叉查找树,也称二叉搜索树、有序二叉树(英语:ordered binary tree)是指一棵空树或者具有下列性质的二叉树:

  • 任意节点的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
  • 任意节点的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
  • 任意节点的左、右子树也分别为二叉查找树;
  • 没有键值相等的节点。二叉查找树相比于其他数据结构的优势在于查找、插入的时间复杂度较低。为 O(log n)。二叉查找树是基础性数据结构,用于构建更为抽象的数据结构,如集合、multiset、关联数组等。

image

在写的时候需要足够理解二叉搜素树的特点,需要先设定好每个节点的数据结构

1
2
3
4
5
6
7
8
class Node {
constructor(data, left, right) {
this.data = data;
this.left = left;
this.right = 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
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
class BinarySearchTree {

constructor() {
this.root = null;
}

insert(data) {
let n = new Node(data, null, null);
if (!this.root) {
return this.root = n;
}
let currentNode = this.root;
let parent = null;
while (1) {
parent = currentNode;
if (data < currentNode.data) {
currentNode = currentNode.left;
if (currentNode === null) {
parent.left = n;
break;
}
} else {
currentNode = currentNode.right;
if (currentNode === null) {
parent.right = n;
break;
}
}
}
}

remove(data) {
this.root = this.removeNode(this.root, data)
}

removeNode(node, data) {
if (node == null) {
return null;
}

if (data == node.data) {
// no children node
if (node.left == null && node.right == null) {
return null;
}
if (node.left == null) {
return node.right;
}
if (node.right == null) {
return node.left;
}

let getSmallest = function(node) {
if(node.left === null && node.right == null) {
return node;
}
if(node.left != null) {
return node.left;
}
if(node.right !== null) {
return getSmallest(node.right);
}

}
let temNode = getSmallest(node.right);
node.data = temNode.data;
node.right = this.removeNode(temNode.right,temNode.data);
return node;

} else if (data < node.data) {
node.left = this.removeNode(node.left,data);
return node;
} else {
node.right = this.removeNode(node.right,data);
return node;
}
}

find(data) {
var current = this.root;
while (current != null) {
if (data == current.data) {
break;
}
if (data < current.data) {
current = current.left;
} else {
current = current.right
}
}
return current.data;
}

}

module.exports = BinarySearchTree;

Q11 数组全排列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var arr = ['foo','bar','hello','world'];
var count = 1;
function getStr(a){
for (var i = 0; i < arr.length; i++) {
// indexOf 是es6数组的方法,如果不存在返回-1,存在返回下标
if(a.indexOf(arr[i])<0){

//数组 a 中不存在 arr[i],将arr[i]添加到数组末尾
a.push(arr[i]);

if(a.length==arr.length){
console.log(count++ + ': ' +a.join(""));
}else{
//结束一次for循环 进行了4次递归 getStr(['foo']) getStr(['bar']) getStr(['hello']) getStr(['world'])
getStr(a);

}
//一定从数组 a 中删除arr[i],进行下次循环,如果不删除就只能获得一种结果了
a.pop();
}
}
}
getStr([])

除去注释只用了 15 行代码,通过上面的方法 我们实现了单个数组全排
更多方法阅读

Q12 最大连续子序列和

思路: 比较若干个连续

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
var arr = [1, 6, -1, 5, 4, -7, 2, 3];
var maxSum = arr[0],
sum = arr[0];
for(var i=1;i<arr.length;i++) {
if(sum< 0) {
sum = arr[i];
} else {
sum += arr[i];
}
if (sum > maxSum) {
maxSum = sum;
}
console.log(sum, maxSum);

}
console.log(maxSum);


function maxSeq(arr) {
var sum=arr[0], maxSum = arr[0]

for(var i=1; i< arr.length; i++) {
if (sum < 0) {
sum = arr[i]
} else {
sum += arr[i]
}

if (sum > maxSum) {
msxSum = sum
}
}

return maxSum
}

Q13 DOM 遍历深度优先和广度优先算法

1. 深度优先

image

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 递归
let arr = []
function deepTraversal(node) {
if(!node) return;
arr.push(node)
for (var i = 0; i< node.children.length; i++) {
deepTraversal(node.children[i])
}

}
// 非递归
let arr =[]
function deepTraversal(node) {
if(!node) return;
var stack = [node];
while (stack.length) {
var item = stack.shift();
arr.push(item);
var children = item.children;
for (var i = children.length - 1; i >= 0 ; i--) {
stack.unshift(children[i]);
}
}
}

2. 广度优先

image

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
// 递归
let nodes = []; //nodes可放函数中
let i = 0;
function wideTraversal(node) {
if (node) {
nodes.push(node);
wideTraversal(node.nextElementSibling);
node = nodes[i++];
wideTraversal(node.firstElementChild);
}
}
wideTraversal(rootElement);
console.log(nodes);

let nodes = [rootElement]; //nodes可放函数中
let stack = []
function wideTraversal(node) {
if (node) {
for(var i =0; i< node.children.length; i++) {
nodes.push(node.children[i]);
stack.push(node.children[i])
}
wideTraversal(stack.shift())
}
}
wideTraversal(rootElement);

// 非递归 先进先出
let arr = [];
let stack = [rootElement]
function wideTraversal(node) {
while(stack.length) {
let item = stack.shift()
arr.push(item)
for (var i = 0; i < item.children.length;i++) {
stack.push(item.children[i]);
}
}
}
wideTraversal(rootElement);
console.log(arr);

Q14 JS 洗牌算法

塔罗牌

举例来说,我们有一个如下图所示的数组,数组长度为 9,数组内元素的值顺次分别是 1~9:

image

从上面这个数组入手,我们要做的就是打乱数组内元素的顺序:

image

代码实现

1
2
3
4
5
6
7
8
9
10
Array.prototype.shuffle = function () {
    let input = this;
    for (let i = input.length - 1; i>= 0; i--) {
        let randomIndex = Math.floor(Math.random() * (i + 1));
        let itemAtIndex = input[randomIndex];
        input[randomIndex] = input[i];
        input[i] = itemAtIndex;
    }
    return input;
}

在上面的代码中,我们创建了一个 shuffle() 方法,该方法用于随机排列数组内的元素。

此外,我们将该方法挂载在了 Array 对象的原型下面,所以任何数组都可以直接调用该方法:

1
2
let tempArray = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]
tempArray.shuffle();

Q15 假设现在有两个函数 function A()和 function B(),现在希望创建一个新的函数 function C(),新函数的逻辑是将自己接收到的前两个参数传给函数 A,剩余所有参数传给函数 B,请用原生 javascript 实现函数 C

举例:

如果调用函数 C:C[a,b,c,d,e]

相当于调用函数 A 和函数 B:A(a,b),B(c,d,e)

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
function C(){
var a_args=Array.prototype.slice.call(arguments,0,2);
var b_args=Array.prototype.slice.call(arguments,2);
A.apply(this,a_args);
B.apply(this,b_args);
}



function C(...s){
A.call(this,s[0],s[1]);
B.apply(this,s.slice(2));
}



function C(...s){
A.apply(this,s.slice(0,2));
B.apply(this,s.slice(2));
}



function C(){
A(arguments[0],arguments[1]);
B(Array.prototype.slice.call(arguments,2));
}


function C(a1,a2,...args) {
A(a1,a2)
B(...args)
}

Array.prototype.slice 表示数组的原型中的 slice 方法。这个 slice 方法返回的是一个 Array 类型的对象。可以把类数组对象转化成真正的数组,与 Array.from 类似。

Q16 请实现以下 template 方法,用于模板解析

var compiled = template(“hello <%=user%>!”);
compiled({“user”:”world”}); => hello world!

var compiled = template(“welocm to <%=location%>!”);
compiled({“location”:”CVTE”}); => welcom to CVTE!;

1
2
3
4
5
6
7
8
9
10
11
function template(source){
var temp=source;
return function(obj){
for(var prop in obj){
var tpl="<%="+prop+"%>";
temp=temp.replace(tpl,obj[prop]);
}
console.log(temp);
}
}

Q17 写一个函数,将传进去的数组按深度展开

例子:
list:[1,2,[3,4],[5,6,[7,8],9],10,11]

depth 等于 1 时输出

depth = 1 :[1,2,3,4,5,6,[7,8],9,10,11]

depth 等于 2 时输出

depth = 2 :[1,2,3,4,5,6,7,8,9,10,11]

1
2
3
4
5
6
7
8
9
function flattern(array,num = 0) {
var newArray = array;
for(let i = 0; i < num; i ++) {
newArray = [].concat(...newArray)
}
return newArray;
}

console.log(flattern([1,2,3,[4,5,[6,7]]],2))

Q18 实现一个简单的模板引擎

例子:

1
2
3
4
5
6
let template = '我是{{name}},年龄{{age}},性别{{sex}}';
let data = {
name: '姓名',
age: 18
}
render(template, data); // 我是姓名,年龄18,性别undefined
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
方法1:先将模板与数据中均存在的属性替换掉,再将数据中不存在模板中存在的属性设置为undefined
function render(template, data) {
for(let key in data) {
if(template.indexOf(key)) {
var reg =new RegExp("{{" + key + "}}","g");
template = template.replace(reg,data[key])
}
}

template = template.replace(/\{\{(\w+)\}\}/g,'undefined')

return template;
}


方法2 迭代,一个一个替换,注意exec匹配到的第一项是匹配字符串,第二项为分组内的字符串[{{name}},name]
function render(template, data) {
const reg = /\{\{(\w+)\}\}/; // 模板字符串正则
if (reg.test(template)) { // 判断模板里是否有模板字符串
const name = reg.exec(template)[1]; // 查找当前模板里第一个模板字符串的字段
template = template.replace(reg, data[name]); // 将第一个模板字符串渲染
return render(template, data); // 递归的渲染并返回渲染后的结构
}
return template; // 如果模板没有模板字符串直接返回
}

Q19 动态规划

给定一个三角形,找出自顶向下的最小路径和。每一步只能移动到下一行中相邻的结点上。相邻的结点 在这里指的是 下标 与 上一层结点下标 相同或者等于 上一层结点下标 + 1 的两个结点。例如,给定三角形:

[[2],[3,4],[6,5,7],[4,1,8,3]]
自顶向下的最小路径和为 11(即,2 + 3 + 5 + 1 = 11)。

动态规划我个人的理解是:能将一个大问题分解为一个个小问题,并且这些小问题之间有共性能重复调用。那么如何判断这道题是否可以用到动态规划,首先从底往上看,[6,5,7]对应的最小路径很明显可以看出分别是[1,1,3],那么后两层的最短路径是[7,6,10],再往上看[3,4]的最短路径也能明显看出是[9,10],那么 2 对应的最短路径很明显就是 11。其实从这里就能看出每层分析判断的逻辑是一致的。js 代码如下:

1
2
3
4
5
6
7
8
9
10
11
const minimumTotal = triangle => {
// es6方法填充数组
const dp = Array.of(...triangle[triangle.length - 1])
for (let i = dp.length - 2; i >= 0; i--) {
for (let j = 0; j < triangle[i].length; j++) {
// 状态转移方程
dp[j] = Math.min(dp[j], dp[j + 1]) + triangle[i][j]
}
}
return dp[0]
}

1. 变量提升

通常 JS 引擎会在正式执行之前先进行一次预编译,在这个过程中,首先将变量声明及函数声明提升至当前作用域的顶端,然后进行接下来的处理。(注:当前流行的 JS 引擎大都对源码进行了编译,由于引擎的不同,编译形式也会有所差异,我们这里说的预编译和提升其实是抽象出来的、易于理解的概念)

下面的代码中,我们在函数中声明了一个变量,不过这个变量声明是在 if 语句块中:

1
2
3
4
5
6
7
8
function hoistVariable() {
if (!foo) {
var foo = 5;
}

console.log(foo); // 5
}
hoistVariable();

运行代码,我们会发现 foo 的值是 5,初学者可能对此不甚理解,如果外层作用域也存在一个 foo 变量,就更加困惑了,该不会是打印外层作用域中的 foo 变量吧?答案是:不会,如果当前作用域中存在此变量声明,无论它在什么地方声明,引用此变量时就会在当前作用域中查找,不会去外层作用域了。

那么至于说打印结果,这要提到预编译机制了,经过一次预编译之后,上面的代码逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
// 预编译之后
function hoistVariable() {
var foo;

if (!foo) {
foo = 5;
}

console.log(foo); // 5
}

hoistVariable();

是的,引擎将变量声明提升到了函数顶部,初始值为 undefined,自然,if 语句块就会被执行,foo 变量赋值为 5,下面的打印也就是预期的结果了。

类似的,还有下面一个例子:

1
2
3
4
5
6
7
8
9
var foo = 3;

function hoistVariable() {
var foo = foo || 5;

console.log(foo); // 5
}

hoistVariable();

foo || 5 这个表达式的结果是 5 而不是 3,虽然外层作用域有个 foo 变量,但函数内是不会去引用的,因为预编译之后的代码逻辑是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
var foo = 3;

// 预编译之后
function hoistVariable() {
var foo;

foo = foo || 5;

console.log(foo); // 5
}

hoistVariable();

如果当前作用域中声明了多个同名变量,那么根据我们的推断,它们的同一个标识符会被提升至作用域顶部,其他部分按顺序执行,比如下面的代码:

1
2
3
4
5
6
7
8
9
10
11
function hoistVariable() {
var foo = 3;

{
var foo = 5;
}

console.log(foo); // 5
}

hoistVariable();

由于 JavaScript 没有块作用域,只有全局作用域和函数作用域,所以预编译之后的代码逻辑为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 预编译之后
function hoistVariable() {
var foo;

foo = 3;

{
foo = 5;
}

console.log(foo); // 5
}

hoistVariable();

2. 函数提升

相信大家对下面这段代码都不陌生,实际开发当中也很常见:

1
2
3
4
5
6
7
8
9
function hoistFunction() {
foo(); // output: I am hoisted

function foo() {
console.log('I am hoisted');
}
}

hoistFunction();

为什么函数可以在声明之前就可以调用,并且跟变量声明不同的是,它还能得到正确的结果,其实引擎是把函数声明整个地提升到了当前作用域的顶部,预编译之后的代码逻辑如下:

1
2
3
4
5
6
7
8
9
10
// 预编译之后
function hoistFunction() {
function foo() {
console.log('I am hoisted');
}

foo(); // output: I am hoisted
}

hoistFunction();

相似的,如果在同一个作用域中存在多个同名函数声明,后面出现的将会覆盖前面的函数声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
function hoistFunction() {
function foo() {
console.log(1);
}

foo(); // output: 2

function foo() {
console.log(2);
}
}

hoistFunction();

对于函数,除了使用上面的函数声明,更多时候,我们会使用函数表达式,下面是函数声明和函数表达式的对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 函数声明
function foo() {
console.log('function declaration');
}

// 匿名函数表达式
var foo = function() {
console.log('anonymous function expression');
};

// 具名函数表达式
var foo = function bar() {
console.log('named function expression');
};

可以看到,匿名函数表达式,其实是将一个不带名字的函数声明赋值给了一个变量,而具名函数表达式,则是带名字的函数赋值给一个变量,需要注意到是,这个函数名只能在此函数内部使用。我们也看到了,其实函数表达式可以通过变量访问,所以也存在变量提升同样的效果。

那么当函数声明遇到函数表达式时,会有什么样的结果呢,先看下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function hoistFunction() {
foo(); // 2

var foo = function() {
console.log(1);
};

foo(); // 1

function foo() {
console.log(2);
}

foo(); // 1
}

hoistFunction();

运行后我们会发现,输出的结果依次是 2 1 1,为什么会有这样的结果呢?

因为 JavaScript 中的函数是一等公民,函数声明的优先级最高,会被提升至当前作用域最顶端,所以第一次调用时实际执行了下面定义的函数声明,然后第二次调用时,由于前面的函数表达式与之前的函数声明同名,故将其覆盖,以后的调用也将会打印同样的结果。上面的过程经过预编译之后,代码逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 预编译之后
function hoistFunction() {
var foo;

foo = function foo() {
console.log(2);
}

foo(); // 2

foo = function() {
console.log(1);
};

foo(); // 1

foo(); // 1
}

hoistFunction();

我们也不难理解,下面的函数和变量重名时,会如何执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var foo = 3;

function hoistFunction() {
console.log(foo); // function foo() {}

foo = 5;

console.log(foo); // 5

function foo() {}
}

hoistFunction();
console.log(foo); // 3

我们可以看到,函数声明被提升至作用域最顶端,然后被赋值为 5,而外层的变量并没有被覆盖,经过预编译之后,上面代码的逻辑是这样的:

// 预编译之后

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var foo = 3;

function hoistFunction() {
var foo;

foo = function foo() {};

console.log(foo); // function foo() {}

foo = 5;

console.log(foo); // 5
}

hoistFunction();
console.log(foo); // 3

所以,函数的优先权是最高的,它永远被提升至作用域最顶部,然后才是函数表达式和变量按顺序执行,这一点要牢记。

3. 为什么要进行提升

关于为什么进行变量提升和函数提升,这个问题一直没有明确的答案,不过最近读到 Dmitry Soshnikov 之前的一篇文章时,多少了解了一些,下面是 Dmitry Soshnikov 早些年的 twitter,他也对这个问题十分感兴趣:

image

然后 Jeremy Ashkenas 想让 Brendan Eich 聊聊这个话题:

image

最后,Brendan Eich 给出了答案:

image

大致的意思就是:由于第一代 JS 虚拟机中的抽象纰漏导致的,编译器将变量放到了栈槽内并编入索引,然后在(当前作用域的)入口处将变量名绑定到了栈槽内的变量。(注:这里提到的抽象是计算机术语,是对内部发生的更加复杂的事情的一种简化。)

然后,Dmitry Soshnikov 又提到了函数提升,他提到了相互递归(就是 A 函数内会调用到 B 函数,而 B 函数也会调用到 A 函数):

image

随后 Brendan Eich 很热心的又给出了答案:

image

Brendan Eich 很确定的说,函数提升就是为了解决相互递归的问题,大体上可以解决像 ML 语言这样自下而上的顺序问题。

这里简单阐述一下相互递归,下面两个函数分别在自己的函数体内调用了对方:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 验证偶数
function isEven(n) {
if (n === 0) {
return true;
}
return isOdd(n - 1);
}

console.log(isEven(2)); // true

// 验证奇数
function isOdd(n) {
if (n === 0) {
return false;
}
return isEven(n - 1);
}

如果没有函数提升,而是按照自下而上的顺序,当 isEven 函数被调用时,isOdd 函数还未声明,所以当 isEven 内部无法调用 isOdd 函数。所以 Brendan Eich 设计了函数提升这一形式,将函数提升至当前作用域的顶部:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 验证偶数
function isEven(n) {
if (n === 0) {
return true;
}
return isOdd(n - 1);
}

// 验证奇数
function isOdd(n) {
if (n === 0) {
return false;
}
return isEven(n - 1);
}

console.log(isEven(2)); // true

这样一来,问题就迎刃而解了。

最后,Brendan Eich 还对变量提升和函数提升做了总结:
image
大概是说,变量提升是人为实现的问题,而函数提升在当初设计时是有目的的。

至此,关于变量提升和函数提升,相信大家已经明白其中的真相了。

4. 最佳实践

理解变量提升和函数提升可以使我们更了解这门语言,更好地驾驭它,但是在开发中,我们不应该使用这些技巧,而是要规范我们的代码,做到可读性和可维护性。

具体的做法是:无论变量还是函数,都必须先声明后使用。下面举了简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var name = 'Scott';
var sayHello = function(guest) {
console.log(name, 'says hello to', guest);
};

var i;
var guest;
var guests = ['John', 'Tom', 'Jack'];

for (i = 0; i < guests.length; i++) {
guest = guests[i];

// do something on guest

sayHello(guest);
}

如果对于新的项目,可以使用 let 替换 var,会变得更可靠,可维护性更高:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let name = 'Scott';
let sayHello = function(guest) {
console.log(name, 'says hello to', guest);
};

let guests = ['John', 'Tom', 'Jack'];

for (let i = 0; i < guests.length; i++) {
let guest = guests[i];

// do something on guest

sayHello(guest);
}

值得一提的是,ES6 中的 class 声明也存在提升,不过它和 let、const 一样,被约束和限制了,其规定,如果再声明位置之前引用,则是不合法的,会抛出一个异常。

所以,无论是早期的代码,还是 ES6 中的代码,我们都需要遵循一点,先声明,后使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const debounce = (fn,delay) => {

// 介绍防抖函数原理,并实现

// your code

}

const throttle = (fn,delay = 500) => {

// 介绍节流函数原理,并实现

// your code

}

1)防抖函数

防抖函数原理:

在事件被触发 n 秒后再执行回调,如果在这 n 秒内又被触发,则重新计时。

适用场景:

1.按钮提交场景:防止多次提交按钮,只执行最后提交的一次。

2.服务端验证场景:表单验证需要服务端配合,只执行—段连续的输入事件的最后一次,还有搜索联想词功能类似

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 手写简化版实现

const debounce = (fn,delay) => {

let timer = null;

return (...args) => {

clearTimeout(timer);

timer = setTimeout(() => {

fn.apply(this,args);

},delay)

}

}

2)节流函数

节流函数原理:

规定在一个单位时间内,只能触发—次函数。如果这个单位时间内触发多次函数,只有一次生效。防抖是延迟执行,而节流是间隔执行,函数节流即每隔一段时间就执行一次。

适用场景:

1.拖拽场景:固定时间内只执行一次,防止超高频次触发位置变动

⒉ 缩放场景:监控浏览器 resize

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
// 手写简化版实现

// ①定时器实现

const throttle = (fn,delay = 500) =>{

let flag = true;

return (...args) => {

if(!flag) return;

flag = false;

setTimeout(() => {

fn.apply(this,args);

flag = true;

},delay);

};

}

// ②时间戳实现

const throttle = (fn,delay = 500) => {

let preTime = Date.now();

return (...args) => {

const nowTime = Date.now();

if(nowTime - preTime >= delay){

preTime = Date.now();

fn.apply(this,args);

}

}

}

HTTP 各版本的区别

什么是 HTTP 和 HTTPS?

HTTP 是浏览器与服务器之间以明文的方式传送内容的一种互联网通信协议。

HTTPS 是在 HTTP 的基础上主要基于 SPDF 协议结合 SSL/TLS 加密协议,客户端依靠证书验证服务器身份传递加密信息的通信协议。

  • 1991 年   HTTP/0.9 仅支持 GET 请求,不支持请求头
  • 1996 年   HTTP/1.0 默认短连接(一次请求建议一次 TCP 连接,请求完就断开),支持 GET、POST、 HEAD 请求
  • 1999 年   HTTP/1.1 默认长连接(一次 TCP 连接可以多次请求);支持 PUT、DELETE、PATCH 等六种请求;增加 host 头,支持虚拟主机;支持断点续传功能
  • 2015 年   HTTP/2.0 多路复用,降低开销(一次 TCP 连接可以处理多个请求);服务器主动推送(相关资源一个请求全部推送);解析基于二进制,解析错误少,更高效(HTTP/1.X 解析基于文本);报头压缩,降低开销。

HTTPS 请求过程:(一次 HTTPS 请求要进行两次 HTTP 传输)

1.客户端发出 https 请求,请求服务端建立 SSL 连接;

2.服务端收到 https 请求,申请或自制数字证书,得到公钥和服务端私钥,并将公钥发送给客户端;

3.客户端验证公钥,不通过验证则发出警告,通过验证则产生一个随机的客户端私钥;

4.客户端将公钥与客户端私钥进行对称加密后传给服务端;

5.服务端收到加密内容后,通过服务端私钥进行非对称解密,得到客户端私钥;

6.服务端将客户端私钥和内容进行对称加密,并将加密内容发送给客户端;

7.客户端收到加密内容后,通过客户端私钥进行对称解密,得到内容。

HTTPS 怎么校验证书的有效性?

证书里面包含了公钥+各种信息+签名,公钥加密私钥解,私钥加密公钥解,通过私钥将签名解密后得到的信息和证书里面的信息比对就可以验证证书的合法性了。

签名是私钥和各种信息加密后形成的签名。

为什么 HTTPS 很安全却不普及?

1.加密通信与普通的文本通信,要消耗更多的 CPU 和内存,缓存慢,通信成本较大;

2.HTTPS 通信需要证书,而证书不是免费的。

前言

image

总体来说分为以下几个过程:

  • DNS 解析:将域名解析成 IP 地址
  • TCP 连接:TCP 三次握手
  • 发送 HTTP 请求
  • 服务器处理请求并返回 HTTP 报文
  • 浏览器解析渲染页面
  • 断开连接:TCP 四次挥手

一、URL 到底是啥

URL(Uniform Resource Locator),统一资源定位符,用于定位互联网上资源,俗称网址。
比如 http://www.w3school.com.cn/ht...,遵守以下的语法规则:

  • scheme://host.domain:port/path/filename
  • 各部分解释如下:
  • scheme - 定义因特网服务的类型。常见的协议有 http、https、ftp、file,其中最常见的类型是 http,而 https 则是进行加密的网络传输。
  • host - 定义域主机(http 的默认主机是 www)
  • domain - 定义因特网域名,比如 w3school.com.cn
  • port - 定义主机上的端口号(http 的默认端口号是 80)
  • path - 定义服务器上的路径(如果省略,则文档必须位于网站的根目录中)。
  • filename - 定义文档/资源的名称

二、域名解析(DNS)

在浏览器输入网址后,首先要经过域名解析,因为浏览器并不能直接通过域名找到对应的服务器,而是要通过 IP 地址。大家这里或许会有个疑问—-计算机既可以被赋予 IP 地址,也可以被赋予主机名和域名。比如 www.hackr.jp。那怎么不一开始就赋予个 IP 地址?这样就可以省去解析麻烦。我们先来了解下什么是 IP 地址

1.IP 地址

IP 地址是指互联网协议地址,是 IP Address 的缩写。IP 地址是 IP 协议提供的一种统一的地址格式,它为互联网上的每一个网络和每一台主机分配一个逻辑地址,以此来屏蔽物理地址的差异。IP 地址是一个 32 位的二进制数,比如 127.0.0.1 为本机 IP。
域名就相当于 IP 地址乔装打扮的伪装者,带着一副面具。它的作用就是便于记忆和沟通的一组服务器的地址。用户通常使用主机名或域名来访问对方的计算机,而不是直接通过 IP 地址访问。因为与 IP 地址的一组纯数字相比,用字母配合数字的表示形式来指定计算机名更符合人类的记忆习惯。但要让计算机去理解名称,相对而言就变得困难了。因为计算机更擅长处理一长串数字。为了解决上述的问题,DNS 服务应运而生。

2.什么是域名解析

DNS 协议提供通过域名查找 IP 地址,或逆向从 IP 地址反查域名的服务。DNS 是一个网络服务器,我们的域名解析简单来说就是在 DNS 上记录一条信息记录。

1
例如 baidu.com  220.114.23.56(服务器外网IP地址)80(服务器端口号)

3. 浏览器如何通过域名去查询 URL 对应的 IP 呢

  • 浏览器缓存:浏览器会按照一定的频率缓存 DNS 记录。
  • 操作系统缓存:如果浏览器缓存中找不到需要的 DNS 记录,那就去操作系统中找。
  • 路由缓存:路由器也有 DNS 缓存。
  • ISP 的 DNS 服务器:ISP 是互联网服务提供商(Internet Service Provider)的简称,ISP 有专门的 DNS 服务器应对 DNS 查询请求。
  • 根服务器:ISP 的 DNS 服务器还找不到的话,它就会向根服务器发出请求,进行递归查询(DNS 服务器先问根域名服务器.com 域名服务器的 IP 地址,然后再问.baidu 域名服务器,依次类推)

image

4. 小结

浏览器通过向 DNS 服务器发送域名,DNS 服务器查询到与域名相对应的 IP 地址,然后返回给浏览器,浏览器再将 IP 地址打在协议上,同时请求参数也会在协议搭载,然后一并发送给对应的服务器。接下来介绍向服务器发送 HTTP 请求阶段,HTTP 请求分为三个部分:TCP 三次握手、http 请求响应信息、关闭 TCP 连接。

image

三、TCP 三次握手

在客户端发送数据之前会发起 TCP 三次握手用以同步客户端和服务端的序列号和确认号,并交换 TCP 窗口大小信息。

image

1.TCP 三次握手的过程如下:

  • 客户端发送一个带 SYN=1,Seq=X 的数据包到服务器端口(第一次握手,由浏览器发起,告诉服务器我要发送请求了)
  • 服务器发回一个带 SYN=1, ACK=X+1, Seq=Y 的响应包以示传达确认信息(第二次握手,由服务器发起,告诉浏览器我准备接受了,你赶紧发送吧)
  • 客户端再回传一个带 ACK=Y+1, Seq=Z 的数据包,代表“握手结束”(第三次握手,由浏览器发送,告诉服务器,我马上就发了,准备接受吧)

2.为啥需要三次握手

谢希仁著《计算机网络》中讲“三次握手”的目的是为了防止已失效的连接请求报文段突然又传送到了服务端,因而产生错误

四、发送 HTTP 请求

TCP 三次握手结束后,开始发送 HTTP 请求报文。
请求报文由请求行(request line)、请求头(header)、请求体三个部分组成,如下图所示:

image

1.请求行包含请求方法、URL、协议版本

  • 请求方法包含 8 种:GET、POST、PUT、DELETE、PATCH、HEAD、OPTIONS、TRACE。
  • URL 即请求地址,由 <协议>://<主机>:<端口>/<路径>?<参数> 组成
  • 协议版本即 http 版本号
1
POST /chapter17/user.html HTTP/1.1

以上代码中“POST”代表请求方法,“/chapter17/user.html”表示 URL,“HTTP/1.1”代表协议和协议的版本。现在比较流行的是 Http1.1 版本

2.请求头包含请求的附加信息,由关键字/值对组成,每行一对,关键字和值用英文冒号“:”分隔。

请求头部通知服务器有关于客户端请求的信息。它包含许多有关的客户端环境和请求正文的有用信息。其中比如:Host,表示主机名,虚拟主机;Connection,HTTP/1.1 增加的,使用 keepalive,即持久连接,一个连接可以发多个请求;User-Agent,请求发出者,兼容性以及定制化需求。

3.请求体,可以承载多个请求参数的数据,包含回车符、换行符和请求数据,并不是所有请求都具有请求数据。

1
name=tom&password=1234&realName=tomson

上面代码,承载着 name、password、realName 三个请求参数。

五、服务器处理请求并返回 HTTP 报文

1. 服务器

服务器是网络环境中的高性能计算机,它侦听网络上的其他计算机(客户机)提交的服务请求,并提供相应的服务,比如网页服务、文件下载服务、邮件服务、视频服务。而客户端主要的功能是浏览网页、看视频、听音乐等等,两者截然不同。 每台服务器上都会安装处理请求的应用——web server。常见的 web server 产品有 apache、nginx、IIS 或 Lighttpd 等。
web server 担任管控的角色,对于不同用户发送的请求,会结合配置文件,把不同请求委托给服务器上处理相应请求的程序进行处理(例如 CGI 脚本,JSP 脚本,servlets,ASP 脚本,服务器端 JavaScript,或者一些其它的服务器端技术等),然后返回后台程序处理产生的结果作为响应。

image

2.MVC 后台处理阶段

后台开发现在有很多框架,但大部分都还是按照 MVC 设计模式进行搭建的。
MVC 是一个设计模式,将应用程序分成三个核心部件:模型(model)– 视图(view)–控制器(controller),它们各自处理自己的任务,实现输入、处理和输出的分离。

image

1、视图(view)

它是提供给用户的操作界面,是程序的外壳。

2、模型(model)

模型主要负责数据交互。在 MVC 的三个部件中,模型拥有最多的处理任务。一个模型能为多个视图提供数据。

3、控制器(controller)

它负责根据用户从”视图层”输入的指令,选取”模型层”中的数据,然后对其进行相应的操作,产生最终结果。控制器属于管理者角色,从视图接收请求并决定调用哪个模型构件去处理请求,然后再确定用哪个视图来显示模型处理返回的数据。
这三层是紧密联系在一起的,但又是互相独立的,每一层内部的变化不影响其他层。每一层都对外提供接口(Interface),供上面一层调用。
至于这一阶段发生什么?简而言之,首先浏览器发送过来的请求先经过控制器,控制器进行逻辑处理和请求分发,接着会调用模型,这一阶段模型会获取 redis db 以及 MySQL 的数据,获取数据后将渲染好的页面,响应信息会以响应报文的形式返回给客户端,最后浏览器通过渲染引擎将网页呈现在用户面前。

3.http 响应报文

响应报文由响应行(request line)、响应头部(header)、响应主体三个部分组成。如下图所示:
image

(1) 响应行包含:协议版本,状态码,状态码描述

状态码规则如下:

  • 1xx:指示信息–表示请求已接收,继续处理。
  • 2xx:成功–表示请求已被成功接收、理解、接受。
  • 3xx:重定向–要完成请求必须进行更进一步的操作。
  • 4xx:客户端错误–请求有语法错误或请求无法实现。
  • 5xx:服务器端错误–服务器未能实现合法的请求。

(2) 响应头部包含响应报文的附加信息,由 名/值 对组成

(3) 响应主体包含回车符、换行符和响应返回数据,并不是所有响应报文都有响应数据

六、浏览器解析渲染页面

浏览器拿到响应文本 HTML 后,接下来介绍下浏览器渲染机制

image

浏览器解析渲染页面分为以下五个步骤:

  • 根据 HTML 解析出 DOM 树
  • 根据 CSS 解析生成 CSS 规则树
  • 结合 DOM 树和 CSS 规则树,生成渲染树
  • 根据渲染树计算每一个节点的信息
  • 根据计算好的信息绘制页面

1.根据 HTML 解析 DOM 树

  • 根据 HTML 的内容,将标签按照结构解析成为 DOM 树,DOM 树解析的过程是一个深度优先遍历。即先构建当前节点的所有子节点,再构建下一个兄弟节点。
  • 在读取 HTML 文档,构建 DOM 树的过程中,若遇到 script 标签,则 DOM 树的构建会暂停,直至脚本执行完毕。

2.根据 CSS 解析生成 CSS 规则树

  • 解析 CSS 规则树时 js 执行将暂停,直至 CSS 规则树就绪。
  • 浏览器在 CSS 规则树生成之前不会进行渲染。

3.结合 DOM 树和 CSS 规则树,生成渲染树

  • DOM 树和 CSS 规则树全部准备好了以后,浏览器才会开始构建渲染树。
  • 精简 CSS 并可以加快 CSS 规则树的构建,从而加快页面响应速度。

4.根据渲染树计算每一个节点的信息(布局)

  • 布局:通过渲染树中渲染对象的信息,计算出每一个渲染对象的位置和尺寸
  • 回流:在布局完成后,发现了某个部分发生了变化影响了布局,那就需要倒回去重新渲染。

5.根据计算好的信息绘制页面

  • 绘制阶段,系统会遍历呈现树,并调用呈现器的“paint”方法,将呈现器的内容显示在屏幕上。
  • 重绘:某个元素的背景颜色,文字颜色等,不影响元素周围或内部布局的属性,将只会引起浏览器的重绘。
  • 回流:某个元素的尺寸发生了变化,则需重新计算渲染树,重新渲染。

七、断开连接

当数据传送完毕,需要断开 tcp 连接,此时发起 tcp 四次挥手。

image

  • 发起方向被动方发送报文,Fin、Ack、Seq,表示已经没有数据传输了。并进入 FIN_WAIT_1 状态。(第一次挥手:由浏览器发起的,发送给服务器,我请求报文发送完了,你准备关闭吧)
  • 被动方发送报文,Ack、Seq,表示同意关闭请求。此时主机发起方进入 FIN_WAIT_2 状态。(第二次挥手:由服务器发起的,告诉浏览器,我请求报文接受完了,我准备关闭了,你也准备吧)
  • 被动方向发起方发送报文段,Fin、Ack、Seq,请求关闭连接。并进入 LAST_ACK 状态。(第三次挥手:由服务器发起,告诉浏览器,我响应报文发送完了,你准备关闭吧)
  • 发起方向被动方发送报文段,Ack、Seq。然后进入等待 TIME_WAIT 状态。被动方收到发起方的报文段以后关闭连接。发起方等待一定时间未收到回复,则正常关闭。(第四次挥手:由浏览器发起,告诉服务器,我响应报文接受完了,我准备关闭了,你也准备吧)

给大家推荐一个好用的 BUG 监控工具[Fundebug]https://www.fundebug.com/?utm_source=liao),欢迎免费试用!

状态码的职责是当客户端向服务器发送请求时,描述返回的请求结果。借助状态码,用户可以知道服务器端是正常处理了请求还是出现了错误。

状态码的类别:

~ 类别 原因短语
1XX Informational(信息性状态码) 接受的请求正在处理
2XX Success(成功状态码) 请求正常处理完毕
3XX Redirection(重定向状态码) 需要进行附加操作以完成请求
4XX Client Error(客户端错误状态码) 服务器无法处理请求
5XX Server Error(服务器错误状态码) 服务器处理请求出错

2XX——表明请求被正常处理了

1、200 OK:请求已正常处理

2、204 No Content:请求处理成功,但没有任何资源可以返回给客户端,一般在只需要从客户端往服务器发送信息,而对客户端不需要发送新信息内容的情况下使用。

3、206 Partial Content:是对资源某一部分的请求,该状态码表示客户端进行了范围请求,而服务器成功执行了这部分的 GET 请求。响应报文中包含由 Content-Range 指定范围的实体内容。

3XX——表明浏览器需要执行某些特殊的处理以正确处理请求

4、301 Moved Permanently:资源的 uri 已更新,你也更新下你的书签引用吧。永久性重定向,请求的资源已经被分配了新的 URI,以后应使用资源现在所指的 URI。

5、302 Found:资源的 URI 已临时定位到其他位置了,姑且算你已经知道了这个情况了。临时性重定向。和 301 相似,但 302 代表的资源不是永久性移动,只是临时性性质的。换句话说,已移动的资源对应的 URI 将来还有可能发生改变。

6、303 See Other:资源的 URI 已更新,你是否能临时按新的 URI 访问。该状态码表示由于请求对应的资源存在着另一个 URL,应使用 GET 方法定向获取请求的资源。303 状态码和 302 状态码有着相同的功能,但 303 状态码明确表示客户端应当采用 GET 方法获取资源,这点与 302 状态码有区别。

当 301,302,303 响应状态码返回时,几乎所有的浏览器都会把 POST 改成 GET,并删除请求报文内的主体,之后请求会自动再次发送。

7、304 Not Modified:资源已找到,但未符合条件请求。该状态码表示客户端发送附带条件的请求时(采用 GET 方法的请求报文中包含 If-Match,If-Modified-Since,If-None-Match,If-Range,If-Unmodified-Since 中任一首部)服务端允许请求访问资源,但因发生请求未满足条件的情况后,直接返回 304.。

8、307 Temporary Redirect:临时重定向。与 302 有相同的含义。

4XX——表明客户端是发生错误的原因所在。

9、400 Bad Request:服务器端无法理解客户端发送的请求,请求报文中可能存在语法错误。

10、401 Unauthorized:该状态码表示发送的请求需要有通过 HTTP 认证(BASIC 认证,DIGEST 认证)的认证信息。

11、403 Forbidden:不允许访问那个资源。该状态码表明对请求资源的访问被服务器拒绝了。(权限,未授权 IP 等)

12、404 Not Found:服务器上没有请求的资源。路径错误等。

5XX——服务器本身发生错误

13、500 Internal Server Error:貌似内部资源出故障了。该状态码表明服务器端在执行请求时发生了错误。也有可能是 web 应用存在 bug 或某些临时故障。

14、503 Service Unavailable:抱歉,我现在正在忙着。该状态码表明服务器暂时处于超负载或正在停机维护,现在无法处理请求。

XSS

cross site script,跨站脚本攻击(关键字:脚本)。为了与 css 冲突取名为 xss!
XSS 攻击的核心原理是:不需要你做任何的登录认证,它会通过合法的操作(比如在 url 中输入、在评论框中输入),向你的页面注入脚本(可能是 js、hmtl 代码块等)。
恶意攻击者往 Web 页面里插入恶意 Script 代码,当用户浏览该页之时,嵌入其中 Web 里面的 Script 代码会被执行,从而达到恶意攻击用户的目的。

最后导致的结果可能是

  • 盗用 Cookie
  • 破坏页面的正常结构,插入广告等恶意内容
  • D-doss 攻击

XSS 的攻击方式

1、反射型(临时,非持久型)
发出请求时,XSS 代码出现在 url 中,作为输入提交到服务器端,服务器端解析后响应,XSS 代码随响应内容一起传回给浏览器,最后浏览器解析执行 XSS 代码。这个过程像一次反射,所以叫反射型 XSS。
客户端提交,服务器解析后响应,到客户端再执行

2、存储型(持久型)
存储型 XSS 和反射型 XSS 的差别在于,提交的代码会存储在服务器端(数据库、内存、文件系统等)。
比如先通过对一个攻击 url 进行编码(来绕过 xss filter),然后提交该 web server(存储在 web server 中), 然后用户在浏览页面时,如果点击该 url,就会触发一个 XSS 攻击。当然用户点击该 url 时,也可能会触发一个 CSRF(Cross site request forgery)攻击。

以上两种服务端参与

3、DOM based XSS
基于 DOM 的 XSS,也就是 web server 不参与,仅仅涉及到浏览器的 XSS。比如根据用户的输入来动态构造一个 DOM 节点,如果没有对用户的输入进行过滤,那么也就导致 XSS 攻击的产生。过滤可以考虑采用 esapi4js。

预防
简而言之:转义+过滤(输入过滤,输出转义

1、在 cookie 中设置了 HttpOnly 属性,那么通过 js 脚本将无法读取到 cookie 信息,这样能有效的防止 XSS 攻击

过滤

移除用户输入的和事件相关的属性。如 onerror 可以自动触发攻击,还有 onclick 等。(总而言是,过滤掉一些不安全的内容)

移除用户输入的 Style 节点、Script 节点、Iframe 节点。(尤其是 Script 节点,它可是支持跨域的呀,一定要移除)。

CSRF

用户登录 A 网站产生 cookie,此时再访问 B(危险),B 要求访问 A,并发起一个请求

此时,B(危险)就利用用户的权限在 A 进行了操作。

image

如何预防:
1、Token 验证:(用的最多)

(1)服务器发送给客户端一个 token;

(2)客户端提交的表单中带着这个 token。

(3)如果这个 token 不合法,那么服务器拒绝这个请求。

2、隐藏令牌:
把 token 隐藏在 http 的 head 头中。

响应头

image

请求头

image

ps:方法二和方法一有点像,本质上没有太大区别,只是使用方式上有区别。

CSRF 和 XSS 的区别

面试官还可能喜欢问二者的区别。

区别一:

CSRF:需要用户先登录网站 A,获取 cookie。
XSS:不需要登录。
区别二:(原理的区别)

CSRF:是利用网站 A 本身的漏洞,去请求网站 A 的 api。
XSS:是向网站 A 注入 JS 代码,然后执行 JS 里的代码,篡改网站 A 的内容。