前端面试笔记

99 阅读3分钟

原型和原型链

  • 原型:每个函数都有一个prototype属性,这个属性指向调用该构造函数而创建的实例的原型。__proto__是每个对象都会有的属性,这个属性指向该对象的原型。每个原型都有一个constructor属性,指向该原型关联的构造函数。
  • 原型链:当我们访问对象的一个属性或方法时,会先在对象自身中寻找,如果没有找到则会去原型中寻找,如果在原型中还没有找到,则会去原型的原型中寻找,直到找到Object.prototype,Object.prototype没有原型,如果在Object.prototype中依然没有找到,则返回undefined。这样一条向上查找的路径就是原型链

作用域和作用域链

  • 作用域:
    • 定义:作⽤域就是变量与函数的可访问范围,即作⽤域控制着变量与函数的可⻅性和⽣命周期。
    • 分类:
      1. 全局作用域
      2. 函数作用域
      3. 块级作用域
    • 拓展(了解即可):
      1. 词法作用域(lexical scoping,也叫静态作用域):函数的作用域在函数定义的时候就决定了,JavaScript采用的就是词法作用域。
      2. 动态作用域:函数的作用域是在函数调用的时候才决定的,bash就是动态作用域
  • 作用域链:
    • 当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会去父级执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。这样由多个执行上下文的变量对象构成的链表就叫做作用域链。
    • 作⽤域链的作⽤是保证执⾏环境⾥有权访问的变量和函数是有序的,作⽤域链的变量只能向上访问,变量访问到 window 对象即被终⽌,作⽤域链向下访问变量是不被允许的。

闭包

  • 闭包指的是引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的
  • 用途:读取函数内部的变量并让这些变量始终保存在内存中
  • 优点:1.可以避免全局变量的污染;2.可以缓存变量
  • 缺点:因为闭包会保留它们包含函数的作用域,所以比其他函数更占用内存。过度使用闭包可能导致内存过度占用,甚至造成内存泄漏
  • 使用闭包的注意点:在退出函数之前,将不使用的局部变量全部删除

模拟实现call和apply

  • call() 方法使用一个指定的 this 值和单独给出的一个或多个参数来调用一个函数。
  • apply() 方法调用一个具有给定 this 值的函数,以及以一个数组(或一个类数组对象)的形式提供的参数。
  • call() 方法的语法和作用与 apply() 方法类似,只有一个区别,就是 call() 方法接受的是一个参数列表,而 apply() 方法接受的是一个包含多个参数的数组
Function.prototype.call2 = function (context) {
    context = context || window;
    context.fn = this;
    var args = Array.prototype.slice.call(arguments, 1);
    // var args = [];
    // for (var i = 1, len = arguments.length; i < len; i++) {
    //   args.push("arguments[" + i + "]");
    // }
    var res = context.fn(...args);
    // var res = eval("context.fn(" + args + ")");
    delete context.fn;
    return res;
};

Function.prototype.apply2 = function (context, arr) {
    context = context || window;
    context.fn = this;
    var result;
    if (!arr) {
      result = context.fn();
    } else {
      result = context.fn(...arr);
      // var args = [];
      // for (var i = 0, len = arr.length; i < len; i++) {
      //   args.push("arr[" + i + "]");
      // }
      // result = eval("context.fn(" + args + ")");
    }
    delete context.fn;
    return result;
};

模拟实现bind

  • bind() 方法创建一个新的函数,在 bind() 被调用时,这个新函数的 this 被指定为 bind() 的第一个参数,而其余参数将作为新函数的参数,供调用时使用。
Function.prototype.bind2 = function (context) {
        var self = this;
        // 获取bind2函数从第二个参数到最后一个参数
        var args = Array.prototype.slice.call(arguments, 1);
        var fNOP = function () {};
        var fBound = function () {
          // 这个时候的arguments是指bind返回的函数传入的参数
          var bindArgs = Array.prototype.slice.call(arguments);
          // 当作为构造函数时,this 指向实例,此时结果为 true,将绑定函数的 this 指向该实例,可以让实例获得来自绑定函数的值
          // 以上面的是 demo 为例,如果改成 `this instanceof fBound ? null : context`,实例只是一个空对象,将 null 改成 this ,实例会具有 habit 属性
          // 当作为普通函数时,this 指向 window,此时结果为 false,将绑定函数的 this 指向 context
          return self.apply(
            this instanceof fBound ? this : context,
            args.concat(bindArgs)
          );
        };
        // 修改返回函数的 prototype 为绑定函数的 prototype,实例就可以继承绑定函数的原型中的值
        fNOP.prototype = this.prototype;
        // 通过一个空函数来进行中转
        fBound.prototype = new fNOP();
        return fBound;
}

简单写法:

Function.prototype.bind2 = function (context, ...args) {
    var self = this;
    var fBound = function () {
      return self.apply(
        this instanceof self ? this : context,
        args.concat(...arguments)
      );
    };
    fBound.prototype = Object.create(this.prototype);
    return fBound;
};

模拟实现new

function objectFactory(constructor, ...args) {
    var obj = Object.create(constructor.prototype);
    var res = constructor.apply(obj, args);
    return typeof res === "object" ? res : obj;
}

类数组对象

  • 什么是类数组对象?
    • 拥有一个 length 属性和若干索引属性
    • 不具有数组所具有的的方法
  • 常见的类数组对象:
    • Arguments 对象
    • 一些 DOM 方法的返回值(例如document.getElementsByTagName()等)
  • 类数组转数组:
var arrayLike = {0: 'name', 1: 'age', 2: 'sex', length: 3 }
// 1. slice
Array.prototype.slice.call(arrayLike); // ["name", "age", "sex"] 
// 2. splice
Array.prototype.splice.call(arrayLike, 0); // ["name", "age", "sex"] 
// 3. ES6 Array.from
Array.from(arrayLike); // ["name", "age", "sex"] 
// 4. apply
Array.prototype.concat.apply([], arrayLike)

创建对象的多种方式及优缺点

1. 工厂模式

function createPerson(name) {
    let o = new Object();
    o.name = name;
    o.getName = function () {
      console.log(this.name);
    };
    return o;
}
let person1 = createPerson("jjc");
  • 缺点:对象无法识别,因为所有的实例都指向同一个原型

2. 构造函数模式

function Person(name) {
    this.name = name;
    this.getName = function () {
      console.log(this.name);
    };
}
let person1 = new Person("jjc");
  • 优点:实例可以识别为一个特定的类型
  • 缺点:每次创建实例时,每个方法都要被创建一次

构造函数模式优化

function Person(name) {
    this.name = name;
    this.getName = getName;
}
function getName() {
    console.log(this.name);
}
let person1 = new Person("jjc");
  • 优点:解决了相同逻辑的函数重复定义的问题
  • 缺点:全局作用域被搞乱了

3. 原型模式

function Person() {}
Person.prototype.name = "jjc";
Person.prototype.getName = function () {
    console.log(this.name);
};
let person1 = new Person();
  • 优点:方法不会重新创建
  • 缺点:1. 所有的属性和方法都共享 2. 不能初始化参数

继承的多种方式及优缺点

1. 原型链继承

function Parent() {
    this.name = "jjc";
}
Parent.prototype.getName = function () {
    console.log(this.name);
};
function Child() {}
Child.prototype = new Parent();
let child1 = new Child();
child1.getName();
  • 缺点:
      1. 引用类型的属性被所有实例共享,举个例子:
      function Parent() {
        this.names = ["kevin", "daisy"];
      }
      function Child() {}
      Child.prototype = new Parent();
      let child1 = new Child();
      child1.names.push("yasuo");
      console.log(child1.names); // ["kevin", "daisy", "yasuo"]
      let child2 = new Child();
      console.log(child2.names); // ["kevin", "daisy", "yasuo"]
      
      1. 在创建 Child 的实例时,不能向 Parent 传参

2. 借用构造函数(经典继承)

function Parent() {
    this.names = ["kevin", "daisy"];
}
function Child() {
    Parent.call(this);
}
let child1 = new Child();
child1.names.push("yayu");
console.log(child1.names); // ["kevin", "daisy", "yayu"]
let child2 = new Child();
console.log(child2.names); // ["kevin", "daisy"]
  • 优点:
      1. 避免了引用类型的属性被所有实例共享
      1. 可以在 Child 中向 Parent 传参,举个例子:
      function Parent(name) {
        this.name = name;
      }
      function Child(name) {
        Parent.call(this, name);
      }
      let child1 = new Child("kevin");
      console.log(child1.name); // kevin
      let child2 = new Child("daisy");
      console.log(child2.name); // daisy
      
  • 缺点:方法都在构造函数中定义,每次创建实例都会创建一遍方法

3. 组合继承

原型链继承和经典继承双剑合璧

function Parent(name) {
    this.name = name;
    this.colors = ["red", "blue", "green"];
}

Parent.prototype.getName = function () {
    console.log(this.name);
};

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

Child.prototype = new Parent();

var child1 = new Child("kevin", "18");

child1.colors.push("black");

child1.getName(); // kevin
console.log(child1.age); // 18
console.log(child1.colors); // ["red", "blue", "green", "black"]

var child2 = new Child("daisy", "20");

child2.getName(); // daisy
console.log(child2.age); // 20
console.log(child2.colors); // ["red", "blue", "green"]
  • 优点:融合了原型链继承和借用构造函数继承的优点,是 JavaScript 中最常用的继承模式

4. 原型式继承

function createObj(o) {
    function F(){}
    F.prototype = o;
    return new F();
}

就是 Object.create 的模拟实现,将传入的对象作为创建的对象的原型

  • 缺点:包含引用类型的属性值始终都会共享相应的值,这点跟原型链继承一样
let person = {
    name: "kevin",
    friends: ["daisy", "kelly"],
};

let person1 = createObj(person);
let person2 = createObj(person);

person1.name = "person1";
console.log(person2.name); // kevin

person1.firends.push("taylor");
console.log(person2.friends); // ["daisy", "kelly", "taylor"]

注意:修改person1.name的值,person2.name的值并未发生改变,并不是因为person1person2有独立的 name 值,而是因为person1.name = 'person1',给person1添加了 name 值,并非修改了原型上的 name 值

5. 寄生式继承

创建一个仅用于封装继承过程的函数,该函数在内部以某种形式来做增强对象,最后返回对象

function createObj(o) {
    let clone = object.create(o);
    clone.sayName = function () {
      console.log("hi");
    };
    return clone;
}

缺点:跟借用构造函数模式一样,每次创建对象都会创建一遍方法

6. 寄生式组合继承

为了方便阅读,在这里贴一下组合继承的代码

function Parent(name) {
    this.name = name;
    this.colors = ["red", "blue", "green"];
}

Parent.prototype.getName = function () {
    console.log(this.name);
};

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

Child.prototype = new Parent();

let child1 = new Child("kevin", "18");

console.log(child1);

组合继承最大的缺点是会调用两次父构造函数,一次是设置子类型实例的原型的时候:

Child.prototype = new Parent();

一次在创建子类型实例的时候:

let child1 = new Child('kevin', '18');

那么我们该如何精益求精,避免这一次重复调用呢?

如果我们不使用 Child.prototype = new Parent() ,而是间接的让 Child.prototype 访问到 Parent.prototype 呢?

function Parent(name) {
    this.name = name;
    this.colors = ["red", "blue", "green"];
}

Parent.prototype.getName = function () {
    console.log(this.name);
};

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

function inheritPrototype(subType, superType) {
    let prototype = Object.create(superType.prototype); // 创建对象
    prototype.constructor = subType; // 增强对象
    subType.prototype = prototype; // 赋值对象
}

inheritPrototype(Child, Parent);

let child1 = new Child("kevin", "18");

console.log(child1);

这里只调用了一次 Parent 构造函数,避免了 Child.prototype 上不必要也用不到的属性。而且,原型链仍然保持不变,因此 instanceof 操作符和 isPrototypeOf() 方法正常有效。寄生式组合继承可以算是引用类型继承的最佳模式

参考资料:《JavaScript 深入系列》juejin.cn/column/7035…