前端面试常见问题总结(1)

126 阅读9分钟

  我司最近因为老板对部门人员众多感到不满意,要对部门人员进行精简,被迫又关注起了市场行情。好久没背过八股文了,趁着这次机会又把老生常谈的问题拿出来再总结一遍,也是自己复习一遍,因本人能力有限,如有错误和不足的还望各位不吝赐教。

事件循环的机制了解吗?宏任务和微任务的执行顺序是怎样的?

概念

  JavaScript是单线程的。

  至于说为什么是单线程,其中一个原因就是:当任务A要修改一个DOM,任务B也要修改这个DOM,以哪个为准?

  首先我们需要了解几个概念:

  • 主线程

    • 同步任务:由JavaScript主线程次序执行。所有的同步任务都在主线程中执行。
    • 异步任务:异步任务会委托给宿主环境进行执行。而异步任务分为宏任务和微任务。
  • 宏任务(macro task)

    • script整体代码
    • setTimeout
    • setInterval
    • setImmediate
    • I/O`
    • UI render`
  • 微任务(micro task)

    • Promise.then()
    • async
    • await
    • process.nextTick
    • mutationObserver

  JavaScript具体的执行原理如图所示:

image.png

  1. 同步任务由主线程依次执行;
  2. 异步任务委托给宿主环境(浏览器/Node.js)执行;
  3. 已经完成的异步任务对应的回调函数会被加入到任务队列中等待执行;
  4. 主线程的执行栈被清空以后,会读取任务队列中的回调函数,依次进行;
  5. 主线程不断重复上一步步骤;

而第四步是循环不断的,这个运行机制就叫事件循环(Event Loop)

  在任务队列中,微任务队列为空才会进行下一个宏任务。即微任务队列在进行中有新的微任务加入进来,也会把新加入的微任务进行完毕以后再进行下一个宏任务。

  一句话概括:有微就微,无微则宏。

例子

大家可以先想想打印顺序:

console.log(1);
setTimeout(function() {
  console.log(2);
  new Promise(function(resolve) {
    console.log(3);
    resolve();
  }).then(function() {
    console.log(4);
  });
});
new Promise(function(resolve) {
  console.log(5);
  resolve();
}).then(function() {
  console.log(6);
});
setTimeout(function() {
  console.log(7);
  new Promise(function(resolve) {
    console.log(8);
    resolve();
  }).then(function() {
    console.log(9);
  });
});
console.log(10);

正确结果应该是1、5、10、6、2、3、4、7、8、9,各位同胞蒙对了吗(手动狗头)。

  • 从上看下来同步任务应该是1、5、19,这一点没有异议,所以直接打印1、5、10;
  • 再看宏任务有2、7。微任务有6。此时注意,2和7中又有新的同步任务和微任务,此时我们应该吧整个宏任务2单独看作一个块级作用域。我们单独把宏任务2拿出来看:此时同步任务是2、3,微任务是4。同理宏任务7中同步任务是7、8,微任务是9。
  • 此时按顺序就应该是先同步任务1、5、10。然后微任务9,再宏任务2,宏任务中按顺序执行就是2、3、4,再宏任务7,按顺序执行7、8、9。
  • 所以最终结果就是同步任务1、5、10、微任务6、宏任务2、3、4,宏任务7、8、9。

具体应用

  1. 异步编程方式
    常见的异步处理方式:回调函数、事件监听、Promise对象和async/await等
  2. 动画
    Window:requestAnimationFrame() 方法:该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。在JavaScript中通过requestAnimationFrame函数可以动态控制动画的帧率,此时每一次重绘都会被添加到任务队列中,并且在下一次的重绘之前执行。

怎么理解闭包这个定义的,在平时工作中有用到闭包的使用吗,举个例子。

  闭包可以简单概括为定义在一个函数内部的函数”。它由要执行的代码块和为其提供的作用域组成。

具体应用

  1. 封装私有方法

  使用闭包可以将变量保存在函数内部,避免被全局变量污染,下面的例子是一个简单的计数器,闭包内部的变量nums不会被外部变量所干扰。

let getData = (function () {
  let nums = 0;
  return {
    increment() {
      nums++;
    },
    decrement() {
      nums--;
    },
    value() {
      return nums;
    },
  };
})();
let num = getData;
num1.increment();
num1.increment();
console.log(num.value()); // 2
  1. 实现模块化

可以把函数和方法封装,通过暴露一些函数方法和变量来组织代码使各个代码块变得像一个个独立的黑盒一样,也避免了变量名污染(毕竟当一个页面有很多差不多意思的变量的时候起名真的很tmd头疼)更容易被维护和管理。

  1. 实现异步编程避免回调地狱

  2. 缓存数据做首次判断

let cacheData = (function() {
  let arr = [];
  return function(val) {
    if (!arr.includes(val)) {
      arr.push(val);
      //执行代码块
      console.log("新增");
    } else {
      //执行代码块
      console.log("已经有该数据了");
    }
  };
})();
cacheData("1"); //新增
cacheData("22"); //新增
cacheData("1"); //已经有该数据了

举几个例子

  • 这是立即调用函数表达式闭包,每隔一秒依次打印1,2,3的例子:
for (var i = 0; i < 3; ++i) {
  (function(n) {
    setTimeout(() => {
      console.log(n + 1);
    }, n * 1000);
  })(i);
}
  • 而这个是每隔一秒依次打印4,4,4的例子:
for (var i = 0; i < 3; ++i) {
  setTimeout(function() {
    console.log(i + 1);
  }, i * 1000);
}

毕竟这个的本质是

setTimeout(function() {
  console.log(i+1);
}, 0);
i++;
setTimeout(function() {
  console.log(i+1);
}, 1000);
i++;
setTimeout(function() {
  console.log(i+1);
}, 2000);
i++;

根据第一个问题中事件循环的讲解大家得知,它的运行顺序是酱紫的:

i++;
i++;
i++;
//此时i==3
setTimeout(function() {
  console.log(i+1);
}, 0);
setTimeout(function() {
  console.log(i+1);
}, 1000);
setTimeout(function() {
  console.log(i+1);
}, 2000);

注意事项

  闭包当然不是万能的也不是没有缺点的,滥用闭包也会导致一些问题,主要就是 内存泄漏&影响性能。类似于setTimeoutsetInterval,闭包会一直持有外部作用域中的变量和参数,也就是说闭包中的变量和参数的生命周期可以比产生闭包的函数更长。也因此不及时释放会导致性能问题。释放闭包把引用闭包的变量赋其他值即可,比如赋null值

能谈谈继承和原型链吗?

  JavaScript中,万物皆为对象。每个对象都有一个私有属性指向另一个名为原型的对象(prototype)。原型对象(prototype)也有自己的一个原型,层层向上直到一个对象的原型为null。null没有原型。顺着这一层层查找的链条就称为原型链。null是它的最后一环。它解释了为何一个对象会拥有定义在其他对象中的属性和方法。

  在聊继承之前,先简要概述一下构造函数、原型和实例的关系:每个构造函数都有一个原型对象(prototype),原型对象都包含一个指向构造函数的指针(prototype.constructor),而实例(instance)包含一个指向原型对象(prototype)的内部指针(proto)

  打个比方就是:构造函数就像工厂里的模具,流水线上用这个模具生产出的产品就是实例。

  举个例子:

//构造函数:Father
function Father() {
  this.name = "dad";
}

//原型对象:Father.prototype
Father.prototype.getFatherValue = function() {
  return this.name;
};

//实例:instance
let instance = new Father();

image.png

  由图可知:Father.prototype == instance.__proto__

  我们可以通过instanceof方法来判断是否实例和原型的关系。通过检测构造函数的原型对象(constructor.prototype)是否出现在实例的原型链上,如果是就返回true。因此像上面的例子中的构造函数Father和实例instance就存在instance instanceof Father == true,与此同时,由于万物皆为对象,所以还存在instance instanceof Object == true。所以也可以说instance是Father和Object任一个构造函数的实例。

继承

  这里只举两个比较常用的方式:组合继承寄生组合式继承

  • 组合继承
    使用原型链继承原型属性和方法,使用构造函数来继承实例属性。
    如下面例子所示,实例ab和hy实现了函数复用,也保证了属性的独立而并非共用,但是问题就在于它会两次调用父类构造函数(调用会被打印两次)。
function Parent(name) {
  console.log("调用");
  this.name = name;
  this.attr = ["blue", "black", "white"];
}

Parent.prototype.sayName = function() {
  return this.name;
};

function Child(name, age) {
  Parent.call(this, name);  // 第1次调用 Parent()
  this.age = age;
}

Child.prototype = new Parent();  // 第2次调用 Parent() 使得子类实例的原型对象指向父类实例对象
let ab = new Child("Ambition", 22);
ab.attr.push("red");
console.log(ab); // age: 22, attr: ['blue', 'black', 'red'], name: "Ambition"

let hy = new Child("Hyanilve", 18);
hy.attr.push("white");
console.log(hy); // age: 18, attr: ['blue', 'black', 'white'], name: "Hyanilve"
  • 寄生组合式继承
    寄生组合式继承就是为了降低调用父类构造函数的开销而出现的。核心思路就是不必为了指定子类型的原型而调用超类型的构造函数
    如下面例子所示,此时调用只会被打印一次,降低了调用父类构造函数的次数。步骤为三步:
  1. 创建超类型原型的一个副本
  2. 为创建的副本添加constructor属性,从而弥补因重写原型而失去的默认的constructor属性
  3. 新创建的对象(即副本)赋值给子类型的原型
function Parent(name) {
  console.log("调用");
  this.name = name;
  this.attr = ["blue", "black"];
}

function Child(name, age) {
  Parent.call(this, name);
  this.age = age;
}

function extend(subClass, superClass) {
  var prototype = Object(superClass.prototype); //第一步
  prototype.constructor = subClass; //第二步,如果没有这一步实例ab的ab.constructor会是Parent,而不是Child
  subClass.prototype = prototype; //第三步
}

extend(Child, Parent);
let ab = new Child("Ambition", 22);

注意事项

  • 当原型对象中包含引用类型值的原型时,该引用类型值会被所有实例共享;
    • 基本类型:Underfined、Null、Boolean、Number、String和Symbol,
    • 引用类型:Array、Object、Function
function Father() {
  this.ages = ["12", "13", "14"];
}
Father.prototype.colors = ["red", "blue"];
Father.prototype.name = "dad";
let instance1 = new Father();
let instance2 = new Father();
instance1.colors.push("black");
instance1.name = "mom";
instance1.ages.push("15");
console.log(instance1.colors, instance1.name, instance1.ages);
//['red', 'blue', 'black'] 'mom' ['12', '13', '14', '15']
console.log(instance2.colors, instance2.name, instance2.ages);
//['red', 'blue', 'black'] 'dad' ['12', '13', '14']

  this.age虽然也能保证不会被所有实例共享,但是每次new一个father的实例其实是重新生成一个属性ages,这样无疑加重了内存的消耗,而prototype.name则不会。

  更详细的可以直接看大佬路易斯的文章《JS原型链与继承别再被问倒了》JavaScript Guidebook中的讲解。