JavaScript
Map 和 Set 的区别,Map 和 Object 的区别
Map 和 Object 的区别
概念
- Object
在 ECMAScript 中,Object
是一个特殊的对象。它本身是一个顶级对象,同时还是一个构造函数,可以通过它(如:new Object()
)来创建一个对象。我们可以认为 JavaScript 中所有的对象都是Object
的一个实例,对象可以用字面量的方法 const obj = {}即可声明。
- Map
Map 是 Object 的一个子类,可以有序保存任意类型的数据,使用键值对去存储,其中键可以存储任意类型,通过 const m = new Map();即可得到一个 map 实例。
访问
map: 通过 map.get(key)方法去访问一个属性, 不存在则返回 undefined。
object: 通过 obj.a 或者 obj[‘a’]去访问一个属性, 不存在则返回 undefined。
赋值
map: 通过 map.set 去设置一个值,key 可以是任意类型。
object: 通过 object.a = 1 或者 object[‘a’] = 1,去赋值,key 只能是字符串,数字或 symbol。
删除
map: 通过 map.delete 去删除一个值,试图删除一个不存在的属性会返回 false。
object: 通过 delete 操作符才能删除对象的一个属性,诡异的是,即使对象不存在该属性,删除也返回 true,当然可以通过Reflect.deleteProperty(target, prop) 删除不存在的属性还是会返回 true。
var obj = {}; // undefined
delete obj.a; // true
Map 和 Set 的区别
Set 的简介
Set 类似于数组,但是它里面每一项的值是唯一的,没有重复的值,Set 是一个构造函数,用来生成 set 的数据结构
let s = new Set();
let arr = [2, 3, 5, 4, 5, 2, 2];
arr.forEach(item => arr.add(item)); //向set添加重复的值for (let i of s) {
console.log(i);
}
// 2 3 5 4 结果set不会添加重复的值
set 构造函数可以接受一个数组当参数,用来初始化。
1、看数组的并集
let arr1 = [1, 2, 3];
let arr2 = [3, 4, 5];
let s1 = new Set([...arr1, ...arr2]); //这样就把重复的3去掉了`
console.log([...s1]); //这就是并集的结果了
2、看数组的交集
let arr1 = [1, 2, 3, 1];
let arr2 = [3, 4, 5, 4];
let s1 = new Set(arr1); //先去除arr1数组自身的重复项
let s2 = new Set(arr2); //去除arr2数组自身的重复项
//先简单介结一个数组的filter方法,它是es5的方法,它的参数是一个函数,如果这个函数返回的是true,
//表示把数组的这一项留下,如果是false的话,会把数组的这一项删除掉
let arr = [...s1].filter(item => {
return s2.has(item); //has方法看s2里有没有item这一项
});
console.log(arr); //[3]
3、看数组的差集
let arr1 = [1, 2, 3, 1];
let arr2 = [3, 4, 5, 4];
let s1 = new Set(arr1);
let s2 = new Set(arr2);
let arr = [...s1].filter(item => {
return !s2.has(item); //重点在这里,就是把咱们的交集取个反就ok了
});
console.log(arr); //[1,2] 这是arr1差arr2的结果是[1,2],如果是arr2差arr1就是[4,5]
Map 的简介
Map 类似于对象,也是键值对的集合,但是“键”的范围不限制于字符串,各种类型的值(包含对象)都可以当作键。Map 也可以接受一个数组作为参数,数组的成员是一个个表示键值对的数组。注意 Map 里面也不可以放重复的项。
let map = new Map([["js", "react"]]);
map.set("js", "react"); //看看是否可以放重复的项
map.set("javaScript", "vue");
console.log(map); //Map {'js' => 'react','javaScript' => 'vue'} 不可以放重复项
数组的 filter、every、some、flat 的作用是什么
filter 的作用
filter()
方法创建一个新数组, 其包含通过所提供函数实现的测试的所有元素。
const words = [
"spray",
"limit",
"elite",
"exuberant",
"destruction",
"present"
];
const result = words.filter(word => word.length > 6);
console.log(result);
// expected output: Array ["exuberant", "destruction", "present"]
every 的作用
every()
方法测试一个数组内的所有元素是否都能通过某个指定函数的测试。它返回一个布尔值。
const array1 = [1, 30, 39, 29, 10, 13];
console.log(array1.every(item=>item<40));
// expected output: true
some 的作用
some()
方法测试一个数组内的所有元素是否有一个能通过某个指定函数的测试。它返回一个布尔值。
const isBelowThreshold = currentValue => currentValue < 40;
const array1 = [1, 30, 39, 29, 10, 13];
console.log(array1.some(item=>item==39));
// expected output: true
flat 的作用
flat()
方法会按照一个可指定的深度递归遍历数组,并将所有元素与遍历到的子数组中的元素合并为一个新数组返回。
const arr1 = [0, 1, 2, [3, 4]];
console.log(arr1.flat());
// expected output: [0, 1, 2, 3, 4]
const arr2 = [0, 1, 2, [[[3, 4]]]];
console.log(arr2.flat(2));
// expected output: [0, 1, 2, [3, 4]]
es6 有哪些新特性
1.不一样的变量声明:const 和 let
ES6 推荐使用 let 声明局部变量,相比之前的 var(无论声明在何处,都会被视为声明在函数的最顶部) let 和 var 声明的区别:
var x = "全局变量";
{
let x = "局部变量";
console.log(x); // 局部变量
}
console.log(x); // 全局变量
let 表示声明变量,而 const 表示声明常量,两者都为块级作用域;const 声明的变量都会被认为是常量,意思就是它的值被设置完成后就不能再修改了:
const a = 1;
a = 0; //报错
如果 const 的是一个对象,对象所包含的值是可以被修改的。抽象一点儿说,就是对象所指向的地址没有变就行:
const student = { name: "cc" };
student.name = "yy"; // 不报错
student = { name: "yy" }; // 报错
有几个点需要注意:
- let 关键词声明的变量不具备变量提升(hoisting)特性
- let 和 const 声明只在最靠近的一个块中(花括号内)有效
- 当使用常量 const 声明时,请使用大写变量,如:CAPITAL_CASING
- const 在声明时必须被赋值
2.模板字符串
在 ES6 之前,我们往往这么处理模板字符串: 通过“\”和“+”来构建模板
$("body").html(
"This demonstrates the output of HTML \
content to the page, including student's\
" +
name +
", " +
seatNumber +
", " +
sex +
" and so on."
);
而对 ES6 来说
- 基本的字符串格式化。将表达式嵌入字符串中进行拼接。用${}来界定;
- ES6 反引号(``)直接搞定;
$("body").html(`This demonstrates the output of HTML content to the page,
including student's ${name}, ${seatNumber}, ${sex} and so on.`);
3.箭头函数(Arrow Functions)
ES6 中,箭头函数就是函数的一种简写形式,使用括号包裹参数,跟随一个 =>,紧接着是函数体;
箭头函数最直观的三个特点:
- 不需要 function 关键字来创建函数
- 省略 return 关键字
- 继承当前上下文的 this 关键字
// ES5
var add = function(a, b) {
return a + b;
};
// 使用箭头函数
var add = (a, b) => a + b;
// ES5
[1, 2, 3].map(
function(x) {
return x + 1;
}.bind(this)
);
// 使用箭头函数
[1, 2, 3].map(x => x + 1);
细节:当你的函数有且仅有一个参数的时候,是可以省略掉括号的。当你函数返回有且仅有一个表达式的时候可以省略{} 和 return;
4. 函数的参数默认值
在 ES6 之前,我们往往这样定义参数的默认值:
// ES6之前,当未传入参数时,text = 'default';
function printText(text) {
text = text || "default";
console.log(text);
}
// ES6;
function printText(text = "default") {
console.log(text);
}
printText("hello"); // hello
printText(); // default
5.Spread / Rest 操作符
Spread / Rest 操作符指的是 …,具体是 Spread 还是 Rest 需要看上下文语境。
当被用于迭代器中时,它是一个 Spread 操作符:
function foo(x, y, z) {
console.log(x, y, z);
}
let arr = [1, 2, 3];
foo(...arr); // 1 2 3
当被用于函数传参时,是一个 Rest 操作符:当被用于函数传参时,是一个 Rest 操作符:
function foo(...args) {
console.log(args);
}
foo(1, 2, 3, 4, 5); // [1, 2, 3, 4, 5]
6.对象和数组解构
// 对象
const student = {
name: "Sam",
age: 22,
sex: "男"
};
// 数组
// const student = ['Sam', 22, '男'];
// ES5;
const name = student.name;
const age = student.age;
const sex = student.sex;
console.log(name + " --- " + age + " --- " + sex);
// ES6
const { name, age, sex } = student;
console.log(name + " --- " + age + " --- " + sex);
7.ES6 中的类
ES6 中支持 class 语法,不过,ES6 的 class 不是新的对象继承模型,它只是原型链的语法糖表现形式。
函数中使用 static 关键词定义构造函数的的方法和属性:
class Student {
constructor() {
console.log("I'm a student.");
}
study() {
console.log("study!");
}
static read() {
console.log("Reading Now.");
}
}
console.log(typeof Student); // function
let stu = new Student(); // "I'm a student."
stu.study(); // "study!"
stu.read(); // "Reading Now."
类中的继承和超集:
class Phone {
constructor() {
console.log("I'm a phone.");
}
}
class MI extends Phone {
constructor() {
super();
console.log("I'm a phone designed by xiaomi");
}
}
let mi8 = new MI();
extends 允许一个子类继承父类,需要注意的是,子类的 constructor 函数中需要执行 super() 函数。 当然,你也可以在子类方法中调用父类的方法,如 super.parentMethodName()。 在 这里 阅读更多关于类的介绍。
有几点值得注意的是:
- 类的声明不会提升(hoisting),如果你要使用某个 Class,那你必须在使用之前定义它,否则会抛出一个 ReferenceError 的错误
- 在类中定义函数不需要使用 function 关键词
说一下对 Promise 的了解
Promise 对象有以下两个特点:
1、对象的状态不受外界影响。Promise 对象代表一个异步操作,有三种状态:
- pending: 初始状态,不是成功或失败状态。
- fulfilled: 意味着操作成功完成。
- rejected: 意味着操作失败。
只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态。这也是 Promise 这个名字的由来,它的英语意思就是「承诺」,表示其他手段无法改变。
2、一旦状态改变,就不会再变,任何时候都可以得到这个结果。Promise 对象的状态改变,只有两种可能:从 Pending 变为 Resolved 和从 Pending 变为 Rejected。只要这两种情况发生,状态就凝固了,不会再变了,会一直保持这个结果。就算改变已经发生了,你再对 Promise 对象添加回调函数,也会立即得到这个结果。这与事件(Event)完全不同,事件的特点是,如果你错过了它,再去监听,是得不到结果的。
Promise 优缺点
有了 Promise 对象,就可以将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数。此外,Promise 对象提供统一的接口,使得控制异步操作更加容易。
Promise 也有一些缺点。首先,无法取消 Promise,一旦新建它就会立即执行,无法中途取消。其次,如果不设置回调函数,Promise 内部抛出的错误,不会反应到外部。第三,当处于 Pending 状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。
Promise 创建
要想创建一个 promise 对象、可以使用 new 来调用 Promise 的构造器来进行实例化。
下面是创建 promise 的步骤:
var promise = new Promise(function(resolve, reject) {
// 异步处理
// 处理结束后、调用resolve 或 reject
});
Promise 构造函数包含一个参数和一个带有 resolve(解析)和 reject(拒绝)两个参数的回调。在回调中执行一些操作(例如异步),如果一切都正常,则调用 resolve,否则调用 reject。
var myFirstPromise = new Promise(function(resolve, reject) {
//当异步代码执行成功时,我们才会调用resolve(...), 当异步代码失败时就会调用reject(...)
//在本例中,我们使用setTimeout(...)来模拟异步代码,实际编码时可能是XHR请求或是HTML5的一些API方法.
setTimeout(function() {
resolve("成功!"); //代码正常执行!
}, 250);
});
myFirstPromise.then(function(successMessage) {
//successMessage的值是上面调用resolve(...)方法传入的值.
//successMessage参数不一定非要是字符串类型,这里只是举个例子
document.write("Yay! " + successMessage);
});
对于已经实例化过的 promise 对象可以调用 promise.then() 方法,传递 resolve 和 reject 方法作为回调。
promise.then() 是 promise 最为常用的方法。
promise.then(onFulfilled, onRejected);
promise 简化了对 error 的处理,上面的代码我们也可以这样写:
promise.then(onFulfilled).catch(onRejected);
Promise Ajax
下面是一个用 Promise 对象实现的 Ajax 操作的例子。
function ajax(URL) {
return new Promise(function(resolve, reject) {
var req = new XMLHttpRequest();
req.open("GET", URL, true);
req.onload = function() {
if (req.status === 200) {
resolve(req.responseText);
} else {
reject(new Error(req.statusText));
}
};
req.onerror = function() {
reject(new Error(req.statusText));
};
req.send();
});
}
var URL = "/try/ajax/testpromise.php";
ajax(URL)
.then(function onFulfilled(value) {
document.write("内容是:" + value);
})
.catch(function onRejected(error) {
document.write("错误:" + error);
});
上面代码中,resolve 方法和 reject 方法调用时,都带有参数。它们的参数会被传递给回调函数。reject 方法的参数通常是 Error 对象的实例,而 resolve 方法的参数除了正常的值以外,还可能是另一个 Promise 实例,比如像下面这样。
var p1 = new Promise(function(resolve, reject) {
// ... some code
});
var p2 = new Promise(function(resolve, reject) {
// ... some code
resolve(p1);
});
上面代码中,p1 和 p2 都是 Promise 的实例,但是 p2 的 resolve 方法将 p1 作为参数,这时 p1 的状态就会传递给 p2。如果调用的时候,p1 的状态是 pending,那么 p2 的回调函数就会等待 p1 的状态改变;如果 p1 的状态已经是 fulfilled 或者 rejected,那么 p2 的回调函数将会立刻执行。
Promise.prototype.then 方法:链式操作
Promise.prototype.then 方法返回的是一个新的 Promise 对象,因此可以采用链式写法。
getJSON("/posts.json")
.then(function(json) {
return json.post;
})
.then(function(post) {
// proceed
});
上面的代码使用 then 方法,依次指定了两个回调函数。第一个回调函数完成以后,会将返回结果作为参数,传入第二个回调函数。
如果前一个回调函数返回的是 Promise 对象,这时后一个回调函数就会等待该 Promise 对象有了运行结果,才会进一步调用。
getJSON("/post/1.json")
.then(function(post) {
return getJSON(post.commentURL);
})
.then(function(comments) {
// 对comments进行处理
});
这种设计使得嵌套的异步操作,可以被很容易得改写,从回调函数的"横向发展"改为"向下发展"。
Promise.prototype.catch 方法:捕捉错误
Promise.prototype.catch 方法是 Promise.prototype.then(null, rejection) 的别名,用于指定发生错误时的回调函数。
getJSON("/posts.json")
.then(function(posts) {
// some code
})
.catch(function(error) {
// 处理前一个回调函数运行时发生的错误
console.log("发生错误!", error);
});
Promise 对象的错误具有"冒泡"性质,会一直向后传递,直到被捕获为止。也就是说,错误总是会被下一个 catch 语句捕获。
getJSON("/post/1.json")
.then(function(post) {
return getJSON(post.commentURL);
})
.then(function(comments) {
// some code
})
.catch(function(error) {
// 处理前两个回调函数的错误
});
Promise.all 方法,Promise.race 方法
Promise.all 方法用于将多个 Promise 实例,包装成一个新的 Promise 实例。
var p = Promise.all([p1, p2, p3]);
上面代码中,Promise.all 方法接受一个数组作为参数,p1、p2、p3 都是 Promise 对象的实例。(Promise.all 方法的参数不一定是数组,但是必须具有 iterator 接口,且返回的每个成员都是 Promise 实例。)
p 的状态由 p1、p2、p3 决定,分成两种情况:
- (1)只有 p1、p2、p3 的状态都变成 fulfilled,p 的状态才会变成 fulfilled,此时 p1、p2、p3 的返回值组成一个数组,传递给 p 的回调函数。
- (2)只要 p1、p2、p3 之中有一个被 rejected,p 的状态就变成 rejected,此时第一个被 reject 的实例的返回值,会传递给 p 的回调函数。
下面是一个具体的例子。
// 生成一个Promise对象的数组
var promises = [2, 3, 5, 7, 11, 13].map(function(id) {
return getJSON("/post/" + id + ".json");
});
Promise.all(promises)
.then(function(posts) {
// ...
})
.catch(function(reason) {
// ...
});
Promise.race 方法同样是将多个 Promise 实例,包装成一个新的 Promise 实例。
var p = Promise.race([p1, p2, p3]);
上面代码中,只要 p1、p2、p3 之中有一个实例率先改变状态,p 的状态就跟着改变。那个率先改变的 Promise 实例的返回值,就传递给 p 的返回值。
如果 Promise.all 方法和 Promise.race 方法的参数,不是 Promise 实例,就会先调用下面讲到的 Promise.resolve 方法,将参数转为 Promise 实例,再进一步处理。
Promise.resolve 方法,Promise.reject 方法
有时需要将现有对象转为 Promise 对象,Promise.resolve 方法就起到这个作用。
var jsPromise = Promise.resolve($.ajax("/whatever.json"));
上面代码将 jQuery 生成 deferred 对象,转为一个新的 ES6 的 Promise 对象。
如果 Promise.resolve 方法的参数,不是具有 then 方法的对象(又称 thenable 对象),则返回一个新的 Promise 对象,且它的状态为 fulfilled。
var p = Promise.resolve("Hello");
p.then(function(s) {
console.log(s);
});
// Hello
上面代码生成一个新的 Promise 对象的实例 p,它的状态为 fulfilled,所以回调函数会立即执行,Promise.resolve 方法的参数就是回调函数的参数。
如果 Promise.resolve 方法的参数是一个 Promise 对象的实例,则会被原封不动地返回。
Promise.reject(reason)方法也会返回一个新的 Promise 实例,该实例的状态为 rejected。Promise.reject 方法的参数 reason,会被传递给实例的回调函数。
var p = Promise.reject("出错了");
p.then(null, function(s) {
console.log(s);
});
// 出错了
上面代码生成一个 Promise 对象的实例,状态为 rejected,回调函数会立即执行。
箭头函数和普通函数的区别
1.外形不同
箭头函数使用箭头定义,普通函数中没有。
// 普通函数
function func() {
// code
}
// 箭头函数
let func = () => {
// code
};
2.箭头函数都是匿名函数
普通函数可以有匿名函数,也可以有具体名函数,但是箭头函数都是匿名函数。
// 具名函数
function func() {
// code
}
// 匿名函数
let func = function() {
// code
};
// 箭头函数全都是匿名函数
let func = () => {
// code
};
3.箭头函数不能用于构造函数,不能使用 new
普通函数可以用于构造函数,以此创建对象实例。
function Person(name, age) {
this.name = name;
this.age = age;
}
let admin = new Person("小白", 18);
console.log(admin.name); // 小白
console.log(admin.age); // 18
Person 用作构造函数,通过它可以创建实例化对象。但是构造函数不能用作构造函数。
4.箭头函数中 this 的指向不同
箭头函数中的 this 指向的是定义时所在的环境,普通函数中的 this 指向的时运行时所在的环境。
在普通函数中,this 总是指向调用它的对象,如果用作构造函数,this 指向创建的对象实例。
箭头函数本身不创建 this
箭头函数本身没有 this,但是它在声明时可以捕获其所在上下文的 this 供自己使用。
注意:this 一旦被捕获,就不再发生变化
箭头函数在全局作用域声明,所以它捕获全局作用域中的 this,this 指向 window 对象。
var name = "案例1";
function wrap() {
this.name = "案例2";
let func = () => {
console.log(this.name);
};
func();
}
let en = new wrap();
// 运行结果:案例2
代码分析: (1)wrap()用作构造函数。 (2)使用 new 调用 wrap()函数之后,此函数作用域中的 this 指向创建的实例化对象。 (3)箭头函数此时被声明,捕获这个 this。 (4)所以打印的是恩诺 2,而不是恩诺 1。
结合 call(),apply()方法使用
箭头函数结合 call(),apply()方法调用一个函数时,只传入一个参数对 this 没有影响。
let obj2 = {
a: 10,
b: function(n) {
let f = n => n + this.a;
return f(n);
},
c: function(n) {
let f = n => n + this.a;
let m = {
a: 20
};
return f.call(m, n);
}
};
console.log(obj2.b(1)); // 结果:11
console.log(obj2.c(1)); // 结果:11
箭头函数不绑定 arguments,取而代之用 rest 参数…解决
每一个普通函数调用后都具有一个 arguments 对象,用来存储实际传递的参数。但是箭头函数并没有此对象。
function A(a) {
console.log(arguments);
}
A(1, 2, 3, 4, 5, 8); // [1, 2, 3, 4, 5, 8, callee: ƒ, Symbol(Symbol.iterator): ƒ]
let B = b => {
console.log(arguments);
};
B(2, 92, 32, 32); // Uncaught ReferenceError: arguments is not defined
let C = (...c) => {
console.log(c);
};
C(3, 82, 32, 11323); // [3, 82, 32, 11323]
其他区别
- 箭头函数不能 Generator 函数,不能使用 yeild 关键字。
- 箭头函数不具有 prototype 原型对象。
- 箭头函数不具有 super。
- 箭头函数不具有 new.target。
总结
- 箭头函数的 this 永远指向其上下文的 this ,任何方法都改变不了其指向,如 call() , bind() , apply()
- 普通函数的 this 指向调用它的那个对象
堆和栈的区别
引言
JS 的内存空间分为栈(stack)、堆(heap)、池(一般也会归类为栈中)。
其中栈存放变量,堆存放复杂对象,池存放常量,所以也叫常量池。
栈数据结构
栈是一种特殊的列表,栈内的元素只能通过列表的一端访问,这一端称为栈顶。 栈被称为是一种后入先出(LIFO,last-in-first-out)的数据结构。 由于栈具有后入先出的特点,所以任何不在栈顶的元素都无法访问。 为了得到栈底的元素,必须先拿掉上面的元素。
在这里,为方便理解,通过类比乒乓球盒子来分析栈的存取方式。
这种乒乓球的存放方式与栈中存取数据的方式如出一辙。 处于盒子中最顶层的乒乓球 5,它一定是最后被放进去,但可以最先被使用。 而我们想要使用底层的乒乓球 1,就必须将上面的 4 个乒乓球取出来,让乒乓球 1 处于盒子顶层。 这就是栈空间先进后出,后进先出的特点。
堆数据结构
堆是一种经过排序的树形数据结构,每个结点都有一个值。 通常我们所说的堆的数据结构,是指二叉堆。 堆的特点是根结点的值最小(或最大),且根结点的两个子树也是一个堆。 由于堆的这个特性,常用来实现优先队列,堆的存取是随意,这就如同我们在图书馆的书架上取书, 虽然书的摆放是有顺序的,但是我们想取任意一本时不必像栈一样,先取出前面所有的书, 我们只需要关心书的名字。
变量类型与内存的关系
基本数据类型
基本数据类型共有 6 种:
- Sting
- Number
- Boolean
- null
- undefined
- Symbol
基本数据类型保存在栈内存中,因为基本数据类型占用空间小、大小固定,通过按值来访问,属于被频繁使用的数据。
为了更好的搞懂基本数据类型变量与栈内存,我们结合以下例子与图解进行理解:
let num1 = 1;
let num2 = 1;
PS: 需要注意的是闭包中的基本数据类型变量不保存在栈内存中,而是保存在堆内存中。这个问题,我们后文再说。
引用数据类型
Array,Function,Object…可以认为除了上文提到的基本数据类型以外,所有类型都是引用数据类型。
引用数据类型存储在堆内存中,因为引用数据类型占据空间大、大小不固定。 如果存储在栈中,将会影响程序运行的性能; 引用数据类型在栈中存储了指针,该指针指向堆中该实体的起始地址。 当解释器寻找引用值时,会首先检索其在栈中的地址,取得地址后从堆中获得实体。
为了更好的搞懂变量对象与堆内存,我们结合以下例子与图解进行理解
// 基本数据类型-栈内存
let a1 = 0;
// 基本数据类型-栈内存
let a2 = "this is string";
// 基本数据类型-栈内存
let a3 = null;
// 对象的指针存放在栈内存中,指针指向的对象存放在堆内存中
let b = { m: 20 };
// 数组的指针存放在栈内存中,指针指向的数组存放在堆内存中
let c = [1, 2, 3];
因此当我们要访问堆内存中的引用数据类型时,实际上我们首先是从变量中获取了该对象的地址指针, 然后再从堆内存中取得我们需要的数据。
从内存角度来看变量复制
基本数据类型的复制
let a = 20;
let b = a;
b = 30;
console.log(a); // 此时a的值是多少,等于20
在这个例子中,a、b 都是基本类型,它们的值是存储在栈内存中的,a、b 分别有各自独立的栈空间, 所以修改了 b 的值以后,a 的值并不会发生变化。
从下图可以清晰的看到变量是如何复制并修改的。
引用数据类型的复制
let m = { a: 10, b: 20 };
let n = m;
n.a = 15;
console.log(m.a); //此时m.a的值是多少,等于15
在这个例子中,m、n 都是引用类型,栈内存中存放地址指向堆内存中的对象, 引用类型的复制会为新的变量自动分配一个新的值保存在变量中, 但只是引用类型的一个地址指针而已,实际指向的是同一个对象, 所以修改 n.a 的值后,相应的 m.a 也就发生了改变。
从下图可以清晰的看到变量是如何复制并修改的。
栈内存和堆内存的优缺点
在 JS 中,基本数据类型变量大小固定,并且操作简单容易,所以把它们放入栈中存储。 引用类型变量大小不固定,所以把它们分配给堆中,让他们申请空间的时候自己确定大小,这样把它们分开存储能够使得程序运行起来占用的内存最小。
栈内存由于它的特点,所以它的系统效率较高。 堆内存需要分配空间和地址,还要把地址存到栈中,所以效率低于栈。
栈内存和堆内存的垃圾回收
栈内存中变量一般在它的当前执行环境结束就会被销毁被垃圾回收制回收, 而堆内存中的变量则不会,因为不确定其他的地方是不是还有一些对它的引用。 堆内存中的变量只有在所有对它的引用都结束的时候才会被回收。
闭包与堆内存
闭包中的变量并不保存中栈内存中,而是保存在堆内存中。 这也就解释了函数调用之后之后为什么闭包还能引用到函数内的变量。
我们先来看什么是闭包:
function A() {
let a = 1;
function B() {
console.log(a);
}
return B;
}
let res = A();
函数 A 返回了一个函数 B,并且函数 B 中使用了函数 A 的变量,函数 B 就被称为闭包。
函数 A 弹出调用栈后,函数 A 中的变量这时候是存储在堆上的,所以函数 B 依旧能引用到函数 A 中的变量。 现在的 JS 引擎可以通过逃逸分析辨别出哪些变量需要存储在堆上,哪些需要存储在栈上。
闭包的原理
准备
在理解闭包之前,有个重要的概念需要先了解一下,就是 js 执行上下文。
这篇文章是执行上下文 很不错的入门教程,文章中提到:
当代码在 JavaScript 中运行时,执行代码的环境非常重要,并将概括为以下几点:
全局作用域——第一次执行代码的默认环境。
函数作用域——当执行流进入函数体时。
换句话说,当我们启动程序时,我们从全局执行上下文中开始。一些变量是在全局执行上下文中声明的。我们称之为全局变量。当程序调用一个函数时,会发生什么?
以下几个步骤:
- JavaScript 创建一个新的执行上下文,我们叫作本地执行上下文。
- 这个本地执行上下文将有它自己的一组变量,这些变量将是这个执行上下文的本地变量。
- 新的执行上下文被推到执行堆栈中。可以将执行堆栈看作是一种保存程序在其执行中的位置的容器。
函数什么时候结束?当它遇到一个return
语句或一个结束括号}
。
当一个函数结束时,会发生以下情况:
- 这个本地执行上下文从执行堆栈中弹出。
- 函数将返回值返回调用上下文。调用上下文是调用这个本地的执行上下文,它可以是全局执行上下文,也可以是另外一个本地的执行上下文。这取决于调用执行上下文来处理此时的返回值,返回的值可以是一个对象、一个数组、一个函数、一个布尔值等等,如果函数没有
return
语句,则返回undefined
。 - 这个本地执行上下文被销毁,销毁是很重要,这个本地执行上下文中声明的所有变量都将被删除,不在有变量,这个就是为什么 称为本地执行上下文中自有的变量。
基础的例子
在讨论闭包之前,让我们看一下下面的代码:
1: let a = 3
2: function addTwo(x) {
3: let ret = x + 2
4: return ret
5: }
6: let b = addTwo(a)
7: console.log(b)
为了理解 JavaScript 引擎是如何工作的,让我们详细分析一下:
- 在第
1
行,我们在全局执行上下文中声明了一个新变量a
,并将赋值为3
。 - 接下来就变得棘手了,第
2
行到第5
行实际上是在一起的。这里发生了什么? 我们在全局执行上下文中声明了一个名为addTwo
的新变量,我们给它分配了什么?一个函数定义。两个括号{}
之间的任何内容都被分配给addTwo
,函数内部的代码没有被求值,没有被执行,只是存储在一个变量中以备将来使用。 - 现在我们在第
6
行。它看起来很简单,但是这里有很多东西需要拆开分析。首先,我们在全局执行上下文中声明一个新变量,并将其标记为b
,变量一经声明,其值即为undefined
。 - 接下来,仍然在第
6
行,我们看到一个赋值操作符。我们准备给变量b
赋一个新值,接下来我们看到一个函数被调用。当看到一个变量后面跟着一个圆括号(…)
时,这就是调用函数的信号,接着,每个函数都返回一些东西(值、对象或 undefined),无论从函数返回什么,都将赋值给变量b
。 - 但是首先我们需要调用
addTwo
的函数。JavaScript 将在其全局执行上下文内存中查找名为addTwo
的变量。噢,它找到了一个,它是在步骤 2(或第 2 - 5 行)中定义的。变量add2
包含一个函数定义。注意,变量a
作为参数传递给函数。JavaScript 在全局执行上下文内存中搜索变量a
,找到它,发现它的值是3
,并将数字3
作为参数传递给函数,准备好执行函数。 - 现在执行上下文将切换,创建了一个新的本地执行上下文,我们将其命名为“addTwo 执行上下文”,执行上下文被推送到调用堆栈上。在
addTwo
执行上下文中,我们要做的第一件事是什么? - 你可能会说,“在
addTwo
执行上下文中声明了一个新的变量ret
”,这是不对的。正确的答案是,我们需要先看函数的参数。在addTwo
执行上下文中声明一个新的变量``x```,因为值3
是作为参数传递的,所以变量x
被赋值为 3。 - 下一步是:在
addTwo
执行上下文中声明一个新的变量ret
。它的值被设置为undefined
(第三行)。 - 仍然是第 3 行,需要执行一个相加操作。首先我们需要
x
的值,JavaScript 会寻找一个变量x
,它会首先在addTwo
执行上下文中寻找,找到了一个值为3
。第二个操作数是数字2
。两个相加结果为5
就被分配给变量ret
。 - 第
4
行,我们返回变量ret
的内容,在 addTwo 执行上下文中查找,找到值为5
,返回,函数结束。 - 第
4-5
行,函数结束。addTwo 执行上下文被销毁,变量x
和ret
被释放,它们已经不存在了。addTwo 执行上下文从调用堆栈中弹出,返回值返回给调用上下文,在这种情况下,调用上下文是全局执行上下文,因为函数addTwo
是从全局执行上下文调用的。 - 现在我们继续第
4
步的内容,返回值 5 被分配给变量b
,程序仍然在第6
行。 - 在第 7 行,
b
的值 5 被打印到控制台了。
对于一个非常简单的程序,这是一个非常冗长的解释,我们甚至还没有涉及闭包。但肯定会涉及的,不过首先我们得绕一两个弯。
词法作用域(Lexical scope)
我们需要理解词法作用域的一些知识。请看下面的例子:
1: let val1 = 2
2: function multiplyThis(n) {
3: let ret = n * val1
4: return ret
5: }
6: let multiplied = multiplyThis(6)
7: console.log('example of scope:', multiplied)
这里想说明,我们在函数执行上下文中有变量,在全局执行上下文中有变量。JavaScript 的一个复杂之处在于它如何查找变量,如果在函数执行上下文中找不到变量,它将在调用上下文中寻找它,如果在它的调用上下文中没有找到,就一直往上一级,直到它在全局执行上下文中查找为止。(如果最后找不到,它就是 undefined
)。
下面列出向个步骤来解释一下(如果你已经熟悉了,请跳过):
- 在全局执行上下文中声明一个新的变量
val1
,并将其赋值为2
。 - 第
2-5
行,声明一个新的变量multiplyThis
,并给它分配一个函数定义。 - 第
6
行,声明一个在全局执行上下文multiplied
新变量。 - 从全局执行上下文内存中查找变量
multiplyThis
,并将其作为函数执行,传递数字6
作为参数。 - 新函数调用(创建新执行上下文),创建一个新的
multiplyThis
函数执行上下文。 - 在
multiplyThis
执行上下文中,声明一个变量n
并将其赋值为6
。 - 第
3
行。在multiplyThis
执行上下文中,声明一个变量ret
。 - 继续第
3
行。对两个操作数n
和val1
进行乘法运算.在multiplyThis
执行上下文中查找变量n
。我们在步骤 6 中声明了它,它的内容是数字6
。在multiplyThis
执行上下文中查找变量val1
。multiplyThis
执行上下文没有一个标记为val1
的变量。我们向调用上下文查找,调用上下文是全局执行上下文,在全局执行上下文中寻找val1
。哦,是的、在那儿,它在步骤 1 中定义,数值是2
。 - 继续第
3
行。将两个操作数相乘并将其赋值给ret
变量,6 * 2 = 12,ret 现在值为12
。 - 返回
ret
变量,销毁multiplyThis
执行上下文及其变量ret
和n
。变量val1
没有被销毁,因为它是全局执行上下文的一部分。 - 回到第
6
行。在调用上下文中,数字12
赋值给multiplied
的变量。 - 最后在第
7
行,我们在控制台中打印multiplied
变量的值。
在这个例子中,我们需要记住一个函数可以访问在它的调用上下文中定义的变量,这个就是词法作用域(Lexical scope) 。
返回函数的函数
在第一个例子中,函数addTwo
返回一个数字。请记住,函数可以返回任何东西。让我们看一个返回函数的函数示例,因为这对于理解闭包非常重要。看粟子:
1: let val = 7
2: function createAdder() {
3: function addNumbers(a, b) {
4: let ret = a + b
5: return ret
6: }
7: return addNumbers
8: }
9: let adder = createAdder()
10: let sum = adder(val, 8)
11: console.log('example of function returning a function: ', sum)
让我们回到分步分解:
- 第
1
行。我们在全局执行上下文中声明一个变量val
并赋值为7
。 - 第
2-8
行。我们在全局执行上下文中声明了一个名为createAdder
的变量,并为其分配了一个函数定义。第3-7
行描述了上述函数定义,和以前一样,在这一点上,我们没有直接讨论这个函数。我们只是将函数定义存储到那个变量(createAdder
)中。 - 第
9
行。我们在全局执行上下文中声明了一个名为adder
的新变量,暂时,值为undefined
。 - 第
9
行。我们看到括号()
,我们需要执行或调用一个函数,查找全局执行上下文的内存并查找名为createAdder
的变量,它是在步骤2
中创建的。好吧,我们调用它。 - 调用函数时,执行到第
2
行。创建一个新的createAdder
执行上下文。我们可以在createAdder
的执行上下文中创建自有变量。js 引擎将createAdder
的上下文添加到调用堆栈。这个函数没有参数,让我们直接跳到它的主体部分. - 第
3-6
行。我们有一个新的函数声明,我们在createAdder
执行上下文中创建一个变量addNumbers
。这很重要,addnumber
只存在于createAdder
执行上下文中。我们将函数定义存储在名为 ``addNumbers``` 的自有变量中。 - 第
7
行,我们返回变量addNumbers
的内容。js 引擎查找一个名为addNumbers
的变量并找到它,这是一个函数定义。好的,函数可以返回任何东西,包括函数定义。我们返addNumbers
的定义。第4
行和第5
行括号之间的内容构成该函数定义。 - 返回时,
createAdder
执行上下文将被销毁。addNumbers
变量不再存在。但addNumbers
函数定义仍然存在,因为它返回并赋值给了adder
变量。 - 第
10
行。我们在全局执行上下文中定义了一个新的变量sum
,先赋值为undefined
; - 接下来我们需要执行一个函数。哪个函数? 是名为
adder
变量中定义的函数。我们在全局执行上下文中查找它,果然找到了它,这个函数有两个参数。 - 让我们查找这两个参数,第一个是我们在步骤 1 中定义的变量
val
,它表示数字7
,第二个是数字8
。 - 现在我们要执行这个函数,函数定义概述在第
3-5
行,因为这个函数是匿名,为了方便理解,我们暂且叫它adder
吧。这时创建一个adder
函数执行上下文,在adder
执行上下文中创建了两个新变量a
和b
。它们分别被赋值为7
和8
,因为这些是我们在上一步传递给函数的参数。 - 第
4
行。在adder
执行上下文中声明了一个名为ret
的新变量 - 第
4
行。将变量a
的内容和变量b
的内容相加得15
并赋给ret
变量。 ret
变量从该函数返回。这个匿名函数执行上下文被销毁,从调用堆栈中删除,变量a
、b
和ret
不再存在。- 返回值被分配给我们在步骤 9 中定义的
sum
变量。 - 我们将
sum
的值打印到控制台。 - 如预期,控制台将打印
15
。我们在这里确实经历了很多困难,我想在这里说明几点。首先,函数定义可以存储在变量中,函数定义在程序调用之前是不可见的。其次,每次调用函数时,都会(临时)创建一个本地执行上下文。当函数完成时,执行上下文将消失。函数在遇到return
或右括号}
时执行完成。
最后,一个闭包
看看下面的代码,并试着弄清楚会发生什么。
1: function createCounter() {
2: let counter = 0
3: const myFunction = function() {
4: counter = counter + 1
5: return counter
6: }
7: return myFunction
8: }
9: const increment = createCounter()
10: const c1 = increment()
11: const c2 = increment()
12: const c3 = increment()
13: console.log('example increment', c1, c2, c3)
现在,我们已经从前两个示例中掌握了它的诀窍,让我们按照预期的方式快速执行它:
- 第
1-8
行。我们在全局执行上下文中创建了一个新的变量createCounter
,并赋值了一个的函数定义。 - 第
9
行。我们在全局执行上下文中声明了一个名为increment
的新变量。 - 第
9
行。我们需要调用createCounter
函数并将其返回值赋给increment
变量。 - 第
1-8
行。调用函数,创建新的本地执行上下文。 - 第
2
行。在本地执行上下文中,声明一个名为counter
的新变量并赋值为0
; - 第
3-6
行。声明一个名为myFunction
的新变量,变量在本地执行上下文中声明,变量的内容是为第4
行和第 5 行所定义。 - 第 7 行。返回
myFunction
变量的内容,删除本地执行上下文。变量myFunction
和counter
不再存在。此时控制权回到了调用上下文。 - 第
9
行。在调用上下文(全局执行上下文)中,createCounter
返回的值赋给了increment
,变量increment
现在包含一个函数定义内容为createCounter
返回的函数。它不再标记为myFunction````,但它的定义是相同的。在全局上下文中,它是的标记为
labeledincrement```。 - 第
10
行。声明一个新变量c1
。 - 继续第
10
行。查找increment
变量,它是一个函数并调用它。它包含前面返回的函数定义,如第4-5
行所定义的。 - 创建一个新的执行上下文。没有参数,开始执行函数。
- 第
4
行。counter=counter + 1
。在本地执行上下文中查找counter
变量。我们只是创建了那个上下文,从来没有声明任何局部变量。让我们看看全局执行上下文。这里也没有counter
变量。Javascript 会将其计算为 counter = undefined + 1,声明一个标记为counter
的新局部变量,并将其赋值为 number 1,因为 undefined 被当作值为 0。 - 第
5
行。我们变量counter
的值1
,我们销毁本地执行上下文和counter
变量。 - 回到第
10
行。返回值1
被赋给c1
。 - 第
11
行。重复步骤10-14
,c2
也被赋值为1
。 - 第
12
行。重复步骤10-14
,c3
也被赋值为1
。 - 第
13
行。我们打印变量c1 c2
和c3
的内容。
你自己试试,看看会发生什么。你会将注意到,它并不像从我上面的解释中所期望的那样记录1,1,1
。而是记录1,2,3
。这个是为什么?
不知怎么滴,increment
函数记住了那个cunter
的值。这是怎么回事?
counter
是全局执行上下文的一部分吗?尝试 console.log(counter)
,得到undefined
的结果,显然不是这样的。
也许,当你调用increment
时,它会以某种方式返回它创建的函数(createCounter)?这怎么可能呢?变量increment
包含函数定义,而不是函数的来源,显然也不是这样的。
所以一定有另一种机制。闭包,我们终于找到了,丢失的那块。
它是这样工作的,无论何时声明新函数并将其赋值给变量,都要存储函数定义和闭包。闭包包含在函数创建时作用域中的所有变量,它类似于背包。函数定义附带一个小背包,它的包中存储了函数定义创建时作用域中的所有变量。
所以我们上面的解释都是错的,让我们再试一次,但是这次是正确的。
1: function createCounter() {
2: let counter = 0
3: const myFunction = function() {
4: counter = counter + 1
5: return counter
6: }
7: return myFunction
8: }
9: const increment = createCounter()
10: const c1 = increment()
11: const c2 = increment()
12: const c3 = increment()
13: console.log('example increment', c1, c2, c3)
- 同上,第
1-8
行。我们在全局执行上下文中创建了一个新的变量createCounter
,它得到了指定的函数定义。 - 同上,第
9
行。我们在全局执行上下文中声明了一个名为increment
的新变量。 - 同上,第
9
行。我们需要调用createCounter
函数并将其返回值赋给increment
变量。 - 同上,第
1-8
行。调用函数,创建新的本地执行上下文。 - 同上,第
2
行。在本地执行上下文中,声明一个名为counter
的新变量并赋值为0
。 - 第
3-6
行。声明一个名为myFunction
的新变量,变量在本地执行上下文中声明,变量的内容是另一个函数定义。如第4
行和第5
行所定义,现在我们还创建了一个闭包,并将其作为函数定义的一部分。闭包包含作用域中的变量,在本例中是变量counter
(值为0
)。 - 第
7
行。返回myFunction
变量的内容,删除本地执行上下文。myFunction
和counter
不再存在。控制权交给了调用上下文,我们返回函数定义和它的闭包,闭包中包含了创建它时在作用域内的变量。 - 第
9
行。在调用上下文(全局执行上下文)中,createCounter
返回的值被指定为increment
,变量increment
现在包含一个函数定义(和闭包),由 createCounter 返回的函数定义,它不再标记为myFunction
,但它的定义是相同的,在全局上下文中,称为increment
。 - 第
10
行。声明一个新变量c1
。 - 继续第
10
行。查找变量increment
,它是一个函数,调用它。它包含前面返回的函数定义,如第4-5
行所定义的。(它还有一个带有变量的闭包)。 - 创建一个新的执行上下文,没有参数,开始执行函数。
- 第
4
行。counter = counter + 1
,寻找变量counter
,在查找本地或全局执行上下文之前,让我们检查一下闭包,瞧,闭包包含一个名为counter
的变量,其值为0
。在第4
行表达式之后,它的值被设置为1
。它再次被储存在闭包里,闭包现在包含值为1
的变量counter
。 - 第
5
行。我们返回counter的值
,销毁本地执行上下文。 - 回到第
10
行。返回值1
被赋给变量c1
。 - 第
11
行。我们重复步骤10-14
。这一次,在闭包中此时变量counter
的值是 1。它在第12
行设置的,它的值被递增并以2
的形式存储在递增函数的闭包中,c2
被赋值为2
。 - 第
12
行。重复步骤10-14
行,c3
被赋值为 3。 - 第 13 行。我们打印变量
c1 c2
和c3
的值。
你可能会问,是否有任何函数具有闭包,甚至是在全局范围内创建的函数?答案是肯定的。在全局作用域中创建的函数创建闭包,但是由于这些函数是在全局作用域中创建的,所以它们可以访问全局作用域中的所有变量,闭包的概念并不重要。
当函数返回函数时,闭包的概念就变得更加重要了。返回的函数可以访问不属于全局作用域的变量,但它们仅存在于其闭包中。
闭包不是那么简单
有时候闭包在你甚至没有注意到它的时候就会出现,你可能已经看到了我们称为部分应用程序的示例,如下面的代码所示:
let c = 4;
const addX = x => n => n + x;
const addThree = addX(3);
let d = addThree(c);
console.log("example partial application", d);
如果箭头函数让你感到困惑,下面是同样效果:
let c = 4;
function addX(x) {
return function(n) {
return n + x;
};
}
const addThree = addX(3);
let d = addThree(c);
console.log("example partial application", d);
我们声明一个能用加法函数addX
,它接受一个参数x
并返回另一个函数。返回的函数还接受一个参数并将其添加到变量x
中。
变量x
是闭包的一部分,当变量addThree
在本地上下文中声明时,它被分配一个函数定义和一个闭包,闭包包含变量x
。
所以当addThree
被调用并执行时,它可以从闭包中访问变量x
以及为参数传递变量n
并返回两者的和 7
。
总结
我将永远记住闭包的方法是通过背包的类比。当一个函数被创建并传递或从另一个函数返回时,它会携带一个背包。背包中是函数声明时作用域内的所有变量。
原型和原型链
四个规则
我们先来了解下面引用类型的四个规则:
- 引用类型,都具有对象特性,即可自由扩展属性。
- 引用类型,都有一个隐式原型
__proto__
属性,属性值是一个普通的对象。 - 引用类型,隐式原型
__proto__
的属性值指向它的构造函数的显式原型prototype
属性值。 - 当你试图得到一个对象的某个属性时,如果这个对象本身没有这个属性,那么它会去它的隐式原型
__proto__
(也就是它的构造函数的显式原型prototype
)中寻找。
引用类型:Object、Array、Function、Date、RegExp。这里我姑且称 proto 为隐式原型,没有官方中文叫法,大家都瞎叫居多。
规则一
引用类型,都具有对象特性,即可自由扩展属性:
const obj = {};
const arr = [];
const fn = function() {};
obj.a = 1;
arr.a = 1;
fn.a = 1;
console.log(obj.a); // 1
console.log(arr.a); // 1
console.log(fn.a); // 1
这个规则应该比较好理解,Date 和 RegExp 也一样,就不赘述了。
规则二
引用类型,都有一个隐式原型 __proto__
属性,属性值是一个普通的对象:
const obj = {};
const arr = [];
const fn = function() {};
console.log("obj.__proto__", obj.__proto__);
console.log("arr.__proto__", arr.__proto__);
console.log("fn.__proto__", fn.__proto__);
规则三
引用类型,隐式原型 __proto__
的属性值指向它的构造函数的显式原型 prototype
属性值:
const obj = {};
const arr = [];
const fn = function() {};
obj.__proto__ == Object.prototype; // true
arr.__proto__ === Array.prototype; // true
fn.__proto__ == Function.prototype; // true
规则四
当你试图得到一个对象的某个属性时,如果这个对象本身没有这个属性,那么它会去它的隐式原型 __proto__
(也就是它的构造函数的显式原型 prototype
)中寻找:
const obj = { a: 1 };
obj.toString;
// ƒ toString() { [native code] }
首先, obj
对象并没有 toString
属性,之所以能获取到 toString
属性,是遵循了第四条规则,从它的构造函数 Object
的 prototype
里去获取。
一个特例
我试图想推翻上面的规则,看下面这段代码:
function Person(name) {
this.name = name;
return this; // 其实这行可以不写,默认返回 this 对象
}
var nick = new Person("nick");
nick.toString;
// ƒ toString() { [native code] }
按理说, nick
是 Person
构造函数生成的实例,而 Person
的 prototype
并没有 toString
方法,那么为什么, nick
能获取到 toString
方法?
这里就引出 原型链
的概念了, nick
实例先从自身出发检讨自己,发现并没有 toString
方法。找不到,就往上走,找 Person
构造函数的 prototype
属性,还是没找到。构造函数的 prototype
也是一个对象嘛,那对象的构造函数是 Object
,所以就找到了 Object.prototype
下的 toString
方法。
上述寻找的过程就形成了原型链的概念,我理解的原型链就是这样一个过程。也不知道哪个人说过一句,JavaScript 里万物皆对象。从上述情况看来,好像是这么个理。🤔
一张图片
用图片描述原型链:
最后一个 null
,设计上是为了避免死循环而设置的, Object.prototype
的隐式原型指向 null
。
一个方法
instanceof
运算符用于测试构造函数的 prototype
属性是否出现在对象原型链中的任何位置。 instanceof
的简易手写版,如下所示:
// 变量R的原型 存在于 变量L的原型链上
function instance_of(L, R) {
// 验证如果为基本数据类型,就直接返回 false
const baseType = ["string", "number", "boolean", "undefined", "symbol"];
if (baseType.includes(typeof L)) {
return false;
}
let RP = R.prototype; // 取 R 的显示原型
L = L.__proto__; // 取 L 的隐式原型
while (true) {
if (L === null) {
// 找到最顶层
return false;
}
if (L === RP) {
// 严格相等
return true;
}
L = L.__proto__; // 没找到继续向上一层原型链查找
}
}
我们再来看下面这段代码:
function Foo(name) {
this.name = name;
}
var f = new Foo("nick");
f instanceof Foo; // true
f instanceof Object; // true
上述代码判断流程大致如下:
f instanceof Foo
:f
的隐式原型__proto__
和Foo.prototype
,是相等的,所以返回true
。f instanceof Object
:f
的隐式原型__proto__
,和Object.prototype
不等,所以继续往上走。f
的隐式原型__proto__
指向Foo.prototype
,所以继续用Foo.prototype.__proto__
去对比Object.prototype
,这会儿就相等了,因为Foo.prototype
就是一个普通的对象。
再一次验证万物皆对象。。。。
总结
通过四个特性、一个例子、一张图片、一个方法,大家应该对原型和原型链的关系有了大概的认知。我的认知就是,原型链就是一个过程,原型是原型链这个过程中的一个单位,贯穿整个原型链。就好像你要是看完了不点个赞,我可以顺着网线找到你。
new 的实现原理
关于 new 操作符
关于 javascript 继承,使用 new 操作符实例化构造函数,像这样 let obj = new Fn() 那么 new 调用类的构造函数会执行什么操作呢?今天来实现下 new 的原理。
模拟 new 的实现
我们先来定义一个构造函数:
function People(name) {
this.name = name; //实例上的属性
}
People.prototype.say = function() {
console.log("haha");
};
let ada = new People("ada");
console.log(ada.name); // 'ada'
ada.say(); // 'haha'
可以看到,使用 new 实现了子类继承父类 People 的属性和原型上的方法。那么接下来实现 new
function myNew() {
let Constructor = Array.prototype.shift.call(arguments); //1、通过参数shift方法取到Constructor
let obj = {}; ///2、在内存中定义一个新对象
obj._proto_ = Constructor.prototype; // 3、新对象的_proto_指针指向构造函数的prototype属性
let r = Constrcutor.apply(obj, arguments); // 4、this指向新对象,并执行构造函数代码
return r instanceof Object ? r : obj; // 若构造函数返回对象,则返回该对象,不然返回新建对象obj
}
let ada = myNew(People, "ada");
console.log(ada);
总结
所以可以得到,使用 new 操作符调用类的构造函数会执行如下操作:
- 在内存中创建一个新对象
- 这个新对象内部的proto指针被赋值给构造函数的 prototype 属性
- 构造函数内部的 this 指向新对象
- 执行构造函数内部的代码
- 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象
JavaScript 判断是否为数组
1.typeof
对于 Function、String、Number、Undefined 等几种类型的对象来说,他完全可以胜任。但是为 Array 时:
var arr = [1, 2, 3];
console.log(typeof arr); // "object"
// 同样的
console.log(typeof null); // "object"
console.log(typeof {}); // "object"
所以不能使用 typeof
来判断。
2.instanceof
instanceof
运算符用于检测构造函数的 prototype
属性是否出现在某个实例对象的原型链上。
var arr = [1, 2, 3];
console.log(arr instanceof Array); // true
3. 原型链(constructor)
一般情况下,除了 undefined
和 null
,其它都能使用 constructor
判断类型。
var arr = [1, 2, 3];
console.log(arr.__proto__.constructor === Array); // true
console.log(arr.constructor === Array); // true
// 注意:arr.__proto__ === Array.prototype 为 true。
但是某些情况下,判断是不准确的,比如:
// 构造函数
function Fn() {}
// 修改原型对象
Fn.prototype = new Array();
// 实例化对象
var fn = new Fn();
console.log(fn.constructor === Fn); // false
console.log(fn.constructor === Array); // true
// 此时的 fn 应该是一个普通对象,而非数组,所以此时使用 constructor 判断是不合适的。
使用 instanceof 和 constructor 的局限性:
使用和声明都必须是在当前页面,比如父页面引用了子页面,在子页面中声明了一个 Array
,将其赋值给父页面的一个变量,那么此时做原型链的判断:Array === object.constructor
得到的是 false
,原因如下:
Array
属于引用型数据,在传递过程中,仅仅是引用地址的传递。- 每个页面的
Array
原生对象所引用的地址是不一样的,在子页面声明的Array
所对应的构造函数是子页面的Array
对象;父页面来进行判断,使用的Array
并不等于子页面的Array
。
看代码:
var iframe = document.createElement("iframe");
document.body.appendChild(iframe);
var xArray = window.frames[window.frames.length - 1].Array;
var xarr = new xArray();
var arr = new Array();
// 不同页面,结果并非我们所预期的 true,而是 false 哦!
console.log(xarr instanceof Array); // false
console.log(xarr.constructor === Array); // false
// 同页面才是 true 哦!
console.log(arr instanceof Array); // true
console.log(arr.constructor === Array); // true
4. Array.isArray
鉴于以上原因,ES5 标准提供的一个判断数组方法 isArray()
,其原理也是通过 Object.prototype.toString()
判断对象的内部属性 [[Class]]
是否为 "Array"
,以达到判断数组的目的。
function isArray(arr) {
return Array.isArray(arr);
}
5. Object.prototype.toString
所以,终极方法就是以下这个 👇
function isArray(arr) {
return Object.prototype.toString.call(arr) === "[object Array]";
}
css
visibility、display、opacity 的区别
display: none;
- DOM 结构:浏览器不会渲染 display 属性为 none 的元素,不占据空间;
- 事件监听:无法进行 DOM 事件监听;
- 性能:动态改变此属性时会引起重排,性能较差;
- 继承:不会被子元素继承,毕竟子类也不会被渲染;
- transition:transition 不支持 display。
visibility: hidden;
- DOM 结构:元素被隐藏,但是会被渲染不会消失,占据空间;
- 事件监听:无法进行 DOM 事件监听;
- 性 能:动态改变此属性时会引起重绘,性能较高;
- 继 承:会被子元素继承,子元素可以通过设置 visibility: visible; 来取消隐藏;
- transition:visibility 会立即显示,隐藏时会延时
opacity: 0;
- DOM 结构:透明度为 100%,元素隐藏,占据空间;
- 事件监听:可以进行 DOM 事件监听;
- 性 能:提升为合成层,不会触发重绘,性能较高;
- 继 承:会被子元素继承,且子元素并不能通过 opacity: 1 来取消隐藏;
- transition:opacity 可以延时显示和隐藏
单行截断 css
单行文本溢出显示省略号(…)
text-overflow:ellipsis-----部分浏览器还需要加宽度 width 属性
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
}
多行文本溢出显示省略号
WebKit 浏览器或移动端的页面
在 WebKit 浏览器或移动端(绝大部分是 WebKit 内核的浏览器)的页面实现比较简单,可以直接使用 WebKit 的 CSS 扩展属性(WebKit 是私有属性)-webkit-line-clamp ;
注意:这是一个 不规范的属性(unsupported WebKit property),它没有出现在 CSS 规范草案中。
-webkit-line-clamp 用来限制在一个块元素显示的文本的行数。 为了实现该效果,它需要组合其他的 WebKit 属性。常见结合属性:
- display: -webkit-box; 必须结合的属性 ,将对象作为弹性伸缩盒子模型显示 。
- -webkit-box-orient 必须结合的属性 ,设置或检索伸缩盒对象的子元素的排列方式 。
- text-overflow: ellipsis;,可以用来多行文本的情况下,用省略号“…”隐藏超出范围的文本。
这个属性比较合适 WebKit 浏览器或移动端(绝大部分是 WebKit 内核的)浏览器
.ellipsis {
overflow: hidden;
display: -webkit-box;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
跨浏览器兼容的方案
比较靠谱简单的做法就是设置相对定位的容器高度,用包含省略号(…)的元素模拟实现;
p {
position: relative;
line-height: 1.4em;
height: 4.2em;
overflow: hidden;
}
p::after {
content: "...";
font-weight: bold;
position: absolute;
bottom: 0;
right: 0;
padding: 0 20px 1px 45px;
background: url(....) repeat-y;
}
这里注意几点:
- height 高度真好是 line-height 的 3 倍;
- 结束的省略好用了半透明的 png 做了减淡的效果,或者设置背景颜色;
- IE6-7 不显示 content 内容,所以要兼容 IE6-7 可以是在内容中加入一个标签,比如用…去模拟;
- 要支持 IE8,需要将::after 替换成:after;
JavaScript 方案
用 js 也可以根据上面的思路去模拟,实现也很简单,推荐几个做类似工作的成熟小工具;
1.Clamp.js 下载及文档地址:github.com/josephschmi… 使用也非常简单:
var module = document.getElementById("clamp-this-module");
$clamp(module, { clamp: 3 });
2.jQuery 插件-jQuery.dotdotdot 这个使用起来也很方便:
$(document).ready(function() {
$("#wrapper").dotdotdot({
//
});
});
Flex 布局
1.基础概念:
采用 Flex 布局的元素,称为 Flex 容器(flex container),简称"容器"。它的所有子元素自动成为容器成员,称为 Flex 项目(flex item),简称"项目"。
(1)基础概念:
容器默认存在两根轴:水平的主轴(main axis)和垂直的交叉轴(cross axis)。主轴的开始位置(与边框的交叉点)叫做main start
,结束位置叫做main end
;交叉轴的开始位置叫做cross start
,结束位置叫做cross end
。
项目默认沿主轴排列。单个项目占据的主轴空间叫做main size
,占据的交叉轴空间叫做cross size
。
回答时候简而言之:弹性布局由父级容器、子容器构成,通过设置主轴和交叉轴来控制子元素的排序方式
2. 说说父级容器属性
以下6个属性设置在容器上。
- flex-direction : row | row-reverse | column | column-reverse; 该属性定义了 子元素排列方向
- **flex-wrap:**nowrap | wrap | wrap-reverse; 该属性称"轴线"
- flex-flow: || ;
flex-direction
和flex-wrap
的简写形式,默认值为row nowrap
- justify-content: flex-start | flex-end | center | space-between | space-around; 该属性定义了子元素在主轴上的对齐方式。
- align-items: flex-start | flex-end | center | baseline | stretch; 定义项目在交叉轴上如何对齐。
- **align-content: ** flex-start | flex-end | center | space-between | space-around | stretch; 属性定义了多根轴线的对齐方式。如果项目只有一根轴线,该属性不起作用
其实,父级容器属性回答到这里就可以了,后面还要子级元素的属性要说说~~!,然后在最后面说几个案例就可以了,比如说,垂直水平居中布局,左边固定右边自适应等常见布局。
下面是初学者可以看的一些详细的一些知识点,
(1) flex-direction属性
sql
复制代码
.box {
flex-direction: row | row-reverse | column | column-reverse;
}
row(默认值):主轴为水平方向,起点在左端。row-reverse:主轴为水平方向,起点在右端。column:主轴为垂直方向,起点在上沿。column-reverse:主轴为垂直方向,起点在下沿。
(2) flex-wrap属性
默认情况下,项目都排在一条线(又称"轴线")上。flex-wrap
属性定义,如果一条轴线排不下,如何换行。
lua
复制代码
.box{
flex-wrap: nowrap | wrap | wrap-reverse;
}
它可能取三个值。
nowrap
(默认):不换行。
wrap
:换行,第一行在上方。
wrap-reverse
:换行,第一行在下方。
**(3)**flex-flow
flex-flow
属性是flex-direction
属性和flex-wrap
属性的简写形式,默认值为row nowrap
。
.box { flex-flow: || ; }
**(4)**justify-content属性
justify-content
属性定义了项目在主轴上的对齐方式。
.box { justify-content: flex-start | flex-end | center | space-between | space-around; }
具体对齐方式与轴的方向有关。下面假设主轴为从左到右。
flex-start
(默认值):左对齐flex-end
:右对齐center
: 居中space-between
:两端对齐,项目之间的间隔都相等。space-around
:每个项目两侧的间隔相等。所以,项目之间的间隔比项目与边框的间隔大一倍。
(5)align-items属性
align-items
属性定义项目在交叉轴上如何对齐。
.box { align-items: flex-start | flex-end | center | baseline | stretch; }
具体的对齐方式与交叉轴的方向有关,下面假设交叉轴从上到下。
flex-start
:交叉轴的起点对齐。flex-end
:交叉轴的终点对齐。center
:交叉轴的中点对齐。baseline
: 项目的第一行文字的基线对齐。stretch
(默认值):如果项目未设置高度或设为auto,将占满整个容器的高度
(6)align-content属性
align-content
属性定义了多根轴线的对齐方式。如果项目只有一根轴线,该属性不起作用。
align-content: flex-start | flex-end | center | space-between | space-around | stretch;
flex-start
:与交叉轴的起点对齐。flex-end
:与交叉轴的终点对齐。center
:与交叉轴的中点对齐。space-between
:与交叉轴两端对齐,轴线之间的间隔平均分布。space-around
:每根轴线两侧的间隔都相等。所以,轴线之间的间隔比轴线与边框的间隔大一倍。stretch
(默认值):轴线占满整个交叉轴。
示例图:(这块比较上面难理解需要手动去写代码实践操作)
3. 说说子级容器属性
6个属性设置在子级元素或者容器上。
弹性布局子元素、子容器、项目、项说的是一个概念子容器
order
flex-grow
flex-shrink
flex-basis
flex
align-self
(1)order属性
order
属性定义子元素或者子容器的排列顺序。数值越小,排列越靠前,默认为0。
.item { order: ; }
(2)flex-grow属性
flex-grow
属性定义子元素或者子容器的放大比例,默认为0
,即如果存在剩余空间,也不放大。
.item { flex-grow: ; /* default 0 */ }
如果所有项目的flex-grow
属性都为1,则它们将等分剩余空间(如果有的话)。如果一个项目的flex-grow
属性为2,其他项目都为1,则前者占据的剩余空间将比其他项多一倍。
(3)flex-shrink属性
flex-shrink
属性定义了项目的缩小比例,默认为1,即如果空间不足,该项目将缩小。
.item { flex-shrink: ; /* default 1 */ }
如果所有项目的flex-shrink
属性都为1,当空间不足时,都将等比例缩小。如果一个项目的flex-shrink
属性为0,其他项目都为1,则空间不足时,前者不缩小。
负值对该属性无效。
(4)flex-basis属性
flex-basis
属性定义了在分配多余空间之前,项目占据的主轴空间(main size)。浏览器根据这个属性,计算主轴是否有多余空间。它的默认值为auto
,即项目的本来大小。
.item { flex-basis: | auto; /* default auto */ }
它可以设为跟width
或height
属性一样的值(比如350px),则项目将占据固定空间。
(5)flex属性
flex
属性是flex-grow
, flex-shrink
和 flex-basis
的简写,默认值为0 1 auto
。后两个属性可选。
.item { flex: none | [ <'flex-grow'> <'flex-shrink'>? || <'flex-basis'> ] }
该属性有两个快捷值:auto
(1 1 auto
) 和 none (0 0 auto
)。
建议优先使用这个属性,而不是单独写三个分离的属性,因为浏览器会推算相关值。
(6)align-self属性
align-self
属性允许单个项目有与其他项目不一样的对齐方式,可覆盖align-items
属性。默认值为auto
,表示继承父元素的align-items
属性,如果没有父元素,则等同于stretch
。
.item { align-self: auto | flex-start | flex-end | center | baseline | stretch; }
该属性可能取6个值,除了auto,其他都与align-items属性完全一致。
浅析 BFC 原理及作用
1.BFC 概念
BFC 即 Block Formatting Contexts (块级格式化上下文),它属于上述布局模式的流动模型。是 W3C CSS2.1 规范中的一个概念,决定了元素如何对其内容进行定位,以及与其他元素的关系和相互作用。具有 BFC 特性的元素可以看做是隔离了的独立容器,容器里面的元素不会在布局上影响到外面的元素,并且 BFC 具有普通容器所没有的的一些特性。通俗一点来讲,可以把 BFC 理解为一个封闭的大箱子,箱子内部的元素无论如何翻江倒海,都不会影响到外部。
2.形成 BFC 的条件
只要元素满足下面任一条件即可触发 BFC 特性:
- body 根元素
浮动元素:float 除 none 以外的值
绝对定位元素:position (absolute、fixed)
display 为 inline-block、table-cells、flex
overflow 除了 visible 以外的值 (hidden、auto、scroll)
3.BFC 的作用
(1)阻止外边距折叠
问题案例:margin 塌陷问题:在标准文档流中,块级标签之间竖直方向的 margin 会以大的为准,这就是 margin 的塌陷现象。可以用 overflow:hidden 产生 bfc 来解决。
(2)包含浮动元素
问题案列:高度塌陷问题,在通常情况下父元素的高度会被子元素撑开,而在这里因为其子元素为浮动元素所以父元素发生了高度坍塌,上下边界重合,这时就可以用 BFC 来清除浮动了。
由于容器内元素浮动,脱离了文档流,所以容器只剩下 2px 的边距高度。如果触发容器的 BFC,那么容器将会包裹浮动元素。
(3)阻止元素被浮动元素覆盖
问题案例:div 浮动兄弟这该问题:由于左侧块级元素发生了浮动,所以和右侧未发生浮动的块级元素不在同一层内,所以会发生 div 遮挡问题。可以给右侧元素添加 overflow: hidden,触发 BFC 来解决遮挡问题。
我是一个左浮动的元素
我是一个没有设置浮动, 也没有触发 BFC 元素, width: 200px; height:200px; background: grey;
这时候其实第二个元素有部分被浮动元素所覆盖,但是文本信息不会被浮动元素所覆盖,如果想避免元素被覆盖,可触发第二个元素的 BFC 特性,在第二个元素中加入 overflow:hidden,就会变成:
我是一个左浮动的元素
我是一个没有设置浮动, 也没有触发 BFC 元素, width: 200px; height:200px; background: grey;
CSS 垂直水平居中的实现方式
1.使用绝对定位和负外边距对块级元素进行垂直居中
这个方法兼容性不错,但是有一个小缺点:必须提前知道被居中块级元素的尺寸,否则无法准确实现垂直居中。
2.使用绝对定位和 transform
test vertical align
这种方法有一个明显的好处就是不必提前知道被居中元素的尺寸了,因为 transform
中 translate
偏移的百分比就是相对于元素自身的尺寸而言的。
3.绝对定位结合 margin: auto
test vertical align
这种实现方式的两个核心是:把要垂直居中的元素相对于父元素绝对定位,top 和 bottom 设为相等的值,我这里设成了 0,当然也可以设为 99999px 或者 -99999px 无论什么,只要两者相等就行,这一步做完之后再将要居中元素的 margin
属性值设为 auto
,这样便可以实现垂直居中了。
4.使用 flex 布局
test vertical align
伪元素和伪类的区别
1.概念
伪类用于当已有元素处于的某个状态时,为其添加对应的样式,这个状态是根据用户行为而动态变化的。比如说,当用户悬停在指定的元素时,我们可以通过:hover 来描述这个元素的状态。虽然它和普通的 css 类相似,可以为已有的元素添加样式,但是它只有处于 dom 树无法描述的状态下才能为元素添加样式,所以将其称为伪类。
伪元素用于创建一些不在文档树中的元素,并为其添加样式。比如说,我们可以通过:before 来在一个元素前增加一些文本,并为这些文本添加样式。虽然用户可以看到这些文本,但是这些文本实际上不在文档树中。
2.区别
伪类的操作对象是文档树中已有的元素,而伪元素则创建了一个文档数外的元素。因此,伪类与伪元素的区别在于:有没有创建一个文档树之外的元素。
3.用法
CSS3 规范中的要求使用双冒号 (:😃 表示伪元素,以此来区分伪元素和伪类,比如::before 和::after 等伪元素使用双冒号 (:😃,:hover 和:active 等伪类使用单冒号 (😃。除了一些低于 IE8 版本的浏览器外,大部分浏览器都支持伪元素的双冒号 (:😃 表示方法。
实际上,伪元素使用单冒号还是双冒号很难说得清谁对谁错,你可以按照个人的喜好来选择某一种写法。
说一下盒模型
盒模型分为 IE 盒模型和 W3C 标准盒模型。
- W3C 标准盒模型:属性 width,height 只包含内容 content,不包含 border 和 padding。
- IE 盒模型:属性 width,height 包含 border 和 padding,指的是 content+padding+border
css 的盒模型由 content(内容)、padding(内边距)、border(边框)、margin(外边距)组成。但盒子的大小由 content+padding+border 这几部分决定,把 margin 算进去的那是盒子占据的位置,而不是盒子的大小!
CSS 三栏布局方案
1.浮动布局方案
通过让左右两栏固定宽度和浮动,并设置中间栏的左右外边距实现三栏自适应
leftright
main缺点:- 内容展现顺序与 DOM 结构不一致,主体内容后加载,一定程度影响用户体验
- 当宽度缩小到不足以显示三栏时,右侧栏会被挤到下方
兼容性:
- PC 端支持 IE6+, Firefox 2+, Chrome 1+
- 移动端支持 iOS Safari 1.0,Android browser 1.0
2.绝对定位布局方案
容器设置为相对定位,左右两栏分别用绝对定位,中间栏增加左右外边距实现自适应。
还有一种思路, 左右两栏分别绝对定位在两侧,中间栏同样使用绝对定位,并设置左右的距离。
leftright
main缺点:
- 父元素必须要定位(使用非 static 的定位方式)
- 宽度缩小到无法显示主体内容时,主体内容会被覆盖无法显示
优点:
- 内容可以优先加载
兼容性:
- PC 端支持 IE6+, Firefox 2+, Chrome 1+
- 移动端未知
3.Flex 布局方案
设置容器为 flex,然后左右栏设置固定宽度不可伸缩,中间栏设置为自动伸缩
left
mainright缺点:
- 无法兼容低版本的浏览器
优点:
- 代码简洁,DOM 结构清晰
- 主流的实现方式
兼容性:
- PC 端支持 IE10 及以上、Edge 12,chrome 21,firefox 28,safari 6.1(IE10 为部分支持,其他浏览器版本为完全支持)
- 移动端支持 iOS Safari 7, android browser 4.4
4.网格布局方案
设置容器为 grid,然后设置行高度为 100px,设置三栏的宽度
left
mainright缺点:
- 兼容性相对弹性盒要差,不过目前绝大部分浏览器较新的版本已经支持
优点:
- 代码简洁,DOM 结构清晰
兼容性:
- PC 端支持 IE10 及以上、Edge 16,chrome 57,firefox 52,safari 10.1(IE10 为部分支持,其他浏览器为完全支持的起始版本)
- 移动端支持 iOS Safari 10.3, android browser 67
5.表格布局方案
设置容器为 table 且宽度为 100%,并设置子元素为 table-cell
left
mainright缺点:
- 非语义化
优点:
- 兼容浏览器版低
兼容性:
- PC 支持 IE8+, Firefox 3+, Chrome 4+, Safari 3.1+
- 移动端支持 iOS Safari 3.2, android browser 2.1
浏览器 & 网络
介绍一下 EventLoop
前言
Event Loop
即事件循环,是指浏览器或Node
的一种解决javaScript
单线程运行时不会阻塞的一种机制,也就是我们经常使用异步的原理。
堆、栈、队列
堆(Heap)
堆是一种数据结构,是利用完全二叉树维护的一组数据,堆分为两种,一种为最大堆,一种为最小堆,将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。堆是线性数据结构,相当于一维数组,有唯一后继。
栈(Stack)
栈在计算机科学中是限定仅在表尾进行插入或删除操作的线性表。 栈是一种数据结构,它按照后进先出的原则存储数据,先进入的数据被压入栈底,最后的数据在栈顶,需要读数据的时候从栈顶开始弹出数据。栈是只能在某一端插入和删除的特殊线性表。
队列(Queue)
特殊之处在于它只允许在表的前端(front
)进行删除操作,而在表的后端(rear
)进行插入操作,和栈一样,队列是一种操作受限制的线性表。
进行插入操作的端称为队尾,进行删除操作的端称为队头。 队列中没有元素时,称为空队列。
队列的数据元素又称为队列元素。在队列中插入一个队列元素称为入队,从队列中删除一个队列元素称为出队。因为队列只允许在一端插入,在另一端删除,所以只有最早进入队列的元素才能最先从队列中删除,故队列又称为先进先出(FIFO—first in first out
)
Event Loop
在JavaScript
中,任务被分为两种,一种宏任务(MacroTask
)也叫Task
,一种叫微任务(MicroTask
)。
MacroTask(宏任务)
script
全部代码、setTimeout
、setInterval
、setImmediate
(浏览器暂时不支持,只有 IE10 支持,具体可见MDN
)、I/O
、UI Rendering
。
MicroTask(微任务)
Process.nextTick(Node独有)
、Promise
、Object.observe(废弃)
、MutationObserver
(具体使用方式查看这里)
浏览器中的 Event Loop
Javascript
有一个 main thread
主线程和 call-stack
调用栈(执行栈),所有的任务都会被放到调用栈等待主线程执行。
JS 调用栈
JS 调用栈采用的是后进先出的规则,当函数执行的时候,会被添加到栈的顶部,当执行栈执行完成后,就会从栈顶移出,直到栈内被清空。
同步任务和异步任务
Javascript
单线程任务被分为同步任务和异步任务,同步任务会在调用栈中按照顺序等待主线程依次执行,异步任务会在异步任务有了结果后,将注册的回调函数放入任务队列中等待主线程空闲的时候(调用栈被清空),被读取到栈内等待主线程的执行。
任务队列Task Queue
,即队列,是一种先进先出的一种数据结构。
事件循环的进程模型
- 选择当前要执行的任务队列,选择任务队列中最先进入的任务,如果任务队列为空即
null
,则执行跳转到微任务(MicroTask
)的执行步骤。 - 将事件循环中的任务设置为已选择任务。
- 执行任务。
- 将事件循环中当前运行任务设置为 null。
- 将已经运行完成的任务从任务队列中删除。
- microtasks 步骤:进入 microtask 检查点。
- 更新界面渲染。
- 返回第一步。
执行进入 microtask 检查点时,用户代理会执行以下步骤:
- 设置 microtask 检查点标志为 true。
- 当事件循环
microtask
执行不为空时:选择一个最先进入的microtask
队列的microtask
,将事件循环的microtask
设置为已选择的microtask
,运行microtask
,将已经执行完成的microtask
为null
,移出microtask
中的microtask
。 - 清理 IndexDB 事务
- 设置进入 microtask 检查点的标志为 false。
上述可能不太好理解,下图是我做的一张图片。
执行栈在执行完同步任务后,查看执行栈是否为空,如果执行栈为空,就会去检查微任务(microTask
)队列是否为空,如果为空的话,就执行Task
(宏任务),否则就一次性执行完所有微任务。
每次单个宏任务执行完毕后,检查微任务(microTask
)队列是否为空,如果不为空的话,会按照先入先出的规则全部执行完微任务(microTask
)后,设置微任务(microTask
)队列为null
,然后再执行宏任务,如此循环。
举个例子
console.log("script start");
setTimeout(function() {
console.log("setTimeout");
}, 0);
Promise.resolve()
.then(function() {
console.log("promise1");
})
.then(function() {
console.log("promise2");
});
console.log("script end");
首先我们划分几个分类:
第一次执行:
Tasks:run script、 setTimeout callback
Microtasks:Promise then
JS stack: script
Log: script start、script end。
执行同步代码,将宏任务(Tasks
)和微任务(Microtasks
)划分到各自队列中。
第二次执行:
Tasks:run script、 setTimeout callback
Microtasks:Promise2 then
JS stack: Promise2 callback
Log: script start、script end、promise1、promise2
执行宏任务后,检测到微任务(Microtasks
)队列中不为空,执行Promise1
,执行完成Promise1
后,调用Promise2.then
,放入微任务(Microtasks
)队列中,再执行Promise2.then
。
第三次执行:
Tasks:setTimeout callback
Microtasks:
JS stack: setTimeout callback
Log: script start、script end、promise1、promise2、setTimeout
当微任务(Microtasks
)队列中为空时,执行宏任务(Tasks
),执行setTimeout callback
,打印日志。
第四次执行:
Tasks:setTimeout callback
Microtasks:
JS stack:
Log: script start、script end、promise1、promise2、setTimeout
清空Tasks队列和JS stack
。
内存泄漏
什么是内存泄漏
引擎中有垃圾回收机制,它主要针对一些程序中不再使用的对象,对其清理回收释放掉内存。
那么垃圾回收机制会把不再使用的对象(垃圾)全都回收掉吗?
其实引擎虽然针对垃圾回收做了各种优化从而尽可能的确保垃圾得以回收,但并不是说我们就可以完全不用关心这块了,我们代码中依然要主动避免一些不利于引擎做垃圾回收操作,因为不是所有无用对象内存都可以被回收的,那当不再用到的对象内存,没有及时被回收时,我们叫它 内存泄漏(Memory leak)
。
常见的内存泄漏
不正当的闭包
闭包就是函数内部嵌套并 return 一个函数???这是大多数人认为的闭包,好吧,它确实也是,我们来看看几本 JS 高光书中的描述:
- JavaScript 高级程序设计:闭包是指有权访问另一个函数作用域中的变量的函数
- JavaScript 权威指南:从技术的角度讲,所有的 JavaScript 函数都是闭包:它们都是对象,它们都关联到作用域链
- 你不知道的 JavaScript:当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行
按照上面三本书中的描述,那闭包所涉及的的范围就比较广了,我们这里暂时不去纠结闭包的定义,就以最简单、大家都认可的闭包例子来看闭包:
function fn1() {
let test = new Array(1000).fill("isboyjc");
return function() {
console.log("hahaha");
};
}
let fn1Child = fn1();
fn1Child();
显然它是一个典型闭包,但是它并没有造成内存泄漏,因为返回的函数中并没有对 fn1
函数内部的引用,也就是说,函数 fn1
内部的 test
变量完全是可以被回收的,那我们再来看:
function fn2() {
let test = new Array(1000).fill("isboyjc");
return function() {
console.log(test);
return test;
};
}
let fn2Child = fn2();
fn2Child();
显然它也是闭包,并且因为 return
的函数中存在函数 fn2
中的 test
变量引用,所以 test
并不会被回收,也就造成了内存泄漏。
其实在函数调用后,把外部的引用关系置空就好了,如下:
function fn2() {
let test = new Array(1000).fill("isboyjc");
return function() {
console.log(test);
return test;
};
}
let fn2Child = fn2();
fn2Child();
fn2Child = null;
“ 减少使用闭包,闭包会造成内存泄漏。。。 ”
醒醒,这句话是过去式了,它的描述不准确,So,应该说不正当的使用闭包可能会造成内存泄漏。
隐式全局变量
我们知道 JavaScript
的垃圾回收是自动执行的,垃圾回收器每隔一段时间就会找出那些不再使用的数据,并释放其所占用的内存空间。
再来看全局变量和局部变量,函数中的局部变量在函数执行结束后这些变量已经不再被需要,所以垃圾回收器会识别并释放它们。但是对于全局变量,垃圾回收器很难判断这些变量什么时候才不被需要,所以全局变量通常不会被回收,我们使用全局变量是 OK 的,但同时我们要避免一些额外的全局变量产生,如下:
function fn() {
// 没有声明从而制造了隐式全局变量test1
test1 = new Array(1000).fill("isboyjc1");
// 函数内部this指向window,制造了隐式全局变量test2
this.test2 = new Array(1000).fill("isboyjc2");
}
fn();
调用函数 fn
,因为 没有声明 和 函数中 this 的问题造成了两个额外的隐式全局变量,这两个变量不会被回收,这种情况我们要尽可能的避免,在开发中我们可以使用严格模式或者通过 lint
检查来避免这些情况的发生,从而降低内存成本。
除此之外,我们在程序中也会不可避免的使用全局变量,这些全局变量除非被取消或者重新分配之外也是无法回收的,这也就需要我们额外的关注,也就是说当我们在使用全局变量存储数据时,要确保使用后将其置空或者重新分配,当然也很简单,在使用完将其置为 null
即可,特别是在使用全局变量做持续存储大量数据的缓存时,我们一定要记得设置存储上限并及时清理,不然的话数据量越来越大,内存压力也会随之增高。
var test = new Array(10000);
// do something
test = null;
游离 DOM 引用
考虑到性能或代码简洁方面,我们代码中进行 DOM 时会使用变量缓存 DOM 节点的引用,但移除节点的时候,我们应该同步释放缓存的引用,否则游离的子树无法释放。
<div id="root">
<ul id="ul">
<li></li>
<li></li>
<li id="li3"></li>
<li></li>
</ul>
</div>
<script>
let root = document.querySelector("#root");
let ul = document.querySelector("#ul");
let li3 = document.querySelector("#li3");
// 由于ul变量存在,整个ul及其子元素都不能GC
root.removeChild(ul);
// 虽置空了ul变量,但由于li3变量引用ul的子节点,所以ul元素依然不能被GC
ul = null;
// 已无变量引用,此时可以GC
li3 = null;
</script>
如上所示,当我们使用变量缓存 DOM 节点引用后删除了节点,如果不将缓存引用的变量置空,依然进行不了 GC,也就会出现内存泄漏。
假如我们将父节点置空,但是被删除的父节点其子节点引用也缓存在变量里,那么就会导致整个父 DOM 节点树下整个游离节点树均无法清理,还是会出现内存泄漏,解决办法就是将引用子节点的变量也置空,如下图:
遗忘的定时器
程序中我们经常会用到计时器,也就是 setTimeout
和 setInterval
,先来看一个例子:
// 获取数据
let someResource = getData()
setInterval(() => {
const node = document.getElementById('Node')
if(node) {
node.innerHTML = JSON.stringify(someResource))
}
}, 1000)
上面是我随便 copy
的一个小例子,其代码中每隔一秒就将得到的数据放入到 Node
节点中去,但是在 setInterval
没有结束前,回调函数里的变量以及回调函数本身都无法被回收。
什么才叫结束呢?也就是调用了 clearInterval
。如果没有被 clear
掉的话,就会造成内存泄漏。不仅如此,如果回调函数没有被回收,那么回调函数内依赖的变量也没法被回收。所以在上例中,someResource
就没法被回收。
同样,setTiemout
也会有同样的问题,所以,当不需要 interval
或者 timeout
时,最好调用 clearInterval
或者 clearTimeout
来清除,另外,浏览器中的 requestAnimationFrame
也存在这个问题,我们需要在不需要的时候用 cancelAnimationFrame
API 来取消使用。
遗忘的事件监听器
当事件监听器在组件内挂载相关的事件处理函数,而在组件销毁时不主动将其清除时,其中引用的变量或者函数都被认为是需要的而不会进行回收,如果内部引用的变量存储了大量数据,可能会引起页面占用内存过高,这样就造成意外的内存泄漏。
我们就拿 Vue 组件来举例子,React 里也是一样的:
<template>
<div></div>
</template>
<script>
export default {
created() {
window.addEventListener("resize", this.doSomething);
},
beforeDestroy() {
window.removeEventListener("resize", this.doSomething);
},
methods: {
doSomething() {
// do something
}
}
};
</script>
遗忘的监听者模式
监听者模式想必我们都知道,不管是 Vue 、 React 亦或是其他,对于目前的前端开发框架来说,监听者模式实现一些消息通信都是非常常见的,比如 EventBus
. . .
当我们实现了监听者模式并在组件内挂载相关的事件处理函数,而在组件销毁时不主动将其清除时,其中引用的变量或者函数都被认为是需要的而不会进行回收,如果内部引用的变量存储了大量数据,可能会引起页面占用内存过高,这样也会造成意外的内存泄漏。
还是用 Vue 组件举例子,因为比较简单:
<template>
<div></div>
</template>
<script>
export default {
created() {
eventBus.on("test", this.doSomething);
},
beforeDestroy() {
eventBus.off("test", this.doSomething);
},
methods: {
doSomething() {
// do something
}
}
};
</script>
如上,我们只需在 beforeDestroy
组件销毁生命周期里将其清除即可。
遗忘的 Map、Set 对象
当使用 Map
或 Set
存储对象时,同 Object
一致都是强引用,如果不将其主动清除引用,其同样会造成内存不自动进行回收。
如果使用 Map
,对于键为对象的情况,可以采用 WeakMap
,WeakMap
对象同样用来保存键值对,对于键是弱引用(注:WeakMap
只对于键是弱引用),且必须为一个对象,而值可以是任意的对象或者原始值,由于是对于对象的弱引用,不会干扰 Js
的垃圾回收。
如果需要使用 Set
引用对象,可以采用 WeakSet
,WeakSet
对象允许存储对象弱引用的唯一值,WeakSet
对象中的值同样不会重复,且只能保存对象的弱引用,同样由于是对于对象的弱引用,不会干扰 Js
的垃圾回收。
这里可能需要简单介绍下,谈弱引用,我们先来说强引用,之前我们说 JS 的垃圾回收机制是如果我们持有对一个对象的引用,那么这个对象就不会被垃圾回收,这里的引用,指的就是 强引用
,而弱引用就是一个对象若只被弱引用所引用,则被认为是不可访问(或弱可访问)的,因此可能在任何时刻被回收。
不明白?来看例子就晓得了:
// obj是一个强引用,对象存于内存,可用
let obj = { id: 1 };
// 重写obj引用
obj = null;
// 对象从内存移除,回收 {id: 1} 对象
上面是一个简单的通过重写引用来清除对象引用,使其可回收。
再看下面这个:
let obj = { id: 1 };
let user = { info: obj };
let set = new Set([obj]);
let map = new Map([[obj, "hahaha"]]);
// 重写obj
obj = null;
console.log(user.info); // {id: 1}
console.log(set);
console.log(map);
此例我们重写 obj
以后,{id: 1}
依然会存在于内存中,因为 user
对象以及后面的 set/map
都强引用了它,Set/Map、对象、数组对象等都是强引用,所以我们仍然可以获取到 {id: 1}
,我们想要清除那就只能重写所有引用将其置空了。
接下来我们看 WeakMap
以及 WeakSet
:
let obj = { id: 1 };
let weakSet = new WeakSet([obj]);
let weakMap = new WeakMap([[obj, "hahaha"]]);
// 重写obj引用
obj = null;
// {id: 1} 将在下一次 GC 中从内存中删除
如上所示,使用了 WeakMap
以及 WeakSet
即为弱引用,将 obj
引用置为 null
后,对象 {id: 1}
将在下一次 GC 中被清理出内存。
未清理的 Console 输出
写代码的过程中,肯定避免不了一些输出,在一些小团队中可能项目上线也不清理这些 console
,殊不知这些 console
也是隐患,同时也是容易被忽略的,我们之所以在控制台能看到数据输出,是因为浏览器保存了我们输出对象的信息数据引用,也正是因此未清理的 console
如果输出了对象也会造成内存泄漏。
所以,开发环境下我们可以使用控制台输出来便于我们调试,但是在生产环境下,一定要及时清理掉输出。
可能有同学会觉得不可思议,甚至不相信,这里我们留一个例子,大家看完文章刚好可以自己测试一下,可以先保存这段代码哦!(如何测试看完下文就明白啦)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>test</title>
</head>
<body>
<button id="click">click</button>
<script>
!(function() {
function Test() {
this.init();
}
Test.prototype.init = function() {
this.a = new Array(10000).fill("isboyjc");
console.log(this);
};
document.querySelector("#click").onclick = function() {
new Test();
};
})();
</script>
</body>
</html>
介绍一下 http 缓存
缓存作为前端性能优化的有效方式之一,对于前端开发工程师来说,相对熟悉的就是 HTTP 缓存。
一、什么是 HTTP 缓存?
HTTP 缓存指的是:当客户端向服务器请求资源时,会先抵达浏览器缓存,如果浏览器缓存中有“要请求的资源”的副本,则直接从浏览器缓存中获取,而不是从目标服务器中获取这个资源。
虽然 HTTP 缓存不是必须的,但重用缓存的资源通常是必要的。然而常见的 HTTP 缓存只能存储 GET 响应,对于其他类型的响应则无能为力。
二、为什么要用 HTTP 缓存?
- 减少冗余的数据传输
- 缓解服务器压力,提高网站性能
- 加快了客户端加载网页及资源的速度
三、哪些资源可以被缓存?
一般包括 html 页面和其他静态资源(js、img、css 等)
四、HTTP 缓存分类
- 强缓存
-
Expires(HTTP 1.0)
- Response Headers 中
- 控制缓存过期时间
- 已被 Cache-Control 代替
- 值为服务器端的绝对时间
-
Cache-Control(HTTP 1.1)
-
Response Headers 中
-
控制强制缓存的逻辑
-
例如 Cache-Control: max-age=31536000(单位是秒)
-
值
- max-age 缓存过期时间(相对时间)
- no-cache 不用本地强制缓存,需要进行协商缓存,发送请求到服务器确认是否使用缓存。
- no-store 不用本地强制缓存,也不用服务端缓存措施,每一次都要重新请求数据。
- private 只能被终端用户缓存,比如:电脑 浏览器 手机等
- public 允许被任何中间人(比如中间代理、CDN 等)缓存
-
- 协商缓存(对比缓存)
-
服务器端缓存策略(服务端判断资源能不能用缓存的内容)
-
服务器判断缓存资源是否和服务端资源一样(一致返回 304,否则返回 200 和最新的资源)
-
在 Response Headers 中,有两种
- Last-Modified 资源的最后修改时间(只能精确到秒级)
- Etag 资源的唯一标示(优先使用)
五、缓存执行流程
浏览器在加载资源时,会先根据本地缓存资源的 header(expires 和 cahe-control) 中的信息判断是否命中强缓存,如果命中则直接使用缓存中的资源不会再向服务器发送请求。 当强缓存没有命中的时候,浏览器会发送一个请求到服务器,服务器根据 header(If-Modified-Since 和 If-None-Match)中的信息来判断是否命中缓存。如果命中,则返回 304 ,告诉浏览器资源未更新,可使用本地的缓存。
六、刷新操作方式,对缓存的影响
三种刷新操作
- 正常操作:地址栏输入 url,跳转链接,前进后退等
- 手动刷新:F5,点击刷新按钮,右击菜单刷新
- 强制刷新:ctrl + F5(Mac:shift + command + r)
不同刷新操作,不同的缓存策略
- 正常操作:强制缓存有效,协商缓存有效
- 手动刷新:强制缓存无效,协商缓存有效
- 强制刷新:强制缓存失效,协商缓存失效
浏览器加载页面的过程
1.解析 HTML,生成 DOM 树(DOM) 2.解析 CSS,生成 CSSOM 树(CSSOM) 3.将 DOM 和 CSSOM 合并,生成渲染树(Render-Tree) 4.计算渲染树的布局(Layout) 5.将布局渲染到屏幕上(Paint)
DOM 事件模型:事件捕获和事件冒泡的使用场景
题目
有如下的 HTML 文档结构:
<div id="parent">
<child id="child" class="child">
点我
</child>
</div>
第一次执行如下 JavaScript 代码:
document.getElementById("parent").addEventListener("click", function() {
alert(`parent 事件触发,` + this.id);
});
document.getElementById("child").addEventListener("click", function() {
alert(`child 事件触发,` + this.id);
});
第二次执行另一套 JavaScript 代码:
document.getElementById("parent").addEventListener("click", function(e) {
alert(`parent 事件触发,` + e.target.id);
});
document.getElementById("child").addEventListener("click", function(e) {
alert(`child 事件触发,` + e.target.id);
});
第三次再执行一套:
document.getElementById("parent").addEventListener(
"click",
function(e) {
alert(`parent 事件触发,` + e.target.id);
},
true
);
document.getElementById("child").addEventListener(
"click",
function(e) {
alert(`child 事件触发,` + e.target.id);
},
true
);
问题如下:点击 id 为 child 的 div 后,这三份 JavaScript 代码的执行结果分别是什么?
解题
- 第一次结果为:先弹出“child 事件触发,child”,再弹出“parent 事件触发,parent”。
- 第二次结果为:先弹出“child 事件触发,child”,再弹出“parent 事件触发,child”。
- 第三次结果为:先弹出“parent 事件触发,child”,再弹出“child 事件触发,child”。
DOM 元素事件执行顺序
HTML 页面上 DOM 元素的事件执行顺序一般有三个阶段:
- 事件捕获
- 事件触发
- 事件冒泡
借用网上的一张图来说明一下这个过程:
dom 标准事件流的触发的先后顺序为:先捕获再冒泡,即当触发 dom 事件时,会先进行事件捕获,捕获到事件源之后通过事件传播进行事件冒泡。
而在浏览器中默认执行的是事件冒泡,即我们一般观察不到事件捕获阶段,比如 onclick 等事件。
如果想要观察到事件的捕获阶段,那我们就需要借助 addEventListener 接口来实现。
关于 addEventListener()
addEventListener 的基本语法为:
target.addEventListener(type, listener, useCapture);
- type 事件类型。
- listener 事件触发实际执行的匿名函数。
- userCapture 可选,类型为 Boolean,意思是是否执行事件捕获阶段。
关于 listener 中的 this 和 target
- 当一个 EventListener 在 EventTarget 正在处理事件的时候被注册到 EventTarget 上,它不会被立即触发,但可能在事件流后面的事件触发阶段被触发,例如可能在捕获阶段添加,然后在冒泡阶段被触发。
- 通常来说 this 的值是触发事件的元素的引用,当使用 addEventListener() 为一个元素注册事件的时候,句柄里的 this 值是该元素的引用。其与传递给句柄的 event 参数的 currentTarget 属性的值一样。
从输入 url 到页面展示的过程
1、输入地址
2、浏览器查找域名的 IP 地址
3、浏览器向 web 服务器发送一个 HTTP 请求
4、服务器的永久重定向响应
6、服务器处理请求
7、服务器返回一个 HTTP 响应
8、浏览器显示 HTML
9、浏览器发送请求获取嵌入在 HTML 中的资源(如图片、音频、视频、CSS、JS 等等)
1、输入地址
当我们开始在浏览器中输入网址的时候,浏览器其实就已经在智能的匹配可能得 url 了,他会从历史记录,书签等地方,找到已经输入的字符串可能对应的 url,然后给出智能提示,让你可以补全 url 地址。对于 google 的 chrome 的浏览器,他甚至会直接从缓存中把网页展示出来,就是说,你还没有按下 enter,页面就出来了。
2、浏览器查找域名的 IP 地址
1、请求一旦发起,浏览器首先要做的事情就是解析这个域名,一般来说,浏览器会首先查看本地硬盘的 hosts 文件,看看其中有没有和这个域名对应的规则,如果有的话就直接使用 hosts 文件里面的 ip 地址。
2、如果在本地的 hosts 文件没有能够找到对应的 ip 地址,浏览器会发出一个 DNS 请求到本地 DNS 服务器 。本地 DNS 服务器一般都是你的网络接入服务器商提供,比如中国电信,中国移动。
3、查询你输入的网址的 DNS 请求到达本地 DNS 服务器之后,本地 DNS 服务器会首先查询它的缓存记录,如果缓存中有此条记录,就可以直接返回结果,此过程是递归的方式进行查询。如果没有,本地 DNS 服务器还要向 DNS 根服务器进行查询。
4、根 DNS 服务器没有记录具体的域名和 IP 地址的对应关系,而是告诉本地 DNS 服务器,你可以到域服务器上去继续查询,并给出域服务器的地址。这种过程是迭代的过程。
5、本地 DNS 服务器继续向域服务器发出请求,在这个例子中,请求的对象是.com 域服务器。.com 域服务器收到请求之后,也不会直接返回域名和 IP 地址的对应关系,而是告诉本地 DNS 服务器,你的域名的解析服务器的地址。
6、最后,本地 DNS 服务器向域名的解析服务器发出请求,这时就能收到一个域名和 IP 地址对应关系,本地 DNS 服务器不仅要把 IP 地址返回给用户电脑,还要把这个对应关系保存在缓存中,以备下次别的用户查询时,可以直接返回结果,加快网络访问。
—知识扩展—
1. 什么是 DNS?
DNS(Domain Name System,域名系统),因特网上作为域名和 IP 地址相互映射的一个分布式数据库,能够使用户更方便的访问互联网,而不用去记住能够被机器直接读取的 IP 数串。通过主机名,最终得到该主机名对应的 IP 地址的过程叫做域名解析(或主机名解析)。 通俗的讲,我们更习惯于记住一个网站的名字,比如 www.baidu.com,而不是记住它的 ip 地址,比如:167.23.10.2。而计算机更擅长记住网站的 ip 地址,而不是像www.baidu.com等链接。因为,DNS 就相当于一个电话本,比如你要找www.baidu.com这个域名,那我翻一翻我的电话本,我就知道,哦,它的电话(ip)是 167.23.10.2。
2. DNS 查询的两种方式:递归查询和迭代查
1、递归解析
当局部 DNS 服务器自己不能回答客户机的 DNS 查询时,它就需要向其他 DNS 服务器进行查询。此时有两种方式,如图所示的是递归方式。局部 DNS 服务器自己负责向其他 DNS 服务器进行查询,一般是先向该域名的根域服务器查询,再由根域名服务器一级级向下查询。最后得到的查询结果返回给局部 DNS 服务器,再由局部 DNS 服务器返回给客户端。
2、迭代解析
当局部 DNS 服务器自己不能回答客户机的 DNS 查询时,也可以通过迭代查询的方式进行解析,如图所示。局部 DNS 服务器不是自己向其他 DNS 服务器进行查询,而是把能解析该域名的其他 DNS 服务器的 IP 地址返回给客户端 DNS 程序,客户端 DNS 程序再继续向这些 DNS 服务器进行查询,直到得到查询结果为止。也就是说,迭代解析只是帮你找到相关的服务器而已,而不会帮你去查。比如说:baidu.com的服务器 ip 地址在 192.168.4.5 这里。
3. DNS 域名称空间的组织方式
我们在前面有说到根 DNS 服务器,域 DNS 服务器,这些都是 DNS 域名称空间的组织方式。按其功能命名空间中用来描述 DNS 域名称的五个类别的介绍详见下表中,以及与每个名称类型的示例
4. DNS 负载均衡
当一个网站有足够多的用户的时候,假如每次请求的资源都位于同一台机器上面,那么这台机器随时可能会蹦掉。处理办法就是用 DNS 负载均衡技术,它的原理是在 DNS 服务器中为同一个主机名配置多个 IP 地址,在应答 DNS 查询时,DNS 服务器对每个查询将以 DNS 文件中主机记录的 IP 地址按顺序返回不同的解析结果,将客户端的访问引导到不同的机器上去,使得不同的客户端访问不同的服务器,从而达到负载均衡的目的。例如可以根据每台机器的负载量,该机器离用户地理位置的距离等等。
3、浏览器向 web 服务器发送一个 HTTP 请求
拿到域名对应的 IP 地址之后,浏览器会以一个随机端口(1024<端口<65535)向服务器的 WEB 程序(常用的有 httpd,nginx 等)80 端口发起 TCP 的连接请求。这个连接请求到达服务器端后(这中间通过各种路由设备,局域网内除外),进入到网卡,然后是进入到内核的 TCP/IP 协议栈(用于识别该连接请求,解封包,一层一层的剥开),还有可能要经过 Netfilter 防火墙(属于内核的模块)的过滤,最终到达 WEB 程序,最终建立了 TCP/IP 的连接。
建立了 TCP 连接之后,发起一个 http 请求。一个典型的 http request header 一般需要包括请求的方法,例如 GET 或者 POST 等,不常用的还有 PUT 和 DELETE 、HEAD、OPTION 以及 TRACE 方法,一般的浏览器只能发起 GET 或者 POST 请求。
客户端向服务器发起 http 请求的时候,会有一些请求信息,请求信息包含三个部分:
- 请求方法 URI 协议/版本
- 请求头(Request Header)
- 请求正文
下面是一个完整的 HTTP 请求例子:
GET/sample.jspHTTP/1.1
Accept:image/gif.image/jpeg,*/*
Accept-Language:zh-cn
Connection:Keep-Alive
Host:localhost
User-Agent:Mozila/4.0(compatible;MSIE5.01;Window NT5.0)
Accept-Encoding:gzip,deflate
username=jinqiao&password=1234
注意:最后一个请求头之后是一个空行,发送回车符和换行符,通知服务器以下不再有请求头。
4、服务器的永久重定向响应
服务器给浏览器响应一个 301 永久重定向响应,这样浏览器就会访问http://www.google.com/
而非http://google.com/
。
为什么服务器一定要重定向而不是直接发送用户想看的网页内容呢?其中一个原因跟搜索引擎排名有关。如果一个页面有两个地址,就像www.yy.com/和yy.com/,搜索引擎会认为它们是两个网站,结果造成每个搜索链接都减少从而降低排名。而搜索引擎知道 301 永久重定向是什么意思,这样就会把访问带 www 的和不带 www 的地址归到同一个网站排名下。还有就是用不同的地址会造成缓存友好性变差,当一个页面有好几个名字时,它可能会在缓存里出现好几次。
—-扩展知识—-
1. 301 和 302 的区别
301 和 302 状态码都表示重定向,就是说浏览器在拿到服务器返回的这个状态码后会自动跳转到一个新的 URL 地址,这个地址可以从响应的 Location 首部中获取(用户看到的效果就是他输入的地址 A 瞬间变成了另一个地址 B)——这是它们的共同点。
他们的不同在于。301 表示旧地址 A 的资源已经被永久地移除了(这个资源不可访问了),搜索引擎在抓取新内容的同时也将旧的网址交换为重定向之后的网址;
302 表示旧地址 A 的资源还在(仍然可以访问),这个重定向只是临时地从旧地址 A 跳转到地址 B,搜索引擎会抓取新的内容而保存旧的网址。SEO302 好于 301
2. 重定向原因
- 网站调整(如改变网页目录结构);
- 网页被移到一个新地址;
- 网页扩展名改变(如应用需要把.php 改成.Html 或.shtml)。
这种情况下,如果不做重定向,则用户收藏夹或搜索引擎数据库中旧地址只能让访问客户得到一个 404 页面错误信息,访问流量白白丧失;再者某些注册了多个域名的网站,也需要通过重定向让访问这些域名的用户自动跳转到主站点等。
3. 什么时候进行 301 或者 302 跳转呢?
当一个网站或者网页 24—48 小时内临时移动到一个新的位置,这时候就要进行 302 跳转,而使用 301 跳转的场景就是之前的网站因为某种原因需要移除掉,然后要到新的地址访问,是永久性的。
清晰明确而言:使用 301 跳转的大概场景如下:
- 域名到期不想续费(或者发现了更适合网站的域名),想换个域名。
- 在搜索引擎的搜索结果中出现了不带 www 的域名,而带 www 的域名却没有收录,这个时候可以用 301 重定向来告诉搜索引擎我们目标的域名是哪一个。
- 空间服务器不稳定,换空间的时候。
5、浏览器跟踪重定向地址
现在浏览器知道了 http://www.google.com/
才是要访问的正确地址,所以它会发送另一个 http 请求。
6、服务器处理请求
经过前面的重重步骤,我们终于将我们的 http 请求发送到了服务器这里,其实前面的重定向已经是到达服务器了,那么,服务器是如何处理我们的请求的呢?
后端从在固定的端口接收到 TCP 报文开始,它会对 TCP 连接进行处理,对 HTTP 协议进行解析,并按照报文格式进一步封装成 HTTP Request 对象,供上层使用。
一些大一点的网站会将你的请求到反向代理服务器中,因为当网站访问量非常大,网站越来越慢,一台服务器已经不够用了。于是将同一个应用部署在多台服务器上,将大量用户的请求分配给多台机器处理。
此时,客户端不是直接通过 HTTP 协议访问某网站应用服务器,而是先请求到 Nginx,Nginx 再请求应用服务器,然后将结果返回给客户端,这里 Nginx 的作用是反向代理服务器。同时也带来了一个好处,其中一台服务器万一挂了,只要还有其他服务器正常运行,就不会影响用户使用。
通过 Nginx 的反向代理,我们到达了 web 服务器,服务端脚本处理我们的请求,访问我们的数据库,获取需要获取的内容等等,当然,这个过程涉及很多后端脚本的复杂操作。由于对这一块不熟,所以这一块只能介绍这么多了。
—-扩展阅读—-
1. 什么是反向代理?
客户端本来可以直接通过 HTTP 协议访问某网站应用服务器,网站管理员可以在中间加上一个 Nginx,客户端请求 Nginx,Nginx 请求应用服务器,然后将结果返回给客户端,此时 Nginx 就是反向代理服务器。
7、服务器返回一个 HTTP 响应
经过前面的 6 个步骤,服务器收到了我们的请求,也处理我们的请求,到这一步,它会把它的处理结果返回,也就是返回一个 HTPP 响应。
HTTP 响应与 HTTP 请求相似,HTTP 响应也由 3 个部分构成,分别是:
- 状态行
- 响应头(Response Header)
- 响应正文
HTTP/1.1 200 OK
Date: Sat, 31 Dec 2005 23:59:59 GMT
Content-Type: text/html;charset=ISO-8859-1
Content-Length: 122
<html>
<head>
<title>http</title>
</head>
<body>
<!-- body goes here -->
</body>
</html>
**状态行:**状态行由协议版本、数字形式的状态代码、及相应的状态描述,各元素之间以空格分隔。
格式: HTTP-Version Status-Code Reason-Phrase CRLF
例如: HTTP/1.1 200 OK
协议版本: 是用 http1.0 还是其他版本
状态描述: 状态描述给出了关于状态代码的简短的文字描述。比如状态代码为 200 时的描述为 ok
状态码: 状态代码由三位数字组成,第一个数字定义了响应的类别,且有五种可能取值,如下:
1xx:信息性状态码,表示服务器已接收了客户端请求,客户端可继续发送请求。
- 100 Continue
- 101 Switching Protocols
- 2xx:成功状态码,表示服务器已成功接收到请求并进行处理。
200 OK 表示客户端请求成功
- 204 No Content 成功,但不返回任何实体的主体部分
- 206 Partial Content 成功执行了一个范围(Range)请求
3xx:重定向状态码,表示服务器要求客户端重定向。
- 301 Moved Permanently 永久性重定向,响应报文的 Location 首部应该有该资源的新 URL
- 302 Found 临时性重定向,响应报文的 Location 首部给出的 URL 用来临时定位资源
- 303 See Other 请求的资源存在着另一个 URI,客户端应使用 GET 方法定向获取请求的资源
- 304 Not Modified 服务器内容没有更新,可以直接读取浏览器缓存
- 307 Temporary Redirect 临时重定向。与 302 Found 含义一样。302 禁止 POST 变换为 GET,但实际使用时并不一定,307 则更多浏览器可能会遵循这一标准,但也依赖于浏览器具体实现
4xx:客户端错误状态码,表示客户端的请求有非法内容。
- 400 Bad Request 表示客户端请求有语法错误,不能被服务器所理解
- 401 Unauthonzed 表示请求未经授权,该状态代码必须与 WWW-Authenticate 报头域一起使用
- 403 Forbidden 表示服务器收到请求,但是拒绝提供服务,通常会在响应正文中给出不提供服务的原因
- 404 Not Found 请求的资源不存在,例如,输入了错误的 URL
5xx:服务器错误状态码,表示服务器未能正常处理客户端的请求而出现意外错误。
- 500 Internel Server Error 表示服务器发生不可预期的错误,导致无法完成客户端的请求
- 503 Service Unavailable 表示服务器当前不能够处理客户端的请求,在一段时间之后,服务器可能会恢复正常
**响应头:**响应头部:由关键字/值对组成,每行一对,关键字和值用英文冒号”:”分隔,典型的响应头有:
**响应正文:**包含着我们需要的一些具体信息,比如 cookie,html,image,后端返回的请求数据等等。
8、浏览器显示 HTML
在浏览器没有完整接受全部 HTML 文档时,它就已经开始显示这个页面了,浏览器是如何把页面呈现在屏幕上的呢?不同浏览器可能解析的过程不太一样,这里我们只介绍 webkit 的渲染过程,下图对应的就是 WebKit 渲染的过程,这个过程包括:
解析html以构建dom树 -> 构建render树 -> 布局render树 -> 绘制render树
浏览器在解析 html 文件时,会”自上而下“加载,并在加载过程中进行解析渲染。在解析过程中,如果遇到请求外部资源时,如图片、外链的 CSS、iconfont 等,请求过程是异步的,并不会影响 html 文档进行加载。
解析过程中,浏览器首先会解析 HTML 文件构建 DOM 树,然后解析 CSS 文件构建渲染树,等到渲染树构建完成后,浏览器开始布局渲染树并将其绘制到屏幕上。这个过程比较复杂,涉及到两个概念: reflow(回流)和 repain(重绘)。
DOM 节点中的各个元素都是以盒模型的形式存在,这些都需要浏览器去计算其位置和大小等,这个过程称为 relow;当盒模型的位置,大小以及其他属性,如颜色,字体,等确定下来之后,浏览器便开始绘制内容,这个过程称为 repain。
页面在首次加载时必然会经历 reflow 和 repain。reflow 和 repain 过程是非常消耗性能的,尤其是在移动设备上,它会破坏用户体验,有时会造成页面卡顿。所以我们应该尽可能少的减少 reflow 和 repain。
当文档加载过程中遇到 js 文件,html 文档会挂起渲染(加载解析渲染同步)的线程,不仅要等待文档中 js 文件加载完毕,还要等待解析执行完毕,才可以恢复 html 文档的渲染线程。因为 JS 有可能会修改 DOM,最为经典的 document.write,这意味着,在 JS 执行完成前,后续所有资源的下载可能是没有必要的,这是 js 阻塞后续资源下载的根本原因。所以我明平时的代码中,js 是放在 html 文档末尾的。
JS 的解析是由浏览器中的 JS 解析引擎完成的,比如谷歌的是 V8。JS 是单线程运行,也就是说,在同一个时间内只能做一件事,所有的任务都需要排队,前一个任务结束,后一个任务才能开始。但是又存在某些任务比较耗时,如 IO 读写等,所以需要一种机制可以先执行排在后面的任务,这就是:同步任务(synchronous)和异步任务(asynchronous)。
JS 的执行机制就可以看做是一个主线程加上一个任务队列(task queue) 。同步任务就是放在主线程上执行的任务,异步任务是放在任务队列中的任务。所有的同步任务在主线程上执行,形成一个执行栈;异步任务有了运行结果就会在任务队列中放置一个事件;脚本运行时先依次运行执行栈,然后会从任务队列里提取事件,运行任务队列中的任务,这个过程是不断重复的,所以又叫做事件循环(Event loop)。
9、浏览器发送请求获取嵌入在 HTML 中的资源(如图片、音频、视频、CSS、JS 等等)
其实这个步骤可以并列在步骤 8 中,在浏览器显示 HTML 时,它会注意到需要获取其他地址内容的标签。这时,浏览器会发送一个获取请求来重新获得这些文件。比如我要获取外图片,CSS,JS 文件等.
描述一下同源策略、跨域及其解决方案
web 安全(xss/csrf)
一、XSS(Cross-Site Scripting) 跨站脚本攻击
原理:恶意攻击者往 Web 页面里插入恶意可执行网页脚本代码,当用户浏览该页之时,嵌入其中 Web 里面的脚本代码会被执行,从而可以达到攻击者盗取用户信息或其他侵犯用户安全隐私的目的。
1.非持久型 XSS(反射型 XSS )
- Web 页面渲染的所有内容或者渲染的数据都必须来自于服务端。
- 2. 尽量不要从 URL,document.referrer,document.forms 等这种 DOM API 中获取数据直接渲染
- 3. 尽量不要使用 eval, new Function(),document.write(),document.writeln(),window.setInterval(),window.setTimeout(),innerHTML,document.createElement() 等可执行字符串的方法。
- 4. 过滤不必要的HTML标签,例如:iframe alt script和特殊字符。过滤一些事件onClcik onfocus。
- 5. 如果做不到以上几点,也必须对涉及 DOM 渲染的方法传入的字符串参数做 escape 转义
2.持久型 XSS(存储型 XSS)
原理:持久型 XSS 漏洞,一般存在于 Form 表单提交等交互功能,如文章留言,提交文本信息等,黑客利用的 XSS 漏洞,将内容经正常功能提交进入数据库持久保存,当前端页面获得后端从数据库中读出的注入代码时,恰好将其渲染执行
防御策略
1、CSP:CSP 本质上就是建立白名单,开发者明确告诉浏览器哪些外部资源可以加载和执行。我们只需要配置规则,如何拦截是由浏览器自己实现的。我们可以通过这种方式来尽量减少 XSS 攻击。
设置 HTTP Header 中的 Content-Security-Policy(原文链接)
- default-src : 定义针对所有类型(js/image/css/font/ajax/iframe/多媒体等)资源的默认加载策略,如果某类型资源没有单独定义策略,就使用默认的。
- script-src : 定义针对 JavaScript 的加载策略。
- style-src : 定义针对样式的加载策略。
- img-src : 定义针对图片的加载策略。
- font-src : 定义针对字体的加载策略。
CSP 指令值
name | 含义 |
---|---|
‘*’ | 允许加载任何内容 |
‘none‘ | 不允许加载任何内容 |
‘self‘ | 允许加载相同源的内容 |
www.a.com | 允许加载指定域名的资源 |
*.a.com | 允许加载 a.com 任何子域名的资源 |
a.com | 允许加载 a.com 的 https 资源 |
https: | 允许加载 https 资源 |
data: | 允许加载 data: 协议,例如:base64 编码的图片 |
‘unsafe-inline‘ | 允许加载 inline 资源,例如 style 属性、onclick、inline js、inline css 等 |
‘unsafe-eval‘ | 允许加载动态 js 代码,例如 eval() |
更多详情请查看官方文档 |
设置 meta 标签的方式
<meta http-equiv="content-security-policy" content="default-src self">
2、转义字符 (可以采用 htmlencode)
function escape(str) {
str = str.replace(/&/g, "&");
str = str.replace(/</g, "<");
str = str.replace(/>/g, ">");
str = str.replace(/"/g, "&quto;");
str = str.replace(/'/g, "'");
str = str.replace(/`/g, "`");
str = str.replace(///g, "/");
return str;
}
3、设置白名单或者黑名单
显示富文本建议采用白名单过滤,因为黑名单需要过滤的标签太多
const xss = require("xss");
let html = xss('<h1 id="title">XSS Demo</h1><script>alert("xss");</script>');
// -> <h1>XSS Demo</h1><script>alert("xss");</script>
console.log(html);
4、HttpOnly (最有效的防御手段)
禁止通过 document.cookie 的方式获取 cookies
二、CSRF 跨站点伪造
原理: 诱导用户打开黑客的网站,在黑客的网站中,利用用户登录状态发起跨站点请求。
防御策略
1、SameSite( Chrome 51 开始支持)
可以对 Cookie 设置 SameSite 属性。该属性表示 Cookie 不随着跨域请求发送,可以很大程度减少 CSRF 的攻击,但是该属性目前并不是所有浏览器都兼容。
- Strict:所有从当前域发送出来的非同域请求都不会带上 cookie
- Lax:就是在 GET 方式提交表单时会携带 cookie,post、iframe/img 等标签加载时不会携带 cookie。
- None:关闭 SameSite,不过,前提是必须同时设置 Secure 属性(Cookie 只能通过 HTTPS 协议发送),否则无效。
Set-Cookie: CookieName=CookieValue; SameSite=Strict;
//这个规则过于严格,可能造成非常不好的用户体验。比如,当前网页有一个 GitHub 链接,用户点击跳转就不会带有 GitHub 的 Cookie,跳转过去总是未登陆状态。
Set-Cookie: CookieName=CookieValue; SameSite=Lax;
//Lax规则稍稍放宽,大多数情况也是不发送第三方 Cookie,但是导航到目标网址的 Get 请求除外。
Set-Cookie: widget_session=abc123; SameSite=None; Secure
2.Referer Check(referer 可以伪造,Referer 记录了请求来源的地址,Origin 只包含了域名信息,并没有具体的 URL)
HTTP Referer 是 header 的一部分,当浏览器向 web 服务器发送请求时,一般会带上 Referer 信息告诉服务器是从哪个页面链接过来的,服务器籍此可以获得一些信息用于处理。可以通过检查请求的来源来防御 CSRF 攻击。正常请求的 referer 具有一定规律,如在提交表单的 referer 必定是在该页面发起的请求。所以通过检查 http 包头 referer 的值是不是这个页面,来判断是不是 CSRF 攻击。
3. Token(前比较完善的解决方案)
即发送请求时在 HTTP 请求中以参数的形式加入一个随机产生的 token,并在服务器建立一个拦截器来验证这个 token。服务器读取浏览器当前域 cookie 中这个 token 值,会进行校验该请求当中的 token 和 cookie 当中的 token 值是否都存在且相等,才认为这是合法的请求。否则认为这次请求是违法的,拒绝该次服务。
JavaScript 的存储
1. cookie
cookie
是客户端的解决方案,是一种网络服务器存储在计算机或移动设备上的纯文本文件,是服务器发送到 Web 浏览器上的一小块数据。一般大小限制在 4kb 以内。cookie
是一个在服务器和客户端之间来回传送文本值的内置机制,服务器可以根据cookie
追踪用户在不同页面的访问信息。
当接收到客户端发出的 HTTP 请求时,服务器可以发送带有响应的Set-Cookie
标头,然后将cookie
与 HTTP 请求头一起发送请求。
cookie 的用处
- 会话管理:用户账号密码
- 个性化:用户偏好设置
- 追踪:记录和分析用户行为
cookie 的特点
- 大小限制在 4KB 以内
- 都会消耗网络的带宽
- 不加密则不安全
- 使用 JS 操作 Cookie 比较复杂
2. Session
Session
是一种服务端解决方案,通过服务器来保持状态。Session
是服务器为了保存用户状态而创建的一个特殊对象。客户端请求服务端,服务端会为这次请示开辟一块内存空间。Session
弥补了 HTTP 的无状态特性。
Session 的创建过程
当浏览器第一次访问服务器时,服务器会创建一个 Session 对象(该对象有唯一的ID
,即SessionID
)。服务器会将SessionID
以cookie
的方式返回浏览器。
当浏览器再次访问服务器时,会将SessionID
发送过来,服务器依据sessionID
就可以找到对应的session
对象。
Session 的缺点
A 服务器存储了 Session,就是做了负载均衡后,假如一段时间内 A 的访问量激增,会转发到 B 进行访问,但是 B 服务器并没有存储 A 的 Session,会导致 Session 的失效。
Web Storage
如果你想要操作一个域名的会话存储,可以使用Window.sessionStorage
;如果想要操作一个域名的本地存储,可以使用Window.localStorage
。
1. localStorage
只读的 localStorage 允许访问一个Document
的对象Storage
,存储的数据将保存在浏览器会话中。
2. sessionStorage
sessionStorage 属性允许你访问一个,对应当前源的sessionStorage
对象。它localStorage
相似,不同之处在于localStorage
里面存储的数据没有过期时间设置,而存储在sessionStorage
里面的数据在页面会话结束时会被清除。
异同点
相同点
localStorage
和SessionStorage
一样都是用来存储客户端临时信息的对象。- 只能存储字符串对象
- 不同浏览器无法共享
localStorage
与SessionStroage
中的信息。相同浏览器的不同页面间(同源页面)可以共享相同的localStorage
,但不能共享sessionStorage
。
不同点
localStorage
的生命周期是永久,除非用户清除localStorage
信息。sessionStorage
的生命周期为当前窗口或标签页。一旦窗口永久关闭就结束。
HTTP VS HTTPS
什么是 Http,什么是 Https
Http (HTTP-Hypertext transfer protocol) 是一个简单的请求-响应协议,它通常运行在TCP之上。它指定了客户端可能发送给服务器什么样的消息以及得到什么样的响应。请求和响应消息的头以 ASCII 码形式给出;而消息内容则具有一个类似 MIME 的格式。这个简单模型是早期 Web 成功的有功之臣,因为它使开发和部署非常地直截了当。
Https(全称:Hyper Text Transfer Protocol over SecureSocket Layer),是以安全为目标的 HTTP 通道,在 HTTP 的基础上通过传输加密和身份认证保证了传输过程的安全性 。HTTPS 在 HTTP 的基础下加入SSL 层,HTTPS 的安全基础是 SSL,因此加密的详细内容就需要 SSL。 HTTPS 存在不同于 HTTP 的默认端口及一个加密/身份验证层(在 HTTP 与 TCP 之间)。这个系统提供了身份验证与加密通讯方法。它被广泛用于万维网上安全敏感的通讯,例如交易支付等方面 。
粗解 Http、Https 的区别
Http是超文本传输协议,数据明文传输,所以会被抓包导致信息泄露,有安全风险问题!Https 则是具有安全性的 SSL 加密传输协议。Http和Https使用的是完全不同的连接方式用的端口也不一样,前者是 80,后者是 443。Http的连接很简单,是无状态的。HTTPS协议是由SSL+HTTP协议构建的可进行加密传输、身份认证的网络协议。Https协议需要到CA付费申请证书
- HTTP 是应用层协议,同其他应用层协议一样,是为了实现某一类具体应用的协议,并由某一运行在用户空间的应用程序来实现其功能。HTTP 是一种协议规范,这种规范记录在文档上,为真正通过 HTTP 协议进行通信的 HTTP 的实现程序。
- HTTP 是一种无状态协议,即服务器不保留与客户交易时的任何状态。这就大大减轻了服务器记忆负担,从而保持较快的响应速度。
- HTTP 是一种面向对象的协议。允许传送任意类型的数据对象。它通过数据类型和长度来标识所传送的数据内容和大小,并允许对数据进行压缩传送。当用户在一个 HTML 文档中定义了一个超文本链后,浏览器将通过 TCP/IP 协议与指定的服务器建立连接。
上图是 Http 的工作流程图,Https 会在 Tcp 首部这一层前加上安全层(SSL/TSL)加解密 Http 报文
注:TLS 是 SSL 的升级替代版,具体发展历史可以参考传输层安全性协议。
Https 解决的问题
Https协议因为采用密文传输,要比Http协议安全。
1、信任主机的问题
采用https的 server 必须从CA申请一个用于证明服务器用途类型的证书. 该证书只有用于对应的 server 的时候,客户度才信任次主机。所以目前所有的银行系统网站,关键部分应用都是https的。客户通过信任该证书,从而信任了该主机。其实这样做效率很低,但是银行更侧重安全。这一点对我们没有任何意义,我们的 server 采用的证书不管自己 issue 还是从公众的地方 issue,客户端都是自己人,所以我们也就肯定信任该 server。
2、通讯过程中的数据的泄密和被窜改
(1)一般意义上的https,就是 server 有一个证书
- 主要目的是保证 server 就是他声称的 server,这个跟第一点一样。
- b) 服务端和客户端之间的所有通讯,都是加密的。
-
- i. 具体讲,是客户端产生一个对称的密钥,通过 server 的证书来交换密钥。一般意义上的握手过程。
-
- ii. 加下来所有的信息往来就都是加密的,第三方即使截获,也没有任何意义,因为他没有密钥,当然窜改也就没有什么意义了。
(2)少许对客户端有要求的情况下,会要求客户端也必须有一个证书
- 这里客户端证书,其实就类似表示个人信息的时候,除了用户名/密码,还有一个 CA 认证过的身份,应为个人证书一般来说上别人无法模拟的,所有这样能够更深的确认自己的身份。
- 目前少数个人银行的专业版是这种做法,具体证书可能是拿 U 盘作为一个备份的载体。
HTTPS 在 HTTP 与 TCP 层之间加入了 SSL/TLS 协议,可以很好的解决了上述的风险:
- 信息加密:交互信息无法被窃取,但你的号会因为「自身忘记」账号而没。
- 校验机制:无法篡改通信内容,篡改了就不能正常显示,但百度「竞价排名」依然可以搜索垃圾广告。
- 身份证书:证明淘宝是真的淘宝网,但你的钱还是会因为「剁手」而没。