涉及面试题:原始类型有哪几种?null 是对象嘛?
在 JS 中,存在着 6 种原始值,分别是:
boolean
null
undefined
number
string
symbol
首先原始类型存储的都是值,是没有函数可以调用的,比如 undefined.toString()
此时你肯定会有疑问,这不对呀,明明 '1'.toString()
是可以使用的。其实在这种情况下,'1'
已经不是原始类型了,而是被强制转换成了 String
类型也就是对象类型,所以可以调用 toString
函数。
除了会在必要的情况下强转类型以外,原始类型还有一些坑。
其中 JS 的 number
类型是浮点类型的,在使用中会遇到某些 Bug,比如 0.1 + 0.2 !== 0.3
,但是这一块的内容会在进阶部分讲到。string
类型是不可变的,无论你在 string
类型上调用何种方法,都不会对值有改变。
另外对于 null
来说,很多人会认为他是个对象类型,其实这是错误的。虽然 typeof null
会输出 object
,但是这只是 JS 存在的一个悠久 Bug。在 JS 的最初版本中使用的是 32 位系统,为了性能考虑使用低位存储变量的类型信息,000
开头代表是对象,然而 null
表示为全零,所以将它错误的判断为 object
。虽然现在的内部类型判断代码已经改变了,但是对于这个 Bug 却是一直流传下来。
涉及面试题:对象类型和原始类型的不同之处?函数参数是对象会发生什么问题?
在 JS 中,除了原始类型那么其他的都是对象类型了。对象类型和原始类型不同的是,原始类型存储的是值,对象类型存储的是地址(指针)。当你创建了一个对象类型的时候,计算机会在内存中帮我们开辟一个空间来存放值,但是我们需要找到这个空间,这个空间会拥有一个地址(指针)。
const a = []
对于常量 a
来说,假设内存地址(指针)为 #001
,那么在地址 #001
的位置存放了值 []
,常量 a
存放了地址(指针) #001
,再看以下代码
const a = []
const b = a
b.push(1)
当我们将变量赋值给另外一个变量时,复制的是原本变量的地址(指针),也就是说当前变量 b
存放的地址(指针)也是 #001
,当我们进行数据修改的时候,就会修改存放在地址(指针) #001
上的值,也就导致了两个变量的值都发生了改变。
接下来我们来看函数参数是对象的情况
function test(person) {
person.age = 26
person = {
name: 'yyy',
age: 30
}
return person
}
const p1 = {
name: 'yck',
age: 25
}
const p2 = test(p1)
console.log(p1) // -> {name: "yck", age: 26}
console.log(p2) // -> {name: "yyy", age: 30}
对于以上代码,你是否能正确的写出结果呢?接下来让我为你解析一番:
p1
的值也被修改了person
分配了一个对象时就出现了分歧所以最后 person
拥有了一个新的地址(指针),也就和 p1
没有任何关系了,导致了最终两个变量的值是不相同的。
涉及面试题:typeof 是否能正确判断类型?instanceof 能正确判断对象的原理是什么?
typeof
对于原始类型来说,除了 null
都可以显示正确的类型
typeof 1 // 'number'
typeof '1' // 'string'
typeof undefined // 'undefined'
typeof true // 'boolean'
typeof Symbol() // 'symbol'
typeof
对于对象来说,除了函数都会显示 object
,所以说 typeof
并不能准确判断变量到底是什么类型
typeof [] // 'object'
typeof {
} // 'object'
typeof console.log // 'function'
如果我们想判断一个对象的正确类型,这时候可以考虑使用 instanceof
,因为内部机制是通过原型链来判断
const Person = function() {
}
const p1 = new Person()
p1 instanceof Person // true
var str = 'hello world'
str instanceof String // false
var str1 = new String('hello world')
str1 instanceof String // true
对于原始类型来说,你想直接通过 instanceof
来判断类型是不行的,当然我们还是有办法让 instanceof
判断原始类型的
class PrimitiveString {
static [Symbol.hasInstance](x) {
return typeof x === 'string'
}
}
console.log('hello world' instanceof PrimitiveString) // true
你可能不知道 Symbol.hasInstance
是什么东西,其实就是一个能让我们自定义 instanceof
行为的东西,以上代码等同于 typeof 'hello world' === 'string'
,所以结果自然是 true
了。这其实也侧面反映了一个问题, instanceof
也不是百分之百可信的。
涉及面试题:该知识点常在笔试题中见到,熟悉了转换规则就不惧怕此类题目了。
首先我们要知道,在 JS 中类型转换只有三种情况,分别是:
在条件判断时,除了 undefined
, null
, false
, NaN
, ''
, 0
, -0
,其他所有值都转为 true
,包括所有对象。
对象在转换类型的时候,会调用内置的 [[ToPrimitive]]
函数,对于该函数来说,算法逻辑一般来说如下:
x.valueOf()
,如果转换为基础类型,就返回转换的值x.toString()
,如果转换为基础类型,就返回转换的值当然你也可以重写 Symbol.toPrimitive
,该方法在转原始类型时调用优先级最高。
let a = {
valueOf() {
return 0
},
toString() {
return '1'
},
[Symbol.toPrimitive]() {
return 2
}
}
1 + a // => 3
加法运算符不同于其他几个运算符,它有以下几个特点:
1 + '1' // '11'
true + true // 2
4 + [1,2,3] // "41,2,3"
如果你对于答案有疑问的话,请看解析:
1
转换为字符串,得到结果 '11'
true
转为数字 1
toString
转为字符串 1,2,3
,得到结果 41,2,3
另外对于加法还需要注意这个表达式 'a' + + 'b'
'a' + + 'b' // -> "aNaN"
因为 + 'b'
等于 NaN
,所以结果为 "aNaN"
,你可能也会在一些代码中看到过 + '1'
的形式来快速获取 number
类型。
那么对于除了加法的运算符来说,只要其中一方是数字,那么另一方就会被转为数字
4 * '3' // 12
4 * [] // 0
4 * [1, 2] // NaN
toPrimitive
转换对象unicode
字符索引来比较let a = {
valueOf() {
return 0
},
toString() {
return '1'
}
}
a > -1 // true
在以上代码中,因为 a
是对象,所以会通过 valueOf
转换为原始类型再比较值。
涉及面试题:如何正确判断 this?箭头函数的 this 是什么
this 的指向,是在调用函数时根据执行上下文所动态确定的。
•函数在浏览器全局环境中被简单调用(非显式/隐式绑定下),
严格模式下 this 绑定到 undefined,
否则绑定到全局对象 window/global;
•在执行函数时,如果函数中的this是被上一级的对象所调用,那么this指向就是上一级的对象; 否则指向全局环境。
•回调函数(除事件函数):
数组的所有遍历方法forEach,map,filter,reduce,every,some,flatMap,sort;这些方法均使用了回调函数,因此在所有使用回调函数的方法中,所有回调函数中this都被指window,
setInterval,setTimeOut 函数中的回调函数的因为作用域不明(不知道在哪里调用)就会指向window:
•事件函数中的this:指向侦听的对象
这里的特殊情况(事件函数)是因为:在函数执行时底层函数调用了call和apply,因此此时的回调函数中的this就会被指向绑定的侦听对象上;
•在定义对象属性时,obj对象还没有创建完成;this仍旧指向window
•一般构造函数 new 调用,绑定到新创建的对象上;
•一般由 call/apply/bind 方法显式调用,绑定到指定参数的对象上;
面试技巧:如果把这项放到最后说下个问题多半就是三者区别
•一般由上下文对象调用,绑定在该对象上;
•箭头函数中,根据外层上下文绑定的 this 决定 this 指向。
*1:全局环境下的 this*
函数在浏览器全局环境中被简单调用,ES5非严格模式下指向 window,ES6严格模式下指向 undefined。
function fn1( ) {
console.log(this) }
fn1( ) // window
function fn2( ) {
'use strict'
console.log(this)}
fn2( ) // undefined
在执行函数时,如果函数中的this是被上一级的对象所调用,那么this指向就是上一级的对象; 否则指向全局环境。
Var foo = {
bar:10,
fn:function( ) {
console.log(this)
console.log(this.bar)}
}
***\*var fn1 = foo.fn\****
fn1( ) // ***\*直接调用\****,this ***\*指向 window\****,window.bar => undefined
foo.fn( ) // 通过 foo 调用,this 指向 foo,foo.bar => 10
this.a=3;//this--->window
var b=5;
function fn(){
var b=10;
console.log(b+this.b);//this--->window
// 这种方法仅限于ES5,在ES6严格模式中this将会变成undefined
}
fn()
*2、回调函数中的this*
数组的所有遍历方法forEach,map,filter,reduce,every,some,flatMap,sort;这些方法均使用了回调函数,因此在所有使用回调函数的方法中,除了特殊的情况外(事件函数),其他所有回调函数中this都被指向window,setInterval,setTimeOut 函数中的回调函数的因为作用域不明(不知道在哪里调用)就会指向window:
var obj = {
fn: function ( ) {
// console.log(this);
***\*return\**** function ( ) {
console.log(this);//this--->window
}
}
}
var fn=obj.fn( );
fn( );//因为是在另外的作用域调用
//return中回调函数因为相当于var fn=obj.fn( )( );是在外部执行所以会指向window
这里的特殊情况(事件函数)是因为:在函数执行时底层函数调用了call和apply,因此此时的回调函数中的this就会被指向document;
*3、对象中的this*
在定义属性时,obj对象还没有创建完成;this仍旧指向window
箭头函数指向当前域外的内容
var c=100;
var obj={
c:10,
b:this.c,//this--->window 定义属性时,obj对象还没有创建完成,this仍旧指向window
a:function(){
// this;//this--->obj
// console.log(obj.c);
console.log(this.c);
},
d:()=>{
//this--->window
console.log(this);
}
}
// console.log(obj);
// obj.d();
var obj1=obj;
obj=null;
obj1.a();
这里a:function( )This.c}中为什么不用obj而用this呢:因为obj的地址值可能改变;就会找不到这个引用变量obj对象;
*4、ES6class中的this*
class Box{
a=3;
static abc(){
console.log(this);//Box 静态方法调用就是通过类名.方法
// Box.abc();
// 尽量不要在静态方法中使用this
}
constructor(_a){
this.a=_a }
play(){
// this就是实例化的对象
console.log(this.a);
// 这个方法是被谁执行的,this就是谁
}
let b=new Box(10);
b.play();
let c=new Box(5);
c.play();
使用静态方法:就指向box:相当于box.abc( )调用该方法;所以指向box
class Box{
a=3;
static abc(){
console.log(this);//Box 静态方法调用就是通过类名.方法
// Box.abc();
// 尽量不要在静态方法中使用this
}
*5、ES5中的this*
function Box(_a){
this.a=_a;
}
Box.prototype.play=function(){
console.log(this.a);//this就是实例化的对象
}
Box.prototype.a=5;
Box.abc=function(){
//this
// 这样的方法,等同于静态方法
}
var a=new Box(10);
a.play();
Box.abc();
*6、事件函数中的this:指向侦听的对象*
document.addEventListener("click",clickHandler);
function clickHandler(e){
console.log(this);//this--->e.currentTarget
}
*7、Call apply bind中的this:指向绑定的对象*
function fn(a,b){
this.a=a;//this如果使用了call,apply,bind,this将会被指向被绑定的对象
this.b=b;
return this;
}
var obj=fn.call({
},3,5)
var obj=fn.apply({
},[3,5])
var obj=fn.bind({
})(3,5);
****8、箭头函数中的this:****指向当前函数外的函数或内容与自带bind(this)的作用
var obj={
a:function(){
document.addEventListener("click",e=>{
console.log(this);//指向事件侦听外函数中的this/obj
});
var arr=[1,2,3];
arr.forEach(item=>{
console.log(this);//this-->obj
});
// 相当于自带bind(this)的作用
arr.forEach((function(item){
}).bind(this));
}
}
以上就是我们 JS 基础知识点的第一部分内容了。这一小节中涉及到的知识点在我们日常的开发中经常可以看到,并且很多容易出现的坑 也出自于这些知识点,相信认真读完的你一定会在日后的开发中少踩很多坑。
在这一章节中我们继续来了解 JS 的一些常考和容易混乱的基础知识点。
涉及面试题:== 和 === 有什么区别?
对于 ==
来说,如果对比双方的类型不一样的话,就会进行类型转换,这也就用到了我们上一章节讲的内容。
假如我们需要对比 x
和 y
是否相同,就会进行如下判断流程:
首先会判断两者类型是否相同。相同的话就是比大小了
类型不相同的话,那么就会进行类型转换
会先判断是否在对比 null
和 undefined
,是的话就会返回 true
判断两者类型是否为 string
和 number
,是的话就会将字符串转换为 number
1 == '1'
↓
1 == 1
判断其中一方是否为 boolean
,是的话就会把 boolean
转为 number
再进行判断
'1' == true
↓
'1' == 1
↓
1 == 1
判断其中一方是否为 object
且另一方为 string
、number
或者 symbol
,是的话就会把 object
转为原始类型再进行判断
'1' == {
name: 'yck' }
↓
'1' == '[object Object]'
思考题:看完了上面的步骤,对于 [] == ![] 你是否能正确写出答案呢?
我这里只将常用到的情况列举了,如果你想了解更多的内容可以参考 标准文档。
对于 ===
来说就简单多了,就是判断两者类型和值是否相同。
涉及面试题:什么是闭包?
要理解闭包,首先必须理解Javascript特殊的变量作用域。
变量的作用域无非就是两种:全局变量和局部变量。
Javascript语言的特殊之处,就在于函数内部可以直接读取全局变量。
而闭包却是能够读取其他函数内部变量的函数。所以,在本质上,闭包就是将函数内部和函数外部连接起来的一座桥梁。
闭包的特点
1.函数嵌套函数
2.函数内部可以引用外部的参数和变量
3.参数和变量不会被垃圾回收机制回收
因此闭包常会被用于
1可以储存一个可以长期驻扎在内存中的变量
2.避免全局变量的污染
3.保证私有成员的存在
那闭包又因为什么原因不被回收呢?
简单来说,js引擎的工作分两个阶段,
一个是语法检查阶段,
一个是运行阶段。而运行阶段又分预解析和执行两个阶段。
在预解析阶段,先会创建执行上下文,执行上下文又包括变量对象、变量对象的作用域链和this指向的创建 。
创建执行上下文后,会对变量对象的属性进行填充。
进入执行代码阶段,此时执行上下文有个Scope属性
该属性作为一个作用域链包含有该函数被定义时所有外层的变量对象的引用
js解析器逐行读取并执行代码时
当我们需要查询外部作用域的变量时,其实就是沿着作用域链,依次在这些变量对象里遍历标志符,直到最后的全局变量对象。
基于js的垃圾回收机制:在Javascript中,如果一个对象不再被引用,那么这个对象就会被GC回收。如果两个对象互相引用,而不再被第3者所引用,那么这两个互相引用的对象也会被回收。因为函数a被b引用,b又被a外的c引用,所以定义了闭包的函数虽然销毁了,但是其变量对象依然被绑定在函数上,只有仍被引用,变量会继续保存在内存中,这就是为什么函数a执行后不会被回收的原因。
变量对象VO:var声明的变量、function声明的函数,及当前函数的形参
作用域链:当前变量对象+所有父级作用域 [[scope]]
this值:在进入执行上下文后不再改变
PS:作用域链其实就是一个变量对象的链,函数的变量对象称之为active object,简称AO。函数创建后就有静态的[[scope]]属性,直到函数销毁)
创建执行上下文后,会对变量对象的属性进行填充。所谓属性,就是var、function声明的标志符及函数形参名,至于属性对应的值:变量值为undefined,函数值为函数定义,形参值为实参,没有传入实参则为undefined。
三、闭包的微观世界
如果要更加深入的了解闭包以及函数a和嵌套函数b的关系,我们需要引入另外几个概念:函数的执行环境(excution context)、活动对象(call object)、作用域(scope)、作用域链(scope chain)。以函数a从定义到执行的过程为例阐述这几个概念。
3 当定义函数a的时候,js解释器会将函数a的作用域链(scope chain)设置为定义a时a所在的“环境”,如果a是一个全局函数,则scope chain中只有window对象。
4 当执行函数a的时候,a会进入相应的执行环境(excution context)。
5 在创建执行环境的过程中,首先会为a添加一个scope属性,即a的作用域,其值就为第1步中的scope chain。即a.scope=a的作用域链。
6 然后执行环境会创建一个活动对象(call object)。活动对象也是一个拥有属性的对象,但它不具有原型而且不能通过JavaScript代码直接访问。创建完活动对象后,把活动对象添加到a的作用域链的最顶端。此时a的作用域链包含了两个对象:a的活动对象和window对象。
7 下一步是在活动对象上添加一个arguments属性,它保存着调用函数a时所传递的参数。
8 最后把所有函数a的形参和内部的函数b的引用也添加到a的活动对象上。在这一步中,完成了函数b的的定义,因此如同第3步,函数b的作用域链被设置为b所被定义的环境,即a的作用域。
到此,整个函数a从定义到执行的步骤就完成了。此时a返回函数b的引用给c,函数b的作用域链又包含了对函数a的活动对象的引用,也就是说b可以访问到a中定义的所有变量和函数。函数b被c引用,函数b又依赖函数a,因此函数a在返回后不会被GC回收。
当函数b执行的时候亦会像以上步骤一样。因此,执行时b的作用域链包含了3个对象:b的活动对象、a的活动对象和window对象,如下图所示:
如图所示,当在函数b中访问一个变量的时候,搜索顺序是:
9 先搜索自身的活动对象,如果存在则返回,如果不存在将继续搜索函数a的活动对象,依次查找,直到找到为止。
10 如果函数b存在prototype原型对象,则在查找完自身的活动对象后先查找自身的原型对象,再继续查找。这就是Javascript中的变量查找机制。
11 如果整个作用域链上都无法找到,则返回undefined。
小结,本段中提到了两个重要的词语:函数的定义与执行。文中提到函数的作用域是在定义函数时候就已经确定,而不是在执行的时候确定(参看步骤1和3)。用一段代码来说明这个问题:
<script>
function f(x) {
var g = function() {
alert(++x);
}
return g;
}
var h = f(1);
h(); // alert 2
h(); // alert 2
</script>
· 假设函数h的作用域是在执行alert(h())确定的,那么此时h的作用域链是:h的活动对象->alert的活动对象->window对象。这段代码中变量h指向了f中的那个匿名函数(由g返回)。
· 假设函数h的作用域是在定义时确定的,就是说h指向的那个匿名函数在定义的时候就已经确定了作用域。那么在执行的时候,h的作用域链为:h的活动对象->f的活动对象->window对象。
如果第一种假设成立,那输出值就是undefined;如果第二种假设成立,输出值则为1。
运行结果证明了第2个假设是正确的,说明函数的作用域确实是在定义这个函数的时候就已经确定了。
(转载请注明出处:http://www.felixwoo.com/archives/247)
四、闭包的应用场景
12 保护函数内的变量安全。以最开始的例子为例,函数a中i只有函数b才能访问,而无法通过其他途径访问到,因此保护了i的安全性。
13 在内存中维持一个变量。依然如前例,由于闭包,函数a中i的一直存在于内存中,因此每次执行c(),都会给i自加1。
14 通过保护变量的安全实现JS私有属性和私有方法(不能被外部访问)推荐阅读:http://javascript.crockford.com/private.html
<script>
function constructor() {
var this = this;
var membername = value;
function membername(...) {
...}
}
</script>
五、Javascript的垃圾回收机制
在Javascript中,如果一个对象不再被引用,那么这个对象就会被GC回收。如果两个对象互相引用,而不再被第3者所引用,那么这两个互相引用的对象也会被回收。因为函数a被b引用,b又被a外的c引用,这就是为什么函数a执行后不会被回收的原因。
在 JS 中,闭包存在的意义就是让我们可以间接访问函数内部的变量。
涉及面试题:如何理解原型?如何理解原型链?
1.每个对象都有__proto__属性
,该属性指向其构造函数的原型对象, __proto__
将对象和其原型对象连接起来组成原型链
2.在调用实例的方法和属性时,如果在实例对象上找不到,就会往原型对象上找
3.构造函数的prototype属性
也指向实例的原型对象
4.原型对象的constructor属性
指向构造函数。
说到继承,最容易想到的是ES6的extends
,当然如果只回答这个肯定不合格,我们要从函数和原型链的角度上实现继承,下面我们一步步地、递进地实现一个合格的继承
实现一个方法可以从而实现对父类的属性和方法的继承,解决代码冗余重复的问题
原型链继承的原理很简单,
直接让子类的原型对象指向父类实例,
Child.prototype=new Parent()
当子类实例找不到对应的属性和方法时,就会往它的原型对象,也就是父类实例上找,
从而实现对父类的属性和方法的继承
原型继承的缺点:
1.由于所有Child实例原型都指向同一个Parent实例, 因此对某个Child实例的父类引用类型变量修改会影响所有的Child实例
2.在创建子类实例时无法向父类构造传参, 即没有实现super()的功能
构造函数继承,即在子类的构造函数中执行父类的构造函数,并为其绑定子类的this,
让父类的构造函数把成员属性和方法都挂到子类的this上去;
在Child的构造函数中执行
Parent.apply(this, arguments);
这样既能避免实例之间共享一个原型实例,又能向父类构造方法传参;
js继承的方式继承不到父类原型上的属性和方法
构造函数继承的缺点:
1.继承不到父类原型上的属性和方法
既然原型链继承和构造函数继承各有互补的优缺点, 那么我们为什么不组合起来使用呢, 所以就有了综合二者的组合式继承
Child.prototype=new Parent()
Child.prototype.constructor=Child //相当于在Child的构造函数中给Parent绑定this
组合式继承的缺点:
1.每次创建子类实例都执行了两次构造函数(Parent.call()和new Parent()),虽然这并不影响对父类的继承,但子类创建实例时,原型中会存在两份相同的属性和方法,这并不优雅
为了解决组合式继承中构造函数被执行两次的问题,
我们将指向父类实例改为指向父类原型, 减去一次构造函数的执行
到这里我们就完成了ES5环境下的继承的实现,这种继承方式称为寄生组合式继承。
Function.prototype.extend = function (supClass) {
// 创建一个中间替代类,防止多次执行父类(超类)的构造函数
function F() {
}
// 将父类的原型赋值给这个中间替代类
F.prototype = supClass.prototype;
// 将原子类的原型保存
var proto = subClass.prototype;
// 将子类的原型设置为中间替代类的实例对象
subClass.prototype = new F();
// 将原子类的原型复制到子类原型上,合并超类原型和子类原型的属性方法
// Object.assign(subClass.prototype,proto);
var names = Object.getOwnPropertyNames(proto);
for (var i = 0; i < names.length; i++) {
var desc = Object.getOwnPropertyDescriptor(proto, names[i]);
Object.defineProperty(subClass.prototype, names[i], desc);
}
// 设置子类的构造函数时自身的构造函数,以防止因为设置原型而覆盖构造函数
subClass.prototype.constructor = subClass;
// 给子类的原型中添加一个属性,可以快捷的调用到父类的原型方法
subClass.prototype.superClass = supClass.prototype;
// 如果父类的原型构造函数指向的不是父类构造函数,重新指向
if (supClass.prototype.constructor !== supClass) {
supClass.prototype.constructor = supClass;
}
}
function Ball(_a) {
this.superClass.constructor.call(this, _a);
}
Ball.prototype.play = function () {
this.superClass.play.call(this);//执行超类的play方法
console.log("end");
}
Object.defineProperty(Ball.prototype, "d", {
value: 20
})
Ball.extend(Box);
var b=new Ball(10);
console.log(b);
是目前最成熟的继承方式,babel对ES6继承的转化也是使用了寄生组合式继承
我们回顾一下实现过程:
涉及面试题:什么是浅拷贝?如何实现浅拷贝?什么是深拷贝?如何实现深拷贝?
在上一章节中,我们了解了对象类型在赋值的过程中其实是复制了地址,从而会导致改变了一方其他也都被改变的情况。通常在开发中我们不希望出现这样的问题,我们可以使用浅拷贝来解决这个情况。
let a = {
age: 1
}
let b = a
a.age = 2
console.log(b.age) // 2
...
来实现浅拷贝和Object.assign({}, a)首先可以通过 Object.assign
来解决这个问题,很多人认为这个函数是用来深拷贝的。其实并不是,Object.assign
只会拷贝所有的属性值到新的对象中,如果属性值是对象的话,拷贝的是地址,所以并不是深拷贝。
let a = {
age: 1
}
let b = Object.assign({
}, a)
a.age = 2
console.log(b.age) // 1
另外我们还可以通过展开运算符 ...
来实现浅拷贝
let a = {
age: 1
}
let b = {
...a }
a.age = 2
console.log(b.age) // 1
通常浅拷贝就能解决大部分问题了,但是当我们遇到如下情况就可能需要使用到深拷贝了
let a = {
age: 1,
jobs: {
first: 'FE'
}
}
let b = {
...a }
a.jobs.first = 'native'
console.log(b.jobs.first) // native
浅拷贝只解决了第一层的问题,如果接下去的值中还有对象的话,那么就又回到最开始的话题了,两者享有相同的地址。要解决这个问题,我们就得使用深拷贝了。
这个问题通常可以通过 JSON.parse(JSON.stringify(object))
来解决。
let a = {
age: 1,
jobs: {
first: 'FE'
}
}
let b = JSON.parse(JSON.stringify(a))
a.jobs.first = 'native'
console.log(b.jobs.first) // FE
但是该方法也是有局限性的:
会忽略 undefined
会忽略 symbol
不能序列化函数
不能解决循环引用的对象
在遇到函数、 undefined
或者 symbol
的时候,该对象也不能正常的序列化
原型链如何处理
DOM 如何处理
Date
Reg
ES6类
null
boolen
array
string
number
实现一个深拷贝是很困难的,需要我们考虑好多种边界情况,比如原型链如何处理、DOM 如何处理等等,所以这里我们实现的深拷贝只是简易版,并且我其实更推荐使用 lodash 的深拷贝函数。
function deepClone(obj) {
function isObject(o) {
return (typeof o === 'object' || typeof o === 'function') && o !== null
}
if (!isObject(obj)) {
throw new Error('非对象')
}
let isArray = Array.isArray(obj)
let newObj = isArray ? [...obj] : {
...obj }
Reflect.ownKeys(newObj).forEach(key => {
newObj[key] = isObject(obj[key]) ? deepClone(obj[key]) : obj[key]
})
return newObj
}
let obj = {
a: [1, 2, 3],
b: {
c: 2,
d: 3
}
}
let newObj = deepClone(obj)
newObj.b.c = 1
console.log(obj.b.c) // 2
谢大师
class Box {
static ARG = ["a", "b"]
constructor(a1, b1) {
this.a = a1
this.b = b1
}
play() {
console.log(this.a1 + this.b1)
}
}
var obj = {
a:1,
b:"a",
c:false,
d:{
e:undefined,
f:null,
g:[1, 2, 3, 4, 5],
h:new Date(),
i:/^[a-z]{2,4}$/gi,
j:new Box(4, 5),
k:{
}
}
}
Object.defineProperties(obj.d.k, {
l:{
value:10 },
m:{
configurable:true,
writable:true,
value:20
},
n:{
enumerable:true,
value:function() {
console.log("aaaa")
}
},
o:{
value:new Image()
}
})
function cloneObject(target, source) {
var names = Object.getOwnPropertyNames(source)
for (let i = 0 i < names.length i++) {
var desc = Object.getOwnPropertyDescriptor(source, names[i])
if (typeof desc.value === "object" && desc.value !== null) {
var obj
if (desc.value instanceof HTMLElement) {
obj = document.createElement(desc.value.nodeName)
} else {
switch (desc.value.constructor) {
case Box:
obj = new desc.value.constructor(desc.value[Box.ARG[0]], desc.value[Box.ARG[1]])
break
case RegExp:
obj = new desc.value.constructor(desc.value.source,desc.value.flags)
break
default :
obj = new desc.value.constructor()
}
}
cloneObject(obj, desc.value)
Object.defineProperty(target, names[i], {
value:obj,
enumerable:desc.enumerable,
writable:desc.writable,
configurable:desc.configurable
})
} else {
Object.defineProperty(target, names[i], desc)
}
}
return target
}
var obj1 = cloneObject({
}, obj)
obj.d.k.m = 100
console.log(obj1)
如下:
创建一个新的对象;
将构造函数的 this 指向这个新对象;
为这个对象添加属性、方法等;
最终返回新对象;
本章节我们将来学习 ES6 部分的内容。
数据类型:
优点:
1、按顺序排列,不考虑元素类型,
2、下标与值对应,
3、紧密型结构,
4、数组可以根据某个值找到相邻数据
缺点:
1、删除和插入都会影响整个长度,和改变数组各个元素位置
2、数组可能有重复
3、速度慢 查询添加 删除都需要遍历数组;
4、字符串数组:长度限定
优点:
1、对象是松散型集合,不需要考虑相邻关系,
2、有键值对;查询 添加 删除快
3、可以有多重集合:键名不能是对象:是字符串
缺点:
1、属性没有关联;
2、按照添加顺序遍历(顺序无法改变)
3、如果需要查询属性时需要遍历
Map 对象保存键值对,并且能够记住键的原始插入顺序。任何值(对象或者原始值) 都可以作为一个键或一个值。
优点:
1、有键值对,需要有长度;
2、具有 Iterator接口可使用for of 可以遍历属性列表;可遍历数值列表;
3、有api增删改查速度快
1、set(key,value):添加元素
2、get(key):获取元素
3、size:获取map的成员数
4、has(key):判断是否是成员;只能查找键
5、clear():清除所有数据
6、遍历map:Set 和 Map 结构也原生具有 Iterator 接口,可以直接使用for…of循环。
for(let obj of maps){console.log(obj);}//遍历对象
for(let key of a.keys()){ console.log(key);}//遍历属性名
for(let value of a.values()){ console.log(value); }//遍历值
for(let item of a.entries()){ console.log(item); }//返回所有成员的遍历器
forEach遍历map//先遍历值在遍历属性
a.forEach((value,key,list)=>{console.log(v,k,list)})
缺点:
1、按照添加顺序遍历(顺序无法改变);
2、普通的map结构不能使用对象作为属性储存值;否则会该对象属性变为强引用类型
WeakMap 对象是一组键/值对的集合,其中的键是弱引用的。其键必须是对象,而值可以是任意的。
1、弱引用列表类型;可以使用对象作为属性储存值
2、长度是可变所以不可遍历;
3、将obj设置null;被维护的WeakMap列表中会自动清除它
Set 对象允许你存储任何类型的唯一值,无论是原始值或者是对象引用。
优点:1、插入,添加,删除速度快,无重复的值的列表(不能有键)
缺点:没有索引,不能用for循环;也不能用下标直接修改或获取;
WeakSet 对象是一些对象值的集合, 并且其中的每个对象值都只能出现一次。在WeakSet的集合中是唯一的
弱引用列表类型;长度是可变所以不可遍历;
将obj设置null;被维护的WeakSet列表中会自动清除它
与Set相比,WeakSet 只能是对象的集合,而不能是任何类型的任意值。
WeakSet持弱引用:集合中对象的引用为弱引用。 如果没有其他的对WeakSet中对象的引用,那么这些对象会被当成垃圾回收掉。 这也意味着WeakSet中没有存储当前对象的列表。 正因为这样,WeakSet 是不可枚举的。
Set:是一个不能有重复元素的集合(列表),重复添加无效
删除添加查找的速度高于数组,但无法找不着关系数组
Api:add(value)、delete(value)、has(value)、clear( )
新建set let a=new Set( )
add(value) 添加元素
//不允许重复的列表,删除添加查找的速度高于数组,但是无法寻找到关系数据
let a=new Set( )
a.add(2)
a.add(3)
a.add(5)
a.add(3)
console.log(a);//Set(3) {2, 3, 5}
delete(value):删除元素:
let a=new Set( );
a.add(2);
a.add(5);
a.add(3);
a.delete(2);//2是上边的值value
console.log(a);//Set(2) {5, 3}
has(value): 判断列表中是否是有该值
let a=new Set( );
a.add(2);
a.add(3);
a.add(5);
console.log(a.has(3));//true判断列表中是否含有该值
**clear( ):**清除所有数据
数组去重:
let arr=[1,2,3,1,2,3,1,2,3];
let sets=new Set(arr);
arr=Array.from(sets);
console.log(arr);
Array.from( )方法从一个类似数组或可迭代对象中创建
map
和对象类似;但对象没有长度,map有长度、删除添加查找的速度高于对象
Api:set(key,value)、get(key)、Size、has(value)、delete( )、clear( )
set(key,value): 添加元素
let map=new Map( );
map.set("name","xietian");
map.set("age",30);
map.set("sex","男");
get(key): 获取元素
Size: 获取map的成员数
let a=new Set([2,3,5,6,7,3,4,2,1,2,3,4,5]);
for(var i=0;i<a.size;i++){
}
console.log(a);//Set(7) {2, 3, 5, 6, 7,4,1}
For of, // 只能遍历Set和Map
let a=new Set([2,3,5,6,7,3,2,4,2,1,3,4,5]);
for(let value of a){
console.log(value);}//2,3,5,6,7,4,1
has(value): 判断是否是成员
// Map hashMap
let map=new Map();
map.set("name","xietian");
map.set("age",30);
map.set("sex","男");
console.log(map.has("age"));//true判断是否有当前属性
delete( )
map.delete("age");
console.log(map)//Map(2) {"name" => "xietian", "sex" => "男"}
clear( ): 清除所有数据;对象无法一次清空
map.clear( );
console.log(map);//Map(0) { }
const obj=new Map( );
obj.set( )
obj.clear( );
和对象类似;但对象没有长度,map有长度
遍历:
let map=new Map();
map.set("name","xietian");
map.set("age",30);
map.set("sex","男");
遍历对象
for(let value of map){
console.log(value)
}
遍历属性名
for(let value of map.keys( )){
console.log(value);//name age sex
}
遍历值
for(let value of map.values( )){
console.log(value);//xietian 30 男
}
返回所有成员的遍历器
For of遍历map
for(let value of map.entries( )){
console.log(value); }
forEach遍历map
map.forEach(function(value,key,map){
console.log(value,key)});
箭头函数 遍历map
map.forEach((value,key,list)=>{
console.log(value,key,list})
//xietian name 30 "age" 男 sex
//Map(3) {"name" => "xietian", "age" => 30, "sex" => "男"}
getter和setter:访问器属性:既有方法也有属性的特征:只存在IE6以上
Getters和Setters使你可以快速获取或设置一个对象的数据。
一个对象拥有两个方法,分别用于获取和设置某个值,
你可以用它来隐藏那些不想让外界直接访问的属性。一个对象内,每个变量只能有一个getter或setter。(因此value可以有一个getter和一个setter,但是value绝没有两个getters)
删除getter或setter的唯一方法是:delete object[name]。delete可以删除一些常见的属性,getters和setters。
1、对数据的访问限制:a.value是对value变量的getter方法调用,如果在getter方法实现中抛出异常,可以阻止对value变量的访问;
2、对dom变量进行监听:window.name是一个跨域非常好用的dom属性(大名鼎鼎,详见百度),如果覆盖window.name的setter实现则可以实现跨页面的内存异步通信;
3、自己发挥想象力,能做的事情好多滴;
// setter和getter 是访问器属性
var obj={
_num:0,//这里始终没有存储值// num 没有存储值,存储在this._num;
// set方法有且仅有,必须有一个参数,不使用return返回内容
//设置setter:
set num(value){
//存储用的内部属性名=value;
this._num=value//如果只有set,没有get,只可写不可读
//当设置这个属性后随之需要执行的方法
},
//设置getter:
// get方法不能有参数,并且必须使用return返回值
get num(){
// 如果没有set,只有get,表示该属性只读不可写
// 当获取这个属性时需要操作的内容(执行这个方法)
return this._num // return 内部存储的这个属性;
}
}
// console.log(obj);
// // obj.num()//没有这个方法
obj.num=10//这时候会调用set方法
console.log(obj.num)//0 //当num作为一种运算值使用时,调用get方法
注意setter和getter设置的属性一般是成对出现,对应的相应属性。
如果仅出现set,没有使用get,表示该属性只写,不能获取,如果仅出现get没有出现set,表示该属性只读,不可写值。
最后说明,setter和getter虽然很好用,但是目前ie6不支持,使用的时候要注意。
1)写法
function* getNums(i) {
yield i;
let s=i+10
yield s
yield s+10
}
let a2= getNums(10)
console.log(a2.next( ).value)//10
console.log(a2.next( ).value)//20
console.log(a2.next( ).value)//30
console.log(a2.next( ).value)//undefined
function* getSum(a,b){
a++
yield a
b--
yield b
let sum=a+b
yield sum
return sum
}
var sum=getSum(3,5)
var first=sum.next( )
while(!first.done){
console.log(first.value)//4-4 8
first=sum.next( )
}
2)yield是停止返回value的点,可控的,类似断点,将异步过程强制变同步阻塞过程。
3)next( ).value就是下一步的返回值直到yield返回
// 异步过程强制变为同步阻塞过程
function* setNums( ){
yield setTimeout(fn1,2000);
yield setTimeout(fn2,2000);
}
function fn1( ){
console.log("aaaa");
a.next( );
}
function fn2( ){
console.log("bbb");
}
var a=setNums( );
a.next( );
涉及面试题:什么是提升?什么是暂时性死区?var、let 及 const 区别?
那么最后我们总结下这小节的内容:
- 函数提升优先于变量提升,函数提升会把整个函数挪到作用域顶部,变量提升只会把声明挪到作用域顶部
var
存在提升,我们能在声明之前使用。let
、const
因为暂时性死区的原因,不能在声明前使用var
在全局作用域下声明变量会导致变量挂载在window
上,其他两者不会let
和const
作用基本一致,但是后者声明的变量不能再次赋值
1. 如何在ES5环境下实现let
对于这个问题,我们可以直接查看
babel
转换前后的结果,看一下在循环中通过let
定义的变量是如何解决变量提升的问题
babel在let定义的变量前加了道下划线,避免在块级作用域外访问到该变量,除了对变量名的转换,我们也可以通过自执行函数来模拟块级作用域
(function(){
for(var i = 0; i < 5; i ++){
console.log(i) // 0 1 2 3 4
}
})();
console.log(i) // Uncaught ReferenceError: i is not defined
2. 如何在ES5环境下实现const
实现const的关键在于Object.defineProperty()
这个API,这个API用于在一个对象上增加或修改属性。通过配置属性描述符,可以精确地控制属性行为。Object.defineProperty()
接收三个参数:
Object.defineProperty(obj, prop, desc)
参数 | 说明 |
---|---|
obj | 要在其上定义属性的对象 |
prop | 要定义或修改的属性的名称 |
descriptor | 将被定义或修改的属性描述符 |
属性描述符 | 说明 | 默认值 |
---|---|---|
value | 该属性对应的值。可以是任何有效的 JavaScript 值(数值,对象,函数等)。默认为 undefined | undefined |
get | 一个给属性提供 getter 的方法,如果没有 getter 则为 undefined | undefined |
set | 一个给属性提供 setter 的方法,如果没有 setter 则为 undefined。当属性值修改时,触发执行该方法 | undefined |
writable | 当且仅当该属性的writable为true时,value才能被赋值运算符改变。默认为 false | false |
enumerable | enumerable定义了对象的属性是否可以在 for…in 循环和 Object.keys() 中被枚举 | false |
Configurable | configurable特性表示对象的属性是否可以被删除,以及除value和writable特性外的其他特性是否可以被修改 | false |
对于const不可修改的特性,我们通过设置writable属性来实现
function _const(key, value) {
const desc = {
value, writable: false}
Object.defineProperty(window, key, desc)
}
_const('obj', {
a: 1}) //定义obj obj.b = 2
//可以正常给obj的属性赋值obj = {}
//抛出错误,提示对象read-only
涉及面试题:为什么要使用模块化?都有哪几种方式可以实现模块化,各有什么特点?
使用一个技术肯定是有原因的,那么使用模块化可以给我们带来以下好处
在早期,使用立即执行函数实现模块化是常见的手段,通过函数作用域解决了命名冲突、污染全局作用域的问题
(function(globalVariable){
globalVariable.test = function() {
}
// ... 声明各种变量、函数都不会污染全局作用域
})(globalVariable)
鉴于目前这两种实现方式已经很少见到,所以不再对具体特性细聊,只需要了解这两者是如何使用的。
// AMD
define(['./a', './b'], function(a, b) {
// 加载模块完毕可以使用
a.do()
b.do()
})
// CMD
define(function(require, exports, module) {
// 加载模块
// 可以把 require 写在函数体的任意地方实现延迟加载
var a = require('./a')
a.doSomething()
})
CommonJS 最早是 Node 在使用,目前也仍然广泛使用,比如在 Webpack 中你就能见到它,当然目前在 Node 中的模块管理已经和 CommonJS 有一些区别了。
// a.js
module.exports = {
a: 1
}
// or
exports.a = 1
// b.js
var module = require('./a.js')
module.a // -> log 1
因为 CommonJS 还是会使用到的,所以这里会对一些疑难点进行解析
先说 require
吧
var module = require('./a.js')
module.a
// 这里其实就是包装了一层立即执行函数,这样就不会污染全局变量了,
// 重要的是 module 这里,module 是 Node 独有的一个变量
module.exports = {
a: 1
}
// module 基本实现
var module = {
id: 'xxxx', // 我总得知道怎么去找到他吧
exports: {
} // exports 就是个空对象
}
// 这个是为什么 exports 和 module.exports 用法相似的原因
var exports = module.exports
var load = function (module) {
// 导出的东西
var a = 1
module.exports = a
return module.exports
};
// 然后当我 require 的时候去找到独特的
// id,然后将要使用的东西用立即执行函数包装下,over
另外虽然 exports
和 module.exports
用法相似,但是不能对 exports
直接赋值。因为 var exports = module.exports
这句代码表明了 exports
和 module.exports
享有相同地址,通过改变对象的属性值会对两者都起效,但是如果直接对 exports
赋值就会导致两者不再指向同一个内存地址,修改并不会对 module.exports
起效。
ES Module 是原生实现的模块化方案,与 CommonJS 有以下几个区别
require(${path}/xx.js)
,后者目前不支持,但是已有提案require/exports
来执行的// 引入模块 API
import XXX from './a.js'
import {
XXX } from './a.js'
// 导出模块 API
export function a() {
}
export default function() {
}
AMD依赖前置,也就是说依赖之前就写好了
ESmodule是静态的,加载的是一个接口
静态引入的好处:可以做代码的静态分析,webpack中的打包就是利用了静态依赖
AMD和commonJS都是动态的,可以实现动态加载,而且加载的是一个对象
// AMD 在Angular中就是非常好的体现
defined(['a','b'],function(a, b){
// 数组中放的是a模块和b模块
// 函数相当于一个c模块
})
涉及面试题:Proxy 可以实现什么功能?
Proxy
对象用于定义基本操作的自定义行为(如属性查找、赋值、枚举、函数调用等)。
target
: 要使用 Proxy 包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)。
handler
: 一个通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理 p 的行为。
如果你平时有关注 Vue 的进展的话,可能已经知道了在 Vue3.0 中将会通过 Proxy
来替换原本的 Object.defineProperty
来实现数据响应式。 Proxy 是 ES6 中新增的功能,它可以用来自定义对象中的操作。
let p = new Proxy(target, handler)
target
代表需要添加代理的对象,handler
用来自定义对象中的操作,比如可以用来自定义 set
或者 get
函数。
接下来我们通过 Proxy
来实现一个数据响应式
let onWatch = (obj, setBind, getLogger) => {
let handler = {
get(target, property, receiver) {
getLogger(target, property)
return Reflect.get(target, property, receiver)
},
set(target, property, value, receiver) {
setBind(value, property)
return Reflect.set(target, property, value)
}
}
return new Proxy(obj, handler)
}
let obj = {
a: 1 }
let p = onWatch(
obj,
(v, property) => {
console.log(`监听到属性${
property}改变为${
v}`)
},
(target, property) => {
console.log(`'${
property}' = ${
target[property]}`)
}
)
p.a = 2 // 监听到属性a改变
p.a // 'a' = 2
在上述代码中,我们通过自定义 set
和 get
函数的方式,在原本的逻辑中插入了我们的函数逻辑,实现了在对对象任何属性进行读写时发出通知。
当然这是简单版的响应式实现,如果需要实现一个 Vue 中的响应式,需要我们在 get
中收集依赖,在 set
派发更新,之所以 Vue3.0 要使用 Proxy
替换原本的 API 原因在于 Proxy
无需一层层递归为每个属性添加代理,一次即可完成以上操作,性能上更好,并且原本的实现有一些数据更新不能监听到,但是 Proxy
可以完美监听到任何方式的数据改变,唯一缺陷可能就是浏览器的兼容性不好了。
涉及面试题:map, filter, reduce 各自有什么作用?
map
作用是生成一个新数组,遍历原数组,将每个元素拿出来做一些变换然后放入到新的数组中。
[1, 2, 3].map(v => v + 1) // -> [2, 3, 4]
另外 map
的回调函数接受三个参数,分别是当前索引元素,索引,原数组
['1','2','3'].map(parseInt)
parseInt('1', 0) -> 1
parseInt('2', 1) -> NaN
parseInt('3', 2) -> NaN
filter
的作用也是生成一个新数组,在遍历数组的时候将返回值为 true
的元素放入新数组,我们可以利用这个函数删除一些不需要的元素
let array = [1, 2, 4, 6]
let newArray = array.filter(item => item !== 6)
console.log(newArray) // [1, 2, 4]
和 map
一样,filter
的回调函数也接受三个参数,用处也相同。
最后我们来讲解 reduce
这块的内容,同时也是最难理解的一块内容。reduce
可以将数组中的元素通过回调函数最终转换为一个值。
如果我们想实现一个功能将函数里的元素全部相加得到一个值,可能会这样写代码
const arr = [1, 2, 3]
let total = 0
for (let i = 0; i < arr.length; i++) {
total += arr[i]
}
console.log(total) //6
但是如果我们使用 reduce
的话就可以将遍历部分的代码优化为一行代码
const arr = [1, 2, 3]
const sum = arr.reduce((acc, current) => acc + current, 0)
console.log(sum)
对于 reduce
来说,它接受两个参数,分别是回调函数和初始值,接下来我们来分解上述代码中 reduce
的过程
0
,该值会在执行第一次回调函数时作为第一个参数传入1
,该结果会在第二次执行回调函数时当做第一个参数传入1
和 2
,以此类推,循环结束后得到结果 6
想必通过以上的解析大家应该明白 reduce
是如何通过回调函数将所有元素最终转换为一个值的,当然 reduce
还可以实现很多功能,接下来我们就通过 reduce
来实现 map
函数
const arr = [1, 2, 3]
const mapArray = arr.map(value => value * 2)
const reduceArray = arr.reduce((acc, current) => {
acc.push(current * 2)
return acc
}, [])
console.log(mapArray, reduceArray) // [2, 4, 6]
如果你对这个实现还有困惑的话,可以根据上一步的解析步骤来分析过程。
这一章节我们了解了部分 ES6 常考的知识点,其他的一些异步内容我们会放在下一章节去讲。
在上一章节中我们了解了常见 ES6 语法的一些知识点。这一章节我们将会学习异步编程这一块的内容,鉴于异步编程是 JS 中至关重要的内容,所以我们将会用三个章节来学习异步编程涉及到的重点和难点,同时这一块内容也是面试常考范围,希望大家认真学习。
涉及面试题:并发与并行的区别?
异步和这小节的知识点其实并不是一个概念,但是这两个名词确实是很多人都常会混淆的知识点。其实混淆的原因可能只是两个名词在中文上的相似,在英文上来说完全是不同的单词。
并发是宏观概念,我分别有任务 A 和任务 B,在一段时间内通过任务间的切换完成了这两个任务,这种情况就可以称之为并发。
并行是微观概念,假设 CPU 中存在两个核心,那么我就可以同时完成任务 A、B。同时完成多个任务的情况就可以称之为并行。
涉及面试题:什么是回调函数?回调函数有什么缺点?如何解决回调地狱问题?
回调函数应该是大家经常使用到的,以下代码就是一个回调函数的例子:
ajax(url, () => {
// 处理逻辑
})
但是回调函数有一个致命的弱点,就是容易写出回调地狱(Callback hell)。假设多个请求存在依赖性,你可能就会写出如下代码:
ajax(url, () => {
// 处理逻辑
ajax(url1, () => {
// 处理逻辑
ajax(url2, () => {
// 处理逻辑
})
})
})
以上代码看起来不利于阅读和维护,当然,你可能会想说解决这个问题还不简单,把函数分开来写不就得了
function firstAjax() {
ajax(url1, () => {
// 处理逻辑
secondAjax()
})
}
function secondAjax() {
ajax(url2, () => {
// 处理逻辑
})
}
ajax(url, () => {
// 处理逻辑
firstAjax()
})
以上的代码虽然看上去利于阅读了,但是还是没有解决根本问题。
回调地狱的根本问题就是:
当然,回调函数还存在着别的几个缺点,比如不能使用 try catch
捕获错误,不能直接 return
。在接下来的几小节中,我们将来学习通过别的技术解决这些问题。
涉及面试题:你理解的 Generator 是什么?
Generator
算是 ES6 中难理解的概念之一了,Generator
最大的特点就是可以控制函数的执行。在这一小节中我们不会去讲什么是 Generator
,而是把重点放在 Generator
的一些容易困惑的地方。
function *foo(x) {
let y = 2 * (yield (x + 1))
let z = yield (y / 3)
return (x + y + z)
}
let it = foo(5)
console.log(it.next()) // => {value: 6, done: false}
console.log(it.next(12)) // => {value: 8, done: false}
console.log(it.next(13)) // => {value: 42, done: true}
你也许会疑惑为什么会产生与你预想不同的值,接下来就让我为你逐行代码分析原因
Generator
函数调用和普通函数不同,它会返回一个迭代器next
时,传参会被忽略,并且函数暂停在 yield (x + 1)
处,所以返回 5 + 1 = 6
next
时,传入的参数等于上一个 yield
的返回值,如果你不传参,yield
永远返回 undefined
。此时 let y = 2 * 12
,所以第二个 yield
等于 2 * 12 / 3 = 8
next
时,传入的参数会传递给 z
,所以 z = 13, x = 5, y = 24
,相加等于 42
Generator
函数一般见到的不多,其实也于他有点绕有关系,并且一般会配合 co 库去使用。当然,我们可以通过 Generator
函数解决回调地狱的问题,可以把之前的回调地狱例子改写为如下代码:
function *fetch() {
yield ajax(url, () => {
})
yield ajax(url1, () => {
})
yield ajax(url2, () => {
})
}
let it = fetch()
let result1 = it.next()
let result2 = it.next()
let result3 = it.next()
涉及面试题:Promise 的特点是什么,分别有什么优缺点?什么是 Promise 链?Promise 构造函数执行和 then 函数执行有什么区别?
所谓promise,简单说是一个容器,里面保存着一个异步操作的结果,从语法上说,promise是一个对象,从它可以获取异步操作的消息,promise提供了统一的API,各种异步操作都可以用同样的方法进行处理。
(1)promise对象的状态不受外界影响,promise对象代表一个异步操作,用维护状态、传递状态的方式来使得回调函数能够及时调用,有三种状态,pending(进行中)、fulfilled(已成功)、rejected(已失败)。只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态
(2)一旦状态改变就不会再变,任何时候都可以得到这个结果,promise对象的状态改变,只有两种可能:从pending变为fulfilled,从pending变为rejected。这时就称为resolved(已定型)。如果改变已经发生了,你再对promise对象添加回调函数,也会立即得到这个结果,这与事件(event)完全不同,事件的特点是:如果你错过了它,再去监听是得不到结果的。
(3)Promise
用链式调用的方式执行回调函数,也就是说每次调用 then
之后返回一个全新的 Promise
,原因也是因为状态不可变。如果你在 then
中 使用了 return
,那么 return
的值会被 Promise.resolve()
包装
首先,一旦新建一个Promise就会立即执行,无法中途取消。
其次,如果不设置回调函数,Promise内部抛出的错误,不会反应到外部。
第三,当处于pending状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。
方法:
1. Promise.resolve()—— 返回一个promise对象
注意点:
1、返回一个状态由给定value决定的Promise对象(有三种value类型)。
2、类型一,value值是一个Promise对象,则直接返回该对象
3、类型二,value值是thenable(即,带有then方法的对象),返回的Promise对象的最终状态由then方法执行决定
4、类型三,value值为空、基本类型或者不带then方法的对象,返回的Promise对象状态为fulfilled,并且将该value传递给对应的then方法。
5、使用场景:如果不知道一个值是否是Promise对象,使用Promise.resolve(value) 来返回一个Promise对象,这样就能将该value以Promise对象形式使用。
2. Promise.reject()——返回一个状态为失败的promise对象
返回一个状态为失败的Promise对象,并将给定的失败信息传递给对应的处理方法
3. Promise.all(iterable)—— 所有成功才成功,一个失败即失败
注意点:
1、该方法返回一个新的promise对象,只有所有的对象成功,才会触发成功,只要有一个失败,就会触发该对象失败。
2、如果该promise对象成功,会把一个包含iterable里所有promise返回值的数组作为成功回调的返回值,顺序跟iterable的顺序保持一致
3、如果该promise对象失败,它会把iterable里第一个触发失败的promise对象的错误信息作为它的失败错误信息。
4、Promise.all方法常被用于处理多个promise对象的状态集合。
4. Promise.race(iterable)——竞速,最快的一个
注意点:
1、只要有任意一个子promise成功或失败,就会触发父promise对应的状态,并返回该promise对象
2、第一个promise对象变为Fulfilled之后,并不会取消其他promise对象的执行。只是只有先完成的Promise才会被Promise.race后面的then处理。其它的Promise还是在执行的,只不过是不会进入到promise.race后面的then内。
原型方法:
1. Promise.prototype.then(resolved,rejected)
注意点:
1、then有两个参数,第一个参数是resolved状态的回调函数,第二个参数(可选)是rejected状态的回调函数
2、then返回的是一个新的 promise, 将以回调的返回值来resolve.
2. Promise.prototype.catch()
注意点:
1、添加一个拒绝(rejection) 回调到当前 promise, 返回一个新的promise,因此catch后面还可以接着调用then方法。(catch只是then的语法糖,相当于.then(null, rejection)的别名,用于指定发生错误时的回调函数。)
2、当这个回调函数被调用,新 promise 将以它的返回值来resolve,否则如果当前promise 进入fulfilled状态,则以当前promise的完成结果作为新promise的完成结果.(即如果在resolve后再throw错误,是不会被catch到的,因为状态改变后不可逆)
3、Promise 对象的错误具有“冒泡”性质,会一直向后传递,直到被捕获为止。(无论前面有多少个then,它们抛出的错误总是会被下一个catch语句捕获。)
4、当catch前后都有多个then的时候,只有catch前面的then们发生了错误才会进入catch,否则跳过catch,继续执行catch后面的then(只有rejected才会进入catch,否则跳过)
5、**catch方法之中,还能再抛出错误。**如果只有一个catch,当catch发生错误时,这个错误不会被捕获,也不会传递到外层。如果有多个catch连写,那么下一个catch就会捕获上一个catch的错误。
3. Promise.prototype.finally()
注意点
1、finally() 方法的回调函数不接受任何参数
2、无论成功还是失败都会执行
3 、finally() 方法总是会返回原来的值。
1、说说promise规范
2、关于catch的一系列提问,如catch后还能不能再catch到错误,catch后还能不能继续写then,then后面的catch还能不能catch到then的错误之类的。
- 如果异步操作抛出错误,状态就会变为Rejected,就会调用catch方法指定的回调函数,处理这个错误。
- then方法指定的回调函数,如果运行中抛出错误,也会被catch方法捕获。
- 在resolve()后面抛出的错误会被忽略(如果前面已经是执行了resolve,那么后面throw 出来的error不会被catch到)
- catch方法返回的还是一个Promise对象,因此后面还可以接着调用then方法。
- 在异步函数中抛出的错误不会被catch捕获到(例如使用了setTimeout之类的,抛出来的错误不会被catch到)
- 有多个catch连写,如果在catch中继续throw出异常,那么后面的catch就会一直执行,如果不throw异常,则不会执行(catch其实是then的语法糖)
3、 现有4个接口地址/a,/b,/c,/d,需要测试出当中的相应速度(处理完成并返回结果)最快的一个接口的耗时,请写出实现过程,可以使用setTimeout来模拟异步请求。
var promise1 = new Promise(function(resolve, reject) {
setTimeout(resolve, 500, 'one');
});
var promise2 = new Promise(function(resolve, reject) {
setTimeout(resolve, 100, 'two');
});
Promise.race([promise1, promise2]).then(function(value) {
console.log(value);
// Both resolve, but promise2 is faster
});
// expected output: "two"
涉及面试题:async 及 await 的特点,它们的优点和缺点分别是什么?await 原理是什么?
一个函数如果加上 async
,那么该函数就会返回一个 Promise
async function test() {
return "1"
}
console.log(test()) // -> Promise {: "1"}
async
就是将函数返回值使用 Promise.resolve()
包裹了下,和 then
中处理返回值一样,并且 await
只能配套 async
使用
async function test() {
let value = await sleep()
}
async
和 await
可以说是异步终极解决方案了,相比直接使用 Promise
来说,优势在于处理 then
的调用链,能够更清晰准确的写出代码,毕竟写一大堆 then
也很恶心,并且也能优雅地解决回调地狱问题。当然也存在一些缺点,因为 await
将异步代码改造成了同步代码,如果多个异步代码没有依赖性却使用了 await
会导致性能上的降低。
async function test() {
// 以下代码没有依赖性的话,完全可以使用 Promise.all 的方式
// 如果有依赖性的话,其实就是解决回调地狱的例子了
await fetch(url)
await fetch(url1)
await fetch(url2)
}
下面来看一个使用 await
的例子:
let a = 0
let b = async () => {
a = a + await 10
console.log('2', a) // -> '2' 10
}
b()
a++
console.log('1', a) // -> '1' 1
对于以上代码你可能会有疑惑,让我来解释下原因
b
先执行,在执行到 await 10
之前变量 a
还是 0,因为 await
内部实现了 generator
,generator
会保留堆栈中东西,所以这时候 a = 0
被保存了下来await
是异步操作,后来的表达式不返回 Promise
的话,就会包装成 Promise.reslove(返回值)
,然后会去执行函数外的同步代码a = 0 + 10
上述解释中提到了 await
内部实现了 generator
,其实 await
就是 generator
加上 Promise
的语法糖,且内部实现了自动执行 generator
。如果你熟悉 co 的话,其实自己就可以实现这样的语法糖。
涉及面试题:setTimeout、setInterval、requestAnimationFrame 各有什么特点?
异步编程当然少不了定时器了,常见的定时器函数有 setTimeout
、setInterval
、requestAnimationFrame
。我们先来讲讲最常用的setTimeout
,很多人认为 setTimeout
是延时多久,那就应该是多久后执行。
其实这个观点是错误的,因为 JS 是单线程执行的,如果前面的代码影响了性能,就会导致 setTimeout
不会按期执行。当然了,我们可以通过代码去修正 setTimeout
,从而使定时器相对准确
let period = 60 * 1000 * 60 * 2
let startTime = new Date().getTime()
let count = 0
let end = new Date().getTime() + period
let interval = 1000
let currentInterval = interval
function loop() {
count++
// 代码执行所消耗的时间
let offset = new Date().getTime() - (startTime + count * interval);
let diff = end - new Date().getTime()
let h = Math.floor(diff / (60 * 1000 * 60))
let hdiff = diff % (60 * 1000 * 60)
let m = Math.floor(hdiff / (60 * 1000))
let mdiff = hdiff % (60 * 1000)
let s = mdiff / (1000)
let sCeil = Math.ceil(s)
let sFloor = Math.floor(s)
// 得到下一次循环所消耗的时间
currentInterval = interval - offset
console.log('时:'+h, '分:'+m, '毫秒:'+s, '秒向上取整:'+sCeil, '代码执行时间:'+offset, '下次循环间隔'+currentInterval)
setTimeout(loop, currentInterval)
}
setTimeout(loop, currentInterval)
接下来我们来看 setInterval
,其实这个函数作用和 setTimeout
基本一致,只是该函数是每隔一段时间执行一次回调函数。
通常来说不建议使用 setInterval
。第一,它和 setTimeout
一样,不能保证在预期的时间执行任务。第二,它存在执行累积的问题,请看以下伪代码
function demo() {
setInterval(function(){
console.log(2)
},1000)
sleep(2000)
}
demo()
以上代码在浏览器环境中,如果定时器执行过程中出现了耗时操作,多个回调函数会在耗时操作结束以后同时执行,这样可能就会带来性能上的问题。
如果你有循环定时器的需求,其实完全可以通过 requestAnimationFrame
来实现
function setInterval(callback, interval) {
let timer
const now = Date.now
let startTime = now()
let endTime = startTime
const loop = () => {
timer = window.requestAnimationFrame(loop)
endTime = now()
if (endTime - startTime >= interval) {
startTime = endTime = now()
callback(timer)
}
}
timer = window.requestAnimationFrame(loop)
return timer
}
let a = 0
setInterval(timer => {
console.log(1)
a++
if (a === 3) cancelAnimationFrame(timer)
}, 1000)
首先 requestAnimationFrame
自带函数节流功能,基本可以保证在 16.6 毫秒内只执行一次(不掉帧的情况下),并且该函数的延时效果是精确的,没有其他定时器时间不准的问题,当然你也可以通过该函数来实现 setTimeout
。
即短时间内大量触发同一事件,只会执行一次函数,防抖常用于搜索框/滚动条的监听事件处理,如果不做防抖,每输入一个字/滚动屏幕,都会触发事件处理,造成性能浪费;实现原理为设置一个定时器,约定在xx毫秒后再触发事件处理,每次触发事件都会重新设置计时器,直到xx毫秒内无第二次操作,。
// func是用户传入需要防抖的函数
// wait是等待时间
const debounce = (func, wait = 50) => {
// 缓存一个定时器id
let timer = 0
// 这里返回的函数是每次用户实际调用的防抖函数
// 如果已经设定过定时器了就清空上一次的定时器
// 开始一个新的定时器,延迟执行用户传入的方法
return function(...args) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
func.apply(this, args)
}, wait)
}
}
防抖是延迟执行
,而节流是间隔执行
,和防抖的区别在于,防抖每次触发事件都重置定时器,而节流在定时器到时间后再清空定时器,函数节流即每隔一段时间就执行一次
,实现原理为设置一个定时器,约定xx毫秒后执行事件,如果时间到了,那么执行函数并重置定时器
,
// func是用户传入需要防抖的函数
// wait是等待时间
const throttle = (func, wait = 50) => {
// 上一次执行该函数的时间
let lastTime = 0
return function(...args) {
// 当前时间
let now = +new Date()
// 将当前时间和上一次执行函数时间对比
// 如果差值大于设置的等待时间就执行函数
if (now - lastTime > wait) {
lastTime = now
func.apply(this, args)
}
}
}
setInterval(
throttle(() => {
console.log(1)
}, 500),
1
)
异步编程是 JS 中较难掌握的内容,同时也是很重要的知识点。以上提到的每个知识点其实都可以作为一道面试题
在上一章节中我们了解了 Promise
的一些易错点,在这一章节中,我们会通过手写一个符合 Promise/A+ 规范的 Promise
来深入理解它,并且手写 Promise
也是一道大厂常考题,在进入正题之前,推荐各位阅读一下 Promise/A+ 规范,这样才能更好地理解这个章节的代码。
在完成符合 Promise/A+ 规范的代码之前,我们可以先来实现一个简易版 Promise
,因为在面试中,如果你能实现出一个简易版的 Promise
基本可以过关了。
那么我们先来搭建构建函数的大体框架
const PENDING = 'pending'
const RESOLVED = 'resolved'
const REJECTED = 'rejected'
function MyPromise(fn) {
const that = this
that.state = PENDING
that.value = null
that.resolvedCallbacks = []
that.rejectedCallbacks = []
// 待完善 resolve 和 reject 函数
// 待完善执行 fn 函数
}
that
,因为代码可能会异步执行,用于获取正确的 this
对象Promise
的状态应该是 pending
value
变量用于保存 resolve
或者 reject
中传入的值resolvedCallbacks
和 rejectedCallbacks
用于保存 then
中的回调,因为当执行完 Promise
时状态可能还是等待中,这时候应该把 then
中的回调保存起来用于状态改变时使用接下来我们来完善 resolve
和 reject
函数,添加在 MyPromise
函数体内部
function resolve(value) {
if (that.state === PENDING) {
that.state = RESOLVED
that.value = value
that.resolvedCallbacks.map(cb => cb(that.value))
}
}
function reject(value) {
if (that.state === PENDING) {
that.state = REJECTED
that.value = value
that.rejectedCallbacks.map(cb => cb(that.value))
}
}
这两个函数代码类似,就一起解析了
value
完成以上两个函数以后,我们就该实现如何执行 Promise
中传入的函数了
try {
fn(resolve, reject)
} catch (e) {
reject(e)
}
reject
函数最后我们来实现较为复杂的 then
函数
MyPromise.prototype.then = function(onFulfilled, onRejected) {
const that = this
onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : v => v
onRejected =
typeof onRejected === 'function'
? onRejected
: r => {
throw r
}
if (that.state === PENDING) {
that.resolvedCallbacks.push(onFulfilled)
that.rejectedCallbacks.push(onRejected)
}
if (that.state === RESOLVED) {
onFulfilled(that.value)
}
if (that.state === REJECTED) {
onRejected(that.value)
}
}
首先判断两个参数是否为函数类型,因为这两个参数是可选参数
当参数不是函数类型时,需要创建一个函数赋值给对应的参数,同时也实现了透传,比如如下代码
// 该代码目前在简单版中会报错
// 只是作为一个透传的例子
Promise.resolve(4).then().then((value) => console.log(value))
接下来就是一系列判断状态的逻辑,当状态不是等待态时,就去执行相对应的函数。如果状态是等待态的话,就往回调函数中 push
函数,比如如下代码就会进入等待态的逻辑
new MyPromise((resolve, reject) => {
setTimeout(() => {
resolve(1)
}, 0)
}).then(value => {
console.log(value)
})
以上就是简单版 Promise
实现,接下来一小节是实现完整版 Promise
的解析,相信看完完整版的你,一定会对于 Promise
的理解更上一层楼。
这小节代码需要大家配合规范阅读,因为大部分代码都是根据规范去实现的。
我们先来改造一下 resolve
和 reject
函数
function resolve(value) {
if (value instanceof MyPromise) {
return value.then(resolve, reject)
}
setTimeout(() => {
if (that.state === PENDING) {
that.state = RESOLVED
that.value = value
that.resolvedCallbacks.map(cb => cb(that.value))
}
}, 0)
}
function reject(value) {
setTimeout(() => {
if (that.state === PENDING) {
that.state = REJECTED
that.value = value
that.rejectedCallbacks.map(cb => cb(that.value))
}
}, 0)
}
resolve
函数来说,首先需要判断传入的值是否为 Promise
类型setTimeout
包裹起来接下来继续改造 then
函数中的代码,首先我们需要新增一个变量 promise2
,因为每个 then
函数都需要返回一个新的 Promise
对象,该变量用于保存新的返回对象,然后我们先来改造判断等待态的逻辑
if (that.state === PENDING) {
return (promise2 = new MyPromise((resolve, reject) => {
that.resolvedCallbacks.push(() => {
try {
const x = onFulfilled(that.value)
resolutionProcedure(promise2, x, resolve, reject)
} catch (r) {
reject(r)
}
})
that.rejectedCallbacks.push(() => {
try {
const x = onRejected(that.value)
resolutionProcedure(promise2, x, resolve, reject)
} catch (r) {
reject(r)
}
})
}))
}
Promise
对象,并在 Promise
中传入了一个函数push
函数try...catch
包裹onFulfilled
或者 onRejected
函数时会返回一个 x
,并且执行 Promise
解决过程,这是为了不同的 Promise
都可以兼容使用,比如 JQuery 的 Promise
能兼容 ES6 的 Promise
接下来我们改造判断执行态的逻辑
if (that.state === RESOLVED) {
return (promise2 = new MyPromise((resolve, reject) => {
setTimeout(() => {
try {
const x = onFulfilled(that.value)
resolutionProcedure(promise2, x, resolve, reject)
} catch (reason) {
reject(reason)
}
})
}))
}
最后,当然也是最难的一部分,也就是实现兼容多种 Promise
的 resolutionProcedure
函数
function resolutionProcedure(promise2, x, resolve, reject) {
if (promise2 === x) {
return reject(new TypeError('Error'))
}
}
首先规范规定了 x
不能与 promise2
相等,这样会发生循环引用的问题,比如如下代码
let p = new MyPromise((resolve, reject) => {
resolve(1)
})
let p1 = p.then(value => {
return p1
})
然后需要判断 x
的类型
if (x instanceof MyPromise) {
x.then(function(value) {
resolutionProcedure(promise2, value, resolve, reject)
}, reject)
}
这里的代码是完全按照规范实现的。如果 x
为 Promise
的话,需要判断以下几个情况:
x
处于等待态,Promise
需保持为等待态直至 x
被执行或拒绝x
处于其他状态,则用相同的值处理 Promise
当然以上这些是规范需要我们判断的情况,实际上我们不判断状态也是可行的。
接下来我们继续按照规范来实现剩余的代码
let called = false
if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
try {
let then = x.then
if (typeof then === 'function') {
then.call(
x,
y => {
if (called) return
called = true
resolutionProcedure(promise2, y, resolve, reject)
},
e => {
if (called) return
called = true
reject(e)
}
)
} else {
resolve(x)
}
} catch (e) {
if (called) return
called = true
reject(e)
}
} else {
resolve(x)
}
called
用于判断是否已经调用过函数x
是否为对象或者函数,如果都不是的话,将 x
传入 resolve
中x
是对象或者函数的话,先把 x.then
赋值给 then
,然后判断 then
的类型,如果不是函数类型的话,就将 x
传入 resolve
中then
是函数类型的话,就将 x
作为函数的作用域 this
调用之,并且传递两个回调函数作为参数,第一个参数叫做 resolvePromise
,第二个参数叫做 rejectPromise
,两个回调函数都需要判断是否已经执行过函数,然后进行相应的逻辑reject
函数中以上就是符合 Promise/A+ 规范的实现了,如果你对于这部分代码尚有疑问,欢迎在评论中与我互动。
这一章节我们分别实现了简单版和符合 Promise/A+ 规范的 Promise
,前者已经足够应付大部分面试的手写题目,毕竟写出一个符合规范的 Promise
在面试中不大现实。后者能让你更加深入地理解 Promise
的运行原理,做技术的深挖者。
在前两章节中我们了解了 JS 异步相关的知识。在实践的过程中,你是否遇到过以下场景,为什么 setTimeout
会比 Promise
后执行,明明代码写在 Promise
之前。这其实涉及到了 Event Loop 相关的知识,这一章节我们会来详细地了解 Event Loop 相关知识,知道 JS 异步运行代码的原理,并且这一章节也是面试常考知识点。
涉及面试题:进程与线程区别?JS 单线程带来的好处?
相信大家经常会听到 JS 是单线程执行的,但是你是否疑惑过什么是线程?
(1)进程
进程是程序的一次执行过程,是一个动态概念,是程序在执行过程中分配和管理资源的基本单位,每一个进程都有一个自己的地址空间,至少有 5 种基本状态,它们是:初始态,执行态,等待状态,就绪状态,终止状态。
(2)线程
线程是CPU调度和分派的基本单位,它可与同属一个进程的其他的线程共享进程所拥有的全部资源。
(3)联系
线程是进程的一部分,一个线程只能属于一个进程,而一个进程可以有多个线程,但至少有一个线程。
(4)区别:理解它们的差别,我从资源使用的角度出发。(所谓的资源就是计算机里的中央处理器,内存,文件,网络等等)
根本区别:进程是操作系统资源分配的基本单位,而线程是任务调度和执行的基本单位
在开销方面:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器(PC),线程之间切换的开销小。
所处环境:在操作系统中能同时运行多个进程(程序);而在同一个进程(程序)中有多个线程同时执行(通过CPU调度,在每个时间片中只有一个线程执行)
内存分配方面:系统在运行的时候会为每个进程分配不同的内存空间;而对线程而言,除了CPU外,系统不会为线程分配内存(线程所使用的资源来自其所属进程的资源),线程组之间只能共享资源。
包含关系:没有线程的进程可以看做是单线程的,如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程。
把这些概念拿到浏览器中来说,当你打开一个 Tab 页时,其实就是创建了一个进程,一个进程中可以有多个线程,比如渲染线程、JS 引擎线程、HTTP 请求线程等等。当你发起一个请求时,其实就是创建了一个线程,当请求结束后,该线程可能就会被销毁。
上文说到了 JS 引擎线程和渲染线程,大家应该都知道,在 JS 运行的时候可能会阻止 UI 渲染,这说明了两个线程是互斥的。这其中的原因是因为 JS 可以修改 DOM,如果在 JS 执行的时候 UI 线程还在工作,就可能导致不能安全的渲染 UI。这其实也是一个单线程的好处,得益于 JS 是单线程运行的,可以达到节省内存,节约上下文切换时间,没有锁的问题的好处。当然前面两点在服务端中更容易体现,对于锁的问题,形象的来说就是当我读取一个数字 15 的时候,同时有两个操作对数字进行了加减,这时候结果就出现了错误。解决这个问题也不难,只需要在读取的时候加锁,直到读取完毕之前都不能进行写入操作。
涉及面试题:什么是执行栈?
可以把执行栈认为是一个存储函数调用的栈结构,遵循先进后出的原则。
执行栈可视化
当开始执行 JS 代码时,首先会执行一个 main
函数,然后执行我们的代码。根据先进后出的原则,后执行的函数会先弹出栈,在图中我们也可以发现,foo
函数后执行,当执行完毕后就从栈中弹出了。
平时在开发中,大家也可以在报错中找到执行栈的痕迹
function foo() {
throw new Error('error')
}
function bar() {
foo()
}
bar()
函数执行顺序
大家可以在上图清晰的看到报错在 foo
函数,foo
函数又是在 bar
函数中调用的。
当我们使用递归的时候,因为栈可存放的函数是有限制的,一旦存放了过多的函数且没有得到释放的话,就会出现爆栈的问题
function bar() {
bar()
}
bar()
爆栈
涉及面试题:异步代码执行顺序?解释一下什么是 Event Loop ?
当我们执行 JS 代码的时候其实就是往执行栈中放入函数,其实当遇到异步的代码时,会被挂起并在需要执行的时候加入到 Task(有多种 Task) 队列中。一旦执行栈为空,Event Loop 就会从 Task 队列中拿出需要执行的代码并放入执行栈中执行,所以本质上来说 JS 中的异步还是同步行为。
不同的任务源会被分配到不同的 Task 队列中,任务源可以分为 微任务(microtask) 和 宏任务(macrotask)。在 ES6 规范中,microtask 称为 jobs
,macrotask 称为 task
。下面来看以下代码的执行顺序:
所以 Event Loop 执行顺序如下所示:
setTimeout
中的回调函数所以以上代码虽然 setTimeout
写在 Promise
之前,但是因为 Promise
属于微任务而 setTimeout
属于宏任务,所以会有以上的打印。
微任务包括 process.nextTick
,promise
,MutationObserver
。
宏任务包括 script
, setTimeout
,setInterval
,setImmediate
,I/O
,UI rendering
。
这里很多人会有个误区,认为微任务快于宏任务,其实是错误的。因为宏任务中包括了 script
,浏览器会先执行一个宏任务,接下来有异步代码的话才会先执行微任务。
涉及面试题:Node 中的 Event Loop 和浏览器中的有什么区别?process.nexttick 执行顺序?
Node 中的 Event Loop 和浏览器中的是完全不相同的东西。
Node 的 Event Loop 分为 6 个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。
timers 阶段会执行 setTimeout
和 setInterval
回调,并且是由 poll 阶段控制的。
同样,在 Node 中定时器指定的时间也不是准确时间,只能是尽快执行。
I/O 阶段会处理一些上一轮循环中的少数未执行的 I/O 回调
idle, prepare 阶段内部实现,这里就忽略不讲了。
poll 是一个至关重要的阶段,这一阶段中,系统会做两件事情
并且在进入该阶段时如果没有设定了 timer 的话,会发生以下两件事情
setImmediate
回调需要执行,poll 阶段会停止并且进入到 check 阶段执行回调setImmediate
回调需要执行,会等待回调被加入到队列中并立即执行回调,这里同样会有个超时时间设置防止一直等待下去当然设定了 timer 的话且 poll 队列为空,则会判断是否有 timer 超时,如果有的话会回到 timer 阶段执行回调。
check 阶段执行 setImmediate
close callbacks 阶段执行 close 事件
在以上的内容中,我们了解了 Node 中的 Event Loop 的执行顺序,接下来我们将会通过代码的方式来深入理解这块内容。
首先在有些情况下,定时器的执行顺序其实是随机的
setTimeout(() => {
console.log('setTimeout')
}, 0)
setImmediate(() => {
console.log('setImmediate')
})
对于以上代码来说,setTimeout
可能执行在前,也可能执行在后
setTimeout(fn, 0) === setTimeout(fn, 1)
,这是由源码决定的setTimeout
回调setImmediate
回调先执行了当然在某些情况下,他们的执行顺序一定是固定的,比如以下代码:
const fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0)
setImmediate(() => {
console.log('immediate')
})
})
在上述代码中,setImmediate
永远先执行。因为两个代码写在 IO 回调中,IO 回调是在 poll 阶段执行,当回调执行完毕后队列为空,发现存在 setImmediate
回调,所以就直接跳转到 check 阶段去执行回调了。
上面介绍的都是 macrotask 的执行情况,对于 microtask 来说,它会在以上每个阶段完成前清空 microtask 队列,下图中的 Tick 就代表了 microtask
setTimeout(() => {
console.log('timer21')
}, 0)
Promise.resolve().then(function() {
console.log('promise1')
})
对于以上代码来说,其实和浏览器中的输出是一样的,microtask 永远执行在 macrotask 前面。
最后我们来讲讲 Node 中的 process.nextTick
,这个函数其实是独立于 Event Loop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
})
})
})
})
对于以上代码,大家可以发现无论如何,永远都是先把 nextTick 全部打印出来。
这一章节我们学习了 JS 实现异步的原理,并且了解了在浏览器和 Node 中 Event Loop 其实是不相同的。Event Loop 这个知识点对于我们理解 JS 是如何执行的至关重要,同时也是常考题。
在这一章节中,我们将会学习到一些原理相关的知识,不会解释涉及到的知识点的作用及用法,如果大家对于这些内容还不怎么熟悉,推荐先去学习相关的知识点内容再来学习原理知识。
涉及面试题:call、apply 及 bind 函数内部实现是怎么样的?
首先从以下几点来考虑如何实现这几个函数
window
this
指向,让新的对象可以执行该函数,并能接受参数那么我们先来实现 call
Function.prototype.myCall = function(context) {
if (typeof this !== 'function') {
throw new TypeError('Error')
}
context = context || window
context.fn = this
const args = [...arguments].slice(1)
const result = context.fn(...args)
delete context.fn
return result
}
以下是对实现的分析:
context
为可选参数,如果不传的话默认上下文为 window
context
创建一个 fn
属性,并将值设置为需要调用的函数call
可以传入多个参数作为调用函数的参数,所以需要将参数剥离出来以上就是实现 call
的思路,apply
的实现也类似,区别在于对参数的处理,所以就不一一分析思路了
Function.prototype.myApply = function(context) {
if (typeof this !== 'function') {
throw new TypeError('Error')
}
context = context || window
context.fn = this
let result
// 处理参数和 call 有区别
if (arguments[1]) {
result = context.fn(...arguments[1])
} else {
result = context.fn()
}
delete context.fn
return result
}
bind
的实现对比其他两个函数略微地复杂了一点,因为 bind
需要返回一个函数,需要判断一些边界问题,以下是 bind
的实现
Function.prototype.myBind = function (context) {
if (typeof this !== 'function') {
throw new TypeError('Error')
}
const _this = this
const args = [...arguments].slice(1)
// 返回一个函数
return function F() {
// 因为返回了一个函数,我们可以 new F(),所以需要判断
if (this instanceof F) {
return new _this(...args, ...arguments)
}
return _this.apply(context, args.concat(...arguments))
}
}
具体bind实现分析https://blog.csdn.net/YZ0826/article/details/80176169
以下是对实现的分析:
bind
返回了一个函数,对于函数来说有两种方式调用,一种是直接调用,一种是通过 new
的方式,我们先来说直接调用的方式apply
的方式实现,但是对于参数需要注意以下情况:因为 bind
可以实现类似这样的代码 f.bind(obj, 1)(2)
,所以我们需要将两边的参数拼接起来,于是就有了这样的实现 args.concat(...arguments)
new
的方式,在之前的章节中我们学习过如何判断 this
,对于 new
的情况来说,不会被任何方式改变 this
,所以对于这种情况我们需要忽略传入的 this
涉及面试题:new 的原理是什么?通过 new 的方式创建对象和通过字面量创建有什么区别?
首先我们要知道
new
做了什么1.创建一个新对象,并继承其构造函数的
prototype
,这一步是为了继承构造函数原型上的属性和方法2.执行构造函数,方法内的
this
被指定为该新实例,这一步是为了执行构造函数内的赋值操作3.返回新实例(规范规定,如果构造方法返回了一个对象,那么返回该对象,否则返回第一步创建的新对象)
在调用 new
的过程中会发生以上四件事情:
根据以上几个过程,我们也可以试着来自己实现一个 new
function create() {
let obj = {
}
let Con = [].shift.call(arguments)
obj.__proto__ = Con.prototype
let result = Con.apply(obj, arguments)
return result instanceof Object ? result : obj
}
以下是对实现的分析:
this
并执行构造函数对于对象来说,其实都是通过 new
产生的,无论是 function Foo()
还是 let a = { b : 1 }
。
对于创建一个对象来说,更推荐使用字面量的方式创建对象(无论性能上还是可读性)。因为你使用 new Object()
的方式创建对象需要通过作用域链一层层找到 Object
,但是你使用字面量的方式就没这个问题。
function Foo() {
}
// function 就是个语法糖
// 内部等同于 new Function()
let a = {
b: 1 }
// 这个字面量内部也是使用了 new Object()
涉及面试题:instanceof 的原理是什么?
instanceof
可以正确的判断对象的类型,因为内部机制是通过判断对象的原型链中是不是能找到类型的 prototype
。
我们也可以试着实现一下 instanceof
function myInstanceof(left, right) {
let prototype = right.prototype
left = left.__proto__
while (true) {
if (left === null || left === undefined)
return false
if (prototype === left)
return true
left = left.__proto__
}
}
以下是对实现的分析:
null
,因为原型链最终为 null
涉及面试题:为什么 0.1 + 0.2 != 0.3?如何解决这个问题?
先说原因,因为 JS 采用 IEEE 754 双精度版本(64位),并且只要采用 IEEE 754 的语言都有该问题。
我们都知道计算机是通过二进制来存储东西的,那么 0.1
在二进制中会表示为
// (0011) 表示循环
0.1 = 2^-4 * 1.10011(0011)
我们可以发现,0.1
在二进制中是无限循环的一些数字,其实不只是 0.1
,其实很多十进制小数用二进制表示都是无限循环的。这样其实没什么问题,但是 JS 采用的浮点数标准却会裁剪掉我们的数字。
IEEE 754 双精度版本(64位)将 64 位分为了三段
0.1
中的 10011(0011)
那么这些循环的数字被裁剪了,就会出现精度丢失的问题,也就造成了 0.1
不再是 0.1
了,而是变成了 0.100000000000000002
0.100000000000000002 === 0.1 // true
那么同样的,0.2
在二进制也是无限循环的,被裁剪后也失去了精度变成了 0.200000000000000002
0.200000000000000002 === 0.2 // true
所以这两者相加不等于 0.3
而是 0.300000000000000004
0.1 + 0.2 === 0.30000000000000004 // true
那么可能你又会有一个疑问,既然 0.1
不是 0.1
,那为什么 console.log(0.1)
却是正确的呢?
因为在输入内容的时候,二进制被转换为了十进制,十进制又被转换为了字符串,在这个转换的过程中发生了取近似值的过程,所以打印出来的其实是一个近似值,你也可以通过以下代码来验证
console.log(0.100000000000000002) // 0.1
那么说完了为什么,最后来说说怎么解决这个问题吧。其实解决的办法有很多,这里我们选用原生提供的方式来最简单的解决问题
parseFloat((0.1 + 0.2).toFixed(10)) === 0.3 // true
涉及面试题:V8 下的垃圾回收机制是怎么样的?
垃圾回收机制(GC:Garbage Collection),执行环境负责管理代码执行过程中使用的内存。垃圾收集器会定期(周期性)找出那些不在继续使用的变量,然后释放其内存。但是这个过程不是实时的,因为其开销比较大,所以垃圾回收器会按照固定的时间间隔周期性的执行。
2种最为常用:标记清除和引用计数,其中标记清除更为常用。
标记清除(mark-and-sweep):
当变量进入作用域时,进行标记,对于脱离作用域的变量进行标记并回收。到目前为止,IE、Firefox、Opera、Chrome、Safari的js实现使用的都是标记清除的垃圾回收策略或类似的策略,只不过垃圾收集的时间间隔互不相同。
当变量进入环境(例如,在函数中声明一个变量)时,就将这个变量标记为“进入环境”。从逻辑上讲,永远不能释放进入环境的变量所占的内存,因为只要执行流进入相应的环境,就可能用到它们。而当变量离开环境时,这将其 标记为“离开环境”。
引用计数:引用计数是跟踪记录每个值被引用的次数。就是变量的引用次数,被引用一次则加1,当这个引用计数为0时,被视为准备回收的对象,每当过一段时间开始垃圾回收的时候,就把被引用数为0的变量回收。引用计数方法可能导致循环引用,类似死锁,导致内存泄露。
在这个例子中,objA和objB通过各自的属性相互引用;也就是说这两个对象的引用次数都是2。在采用引用计数的策略中,由于函数执行之后,这两个对象都离开了作用域,函数执行完成之后,objA和objB还将会继续存在,因为他们的引用次数永远不会是0。这样的相互引用如果说很大量的存在就会导致大量的内存泄露。
1、全局变量引起的内存泄露
2、闭包引起的内存泄露:慎用闭包
3、dom清空或删除时,事件未清除导致的内存泄漏
3、循环引用带来的内存泄露
由于每次的垃圾回收开销都相对较大,并且由于机制的一些不完善的地方,可能会导致内存泄露,我们可以利用一些方法减少垃圾回收,并且尽量避免循环引用。
在对象结束使用后 ,令obj = null。这样利于解除循环引用,使得无用变量及时被回收;
js中开辟空间的操作有new(), [ ], { }, function (){…}。最大限度的实现对象的重用;
慎用闭包。闭包容易引起内存泄露。本来在函数返回之后,之前的空间都会被回收。但是由于闭包可能保存着函数内部变量的引用,且闭包在外部环境,就会导致函数内部的变量不能够销毁。
和其他语言一样,javascript的GC策略也无法避免一个问题:GC时,停止响应其他操作,这是为了安全考虑。而Javascript的GC在100ms甚至以上,对一般的应用还好,但对于JS游戏,动画对连贯性要求比较高的应用,就麻烦了。这就是新引擎需要优化的点:避免GC造成的长时间停止响应。
分代回收(Generation GC):与Java回收策略思想是一致的。目的是通过区分“临时”与“持久”对象;多回收“临时对象”区(young generation),少回收“持久对象”区(tenured generation),减少每次需遍历的对象,从而减少每次GC的耗时。
增量GC:这个方案的思想很简单,就是“每次处理一点,下次再处理一点,如此类推。
新生代中的对象一般存活时间较短,使用 Scavenge GC 算法。
在新生代空间中,内存空间分为两部分,分别为 From 空间和 To 空间。在这两个空间中,必定有一个空间是使用的,另一个空间是空闲的。新分配的对象会被放入 From 空间中,当 From 空间被占满时,新生代 GC 就会启动了。算法会检查 From 空间中存活的对象并复制到 To 空间中,如果有失活的对象就会销毁。当复制完成后将 From 空间和 To 空间互换,这样 GC 就结束了。
老生代中的对象一般存活时间较长且数量也多,使用了两个算法,分别是标记清除算法和标记压缩算法。
在讲算法前,先来说下什么情况下对象会出现在老生代空间中:
老生代中的空间很复杂,有如下几个空间
enum AllocationSpace {
// TODO(v8:7464): Actually map this space's memory as read-only.
RO_SPACE, // 不变的对象空间
NEW_SPACE, // 新生代用于 GC 复制算法的空间
OLD_SPACE, // 老生代常驻对象空间
CODE_SPACE, // 老生代代码对象空间
MAP_SPACE, // 老生代 map 对象
LO_SPACE, // 老生代大空间对象
NEW_LO_SPACE, // 新生代大空间对象
FIRST_SPACE = RO_SPACE,
LAST_SPACE = NEW_LO_SPACE,
FIRST_GROWABLE_PAGED_SPACE = OLD_SPACE,
LAST_GROWABLE_PAGED_SPACE = MAP_SPACE
};
在老生代中,以下情况会先启动标记清除算法:
在这个阶段中,会遍历堆中所有的对象,然后标记活的对象,在标记完成后,销毁所有没有被标记的对象。在标记大型对内存时,可能需要几百毫秒才能完成一次标记。这就会导致一些性能上的问题。为了解决这个问题,2011 年,V8 从 stop-the-world 标记切换到增量标志。在增量标记期间,GC 将标记工作分解为更小的模块,可以让 JS 应用逻辑在模块间隙执行一会,从而不至于让应用出现停顿情况。但在 2018 年,GC 技术又有了一个重大突破,这项技术名为并发标记。该技术可以让 GC 扫描和标记对象时,同时允许 JS 运行,你可以点击 该博客 详细阅读。
清除对象后会造成堆内存出现碎片的情况,当碎片超过一定限制后会启动压缩算法。在压缩过程中,将活的对象像一端移动,直到所有对象都移动完成然后清理掉不需要的内存。
以上就是 JS 进阶知识点的内容了,这部分的知识相比于之前的内容更加深入也更加的理论,也是在面试中能够于别的候选者拉开差距的一块内容。
这一章节我们将会来学习浏览器的一些基础知识点,包括:事件机制、跨域、存储相关,这几个知识点也是面试经常会考到的内容。
涉及面试题:事件的触发过程是怎么样的?知道什么是事件代理嘛?
事件触发有三个阶段:
window
往事件触发处传播,遇到注册的捕获事件会触发window
传播,遇到注册的冒泡事件会触发事件触发一般来说会按照上面的顺序进行,但是也有特例,如果给一个 body
中的子节点同时注册冒泡和捕获事件,事件触发会按照注册的顺序执行。
// 以下会先打印冒泡然后是捕获
node.addEventListener(
'click',
event => {
console.log('冒泡')
},
false
)
node.addEventListener(
'click',
event => {
console.log('捕获 ')
},
true
)
通常我们使用 addEventListener
注册事件,它主要有三个参数:
event 事件名 必须
function 处理函数 必须
useCapture 指定事件是否在捕获或冒泡阶段执行, 该函数的第三个参数可以是布尔值,也可以是对象。对于布尔值 useCapture
参数来说,该参数默认值为 false
,useCapture
决定了注册的事件是捕获事件还是冒泡事件。对于对象参数来说,可以使用以下几个属性
capture
:布尔值,和 useCapture
作用一样once
:布尔值,值为 true
表示该回调只会调用一次,调用后会移除监听passive
:布尔值,表示永远不会调用 preventDefault
一般来说,如果我们只希望事件只触发在目标上,这时候可以使用 stopPropagation
来阻止事件的进一步传播。通常我们认为 stopPropagation
是用来阻止事件冒泡的,其实该函数也可以阻止捕获事件。stopImmediatePropagation
同样也能实现阻止事件,但是还能阻止该事件目标执行别的注册事件。
node.addEventListener(
'click',
event => {
event.stopImmediatePropagation()
console.log('冒泡')
},
false
)
// 点击 node 只会执行上面的函数,该函数不会执行
node.addEventListener(
'click',
event => {
console.log('捕获 ')
},
true
)
如果一个节点中的子节点是动态生成的,那么子节点需要注册事件的话应该注册在父节点上
<ul id="ul">
<li>1</li>
<li>2</li>
<li>3</li>
<li>4</li>
<li>5</li>
</ul>
<script>
let ul = document.querySelector('#ul')
ul.addEventListener('click', (event) => {
console.log(event.target);
})
</script>
事件代理的方式相较于直接给目标注册事件来说,有以下优点:
涉及面试题:什么是跨域?为什么浏览器要使用同源策略?你有几种方式可以解决跨域问题?了解预检请求嘛?[p
因为浏览器出于安全考虑,有同源策略。也就是说,如果协议、域名或者端口有一个不同就是跨域,Ajax 请求会失败。
那么是出于什么安全考虑才会引入这种机制呢? 其实主要是用来防止 CSRF 攻击的。简单点说,CSRF 攻击是利用用户的登录态发起恶意请求。
也就是说,没有同源策略的情况下,A 网站可以被任意其他来源的 Ajax 访问到内容。如果你当前 A 网站还存在登录态,那么对方就可以通过 Ajax 获得你的任何信息。当然跨域并不能完全阻止 CSRF。
然后我们来考虑一个问题,请求跨域了,那么请求到底发出去没有? 请求必然是发出去了,但是浏览器拦截了响应。你可能会疑问明明通过表单的方式可以发起跨域请求,为什么 Ajax 就不会。因为归根结底,跨域是为了阻止用户读取到另一个域名下的内容,Ajax 可以获取响应,浏览器认为这不安全,所以拦截了响应。但是表单并不会获取新的内容,所以可以发起跨域请求。同时也说明了跨域并不能完全阻止 CSRF,因为请求毕竟是发出去了。
接下来我们将来学习几种常见的方式来解决跨域的问题。
JSONP 的原理很简单,就是利用 标签没有跨域限制的漏洞。通过
标签指向一个需要访问的地址并提供一个回调函数来接收数据当需要通讯时。
<script src="http://domain/api?param1=a¶m2=b&callback=jsonp"></script>
<script>
function jsonp(data) {
console.log(data)
}
</script>
JSONP 使用简单且兼容性不错,但是只限于 get
请求。
在开发中可能会遇到多个 JSONP 请求的回调函数名是相同的,这时候就需要自己封装一个 JSONP,以下是简单实现
function jsonp(url, jsonpCallback, success) {
let script = document.createElement('script')
script.src = url
script.async = true
script.type = 'text/javascript'
window[jsonpCallback] = function(data) {
success && success(data)
}
document.body.appendChild(script)
}
jsonp('http://xxx', 'callback', function(value) {
console.log(value)
})
CORS 需要浏览器和后端同时支持。IE 8 和 9 需要通过 XDomainRequest
来实现。
浏览器会自动进行 CORS 通信,实现 CORS 通信的关键是后端。只要后端实现了 CORS,就实现了跨域。
服务端设置 Access-Control-Allow-Origin
就可以开启 CORS。 该属性表示哪些域名可以访问资源,如果设置通配符则表示所有网站都可以访问资源。
虽然设置 CORS 和前端没什么关系,但是通过这种方式解决跨域问题的话,会在发送请求时出现两种情况,分别为简单请求和复杂请求。
另外,如果面试官问:“CORS为什么支持跨域的通信?”
答案:
以 Ajax 为例,当满足以下条件时,会触发简单请求
GET
HEAD
POST
Content-Type
的值仅限于下列三者之一:
text/plain
multipart/form-data
application/x-www-form-urlencoded
请求中的任意 XMLHttpRequestUpload
对象均没有注册任何事件监听器; XMLHttpRequestUpload
对象可以使用 XMLHttpRequest.upload
属性访问。
那么很显然,不符合以上条件的请求就肯定是复杂请求了。
对于复杂请求来说,首先会发起一个预检请求,该请求是 option
方法的,通过该请求来知道服务端是否允许跨域请求。
对于预检请求来说,如果你使用过 Node 来设置 CORS 的话,可能会遇到过这么一个坑。
以下以 express 框架举例:
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*')
res.header('Access-Control-Allow-Methods', 'PUT, GET, POST, DELETE, OPTIONS')
res.header(
'Access-Control-Allow-Headers',
'Origin, X-Requested-With, Content-Type, Accept, Authorization, Access-Control-Allow-Credentials'
)
next()
})
该请求会验证你的 Authorization
字段,没有的话就会报错。
当前端发起了复杂请求后,你会发现就算你代码是正确的,返回结果也永远是报错的。因为预检请求也会进入回调中,也会触发 next
方法,因为预检请求并不包含 Authorization
字段,所以服务端会报错。
想解决这个问题很简单,只需要在回调中过滤 option
方法即可
res.statusCode = 204
res.setHeader('Content-Length', '0')
res.end()
该方式只能用于二级域名相同的情况下,比如 a.test.com
和 b.test.com
适用于该方式。
只需要给页面添加 document.domain = 'test.com'
表示二级域名都相同就可以实现跨域
H5中新增的postMessage()方法,可以用来做跨域通信。既然是H5中新增的,那就一定要提到。
场景: 窗口 A (http:A.com
)向跨域的窗口 B (http:B.com
)发送信息。步骤如下。
(1)在A窗口中操作如下:向B窗口发送数据:
// 窗口A(http:A.com)向跨域的窗口B(http:B.com)发送信息
Bwindow.postMessage('data', 'http://B.com'); //这里强调的是B窗口里的window对象
(2)在B窗口中操作如下:
// 在窗口B中监听 message 事件
Awindow.addEventListener('message', function (event) {
//这里强调的是A窗口里的window对象
console.log(event.origin); //获取 :url。这里指:http://A.com
console.log(event.source); //获取:A window对象
console.log(event.data); //获取传过来的数据
}, false);
这种方式通常用于获取嵌入页面中的第三方页面数据。一个页面发送消息,另一个页面判断来源并接收消息
// 发送消息端
window.parent.postMessage('message', 'http://test.com')
// 接收消息端
var mc = new MessageChannel()
mc.addEventListener('message', event => {
var origin = event.origin || event.originalEvent.origin
if (origin === 'http://test.com') {
console.log('验证通过')
}
})
url的#
后面的内容就叫Hash。Hash的改变,页面不会刷新。这就是用 Hash 做跨域通信的基本原理。
补充:url的?
后面的内容叫Search。Search的改变,会导致页面刷新,因此不能做跨域通信。
使用举例:
**场景:**我的页面 A 通过iframe或frame嵌入了跨域的页面 B。
现在,我这个A页面想给B页面发消息,怎么操作呢?
(1)首先,在我的A页面中:
//伪代码
var B = document.getElementsByTagName('iframe');
B.src = B.src + '#' + 'jsonString'; //我们可以把JS 对象,通过 JSON.stringify()方法转成 json字符串,发给 B
(2)然后,在B页面中:
// B中的伪代码
window.onhashchange = function () {
//通过onhashchange方法监听,url中的 hash 是否发生变化
var data = window.location.hash;
};
全双工(full-duplex)通信自然可以实现多个标签页之间的通信,
相信网上通过websocket实现聊天室的教程也不少(用来实现双向通信,客户端和服务端实时通信)
//初始化一个node项目:node init,一路确认就可以,文件夹会自动创建一个package.json文件
监听
//获得WebSocketServerr类型
var WebSocketServer = require(‘ws’).Server;
//创建WebSocketServer对象实例,监听指定端口
var wss = new WebSocketServer({
port:8080 });
//创建保存所有已连接到服务器的客户端对象的数组
var clients=[];
//为服务器添加connection事件监听,当有客户端连接到服务端时,立刻将客户端对象保存进数组中
wss.on('connection', function (client) {
console.log("一个客户端连接到服务器")
if(clients.indexOf(client)===-1){
//如果是首次连接
clients.push(client) //就将当前连接保存到数组备用
console.log("有"+clients.length+"客户端在线")
//为每个client对象绑定message事件,当某个客户端发来消息时,自动触发
client.on('message',function(msg){
console.log('收到消息'+msg)
//遍历clients数组中每个其他客户端对象,并发送消息给其他客户端
for(var c of clients){
if(c!=client){
//把消息发给别人
c.send(msg);
}
}
})
}
})
a页面发送信息
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<!-- 这个页面是用来发送信息的 -->
<input type="text" id="msg">
<button id="send">发送</button>
<script>
//建立到服务端webSoket连接
var ws=new WebSocket("ws://localhost:8080")
send.onclick=function(){
if(msg.value.trim()!=''){
//如果msg输入框内容不是空的
ws.send(msg.value.trim()) //将msg输入框中的内容发送给服务器
}
}
</script>
</body>
</html>
接收a页面发送信息
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<!-- 这个标签页是用来接收信息的 -->
<h1 >收到的消息:<p id="recMsg"></p></h1>
<script>
//建立到服务端webSoket连接
var ws=new WebSocket("ws://localhost:8080")
//当连接被打开时,注册接收消息的处理函数
ws.onopen=function(event) {
//当有消息发过来时,就将消息放到显示元素上
ws.onmessage=function(event) {
recMsg.innerHTML=event.data;
}
}
</script>
</body>
</html>
localstorage是浏览器多个标签共用的存储空间,所以可以用来实现多标签之间的通信
(ps:session是会话级的存储空间,每个标签页都是单独的)。
直接在window对象上添加监听即可:
window.onstorage = (e) => {console.log(e)}
// 或者这样
window.addEventListener(‘storage’, (e) => console.log(e))
onstorage以及storage事件,针对都是非当前页面对localStorage进行修改时才会触发,
当前页面修改localStorage不会触发监听函数。然后就是在对原有的数据的值进行修改时才会触发,
比如原本已经有一个key会a值为b的localStorage,
你再执行:localStorage.setItem(‘a’, ‘b’)代码,同样是不会触发监听函数的。
涉及面试题:有几种方式可以实现存储功能,分别有什么优缺点?什么是 Service Worker?
我们先来通过表格学习下这几种存储方式的区别
特性 | cookie | localStorage | sessionStorage | indexDB |
---|---|---|---|---|
数据生命周期 | 一般由服务器生成,可以设置过期时间 | 除非被清理,否则一直存在 | 页面关闭就清理 | 除非被清理,否则一直存在 |
数据存储大小 | 4K | 5M | 5M | 无限 |
与服务端通信 | 每次都会携带在 header 中,对于请求性能影响 | 不参与 | 不参与 | 不参与 |
从上表可以看到,cookie
已经不建议用于存储。如果没有大量数据存储需求的话,可以使用 localStorage
和 sessionStorage
。对于不怎么改变的数据尽量使用 localStorage
存储,否则可以用 sessionStorage
存储。
对于 cookie
来说,我们还需要注意安全性。
属性 | 作用 |
---|---|
value | 如果用于保存用户登录态,应该将该值加密,不能使用明文的用户标识 |
http-only | 不能通过 JS 访问 Cookie,减少 XSS 攻击 |
secure | 只能在协议为 HTTPS 的请求中携带 |
same-site | 规定浏览器不能在跨域请求中携带 Cookie,减少 CSRF 攻击 |
html5中的Web Storage包括了两种存储方式:sessionStorage和localStorage。
sessionStorage用于本地存储—个会话(session)中的数据,这些数据只有在同—个会话中的页面才能访问并且当会话结束后数据也随之销毁。因此sessionStorage不是—种持久化的本地存储,仅仅是会话级别的存储。
而localStorage用于持久化的本地存储,除非主动删除数据,否则数据是永远不会过期的;
cookie是网站为了标示用户身份而储存在用户本地终端(Client Side)上的数据(通常经过加密)。window.localStorage.removeItem(‘key’)
区别:
1、 cookie数据始终在同源的http请求中携带(即使不需要),即cookie在浏览器和服务器间来回传递。
而sessionStorage和localStorage不会自动把数据发给服务器,仅在本地保存。
cookie数据还有路径(path)的概念,可以限制cookie只属于某个路径下。
2、 存储大小限制也不同,
cookie数据不能超过4k,同时因为每次http请求都会携带cookie,所以cookie只适合保存很小的数据,如会话标识。
sessionStorage和localStorage 虽然也有存储大小的限制,但比cookie大得多,可以达到5M或更大。
3、 数据有效期不同,
sessionStorage:仅在当前浏览器窗口关闭前有效,自然也就不可能持久保持;
localStorage:始终有效,窗口或浏览器关闭也—直保存,因此用作持久数据;
cookie只在设置的cookie过期时间之前—直有效,即使窗口或浏览器关闭。
4、 作用域不同,
sessionStorage不在不同的浏览器窗口中共享,即使是同—个页面;
localStorage 在所有同源窗口中都是共享的;
cookie也是在所有同源窗口中都是共享的。
cookie
、session
、token
、OAuth
cookie 重要的属性属性说明name=value键值对,设置 Cookie 的名称及相对应的值,都必须是字符串类型
/abc
,则只有 /abc
下的路由可以访问到该 cookie,如:/abc/read
。maxAgecookie 失效的时间,单位秒。如果为整数,则该 cookie 在 maxAge 秒后失效。如果为负数,该 cookie 为临时 cookie ,关闭浏览器即失效,浏览器也不会以任何形式保存该 cookie 。如果为 0,表示删除该 cookie 。默认为 -1。session.png
根据以上流程可知,SessionID 是连接 Cookie 和 Session 的一道桥梁,大部分系统也是根据此原理来验证用户登录状态。
Acesss Token
Refresh Token
生成 JWT
jwt.io/www.jsonwebtoken.io/
JWT 的原理
JWT 认证流程:
post/user/login登录输入用户名密码进行登录
服务器端使用密钥创建JWT
把JWT返回给浏览器
发送请求时
在发给浏览器的认识头里面发送JWT
服务
会检查JWT的签名,从JWT获取用户信息
把响应发送给客户端
客户端将 token 保存到本地(通常使用 localstorage,也可以使用 cookie)
当用户希望访问一个受保护的路由或者资源的时候,需要请求头的 Authorization 字段中使用Bearer 模式添加 JWT,其内容看起来是下面这样
Authorization: Bearer复制代码
服务端的保护路由将会检查请求头 Authorization 中的 JWT 信息,如果合法,则允许用户的行为
因为 JWT 是自包含的(内部包含了一些会话信息),因此减少了需要查询数据库的需要
因为 JWT 并不使用 Cookie 的,所以你可以使用任何域名提供你的 API 服务而不需要担心跨域资源共享问题(CORS)
因为用户的状态不再存储在服务端的内存中,所以这是一种无状态的认证机制
JWT 的使用方式
方式一
GET /calendar/v1/events
Host: api.example.com
Authorization: Bearer
方式二
方式三
http://www.example.com/user?token=xxx
项目中使用 JWT
**项目地址: https://github.com/yjdjiayou/jwt-demo **
相同:
区别:
Session-Cookie
Token
验证(包括 JWT
,SSO
)OAuth2.0
(开放授权)image.png
注意:
注意:该知识点属于性能优化领域,并且整一章节都是一个面试题。
缓存可以说是性能优化中简单高效的一种优化方式了,它可以显著减少网络传输所带来的损耗。
对于一个数据请求来说,可以分为发起网络请求、后端处理、浏览器响应三个步骤。浏览器缓存可以帮助我们在第一和第三步骤中优化性能。比如说直接使用缓存而不发起请求,或者发起了请求但后端存储的数据和前端一致,那么就没有必要再将数据回传回来,这样就减少了响应数据。
接下来的内容中我们将通过以下几个部分来探讨浏览器缓存机制:
从缓存位置上来说分为四种,并且各自有优先级,当依次查找缓存且都没有命中的时候,才会去请求网络
在上一章节中我们已经介绍了 Service Worker 的内容,这里就不演示相关的代码了。
Service Worker 的缓存与浏览器其他内建的缓存机制不同,它可以让我们自由控制缓存哪些文件、如何匹配缓存、如何读取缓存,并且缓存是持续性的。
当 Service Worker 没有命中缓存的时候,我们需要去调用 fetch
函数获取数据。也就是说,如果我们没有在 Service Worker 命中缓存的话,会根据缓存查找优先级去查找数据。但是不管我们是从 Memory Cache 中还是从网络请求中获取的数据,浏览器都会显示我们是从 Service Worker 中获取的内容。
Service Worker 是运行在浏览器背后的独立线程,一般可以用来实现缓存功能。使用 Service Worker的话,传输协议必须为 HTTPS。因为 Service Worker 中涉及到请求拦截,所以必须使用 HTTPS 协议来保障安全。
Service Worker 实现缓存功能一般分为三个步骤:首先需要先注册 Service Worker,然后监听到 install
事件以后就可以缓存需要的文件,那么在下次用户访问的时候就可以通过拦截请求的方式查询是否存在缓存,存在缓存的话就可以直接读取缓存文件,否则就去请求数据。以下是这个步骤的实现:
// index.js
if (navigator.serviceWorker) {
navigator.serviceWorker
.register('sw.js')
.then(function(registration) {
console.log('service worker 注册成功')
})
.catch(function(err) {
console.log('servcie worker 注册失败')
})
}
// sw.js
// 监听 `install` 事件,回调中缓存所需文件
self.addEventListener('install', e => {
e.waitUntil(
caches.open('my-cache').then(function(cache) {
return cache.addAll(['./index.html', './index.js'])
})
)
})
// 拦截所有请求事件
// 如果缓存中已经有请求的数据就直接用缓存,否则去请求数据
self.addEventListener('fetch', e => {
e.respondWith(
caches.match(e.request).then(function(response) {
if (response) {
return response
}
console.log('fetch source')
})
)
})
打开页面,可以在开发者工具中的 Application
看到 Service Worker 已经启动了
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OVgQU7Mj-1597842009426)(file:///C:/Users/29150/Desktop/%E5%89%8D%E7%AB%AF%E9%9D%A2%E8%AF%95%E4%B9%8B%E9%81%93/11-%E6%B5%8F%E8%A7%88%E5%99%A8%E5%9F%BA%E7%A1%80%E7%9F%A5%E8%AF%86%E7%82%B9%E5%8F%8A%E5%B8%B8%E8%80%83%E9%9D%A2%E8%AF%95%E9%A2%98_files/1626b1e8eba68e1c)]
在 Cache 中也可以发现我们所需的文件已被缓存
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Nick7uKW-1597842009427)(file:///C:/Users/29150/Desktop/%E5%89%8D%E7%AB%AF%E9%9D%A2%E8%AF%95%E4%B9%8B%E9%81%93/11-%E6%B5%8F%E8%A7%88%E5%99%A8%E5%9F%BA%E7%A1%80%E7%9F%A5%E8%AF%86%E7%82%B9%E5%8F%8A%E5%B8%B8%E8%80%83%E9%9D%A2%E8%AF%95%E9%A2%98_files/1626b20dfc4fcd26)]
当我们重新刷新页面可以发现我们缓存的数据是从 Service Worker 中读取的
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KQsdwaav-1597842009428)(file:///C:/Users/29150/Desktop/%E5%89%8D%E7%AB%AF%E9%9D%A2%E8%AF%95%E4%B9%8B%E9%81%93/11-%E6%B5%8F%E8%A7%88%E5%99%A8%E5%9F%BA%E7%A1%80%E7%9F%A5%E8%AF%86%E7%82%B9%E5%8F%8A%E5%B8%B8%E8%80%83%E9%9D%A2%E8%AF%95%E9%A2%98_files/1626b20e4f8f3257)]
以上就是浏览器基础知识点的内容了。
Memory Cache 也就是内存中的缓存,读取内存中的数据肯定比磁盘快。但是内存缓存虽然读取高效,可是缓存持续性很短,会随着进程的释放而释放。 一旦我们关闭 Tab 页面,内存中的缓存也就被释放了。
当我们访问过页面以后,再次刷新页面,可以发现很多数据都来自于内存缓存
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SmlPIJFR-1597842009429)(file:///C:/Users/29150/Desktop/%E5%89%8D%E7%AB%AF%E9%9D%A2%E8%AF%95%E4%B9%8B%E9%81%93/12-%E6%B5%8F%E8%A7%88%E5%99%A8%E7%BC%93%E5%AD%98%E6%9C%BA%E5%88%B6_files/1677db8003dc8311)]
从内存中读取缓存
那么既然内存缓存这么高效,我们是不是能让数据都存放在内存中呢?
先说结论,这是不可能的。首先计算机中的内存一定比硬盘容量小得多,操作系统需要精打细算内存的使用,所以能让我们使用的内存必然不多。内存中其实可以存储大部分的文件,比如说 JSS、HTML、CSS、图片等等。但是浏览器会把哪些文件丢进内存这个过程就很玄学了,我查阅了很多资料都没有一个定论。
当然,我通过一些实践和猜测也得出了一些结论:
Disk Cache 也就是存储在硬盘中的缓存,读取速度慢点,但是什么都能存储到磁盘中,比之 Memory Cache 胜在容量和存储时效性上。
在所有浏览器缓存中,Disk Cache 覆盖面基本是最大的。它会根据 HTTP Herder 中的字段判断哪些资源需要缓存,哪些资源可以不请求直接使用,哪些资源已经过期需要重新请求。并且即使在跨站点的情况下,相同地址的资源一旦被硬盘缓存下来,就不会再次去请求数据。
Push Cache 是 HTTP/2 中的内容,当以上三种缓存都没有命中时,它才会被使用。并且缓存时间也很短暂,只在会话(Session)中存在,一旦会话结束就被释放。
Push Cache 在国内能够查到的资料很少,也是因为 HTTP/2 在国内不够普及,但是 HTTP/2 将会是日后的一个趋势。这里推荐阅读 HTTP/2 push is tougher than I thought 这篇文章,但是内容是英文的,我翻译一下文章中的几个结论,有能力的同学还是推荐自己阅读
no-cache
和 no-store
的资源如果所有缓存都没有命中的话,那么只能发起请求来获取资源了。
那么为了性能上的考虑,大部分的接口都应该选择好缓存策略,接下来我们就来学习缓存策略这部分的内容。
通常浏览器缓存策略分为两种:强缓存和协商缓存,并且缓存策略都是通过设置 HTTP Header 来实现的。
强缓存可以通过设置两种 HTTP Header 实现:Expires
和 Cache-Control
。强缓存表示在缓存期间不需要请求,state code
为 200。
Expires: Wed, 22 Oct 2018 08:41:00 GMT
Expires
是 HTTP/1 的产物,表示资源会在 Wed, 22 Oct 2018 08:41:00 GMT
后过期,需要再次请求。并且 Expires
受限于本地时间,如果修改了本地时间,可能会造成缓存失效。
Cache-control: max-age=30
Cache-Control
出现于 HTTP/1.1,优先级高于 Expires
。该属性值表示资源会在 30 秒后过期,需要再次请求。
Cache-Control
可以在请求头或者响应头中设置,并且可以组合使用多种指令
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6tNMzEap-1597842009429)(file:///C:/Users/29150/Desktop/%E5%89%8D%E7%AB%AF%E9%9D%A2%E8%AF%95%E4%B9%8B%E9%81%93/12-%E6%B5%8F%E8%A7%88%E5%99%A8%E7%BC%93%E5%AD%98%E6%9C%BA%E5%88%B6_files/1678234a1ed20487)]
多种指令配合流程图
从图中我们可以看到,我们可以将多个指令配合起来一起使用,达到多个目的。比如说我们希望资源能被缓存下来,并且是客户端和代理服务器都能缓存,还能设置缓存失效时间等等。
接下来我们就来学习一些常见指令的作用
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SHbPJ0up-1597842009431)(file:///C:/Users/29150/Desktop/%E5%89%8D%E7%AB%AF%E9%9D%A2%E8%AF%95%E4%B9%8B%E9%81%93/12-%E6%B5%8F%E8%A7%88%E5%99%A8%E7%BC%93%E5%AD%98%E6%9C%BA%E5%88%B6_files/1677ef2cd7bf1bba)]
常见指令作用
如果缓存过期了,就需要发起请求验证资源是否有更新。协商缓存可以通过设置两种 HTTP Header 实现:Last-Modified
和 ETag
。
当浏览器发起请求验证资源时,如果资源没有做改变,那么服务端就会返回 304 状态码,并且更新浏览器缓存有效期。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Gq6U7fsi-1597842009432)(file:///C:/Users/29150/Desktop/%E5%89%8D%E7%AB%AF%E9%9D%A2%E8%AF%95%E4%B9%8B%E9%81%93/12-%E6%B5%8F%E8%A7%88%E5%99%A8%E7%BC%93%E5%AD%98%E6%9C%BA%E5%88%B6_files/16782357baddf1c6)]
协商缓存
Last-Modified
表示本地文件最后修改日期,If-Modified-Since
会将 Last-Modified
的值发送给服务器,询问服务器在该日期后资源是否有更新,有更新的话就会将新的资源发送回来,否则返回 304 状态码。
last-Modified 是一个响应首部,其中包含源头服务器认定的资源做出修改的日期及时间。 它通常被用作一个验证器来判断接收到的或者存储的资源是否彼此一致。由于精确度比 ETag 要低,所以这是一个备用机制。包含有 If-Modified-Since 或 If-Unmodified-Since 首部的条件请求会使用这个字段。
基于客户端和服务端协商的缓存机制
Last-Modified ----response header
If-Modified-Since----request header
需要与cache-control共同使用
max-age的优先级高于Last-Modified
缺点
某些服务端不能获取精确的修改时间
文件修改时间改了,但文件内容却没有变
因为以上这些弊端,所以在 HTTP / 1.1 出现了 ETag
。
ETag
类似于文件指纹,If-None-Match
会将当前 ETag
发送给服务器,询问该资源 ETag
是否变动,有变动的话就将新的资源发送回来。并且 ETag
优先级比 Last-Modified
高。
ETagHTTP响应头是资源的特定版本的标识符。这可以让缓存更高效,并节省带宽,因为如果内容没有改变,Web服务器不需要发送完整的响应。而如果内容发生了变化,使用ETag有助于防止资源的同时更新相互覆盖(“空中碰撞”)
文件内容的hash值
etag–response header
if-none-match – request header
要与cache-control共同使用
4.6.4两者对比
首先在精确度上,Etag要优于Last-Modified。
第二在性能上,Etag要逊于Last-Modified,毕竟Last-Modified只需要记录时间,而Etag需要服务器通过算法来计算出一个hash值。
第三在优先级上,服务器校验优先考虑Etag
4.6.2 Last-Modified和If-Modified-Since
4.6.3 Etag/If-None-Match
单纯了解理论而不付诸于实践是没有意义的,接下来我们来通过几个场景学习下如何使用这些理论。
对于频繁变动的资源,首先需要使用 Cache-Control: no-cache
使浏览器每次都请求服务器,然后配合 ETag
或者 Last-Modified
来验证资源是否有效。这样的做法虽然不能节省请求数量,但是能显著减少响应数据大小。
这里特指除了 HTML 外的代码文件,因为 HTML 文件一般不缓存或者缓存时间很短。
一般来说,现在都会使用工具来打包代码,那么我们就可以对文件名进行哈希处理,只有当代码修改后才会生成新的文件名。基于此,我们就可以给代码文件设置缓存有效期一年 Cache-Control: max-age=31536000
,这样只有当 HTML 文件中引入的文件名发生了改变才会去下载最新的代码文件,否则就一直使用缓存。
在这一章节中我们了解了浏览器的缓存机制,并且列举了几个场景来实践我们学习到的理论。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7ciZCGeb-1597842009433)(C:\Users\29150\AppData\Roaming\Typora\typora-user-images\image-20200527093939322.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AoNkVx6r-1597842009433)(C:\Users\29150\AppData\Roaming\Typora\typora-user-images\image-20200527094224327.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AOPw5qTl-1597842009434)(C:\Users\29150\AppData\Roaming\Typora\typora-user-images\image-20200527094342097.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2TOv7UHn-1597842009435)(C:\Users\29150\AppData\Roaming\Typora\typora-user-images\image-20200527094409255.png)]
大体流程如下:
1.HTML和CSS经过各自解析,生成DOM树和CSSOM树
2.合并成为渲染树
3.根据渲染树进行布局
4.最后调用GPU进行绘制,显示在屏幕上
当我们打开一个网页时,浏览器都会去请求对应的 HTML 文件。虽然平时我们写代码时都会分为 JS、CSS、HTML 文件,也就是字符串,但是计算机硬件是不理解这些字符串的,所以在网络中传输的内容其实都是 0
和 1
这些字节数据。当浏览器接收到这些字节数据以后,它会将这些字节数据转换为字符串,也就是我们写的代码。
当数据转换为字符串以后,浏览器会先将这些字符串通过词法分析转换为标记(token),这一过程在词法分析中叫做标记化(tokenization)。
那么什么是标记呢?这其实属于编译原理这一块的内容了。简单来说,标记还是字符串,是构成代码的最小单位。这一过程会将代码分拆成一块块,并给这些内容打上标记,便于理解这些最小单位的代码是什么意思。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iXdIsM5d-1597842009436)(file:///C:/Users/29150/Desktop/%E5%89%8D%E7%AB%AF%E9%9D%A2%E8%AF%95%E4%B9%8B%E9%81%93/13-%E6%B5%8F%E8%A7%88%E5%99%A8%E6%B8%B2%E6%9F%93%E5%8E%9F%E7%90%86_files/167540a7b5cef612)]
当结束标记化后,这些标记会紧接着转换为 Node,最后这些 Node 会根据不同 Node 之前的联系构建为一颗 DOM 树。
以上就是浏览器从网络中接收到 HTML 文件然后一系列的转换过程。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-w9lsXloL-1597842009437)(file:///C:/Users/29150/Desktop/%E5%89%8D%E7%AB%AF%E9%9D%A2%E8%AF%95%E4%B9%8B%E9%81%93/13-%E6%B5%8F%E8%A7%88%E5%99%A8%E6%B8%B2%E6%9F%93%E5%8E%9F%E7%90%86_files/167542b09875a74a)]
其实转换 CSS 到 CSSOM 树的过程和上一小节的过程是极其类似的
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vWKM5h5l-1597842009437)(file:///C:/Users/29150/Desktop/%E5%89%8D%E7%AB%AF%E9%9D%A2%E8%AF%95%E4%B9%8B%E9%81%93/13-%E6%B5%8F%E8%A7%88%E5%99%A8%E6%B8%B2%E6%9F%93%E5%8E%9F%E7%90%86_files/167542a9af5f193f)]
在这一过程中,浏览器会确定下每一个节点的样式到底是什么,并且这一过程其实是很消耗资源的。因为样式你可以自行设置给某个节点,也可以通过继承获得。在这一过程中,浏览器得递归 CSSOM 树,然后确定具体的元素到底是什么样式。
如果你有点不理解为什么会消耗资源的话,我这里举个例子
对于第一种设置样式的方式来说,浏览器只需要找到页面中所有的 span
标签然后设置颜色,但是对于第二种设置样式的方式来说,浏览器首先需要找到所有的 span
标签,然后找到 span
标签上的 a
标签,最后再去找到 div
标签,然后给符合这种条件的 span
标签设置颜色,这样的递归过程就很复杂。所以我们应该尽可能的避免写过于具体的 CSS 选择器,然后对于 HTML 来说也尽量少的添加无意义标签,保证层级扁平。
当我们生成 DOM 树和 CSSOM 树以后,就需要将这两棵树组合为渲染树。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OMd19J2Q-1597842009438)(file:///C:/Users/29150/Desktop/%E5%89%8D%E7%AB%AF%E9%9D%A2%E8%AF%95%E4%B9%8B%E9%81%93/13-%E6%B5%8F%E8%A7%88%E5%99%A8%E6%B8%B2%E6%9F%93%E5%8E%9F%E7%90%86_files/16754488529c48bd)]
在这一过程中,不是简单的将两者合并就行了。渲染树只会包括需要显示的节点和这些节点的样式信息,如果某个节点是 display: none
的,那么就不会在渲染树中显示。
当浏览器生成渲染树以后,就会根据渲染树来进行布局(也可以叫做回流),然后调用 GPU 绘制,合成图层,显示在屏幕上。对于这一部分的内容因为过于底层,还涉及到了硬件相关的知识,这里就不再继续展开内容了。
那么通过以上内容,我们已经详细了解到了浏览器从接收文件到将内容渲染在屏幕上的这一过程。接下来,我们将会来学习上半部分遗留下来的一些知识点。
想必大家都听过操作 DOM 性能很差,但是这其中的原因是什么呢?
因为 DOM 是属于渲染引擎中的东西,而 JS 又是 JS 引擎中的东西。当我们通过 JS 操作 DOM 的时候,其实这个操作涉及到了两个线程之间的通信,那么势必会带来一些性能上的损耗。操作 DOM 次数一多,也就等同于一直在进行线程之间的通信,并且操作 DOM 可能还会带来重绘回流的情况,所以也就导致了性能上的问题。
经典面试题:插入几万个 DOM,如何实现页面不卡顿?
对于这道题目来说,首先我们肯定不能一次性把几万个 DOM 全部插入,这样肯定会造成卡顿,所以解决问题的重点应该是如何分批次部分渲染 DOM。大部分人应该可以想到通过 requestAnimationFrame
的方式去循环的插入 DOM,其实还有种方式去解决这个问题:虚拟滚动(virtualized scroller)。
这种技术的原理就是只渲染可视区域内的内容,非可见区域的那就完全不渲染了,当用户在滚动的时候就实时去替换渲染的内容。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jpOI3ekQ-1597842009439)(file:///C:/Users/29150/Desktop/%E5%89%8D%E7%AB%AF%E9%9D%A2%E8%AF%95%E4%B9%8B%E9%81%93/13-%E6%B5%8F%E8%A7%88%E5%99%A8%E6%B8%B2%E6%9F%93%E5%8E%9F%E7%90%86_files/167b1c6887ecbba7)]
从上图中我们可以发现,即使列表很长,但是渲染的 DOM 元素永远只有那么几个,当我们滚动页面的时候就会实时去更新 DOM,这个技术就能顺利解决这道经典面试题。如果你想了解更多的内容可以了解下这个 react-virtualized。
首先渲染的前提是生成渲染树,所以 HTML 和 CSS 肯定会阻塞渲染。如果你想渲染的越快,你越应该降低一开始需要渲染的文件大小,并且扁平层级,优化选择器。
然后当浏览器在解析到 script
标签时,会暂停构建 DOM,完成后才会从暂停的地方重新开始。也就是说,如果你想首屏渲染的越快,就越不应该在首屏就加载 JS 文件,这也是都建议将 script
标签放在 body
标签底部的原因。
当然在当下,并不是说 script
标签必须放在底部,因为你可以给 script
标签添加 defer
或者 async
属性。
当 script
标签加上 defer
属性以后,表示该 JS 文件会并行下载,但是会放到 HTML 解析完成后顺序执行,所以对于这种情况你可以把 script
标签放在任意位置。
对于没有任何依赖的 JS 文件可以加上 async
属性,表示 JS 文件下载和解析不会阻塞渲染。
重绘和回流会在我们设置节点样式时频繁出现,同时也会很大程度上影响性能。
color
就叫称为重绘回流必定会发生重绘,重绘不一定会引发回流。回流所需的成本比重绘高的多,改变父节点里的子节点很可能会导致父节点的一系列回流。
以下几个动作可能会导致性能问题:
window
大小并且很多人不知道的是,重绘和回流其实也和 Eventloop 有关。
document
是否需要更新,因为浏览器是 60Hz 的刷新率,每 16.6ms 才会更新一次。resize
或者 scroll
事件,有的话会去触发事件,所以 resize
和 scroll
事件也是至少 16ms 才会触发一次,并且自带节流功能。requestAnimationFrame
回调IntersectionObserver
回调,该方法用于判断元素是否可见,可以用于懒加载上,但是兼容性不好requestIdleCallback
回调。以上内容来自于 HTML 文档。
既然我们已经知道了重绘和回流会影响性能,那么接下来我们将会来学习如何减少重绘和回流的次数。
transform
替代 top
使用 visibility
替换 display: none
,因为前者只会引起重绘,后者会引发回流(改变了布局)
不要把节点的属性值放在一个循环里当成循环里的变量
for(let i = 0; i < 1000; i++) {
// 获取 offsetTop 会导致回流,因为需要去获取正确的值
console.log(document.querySelector('.test').style.offsetTop)
}
不要使用 table
布局,可能很小的一个小改动会造成整个 table
的重新布局
动画实现的速度的选择,动画速度越快,回流次数越多,也可以选择使用 requestAnimationFrame
CSS 选择符从右往左匹配查找,避免节点层级过多
将频繁重绘或者回流的节点设置为图层,图层能够阻止该节点的渲染行为影响别的节点。比如对于 video
标签来说,浏览器会自动将该节点变为图层。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UQ1GxWhw-1597842009439)(file:///C:/Users/29150/Desktop/%E5%89%8D%E7%AB%AF%E9%9D%A2%E8%AF%95%E4%B9%8B%E9%81%93/13-%E6%B5%8F%E8%A7%88%E5%99%A8%E6%B8%B2%E6%9F%93%E5%8E%9F%E7%90%86_files/1626fb6f33a6f9d7)]
设置节点为图层的方式有很多,我们可以通过以下几个常用属性可以生成新图层
will-change
video
、iframe
标签思考题:在不考虑缓存和优化网络协议的前提下,考虑可以通过哪些方式来最快的渲染页面,也就是常说的关键渲染路径,这部分也是性能优化中的一块内容。
首先你可能会疑问,那怎么测量到底有没有加快渲染速度呢
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eSQUpLsN-1597842009440)(file:///C:/Users/29150/Desktop/%E5%89%8D%E7%AB%AF%E9%9D%A2%E8%AF%95%E4%B9%8B%E9%81%93/13-%E6%B5%8F%E8%A7%88%E5%99%A8%E6%B8%B2%E6%9F%93%E5%8E%9F%E7%90%86_files/16754b5a3511198f)]
当发生 DOMContentLoaded
事件后,就会生成渲染树,生成渲染树就可以进行渲染了,这一过程更大程度上和硬件有关系了。
提示如何加速:
script
标签使用上来考虑以上提示大家都可以从文中找到,同时也欢迎大家踊跃在评论区写出你的答案。
以上就是我们这一章节的内容了。在这一章节中,我们了解了浏览器如何将文件渲染为页面,同时也掌握了一些优化的小技巧。
这一章我们将来学习安全防范这一块的知识点。总的来说安全是很复杂的一个领域,不可能通过一个章节就能学习到这部分的内容。在这一章节中,我们会学习到常见的一些安全问题及如何防范的内容,在当下其实安全问题越来越重要,已经逐渐成为前端开发必备的技能了。
涉及面试题:什么是 XSS 攻击?如何防范 XSS 攻击?什么是 CSP?
XSS 简单点来说,就是攻击者想尽一切办法将可以执行的代码注入到网页中。
XSS 可以分为多种类型,但是总体上我认为分为两类:持久型和非持久型。
持久型也就是攻击的代码被服务端写入进数据库中,这种攻击危害性很大,因为如果网站访问量很大的话,就会导致大量正常访问页面的用户都受到攻击。
举个例子,对于评论功能来说,就得防范持久型 XSS 攻击,因为我可以在评论中输入以下内容
这种情况如果前后端没有做好防御的话,这段评论就会被存储到数据库中,这样每个打开该页面的用户都会被攻击到。
非持久型相比于前者危害就小的多了,一般通过修改 URL 参数的方式加入攻击代码,诱导用户访问链接从而进行攻击。
举个例子,如果页面需要从 URL 中获取某些参数作为内容的话,不经过过滤就会导致攻击代码被执行
<!-- http://www.domain.com?name=<script>alert(1)</script> -->
<div>{
{
name}}</div>
但是对于这种攻击方式来说,如果用户使用 Chrome 这类浏览器的话,浏览器就能自动帮助用户防御攻击。但是我们不能因此就不防御此类攻击了,因为我不能确保用户都使用了该类浏览器。
对于 XSS 攻击来说,通常有两种方式可以用来防御。
首先,对于用户的输入应该是永远不信任的。最普遍的做法就是转义输入输出的内容,对于引号、尖括号、斜杠进行转义
function escape(str) {
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
}
通过转义可以将攻击代码 alert(1)
变成
// -> <script>alert(1)</script>
escape('')
但是对于显示富文本来说,显然不能通过上面的办法来转义所有字符,因为这样会把需要的格式也过滤掉。对于这种情况,通常采用白名单过滤的办法,当然也可以通过黑名单过滤,但是考虑到需要过滤的标签和标签属性实在太多,更加推荐使用白名单的方式。
const xss = require('xss')
let html = xss('XSS Demo
')
// -> XSS Demo
<script>alert("xss");</script>
console.log(html)
以上示例使用了 js-xss
来实现,可以看到在输出中保留了 h1
标签且过滤了 script
标签。
CSP 本质上就是建立白名单,开发者明确告诉浏览器哪些外部资源可以加载和执行。我们只需要配置规则,如何拦截是由浏览器自己实现的。我们可以通过这种方式来尽量减少 XSS 攻击。
通常可以通过两种方式来开启 CSP:
Content-Security-Policy
meta
标签的方式 ``这里以设置 HTTP Header 来举例
只允许加载本站资源
Content-Security-Policy: default-src ‘self’
只允许加载 HTTPS 协议图片
Content-Security-Policy: img-src https://*
允许加载任何来源框架
Content-Security-Policy: child-src 'none'
当然可以设置的属性远不止这些,你可以通过查阅 文档 的方式来学习,这里就不过多赘述其他的属性了。
对于这种方式来说,只要开发者配置了正确的规则,那么即使网站存在漏洞,攻击者也不能执行它的攻击代码,并且 CSP 的兼容性也不错。
涉及面试题:什么是 CSRF 攻击?如何防范 CSRF 攻击?
CSRF 中文名为跨站请求伪造。原理就是攻击者构造出一个后端请求地址,诱导用户点击或者通过某些途径自动发起请求。如果用户是在登录状态下的话,后端就以为是用户在操作,从而进行相应的逻辑。
举个例子,假设网站中有一个通过 GET
请求提交用户评论的接口,那么攻击者就可以在钓鱼网站中加入一个图片,图片的地址就是评论接口
那么你是否会想到使用 POST
方式提交请求是不是就没有这个问题了呢?其实并不是,使用这种方式也不是百分百安全的,攻击者同样可以诱导用户进入某个页面,在页面中通过表单提交 POST
请求。
防范 CSRF 攻击可以遵循以下几种规则:
可以对 Cookie 设置 SameSite
属性。该属性表示 Cookie 不随着跨域请求发送,可以很大程度减少 CSRF 的攻击,但是该属性目前并不是所有浏览器都兼容。
对于需要防范 CSRF 的请求,我们可以通过验证 Referer 来判断该请求是否为第三方网站发起的。
服务器下发一个随机 Token,每次发起请求时将 Token 携带上,服务器验证 Token 是否有效。
涉及面试题:什么是点击劫持?如何防范点击劫持?
点击劫持是一种视觉欺骗的攻击手段。攻击者将需要攻击的网站通过 iframe
嵌套的方式嵌入自己的网页中,并将 iframe
设置为透明,在页面中透出一个按钮诱导用户点击。
对于这种攻击方式,推荐防御的方法有两种。
X-FRAME-OPTIONS
是一个 HTTP 响应头,在现代浏览器有一个很好的支持。这个 HTTP 响应头 就是为了防御用 iframe
嵌套的点击劫持攻击。
该响应头有三个值可选,分别是
DENY
,表示页面不允许通过 iframe
的方式展示SAMEORIGIN
,表示页面可以在相同域名下通过 iframe
的方式展示ALLOW-FROM
,表示页面可以在指定来源的 iframe
中展示对于某些远古浏览器来说,并不能支持上面的这种方式,那我们只有通过 JS 的方式来防御点击劫持了。
以上代码的作用就是当通过 iframe
的方式加载页面时,攻击者的网页直接不显示所有内容了。
涉及面试题:什么是中间人攻击?如何防范中间人攻击?
中间人攻击是攻击方同时与服务端和客户端建立起了连接,并让对方认为连接是安全的,但是实际上整个通信过程都被攻击者控制了。攻击者不仅能获得双方的通信信息,还能修改通信信息。
通常来说不建议使用公共的 Wi-Fi,因为很可能就会发生中间人攻击的情况。如果你在通信的过程中涉及到了某些敏感信息,就完全暴露给攻击方了。
当然防御中间人攻击其实并不难,只需要增加一个安全通道来传输信息。HTTPS 就可以用来防御中间人攻击,但是并不是说使用了 HTTPS 就可以高枕无忧了,因为如果你没有完全关闭 HTTP 访问的话,攻击方可以通过某些方式将 HTTPS 降级为 HTTP 从而实现中间人攻击。
在这一章中,我们学习到了一些常见的前端安全方面的知识及如何防御这些攻击。但是安全的领域相当大,这些内容只是沧海一粟,如果大家对于安全有兴趣的话,可以阅读 这个仓库的内容 来学习和实践这方面的知识。
注意:该知识点属于性能优化领域
在学习如何性能优化之前,我们先来了解下如何测试性能问题,毕竟是先有问题才会去想着该如何改进。
CDN
var obj ={};
/**
* 按需加载JS
* @param {string} url 脚本地址
* @param {function} callback 回调函数
*/
export function dynamicLoadJs (url, callback) {
if(obj[url]){
callback();
return;
}
obj[url]=true;
var head = document.getElementsByTagName('head')[0]
var script = document.createElement('script')
script.type = 'text/javascript'
script.src = url
if (typeof (callback) === 'function') {
script.onload = script.onreadystatechange = function () {
if (!this.readyState || this.readyState === 'loaded' || this.readyState === 'complete') {
callback()
script.onload = script.onreadystatechange = null
}
}
}
head.appendChild(script)
}
每次加载资源后,需要缓存,防止重复多次加载;
//获取元素是否在可视区域
function isElementInViewport(el) {
var rect = el.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <=
(window.innerHeight || document.documentElement.clientHeight) &&
rect.right <=
(window.innerWidth || document.documentElement.clientWidth)
);
}
function checkImg() {
let imgs = document.querySelectorAll("img[lazy]");
Array.from(imgs).forEach(ele => {
if (isElementInViewport(ele)) {
loadImg(ele)
}
})
}
function loadImg(el) {
if (!el.src) {
let source = el.dataset.src;
el.src = source;
}
}
提前加载资源
预加载css
preload 是告诉浏览器页面必定需要的资源,浏览器一定会加载这些资源;
prefetch 是告诉浏览器页面可能需要的资源,浏览器不一定会加载这些资源。
preload 是确认会加载指定资源,如在我们的场景中,x-report.js 初始化后一定会加载 PcCommon.js 和 TabsPc.js, 则可以预先 preload 这些资源;
prefetch 是预测会加载指定资源,如在我们的场景中,我们在页面加载后会初始化首屏组件,当用户滚动页面时,会拉取第二屏的组件,若能预测用户行为,则可以 prefetch 下一屏的组件。
可以通过用meta信息来告知浏览器, 我这页面要做DNS预解析
可以使用link标签来强制对DNS做预解析:
https://http2.akamai.com/demo
HTTP2.0中所有加强性能的核心是二进制传输,在HTTP1.x中,我们是通过文本的方式传输数据。基于文本的方式传输数据存在很多缺陷,文本的表现形式有多样性,因此要做到健壮性考虑的场景必然有很多,但是二进制则不同,只有0和1的组合,因此选择了二进制传输,实现方便且健壮。
在HTTP2.0中引入了新的编码机制,所有传输的数据都会被分割,并采用二进制格式编码。
在HTTP1.0中,我们使用文本的形式传输header,在header中携带cookie的话,每次都需要重复传输几百到几千的字节,这着实是一笔不小的开销。
在HTTP2.0中,我们使用了HPACK(HTTP2头部压缩算法)压缩格式对传输的header进行编码,减少了header的大小。并在两端维护了索引表,用于记录出现过的header,后面在传输过程中就可以传输已经记录过的header的键名,对端收到数据后就可以通过键名找到对应的值。
在HTTP1.0中,我们经常会使用到雪碧图、使用多个域名等方式来进行优化,都是因为浏览器限制了同一个域名下的请求数量,当页面需要请求很多资源的时候,队头阻塞(Head of line blocking)会导致在达到最大请求时,资源需要等待其他资源请求完成后才能继续发送。
HTTP2.0中,有两个概念非常重要:帧(frame)和流(stream)。
帧是最小的数据单位,每个帧会标识出该帧属于哪个流,流是多个帧组成的数据流。
所谓多路复用,即在一个TCP连接中存在多个流,即可以同时发送多个请求,对端可以通过帧中的表示知道该帧属于哪个请求。在客户端,这些帧乱序发送,到对端后再根据每个帧首部的流标识符重新组装。通过该技术,可以避免HTTP旧版本的队头阻塞问题,极大提高传输性能。
在HTTP2.0中,服务端可以在客户端某个请求后,主动推送其他资源。
可以想象一下,某些资源客户端是一定会请求的,这时就可以采取服务端push的技术,提前给客户端推送必要的资源,就可以相对减少一点延迟时间。在浏览器兼容的情况下也可以使用prefetch。
HTTP2.0使用了tls的拓展ALPN做为协议升级,除此之外,HTTP2.0对tls的安全性做了近一步加强,通过黑名单机制禁用了几百种不再安全的加密算法。
注意:该知识点属于性能优化领域。
总的来说性能优化这个领域的很多内容都很碎片化,这一章节我们将来学习这些碎片化的内容。
对于一张 100 * 100 像素的图片来说,图像上有 10000 个像素点,如果每个像素的值是 RGBA 存储的话,那么也就是说每个像素有 4 个通道,每个通道 1 个字节(8 位 = 1个字节),所以该图片大小大概为 39KB(10000 * 1 * 4 / 1024)。
但是在实际项目中,一张图片可能并不需要使用那么多颜色去显示,我们可以通过减少每个像素的调色板来相应缩小图片的大小。
了解了如何计算图片大小的知识,那么对于如何优化图片,想必大家已经有 2 个思路了:
DNS 解析也是需要时间的,可以通过预解析的方式来预先获得域名所对应的 IP。
防抖是延迟执行
,而节流是间隔执行
,和防抖的区别在于,防抖每次触发事件都重置定时器,而节流在定时器到时间后再清空定时器,函数节流即每隔一段时间就执行一次
,实现原理为设置一个定时器,约定xx毫秒后执行事件,如果时间到了,那么执行函数并重置定时器
,
// func是用户传入需要防抖的函数
// wait是等待时间
const throttle = (func, wait = 50) => {
// 上一次执行该函数的时间
let lastTime = 0
return function(...args) {
// 当前时间
let now = +new Date()
// 将当前时间和上一次执行函数时间对比
// 如果差值大于设置的等待时间就执行函数
if (now - lastTime > wait) {
lastTime = now
func.apply(this, args)
}
}
}
setInterval(
throttle(() => {
console.log(1)
}, 500),
1
)
即短时间内大量触发同一事件,只会执行一次函数,防抖常用于搜索框/滚动条的监听事件处理,如果不做防抖,每输入一个字/滚动屏幕,都会触发事件处理,造成性能浪费;实现原理为设置一个定时器,约定在xx毫秒后再触发事件处理,每次触发事件都会重新设置计时器,直到xx毫秒内无第二次操作,。
// func是用户传入需要防抖的函数
// wait是等待时间
const debounce = (func, wait = 50) => {
// 缓存一个定时器id
let timer = 0
// 这里返回的函数是每次用户实际调用的防抖函数
// 如果已经设定过定时器了就清空上一次的定时器
// 开始一个新的定时器,延迟执行用户传入的方法
return function(...args) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
func.apply(this, args)
}, wait)
}
}
在开发中,可能会遇到这样的情况。有些资源不需要马上用到,但是希望尽早获取,这时候就可以使用预加载。
预加载其实是声明式的 fetch
,强制浏览器请求资源,并且不会阻塞 onload
事件,可以使用以下代码开启预加载
预加载可以一定程度上降低首屏的加载时间,因为可以将一些不影响首屏但重要的文件延后加载,唯一缺点就是兼容性不好。
可以通过预渲染将下载的文件预先在后台渲染,可以使用以下代码开启预渲染
预渲染虽然可以提高页面的加载速度,但是要确保该页面大概率会被用户在之后打开,否则就是白白浪费资源去渲染。
懒执行就是将某些逻辑延迟到使用时再计算。该技术可以用于首屏优化,对于某些耗时逻辑并不需要在首屏就使用的,就可以使用懒执行。懒执行需要唤醒,一般可以通过定时器或者事件的调用来唤醒。
懒加载就是将不关键的资源延后加载。
懒加载的原理就是只加载自定义区域(通常是可视区域,但也可以是即将进入可视区域)内需要加载的东西。对于图片来说,先设置图片标签的 src
属性为一张占位图,将真实的图片资源放入一个自定义属性中,当进入自定义区域时,就将自定义属性替换为 src
属性,这样图片就会去下载资源,实现了图片懒加载。
懒加载不仅可以用于图片,也可以使用在别的资源上。比如进入可视区域才开始播放视频等等。
CDN 的原理是尽可能的在各个地方分布机房缓存数据,这样即使我们的根服务器远在国外,在国内的用户也可以通过国内的机房迅速加载资源。
因此,我们可以将静态资源尽量使用 CDN 加载,由于浏览器对于单个域名有并发请求上限,可以考虑使用多个 CDN 域名。并且对于 CDN 加载静态资源需要注意 CDN 域名要与主站不同,否则每次请求都会带上主站的 Cookie,平白消耗流量。
这些碎片化的性能优化点看似很短,但是却能在出现性能问题时简单高效的提高性能,并且好几个点都是面试高频考点,比如节流、防抖。如果你还没有在项目中使用过这些技术,可以尝试着用到项目中,体验下功效。
原本小册计划中是没有这一章节的,Webpack 工作原理应该是上一章节包含的内容。但是考虑到既然讲到工作原理,必然需要讲解源码,但是 Webpack 的源码很难读,不结合源码干巴巴讲原理又没有什么价值。所以在这一章节中,我将会带大家来实现一个几十行的迷你打包工具,该工具可以实现以下两个功能
import
CSS 文件通过这个工具的实现,大家可以理解到打包工具的原理到底是什么。
因为涉及到 ES6 转 ES5,所以我们首先需要安装一些 Babel 相关的工具
yarn add babylon babel-traverse babel-core babel-preset-env
接下来我们将这些工具引入文件中
const fs = require('fs')
const path = require('path')
const babylon = require('babylon')
const traverse = require('babel-traverse').default
const {
transformFromAst } = require('babel-core')
首先,我们先来实现如何使用 Babel 转换代码
function readCode(filePath) {
// 读取文件内容
const content = fs.readFileSync(filePath, 'utf-8')
// 生成 AST
const ast = babylon.parse(content, {
sourceType: 'module'
})
// 寻找当前文件的依赖关系
const dependencies = []
traverse(ast, {
ImportDeclaration: ({ node }) => {
dependencies.push(node.source.value)
}
})
// 通过 AST 将代码转为 ES5
const { code } = transformFromAst(ast, null, {
presets: ['env']
})
return {
filePath,
dependencies,
code
}
}
fs
将文件中的内容读取出来babylon
解析代码获取 AST,目的是为了分析代码中是否还引入了别的文件dependencies
来存储文件中的依赖,然后再将 AST 转换为 ES5 代码接下来我们需要实现一个函数,这个函数的功能有以下几点
readCode
函数,传入入口文件function getDependencies(entry) {
// 读取入口文件
const entryObject = readCode(entry)
const dependencies = [entryObject]
// 遍历所有文件依赖关系
for (const asset of dependencies) {
// 获得文件目录
const dirname = path.dirname(asset.filePath)
// 遍历当前文件依赖关系
asset.dependencies.forEach(relativePath => {
// 获得绝对路径
const absolutePath = path.join(dirname, relativePath)
// CSS 文件逻辑就是将代码插入到 `style` 标签中
if (/\.css$/.test(absolutePath)) {
const content = fs.readFileSync(absolutePath, 'utf-8')
const code = `
const style = document.createElement('style')
style.innerText = ${JSON.stringify(content).replace(/\\r\\n/g, '')}
document.head.appendChild(style)
`
dependencies.push({
filePath: absolutePath,
relativePath,
dependencies: [],
code
})
} else {
// JS 代码需要继续查找是否有依赖关系
const child = readCode(absolutePath)
child.relativePath = relativePath
dependencies.push(child)
}
})
}
return dependencies
}
push
到这个数组中style
标签,将代码插入进标签并且放入 head
中即可push
进数组中现在我们已经获取到了所有的依赖文件,接下来就是实现打包的功能了
function bundle(dependencies, entry) {
let modules = ''
// 构建函数参数,生成的结构为
// { './entry.js': function(module, exports, require) { 代码 } }
dependencies.forEach(dep => {
const filePath = dep.relativePath || entry
modules += `'${filePath}': (
function (module, exports, require) { ${dep.code} }
),`
})
// 构建 require 函数,目的是为了获取模块暴露出来的内容
const result = `
(function(modules) {
function require(id) {
const module = { exports : {} }
modules[id](module, module.exports, require)
return module.exports
}
require('${entry}')
})({${modules}})
`
// 当生成的内容写入到文件中
fs.writeFileSync('./bundle.js', result)
}
这段代码需要结合着 Babel 转换后的代码来看,这样大家就能理解为什么需要这样写了
// entry.js
var _a = require('./a.js')
var _a2 = _interopRequireDefault(_a)
function _interopRequireDefault(obj) {
return obj && obj.__esModule ? obj : { default: obj }
}
console.log(_a2.default)
// a.js
Object.defineProperty(exports, '__esModule', {
value: true
})
var a = 1
exports.default = a
Babel 将我们 ES6 的模块化代码转换为了 CommonJS(如果你不熟悉 CommonJS 的话,可以阅读这一章节中关于 模块化的知识点) 的代码,但是浏览器是不支持 CommonJS 的,所以如果这段代码需要在浏览器环境下运行的话,我们需要自己实现 CommonJS 相关的代码,这就是 bundle
函数做的大部分事情。
接下来我们再来逐行解析 bundle
函数
首先遍历所有依赖文件,构建出一个函数参数对象
对象的属性就是当前文件的相对路径,属性值是一个函数,函数体是当前文件下的代码,函数接受三个参数
module
、
exports
、
require
module
参数对应 CommonJS 中的 module
exports
参数对应 CommonJS 中的 module.export
require
参数对应我们自己创建的 require
函数接下来就是构造一个使用参数的函数了,函数做的事情很简单,就是内部创建一个 require
函数,然后调用 require(entry)
,也就是 require('./entry.js')
,这样就会从函数参数中找到 ./entry.js
对应的函数并执行,最后将导出的内容通过 module.export
的方式让外部获取到
最后再将打包出来的内容写入到单独的文件中
如果你对于上面的实现还有疑惑的话,可以阅读下打包后的部分简化代码
;(function(modules) {
function require(id) {
// 构造一个 CommonJS 导出代码
const module = { exports: {} }
// 去参数中获取文件对应的函数并执行
modules[id](module, module.exports, require)
return module.exports
}
require('./entry.js')
})({
'./entry.js': function(module, exports, require) {
// 这里继续通过构造的 require 去找到 a.js 文件对应的函数
var _a = require('./a.js')
console.log(_a2.default)
},
'./a.js': function(module, exports, require) {
var a = 1
// 将 require 函数中的变量 module 变成了这样的结构
// module.exports = 1
// 这样就能在外部取到导出的内容了
exports.default = a
}
// 省略
})
虽然实现这个工具只写了不到 100 行的代码,但是打包工具的核心原理就是这些了
找出入口文件所有的依赖关系
然后通过构建 CommonJS 代码来获取 exports
导出的内容。