前端面试超全整理1( js 浏览器安全 性能)

1、JS 基础面试题(一)

原始(Primitive)类型

涉及面试题:原始类型有哪几种?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 却是一直流传下来。

对象(Object)类型

涉及面试题:对象类型和原始类型的不同之处?函数参数是对象会发生什么问题?

在 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 VS instanceof

涉及面试题: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 中类型转换只有三种情况,分别是:

  • 转换为布尔值
  • 转换为数字
  • 转换为字符串

我们先来看一个类型转换表格,然后再进入正题
前端面试超全整理1( js 浏览器安全 性能)_第1张图片

转Boolean

在条件判断时,除了 undefinednullfalseNaN''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

比较运算符

  1. 如果是对象,就通过 toPrimitive 转换对象
  2. 如果是字符串,就通过 unicode 字符索引来比较
let a = {
     
  valueOf() {
     
    return 0
  },
  toString() {
     
    return '1'
  }
}
a > -1 // true

在以上代码中,因为 a 是对象,所以会通过 valueOf 转换为原始类型再比较值。

This指向问题:

涉及面试题:如何正确判断 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=5function 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=100var 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=3static 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 基础知识点的第一部分内容了。这一小节中涉及到的知识点在我们日常的开发中经常可以看到,并且很多容易出现的坑 也出自于这些知识点,相信认真读完的你一定会在日后的开发中少踩很多坑。

2、JS 常考进阶面试题(二)

在这一章节中我们继续来了解 JS 的一些常考和容易混乱的基础知识点。

== vs ===

涉及面试题:== 和 === 有什么区别?

对于 == 来说,如果对比双方的类型不一样的话,就会进行类型转换,这也就用到了我们上一章节讲的内容。

假如我们需要对比 xy 是否相同,就会进行如下判断流程:

  1. 首先会判断两者类型是否相同。相同的话就是比大小了

  2. 类型不相同的话,那么就会进行类型转换

  3. 会先判断是否在对比 nullundefined,是的话就会返回 true

  4. 判断两者类型是否为 stringnumber,是的话就会将字符串转换为 number

    1 == '1'1 ==  1
    
  5. 判断其中一方是否为 boolean,是的话就会把 boolean 转为 number 再进行判断

    '1' == true'1' ==  11  ==  1
    
  6. 判断其中一方是否为 object 且另一方为 stringnumber 或者 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对象,如下图所示:

前端面试超全整理1( js 浏览器安全 性能)_第2张图片

如图所示,当在函数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继承的转化也是使用了寄生组合式继承

我们回顾一下实现过程:

  • 原型链继承,通过把子类实例的原型指向父类实例来继承父类的属性和方法;但缺陷在于,对子类实例继承的引用类型的修改会影响到所有的实例对象以及无法向父类的构造方法传参。
  • 因此我们引入了构造函数继承, 通过在子类构造函数中调用父类构造函数并传入子类this来获取父类的属性和方法,但缺陷在于,构造函数继承不能继承到父类原型链上的属性和方法。
  • 综合了两种继承的优点,提出了组合式继承,但组合式继承也引入了新的问题,它每次创建子类实例都执行了两次父类构造方法,
  • 我们通过将子类原型指向父类实例改为子类原型指向父类原型的浅拷贝来解决这一问题,也就是最终实现 —— 寄生组合式继承

深浅拷贝

涉及面试题:什么是浅拷贝?如何实现浅拷贝?什么是深拷贝?如何实现深拷贝?

在上一章节中,我们了解了对象类型在赋值的过程中其实是复制了地址,从而会导致改变了一方其他也都被改变的情况。通常在开发中我们不希望出现这样的问题,我们可以使用浅拷贝来解决这个情况。

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)  

new 操作符调用构造函数具体做了什么?

如下:

  • 创建一个新的对象;

  • 将构造函数的 this 指向这个新对象;

  • 为这个对象添加属性、方法等;

  • 最终返回新对象;

3、ES6 知识点及常考面试题

本章节我们将来学习 ES6 部分的内容。

Set和Map

数据类型:

数组

优点:

​ 1、按顺序排列,不考虑元素类型,

​ 2、下标与值对应,

​ 3、紧密型结构,

​ 4、数组可以根据某个值找到相邻数据

缺点:

​ 1、删除和插入都会影响整个长度,和改变数组各个元素位置

​ 2、数组可能有重复

​ 3、速度慢 查询添加 删除都需要遍历数组;

​ 4、字符串数组:长度限定

对象:

优点:

​ 1、对象是松散型集合,不需要考虑相邻关系,

​ 2、有键值对;查询 添加 删除快

​ 3、可以有多重集合:键名不能是对象:是字符串

缺点:
1、属性没有关联;
2、按照添加顺序遍历(顺序无法改变)
3、如果需要查询属性时需要遍历

map类型

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类型:

WeakMap 对象是一组键/值对的集合,其中的键是弱引用的。其键必须是对象,而值可以是任意的。

​ 1、弱引用列表类型;可以使用对象作为属性储存值

​ 2、长度是可变所以不可遍历;

​ 3、将obj设置null;被维护的WeakMap列表中会自动清除它

Set

Set 对象允许你存储任何类型的唯一值,无论是原始值或者是对象引用。

优点:1、插入,添加,删除速度快,无重复的值的列表(不能有键)
缺点:没有索引,不能用for循环;也不能用下标直接修改或获取;

WeakSet类型:

WeakSet 对象是一些对象值的集合, 并且其中的每个对象值都只能出现一次。在WeakSet的集合中是唯一的

弱引用列表类型;长度是可变所以不可遍历;
​ 将obj设置null;被维护的WeakSet列表中会自动清除它

它和 Set 对象的区别有两点:

与Set相比,WeakSet 只能是对象的集合,而不能是任何类型的任意值。
WeakSet持弱引用:集合中对象的引用为弱引用。 如果没有其他的对WeakSet中对象的引用,那么这些对象会被当成垃圾回收掉。 这也意味着WeakSet中没有存储当前对象的列表。 正因为这样,WeakSet 是不可枚举的。

Set和Map详解

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=[123123123]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([2356734212345])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([2356732421345])for(let value of a){
     
        console.log(value)}//2,3,5,6,7,4,1

has(value): 判断是否是成员

// Map hashMaplet 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" => "男"}

对象访问器属性:setter与getter

​ 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不支持,使用的时候要注意。

Generators生成器函数

​ 1)写法

function* getNums(i) {
     yield i;let s=i+10yield 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)//undefinedfunction* getSum(a,b){
     
​      a++yield a
​      b--yield b
​      let sum=a+b
​      yield sum
​      return sum
​ }  

   var sum=getSum(35)
   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 存在提升,我们能在声明之前使用。letconst 因为暂时性死区的原因,不能在声明前使用
  • var 在全局作用域下声明变量会导致变量挂载在 window 上,其他两者不会
  • letconst 作用基本一致,但是后者声明的变量不能再次赋值

1. 如何在ES5环境下实现let

对于这个问题,我们可以直接查看babel转换前后的结果,看一下在循环中通过let定义的变量是如何解决变量提升的问题

nullbabel在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 和 CMD

鉴于目前这两种实现方式已经很少见到,所以不再对具体特性细聊,只需要了解这两者是如何使用的。

// 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

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

另外虽然 exportsmodule.exports 用法相似,但是不能对 exports 直接赋值。因为 var exports = module.exports 这句代码表明了 exportsmodule.exports 享有相同地址,通过改变对象的属性值会对两者都起效,但是如果直接对 exports 赋值就会导致两者不再指向同一个内存地址,修改并不会对 module.exports 起效。

ES Module

ES Module 是原生实现的模块化方案,与 CommonJS 有以下几个区别

  • CommonJS 支持动态导入,也就是 require(${path}/xx.js),后者目前不支持,但是已有提案
  • CommonJS 是同步导入,因为用于服务端,文件都在本地,同步导入即使卡住主线程影响也不大。而后者是异步导入,因为用于浏览器,需要下载文件,如果也采用同步导入会对渲染有很大影响
  • CommonJS 在导出时都是值拷贝,就算导出的值变了,导入的值也不会改变,所以如果想更新值,必须重新导入一次。但是 ES Module 采用实时绑定的方式,导入导出的值都指向同一个内存地址,所以导入值会跟随导出值变化
  • ES Module 会编译成 require/exports 来执行的
// 引入模块 API
import XXX from './a.js'
import {
      XXX } from './a.js'
// 导出模块 API
export function a() {
     }
export default function() {
     }

模块化的差异 AMD CommonJS ESModule

AMD依赖前置,也就是说依赖之前就写好了
ESmodule是静态的,加载的是一个接口

静态引入的好处:可以做代码的静态分析,webpack中的打包就是利用了静态依赖

AMDcommonJS都是动态的,可以实现动态加载,而且加载的是一个对象

// AMD 在Angular中就是非常好的体现
defined(['a','b'],function(a, b){
     
    // 数组中放的是a模块和b模块
    // 函数相当于一个c模块
})

Proxy

涉及面试题: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

在上述代码中,我们通过自定义 setget 函数的方式,在原本的逻辑中插入了我们的函数逻辑,实现了在对对象任何属性进行读写时发出通知。

当然这是简单版的响应式实现,如果需要实现一个 Vue 中的响应式,需要我们在 get 中收集依赖,在 set 派发更新,之所以 Vue3.0 要使用 Proxy 替换原本的 API 原因在于 Proxy 无需一层层递归为每个属性添加代理,一次即可完成以上操作,性能上更好,并且原本的实现有一些数据更新不能监听到,但是 Proxy 可以完美监听到任何方式的数据改变,唯一缺陷可能就是浏览器的兼容性不好了。

map, filter, reduce

涉及面试题: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,该结果会在第二次执行回调函数时当做第一个参数传入
  • 所以在第二次执行回调函数时,相加的值就分别是 12,以此类推,循环结束后得到结果 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 常考的知识点,其他的一些异步内容我们会放在下一章节去讲。

4、JS 异步编程及常考面试题

在上一章节中我们了解了常见 ES6 语法的一些知识点。这一章节我们将会学习异步编程这一块的内容,鉴于异步编程是 JS 中至关重要的内容,所以我们将会用三个章节来学习异步编程涉及到的重点和难点,同时这一块内容也是面试常考范围,希望大家认真学习。

并发和并行区别

涉及面试题:并发与并行的区别?

异步和这小节的知识点其实并不是一个概念,但是这两个名词确实是很多人都常会混淆的知识点。其实混淆的原因可能只是两个名词在中文上的相似,在英文上来说完全是不同的单词。

并发是宏观概念,我分别有任务 A 和任务 B,在一段时间内通过任务间的切换完成了这两个任务,这种情况就可以称之为并发。

并行是微观概念,假设 CPU 中存在两个核心,那么我就可以同时完成任务 A、B。同时完成多个任务的情况就可以称之为并行。

回调函数(Callback)

涉及面试题:什么是回调函数?回调函数有什么缺点?如何解决回调地狱问题?

回调函数应该是大家经常使用到的,以下代码就是一个回调函数的例子:

ajax(url, () => {
     
    // 处理逻辑
})

但是回调函数有一个致命的弱点,就是容易写出回调地狱(Callback hell)。假设多个请求存在依赖性,你可能就会写出如下代码:

ajax(url, () => {
     
    // 处理逻辑
    ajax(url1, () => {
     
        // 处理逻辑
        ajax(url2, () => {
     
            // 处理逻辑
        })
    })
})

以上代码看起来不利于阅读和维护,当然,你可能会想说解决这个问题还不简单,把函数分开来写不就得了

function firstAjax() {
     
  ajax(url1, () => {
     
    // 处理逻辑
    secondAjax()
  })
}
function secondAjax() {
     
  ajax(url2, () => {
     
    // 处理逻辑
  })
}
ajax(url, () => {
     
  // 处理逻辑
  firstAjax()
})

以上的代码虽然看上去利于阅读了,但是还是没有解决根本问题。

回调地狱的根本问题就是:

  1. 嵌套函数存在耦合性,一旦有所改动,就会牵一发而动全身
  2. 嵌套函数一多,就很难处理错误

当然,回调函数还存在着别的几个缺点,比如不能使用 try catch 捕获错误,不能直接 return。在接下来的几小节中,我们将来学习通过别的技术解决这些问题。

Generator

涉及面试题:你理解的 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 链?Promise 构造函数执行和 then 函数执行有什么区别?

promise含义

所谓promise,简单说是一个容器,里面保存着一个异步操作的结果,从语法上说,promise是一个对象,从它可以获取异步操作的消息,promise提供了统一的API,各种异步操作都可以用同样的方法进行处理。

promise规范

(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就会立即执行,无法中途取消。

其次,如果不设置回调函数,Promise内部抛出的错误,不会反应到外部。

第三,当处于pending状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。

promise API

方法:
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的错误之类的。

  1. 如果异步操作抛出错误,状态就会变为Rejected,就会调用catch方法指定的回调函数,处理这个错误。
  2. then方法指定的回调函数,如果运行中抛出错误,也会被catch方法捕获。
  3. 在resolve()后面抛出的错误会被忽略(如果前面已经是执行了resolve,那么后面throw 出来的error不会被catch到)
  4. catch方法返回的还是一个Promise对象,因此后面还可以接着调用then方法。
  5. 在异步函数中抛出的错误不会被catch捕获到(例如使用了setTimeout之类的,抛出来的错误不会被catch到)
  6. 有多个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"
  1. setTimeout、async、promise执行顺序是如何的
    https://blog.csdn.net/baidu_33295233/article/details/79335127

async 及 await

涉及面试题: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()
}

asyncawait 可以说是异步终极解决方案了,相比直接使用 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 内部实现了 generatorgenerator 会保留堆栈中东西,所以这时候 a = 0 被保存了下来
  • 因为 await 是异步操作,后来的表达式不返回 Promise 的话,就会包装成 Promise.reslove(返回值),然后会去执行函数外的同步代码
  • 同步代码执行完毕后开始执行异步代码,将保存下来的值拿出来使用,这时候 a = 0 + 10

上述解释中提到了 await 内部实现了 generator,其实 await 就是 generator 加上 Promise 的语法糖,且内部实现了自动执行 generator。如果你熟悉 co 的话,其实自己就可以实现这样的语法糖。

常用定时器函数

涉及面试题:setTimeout、setInterval、requestAnimationFrame 各有什么特点?

异步编程当然少不了定时器了,常见的定时器函数有 setTimeoutsetIntervalrequestAnimationFrame。我们先来讲讲最常用的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 中较难掌握的内容,同时也是很重要的知识点。以上提到的每个知识点其实都可以作为一道面试题

5、手写 Promise

在上一章节中我们了解了 Promise 的一些易错点,在这一章节中,我们会通过手写一个符合 Promise/A+ 规范的 Promise 来深入理解它,并且手写 Promise 也是一道大厂常考题,在进入正题之前,推荐各位阅读一下 Promise/A+ 规范,这样才能更好地理解这个章节的代码。

实现一个简易版 Promise

在完成符合 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 中传入的值
  • resolvedCallbacksrejectedCallbacks 用于保存 then 中的回调,因为当执行完 Promise 时状态可能还是等待中,这时候应该把 then 中的回调保存起来用于状态改变时使用

接下来我们来完善 resolvereject 函数,添加在 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 的理解更上一层楼。

实现一个符合 Promise/A+ 规范的 Promise

这小节代码需要大家配合规范阅读,因为大部分代码都是根据规范去实现的。

我们先来改造一下 resolvereject 函数

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)
      }
    })
  }))
}
  • 其实大家可以发现这段代码和判断等待态的逻辑基本一致,无非是传入的函数的函数体需要异步执行,这也是规范规定的
  • 对于判断拒绝态的逻辑这里就不一一赘述了,留给大家自己完成这个作业

最后,当然也是最难的一部分,也就是实现兼容多种 PromiseresolutionProcedure 函数

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)
}

这里的代码是完全按照规范实现的。如果 xPromise 的话,需要判断以下几个情况:

  1. 如果 x 处于等待态,Promise 需保持为等待态直至 x 被执行或拒绝
  2. 如果 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 的运行原理,做技术的深挖者。

6、Event Loop

在前两章节中我们了解了 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()

前端面试超全整理1( js 浏览器安全 性能)_第3张图片

​ 函数执行顺序

大家可以在上图清晰的看到报错在 foo 函数,foo 函数又是在 bar 函数中调用的。

当我们使用递归的时候,因为栈可存放的函数是有限制的,一旦存放了过多的函数且没有得到释放的话,就会出现爆栈的问题

function bar() {
     
  bar()
}
bar()

前端面试超全整理1( js 浏览器安全 性能)_第4张图片

​ 爆栈

浏览器中的 Event Loop

涉及面试题:异步代码执行顺序?解释一下什么是 Event Loop ?

当我们执行 JS 代码的时候其实就是往执行栈中放入函数,其实当遇到异步的代码时,会被挂起并在需要执行的时候加入到 Task(有多种 Task) 队列中。一旦执行栈为空,Event Loop 就会从 Task 队列中拿出需要执行的代码并放入执行栈中执行,所以本质上来说 JS 中的异步还是同步行为。

不同的任务源会被分配到不同的 Task 队列中,任务源可以分为 微任务(microtask) 和 宏任务(macrotask)。在 ES6 规范中,microtask 称为 jobs,macrotask 称为 task。下面来看以下代码的执行顺序:

所以 Event Loop 执行顺序如下所示:

  • 首先执行同步代码,这属于宏任务
  • 当执行完所有同步代码后,执行栈为空,查询是否有异步代码需要执行
  • 执行所有微任务
  • 当执行完所有微任务后,如有必要会渲染页面
  • 然后开始下一轮 Event Loop,执行宏任务中的异步代码,也就是 setTimeout 中的回调函数

所以以上代码虽然 setTimeout 写在 Promise 之前,但是因为 Promise 属于微任务而 setTimeout 属于宏任务,所以会有以上的打印。

微任务包括 process.nextTickpromiseMutationObserver

宏任务包括 scriptsetTimeoutsetIntervalsetImmediateI/OUI rendering

这里很多人会有个误区,认为微任务快于宏任务,其实是错误的。因为宏任务中包括了 script ,浏览器会先执行一个宏任务,接下来有异步代码的话才会先执行微任务。

Node 中的 Event Loop

涉及面试题:Node 中的 Event Loop 和浏览器中的有什么区别?process.nexttick 执行顺序?

Node 中的 Event Loop 和浏览器中的是完全不相同的东西。

Node 的 Event Loop 分为 6 个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。

前端面试超全整理1( js 浏览器安全 性能)_第5张图片

timer

timers 阶段会执行 setTimeoutsetInterval 回调,并且是由 poll 阶段控制的。

同样,在 Node 中定时器指定的时间也不是准确时间,只能是尽快执行。

I/O

I/O 阶段会处理一些上一轮循环中的少数未执行的 I/O 回调

idle, prepare

idle, prepare 阶段内部实现,这里就忽略不讲了。

poll

poll 是一个至关重要的阶段,这一阶段中,系统会做两件事情

  1. 回到 timer 阶段执行回调
  2. 执行 I/O 回调

并且在进入该阶段时如果没有设定了 timer 的话,会发生以下两件事情

  • 如果 poll 队列不为空,会遍历回调队列并同步执行,直到队列为空或者达到系统限制
  • 如果 poll 队列为空时,会有两件事发生
    • 如果有 setImmediate 回调需要执行,poll 阶段会停止并且进入到 check 阶段执行回调
    • 如果没有 setImmediate 回调需要执行,会等待回调被加入到队列中并立即执行回调,这里同样会有个超时时间设置防止一直等待下去

当然设定了 timer 的话且 poll 队列为空,则会判断是否有 timer 超时,如果有的话会回到 timer 阶段执行回调。

check

check 阶段执行 setImmediate

close callbacks

close callbacks 阶段执行 close 事件

在以上的内容中,我们了解了 Node 中的 Event Loop 的执行顺序,接下来我们将会通过代码的方式来深入理解这块内容。

首先在有些情况下,定时器的执行顺序其实是随机

setTimeout(() => {
     
    console.log('setTimeout')
}, 0)
setImmediate(() => {
     
    console.log('setImmediate')
})

对于以上代码来说,setTimeout 可能执行在前,也可能执行在后

  • 首先 setTimeout(fn, 0) === setTimeout(fn, 1),这是由源码决定的
  • 进入事件循环也是需要成本的,如果在准备时候花费了大于 1ms 的时间,那么在 timer 阶段就会直接执行 setTimeout 回调
  • 那么如果准备时间花费小于 1ms,那么就是 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 是如何执行的至关重要,同时也是常考题。

7、JS 进阶知识点及常考面试题

在这一章节中,我们将会学习到一些原理相关的知识,不会解释涉及到的知识点的作用及用法,如果大家对于这些内容还不怎么熟悉,推荐先去学习相关的知识点内容再来学习原理知识。

手写 call、apply 及 bind 函数

涉及面试题: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 的方式,我们先来说直接调用的方式
  • 前端面试超全整理1( js 浏览器安全 性能)_第6张图片
  • 对于直接调用来说,这里选择了 apply 的方式实现,但是对于参数需要注意以下情况:因为 bind 可以实现类似这样的代码 f.bind(obj, 1)(2),所以我们需要将两边的参数拼接起来,于是就有了这样的实现 args.concat(...arguments)
  • 最后来说通过 new 的方式,在之前的章节中我们学习过如何判断 this,对于 new 的情况来说,不会被任何方式改变 this,所以对于这种情况我们需要忽略传入的 this

new

涉及面试题:new 的原理是什么?通过 new 的方式创建对象和通过字面量创建有什么区别?

首先我们要知道new做了什么

1.创建一个新对象,并继承其构造函数的prototype,这一步是为了继承构造函数原型上的属性和方法

2.执行构造函数,方法内的this被指定为该新实例,这一步是为了执行构造函数内的赋值操作

3.返回新实例(规范规定,如果构造方法返回了一个对象,那么返回该对象,否则返回第一步创建的新对象)

在调用 new 的过程中会发生以上四件事情:

  1. 新生成了一个对象
  2. 链接到原型
  3. 绑定 this
  4. 返回新对象

根据以上几个过程,我们也可以试着来自己实现一个 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 的原理是什么?

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

涉及面试题:为什么 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 位分为了三段

  • 第一位用来表示符号
  • 接下去的 11 位用来表示指数
  • 其他的位数用来表示有效位,也就是用二进制表示 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 下的垃圾回收机制是怎么样的?

(1)垃圾回收概述

  垃圾回收机制(GC:Garbage Collection),执行环境负责管理代码执行过程中使用的内存。垃圾收集器会定期(周期性)找出那些不在继续使用的变量,然后释放其内存。但是这个过程不是实时的,因为其开销比较大,所以垃圾回收器会按照固定的时间间隔周期性的执行。

(2)垃圾回收策略:

2种最为常用:标记清除和引用计数,其中标记清除更为常用。

标记清除(mark-and-sweep)

​ 当变量进入作用域时,进行标记,对于脱离作用域的变量进行标记并回收。到目前为止,IE、Firefox、Opera、Chrome、Safari的js实现使用的都是标记清除的垃圾回收策略或类似的策略,只不过垃圾收集的时间间隔互不相同。
​ 当变量进入环境(例如,在函数中声明一个变量)时,就将这个变量标记为“进入环境”。从逻辑上讲,永远不能释放进入环境的变量所占的内存,因为只要执行流进入相应的环境,就可能用到它们。而当变量离开环境时,这将其 标记为“离开环境”。

引用计数:引用计数是跟踪记录每个值被引用的次数。就是变量的引用次数,被引用一次则加1,当这个引用计数为0时,被视为准备回收的对象,每当过一段时间开始垃圾回收的时候,就把被引用数为0的变量回收。引用计数方法可能导致循环引用,类似死锁,导致内存泄露。

在这个例子中,objA和objB通过各自的属性相互引用;也就是说这两个对象的引用次数都是2。在采用引用计数的策略中,由于函数执行之后,这两个对象都离开了作用域,函数执行完成之后,objA和objB还将会继续存在,因为他们的引用次数永远不会是0。这样的相互引用如果说很大量的存在就会导致大量的内存泄露。

(3)内存泄漏的原因

1、全局变量引起的内存泄露
2、闭包引起的内存泄露:慎用闭包
3、dom清空或删除时,事件未清除导致的内存泄漏
3、循环引用带来的内存泄露

(4)如何减少垃圾回收开销

    由于每次的垃圾回收开销都相对较大,并且由于机制的一些不完善的地方,可能会导致内存泄露,我们可以利用一些方法减少垃圾回收,并且尽量避免循环引用。

在对象结束使用后 ,令obj = null。这样利于解除循环引用,使得无用变量及时被回收;
js中开辟空间的操作有new(), [ ], { }, function (){…}。最大限度的实现对象的重用;
慎用闭包。闭包容易引起内存泄露。本来在函数返回之后,之前的空间都会被回收。但是由于闭包可能保存着函数内部变量的引用,且闭包在外部环境,就会导致函数内部的变量不能够销毁。

(5)垃圾回收的缺陷

  和其他语言一样,javascript的GC策略也无法避免一个问题:GC时,停止响应其他操作,这是为了安全考虑。而Javascript的GC在100ms甚至以上,对一般的应用还好,但对于JS游戏,动画对连贯性要求比较高的应用,就麻烦了。这就是新引擎需要优化的点:避免GC造成的长时间停止响应。

(6)优化GC

分代回收(Generation GC):与Java回收策略思想是一致的。目的是通过区分“临时”与“持久”对象;多回收“临时对象”区(young generation),少回收“持久对象”区(tenured generation),减少每次需遍历的对象,从而减少每次GC的耗时。
增量GC:这个方案的思想很简单,就是“每次处理一点,下次再处理一点,如此类推。

新生代算法

新生代中的对象一般存活时间较短,使用 Scavenge GC 算法。

在新生代空间中,内存空间分为两部分,分别为 From 空间和 To 空间。在这两个空间中,必定有一个空间是使用的,另一个空间是空闲的。新分配的对象会被放入 From 空间中,当 From 空间被占满时,新生代 GC 就会启动了。算法会检查 From 空间中存活的对象并复制到 To 空间中,如果有失活的对象就会销毁。当复制完成后将 From 空间和 To 空间互换,这样 GC 就结束了。

老生代算法

老生代中的对象一般存活时间较长且数量也多,使用了两个算法,分别是标记清除算法和标记压缩算法。

在讲算法前,先来说下什么情况下对象会出现在老生代空间中:

  • 新生代中的对象是否已经经历过一次 Scavenge 算法,如果经历过的话,会将对象从新生代空间移到老生代空间中。
  • To 空间的对象占比大小超过 25 %。在这种情况下,为了不影响到内存分配,会将对象从新生代空间移到老生代空间中。

老生代中的空间很复杂,有如下几个空间

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 进阶知识点的内容了,这部分的知识相比于之前的内容更加深入也更加的理论,也是在面试中能够于别的候选者拉开差距的一块内容。

8、浏览器基础知识点及常考面试

这一章节我们将会来学习浏览器的一些基础知识点,包括:事件机制、跨域、存储相关,这几个知识点也是面试经常会考到的内容。

事件机制

涉及面试题:事件的触发过程是怎么样的?知道什么是事件代理嘛?

事件触发三阶段

事件触发有三个阶段:

  • 捕获阶段: window 往事件触发处传播,遇到注册的捕获事件会触发
  • 目标阶段: 传播到事件触发处时触发注册的事件
  • 冒泡阶段: 从事件触发处往 window 传播,遇到注册的冒泡事件会触发

事件触发一般来说会按照上面的顺序进行,但是也有特例,如果给一个 body 中的子节点同时注册冒泡和捕获事件,事件触发会按照注册的顺序执行。

// 以下会先打印冒泡然后是捕获
node.addEventListener(
  'click',
  event => {
     
    console.log('冒泡')
  },
  false
)
node.addEventListener(
  'click',
  event => {
     
    console.log('捕获 ')
  },
  true
)

注册事件

通常我们使用 addEventListener 注册事件,它主要有三个参数:

  • event 事件名 必须

  • function 处理函数 必须

  • useCapture 指定事件是否在捕获或冒泡阶段执行, 该函数的第三个参数可以是布尔值,也可以是对象。对于布尔值 useCapture 参数来说,该参数默认值为 falseuseCapture 决定了注册的事件是捕获事件还是冒泡事件。对于对象参数来说,可以使用以下几个属性

    • 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,因为请求毕竟是发出去了。

接下来我们将来学习几种常见的方式来解决跨域的问题。

1、JSONP

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)
})

2、CORS

CORS 需要浏览器和后端同时支持。IE 8 和 9 需要通过 XDomainRequest 来实现。

浏览器会自动进行 CORS 通信,实现 CORS 通信的关键是后端。只要后端实现了 CORS,就实现了跨域。

服务端设置 Access-Control-Allow-Origin 就可以开启 CORS。 该属性表示哪些域名可以访问资源,如果设置通配符则表示所有网站都可以访问资源。

虽然设置 CORS 和前端没什么关系,但是通过这种方式解决跨域问题的话,会在发送请求时出现两种情况,分别为简单请求和复杂请求

另外,如果面试官问:“CORS为什么支持跨域的通信?”

答案:

  • 浏览器在请求头中携带Origin请求头,表明源请求地址:
    Origin: 源请求地址
  • 服务器在返回响应时,如果允许源地址对其进行跨域请求,需要在响应头中携带Access-Control-Allow-Origin响应头:
    Access-Control-Allow-Origin: 源请求地址
  • 浏览器在收到响应时,如果发现响应头中没有Access-Control-Allow-Origin响应头,浏览器会直接将请求驳回,产生CORS跨域请求限制。如果有的话就不限制了
    注:跨域请求不一定有异步请求,还有同步,浏览器的限制只对异步请求

简单请求

以 Ajax 为例,当满足以下条件时,会触发简单请求

  1. 使用下列方法之一:
    • GET
    • HEAD
    • POST
  2. 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()

3、document.domain

该方式只能用于二级域名相同的情况下,比如 a.test.comb.test.com 适用于该方式。

只需要给页面添加 document.domain = 'test.com' 表示二级域名都相同就可以实现跨域

4、postMessage

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('验证通过')
  }
})

5、Hash

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;
    };

6、websocket

全双工(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>

7、localstorage

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’)代码,同样是不会触发监听函数的。

存储localStorage、sessionStorage、cookie、indexDB

涉及面试题:有几种方式可以实现存储功能,分别有什么优缺点?什么是 Service Worker?

我们先来通过表格学习下这几种存储方式的区别

特性 cookie localStorage sessionStorage indexDB
数据生命周期 一般由服务器生成,可以设置过期时间 除非被清理,否则一直存在 页面关闭就清理 除非被清理,否则一直存在
数据存储大小 4K 5M 5M 无限
与服务端通信 每次都会携带在 header 中,对于请求性能影响 不参与 不参与 不参与

从上表可以看到,cookie 已经不建议用于存储。如果没有大量数据存储需求的话,可以使用 localStoragesessionStorage 。对于不怎么改变的数据尽量使用 localStorage 存储,否则可以用 sessionStorage 存储。

对于 cookie 来说,我们还需要注意安全性。

属性 作用
value 如果用于保存用户登录态,应该将该值加密,不能使用明文的用户标识
http-only 不能通过 JS 访问 Cookie,减少 XSS 攻击
secure 只能在协议为 HTTPS 的请求中携带
same-site 规定浏览器不能在跨域请求中携带 Cookie,减少 CSRF 攻击

Html5中本地存储概念是什么,优点,与cookie有什么区别

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也是在所有同源窗口中都是共享的。

什么是认证(Authentication)

  • 通俗地讲就是验证当前用户的身份,证明“你是你自己”(比如:你每天上下班打卡,都需要通过指纹打卡,当你的指纹和系统里录入的指纹相匹配时,就打卡成功)
  • 互联网中的认证:
  • 用户名密码登录
  • 邮箱发送登录链接
  • 手机号接收验证码
  • 只要你能收到邮箱/验证码,就默认你是账号的主人

什么是授权

  • 用户授予第三方应用访问该用户某些资源的权限
  • 你在安装手机应用的时候,APP 会询问是否允许授予权限(访问相册、地理位置等权限)
  • 你在访问微信小程序时,当登录时,小程序会询问是否允许授予权限(获取昵称、头像、地区、性别等个人信息)
  • 实现授权的方式有:cookiesessiontokenOAuth

什么是凭证(Credentials)

  • 实现认证和授权的前提是需要一种媒介(证书) 来标记访问者的身份
  • 在战国时期,商鞅变法,发明了照身帖。照身帖由官府发放,是一块打磨光滑细密的竹板,上面刻有持有人的头像和籍贯信息。国人必须持有,如若没有就被认为是黑户,或者间谍之类的。
  • 在现实生活中,每个人都会有一张专属的居民身份证,是用于证明持有人身份的一种法定证件。通过身份证,我们可以办理手机卡/银行卡/个人贷款/交通出行等等,这就是认证的凭证。
  • 在互联网应用中,一般网站(如掘金)会有两种模式,游客模式和登录模式。游客模式下,可以正常浏览网站上面的文章,一旦想要点赞/收藏/分享文章,就需要登录或者注册账号。当用户登录成功后,服务器会给该用户使用的浏览器颁发一个令牌(token),这个令牌用来表明你的身份,每次浏览器发送请求时会带上这个令牌,就可以使用游客模式下无法使用的功能。

什么是 Cookie

  • HTTP 是无状态的协议(对于事务处理没有记忆能力,每次客户端和服务端会话完成时,服务端不会保存任何会话信息):每个请求都是完全独立的,服务端无法确认当前访问者的身份信息,无法分辨上一次的请求发送者和这一次的发送者是不是同一个人。所以服务器与浏览器为了进行会话跟踪(知道是谁在访问我),就必须主动的去维护一个状态,这个状态用于告知服务端前后两个请求是否来自同一浏览器。而这个状态需要通过 cookie 或者 session 去实现。
  • cookie 存储在客户端: cookie 是服务器发送到用户浏览器并保存在本地的一小块数据,它会在浏览器下次向同一服务器再发起请求时被携带并发送到服务器上。
  • cookie 是不可跨域的: 每个 cookie 都会绑定单一的域名,无法在别的域名下获取使用,一级域名和二级域名之间是允许共享使用的靠的是 domain)

cookie 重要的属性属性说明name=value键值对,设置 Cookie 的名称及相对应的值,都必须是字符串类型

  • 如果值为 Unicode 字符,需要为字符编码。
  • 如果值为二进制数据,则需要使用 BASE64 编码。domain指定 cookie 所属域名,默认是当前域名path****指定 cookie 在哪个路径(路由)下生效,默认是 ‘/’。如果设置为 /abc,则只有 /abc 下的路由可以访问到该 cookie,如:/abc/readmaxAgecookie 失效的时间,单位秒。如果为整数,则该 cookie 在 maxAge 秒后失效。如果为负数,该 cookie 为临时 cookie ,关闭浏览器即失效,浏览器也不会以任何形式保存该 cookie 。如果为 0,表示删除该 cookie 。默认为 -1。
  • 比 expires 好用expires过期时间,在设置的某个时间点后该 cookie 就会失效。一般浏览器的 cookie 都是默认储存的,当关闭浏览器结束这个会话的时候,这个 cookie 也就会被删除secure该 cookie 是否仅被使用安全协议传输。安全协议有 HTTPS,SSL等,在网络上传输数据之前先将数据加密。默认为false。当 secure 值为 true 时,cookie 在 HTTP 中是无效,在 HTTPS 中才有效。*httpOnly***如果给某个 cookie 设置了 httpOnly 属性,则无法通过 JS 脚本 读取到该 cookie 的信息,但还是能通过 Application 中手动修改 cookie,所以只是在一定程度上可以防止 XSS 攻击,不是绝对的安全

什么是 Session

  • session 是另一种记录服务器和客户端会话状态的机制
  • session 是基于 cookie 实现的,session 存储在服务器端,sessionId 会被存储到客户端的cookie 中

imgsession.png

  • session 认证流程:
  • 用户第一次请求服务器的时候,服务器根据用户提交的相关信息,创建对应的 Session
  • 请求返回时将此 Session 的唯一标识信息 SessionID 返回给浏览器
  • 浏览器接收到服务器返回的 SessionID 信息后,会将此信息存入到 Cookie 中,同时 Cookie 记录此 SessionID 属于哪个域名
  • 当用户第二次访问服务器的时候,请求会自动判断此域名下是否存在 Cookie 信息,如果存在自动将 Cookie 信息也发送给服务端,服务端会从 Cookie 中获取 SessionID,再根据 SessionID 查找对应的 Session 信息,如果没有找到说明用户没有登录或者登录失效,如果找到 Session 证明用户已经登录可执行后面操作。

根据以上流程可知,SessionID 是连接 Cookie 和 Session 的一道桥梁,大部分系统也是根据此原理来验证用户登录状态。

什么是 Token(令牌)

Acesss Token

  • 访问资源接口(API)时所需要的资源凭证
  • 简单 token 的组成: uid(用户唯一的身份标识)、time(当前时间的时间戳)、sign(签名,token 的前几位以哈希算法压缩成的一定长度的十六进制字符串)
  • 特点:
  • 服务端无状态化、可扩展性好
  • 支持移动端设备
  • 安全
  • 支持跨程序调用
  • token 的身份验证流程:

img

  1. 客户端使用用户名跟密码请求登录
  2. 服务端收到请求,去验证用户名与密码
  3. 验证成功后,服务端会签发一个 token 并把这个 token 发送给客户端
  4. 客户端收到 token 以后,会把它存储起来,比如放在 cookie 里或者 localStorage 里
  5. 客户端每次向服务端请求资源的时候需要带着服务端签发的 token
  6. 服务端收到请求,然后去验证客户端请求里面带着的 token ,如果验证成功,就向客户端返回请求的数据
  • 每一次请求都需要携带 token,需要把 token 放到 HTTP 的 Header 里
  • **基于 token 的用户认证是一种服务端无状态的认证方式,服务端不用存放 token 数据。**用解析 token 的计算时间换取 session 的存储空间,从而减轻服务器的压力,减少频繁的查询数据库
  • token 完全由应用管理,所以它可以避开同源策略

Refresh Token

  • 另外一种 token——refresh token
  • refresh token 是专用于刷新 access token 的 token。如果没有 refresh token,也可以刷新 access token,但每次刷新都要用户输入登录用户名与密码,会很麻烦。有了 refresh token,可以减少这个麻烦,客户端直接用 refresh token 去更新 access token,无需用户进行额外的操作。

img

  • Access Token 的有效期比较短,当 Acesss Token 由于过期而失效时,使用 Refresh Token 就可以获取到新的 Token,如果 Refresh Token 也失效了,用户就只能重新登录了。
  • Refresh Token 及过期时间是存储在服务器的数据库中,只有在申请新的 Acesss Token 时才会验证,不会对业务接口响应时间造成影响,也不需要向 Session 一样一直保持在内存中以应对大量的请求。

Token 和 Session 的区别

  • Session 是一种记录服务器和客户端会话状态的机制,使服务端有状态化,可以记录会话信息。而 Token 是令牌访问资源接口(API)时所需要的资源凭证。Token 使服务端无状态化,不会存储会话信息。
  • Session 和 Token 并不矛盾,作为身份认证 Token 安全性比 Session 好,因为每一个请求都有签名还能防止监听以及重放攻击,而 Session 就必须依赖链路层来保障通讯安全了。如果你需要实现有状态的会话,仍然可以增加 Session 来在服务器端保存一些状态。
  • 所谓 Session 认证只是简单的把 User 信息存储到 Session 里,因为 SessionID 的不可预测性,暂且认为是安全的。而 Token ,如果指的是 OAuth Token 或类似的机制的话,提供的是 认证 和 授权 ,认证是针对用户,授权是针对 App 。其目的是让某 App 有权利访问某用户的信息。这里的 Token 是唯一的。不可以转移到其它 App上,也不可以转到其它用户上。Session 只提供一种简单的认证,即只要有此 SessionID ,即认为有此 User 的全部权利。是需要严格保密的,这个数据应该只保存在站方,不应该共享给其它网站或者第三方 App。所以简单来说:**如果你的用户数据可能需要和第三方共享,或者允许第三方调用 API 接口,用 Token 。**如果永远只是自己的网站,自己的 App,用什么就无所谓了。

什么是 JWT

  • JSON Web Token(简称 JWT)是目前最流行的跨域认证解决方案。
  • 是一种认证授权机制
  • JWT 是为了在网络应用环境间传递声明而执行的一种基于 JSON 的开放标准(RFC 7519)。JWT 的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源。比如用在用户登录上。
  • 可以使用 HMAC 算法或者是 RSA 的公/私秘钥对 JWT 进行签名。因为数字签名的存在,这些传递的信息是可信的。
  • 阮一峰老师的 JSON Web Token 入门教程 讲的非常通俗易懂,这里就不再班门弄斧了

生成 JWT

jwt.io/www.jsonwebtoken.io/

JWT 的原理

img

  • 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 的使用方式

  • 客户端收到服务器返回的 JWT,可以储存在 Cookie 里面,也可以储存在 localStorage。

方式一

  • 当用户希望访问一个受保护的路由或者资源的时候,可以把它放在 Cookie 里面自动发送,但是这样不能跨域,所以更好的做法是放在 HTTP 请求头信息的 Authorization 字段里,使用 Bearer 模式添加 JWT。
    GET /calendar/v1/events
    Host: api.example.com
    Authorization: Bearer 
  • 用户的状态不会存储在服务端的内存中,这是一种 无状态的认证机制
  • 服务端的保护路由将会检查请求头 Authorization 中的 JWT 信息,如果合法,则允许用户的行为。
  • 由于 JWT 是自包含的,因此减少了需要查询数据库的需要
  • JWT 的这些特性使得我们可以完全依赖其无状态的特性提供数据 API 服务,甚至是创建一个下载流服务。
  • 因为 JWT 并不使用 Cookie ,所以你可以使用任何域名提供你的 API 服务而不需要担心跨域资源共享问题(CORS)

方式二

  • 跨域的时候,可以把 JWT 放在 POST 请求的数据体里。

方式三

  • 通过 URL 传输
    http://www.example.com/user?token=xxx

项目中使用 JWT

**项目地址: https://github.com/yjdjiayou/jwt-demo **

Token 和 JWT 的区别

相同:

  • 都是访问资源的令牌
  • 都可以记录用户的信息
  • 都是使服务端无状态化
  • 都是只有验证成功后,客户端才能访问服务端上受保护的资源

区别:

  • Token:服务端验证客户端发送过来的 Token 时,还需要查询数据库获取用户信息,然后验证 Token 是否有效。
  • JWT:将 Token 和 Payload 加密后存储于客户端,服务端只需要使用密钥解密进行校验(校验也是 JWT 自己实现的)即可,不需要查询或者减少查询数据库,因为 JWT 自包含了用户信息和加密的数据。

常见的前后端鉴权方式

  1. Session-Cookie
  2. Token 验证(包括 JWTSSO
  3. OAuth2.0(开放授权)

常见的加密算法

imgimage.png

  • 哈希算法(Hash Algorithm)又称散列算法、散列函数、哈希函数,是一种从任何一种数据中创建小的数字“指纹”的方法。哈希算法将数据重新打乱混合,重新创建一个哈希值。
  • 哈希算法主要用来保障数据真实性(即完整性),即发信人将原始消息和哈希值一起发送,收信人通过相同的哈希函数来校验原始数据是否真实。
  • 哈希算法通常有以下几个特点:
  • 正像快速:原始数据可以快速计算出哈希值
  • 逆向困难:通过哈希值基本不可能推导出原始数据
  • 输入敏感:原始数据只要有一点变动,得到的哈希值差别很大
  • 冲突避免:很难找到不同的原始数据得到相同的哈希值,宇宙中原子数大约在 10 的 60 次方到 80 次方之间,所以 2 的 256 次方有足够的空间容纳所有的可能,算法好的情况下冲突碰撞的概率很低:
  • 2 的 128 次方为 340282366920938463463374607431768211456,也就是 10 的 39 次方级别
  • 2 的 160 次方为 1.4615016373309029182036848327163e+48,也就是 10 的 48 次方级别
  • 2 的 256 次方为 1.1579208923731619542357098500869 × 10 的 77 次方,也就是 10 的 77 次方

注意:

  1. 以上不能保证数据被恶意篡改,原始数据和哈希值都可能被恶意篡改,要保证不被篡改,可以使用RSA 公钥私钥方案,再配合哈希值。
  2. 哈希算法主要用来防止计算机传输过程中的错误,早期计算机通过前 7 位数据第 8 位奇偶校验码来保障(12.5% 的浪费效率低),对于一段数据或文件,通过哈希算法生成 128bit 或者 256bit 的哈希值,如果校验有问题就要求重传。

9、浏览器缓存机制

注意:该知识点属于性能优化领域,并且整一章节都是一个面试题。

缓存可以说是性能优化中简单高效的一种优化方式了,它可以显著减少网络传输所带来的损耗

对于一个数据请求来说,可以分为发起网络请求、后端处理、浏览器响应三个步骤。浏览器缓存可以帮助我们在第一和第三步骤中优化性能。比如说直接使用缓存而不发起请求,或者发起了请求但后端存储的数据和前端一致,那么就没有必要再将数据回传回来,这样就减少了响应数据。

接下来的内容中我们将通过以下几个部分来探讨浏览器缓存机制:

  • 缓存位置
  • 缓存策略
  • 实际场景应用缓存策略

缓存位置

从缓存位置上来说分为四种,并且各自有优先级,当依次查找缓存且都没有命中的时候,才会去请求网络

  1. Service Worker
  2. Memory Cache
  3. Disk Cache
  4. Push Cache
  5. 网络请求

Service Worker

在上一章节中我们已经介绍了 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

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

Disk Cache 也就是存储在硬盘中的缓存,读取速度慢点,但是什么都能存储到磁盘中,比之 Memory Cache 胜在容量和存储时效性上。

在所有浏览器缓存中,Disk Cache 覆盖面基本是最大的。它会根据 HTTP Herder 中的字段判断哪些资源需要缓存,哪些资源可以不请求直接使用,哪些资源已经过期需要重新请求。并且即使在跨站点的情况下,相同地址的资源一旦被硬盘缓存下来,就不会再次去请求数据。

Push Cache

Push Cache 是 HTTP/2 中的内容,当以上三种缓存都没有命中时,它才会被使用。并且缓存时间也很短暂,只在会话(Session)中存在,一旦会话结束就被释放。

Push Cache 在国内能够查到的资料很少,也是因为 HTTP/2 在国内不够普及,但是 HTTP/2 将会是日后的一个趋势。这里推荐阅读 HTTP/2 push is tougher than I thought 这篇文章,但是内容是英文的,我翻译一下文章中的几个结论,有能力的同学还是推荐自己阅读

  • 所有的资源都能被推送,但是 Edge 和 Safari 浏览器兼容性不怎么好
  • 可以推送 no-cacheno-store 的资源
  • 一旦连接被关闭,Push Cache 就被释放
  • 多个页面可以使用相同的 HTTP/2 连接,也就是说能使用同样的缓存
  • Push Cache 中的缓存只能被使用一次
  • 浏览器可以拒绝接受已经存在的资源推送
  • 你可以给其他域名推送资源

网络请求

如果所有缓存都没有命中的话,那么只能发起请求来获取资源了。

那么为了性能上的考虑,大部分的接口都应该选择好缓存策略,接下来我们就来学习缓存策略这部分的内容。

缓存策略

通常浏览器缓存策略分为两种:强缓存协商缓存,并且缓存策略都是通过设置 HTTP Header 来实现的。

强缓存

强缓存可以通过设置两种 HTTP Header 实现:ExpiresCache-Control 。强缓存表示在缓存期间不需要请求,state code 为 200。

Expires

Expires: Wed, 22 Oct 2018 08:41:00 GMT

Expires 是 HTTP/1 的产物,表示资源会在 Wed, 22 Oct 2018 08:41:00 GMT 后过期,需要再次请求。并且 Expires 受限于本地时间,如果修改了本地时间,可能会造成缓存失效。

Cache-control

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-ModifiedETag

当浏览器发起请求验证资源时,如果资源没有做改变,那么服务端就会返回 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 表示本地文件最后修改日期,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 类似于文件指纹,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 文件中引入的文件名发生了改变才会去下载最新的代码文件,否则就一直使用缓存。

小结

在这一章节中我们了解了浏览器的缓存机制,并且列举了几个场景来实践我们学习到的理论。

10、V8引擎机制

V8执行js

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(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. 浏览器的渲染过程是怎样的

null大体流程如下:

1.HTML和CSS经过各自解析,生成DOM树和CSSOM树

2.合并成为渲染树

3.根据渲染树进行布局

4.最后调用GPU进行绘制,显示在屏幕上

前端面试超全整理1( js 浏览器安全 性能)_第7张图片

11、浏览器渲染原理

浏览器接收到 HTML 文件并转换为 DOM 树

当我们打开一个网页时,浏览器都会去请求对应的 HTML 文件。虽然平时我们写代码时都会分为 JS、CSS、HTML 文件,也就是字符串,但是计算机硬件是不理解这些字符串的,所以在网络中传输的内容其实都是 01 这些字节数据。当浏览器接收到这些字节数据以后,它会将这些字节数据转换为字符串,也就是我们写的代码。

当数据转换为字符串以后,浏览器会先将这些字符串通过词法分析转换为标记(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 树

其实转换 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 性能很差,但是这其中的原因是什么呢?

因为 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 文件下载和解析不会阻塞渲染。

重绘(Repaint)和回流(Reflow)

重绘和回流会在我们设置节点样式时频繁出现,同时也会很大程度上影响性能。

  • 重绘是当节点需要更改外观而不会影响布局的,比如改变 color 就叫称为重绘
  • 回流是布局或者几何属性需要改变就称为回流。

回流必定会发生重绘,重绘不一定会引发回流。回流所需的成本比重绘高的多,改变父节点里的子节点很可能会导致父节点的一系列回流。

以下几个动作可能会导致性能问题:

  • 改变 window 大小
  • 改变字体
  • 添加或删除样式
  • 文字改变
  • 定位或者浮动
  • 盒模型

并且很多人不知道的是,重绘和回流其实也和 Eventloop 有关。

  1. 当 Eventloop 执行完 Microtasks 后,会判断 document 是否需要更新,因为浏览器是 60Hz 的刷新率,每 16.6ms 才会更新一次。
  2. 然后判断是否有 resize 或者 scroll 事件,有的话会去触发事件,所以 resizescroll 事件也是至少 16ms 才会触发一次,并且自带节流功能。
  3. 判断是否触发了 media query
  4. 更新动画并且发送事件
  5. 判断是否有全屏操作事件
  6. 执行 requestAnimationFrame 回调
  7. 执行 IntersectionObserver 回调,该方法用于判断元素是否可见,可以用于懒加载上,但是兼容性不好
  8. 更新界面
  9. 以上就是一帧中可能会做的事情。如果在一帧中有空闲时间,就会去执行 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
    • videoiframe 标签

思考题

思考题:在不考虑缓存和优化网络协议的前提下,考虑可以通过哪些方式来最快的渲染页面,也就是常说的关键渲染路径,这部分也是性能优化中的一块内容。

首先你可能会疑问,那怎么测量到底有没有加快渲染速度呢

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(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 事件后,就会生成渲染树,生成渲染树就可以进行渲染了,这一过程更大程度上和硬件有关系了。

提示如何加速:

  1. 从文件大小考虑
  2. script 标签使用上来考虑
  3. 从 CSS、HTML 的代码书写上来考虑
  4. 从需要下载的内容是否需要在首屏使用上来考虑

以上提示大家都可以从文中找到,同时也欢迎大家踊跃在评论区写出你的答案。

小结

以上就是我们这一章节的内容了。在这一章节中,我们了解了浏览器如何将文件渲染为页面,同时也掌握了一些优化的小技巧。

12、安全防范知识点

这一章我们将来学习安全防范这一块的知识点。总的来说安全是很复杂的一个领域,不可能通过一个章节就能学习到这部分的内容。在这一章节中,我们会学习到常见的一些安全问题及如何防范的内容,在当下其实安全问题越来越重要,已经逐渐成为前端开发必备的技能了。

XSS

涉及面试题:什么是 XSS 攻击?如何防范 XSS 攻击?什么是 CSP?

XSS 简单点来说,就是攻击者想尽一切办法将可以执行的代码注入到网页中。

XSS 可以分为多种类型,但是总体上我认为分为两类:持久型和非持久型

持久型也就是攻击的代码被服务端写入进数据库中,这种攻击危害性很大,因为如果网站访问量很大的话,就会导致大量正常访问页面的用户都受到攻击。

举个例子,对于评论功能来说,就得防范持久型 XSS 攻击,因为我可以在评论中输入以下内容

前端面试超全整理1( js 浏览器安全 性能)_第8张图片

这种情况如果前后端没有做好防御的话,这段评论就会被存储到数据库中,这样每个打开该页面的用户都会被攻击到。

非持久型相比于前者危害就小的多了,一般通过修改 URL 参数的方式加入攻击代码,诱导用户访问链接从而进行攻击。

举个例子,如果页面需要从 URL 中获取某些参数作为内容的话,不经过过滤就会导致攻击代码被执行

<!-- http://www.domain.com?name=<script>alert(1)</script> -->
<div>{
     {
     name}}</div>                                                  

但是对于这种攻击方式来说,如果用户使用 Chrome 这类浏览器的话,浏览器就能自动帮助用户防御攻击。但是我们不能因此就不防御此类攻击了,因为我不能确保用户都使用了该类浏览器。

前端面试超全整理1( js 浏览器安全 性能)_第9张图片

对于 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

CSP 本质上就是建立白名单,开发者明确告诉浏览器哪些外部资源可以加载和执行。我们只需要配置规则,如何拦截是由浏览器自己实现的。我们可以通过这种方式来尽量减少 XSS 攻击。

通常可以通过两种方式来开启 CSP:

  1. 设置 HTTP Header 中的 Content-Security-Policy
  2. 设置 meta 标签的方式 ``

这里以设置 HTTP Header 来举例

  • 只允许加载本站资源

    Content-Security-Policy: default-src ‘self’
    
  • 只允许加载 HTTPS 协议图片

    Content-Security-Policy: img-src https://*
    
  • 允许加载任何来源框架

    Content-Security-Policy: child-src 'none'
    

当然可以设置的属性远不止这些,你可以通过查阅 文档 的方式来学习,这里就不过多赘述其他的属性了。

对于这种方式来说,只要开发者配置了正确的规则,那么即使网站存在漏洞,攻击者也不能执行它的攻击代码,并且 CSP 的兼容性也不错。

前端面试超全整理1( js 浏览器安全 性能)_第10张图片

CSRF

涉及面试题:什么是 CSRF 攻击?如何防范 CSRF 攻击?

CSRF 中文名为跨站请求伪造。原理就是攻击者构造出一个后端请求地址,诱导用户点击或者通过某些途径自动发起请求。如果用户是在登录状态下的话,后端就以为是用户在操作,从而进行相应的逻辑。

举个例子,假设网站中有一个通过 GET 请求提交用户评论的接口,那么攻击者就可以在钓鱼网站中加入一个图片,图片的地址就是评论接口


那么你是否会想到使用 POST 方式提交请求是不是就没有这个问题了呢?其实并不是,使用这种方式也不是百分百安全的,攻击者同样可以诱导用户进入某个页面,在页面中通过表单提交 POST 请求。

如何防御

防范 CSRF 攻击可以遵循以下几种规则:

  1. Get 请求不对数据进行修改
  2. 不让第三方网站访问到用户 Cookie
  3. 阻止第三方网站请求接口
  4. 请求时附带验证信息,比如验证码或者 Token

SameSite

可以对 Cookie 设置 SameSite 属性。该属性表示 Cookie 不随着跨域请求发送,可以很大程度减少 CSRF 的攻击,但是该属性目前并不是所有浏览器都兼容。

验证 Referer

对于需要防范 CSRF 的请求,我们可以通过验证 Referer 来判断该请求是否为第三方网站发起的。

Token

服务器下发一个随机 Token,每次发起请求时将 Token 携带上,服务器验证 Token 是否有效。

点击劫持

涉及面试题:什么是点击劫持?如何防范点击劫持?

点击劫持是一种视觉欺骗的攻击手段。攻击者将需要攻击的网站通过 iframe 嵌套的方式嵌入自己的网页中,并将 iframe 设置为透明,在页面中透出一个按钮诱导用户点击。
前端面试超全整理1( js 浏览器安全 性能)_第11张图片

对于这种攻击方式,推荐防御的方法有两种。

X-FRAME-OPTIONS

X-FRAME-OPTIONS 是一个 HTTP 响应头,在现代浏览器有一个很好的支持。这个 HTTP 响应头 就是为了防御用 iframe 嵌套的点击劫持攻击。

该响应头有三个值可选,分别是

  • DENY,表示页面不允许通过 iframe 的方式展示
  • SAMEORIGIN,表示页面可以在相同域名下通过 iframe 的方式展示
  • ALLOW-FROM,表示页面可以在指定来源的 iframe 中展示

JS 防御

对于某些远古浏览器来说,并不能支持上面的这种方式,那我们只有通过 JS 的方式来防御点击劫持了。


  


  

以上代码的作用就是当通过 iframe 的方式加载页面时,攻击者的网页直接不显示所有内容了。

中间人攻击

涉及面试题:什么是中间人攻击?如何防范中间人攻击?

中间人攻击是攻击方同时与服务端和客户端建立起了连接,并让对方认为连接是安全的,但是实际上整个通信过程都被攻击者控制了。攻击者不仅能获得双方的通信信息,还能修改通信信息。

通常来说不建议使用公共的 Wi-Fi,因为很可能就会发生中间人攻击的情况。如果你在通信的过程中涉及到了某些敏感信息,就完全暴露给攻击方了。

当然防御中间人攻击其实并不难,只需要增加一个安全通道来传输信息。HTTPS 就可以用来防御中间人攻击,但是并不是说使用了 HTTPS 就可以高枕无忧了,因为如果你没有完全关闭 HTTP 访问的话,攻击方可以通过某些方式将 HTTPS 降级为 HTTP 从而实现中间人攻击。

小结

在这一章中,我们学习到了一些常见的前端安全方面的知识及如何防御这些攻击。但是安全的领域相当大,这些内容只是沧海一粟,如果大家对于安全有兴趣的话,可以阅读 这个仓库的内容 来学习和实践这方面的知识。

13、从 V8 中看 JS 性能优化

注意:该知识点属于性能优化领域

在学习如何性能优化之前,我们先来了解下如何测试性能问题,毕竟是先有问题才会去想着该如何改进。

测试性能工具

  • 可以通过 Audit 工具 run Audit获得网站的多个指标的性能报告
  • 可以通过 Performance 工具了解网站的性能瓶颈
  • 可以通过 Performance API 具体测量时间
  • 为了减少编译时间,我们可以采用减少代码文件的大小或者减少书写嵌套函数的方式
  • 为了让 V8 优化代码,我们应该尽可能保证传入参数的类型一致。这也给我们带来了一个思考,这是不是也是使用 TypeScript 能够带来的好处之一。

项目怎么进行优化

延迟:

CDN

带宽

js延迟、按需加载

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 下一屏的组件。

DNS解析

DNS解析流程

  1. 查找浏览器缓存。
  2. 查找系统缓存。
  3. 查找路由器缓存。
  4. 查找ISP DNS 缓存。
  5. 迭代查询。

优化思路

  1. 减少DNS查找,避免重定向
  2. 使用浏览器DNS缓存 、计算机DNS缓存、 服务器DNS缓存,防止DNS迭代查询;
  3. 使用Keep-Alive特性 来减少DNS查找的频率;
  4. 使用较少的域名(服务器主机)来减少DNS查找的数量。

影响DNS缓存的因素

  1. 首先,服务器可以表明记录可以被缓存多久。查找返回的DNS记录包含了一个存活时间(Time-to-live,TTL)值。该值告诉客户端可以对该记录缓存多久。
  2. 尽管操作系统缓存会考虑 TTL 值,但浏览器通常忽略该值,并设置它自己的时间限制。
  3. 此外,HTTP 协议中的 Keep-Alive 特性可以同时覆盖 TTL 和浏览器的时间限制。换句话说,只要浏览器和Web服务器愉快地通信着,并保持 TCP 连接打开的状态,就没有理由进行 DNS 查找。
  4. 浏览器对缓存的 DNS 记录的数量也有限制,而不管缓存记录的时间。如果用户在短时间内访问了很多具有不同域名的网站,较早的 DNS 记录将被丢弃,必须重新查找该域名。
  5. 不过,要记得即便浏览器丢弃了 DNS 记录,操作系统可能依然保持着该记录,这能扭转一下局面,因为无需通过网络发送查询,从而避免了明显的延迟。
  6. 当客户端的DNS缓存为空时(浏览器与操作系统缓存为空时),DNS查找的数量与Web页面中唯一主机名的数量相等。减少唯一主机名的数量就可以减少DNS查找的数量。
  7. 但减少唯一主机名的数量会潜在地减少页面中并行下载的数量。(HTTP1.1,浏览器对于同一个服务器的并行连接数有限制)因此,虽然避免 DNS 查找降低了相应时间,但减少并行下载可能会增加响应时间。所以,在减少 DNS 查找与允许高速并行下载之间做权衡,建议使用 2-4 个服务器主机。

DNS的预解析

可以通过用meta信息来告知浏览器, 我这页面要做DNS预解析


可以使用link标签来强制对DNS做预解析:

 

TCP/TLS

  • 减少页面重定向 页面重定向性能消耗较大
  • 减少 Rewrite
  • SPA,减少tcp请求
  • cdn:更低的延迟
  • http2.0

http2.0优点

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旧版本的队头阻塞问题,极大提高传输性能。

服务器Push

在HTTP2.0中,服务端可以在客户端某个请求后,主动推送其他资源。
可以想象一下,某些资源客户端是一定会请求的,这时就可以采取服务端push的技术,提前给客户端推送必要的资源,就可以相对减少一点延迟时间。在浏览器兼容的情况下也可以使用prefetch。

更安全

HTTP2.0使用了tls的拓展ALPN做为协议升级,除此之外,HTTP2.0对tls的安全性做了近一步加强,通过黑名单机制禁用了几百种不再安全的加密算法。

HTTP1.x主要缺点

  • HTTP/1.0一次只允许在一个TCP连接上发起一个请求,HTTP/1.1使用的流水线技术也只能部分处理请求并发,仍然会存在队列头阻塞问题,因此客户端在需要发起多次请求时,通常会采用建立多连接来减少延迟。
  • 单向请求,只能由客户端发起。
  • 请求报文与响应报文首部信息冗余量大。
  • 数据未压缩,导致数据的传输量大。

静态资源

  • 数据压缩传输(gzip,broti,http2.0)
  • webpack:tree shaking,。。。
  • 移除不必要的lib引用,或迁移到CDN (webpack-bundle-analyzer)

14、性能优化琐碎事

注意:该知识点属于性能优化领域。

总的来说性能优化这个领域的很多内容都很碎片化,这一章节我们将来学习这些碎片化的内容。

图片优化

计算图片大小

对于一张 100 * 100 像素的图片来说,图像上有 10000 个像素点,如果每个像素的值是 RGBA 存储的话,那么也就是说每个像素有 4 个通道,每个通道 1 个字节(8 位 = 1个字节),所以该图片大小大概为 39KB(10000 * 1 * 4 / 1024)。

但是在实际项目中,一张图片可能并不需要使用那么多颜色去显示,我们可以通过减少每个像素的调色板来相应缩小图片的大小。

了解了如何计算图片大小的知识,那么对于如何优化图片,想必大家已经有 2 个思路了:

  • 减少像素点
  • 减少每个像素点能够显示的颜色

图片加载优化

  1. 不用图片。很多时候会使用到很多修饰类图片,其实这类修饰图片完全可以用 CSS 去代替。
  2. 对于移动端来说,屏幕宽度就那么点,完全没有必要去加载原图浪费带宽。一般图片都用 CDN 加载,可以计算出适配屏幕的宽度,然后去请求相应裁剪好的图片。
  3. 小图使用 base64 格式
  4. 将多个图标文件整合到一张图片中(雪碧图)
  5. 选择正确的图片格式:
    • 对于能够显示 WebP 格式的浏览器尽量使用 WebP 格式。因为 WebP 格式具有更好的图像数据压缩算法,能带来更小的图片体积,而且拥有肉眼识别无差异的图像质量,缺点就是兼容性并不好
    • 小图使用 PNG,其实对于大部分图标这类图片,完全可以使用 SVG 代替
    • 照片使用 JPEG

DNS 预解析

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 加载静态资源需要注意 CDN 域名要与主站不同,否则每次请求都会带上主站的 Cookie,平白消耗流量。

小结

这些碎片化的性能优化点看似很短,但是却能在出现性能问题时简单高效的提高性能,并且好几个点都是面试高频考点,比如节流、防抖。如果你还没有在项目中使用过这些技术,可以尝试着用到项目中,体验下功效。

15、实现小型打包工具

原本小册计划中是没有这一章节的,Webpack 工作原理应该是上一章节包含的内容。但是考虑到既然讲到工作原理,必然需要讲解源码,但是 Webpack 的源码很难读,不结合源码干巴巴讲原理又没有什么价值。所以在这一章节中,我将会带大家来实现一个几十行的迷你打包工具,该工具可以实现以下两个功能

  • 将 ES6 转换为 ES5
  • 支持在 JS 文件中 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 函数,传入入口文件
  • 分析入口文件的依赖
  • 识别 JS 和 CSS 文件
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 到这个数组中
  • 在遍历的过程中,我们先获得该文件对应的目录,然后遍历当前文件的依赖关系
  • 在遍历当前文件依赖关系的过程中,首先生成依赖文件的绝对路径,然后判断当前文件是 CSS 文件还是 JS 文件
    • 如果是 CSS 文件的话,我们就不能用 Babel 去编译了,只需要读取 CSS 文件中的代码,然后创建一个 style 标签,将代码插入进标签并且放入 head 中即可
    • 如果是 JS 文件的话,我们还需要分析 JS 文件是否还有别的依赖关系
    • 最后将读取文件后的对象 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 行的代码,但是打包工具的核心原理就是这些了

  1. 找出入口文件所有的依赖关系

  2. 然后通过构建 CommonJS 代码来获取 exports 导出的内容。

你可能感兴趣的:(interview,javascript)