前端知识点汇总、深入浅出

一、JavaScript常考题


1、原始(Primitive)类型


涉及面试题:原始类型有哪几种?null 是对象吗?


7种原始:

  • boolean 布尔值
  • null 空值
  • undefined 未定义
  • number 数值
  • string 字符串
  • symbol 唯一值
  • bigInt 任意大的整数

    let b = null
    console.log(typeof b)
    // => object

这只是 JS 存在的一个悠久 Bug。在 JS 的最初版本中使用的是 32 位系统,为了性能考虑使用低位存储变量的类型信息,000 开头代表是对象,然而 null 表示为全零,所以将它错误的判断为 object 。虽然现在的内部类型判断代码已经改变了,但是对于这个 Bug 却是一直流传下来。


Number注意点:JS中整数的运算可以保证精确的结果,但在JS中浮点数的运算可能得到一个不精确的结果;string 类型是不可变的,无论你在 string 类型上调用何种方法,都不会对值有改变。



2、对象(Object)类型


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

在 JS 中,除了原始类型那么其他的都是对象类型了。对象类型和原始类型不同的是,原始类型存储的是值,对象类型存储的是地址(指针)。当你创建了一个对象类型的时候,计算机会在内存中帮我们开辟一个空间来存放值,但是我们需要找到这个空间,这个空间会拥有一个地址(指针)。

    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}

  • 函数传参是传递对象指针的副本
  • 到函数内部修改参数的属性这步const p2 = test(p1),当前 p1 的值被修改了
  • 当执行 test(person) 会重新为 person 分配了一个对象,所以最后 person 拥有了一个新的地址(指针),也就和 p1 没有任何关系了,导致了最终两个变量的值是不相同的。

在这里插入图片描述



3、typeof vs instanceof


涉及面试题:typeof 是否能正确判断类型?instanceof 能正确判断对象的原理是什么?

①、typeof


  • 对于原始类型来说,除了 null 都可以显示正确的类型
  • 对于对象来说,除了函数都会显示 object,所以说 typeof 并不能准确判断变量到底是什么类型
    let a = 123
    console.log(typeof a)
    // => number
    let b = "123"
    console.log(typeof b)
    // => string
    let c = true
    console.log(typeof c)
    // => boolean
    let d = null
    console.log(typeof d)
    // => object
    let e = undefined
    console.log(typeof e)
    // => undefined
    let f = Symbol()
    console.log(typeof f)
    // => symbol

    let obj1 = []
    console.log(typeof obj1)
    // => object
    let obj2 = {}
    console.log(typeof obj2)
    // => object
    let obj3 = console.log
    console.log(typeof obj3)
    // => function

②、instanceof


语法: object (实例对象) instanceof constructor (构造函数)

  • 如果我们想判断一个对象的正确类型,这时候可以考虑使用 instanceof,因为内部机制是通过原型链来判断的
  • instanceof 运算符用来检测 constructor.prototype 是否存在于参数 object 的原型链上

// 定义构造函数
function C(){} 
function D(){} 
 
var o = new C();
 
 
o instanceof C; // true,因为 Object.getPrototypeOf(o) === C.prototype
 
 
o instanceof D; // false,因为 D.prototype 不在 o 的原型链上
 
o instanceof Object; // true,因为 Object.prototype.isPrototypeOf(o) 返回 true
C.prototype instanceof Object // true,同上
 
C.prototype = {};
var o2 = new C();
 
o2 instanceof C; // true
 
o instanceof C; // false,C.prototype 指向了一个空对象,这个空对象不在 o 的原型链上.
 
D.prototype = new C(); // 继承
var o3 = new D();
o3 instanceof D; // true
o3 instanceof C; // true 因为 C.prototype 现在在 o3 的原型链上 

实例

function Car(make, model, year) {
  this.make = make;
  this.model = model;
  this.year = year;
}
var mycar = new Car("Honda", "Accord", 1998);
var a = mycar instanceof Car;    // 返回 true
var b = mycar instanceof Object; // 返回 true

// 检测对象不是某个构造函数的实例
if (!(mycar instanceof Car)) {
  // Do something, like mycar = new Car(mycar)
}


4、类型转换


涉及面试题:在笔试题中必考, 理解转换规则就不担心了


原始值转换类型结果
String数值“空” => 0, “number” => number, 其他的都是 NaN
Boolean数值true => 1, false => 0
undefined数值NaN
null数值0
string布尔值“” => 0, 其他的都是 true
Number布尔值0 / NaN => false, 其他的都是 true
undefined布尔值false
null布尔值false
Symbol数值抛错
数组字符串’ [1, 2] ’
对象字符串’ [object, Object] ’
Boolean / 函数 / Symbol字符串’ true ’

①、转 Number


  • 通过Number(常量or变量)方式来转换
  • 可以通过数学运算中的+号-号 来转换
  • 可以通过parseInt(需要转换的字符串) / parseFloat(需要转换的字符串)
    // Number(常量or变量)
    let str1 = "123";
    let num1 = Number(str1);
    console.log(typeof num1);


    // +号 和 -号
    // +/-底层本质上就是调用了Number函数
    let str2 = "123";
    let num2 = + "12px";
    console.log(typeof num2);

    
    // parseInt(需要转换的字符串) / parseFloat(需要转换的字符串)
    let str3 = "3.14px";
    let num3 = parseInt(str3);  // 取整
    // let num3 = parseFloat(str3);  // 可以取到小数
    console.log(num3);
    console.log(typeof num3);

    let str4 = "a3.14px";
    let num4 = parseFloat(str4);
    // 注意点: parseInt/parseFloat都会从左至右的提取数值, 一旦遇到非数值就会立即停止
    console.log(num4);
    console.log(typeof num4);

②、转 Boolean


  • Boolean(常量or变量)
    // let str = "abc"; // true
    // let str = "     "; // true
    // let str = ""; // false
    let str = null; // false
    let flag = Boolean(str);
    console.log(flag);
    console.log(typeof flag);

    // 注意点: 在JavaScript中NaN属于Number类型

③、转 String


  • 对于Number类型和Boolean类型来说, 可以通过 变量名称.toString() 的方式来转换
  • 可以通过 String(常量or变量) 转换为字符串
  • 可以通过 变量or常量 + "" / 变量or常量 + '' 转换为字符串

    let value1 = 123;
    // 将value变量中存储的数据拷贝一份, 然后将拷贝的数据转换为字符串之后返回
    let str1 = value1.toString();
    // let str1 = 123.toString();
    // 注意点: 不能使用常量直接调用toString方法, 因为常量是不能改变的
    console.log(str1);
    console.log(typeof str1);


    // String(常量or变量)
    let value2 = 456;

    // String(变量)
    let str2 = String(value2);
    console.log(str2);
    console.log(typeof str2);

    // String(常量)
    let str3 = String(456);
    console.log(str3);
    console.log(typeof str3);


    // 变量or常量 + "" / ''
    // +''或者+""底层的本质其实就是调用String()函数
    let value4 = 789;
    let str4 = value4 + "";
    console.log(str4);
    console.log(typeof str4);


5、运算符


优先级运算符说明结合性
1[ ]( ).数组索引、函数调用、表达式分组从左向右
2++--+-!~newtypeofvoid一元运算符(自增、自减、取正、取负、取非、取反码)、对象创建、返回数据类型、未定义从右到左
3*/%乘、除、取余从左向右
4+-加、减从左向右
5<<>>>>>左位移、右位移、无符号右移(二进制的基础上对数字进行平移)从左向右
6<><=>=instanceof小于、小于等于、大于、大于等于、是否为特定类型实例从左向右
7==!====!==相等、不相等、全等、不全等从左向右
8&按位’与’从左向右
9^按位’异或’从左向右
10|按位’或’从左向右
11&&逻辑’与’从左向右
12||逻辑’或’’ true ’
13?条件运算符从右向左
14=+=-=*=/=%=&=!=^=>>=混合赋值运算符从右向左
15,多个计算’ true ’

任何值和NaN做运算都得NaN
非Number类型的值进行运算时,会将这些值转换为Number然后在运算
任何的值和字符串做加法运算,都会先转换为字符串,然后再和字符串做拼串的操作
任何的值和字符串做- * / %法运算, 都会先转换为字符串转换为Number
取余运算m%n, n等于0 返回NaN


在企业开发中自增自减运算符最好单独出现, 不要出现在表达式中

    // 前置与后置的区别:参与运算的时候有区别
    //num++: 先使用,然后再+1;
    //++num:先自身+1,再使用

    var num = 100;
    var result = num++ +10;
    console.log(num);//101
    console.log(result);//110

    // 在企业开发中自增自减运算符最好单独出现, 不要出现在表达式中
    var a = 10;
    var b = a++ + a++;//10 + 11
    console.log(b);//21

    var c = 10;
    var d = ++c + ++c;//11 + 12
    console.log(d);//23

    var e = 10;
    var f = ++e + e++;//11 + 11
    console.log(f);//22

    var m = 10;
    var n = m++ + ++m;//10 +12
    console.log(n); //22

  • 要求用户输入一个整数, 判断这个数是否是100~200之间的数
let num = prompt("请输入一个整数");
// console.log(num);
num >= 100 && num <=200 ? alert("是100~200之间的数") : alert("不是100~200之间的数");

  • 要求用户输入两个整数, 找出最大的那个数之后输出
let num1 = prompt("请输入第一个整数");
let num2 = prompt("请输入第二个整数");
let max = num1 > num2 ? num1 : num2;
alert("最大的那个整数是:" + max);


6、this指向


涉及面试题:如何正确判断 this?箭头函数的 this 是什么?

大道至简, 想清晰简洁地说明this的指向, 下图的流程只针对于单个规则

在这里插入图片描述

发生多个规则同时出现的情况,不同的规则之间会根据优先级最高的来决定 this 最终指向哪里。首先,new 的方式优先级最高,接下来是 bind 这些函数,然后是 obj.foo() 这种调用方式,最后是 foo 这种调用方式,同时,箭头函数的 this 一旦被绑定,就不会再被任何方式所改变。


函数调用的场景

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

const obj = {
  a: 2,
  foo: foo
}
obj.foo()

const c = new foo()

  • 对于直接调用 foo 来说,不管 foo 函数被放在了什么地方,this 一定是 window
  • 对于 obj.foo() 来说,我们只需要记住,谁调用了函数,谁就是 this,所以在这个场景下 foo 函数中的 this 就是 obj 对象
  • 对于 new 的方式来说,this 被永远绑定在了 c 上面,不会被任何方式改变 this

箭头函数其实是没有 this 的,箭头函数中的 this 只取决包裹箭头函数的第一个普通函数的 this


function a() {
  return () => {
    return () => {
      console.log(this)
    }
  }
}
console.log(a()()())
// => 因为包裹箭头函数的第一个普通函数是 a,所以此时的 this 是 window

bind

let a = {}
let fn = function () { console.log(this) }
fn.bind().bind(a)() // => ?

// fn.bind().bind(a) 等于
let fn2 = function fn1() {
  return function() {
    return fn.apply()
  }.apply(a)
}
fn2()

不管我们给函数 bind 几次,fn 中的 this 永远由第一次 bind 决定,所以结果永远是 window。



7、== vs ===


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


== 是值等, 数值相同即可;===是全等, 需要类型和值都相等;

  • === 全等:先判断类型, 再判断值

  • == 值等的流程图:
    在这里插入图片描述



8、闭包


定义:函数 A 内部有一个函数 B,函数 B 可以访问到函数 A 中的变量,那么函数 B 就是闭包。
闭包存在的意义就是让我们可以间接访问函数内部的变量
闭包并非一个函数嵌套另一个函数

function A() {
  let a = 1
  window.B = function () {
      console.log(a)
  }
}
A()
B() // 1

经典面试题,循环中使用闭包解决 var 定义函数的问题


for (var i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i)
  }, i * 1000)
}
// => 6,6,6,6,6,6

因为 setTimeout 是个异步函数,所以会先把循环全部执行完毕,这时候 i 就是 6 了,所以会输出一堆 6。

  • 方案一:闭包

首先使用了立即执行函数将 i 传入函数内部,这个时候值就被固定在了参数 j 上面不会改变,当下次执行 timer 这个闭包的时候,就可以使用外部函数的变量 j,从而达到目的。

for (var i = 1; i <= 5; i++) {
  (function(j) {
    setTimeout(function timer() {
      console.log(j)
    }, j * 1000)
  })(i)
}

  • 方案二:将函数作为参数传入

使用 setTimeout 的第三个参数,这个参数会被当成 timer 函数的参数传入。

for (var i = 1; i <= 5; i++) {
  setTimeout(
    function timer(j) {
      console.log(j)
    },
    i * 1000,
    i
  )
}

  • 方案三:let定义局部变量 i

使用 let 定义 i 了来解决问题了,这个也是最为推荐的方式

for (let i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i)
  }, i * 1000)
}


9、深浅拷贝


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


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

①、浅拷贝


  • 可以通过 Object.assign 实现浅拷贝,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)) 实现深拷贝
  • 局限性:会忽略 undefined、会忽略 symbol、不能序列化函数、不能解决循环引用的对象
  • 如果所需拷贝的对象含有内置类型并且不包含函数,可以使用 MessageChannel

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

// 局限性
let a = {
  age: undefined,
  sex: Symbol('male'),
  jobs: function() {},
  name: 'yck'
}
let b = JSON.parse(JSON.stringify(a))
console.log(b) // {name: "yck"}

// MessageChannel
function structuralClone(obj) {
  return new Promise(resolve => {
    const { port1, port2 } = new MessageChannel()
    port2.onmessage = ev => resolve(ev.data)
    port1.postMessage(obj)
  })
}

var obj = {
  a: 1,
  b: {
    c: 2
  }
}

obj.b.d = obj.b

// 注意该方法是异步的
// 可以处理 undefined 和循环引用对象
const test = async () => {
  const clone = await structuralClone(obj)
  console.log(clone)
}
test()

实现一个深拷贝(简易版):

推荐使用 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


10、原型


涉及面试题:如何理解原型?如何理解原型链?

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


加粗样式

①、构造函数创建对象


Person 就是一个构造函数,我们使用 new 创建了一个实例对象 person

function Person() {

}
var person = new Person();
person.name = 'Kevin';
console.log(person.name) // Kevin


当我们创建一个对象, 在浏览器中打印 obj 时你会发现,在 obj 上居然还有一个 __proto__ 属性;其实每个 JS 对象都有 proto 属性,这个属性指向了原型

  • **prototype:**函数的 prototype 属性指向了一个对象,这个对象正是调用该构造函数而创建的实例的原型
  • proto:每一个JS对象(除了 null )都具有的一个属性,叫 __proto__ ,这个属性会指向该对象的原型
  • **constructor:**每个原型都有一个 constructor 属性指向关联的构造函数

在这里插入图片描述

function Person() {
}

var person = new Person();

console.log(person.__proto__ == Person.prototype) // true
console.log(Person.prototype.constructor == Person) // true
// 顺便学习一个ES5的方法,可以获得对象的原型
console.log(Object.getPrototypeOf(person) === Person.prototype) // true

②、实例与原型

当读取实例的属性时,如果找不到,就会查找与对象关联的原型中的属性,如果还查不到,就去找原型的原型,一直找到最顶层为止。

function Person() {

}

Person.prototype.name = 'Kevin';

var person = new Person();

person.name = 'Daisy';
console.log(person.name) // Daisy

delete person.name;
console.log(person.name) // Kevin

  1. List item在这个例子中,我们给实例对象 person 添加了 name 属性,当我们打印 person.name 的时候,结果自然为 Daisy
  2. 但是当我们删除了 personname 属性时,读取 person.name,从 person 对象中找不到 name 属性就会从 person 的原型也就是 person.__proto__ ,也就是 Person.prototype 中查找,幸运的是我们找到了 name 属性,结果为 Kevin
  3. 但是万一还没有找到呢?原型的原型又是什么呢

③、原型的原型


原型也是一个对象,既然是对象,我们就可以用最原始的方式创建它,那就是:
var obj = new Object();
obj.name = 'Kevin'
console.log(obj.name) // Kevin

原型对象就是通过 Object 构造函数生成的,结合之前所讲,实例的 __proto__ 指向构造函数的 prototype ,所以我们再更新下关系图
在这里插入图片描述

④、原型链

那 Object.prototype 的原型呢?

console.log(Object.prototype.__proto__ === null) // true

Object.prototype.__proto__ 的值为 nullObject.prototype 没有原型,其实是一个意思。

所以查找属性的时候查到 Object.prototype 就可以停止查找了。

由相互关联的原型组成的链状结构就是原型链,也就是蓝色的这条线
在这里插入图片描述




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


1、var、let、const


涉及面试题:什么是提升?什么是暂时性死区?var、let 及 const 区别?

提升:变量 还没有被声明, 但却可以使用这个未被声明的变量

暂时性死区:let const 的变量提升在 局部作用域, 访问会受到限制

  • 函数提升优先于变量提升,函数提升会把整个函数挪到作用域顶部,变量提升只会把声明挪到作用域顶部
  • var 存在提升,我们能在声明之前使用。let、const 因为暂时性死区的原因,不能在声明前使用
  • var 在全局作用域下声明变量会导致变量挂载在 window 上,其他两者不会
  • let 和 const 作用基本一致,但是后者声明的变量不能再次赋值



2、原型继承 和 Class继承


涉及面试题:原型如何实现继承?Class 如何实现继承?Class 本质是什么?


class本质:在JS中并不存在类, class只是语法糖, 本质还是函数


  • 组合继承
  • 寄生组合继承
  • class继承

组合继承是最常用的继承方式

function Parent(value) {
  this.val = value
}
Parent.prototype.getValue = function() {
  console.log(this.val)
}
function Child(value) {
  Parent.call(this, value)
}
Child.prototype = new Parent()

const child = new Child(1)

child.getValue() // 1
child instanceof Parent // true

  • 核心:在子类的构造函数中通过 Parent.call(this) 继承父类的属性,然后改变子类的原型为 new Parent() 来继承父类的函数
  • 优点:构造函数可以传参,不会与父类引用属性共享,可以复用父类的函数
  • 在继承父类函数的时候调用了父类构造函数,导致子类的原型上多了不需要的父类属性,存在内存上的浪费

寄生组合继承 优化了组合继承的缺点 - 继承父类函数时调用了构造函数

function Parent(value) {
  this.val = value
}
Parent.prototype.getValue = function() {
  console.log(this.val)
}

function Child(value) {
  Parent.call(this, value)
}
Child.prototype = Object.create(Parent.prototype, {
  constructor: {
    value: Child,
    enumerable: false,
    writable: true,
    configurable: true
  }
})

const child = new Child(1)

child.getValue() // 1
child instanceof Parent // true

  • 核心:将父类的原型赋值给了子类,并且将构造函数设置为子类
  • 优点:既解决了无用的父类属性问题,还能正确的找到子类的构造函数

class 继承

class Parent {
  constructor(value) {
    this.val = value
  }
  getValue() {
    console.log(this.val)
  }
}
class Child extends Parent {
  constructor(value) {
    super(value)
  }
}
let child = new Child(1)
child.getValue() // 1
child instanceof Parent // true

  • 核心:使用 extends 表明继承自哪个父类,并且在子类构造函数中必须调用 super,因为这段代码可以看成 Parent.call(this, value)



3、模块化


涉及面试题:为什么要使用模块化?都有哪几种方式可以实现模块化,各有什么特点?


使用模块化的好处:

  • 解决命名冲突
  • 提供复用性
  • 提高代码可维护性

实现模块化的方法:

  • 立即执行函数
  • AMD & CMD ( 很少 )
  • CommonJS
  • ES Module

立即执行函数:

在早期,使用立即执行函数实现模块化是常见的手段,通过函数作用域解决了命名冲突、污染全局作用域的问题

(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 有一些区别了

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

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


4、Proxy


涉及面试题:Proxy 可以实现什么功能?


Proxy 是 ES6 中新增的功能,它可以用来自定义对象中的操作。例如在 Vue3.0 中将会通过 Proxy 来替换原本的 Object.defineProperty 来实现数据响应式。

let p = new Proxy(target, handler)
  • target 代表需要添加代理的对象
  • handler 用来自定义对象中的操作,比如可以用来自定义 set 或者 get 函数。

通过 `Proxy` 来实现一个数据响应式
let onWatch = (obj, setBind, getLogger) => {
  let handler = {
    get(target, property, receiver) {
      getLogger(target, property)
      return Reflect.get(target, property, receiver)
    },
    set(target, property, value, receiver) {
      setBind(value, property)
      return Reflect.set(target, property, value)
    }
  }
  return new Proxy(obj, handler)
}

let obj = { a: 1 }
let p = onWatch(
  obj,
  (v, property) => {
    console.log(`监听到属性${property}改变为${v}`)
  },
  (target, property) => {
    console.log(`'${property}' = ${target[property]}`)
  }
)
p.a = 2 // 监听到属性a改变
p.a // 'a' = 2

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


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



5、map, filter, reduce


涉及面试题:map, filter, reduce 各自有什么作用?


⑴、 map


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


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 可以将数组中的元素通过回调函数最终转换为一个值

let array = [1, 2, 4, 6]
let newArray = array.filter(item => item !== 6)
console.log(newArray) // [1, 2, 4]

实现一个功能将函数里的元素全部相加得到一个值


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)

实现过程:

  • 首先初始值为 0,该值会在执行第一次回调函数时作为第一个参数传入
  • 回调函数接受四个参数,分别为累计值、当前元素、当前索引、原数组,后三者想必大家都可以明白作用,这里着重分析第一个参数
  • 在一次执行回调函数时,当前值和初始值相加得出结果 1,该结果会在第二次执行回调函数时当做第一个参数传入
  • 所以在第二次执行回调函数时,相加的值就分别是 1 和 2,以此类推,循环结束后得到结果 6

** 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]



三、异步编程及常考面试题


1、并发(concurrency)和并行(parallelism)


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

  • 并发是宏观概念,在一段时间内通过任务间的切换完成多个任务,这种情况就可以称之为并发
  • 并行是微观概念,同时完成多个任务的情况就可以称之为并行

2、回调函数(Callback)


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

⑴、什么是回调函数


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

⑵、回调函数的缺点 ( 回调地狱 )


// 回调地狱(Callback hell)
ajax(url, () => {
    // 处理逻辑
    ajax(url1, () => {
        // 处理逻辑
        ajax(url2, () => {
            // 处理逻辑
        })
    })
})

  • List item嵌套函数存在耦合性,一旦有所改动,就会牵一发而动全身
  • 嵌套函数一多,就很难处理错误
  • 不能使用 try catch 捕获错误,不能直接 return

⑶、如何解决回调地狱问题

  • List item

3、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()

4、Promise


涉及面试题:Promise 的特点是什么,分别有什么优缺点?什么是 Promise 链?Promise 构造函数执行和 then 函数执行有什么区别?

⑴、状态


Promise 翻译过来就是承诺的意思,这个承诺会在未来有一个稳定的状态,分别是:

  • 等待中(pending)
  • 完成了 (resolved)
  • 拒绝了(rejected)

一旦从等待状态变成为其他状态就永远不能更改状态

new Promise((resolve, reject) => {
  resolve('success')
  // 无效
  reject('reject')
})

⑵、链式调用

每次调用 then 之后返回的都是一个 全新的 Promise,原因也是因为状态不可变。如果你在 then 中 使用了 return,那么 return 的值会被 Promise.resolve() 包装

Promise.resolve(1)
  .then(res => {
    console.log(res) // => 1
    return 2 // 包装成 Promise.resolve(2)
  })
  .then(res => {
    console.log(res) // => 2
  })

⑶、解决地狱回调

// 回调地狱(Callback hell)
ajax(url, () => {
    // 处理逻辑
    ajax(url1, () => {
        // 处理逻辑
        ajax(url2, () => {
            // 处理逻辑
        })
    })
})

// Promise
ajax(url)
  .then(res => {
      console.log(res)
      return ajax(url1)
  }).then(res => {
      console.log(res)
      return ajax(url2)
  }).then(res => console.log(res))

⑷、缺点

无法取消 Promise,错误需要通过回调函数捕获。


5、async & await


涉及面试题:async 及 await 的特点,它们的优点和缺点分别是什么?await 原理是什么?

  • 一个函数如果加上 async ,那么该函数就会返回一个 Promise
  • async 就是将函数返回值使用 Promise.resolve() 包裹了下,和 then 中处理返回值一样,并且 await 只能配套 async 使用

async function test() {
  return "1"
}
console.log(test()) // -> Promise {<resolved>: "1"}

async function test() {
  let value = await sleep()
}

asyncawait 可以说是异步终极解决方案,相比直接使用 Promise 来说,优势在于处理 then 的调用链,能够更清晰准确的写出代码,也能优雅地解决回调地狱问题。

当然也存在一些缺点,因为 await异步代码改造成了同步代码,如果多个异步代码没有依赖性却使用了 await 会导致性能上的降低。

async function test() {
  // 以下代码没有依赖性的话,完全可以使用 Promise.all 的方式
  // 如果有依赖性的话,其实就是解决回调地狱的例子了
  await fetch(url)
  await fetch(url1)
  await fetch(url2)
}

实例:

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

6、常用定时器函数


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

⑴、setTimeout


很多人认为 setTimeout 是延时多久,那就应该是多久后执行。其实这个观点是错误的,因为 JS 是单线程执行的,如果前面的代码影响了性能,就会导致 setTimeout 不会按期执行。

可以通过代码去修正 setTimeout,从而使定时器相对准确

let period = 60 * 1000 * 60 * 2
let startTime = new Date().getTime()
let count = 0
let end = new Date().getTime() + period
let interval = 1000
let currentInterval = interval

function loop() {
  count++
  // 代码执行所消耗的时间
  let offset = new Date().getTime() - (startTime + count * interval);
  let diff = end - new Date().getTime()
  let h = Math.floor(diff / (60 * 1000 * 60))
  let hdiff = diff % (60 * 1000 * 60)
  let m = Math.floor(hdiff / (60 * 1000))
  let mdiff = hdiff % (60 * 1000)
  let s = mdiff / (1000)
  let sCeil = Math.ceil(s)
  let sFloor = Math.floor(s)
  // 得到下一次循环所消耗的时间
  currentInterval = interval - offset 
  console.log('时:'+h, '分:'+m, '毫秒:'+s, '秒向上取整:'+sCeil, '代码执行时间:'+offset, '下次循环间隔'+currentInterval) 

  setTimeout(loop, currentInterval)
}

setTimeout(loop, currentInterval)

⑵、setInterval


setTimeoutsetTimeout 基本一致,只是该函数是每隔一段时间执行一次回调函数。

不建议使用 setInterval。第一,它和 setTimeout 一样,不能保证在预期的时间执行任务。第二,它存在执行累积的问题

function demo() {
  setInterval(function(){
    console.log(2)
  },1000)
  sleep(2000)
}
demo()

如果定时器执行过程中出现了耗时操作,多个回调函数会在耗时操作结束以后同时执行,这样可能就会带来性能上的问题。

⑶、requestAnimationFrame

首先 requestAnimationFrame 自带函数节流功能,基本可以保证在 16.6 毫秒内只执行一次(不掉帧的情况下),并且该函数的延时效果是精确的,没有其他定时器时间不准的问题,当然你也可以通过该函数来实现 setTimeout

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)



四、手写 Promise

可以看看这篇优秀的文章**《JS基础_手写Promise源码》** 也是我写的




五、Event Loop

待续…




六、JS 进阶知识点


1、手写 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 可以传入多个参数作为调用函数的参数,所以需要将参数剥离出来
  • 然后调用函数并将对象上的函数删除



⑵、apply

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 返回了一个函数,对于函数来说有两种方式调用,一种是直接调用,一种是通过 new 的方式,我们先来说直接调用的方式
  • 对于直接调用来说,这里选择了 apply 的方式实现,但是对于参数需要注意以下情况:因为 bind 可以实现类似这样的代码 f.bind(obj, 1)(2),所以我们需要将两边的参数拼接起来,于是就有了这样的实现 args.concat(…arguments)
  • 最后来说通过 new 的方式,在之前的章节中我们学习过如何判断 this,对于 new 的情况来说,不会被任何方式改变 this,所以对于这种情况我们需要忽略传入的 this



2、new


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

调用 new 的过程会发生:

  • 新生成了一个对象
  • 链接到原型
  • 绑定 this
  • 返回新对象

自己实现 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 } 。


function Foo() {}
// function 就是个语法糖
// 内部等同于 new Function()
let a = { b: 1 }
// 这个字面量内部也是使用了 new Object()


3、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



4、为什么 0.1 + 0.2 != 0.3


涉及面试题:为什么 0.1 + 0.2 != 0.3?如何解决这个问题?

因为 JS 采用 IEEE 754 双精度版本(64位),并且只要采用 IEEE 754 的语言都有该问题

IEEE 754 双精度版本(64位)将 64 位分为了三段:

  • 第一位用来表示符号
  • 接下去的 11 位用来表示指数
  • 其他的位数用来表示有效位,也就是用二进制表示 0.1 中的 10011(0011)

0.100000000000000002 === 0.1 // true

console.log()

console.log() 在输入内容的时候,二进制被转换为了十进制,十进制又被转换为了字符串,在这个转换的过程中发生了取近似值的过程,所以打印出来的其实是一个近似值

console.log(0.100000000000000002) // 0.1

其实解决的办法有很多,这里选用原生提供的方式来最简单的解决问题

parseFloat((0.1 + 0.2).toFixed(10)) === 0.3 // true


5、垃圾回收机制


涉及面试题:V8 下的垃圾回收机制是怎么样的?

V8 实现了准确式 GC,GC 算法采用了分代式垃圾回收机制。因此,V8 将内存(堆)分为新生代和老生代两部分。

⑴、新生代算法


新生代中的对象一般存活时间较短,使用 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
};

在老生代中,以下情况会先启动标记清除算法:

  • 某一个空间没有分块的时候
  • 空间中被对象超过一定限制
  • 空间不能保证新生代中的对象移动到老生代中

在这个阶段中,会遍历堆中所有的对象,然后标记活的对象,在标记完成后,销毁所有没有被标记的对象。在标记大型对内存时,可能需要几百毫秒才能完成一次标记。这就会导致一些性能上的问题。

清除对象后会造成堆内存出现碎片的情况,当碎片超过一定限制后会启动压缩算法。在压缩过程中,将活的对象像一端移动,直到所有对象都移动完成然后清理掉不需要的内存。




七、JS 思考题


思考题一:JS 分为哪两大类型?都有什么各自的特点?你该如何判断正确的类型?


思路引导:

  • 对于原始类型来说,可以指出 nullnumber 存在的一些问题。
  • 对于对象类型来说,可以从垃圾回收的角度去切入,也可以说一下对象类型存在深浅拷贝的问题。
  • 对于判断类型来说,可以去对比一下 typeofinstanceof 之间的区别,也可以指出 instanceof 判断类型也不是完全准确的。
  • 尽量去引申出这个知识点的某些坑或者与这个知识点相关联的东西。



思考题二:你理解的原型是什么?


思路引导:

原型的基础内容,然后还可以指出一些小点,比如并不是所有函数都有 prototype 属性,然后引申出原型链的概念,提出如何使用原型实现继承,继而可以引申出 ES6 中的 class 实现继承。



思考题三:bind、call 和 apply 各自有什么区别?


思路引导:

首先是三者的不同,如果实现过其中的函数,可以尝试说出思路。然后聊一聊 this 的内容,有几种规则判断 this 到底是什么,this 规则会涉及到 new,那么最后可以说下自己对于 new 的理解。



思考题四:ES6 中有使用过什么?


思路引导:

可以列举 1 - 2 个点。

  • 比如说说 class,那么 class 又可以拉回到原型的问题;
  • 可以说说 promise,那么线就被拉到了异步的内容;
  • 可以说说 proxy,那么如果你使用过 Vue 这个框架,就可以谈谈响应式原理的内容;
  • 同样也可以说说 let 这些声明变量的语法,那么就可以谈及与 var 的不同,说到提升这块的内容。



思考题五:JS 是如何运行的?


思路引导:

这其实是很大的一块内容。可以先说 JS 是单线程运行的,这里就可以说说你理解的线程和进程的区别。然后讲到执行栈,接下来的内容就是涉及 Eventloop 了,微任务和宏任务的区别,哪些是微任务,哪些又是宏任务,还可以谈及浏览器和 Node 中的 Eventloop 的不同,最后还可以聊一聊 JS 中的垃圾回收。




八、DevTools Tips

待续…




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


1、事件机制


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

①、事件触发三阶段


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

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

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

②、注册事件


常使用 addEventListener 注册事件,该函数的第三个参数可以是布尔值,也可以是对象。对于布尔值 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>

2、跨域


涉及面试题:什么是跨域?为什么浏览器要使用同源策略?你有几种方式可以解决跨域问题?了解预检请求嘛?

什么是跨域 ?

因为浏览器出于安全考虑,有同源策略。如果协议、域名或者端口有一个不同就是跨域,Ajax 请求会失败。

为什么浏览器要使用同源策略?

因为浏览器出于安全考虑,有同源策略。如果协议、域名或者端口有一个不同就是跨域,Ajax 请求会失败。

出于什么安全考虑才会引入这种机制呢?

其实主要是用来防止 CSRF 攻击的。简单点说,CSRF 攻击是利用用户的登录态发起恶意请求。

也就是说,没有同源策略的情况下,A 网站可以被任意其他来源的 Ajax 访问到内容。如果你当前 A 网站还存在登录态,那么对方就可以通过 Ajax 获得你的任何信息。当然跨域并不能完全阻止 CSRF。

请求跨域了,那么请求到底发出去没有?

请求必然是发出去了,但是浏览器拦截了响应。可能会疑问明明通过表单的方式可以发起跨域请求,为什么 Ajax 就不会。因为归根结底,跨域是为了阻止用户读取到另一个域名下的内容,Ajax 可以获取响应,浏览器认为这不安全,所以拦截了响应。但是表单并不会获取新的内容,所以可以发起跨域请求。同时也说明了跨域并不能完全阻止 CSRF,因为请求毕竟是发出去了。

解决跨域问题

  • List item

⑴、JSONP


JSONP 的原理很简单,就是利用 <script> 标签没有跨域限制的漏洞。通过 <script> 标签指向一个需要访问的地址并提供一个回调函数来接收数据当需要通讯时。

JSONP 使用简单且兼容性不错,但是只限于 get 请求。

<script src="http://domain/api?param1=a&param2=b&callback=jsonp"></script>
<script>
    function jsonp(data) {
    	console.log(data)
	}
</script>  

在开发中可能会遇到多个 JSONP 请求的回调函数名是相同的,这时候就需要自己封装一个 JSONP,以下是简单实现:

function jsonp(url, jsonpCallback, success) {
  let script = document.createElement('script')
  script.src = url
  script.async = true
  script.type = 'text/javascript'
  window[jsonpCallback] = function(data) {
    success && success(data)
  }
  document.body.appendChild(script)
}
jsonp('http://xxx', 'callback', function(value) {
  console.log(value)
})

⑵、CORS


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

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

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

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

①、简单请求

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

  • 使用下列方法之一:GETHEADPOST
  • Content-Type 的值仅限于下列三者之一:text/plainmultipart/form-dataapplication/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()

⑶、document.domain


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

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

⑷、postMessage


这种方式通常用于获取嵌入页面中的第三方页面数据。一个页面发送消息,另一个页面判断来源并接收消息

// 发送消息端
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('验证通过')
  }
})


3、储存


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

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

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


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

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

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



十、浏览器缓存机制

待续…




十一、浏览器渲染原理

待续…




十二、安全防范知识点


1、XSS


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

XSS:就是攻击者想尽一切办法将可以执行的代码注入到网页中


⑴、XSS类型

XSS 总体上分为两类:持久型和非持久型

①、持久性

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

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

在这里插入图片描述

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

②、非持久性

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

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

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

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

在这里插入图片描述

⑵、对于XSS攻击 的防御


①、转义字符

对于用户的输入应该是永远不信任的。最普遍的做法就是转义输入输出的内容,对于引号、尖括号、斜杠进行转义

function escape(str) {
  str = str.replace(/&/g, '&amp;')
  str = str.replace(/</g, '&lt;')
  str = str.replace(/>/g, '&gt;')
  str = str.replace(/"/g, '&quto;')
  str = str.replace(/'/g, '&#39;')
  str = str.replace(/`/g, '&#96;')
  str = str.replace(/\//g, '&#x2F;')
  return str
}

通过转义可以将攻击代码 变成

// -> &lt;script&gt;alert(1)&lt;&#x2F;script&gt;
escape('<script>alert(1)</script>')

但是对于显示富文本来说,显然不能通过上面的办法来转义所有字符,因为这样会把需要的格式也过滤掉。对于这种情况,通常采用白名单过滤的办法,当然也可以通过黑名单过滤,但是考虑到需要过滤的标签和标签属性实在太多,更加推荐使用白名单的方式。

const xss = require('xss')
let html = xss('<h1 id="title">XSS Demo</h1><script>alert("xss");</script>')
// -> <h1>XSS Demo</h1>&lt;script&gt;alert("xss");&lt;/script&gt;
console.log(html)

以上示例使用了 js-xss 来实现,可以看到在输出中保留了 h1 标签且过滤了 script 标签。


②、CSP

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


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

  • 设置 HTTP Header 中的 Content-Security-Policy
  • 设置 meta 标签的方式

这里以设置 HTTP Header 来举例:

  • 只允许加载本站资源
Content-Security-Policy: default-src ‘self’

  • 只允许加载 HTTPS 协议图片
Content-Security-Policy: img-src https://*

  • 允许加载任何来源框架
Content-Security-Policy: child-src 'none'


2、CSRF


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

什么是 CSRF 攻击?


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

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

<img src="http://www.domain.com/xxx?comment='attack'"/>

攻击者同样可以诱导用户进入某个页面,在页面中通过表单提交 POST 请求

如何防范 CSRF 攻击?


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

  • Get 请求不对数据进行修改

  • 不让第三方网站访问到用户 Cookie

  • 阻止第三方网站请求接口

  • 请求时附带验证信息,比如验证码或者 Token

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

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

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

3、点击劫持


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

点击劫持是一种视觉欺骗的攻击手段。攻击者将需要攻击的网站通过 iframe 嵌套的方式嵌入自己的网页中,并将 iframe 设置为透明,在页面中透出一个按钮诱导用户点击。

在这里插入图片描述

⑴、X-FRAME-OPTIONS


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

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

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

⑵、JS 防御


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

<head>
  <style id="click-jack">
    html {
      display: none !important;
    }
  </style>
</head>
<body>
  <script>
    if (self == top) {
      var style = document.getElementById('click-jack')
      document.body.removeChild(style)
    } else {
      top.location = self.location
    }
  </script>
</body>

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


4、中间人攻击


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

什么是中间人攻击?


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

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

如何防范中间人攻击?


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

待续…




十三、从 V8 中看 JS 性能优化

待续…




十四、性能优化琐碎事


1、图片优化


⑴、计算图片大小

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

但是在实际项目中,一张图片可能并不需要使用那么多颜色去显示,优化图片大小,可以有这两个方向:

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

⑵、图片加载优化

  • 不用图片。很多时候会使用到很多修饰类图片,其实这类修饰图片完全可以用 CSS 去代替。

  • 对于移动端来说,屏幕宽度就那么点,完全没有必要去加载原图浪费带宽。一般图片都用 CDN 加载,可以计算出适配屏幕的宽度,然后去请求相应裁剪好的图片。

  • 小图使用 base64 格式

  • 将多个图标文件整合到一张图片中(雪碧图)

  • 选择正确的图片格式:

    1. 对于能够显示 WebP 格式的浏览器尽量使用 WebP 格式。因为 WebP 格式具有更好的图像数据压缩算法,能带来更小的图片体积,而且拥有肉眼识别无差异的图像质量,缺点就是兼容性并不好
    2. 小图使用 PNG,其实对于大部分图标这类图片,完全可以使用 SVG 代替
    3. 照片使用 JPEG

2、DNS 预解析


DNS 解析也是需要时间的,可以通过预解析的方式来预先获得域名所对应的 IP。

<link rel="dns-prefetch" href="//yuchengkai.cn">

3、节流


考虑一个场景,滚动事件中会发起网络请求,但是我们并不希望用户在滚动过程中一直发起请求,而是隔一段时间发起一次,对于这种情况我们就可以使用节流。

理解了节流的用途,来实现下这个函数:

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

4、防抖


考虑一个场景,有一个按钮点击会触发网络请求,但是我们并不希望每次点击都发起网络请求,而是当用户点击按钮一段时间后没有再次点击的情况才去发起网络请求,对于这种情况我们就可以使用防抖。

理解了防抖的用途,来实现下这个函数:

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

5、预加载


在开发中,可能会遇到这样的情况。有些资源不需要马上用到,但是希望尽早获取,这时候就可以使用预加载。

预加载其实是声明式的 fetch ,强制浏览器请求资源,并且不会阻塞 onload 事件,可以使用以下代码开启预加载

<link rel="preload" href="http://example.com">

预加载可以一定程度上降低首屏的加载时间,因为可以将一些不影响首屏但重要的文件延后加载,唯一缺点就是兼容性不好。

6、预渲染


可以通过预渲染将下载的文件预先在后台渲染,可以使用以下代码开启预渲染

<link rel="prerender" href="http://example.com"> 

预渲染虽然可以提高页面的加载速度,但是要确保该页面大概率会被用户在之后打开,否则就是白白浪费资源去渲染。

7、懒执行


懒执行就是将某些逻辑延迟到使用时再计算。该技术可以用于首屏优化,对于某些耗时逻辑并不需要在首屏就使用的,就可以使用懒执行。懒执行需要唤醒,一般可以通过定时器或者事件的调用来唤醒。

8、懒加载


懒加载就是将不关键的资源延后加载。懒加载的原理就是只加载自定义区域(通常是可视区域,但也可以是即将进入可视区域)内需要加载的东西。对于图片来说,先设置图片标签的 src 属性为一张占位图,将真实的图片资源放入一个自定义属性中,当进入自定义区域时,就将自定义属性替换为 src 属性,这样图片就会去下载资源,实现了图片懒加载。

懒加载不仅可以用于图片,也可以使用在别的资源上。比如进入可视区域才开始播放视频等等。

9、CDN


CDN 的原理是尽可能的在各个地方分布机房缓存数据,这样即使我们的根服务器远在国外,在国内的用户也可以通过国内的机房迅速加载资源。

因此,我们可以将静态资源尽量使用 CDN 加载,由于浏览器对于单个域名有并发请求上限,可以考虑使用多个 CDN 域名。并且对于 CDN 加载静态资源需要注意 CDN 域名要与主站不同,否则每次请求都会带上主站的 Cookie,平白消耗流量。




十五、Webpack 性能优化


1、减少 Webpack 打包时间


⑴、优化 Loader

对于 Loader来说,影响打包效率首当其冲必属 Babel 了。因为 Babel 会将代码转为字符串生成 AST,然后对 AST 继续进行转变最后再生成新的代码,项目越大,转换代码越多,效率就越低。

可以优化 Loader 的文件搜索范围

module.exports = {
  module: {
    rules: [
      {
        // js 文件才使用 babel
        test: /\.js$/,
        loader: 'babel-loader',
        // 只在 src 文件夹下查找
        include: [resolve('src')],
        // 不会去查找的路径
        exclude: /node_modules/
      }
    ]
  }
}

对于 Babel 来说,肯定是希望只作用在 JS 代码上的,然后 node_modules 中使用的代码都是编译过的,所以也完全没有必要再去处理一遍。

当然这样做还不够,还可以将 Babel 编译过的文件缓存起来,下次只需要编译更改过的代码文件即可,这样可以大幅度加快打包时间

loader: 'babel-loader?cacheDirectory=true'


⑵、HappyPack

受限于 Node 是单线程运行的,所以 Webpack 在打包的过程中也是单线程的,特别是在执行 Loader 的时候,长时间编译的任务很多,这样就会导致等待的情况。

HappyPack 可以将 Loader同步执行转换为并行的,这样就能充分利用系统资源来加快打包效率了

module: {
  loaders: [
    {
      test: /\.js$/,
      include: [resolve('src')],
      exclude: /node_modules/,
      // id 后面的内容对应下面
      loader: 'happypack/loader?id=happybabel'
    }
  ]
},
plugins: [
  new HappyPack({
    id: 'happybabel',
    loaders: ['babel-loader?cacheDirectory'],
    // 开启 4 个线程
    threads: 4
  })
]


⑶、DllPlugin

DllPlugin 可以将特定的类库提前打包然后引入。这种方式可以极大的减少打包类库的次数,只有当类库更新版本才有需要重新打包,并且也实现了将公共代码抽离成单独文件的优化方案。

使用 DllPlugin:

// 单独配置在一个文件中
// webpack.dll.conf.js
const path = require('path')
const webpack = require('webpack')
module.exports = {
  entry: {
    // 想统一打包的类库
    vendor: ['react']
  },
  output: {
    path: path.join(__dirname, 'dist'),
    filename: '[name].dll.js',
    library: '[name]-[hash]'
  },
  plugins: [
    new webpack.DllPlugin({
      // name 必须和 output.library 一致
      name: '[name]-[hash]',
      // 该属性需要与 DllReferencePlugin 中一致
      context: __dirname,
      path: path.join(__dirname, 'dist', '[name]-manifest.json')
    })
  ]
}


然后需要执行这个配置文件生成依赖文件,接下来需要使用 DllReferencePlugin 将依赖文件引入项目中

// webpack.conf.js
module.exports = {
  // ...省略其他配置
  plugins: [
    new webpack.DllReferencePlugin({
      context: __dirname,
      // manifest 就是之前打包出来的 json 文件
      manifest: require('./dist/vendor-manifest.json'),
    })
  ]
}


⑷、代码压缩

在 Webpack3 中,一般使用 UglifyJS 来压缩代码,但是这个是单线程运行的,可以使用 webpack-parallel-uglify-plugin 来并行运行 UglifyJS,从而提高效率。

在 Webpack4 中,就不需要以上这些操作了,只需要将 mode 设置为 production 就可以默认开启以上功能。代码压缩也是必做的性能优化方案,当然不止可以压缩 JS 代码,还可以压缩 HTMLCSS 代码,并且在压缩 JS 代码的过程中,还可以通过配置实现比如删除 console.log 这类代码的功能。



⑸、一些小的优化点

还可以通过一些小的优化点来加快打包速度:

  • **resolve.extensions:**用来表明文件后缀列表,默认查找顺序是 [’.js’, ‘.json’],如果你的导入文件没有添加后缀就会按照这个顺序查找文件。应该尽可能减少后缀列表长度,然后将出现频率高的后缀排在前面
  • **esolve.alias:**可以通过别名的方式来映射一个路径,能让 Webpack 更快找到路径
  • **module.noParse:**如果确定一个文件下没有其他依赖,就可以使用该属性让 Webpack 不扫描该文件,这种方式对于大型的类库很有帮助



2、减少 Webpack 打包后的文件体积


⑴、按需加载

在开发 SPA 项目的时候,项目中都会存在十几甚至更多的路由页面。如果将这些页面全部打包进一个 JS 文件的话,虽然将多个请求合并了,但是同样也加载了很多并不需要的代码,耗费了更长的时间。那么为了首页能更快地呈现给用户,肯定是希望首页能加载的文件体积越小越好,这时候就可以使用按需加载,将每个路由页面单独打包为一个文件。当然不仅仅路由可以按需加载,对于 loadash 这种大型类库同样可以使用这个功能。

按需加载的代码实现这里就不详细展开了,因为鉴于用的框架不同,实现起来都是不一样的。虽然他们的用法可能不同,但是底层的机制都是一样的。都是当使用的时候再去下载对应文件,返回一个 Promise,当 Promise 成功以后去执行回调。

⑵、Scope Hoisting

Scope Hoisting 会分析出模块之间的依赖关系,尽可能的把打包出来的模块合并到一个函数中去。

打包两个文件:

// test.js
export const a = 1
// index.js
import { a } from './test.js'

打包出来的代码会类似这样:

[
  /* 0 */
  function (module, exports, require) {
    //...
  },
  /* 1 */
  function (module, exports, require) {
    //...
  }
]

如果使用 Scope Hoisting 的话,代码就会尽可能的合并到一个函数中去,也就变成了这样的类似代码:

[
  /* 0 */
  function (module, exports, require) {
    //...
  }
]

这样的打包方式生成的代码明显比之前的少多了。如果在 Webpack4 中希望开启这个功能,只需要启用 optimization.concatenateModules 就可以了:

module.exports = {
  optimization: {
    concatenateModules: true
  }
}

⑶、Tree Shaking

Tree Shaking 可以实现删除项目中未被引用的代码

// test.js
export const a = 1
export const b = 2
// index.js
import { a } from './test.js'

对于以上情况,test 文件中的变量 b 如果没有在项目中使用到的话,就不会被打包到文件中。

如果使用 Webpack 4 的话,开启生产环境就会自动启动这个优化功能。




十六、实现小型打包工具

待续…




十七、React & Vue


1、MVVM

涉及面试题:什么是 MVVM?比之 MVC 有什么区别?

首先先申明一点,不管是 React 还是 Vue,它们都不是 MVVM 框架,只是有借鉴 MVVM 的思路。


⑴、数据渲染流程

  • View :用户看到的视图
  • Model :就是本地数据和数据库中的数据

基本上,产品就是通过接口从数据库中读取数据,然后将数据经过处理展现到用户看到的视图上。还可以将用户的输入通过接口写入到数据库中。


⑵、MVC

在这里插入图片描述

传统的 MVC 架构通常是使用控制器更新模型,视图从模型中获取数据去渲染。当用户有输入时,会通过控制器去更新模型,并且通知视图进行更新。


但是 MVC 有一个巨大的缺陷就是控制器承担的责任太大了,随着项目愈加复杂,控制器中的代码会越来越臃肿,导致出现不利于维护的情况。


⑵、MVVM

在这里插入图片描述

在 MVVM 架构中,引入了 ViewModel 的概念。ViewModel 只关心数据和业务的处理,不关心 View 如何处理数据,在这种情况下,View 和 Model 都可以独立出来,任何一方改变了也不一定需要改变另一方,并且可以将一些可复用的逻辑放在一个 ViewModel 中,让多个 View 复用这个 ViewModel。


在这里插入图片描述

除了以上三个部分,其实在 MVVM 中还引入了一个隐式的 Binder 层,实现了 View 和 ViewModel 的绑定。

对于 MVVM 来说,其实最重要的并不是通过双向绑定或者其他的方式将 View 与 ViewModel 绑定起来,而是通过 ViewModel 将视图中的状态和用户的行为分离出一个抽象,这才是 MVVM 的精髓



2、Virtual DOM

涉及面试题:什么是 Virtual DOM?为什么 Virtual DOM 比原生 DOM 快?

首先先申明一点,不管是 React 还是 Vue,它们都不是 MVVM 框架,只是有借鉴 MVVM 的思路。


⑴、DOM & JS

大家都知道操作 DOM 是很慢 ( 浏览器渲染原理 ) 的,那么相较于 DOM 来说,操作 JS 对象会快很多,并且我们也可以通过 JS 来模拟 DOM

const ul = {
  tag: 'ul',
  props: {
    class: 'list'
  },
  children: {
    tag: 'li',
    children: '1'
  }
}

对应的DOM:

<ul class='list'>
  <li>1</li>
</ul>

⑵、什么是 Virtual DOM?

那么既然 DOM 可以通过 JS 对象来模拟,反之也可以通过 JS 对象来渲染出对应的 DOM。实现如何判断新旧两个 JS 对象的最小差异并且实现局部更新 DOM

首先 DOM 是一个多叉树的结构,如果需要完整的对比两颗树的差异,那么需要的时间复杂度会是 O(n ^ 3),这个复杂度肯定是不能接受的。于是 React 团队优化了算法,实现了 O(n) 的复杂度来对比差异。 实现 O(n) 复杂度的关键就是只对比同层的节点,而不是跨层对比,这也是考虑到在实际业务中很少会去跨层的移动 DOM 元素。 所以判断差异的算法就分为了两步:

  • 首先从上至下,从左往右遍历对象,也就是树的深度遍历,这一步中会给每个节点添加索引,便于最后渲染差异
  • 一旦节点有子元素,就去判断子元素是否有不同

  1. 在第一步算法中需要判断新旧节点的 tagName 是否相同,如果不相同的话就代表节点被替换了。如果没有更改 tagName 的话,就需要判断是否有子元素,有的话就进行第二步算法。
  2. 在第二步算法中,需要判断原本的列表中是否有节点被移除,在新的列表中需要判断是否有新的节点加入,还需要判断节点是否有移动。

举个例子来说,假设页面中只有一个列表,对列表中的元素进行了变更:

// 假设这里模拟一个 ul,其中包含了 5 个 li
[1, 2, 3, 4, 5]
// 这里替换上面的 li
[1, 2, 5, 4]

从上述例子中,一眼就可以看出先前的 ul 中的第三个 li 被移除了,四五替换了位置。

那么在实际的算法中,如何去识别改动的是哪个节点呢?这就引入了 key 这个属性。这个属性是用来给每一个节点打标志的,用于判断是否是同一个节点。当然在判断以上差异的过程中,我们还需要判断节点的属性是否有变化等等。

当我们判断出以上的差异后,就可以把这些差异记录下来。当对比完两棵树以后,就可以通过差异去局部更新 DOM,实现性能的最优化。

⑶、为什么 Virtual DOM 比原生 DOM 快?


首先这个问题得分场景来说,如果无脑替换所有的 DOM 这种场景来说,Virtual DOM 的局部更新肯定要来的快。但是如果你可以手动去局部替换 DOM,那么 Virtual DOM 必然没有你直接操作 DOM 来的快,毕竟还有一层 diff 算法的损耗。

当然了 Virtual DOM 提高性能是其中一个优势,其实最大的优势还是在于:

  • Virtual DOM 作为一个兼容层,让我们还能对接非 Web 端的系统,实现跨端开发。
  • 同样的,通过 Virtual DOM 我们可以渲染到其他的平台,比如实现 SSR、同构渲染等等。
  • 实现组件的高度抽象化


3、路由原理

涉及面试题:前端路由原理?两种实现方式有什么区别?


⑴、前端路由原理?

前端路由实现起来其实很简单,本质就是监听 URL 的变化,然后匹配路由规则,显示相应的页面,并且无须刷新页面。

⑵、Hash 模式

www.test.com/#/ 就是 Hash URL,当 # 后面的哈希值发生变化时,可以通过 hashchange 事件来监听到 URL 的变化,从而进行跳转页面,并且无论哈希值如何变化,服务端接收到的 URL 请求永远是 www.test.com

window.addEventListener('hashchange', () => {
  // ... 具体逻辑
})

Hash 模式相对来说更简单,并且兼容性也更好。

⑶、History 模式

History 模式是 HTML5 新推出的功能,主要使用 history.pushStatehistory.replaceState 改变 URL

通过 History 模式改变 URL 同样不会引起页面的刷新,只会更新浏览器的历史记录。

// 新增历史记录
history.pushState(stateObject, title, URL)
// 替换当前历史记录
history.replaceState(stateObject, title, URL)

当用户做出浏览器动作时,比如点击后退按钮时会触发 popState 事件

window.addEventListener('popstate', e => {
  // e.state 就是 pushState(stateObject) 中的 stateObject
  console.log(e.state)
})

⑷、两种模式对比

  • Hash 模式只可以更改 # 后面的内容,History 模式可以通过 API 设置任意的同源 URL
  • Hash 模式无需后端配置,并且兼容性好。History 模式在用户手动输入地址或者刷新页面的时候会发起 URL 请求,后端需要配置 index.html 页面用于匹配不到静态资源的时候
  • History 模式可以通过 API 添加任意类型的数据到历史记录中,Hash 模式只能更改哈希值,也就是字符串


4、Vue 和 React 之间的区别


Vue 的表单可以使用 v-model 支持双向绑定,相比于 React 来说开发上更加方便,当然了 v-model 其实就是个语法糖,本质上和 React 写表单的方式没什么区别。

改变数据方式不同,Vue 修改状态相比来说要简单许多,React 需要使用 setState 来改变状态,并且使用这个 API 也有一些坑点。并且 Vue 的底层使用了依赖追踪,页面更新渲染已经是最优的了,但是 React 还是需要用户手动去优化这方面的问题。

React 需要使用 JSX,有一定的上手成本,并且需要一整套的工具链支持,但是完全可以通过 JS 来控制页面,更加的灵活。
Vue 使用了模板语法,相比于 JSX 来说没有那么灵活,但是完全可以脱离工具链,通过直接编写 render 函数就能在浏览器中运行。

在上手成本上来说,Vue 一开始的定位就是尽可能的降低前端开发的门槛,然而 React 更多的是去改变用户去接受它的概念和思想,相较于 Vue 来说上手成本略高。




十八、Vue 常考基础知识点


1、生命周期钩子函数



2、组件通信


⑴、父子组件通信


⑵、兄弟组件通信


⑶、跨多层级组件通信


⑷、任意组件



3、extend 能做什么

这个 API 很少用到,作用是扩展组件生成一个构造器,通常会与 $mount 一起使用。

// 创建组件构造器
let Component = Vue.extend({
  template: '<div>test</div>'
})
// 挂载到 #app 上
new Component().$mount('#app')
// 除了上面的方式,还可以用来扩展已有的组件
let SuperComponent = Vue.extend(Component)
new SuperComponent({
    created() {
        console.log(1)
    }
})
new SuperComponent().$mount('#app')


4、mixin 和 mixins 区别

mixin 用于全局混入,会影响到每个组件实例,通常插件都是这样做初始化的。

Vue.mixin({
    beforeCreate() {
        // ...逻辑
        // 这种方式会影响到每个组件的 beforeCreate 钩子函数
    }
})

虽然文档不建议在应用中直接使用 mixin,但是如果不滥用的话也是很有帮助的,比如可以全局混入封装好的 ajax 或者一些工具函数等等。

mixins 应该是最常使用的扩展组件的方式。如果多个组件中有相同的业务逻辑,就可以将这些逻辑剥离出来,通过 mixins 混入代码,比如上拉下拉加载数据这种逻辑等等。

另外需要注意的是 mixins 混入的钩子函数会先于组件内的钩子函数执行,并且在遇到同名选项的时候也会有选择性的进行合并。



5、computed 和 watch 区别


computed 是计算属性,依赖其他属性计算值,并且 computed 的值有缓存,只有当计算值变化才会返回内容。

watch 监听到值的变化就会执行回调,在回调中可以进行一些逻辑操作。

所以一般来说需要依赖别的属性来动态获得值的时候可以使用 computed,对于监听到值的变化需要做一些复杂业务逻辑的情况可以使用 watch。另外 computed 和 watch 还都支持对象的写法。

vm.$watch('obj', {
    // 深度遍历
    deep: true,
    // 立即触发
    immediate: true,
    // 执行的函数
    handler: function(val, oldVal) {}
})
var vm = new Vue({
  data: { a: 1 },
  computed: {
    aPlus: {
      // this.aPlus 时触发
      get: function () {
        return this.a + 1
      },
      // this.aPlus = 1 时触发
      set: function (v) {
        this.a = v - 1
      }
    }
  }
})


6、keep-alive 组件有什么作用


如果你需要在组件切换的时候,保存一些组件的状态防止多次渲染,就可以使用 keep-alive 组件包裹需要保存的组件。

对于 keep-alive 组件来说,它拥有两个独有的生命周期钩子函数,分别为 activateddeactivated 。用 keep-alive 包裹的组件在切换时不会进行销毁,而是缓存到内存中并执行 deactivated 钩子函数,命中缓存渲染后会执行 actived 钩子函数。



7、v-show 与 v-if 区别


v-show 只是在 display: nonedisplay: block 之间切换。无论初始条件是什么都会被渲染出来,后面只需要切换 CSS,DOM 还是一直保留着的。所以总的来说 v-show 在初始渲染时有更高的开销,但是切换开销很小,更适合于频繁切换的场景

v-if 的话就得说到 Vue 底层的编译了。当属性初始为 false 时,组件就不会被渲染,直到条件为 true,并且切换条件时会触发销毁/挂载组件,所以总的来说在切换时开销更高,更适合不经常切换的场景

并且基于 v-if 的这种惰性渲染机制,可以在必要的时候才去渲染组件,减少整个页面的初始渲染开销。



8、组件中 data 什么时候可以使用对象


组件复用时所有组件实例都会共享 data,如果 data对象的话,就会造成一个组件修改 data 以后会影响到其他所有组件,所以需要将 data 写成函数,每次用到就调用一次函数获得新的数据

当我们使用 new Vue() 的方式的时候,无论我们将 data 设置为对象还是函数都是可以的,因为 new Vue()的方式是生成一个根组件,该组件不会复用,也就不存在共享 data 的情况了。




十九、Vue 常考进阶知识点


1、响应式原理



2、编译过程



3、NextTick 原理分析




二十、React 常考基础知识点


1、生命周期



2、setState



3、性能优化



4、组件通信


⑴、父子组件通信


⑵、兄弟组件通信


⑶、跨多层级组件通信


⑷、任意组件




二十一、React 常考进阶知识点


1、HOC 是什么?相比 mixins 有什么优点?



2、事件机制




二十二、监控


1、页面埋点


页面埋点应该是最常写的监控了,一般起码会监控以下几个数据:

  • PV / UV
  • 停留时长
  • 流量来源
  • 用户交互

对于这几类统计,一般的实现思路大致可以分为两种,分别为手写埋点和无埋点的方式。

手写埋点也是最常用的方式,可以自主选择需要监控的数据然后在相应的地方写入代码。这种方式的灵活性很大,但是唯一的缺点就是工作量较大,每个需要监控的地方都得插入代码。

无埋点的方式基本不需要开发者手写埋点了,而是统计所有的事件并且定时上报。这种方式虽然没有前一种方式繁琐了,但是因为统计的是所有事件,所以还需要后期过滤出需要的数据。



2、性能监控


性能监控可以很好的帮助开发者了解在各种真实环境下,页面的性能情况如何。

对于性能监控来说,可以直接使用浏览器自带的 Performance API 来实现这个功能。

对于性能监控来说,其实只需要调用 performance.getEntriesByType('navigation') 这行代码就行了。一行代码就可以获得页面中各种详细的性能相关信息。

在这里插入图片描述

可以发现这行代码返回了一个数组,内部包含了相当多的信息,从数据开始在网络中传输到页面加载完成都提供了相应的数据。

在这里插入图片描述



3、异常监控


对于异常监控来说,以下两种监控是必不可少的,分别是代码报错以及接口异常上报

对于代码运行错误,通常的办法是使用 window.onerror 拦截报错。该方法能拦截到大部分的详细报错信息,但是也有例外

  • 对于跨域的代码运行错误会显示 Script error. 对于这种情况需要给 script标签添加 crossorigin 属性
  • 对于某些浏览器可能不会显示调用栈信息,这种情况可以通过 arguments.callee.caller 来做栈递归

对于异步代码来说,可以使用 catch 的方式捕获错误。比如 Promise 可以直接使用 catch 函数,async await 可以使用 try catch

但是要注意线上运行的代码都是压缩过的,需要在打包时生成 sourceMap 文件便于 debug

对于捕获的错误需要上传给服务器,通常可以通过 img 标签的 src 发起一个请求。

另外接口异常就相对来说简单了,可以列举出出错的状态码。一旦出现此类的状态码就可以立即上报出错。接口异常上报可以让开发人员迅速知道有哪些接口出现了大面积的报错,以便迅速修复问题。




二十三、UDP


首先 UDP 协议是面向无连接的,也就是说不需要在正式传递数据之前先连接起双方。然后 UDP 协议只是数据报文的搬运工,不保证有序且不丢失的传递到对端,并且UDP 协议也没有任何控制流量的算法,总的来说 UDP 相较于 TCP 更加的轻便。

1、面向无连接


首先 UDP 是不需要和 TCP 一样在发送数据前进行三次握手建立连接的,想发数据就可以开始发送了。并且也只是数据报文的搬运工,不会对数据报文进行任何拆分和拼接操作。

具体来说就是:

  • 在发送端,应用层将数据传递给传输层的 UDP 协议,UDP 只会给数据增加一个 UDP 头标识下是 UDP 协议,然后就传递给网络层了
  • 在接收端,网络层将数据传递给传输层,UDP 只去除 IP 报文头就传递给应用层,不会任何拼接操作



2、不可靠性


首先不可靠性体现在无连接上,通信都不需要建立连接,想发就发,这样的情况肯定不可靠。

并且收到什么数据就传递什么数据,并且也不会备份数据,发送数据也不会关心对方是否已经正确接收到数据了。

再者网络环境时好时坏,但是 UDP 因为没有拥塞控制,一直会以恒定的速度发送数据。即使网络条件不好,也不会对发送速率进行调整。这样实现的弊端就是在网络条件不好的情况下可能会导致丢包,但是优点也很明显,在某些实时性要求高的场景(比如电话会议)就需要使用 UDP 而不是 TCP。



3、高效


虽然 UDP 协议不是那么的可靠,但是正是因为它不是那么的可靠,所以也就没有 TCP 那么复杂了,需要保证数据不丢失且有序到达。

因此 UDP 的头部开销小,只有八字节,相比 TCP 的至少二十字节要少得多,在传输数据报文时是很高效的。

在这里插入图片描述

UDP 头部包含了以下几个数据:

  • 两个十六位的端口号,分别为源端口(可选字段)和目标端口
  • 整个数据报文的长度
  • 整个数据报文的检验和(IPv4 可选 字段),该字段用于发现头部信息和数据中的错误



4、传输方式


UDP 不止支持一对一的传输方式,同样支持一对多,多对多,多对一的方式,也就是说 UDP 提供了单播,多播,广播的功能。



5、应用场景


⑴、直播


想必大家都看过直播吧,大家可以考虑下如果直播使用了基于 TCP 的协议会发生什么事情?

TCP 会严格控制传输的正确性,一旦有某一个数据对端没有收到,就会停止下来直到对端收到这个数据。这种问题在网络条件不错的情况下可能并不会发生什么事情,但是在网络情况差的时候就会变成画面卡住,然后再继续播放下一帧的情况。

但是对于直播来说,用户肯定关注的是最新的画面,而不是因为网络条件差而丢失的老旧画面,所以 TCP 在这种情况下无用武之地,只会降低用户体验。

⑵、网游


对于实时性要求很高的游戏来说,当然是选择 UDP。

首先对于游戏来说,用户体量是相当大的,如果使用 TCP 连接的话,就可能会出现服务器不够用的情况,因为每台服务器可供支撑的 TCP 连接数量是有限制的

再者,因为 TCP 会严格控制传输的正确性,如果因为用户网络条件不好就造成页面卡顿然后再传输旧的游戏画面是肯定不能接受的,毕竟对于这类实时性要求很高的游戏来说,最新的游戏画面才是最需要的,而不是老旧的画面,否则角色都不知道死多少次了。




二十四、TCP


TCP 基本是和 UDP 反着来,建立连接断开连接都需要先需要进行握手。在传输数据的过程中,通过各种算法保证数据的可靠性,当然带来的问题就是相比 UDP 来说不那么的高效。

1、头部


可以发现 TCP 头部比 UDP 头部复杂的多

在这里插入图片描述


对于 TCP 头部来说,以下几个字段是很重要的:

  • **Sequence number,**这个序号保证了 TCP 传输的报文都是有序的,对端可以通过序号顺序的拼接报文

  • **Acknowledgement Number,**这个序号表示数据接收端期望接收的下一个字节的编号是多少,同时也表示上一个序号的数据已经收到

  • **Window Size,**窗口大小,表示还能接收多少字节的数据,用于流量控制
    标识符

    1. URG=1:该字段为一表示本数据报的数据部分包含紧急信息,是一个高优先级数据报文,此时紧急指针有效。紧急数据一定位于当前数据包数据部分的最前面,紧急指针标明了紧急数据的尾部。
    2. ACK=1:该字段为一表示确认号字段有效。此外,TCP 还规定在连接建立后传送的所有报文段都必须把 ACK 置为一。
    3. PSH=1:该字段为一表示接收端应该立即将数据 push 给应用层,而不是等到缓冲区满后再提交。
    4. RST=1:该字段为一表示当前 TCP 连接出现严重问题,可能需要重新建立 TCP 连接,也可以用于拒绝非法的报文段和拒绝连接请求。
    5. SYN=1:当SYN=1,ACK=0时,表示当前报文段是一个连接请求报文。当SYN=1,ACK=1时,表示当前报文段是一个同意建立连接的应答报文。
    6. FIN=1:该字段为一表示此报文段是一个释放连接的请求报文。



2、状态机


TCP 的状态机是很复杂的,并且与建立断开连接时的握手息息相关,接下来就来详细描述下两种握手。

在这里插入图片描述

RTT:该指标表示发送端发送数据到接收到对端数据所需的往返时间。

⑴、建立连接三次握手


首先假设主动发起请求的一端称为客户端,被动连接的一端称为服务端。不管是客户端还是服务端,TCP 连接建立完后都能发送和接收数据,所以 TCP 是一个全双工的协议。起初,两端都为 CLOSED 状态。在通信开始前,双方都会创建 TCB。 服务器创建完 TCB 后便进入 LISTEN 状态,此时开始等待客户端发送数据。

第一次握手

**客户端向服务端发送连接请求报文段。**该报文段中包含自身的数据通讯初始序号。请求发送后,客户端便进入 SYN-SENT 状态。

第二次握手

服务端收到连接请求报文段后,如果同意连接,则会发送一个应答,该应答中也会包含自身的数据通讯初始序号,发送完成后便进入 SYN-RECEIVED 状态。

第三次握手

当客户端收到连接同意的应答后,还要向服务端发送一个确认报文。客户端发完这个报文段后便进入 ESTABLISHED 状态,服务端收到这个应答后也进入 ESTABLISHED 状态,此时连接建立成功。

第三次握手中可以包含数据,通过快速打开(TFO)技术就可以实现这一功能。其实只要涉及到握手的协议,都可以使用类似 TFO 的方式,客户端和服务端存储相同的 cookie,下次握手时发出 cookie 达到减少 RTT 的目的。



常考面试题:为什么 TCP 建立连接需要三次握手,明明两次就可以建立起连接


因为这是为了防止出现失效的连接请求报文段被服务端接收的情况,从而产生错误。

可以想象如下场景:客户端发送了一个连接请求 A,但是因为网络原因造成了超时,这时 TCP 会启动超时重传的机制再次发送一个连接请求 B。此时请求顺利到达服务端,服务端应答完就建立了请求,然后接收数据后释放了连接。

假设这时候连接请求 A 在两端关闭后终于抵达了服务端,那么此时服务端会认为客户端又需要建立 TCP 连接,从而应答了该请求并进入 ESTABLISHED 状态。但是客户端其实是 CLOSED 的状态,那么就会导致服务端一直等待,造成资源的浪费。

在建立连接中,任意一端掉线,TCP 都会重发 SYN 包,一般会重试五次,在建立连接中可能会遇到 SYN Flood 攻击。遇到这种情况你可以选择调低重试次数或者干脆在不能处理的情况下拒绝请求。


⑵、断开连接四次握手


![在这里插入图片描述](https://img-blog.csdnimg.cn/20210717173705660.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80NTEzNzU2NQ==,size_16,color_FFFFFF,t_70)

TCP 是全双工的,在断开连接时两端都需要发送 FIN 和 ACK。

第一次握手

若客户端 A 认为数据发送完成,则它需要向服务端 B 发送连接释放请求

第二次握手

**B 收到连接释放请求后,会告诉应用层要释放 TCP 链接。**然后会发送 ACK 包,并进入 CLOSE_WAIT 状态,此时表明 A 到 B 的连接已经释放,不再接收 A 发的数据了。但是因为 TCP 连接是双向的,所以 B 仍旧可以发送数据给 A

第三次握手

B 如果此时还有没发完的数据会继续发送,完毕后会向 A 发送连接释放请求,然后 B 便进入 LAST-ACK 状态。

通过延迟确认的技术(通常有时间限制,否则对方会误认为需要重传),可以将第二次和第三次握手合并,延迟 ACK 包的发送。


第四次握手

A 收到释放请求后,向 B 发送确认应答,此时 A 进入 TIME-WAIT 状态。该状态会持续 2MSL(最大段生存期,指报文段在网络中生存的时间,超时会被抛弃) 时间,若该时间段内没有 B 的重发请求的话,就进入 CLOSED 状态。当 B 收到确认应答后,也便进入 CLOSED 状态。


为什么 A 要进入 TIME-WAIT 状态,等待 2MSL 时间后才进入 CLOSED 状态?

为了保证 B 能收到 A 的确认应答。若 A 发完确认应答后直接进入 CLOSED 状态,如果确认应答因为网络问题一直没有到达,那么会造成 B 不能正常关闭。



3、ARQ 协议


ARQ 协议也就是超时重传机制。通过确认和超时机制保证了数据的正确送达,ARQ 协议包含停止等待 ARQ 和连续 ARQ 两种协议

⑴、停止等待 ARQ


①、正常传输过程

只要 A 向 B 发送一段报文,都要停止发送并启动一个定时器,等待对端回应,在定时器时间内接收到对端应答就取消定时器并发送下一段报文。


②、报文丢失或出错

报文传输的过程中可能会出现丢包。这时候超过定时器设定的时间就会再次发送丢失的数据直到对端响应,所以需要每次都备份发送的数据

即使报文正常的传输到对端,也可能出现在传输过程中报文出错的问题。这时候对端会抛弃该报文并等待 A 端重传

一般定时器设定的时间都会大于一个 RTT 的平均时间。


③、ACK 超时或丢失

对端传输的应答也可能出现丢失或超时的情况。那么超过定时器时间 A 端照样会重传报文。这时候 B 端收到相同序号的报文会丢弃该报文并重传应答,直到 A 端发送下一个序号的报文。

在超时的情况下也可能出现应答很迟到达,这时 A 端会判断该序号是否已经接收过,如果接收过只需要丢弃应答即可。

假设在良好的网络环境中,每次发送数据都需要等待片刻肯定是不能接受的。那么既然不能接受这个不那么高效的协议,就来看看相对高效的协议吧。

⑵、连续 ARQ


在连续 ARQ 中,发送端拥有一个发送窗口,可以在没有收到应答的情况下持续发送窗口内的数据,这样相比停止等待 ARQ 协议来说减少了等待时间,提高了效率。

累计确认

连续 ARQ 中,接收端会持续不断收到报文。如果和停止等待 ARQ 中接收一个报文就发送一个应答一样,就太浪费资源了。通过累计确认,可以在收到多个报文以后统一回复一个应答报文。报文中的 ACK 标志位可以用来告诉发送端这个序号之前的数据已经全部接收到了,下次请发送这个序号后的数据。

但是累计确认也有一个弊端。在连续接收报文时,可能会遇到接收到序号 5 的报文后,并未接收到序号 6 的报文,然而序号 7 以后的报文已经接收。遇到这种情况时,ACK 只能回复 6,这样就会造成发送端重复发送数据的情况。



4、滑动窗口


在 TCP 中,两端其实都维护着窗口:分别为发送端窗口和接收端窗口

发送端窗口包含已发送但未收到应答的数据和可以发送但是未发送的数据。

在这里插入图片描述
发送端窗口是由接收窗口剩余大小决定的。接收方会把当前接收窗口的剩余大小写入应答报文,发送端收到应答后根据该值和当前网络拥塞情况设置发送窗口的大小,所以发送窗口的大小是不断变化的。

当发送端接收到应答报文后,会随之将窗口进行滑动

在这里插入图片描述
滑动窗口是一个很重要的概念,它帮助 TCP 实现了流量控制的功能。接收方通过报文告知发送方还可以发送多少数据,从而保证接收方能够来得及接收数据,防止出现接收方带宽已满,但是发送方还一直发送数据的情况。

Zero 窗口

在发送报文的过程中,可能会遇到对端出现零窗口的情况。在该情况下,发送端会停止发送数据,并启动 persistent timer 。该定时器会定时发送请求给对端,让对端告知窗口大小。在重试次数超过一定次数后,可能会中断 TCP 链接。



5、拥塞处理


拥塞处理和流量控制不同,前者是作用于网络,防止过多的数据拥塞网络,避免出现网络负载过大的情况。而后者是作用于接收方,保证接收方来得及接受数据

拥塞处理包括了四个算法,分别为:慢开始,拥塞避免,快速重传,快速恢复

⑴、慢开始算法


慢开始算法,顾名思义,就是在传输开始时将发送窗口慢慢指数级扩大,从而避免一开始就传输大量数据导致网络拥塞。想必大家都下载过资源,每当开始下载的时候都会发现下载速度是慢慢提升的,而不是一蹴而就直接拉满带宽。

慢开始算法步骤具体如下:

  • 连接初始设置拥塞窗口(Congestion Window) 为 1 MSS(一个分段的最大数据量)
  • 每过一个 RTT 就将窗口大小乘二
  • 指数级增长肯定不能没有限制的,所以有一个阈值限制,当窗口大小大于阈值时就会启动拥塞避免算法。

⑵、拥塞避免算法


拥塞避免算法相比简单点,每过一个 RTT 窗口大小只加一,这样能够避免指数级增长导致网络拥塞,慢慢将大小调整到最佳值。

在传输过程中可能定时器超时的情况,这时候 TCP 会认为网络拥塞了,会马上进行以下步骤:

  • 将阈值设为当前拥塞窗口的一半
  • 将拥塞窗口设为 1 MSS
  • 启动拥塞避免算法

⑶、快速重传


快速重传一般和快恢复一起出现。一旦接收端收到的报文出现失序的情况,接收端只会回复最后一个顺序正确的报文序号

如果发送端收到三个重复的 ACK,无需等待定时器超时而是直接启动快速重传算法。具体算法分为两种:

①、TCP Taho 实现如下

  • 将阈值设为当前拥塞窗口的一半
  • 将拥塞窗口设为 1 MSS
  • 启动拥塞避免算法

②、TCP Reno 实现如下

  • 拥塞窗口减半
  • 将阈值设为当前拥塞窗口
  • 进入快恢复阶段(重发对端需要的包,一旦收到一个新的 ACK 答复就退出该阶段),这种方式在丢失多个包的情况下就不那么好了
  • 使用拥塞避免算法

⑷、TCP New Ren 改进后的快恢复


TCP New Reno 算法改进了之前 TCP Reno 算法的缺陷。在之前,快恢复中只要收到一个新的 ACK 包,就会退出快恢复。

在 TCP New Reno 中,TCP 发送方先记下三个重复 ACK 的分段的最大序号。

假如我有一个分段数据是 1 ~ 10 这十个序号的报文,其中丢失了序号为 3 和 7 的报文,那么该分段的最大序号就是 10。发送端只会收到 ACK 序号为 3 的应答。这时候重发序号为 3 的报文,接收方顺利接收的话就会发送 ACK 序号为 7 的应答。这时候 TCP 知道对端是有多个包未收到,会继续发送序号为 7 的报文,接收方顺利接收并会发送 ACK 序号为 11 的应答,这时发送端认为这个分段接收端已经顺利接收,接下来会退出快恢复阶段。




二十五、HTTP 及 TLS


HTTP 经常考到的内容包括:请求方法、首部的作用以及状态码的含义
TLS 中经常考到的内容包括:两种加密方式以及握手的流程


1、HTTP 请求中的内容


HTTP 请求由三部分构成,分别为:请求体、首部、实体

请求行大概长这样 GET /images/logo.gif HTTP/1.1,基本由请求方法、URL、协议版本组成

常考面试题:Post 和 Get 的区别?

首先先引入副作用和幂等的概念。

副作用对服务器上的资源做改变,搜索是无副作用的,注册是副作用的。

幂等发送 M 和 N 次请求(两者不相同且都大于 1),服务器上资源的状态一致,比如注册 10 个和 11 个帐号是不幂等的,对文章进行更改 10 次和 11 次是幂等的。因为前者是多了一个账号(资源),后者只是更新同一个资源。

在规范的应用场景上说,Get 多用于无副作用,幂等的场景,例如搜索关键字Post 多用于副作用,不幂等的场景,例如注册

在技术上说:

  • Get 请求能缓存,Post 不能
  • Post 相对 Get 安全一点点,因为Get 请求都包含在 URL 里(当然你想写到 body 里也是可以的),且会被浏览器保存历史纪录。Post 不会,但是在抓包的情况下都是一样的。
  • URL有长度限制,会影响 Get 请求,但是这个长度限制是浏览器规定的,不是 RFC 规定的
  • Post 支持更多的编码类型且不对数据类型限制

2、首部

首部分为请求首部和响应首部,并且部分首部两种通用,下面是部分的常用首部。


通用首部

  • Cache-Control:控制缓存的行为

请求首部

  • Cache-Control:控制缓存的行为

响应首部

  • Cache-Control:控制缓存的行为

实体首部

  • Cache-Control:控制缓存的行为

3、常见状态码


状态码表示了响应的一个状态,可以让我们清晰的了解到这一次请求是成功还是失败,如果失败的话,是什么原因导致的,状态码也是用于传达语义的。


2XX 成功:

  • 200 OK,表示从客户端发来的请求在服务器端被正确处理
  • 204 No content,表示请求成功,但响应报文不含实体的主体部分
  • 205 Reset Content,表示请求成功,但响应报文不含实体的主体部分,但是与 204 响应不同在于要求请求方重置内容
  • 206 Partial Content,进行范围请求

3XX 重定向:

  • 301 moved permanently,永久性重定向,表示资源已被分配了新的 URL
  • 302 found,临时性重定向,表示资源临时被分配了新的 URL
  • 303 see other,表示资源存在着另一个 URL,应使用 GET 方法获取资源
  • 304 not modified,表示服务器允许访问资源,但因发生请求未满足条件的情况
  • 307 temporary redirect,临时重定向,和302含义类似,但是期望客户端保持请求方法不变向新的地址发出请求

4XX 客户端错误:

  • 400 bad request,请求报文存在语法错误
  • 401 unauthorized,表示发送的请求需要有通过 HTTP 认证的认证信息
  • 403 forbidden,表示对请求资源的访问被服务器拒绝
  • 404 not found,表示在服务器上没有找到请求的资源

5XX 服务器错误:

  • 500 internal sever error,表示服务器端在执行请求时发生了错误
  • 501 Not Implemented,表示服务器不支持当前请求所需要的某个功能
  • 503 service unavailable,表明服务器暂时处于超负载或正在停机维护,无法处理请求



二十六、TLS


HTTPS 还是通过了 HTTP 来传输信息,但是信息通过 TLS 协议进行了加密。

TLS 协议位于传输层之上,应用层之下。首次进行 TLS 协议传输需要两个 RTT ,接下来可以通过 Session Resumption 减少到一个 RTT

TLS 中使用了两种加密技术,分别为:对称加密和非对称加密

1、对称加密:


对称加密就是两边拥有相同的秘钥,两边都知道如何将密文加密解密

这种加密方式固然很好,但是问题就在于如何让双方知道秘钥。因为传输数据都是走的网络,如果将秘钥通过网络的方式传递的话,一旦秘钥被截获就没有加密的意义的。

2、非对称加密:


有公钥私钥之分,公钥所有人都可以知道,可以将数据用公钥加密,但是将数据解密必须使用私钥解密,私钥只有分发公钥的一方才知道。

这种加密方式就可以完美解决对称加密存在的问题。假设现在两端需要使用对称加密,那么在这之前,可以先使用非对称加密交换秘钥。

简单流程如下:首先服务端将公钥公布出去,那么客户端也就知道公钥了。接下来客户端创建一个秘钥,然后通过公钥加密并发送给服务端,服务端接收到密文以后通过私钥解密出正确的秘钥,这时候两端就都知道秘钥是什么了。


3、TLS 握手过程如下图:


在这里插入图片描述

客户端发送一个随机值以及需要的协议和加密方式。

服务端收到客户端的随机值,自己也产生一个随机值,并根据客户端需求的协议和加密方式来使用对应的方式,并且发送自己的证书(如果需要验证客户端证书需要说明)

客户端收到服务端的证书并验证是否有效,验证通过会再生成一个随机值,通过服务端证书的公钥去加密这个随机值并发送给服务端,如果服务端需要验证客户端证书的话会附带证书

服务端收到加密过的随机值并使用私钥解密获得第三个随机值,这时候两端都拥有了三个随机值,可以通过这三个随机值按照之前约定的加密方式生成密钥,接下来的通信就可以通过该密钥来加密解密

通过以上步骤可知,在 TLS 握手阶段,两端使用非对称加密的方式来通信,但是因为非对称加密损耗的性能比对称加密大,所以在正式传输数据时,两端使用对称加密的方式通信

以上说明的都是 TLS 1.2 协议的握手情况,在 1.3 协议中,首次建立连接只需要一个 RTT,后面恢复连接不需要 RTT 了。




二十七、HTTP/2 及 HTTP/3


HTTP/2 通过多路复用、二进制流、Header 压缩等等技术,极大地提高了性能,但是还是存在着问题的
QUIC 基于 UDP 实现,是 HTTP/3 中的底层支撑协议,该协议基于 UDP,又取了 TCP 中的精华,实现了即快又可靠的协议


1、HTTP/2


在 HTTP/1 中,为了性能考虑,我们会引入雪碧图、将小图内联、使用多个域名等等的方式。这一切都是因为浏览器限制了同一个域名下的请求数量(Chrome 下一般是限制六个连接),当页面中需要请求很多资源的时候,队头阻塞(Head of line blocking)会导致在达到最大请求数量时,剩余的资源需要等待其他资源请求完成后才能发起请求。

在 HTTP/2 中引入了多路复用的技术,这个技术可以只*通过一个 TCP 连接就可以传输所有的请求数据。多路复用很好的解决了浏览器限制同一个域名下的请求数量的问题,同时也间接更容易实现全速传输,毕竟新开一个 TCP 连接都需要慢慢提升传输速度。

在这里插入图片描述

在 HTTP/1 中,因为队头阻塞的原因,你会发现发送请求是长这样的

在这里插入图片描述
在 HTTP/2 中,因为可以复用同一个 TCP 连接,你会发现发送请求是长这样的

在这里插入图片描述

2、二进制传输


HTTP/2 中所有加强性能的核心点在于此。在之前的 HTTP 版本中,我们是通过文本的方式传输数据。在 HTTP/2 中引入了新的编码机制,所有传输的数据都会被分割,并采用二进制格式编码
在这里插入图片描述

3、多路复用


在 HTTP/2 中,有两个非常重要的概念,分别是(frame)和(stream)。

代表着最小的数据单位,每个帧会标识出该帧属于哪个流,流也就是多个帧组成的数据流

多路复用,就是在一个 TCP 连接中可以存在多条流。换句话说,也就是可以发送多个请求,对端可以通过帧中的标识知道属于哪个请求。通过这个技术,可以避免 HTTP 旧版本中的队头阻塞问题,极大的提高传输性能。

在这里插入图片描述

4、Header 压缩


HTTP/1 中,我们使用文本的形式传输 header,在 header 携带 cookie 的情况下,可能每次都需要重复传输几百到几千的字节。

HTTP /2 中,使用了 HPACK 压缩格式对传输的 header 进行编码,减少了 header 的大小。并在两端维护了索引表,用于记录出现过的 header ,后面在传输过程中就可以传输已经记录过的 header 的键名,对端收到数据后就可以通过键名找到对应的值。

5、服务端 Push


HTTP/2 中,服务端可以在客户端某个请求后,主动推送其他资源。

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

6、HTTP/3


因为 HTTP/2 使用了多路复用,一般来说同一域名下只需要使用一个 TCP 连接。当这个连接中出现了丢包的情况,那就会导致 HTTP/2 的表现情况反倒不如 HTTP/1 了。

因为在出现丢包的情况下,整个 TCP 都要开始等待重传,也就导致了后面的所有数据都被阻塞了。但是对于 HTTP/1 来说,可以开启多个 TCP 连接,出现这种情况反到只会影响其中一个连接,剩余的 TCP 连接还可以正常传输数据。

那么可能就会有人考虑到去修改 TCP 协议,其实这已经是一件不可能完成的任务了。因为 TCP 存在的时间实在太长,已经充斥在各种设备中,并且这个协议是由操作系统实现的,更新起来不大现实。

基于这个原因,Google 就更起炉灶搞了一个基于 UDP 协议的 QUIC 协议,并且使用在了 HTTP/3 上,当然 HTTP/3之前名为 HTTP-over-QUIC,从这个名字中我们也可以发现,HTTP/3 最大的改造就是使用了 QUIC

⑴、QUIC


UDP 协议虽然效率很高,但是并不是那么的可靠。QUIC 虽然基于 UDP,但是在原本的基础上新增了很多功能,比如多路复用、0-RTT、使用 TLS1.3 加密、流量控制、有序交付、重传等等功能

⑵、多路复用


虽然 HTTP/2 支持了多路复用,但是 TCP 协议终究是没有这个功能的。QUIC 原生就实现了这个功能,并且传输的单个数据流可以保证有序交付且不会影响其他的数据流,这样的技术就解决了之前 TCP 存在的问题。

并且 QUIC 在移动端的表现也会比 TCP 好。因为 TCP 是基于 IP 和端口去识别连接的,这种方式在多变的移动端网络环境下是很脆弱的。但是 QUIC 是通过 ID 的方式去识别一个连接,不管你网络环境如何变化,只要 ID 不变,就能迅速重连上。

⑶、0-RTT

通过使用类似 TCP 快速打开的技术,缓存当前会话的上下文,在下次恢复会话的时候,只需要将之前的缓存传递给服务端验证通过就可以进行传输了。

⑷、纠错机制


假如说这次我要发送三个包,那么协议会算出这三个包的异或值并单独发出一个校验包,也就是总共发出了四个包。

当出现其中的非校验包丢包的情况时,可以通过另外三个包计算出丢失的数据包的内容。

当然这种技术只能使用在丢失一个包的情况下,如果出现丢失多个包就不能使用纠错机制了,只能使用重传的方式了。




二十八、CSS面试题


未完待续…

  • 2
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

后海 0_o

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值