来咯来咯!2020最新web前端面试题总结(欢迎收藏)

开头

这些内容送给正在准备面试中的小伙伴,有一些内容会写得特别特别详细,而有一些内容则写得比较少,但是保证里面的很多内容都是干货,很多都有详细的解释,干货都在后面啊,继续往下看吧。。。各位路过的小哥哥小姐姐们,希望看完了对你有所帮助。万字长文~~~如果你看完了所有内容算我输!!!

Let's go!!!

一些JS的基础题

Javascript中的数据类型(8种)

  • 简单数据类型:Number、String、Boolean、undefined、null、Symbol、Bigint(ES2020新增)
  • 复杂数据类型:Object Object又有一些子类型,Array、Function、RegExp、Date这些都属于Object类型

for循环setTimeout打印输出

如果不采用立即执行函数或者let的形式就会直接打印出10个10,通过采取闭包或者let有了块级作用域之后就不会出现这样的问题

for (var i = 0; i < 10; i++) {
  (function (j) {
    setTimeout(() => {
      console.log(j)
    }, 1000)
  })(i)
}
复制代码

给定时器传入第三个参数, 定时器可以传多个参数给定时器函数,此处将外层的i传递给了定时器中回调函数作为参数使用。

for(var i = 1;i <= 5; i++){
  setTimeout(function timer(j){
    console.log(j)
  }, 0, i)
}
复制代码

用let给定块级作用域

for (let i = 0; i < 10; i++) {
  setTimeout(() => {
    console.log(i)
  }, 1000)
}
复制代码

关于setTimeout的一些冷知识

  • 由于消息队列的机制,不一定能按照自己设置的时间执行
  • settimeout嵌套settimeout时,系统会设置最短时间间隔为4ms
  • 未激活的页面,settimeout最小时间间隔为1000ms
  • 延时执行时间最大值为2147483647(32bit),溢出这个值会导致定时器立即执行 setTimeout(()=> { console.log('这里会立即执行') } ,2147483648) 复制代码

数组扁平化的方法

//使用ES6中的Array.prototype.flat方法
arr.flat(Infinity)
复制代码
//使用reduce的方式
function arrFlat(arr) {
  return arr.reduce((pre, cur) => {
    return pre.concat(Array.isArray(cur) ? arrFlat(cur) : cur)
  }, [])
}
复制代码
//使用递归加循环的方式
function arrFlat(arr) {
  let result = []
  arr.map((item, index) => {
    if (Array.isArray(item)) {
      result = result.concat(arrFlat(item))
    } else {
      result.push(item)
    }
  })
  return result
}
复制代码
//将数组先变成字符串,再复原 toString()
//这种方法存在缺陷,就是数组中元素都是Number或者String类型的才能展开
function arrFlat(arr) {
    return arr.toString().split(',').map(item=> +item)
}
复制代码

数组去重

定义去重数据

let arr = [1, 1, "1", "1", null, null, undefined, undefined, /a/, /a/, NaN, NaN, {}, {}, [], []]

复制代码

我们先看下几种不能去掉重复的引用数据类型的写法

// 使用 Set
let res = [...new Set(arr)]
console.log(res)
复制代码


这种方法虽然很简洁,但是使用该种方法我们可以看到里面的引用数据类型并没有能成功去重,只能去除基本数据类型

//使用filter
let res = arr.filter((item, index) => {
  return arr.indexOf(item) === index
})
console.log(res)

复制代码
//使用reduce
let res = arr.reduce((pre, cur) => {
  return pre.includes(cur) ? pre : [...pre, cur]
}, [])
console.log(res)
复制代码

使用该两种方法也和上面的方法一样,不能去掉引用数据类型。

我们再来看一下如何去除引用类型的重复值

利用对象的hasOwnProperty方法进行判断对象上是否含有该属性,如果含有则过滤掉,不含有则返回新数组中

let obj = {}
let res = arr.filter(item => {
  if (obj.hasOwnProperty(typeof item + item)) {
    return false
  } else {
    obj[typeof item + item] = true
    return true
  }
})
console.log(res)
复制代码



这次可以看到成功的将引用数据类型也去掉了。

除了以上这几种方法,还有一些循环遍历的方法也是类似的

类数组变成数组

类数组是具有length属性,但不具有数组原型上的方法。 比如说arguments,DOM操作返回的结果就是类数组。那么如何将类数组变成数组呢

  • Array.from(document.querySelectorAll('div'))
  • Array.prototype.slice.call(document.querySelectorAll('div'))
  • [...document.querySelectorAll('div')]

数据类型检测

typeof 1  	 	 // number
typeof '1'  	 // string
typeof undefined // undefined
typeof true      // boolean
typeof Symbol()  // symbol
复制代码

上面的几种类型都能正确的检测,但是引用数据类型除了函数都会显示为object,而且对于 typeof null 也是 object 这是历史遗留下的bug,因为怕影响到一些现有的web项目,所以一直没有修复这个bug。

当检测引用数据类型的时候,用instanceof比较好,它会基于原型链进行查询,如果查询结果在原型链中,就会返回true。

Object.prototype.toString.call(检测数据类型最佳方案)

调用Object原型上的toString()方法,并且通过call改变this指向。返回字符串 ,我们看看八种数据类型分别返回的结果

function checkType(param) {
  return Object.prototype.toString.call(param)
}

console.log(checkType(123)) //[object Number]
console.log(checkType("123")) //[object String]
console.log(checkType(true)) //[object Boolean]
console.log(checkType({ a: 123 })) //[object Object]
console.log(checkType(() => {})) //[object Function]
console.log(Symbol(1)) //Symbol(1)
console.log(null) //null
console.log(undefined) //undefined
复制代码

我们再对上述函数进行一下处理

function checkType(param) {
  return Object.prototype.toString.call(param).slice(8, -1).toLowerCase()
}

console.log(checkType(1)) // number
复制代码

Object.is和===的区别

Object.is在严格等于上的基础修复了一些特殊情况下的错误,比如NaN 不等于 NaN

function is(x, y){
    if(x === y){
        // 1/+0 = +Infinity  1/-0 = -Infinity 这两个是不相等的
        // 当 x和y都等于0的时候,就对x/0和y/0做判断
        return x !== 0 || y !== 0 || x / 0 === y / 0
    }
}
复制代码

==和===的区别和隐式数据类型转化

===是严格相等,左右两边不仅值要相等,类型也要相等,例如'1'===1的结果是false,因为左边是string,右边是number。

==只要值相等就会返回true,而且使用==时会发生隐式类型转化, 在js中,当运算符在运算时,如果两边数据不统一,CPU就无法计算,这时我们编译器会自动将运算符两边的数据做一个数据类型转换,转成一样的数据类型再计算 。

  • 转成string类型:+ 字符串连接符如 1 + "1" = "11"
  • 转成number类型: ++、--(自增自减运算符) + 、-、*、/、%(加减乘除取余算术运算符) >、 <、 >=、 <=、 ==、 !=、 ===、 !== (关系运算符) let i = "1" console.log(++i) // 2 复制代码
  • 转成boolean类型 : !(逻辑非运算符取反操作),使用Boolean转化除了下面这八种情况得到false以外,其它的情况都转为true 。0、-0、NaN、undefined、null、“”(空字符串)、false、document.all()
  • 如果其中一方为Object,且另一方为String、Number或者Symbol,会将Object转换成字符串,再进行比较

例子:

//字符串连接符
console.log(1 + 'true')// +是字符串连接符, String(1) + 'true',打印出'1true'

//算术运算符
console.log(1 + true) // +是算术运算符,true被Number(true)->1,打印出2
console.log(1 + undefined) // 1 + Number(undefined) -> 1 + NaN, 打印NaN
console.log(1 + null) // 1 + Number(null) -> 1 + 0,打印出1

//关系运算符
// 一边数字一边字符串,Number("2")
// 2 > 5,打印false
console.log("2" > 5)
// 两边字符串,调用"2".charCodeAt() -> 50 
// "5".charAtCode()-> 53, 打印false
console.log("2" > "5") 

//多个字符串从左往右匹配,也是调用charCodeAt方法进行比较
//比较"a".charCodeAt() < "b".charCodeAt(),打印false
console.log("abc" > "b") 

// 左边第一个"a"和右边第一个"a"的unicode编码相等
// 继续比较两边第二个字符, "b" > "a",打印true
console.log("abc" > "aaa") 

//无视上述规则自成体系
console.log(NaN == NaN) // NaN和任何数据比较都是 false
console.log(undefined == undefined) //true
console.log(undefined === undefined) //true
console.log(undefined == null) //true
console.log(undefined === null) //false
复制代码

对于复杂的数据类型,比如对象和数组

对象和数组和字符串类型比较:先使用valueOf() 取得原始值,如果原始值不是number类型,则用toString()方法转成字符串类型valueOf -> toString

//发生了a.valueOf().toString()的转化,打印true
console.log([1,2] == "1,2") 

// 发生了a.valueOf().toString()的转化,打印true
let a = {}
console.log(a == "[object Object]") 
复制代码

对象转原始类型,会调用内置的[ToPrimitive]函数,对于该函数而言,其逻辑如下:

  • 如果有设置Symbol.toPrimitive()方法,会优先调用并返回数据
  • 调用valueOf(),如果转换为原始类型,则返回
  • 调用toString(),如果转换为原始类型,则返回
  • 如果没有返回原始类型,则报错

让我们来看俩个例子

let obj = {
  value: 3,
  valueOf() {
    return 4
  },
  toString() {
    return 5
  },
  [Symbol.toPrimitive]() {
    return 6
  },
}
console.log(obj + 1) //打印7
复制代码

让 if(a ==1 && a == 2 && a == 3)成立

let a = {
  value: 0,
  valueOf() {
    return ++a.value
  },
}
// 每次调用这个a对象的时候都会在0的基础上加1,调用3次后就变成了3
console.log(a == 1 && a == 2 && a == 3) //true
复制代码

如果是数组和对象与number类型比较,先用valueOf取得原始值,原始值不是number类型则调用toString,然后再将字符串类型用Number转成数字类型,调用顺序valueOf() -> toString() -> Number()

空数组的toString()方法会得到空字符串,而空对象的toString()方法会得到字符串[object Object]

//发生了这样的转化:Number([].valueOf().toString()),打印true
console.log([] == 0) 

//逻辑非运算符优先级大于关系运算符
//空数组转布尔得到true,然后取反得到false
//false = 0 ,打印true
console.log(![] == 0) 

//左边:{}.valueOf().toString()得到”[object Object]“,Number(”[object Object]“)->NaN
//右边:!{}得到false ,Number(false) -> 0
//两边不相等,打印false
console.log({} == !{})

//左边:[].valueOf().toString()得到空字符串
//右边:![] 得到false
// Number("") = Number(false) 两边都为0
//打印true
console.log([] == ![])

//因为引用数据类型存储在堆中的地址,左边和右边分别属于两块不同的空间
//他们地址不相同,所以两边不相等
//下面两种情况都打印false
console.log([] == [])
console.log({} == {})
复制代码

记录遇到的一个另一个相关问题

下面这三个的打印结果

//typof null返回的是object
console.log(typeof null)

//从右往左看,先看右边的typeof null整体,返回object之后
//再将整体看成typeof object
//打印结果为string,原因是typeof null返回的是object字符串
console.log(typeof typeof null)

//到这里也是从右往左看,相当于typeof string
//结果打印是string
console.log(typeof typeof typeof null)
复制代码

实现一个instanceof

function myInstanceof(left,right) {
    if(typeof left !== 'object' || left === null) return false
    //获取原型
    let proto = Object.getPrototypeOf(left)
    while(true){
        //如果原型为null,则已经到了原型链顶端,判断结束
        if(proto === null) return false
        //左边的原型等于右边的原型,则返回结果
        if(proto === right.prototype) return true
        //否则就继续向上获取原型
        proto = Object.getPrototypeOf(proto)
    }
}
复制代码

实现继承

ES5中实现继承

    //实现一下继承
    function Parent() {
      this.name = "大人"
      this.hairColor = "黑色"
    }

    function Child() {
      Parent.call(this)
      this.name = "小孩"
    }

    Child.prototype = Object.create(Parent.prototype)
	//将丢失的构造函数给添加回来
    Child.prototype.constructor = Child

    let c1 = new Child()
    console.log(c1.name, c1.hairColor) //小孩,黑色
    console.log(Object.getPrototypeOf(c1))
    console.log(c1.constructor) //Child构造函数

    let p1 = new Parent()
    console.log(p1.name, p1.hairColor) //大人,黑色
    console.log(Object.getPrototypeOf(p1))
    console.log(p1.constructor) //Parent构造函数
复制代码

ES6中实现继承

// ES6的继承
class Parent {
  constructor() {
    this.name = "大人"
    this.hairColor = "黑色"
  }
}

class Child extends Parent {
  constructor() {
    super()  //调用父级的方法和属性
    this.name = "小孩"
  }
}

let c = new Child()
console.log(c.name, c.hairColor) //小孩 黑色

let p = new Parent()
console.log(p.name, p.hairColor) //大人 黑色
复制代码

如何在ES5环境下实现const

此处需要用到Object.defineProperty(Obj,prop,desc)这个API

function _const (key, value) {
	const desc = {
        value,
        writable:false
    }
    Object.defineProperty(window,key,desc)
}

_const('obj',{a:1}) //定义obj
obj = {} //重新赋值不生效
复制代码

手写Call

//手写call
let obj = {
  msg: "我叫王大锤",
}

function foo() {
  console.log(this.msg)
}

// foo.call(obj)
//调用call的原理就跟这里一样,将函数挂载到对象上,然后在对象中执行这个函数
// obj.foo = foo
// obj.foo()

Function.prototype.myCall = function (thisArg, ...args) {
  const fn = Symbol("fn") // 声明一个独有的Symbol属性, 防止fn覆盖已有属性
  thisArg = thisArg || window // 若没有传入this, 默认绑定window对象
  thisArg[fn] = this //this指向调用者
  const result = thisArg[fn](...args) //执行当前函数
  delete thisArg[fn]
  return result
}

foo.myCall(obj)
复制代码

手写apply

// 手写apply (args传入一个数组的形式),原理其实和call差不多,只是入参不一样
Function.prototype.myApply = function (thisArg, args = []) {
  const fn = Symbol("fn")
  thisArg = thisArg || window
  thisArg[fn] = this
  //虽然apply()接收的是一个数组,但在调用原函数时,依然要展开参数数组
  //可以对照原生apply(),原函数接收到展开的参数数组
  const result = thisArg[fn](...args)
  delete thisArg[fn]
  return result
}

foo.myApply(obj)
复制代码

手写bind

Function.prototype.myBind = function (thisArg, ...args) {
  let self = this //这里的this是指向thisArg(调用者)
  let fnBound = function () {
    //this instanceof self ? this : thisArg 判断是构造函数还是普通函数
    //后面的args.concat(Array.prototype.slice.call(arguments))是利用函数柯里化来获取调用时传入的参数
    self.apply(this instanceof self ? this : thisArg, args.concat(Array.prototype.slice.call(arguments)))
  }
  // 继承原型上的属性和方法
  fnBound.prototype = Object.create(self.prototype)
  //返回已经绑定的函数
  return fnBound
}

//通过普通函数调用
// foo.myBind(obj, 1, 2, 3)()

//通过构造函数调用
function fn(name, age) {
  this.test = "测试数据"
}
fn.prototype.protoData = "原型数据"
let fnBound = fn.myBind(obj, "王大锤", 18)
let newBind = new fnBound()
console.log(newBind.protoData) // "原型数据"

复制代码

另外之前看到的一个关于bind的问题,在此也收录一下

对于foo.bind(A).bind(B).bind(C) 这个问题

let obj = { a: 1 }
let obj2 = { a: 2 }
let obj3 = { a: 3 }
let obj4 = { a: 4 }

function foo() {
  console.log(this.a)
}

let boundFn = foo.bind(obj).bind(obj2).bind(obj3)
boundFn.call(obj4)  //打印结果为1
boundFn.apply(obj4) //打印结果为1
boundFn()  //打印结果为1

复制代码

由此我们可以看出bind是永久绑定,往后的操作都不会再更改其指向

模拟实现一个new操作符

模拟实现一个new操作符,传入一个构造函数和参数

function myNew(constructFn, ...args) {
  // 创建新对象,并继承构造方法的prototype属性,
  //把obj挂原型链上, 相当于obj.__proto__ = constructFn.prototype
  let obj = Object.create(constructFn.prototype)

  //执行构造函数,将args参数传入,主要是为了进行赋值this.name = name等操作
  let res = constructFn.apply(obj, args)

  //确保返回值是一个对象
  return res instanceof Object ? res : obj
}

function Dog(name) {
  this.name = name

  this.woof = function () {
    console.log("汪汪汪")
  }
  //构造函数可以返回一个对象
  //return { a: 1 }
}

let dog = new Dog("阿狸")
console.log(dog.name) //阿狸
dog.woof() //汪汪汪

let dog2 = myNew(Dog, "大狗")
console.log(dog2.name) //大狗
dog2.woof() //汪汪汪
复制代码

节流

节流可以控制事件触发的频率,节流就跟小水管一样,如果不加节流的话,水就会哗啦啦啦啦啦啦的流出来,但是一旦加了节流阀,你就可以自己控制水的流速了,加了节流后水可以从哗啦啦啦变成滴答滴答滴答,放到我们的函数事件里面说就是可以让事件触发变慢,比如说事件触发可以让它在每一秒内只触发一次,可以提高性能。

function throttle(fn, wait) {
    let prev = +new Date()
    return function() {
        let now = +new Date()
        /*当下一次事件触发的时间和初始事件触发的时间的差值大于
			等待时间时才触发新事件 */
        if(now - prev > wait) {
            fn.apply(this, arguments)
        }
        //重置初始触发时间
        prev = +new Date()
    }
}
复制代码

防抖

防抖就是可以限制事件在一定时间内不能多次触发,比如说你疯狂按点击按钮,一顿操作猛如虎,不加防抖的话它也会跟着你疯起来,疯狂执行触发的方法。但是一旦加了防抖,无论你点击多少次,他都只会在你最后一次点击的时候才执行。 防抖常用于搜索框或滚动条等的监听事件处理,可以提高性能。

function debounce(fn, wait = 50) {
    //初始化一个定时器
    let timer
    return function() {
        //如果timer存在就将其清除
        if(timer) {
            clearTimeout(timer)
        }
        //重置timer
        timer = setTimeout(() => {
            //将入参绑定给调用对象
            fn.apply(this, arguments)
        }, wait)
    }
}
复制代码

深拷贝和浅拷贝

浅拷贝: 顾名思义,所谓浅拷贝就是对对象进行浅层次的复制,只复制一层对象的属性,并不包括对象里面的引用类型数据 , 当遇到有子对象的情况时,子对象就会互相影响 ,修改拷贝出来的子对象也会影响原有的子对象

深拷贝: 深拷贝是对对象以及对象的所有子对象进行拷贝,也就是说新拷贝对象的子对象里的属性也不会影响到原来的对象

我们先定义一个对象

let obj = {
  a: 1,
  b: 2,
  c: {
    d: 3,
    e: 4
  }
}

复制代码

实现浅拷贝

使用Object.assign()

let obj2 = Object.assign({}, obj)
obj2.a = 111
obj2.c.e = 555
console.log(obj)
console.log(obj2)

复制代码

使用展开运算符

let obj2 = {...obj}
obj2.a = 111
obj2.c.e = 555
console.log(obj)
console.log(obj2)
复制代码



查看结果,发现第一层对象的a不互相影响,但是子对象c里的数据是会互相影响的

对数组的浅拷贝道理其实是一样的,对数组浅拷贝我们可以使用

  • 展开运算符...

定义一个数组

let arr = [1, 2, { a: 3 }]
复制代码

使用Array.prototype.slice()

let arr2 = arr.slice()
arr2[0] = 222
arr[2].a = 333
console.log(arr)
console.log(arr2)
复制代码

使用Array.prototype.concat()

let arr2 = arr.concat()
arr2[0] = 222
arr[2].a = 333
console.log(arr)
console.log(arr2)
复制代码

使用展开运算符...

let arr2 = [...arr]
arr2[0] = 222
arr[2].a = 333
console.log(arr)
console.log(arr2)
复制代码

他们最后的打印结果都是一样的



我们可以也可以使用遍历的方式写一个浅拷贝函数对数组和对象进行判断

source:源输入

target:目标输出

function shallowCopy(source) {
    //开头可以判断一下入参是不是一个对象
    let target = Array.isArray(source) ? [] : {}
    for(let key in source) {
        //使用 hasOwnProperty 限制循环只在对象自身,不去遍历原型上的属性
        if(source.hasOwnProperty(key)) {
            target[key] = source[key]
        }
    }
    return target
}
复制代码

实现深拷贝

最简单的深拷贝方式

let target = JSON.parse(JSON.stringify(source))

复制代码

但是这种方法的话只支持object、array、string、number、true、false、null这几种数据或者值,其他的比如函数、undefined、Date、RegExp等数据类型都不支持。对于它不支持的数据都会直接忽略该属性。

递归的方式

浅拷贝由于只是复制一层对象的属性,当遇到有子对象的情况时, 其实我们也可以递归调用浅拷贝

function deepCopy(source) {
  //开头这里可以判断入参是不是一个对象
  let target = Array.isArray(source) ? [] : {}
  for (let key in source) {
    if (source.hasOwnProperty(key)) {
      //这里我们再做一层判断看看是否有子属性
      //这里也可以直接调用上面写过的那个checkType函数进行判断,就不用写两个typeof了
      if (source[key] && typeof source[key] !== null && typeof source[key] === "object")
      {
        target[key] = Array.isArray(source[key]) ? [] : {}
        //递归调用
        target[key] = deepCopy(source[key])
      } else {
        target[key] = source[key]
      }
    }
  }
  return target
}
复制代码

这里的深拷贝只能进行简单数据类型的拷贝,如果是复杂数据类型则拷贝的过程种会丢失掉数据,比如说拷贝的对象里含有正则表达式或者函数,日期,这些是无法拷贝的,如果需要拷贝这些的话就需要我们单独去对每一种类型做出判断,另外就是递归的版本还会存在循环引用的问题 比如obj.obj = obj,这样无限循环下去就会出现爆栈的情况,所以我们还需要进行优化

我们再来看一下优化之后的版本

function deepCopy(source, cache = new Map()) {
  if (cache.has(source)) {
    //如果缓存中已经有值则直接返回,解决循环调用的问题
    return cache.get(source)
  }
  //当入参属于Object复杂数据类型就开始做子类检测-> Function Array RegExp Date 都属于Object类型
  if (source instanceof Object) {
    let target
    if (source instanceof Array) {
      //判断数组的情况
      target = []
    } else if (source instanceof Function) {
      //判断函数的情况
      target = function () {
        return source.apply(this, arguments)
      }
    } else if (source instanceof RegExp) {
      //判断正则表达式的情况
      target = source
    } else if (source instanceof Date) {
      target = new Date(source)
    } else {
      //普通对象
      target = {}
    }
    // 将属性和拷贝后的值进行缓存
    cache.set(source, target)
    //开始做遍历递归调用
    for (let key in source) {
      if (source.hasOwnProperty(key)) {
        target[key] = deepCopy(source[key], cache)
      }
    }
    return target
  } else {
    //如果不是复杂数据类型的话就直接返回
    return source
  }
}
复制代码

我们在此测试一下

let obj = {
  a: 1,
  b: undefined,
  c: null,
  d: Symbol(),
  e: new Date(),
  f: new RegExp("123", "ig"),
  g: function () {
    console.log("我叫王大锤")
  },
  h: [1, 2, 3],
  i: { a: 1, b: 2 },
}
let obj2 = deepCopy(obj)
obj2.g = function () {
  console.log("我不叫王大锤")
}
obj.g() //我叫王大锤
obj2.g() //我不叫王大锤
obj2.h[0] = 111
console.log(obj)
console.log(obj2)
复制代码



可以看到这次我们的深拷贝是比较完善的了,当然还可以做进一步的优化,比如for in循环的性能较低,可以改成while循环作为遍历,这里我们就不改了。

事件循环机制

浏览器环境中

宏任务: 包括整体代码script,setTimeout,setInterval 、 I/O 操作、UI 渲染 等

微任务: Promise.then

特别说明的是new Promise里面的内容是同步执行的,像new Promise(resolve(console.log('1')))同步执行,resolve之后.then进入微任务队列,具体的内容请往下继续看。

__在浏览器环境中:__事件循环的顺序,决定js代码的执行顺序。进入整体代码(宏任务)后,开始第一次循环。接着执行所有的微任务。然后再次从宏任务开始,找到其中一个任务队列执行完毕,再执行所有的微任务。大概就是先执行同步代码,然后就将宏任务放进宏任务队列,宏任务队列中有微任务就将其放进微任务队列,当宏任务队列执行完就检查微任务队列,微任务队列为空了就开始下一轮宏任务的执行,往复循环。 宏任务 -> 微任务 -> 宏任务 -> 微任务一直循环。

我们再来看几道题目提高一下理解

console.log(1);

setTimeout(() => {
  console.log(2);
  new Promise((resolve) => {
    console.log(3);
    resolve();
  }).then(() => {
    console.log(4);
  });
});

new Promise((resolve) => {
  console.log(5);
  resolve();
}).then(() => {
  console.log(6);
});

setTimeout(() => {
  console.log(7);
  new Promise((resolve) => {
    console.log(8);
    resolve();
  }).then(() => {
    console.log(9);
  });
});

console.log(10)
复制代码

第一轮循环:从上往下看代码 —> 打印1(同步代码),第一个setTimeout进入宏任务队列等待执行,然后执行到第一个new Promise,里面的内容同步执行,直接打印5,然后resolve,.then里的代码放到微任务队列等待执行,遇到第二个setTimeout,放到宏任务队列。 最后打印10。

script宏任务执行完后打印1 -> 5 -> 10 。然后看看队列中的情况

宏任务队列微任务队列setTimeout1then1setTimeout2

我们发现微任务队列中存在一个微任务,然后去执行它。

then1打印6,所以第一轮循环结束后打印了1 -> 5 -> 10 -> 6

第二轮循环:执行宏任务队列里的setTimeout1,先执行里面的同步代码,打印2和3,.then进入微任务队列

宏任务队列微任务队列setTimeout2then2

然后去执行微任务队列中的任务,打印4,第二轮循环结束打印了2 -> 3 -> 4

第三轮循环:执行宏任务队列里的setTimeout2,先执行里面的同步代码打印7和8,.then进入微任务队列

宏任务队列微任务队列then3

然后去执行微任务队列中的任务,打印9,第三轮循环结束打印了7 -> 8 -> 9

宏任务和微任务队列都为空便结束循环,最后打印的顺序是:

1 -> 5 -> 10 -> 6 -> 2 -> 3 -> 4 -> 7 -> 8 -> 9

上述结果在浏览器环境(谷歌浏览器86版本)和node v12.18.0环境中测试均一样

最后说明一下:如果遇到async / await,可以将await理解成Promise.then。然后我们再对知识点进行一下巩固

console.log('start')

async function async1() {
  await async2()
  console.log('async1')
}
async function async2() {
  console.log('async2')
}
async1()

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

new Promise(resolve => {
  console.log('promise')
  resolve()
})
  .then(() => {
    console.log('then1')
  })
  .then(() => {
    console.log('then2')
  })

console.log('end')
复制代码

上面代码async1函数里的内容可以看成,async2()会立即执行,然后.then里的内容进入微任务队列

async function async1() {
  Promise.resolve(async2()).then(() => {
     console.log('async1')
  })
}
复制代码

根据我们上面所说的知识点,最后打印顺序为:start -> async2 -> promise -> end -> async1 -> then1-> then2-> setTimeout

node环境中(v12.18.0)

在node环境中事件循环的机制和浏览器有些不同

node中事件循环大概可以分为这几个阶段,优先级 idle观察者 > I/O观察者 > check观察者。

  • idle观察者: process.nextTick
  • I/O观察者(轮询):一般性的I/O回调,如网络,文件,数据库I/O等
  • check观察者(检测):setImmediate,setTimeout

其实除了上面三个阶段还有其它的几个阶段,具体可以看这里,但是我们现在主要就是介绍上面说的这三个阶段



我们看一段代码比对一下process.nextTick和setImmediate

process.nextTick(() => {
  console.log('1');
})

setImmediate(() => {
  console.log('2');
  process.nextTick(() => {
    console.log('3');
  })
})

process.nextTick(() => {
  console.log('4');
})

setImmediate(() => {
  console.log('5');
})

process.nextTick(() => {
  console.log('6');
})
复制代码

不同于浏览器环境中的宏任务和微任务队列,在node中每一个观察者队列都先将自身的任务执行完才会开始下一个阶段的执行。

官网中的解释是: 每个阶段都有一个 FIFO 队列来执行回调。虽然每个阶段都是特殊的,但通常情况下,当事件循环进入给定的阶段时,它将执行特定于该阶段的任何操作,然后执行该阶段队列中的回调,直到队列用尽或最大回调数已执行。当该队列已用尽或达到回调限制,事件循环将移动到下一阶段,等等。

我们把每一个观察者看成是一个队列,所以我们看一下入队顺序

idle队列check队列process1setImemediate1process2setImemediate2process3

此处三个processs入队,然后执行三个process,打印顺序是1 -> 4 -> 6。

然后就开始执行check队列中的两个setImemediate。第一个setImemediate中先打印2,然后遇到process.nextTick,直接打印process.nextTick回调函数中的3,然后才开始第二个setImemediate的调用,打印5,所以打印顺序是 2 -> 3 -> 5

最终打印顺序是1 -> 4 -> 6 -> 2 -> 3 -> 5。

这里说明一下,process.nextTick总是优先于setImemediate执行的,为什么呢,我们看下官网中的解释

process.nextTick() 从技术上讲不是事件循环的一部分。相反,它都将在当前操作完成后处理 nextTickQueue(nextTick队列), 而不管事件循环的当前阶段如何。 任何时候在给定的阶段中调用 process.nextTick(),所有传递到 process.nextTick() 的回调将在事件循环继续之前解析 。

所以我们可以知道为什么setImemediate1中的process.nextTick会比setImemediate2要先执行。

这里要说的第二个问题是关于setTimeout和setImemediate

  • setImmediate() 设计为一旦在当前 轮询 阶段完成, 就执行脚本。
  • setTimeout() 在最小阈值(ms )过后运行脚本。

如果把这两个东西放在主模块中运行,他们的执行顺序是不确定的(受计算器运行速度影响),像下面这段代码

setImmediate(() => {
  setImmediate(() => {
    console.log('1')
    setImmediate(() => {
      console.log('2')
    })
  })

  setImmediate(() => {
    console.log('3')
    setImmediate(() => {
      console.log('4')
    })
  })
})

setTimeout(() => {
  console.log('timeout')
}, 0);
复制代码

我们多执行几次可以看到两种不一样的输出结果,分别是:

timeout -> 1 -> 3 -> 2 -> 4

1 -> 3 -> timeout -> 2 -> 4

但是为什么会这样,我们再去探究。打开github搜索node源码,来到lib/internal/timer.js中,我们可以看到这样的一段代码(在164行的位置)

node源码中关于setTimeout这一块的实现,意思就是如果没有设置这个after,或者小于1,或者大于TIMEOUT_MAX(2^31-1),都会被强制设置为1ms。也就是说setTimeout(xxx,0)其实等同于setTimeout(xxx,1)。



所以我们可以得出结论:setTimeout其实总是优先于setImemediate执行的!!!但是如果在这1ms内,setTimeout的回调函数没来得及执行,setImemediate就会先执行,我们可以看下这样的例子

setImmediate(() => {
  setImmediate(() => {
    console.log('1')
    setImmediate(() => {
      console.log('2')
    })
  })

  setImmediate(() => {
    console.log('3')
    setImmediate(() => {
      console.log('4')
    })
  })
})

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

for (let i = 0; i < 10000; i++) { }
复制代码

我们在setTimeout后面加了一个for循环来验证是不是setTimeout是先于setImemediate执行的

执行后发现每次打印的都是timeout -> 1 -> 3 -> 2 -> 4这个结果,setTimeout执行后先执行回调打印了timeout,然后setImemediate的回调执行被for循环给阻塞了,当for循环结束之后,setImemediate中的回调才开始打印,所以我们可以验证出结果。

我们再来验证另外一个,为什么会打印第二个结果 1 -> 3 -> timeout -> 2 -> 4

在我的电脑上执行这段代码(CPU是10代I7)将时间从0改成5

setImmediate(() => {
  setImmediate(() => {
    console.log('1')
    setImmediate(() => {
      console.log('2')
    })
  })

  setImmediate(() => {
    console.log('3')
    setImmediate(() => {
      console.log('4')
    })
  })
})

setTimeout(() => {
  console.log('timeout')
}, 5);
复制代码

在这里执行之后发现每次都是执行的都是第二个结果 1 -> 3 -> timeout -> 2 -> 4,所以我们可以得出,为什么在设置定时器时间为0的时候,有时候会打印第二个结果,因为这会受电脑cpu解析代码的速度影响

(如果在你的电脑上将0改成5没跑出这个结果,可以尝试这把5再改大一点,用来验证到底是不是cpu速度影响了执行顺序)

到此我们再来看一段代码,对上述知识进行巩固

console.log('start');

async function async1() {
  await async2()
  console.log('async1')
}

async function async2() {
  console.log('async2')
}

async1()

setTimeout(() => {
  console.log('timeout');
})

setImmediate(() => {
  console.log('immediate');
})

process.nextTick(() => {
  console.log('nextTick');
})

new Promise(resolve => {
  console.log('promise');
  resolve()
})
  .then(() => {
    console.log('then');
  })

console.log('end');
复制代码

最后打印的结果是:start -> async2 -> promise -> end -> nextTick -> async1 -> then -> timeout -> immediate

再特别提醒一下:process.nextTick独占一个队列,且process.nextTick队列优先于Promise.then微任务队列的执行

 process.nextTick(() => console.log(1)); 
 Promise.resolve().then(() => console.log(2)); 
 process.nextTick(() => console.log(3)); 
 Promise.resolve().then(() => console.log(4)); 
 //打印结果是 1 -> 3 -> 2 -> 4
复制代码

总结

在浏览器环境中,分为宏任务和微任务

主要宏任务有:script主代码、setTimeout、setInterval

主要微任务有:Promise.then

先执行宏任务队列 -> 微任务队列 -> 循环

在node环境中,先执行宏任务队列 -> process.nextTick队列 -> 微任务队列 -> setTimeout -> setImemediate

收录的一些编程题目?

实现一个sleep休眠函数

从Promise,async/await,generator等角度出发进行考虑

//方法一:
function sleep(time) {
    return new Promise(resolve => {
        setTimeout(resolve, time)
    })
}

sleep(1000).then(() => {
    console.log('1秒过后执行这里')
})

//方法二:
//在函数中用async await调用上面封装好的sleep方法
async foo() {
    await sleep(1000)
    console.log('这里在一秒后打印')
}
foo()

//方法三:
//generator实现
function* generatorSleep(time){
  yield new Promise(reslove => {
      setTimeout(reslove, time)
  })
}

generatorSleep(1000).next().value.then(()=>{
  console.log('generator实现方式')
})

//方法四:
//通过回调函数来调用 cb->callback
function sleepCb(cb, time) {
    if(typeof cb !== 'function') return
    setTimeout(cb, time)
}

function foo() {
    console.log('1秒后打印这个回调函数')
}
sleepCb(foo, 1000)
复制代码

实现一个repeat重复执行函数

传入一个方法,然后每隔一段时间执行一次,执行n次

//每隔2s输出一次helloworld,共输出4次 const repeatFunc = repeat(console.log, 4, 2000); //repeatFunc("helloworld")

function repeat(fn, n, interval) {
  return (...args) => {
    let timer
    let counter = 0
    timer = setInterval(() => {
      counter++
      fn.apply(this, args)
      if (counter === n) {
        clearInterval(timer)
      }
    }, interval);
  }
}

const repeatFn = repeat(console.log, 4, 2000)
repeatFn('helloworld')
复制代码

实现一个函数将下划线命名转化成驼峰命名法

function formatHump(str) {
  if (typeof str !== "string") return false
  //将str分割成数组
  str = str.split("_") // ["get", "element", "by", "id"]
  if (str.length > 1) {
    // 从1开始for循环遍历,因为数组第一个字符串的首字母不需要转大写
    // 将数组里的每一个字符串第一个字母变成大写
    for (let i = 1; i < str.length; i++) {
      str[i] = str[i][0].toUpperCase() + str[i].substr(1)
    }
    //将数组拼接回字符串
    return str.join("")
  }
}

console.log(formatHump("get_element_by_id"))  //getElementById
复制代码

实现一个带并发限制的异步调度器Scheduler,最多同时运行两个任务

//异步调度器
class Scheduler {
  constructor(maxNum) {
    //等待执行的任务队列
    this.taskList = []
    //当前任务数
    this.count = 0
    //最大任务数
    this.maxNum = maxNum
  }
    
  async add(promiseCreator) {
     //当当前任务数超出最大任务数就将其加入等待执行的任务队列
    if (this.count >= this.maxNum) {
      await new Promise(resolve => {
        this.taskList.push(resolve)
      })
    }
    this.count++
    const result = await promiseCreator()
    this.count--
    //当其它任务执行完任务队列中还有任务没执行就将其出队并执行
    if (this.taskList.length > 0) {
      this.taskList.shift()()
    }
    return result
  }
}

const timeout = time => {
  return new Promise(resolve => {
    setTimeout(resolve, time)
  })
}

const scheduler = new Scheduler(2)
const addTask = (time, value) => {
  scheduler.add(() => {
    return timeout(time).then(() => {
      console.log(value)
    })
  })
}

addTask(1000, "1")
addTask(500, "2")
addTask(300, "3")
addTask(400, "4")

//此处输出2 -> 3 ->1 -> 4
//一开始1、2两个任务进入队列
//500ms时,2完成,输出2,任务3进入队列
//800ms时,3完成,输出3,任务4进入队列
//1000ms时,1完成,输出1
//1200ms时,4完成,输出4
复制代码

更多题目今后继续添加...(肝不动了)

其它的一些讲解

进程和线程

多线程可以并行处理任务,但是线程不能单独存在,必须由进程来启动来管理。就是说进程是爸爸,线程是儿子,一个爸爸可以有很多个儿子。下面是关于线程的一张图,外面的框框就是一个进程,里面的就是线程。



一个进程就是一个程序的运行实例,启动一个程序的时候,操作系统为该程序创建一块内存,用来存放代码和运行中的数据和一个执行任务的主线程,这就是进程。

这里有几个特点:

  • 进程中任一线程出错,都会导致整个进程崩溃
  • 线程之间可以共享进程中的数据
  • 一个进程关闭后,操作系统会回收进程占用的内存
  • 进程之间的内容相互隔离

目前谷歌浏览器的多进程架构

最新的谷歌浏览器包括了:1个浏览器(Browser)主进程,1个GPU进程,一个网络(NetWork)进程,多个渲染进程和多个插件进程

  • 浏览器进程:负责页面显示、用户交互、子进程管理、提供存储等功能
  • 渲染进程:核心任务是将HTML、CSS和Js转换为可以与用户交互的网页,排版进程Blink和Js的V8引擎运行在该进程中,默认情况下,Chrome会为每一个新开标签创建一个新的渲染进程(还会受同一站点的影响,下一题解释),每一个渲染进程运行在安全沙箱下,
  • GPU进程:实现3D CSS效果,绘制网页的UI界面
  • 网络进程:负责页面的网络资源加载
  • 插件进程:负责插件的运行,每一个插件对应一个线程。单独开一个线程主要是为了防止插件崩溃而对网页造成影响

从输入一个URL到页面显示经过了什么

输入URL地址

  • 用户输入地址后,浏览器判断输入信息是搜索还是网址,如果是搜索内容则使用默认搜索引擎合成新的URL;如果输入的URL地址符合规则,浏览器根据URL协议,在这段内容加上协议合成合法URL,例如输入www.baidu.com,会加上协议合成https://www.baidu.com

按下回车键

  • 用户输入内容按下回车键后浏览器导航栏显示loading状态,页面上还显示着前一个页面的内容,这是因为请求的新页面还没有响应数据

构建请求

  • 浏览器构建请求头和请求行信息 GET /index.html HTTP1.1 ,通过进程间通信(IPC)将URL请求发送给浏览器的网络进程

查找缓存

  • 网络进程收到URL地址发起网络请求之前,在浏览器中查找是否有要请求的文件,如果本地有缓存副本则拦截请求,返回本地资源副本,并直接结束请求返回200。如果本地没有缓存,则进入网络请求过程

准备IP地址和端口

  • 网络进程请求DNS返回域名对应的IP和端口号,如果之前DNS数据缓存服务缓存过当前域名信息,则直接返回缓存信息,否则发起请求获取根据域名解析出来的IP和端口号。如果没有端口号,则使用默认端口号,http使用80端口,https使用443端口。如果是https请求,还需要建立TLS连接。

Chrome浏览器的队列机制

  • 同一域名同时最多只能建立6个TCP连接,如果在同一个域名下同时有超过6个的请求发生,则剩下的请求进入队列排队,直至进行中的请求完成,如果当前请求数少于6个,则直接建立TCP连接

发起请求

  • 经过TCP三次握手便可以发起请求,然后http请求会加上TCP头部—包括源端口号,目的端口号和校验数据完整性的序号,向下层传输
  • 网络层在数据包加上IP头部,包括源IP地址和目的IP地址,继续向下层传输
  • 底层通过物理网络传输到目的服务器

目的服务器解析请求

  • 目的服务器主机网络层接收到数据包解析出IP头部,识别数据部分,解包后开始向上传输到传输层
  • 传输层获取到数据解析出TCP头部,识别端口,解包后向上传输到应用层
  • 应用层HTTP解析出请求头和请求体,如果需要重定向,HTTP直接返回HTTP响应状态码301(永久重定向)或者302(临时重定向),同时在请求头Location字段附上重定向地址,浏览器进行重定向操作。如果不是重定向,服务器根据请求头中的If-None-Match的值判断请求资源是否被更新,如果没有更新返回304,告诉浏览器之前的缓存还可以使用,否则就返回新数据200状态码,服务器可以在响应头中设置Cache-Control:Max-age=2000(单位:秒)来让浏览器设置数据缓存时间。
  • 数据最终又通过应用层 —> 传输层 —> 网络层 —> 底层 —> 底层 —> 网络层 —> 传输层 —> 应用层的顺序返回到浏览器的网络进程中
  • 数据传输完成后,TCP四次挥手断开连接。如果浏览器或者服务器在请求头加上Connection:Keep-Alive字段则可以保持浏览器和服务器的连接状态,省去下次重新建立连接的时间。

浏览器解析响应数据

  • 浏览器的网络进程获得数据包后根据响应头的Content-Type字段判断响应数据类型,如果是字节流类型则将请求交给下载管理器。如果是text/html类型,则通知浏览器进程获取到文档进行渲染。
  • 浏览器进程收到通知,判断页面B是否从页面A打开并且判断A和B是否是同一个站点(根域名和协议一样就属于同一站点),如果满足条件则页面B和页面A共用同一个渲染进程,如果以后打开更多页面符合同一站点规则,则都复用页面A的渲染进程。不是同一站点,则新建一个单独的渲染进程。
  • 浏览器收到确认提交的消息后便更新浏览器的页面状态,包括安全状态、URL地址,前进后退的历史状态,并更新页面。
  • 渲染进程开始页面的渲染,HTML解析生成DOM树,CSS样式表转化为浏览器能看懂的styleSheets,计算出DOM节点的样式
  • 创建布局树,计算元素布局信息
  • 对布局树进行分层,生成分层树
  • 为每个图层生成绘制列表,将其提交到合成线程(属于渲染进程),合成线程将图层分成图块,并在光栅化线程池中将图块转化成位图
  • 合成线程发送绘制图块命令DrawQuad给浏览器进程,浏览器进程收到信息后生成页面并显示。

垃圾怎么进行回收的

内存空间分为栈和堆。栈中的数据是每当执行完一个函数的实行上下文时就会销毁,例如showName这个函数

showName() {
    let name = '王大锤'
    console.log(name)
}
复制代码

执行这个函数的时候,js引擎会创建它的执行上下文,压入调用栈中。当执行完这个函数就会出栈,最后内存就销毁了。

而销毁堆中的内存就需要垃圾回收器来帮助了,我们来看另一个函数

function bar() {
    let obj = {name: '王大锤'}  //obj是指向堆中保存这个对象的内存的引用
}
复制代码

执行bar函数的时候它的执行上下文会入栈,而函数里创建了一个对象,此时变量obj是一个引用类型变量,它指向堆中的一个内存地址,而堆中这个内存地址存放着{name: '王大锤'}这个数据。当bar函数执行上下文出栈的时候就销毁了obj变量,但是对象obj是指向堆内存中的一块地址引用,仍然在堆中存在没有被销毁。我们再看看V8引擎是如何销毁堆中的垃圾数据的。

新生代和老生代

V8引擎会把堆分成新生代和老生代两个区域。由V8引擎中的两个不同的垃圾回收器进行处理。

  • 新生代:存放生存时间短的对象。通常只支持1~8M容量。由V8引擎中的副垃圾回收器处理
  • 老生代:存放生存时间长的对象。支持容量比新生代大得多。由V8引擎中的主垃圾回收器处理

垃圾回收器工作流程

  • 标记空间中活动对象(仍在使用)和非活动对象(可以进行回收)
  • 标记完成后,回收非活动对象占据的内存
  • 内存整理,频繁回收对象就会在内存中出现不连续空间(内存碎片),如下次需要分配较大连续内存的时候就会出现内存不足的情况。(主垃圾回收器产生内存碎片,副垃圾回收器不产生内存碎片)

副垃圾回收器

小对象通常被分到新生区。虽然新生区空间不大,但是垃圾回收比较频繁。新生代采用了Scavenge算法:把新生区空间对半划分,一半是对象区域,一半是空闲区域。新加入对象存放到对象区域,当对象区域快满的时候就进行垃圾回收处理。垃圾清理过程

  • 垃圾标记
  • 把存活对象复制到空闲区域,并有序排列起来,相当于完成了内存整理
  • 复制完成后对象区域和空闲区域进行身份互换,原来的对象区域变成了空闲区域,空闲区域变成了对象区域,这种角色互换可以无限的进行重复
  • 如果经过两次垃圾回收还存活的对象,移入老生区(这个过程称为对象晋升策略)

主垃圾回收器

除了新生代晋升的对象,一些大的对象会直接分配到老生区。老生区对象有两个特点:占用空间大,存活时间长。

主垃圾回收器采用标记—清除的算法进行垃圾回收:标记从一组根元素开始,递归遍历,能到达的元素是活动对象,没到达的元素就是垃圾数据,没有了外界的引用,进行标记。如果某个对象已经没有了引用其的变量,那么就会被当作垃圾回收了。

对一块内存地址进行多次标记—清除算法会产生大量不连续内存碎片,这种情况需要另一种标记—整理算法来处理,标记过程和标记—清除算法一样,标记之后就让所有存活对象往内存块的一端移动,形成了连续的内存地址。

全停顿

js是运行在主线程的,一旦执行垃圾回收算法,就会对主线程进行阻塞,当垃圾回收完之后才恢复js脚本的运行,这种情况叫做全停顿。如果堆中内存过大,执行一次完整的垃圾回收可能需要的时间就到达1s以上,这样就会给页面造成了卡顿现象。一般出现全停顿现象的都是在老生代中,因为其中的对象都比较大,垃圾回收需要的时间长。为了降低这种卡顿现象,V8引擎采用了增量标记算法,让垃圾回收标记和js的逻辑交替进行,直到标记阶段完成,采取这种算法把一次大的垃圾回收分成了很多个小任务,进行穿插执行,这样就不会给页面造成卡顿现象了。

最后总结

如果里面有哪里内容写得不正确的话欢迎各位指出,我会及时更正的!(谦虚向各位大佬学习)


作者:叽叽复饥饥
链接:https://juejin.cn/post/6893856813247266823
来源:掘金

原文链接:,转发请注明来源!