(六)JS--续 面试题详解(2024)

473 阅读18分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第 6 天,点击查看活动详情

谈谈你对 this 的了解

对于this关键字,在OOP编程语言中我们再熟悉不过了,在 C++、C#、Java 中,或许你可以使用得顺手捏来,但是在JS中,用起来可是要混乱的多了喔!

this 指向问题  

我相信这个问题给大家带来过不少的困扰,可能当面试官问你,这个时候或那个时候,this指向哪,你都能正确说出,但是当面试官把问题包装下,不直接问你,很多人就摸不着头脑了

要判断this的指向,我们首先要明确一点:

this的指向不是在编写时确定的,而是在执行时确定的,且遵循了一定的规则

  • 默认情况下:this指向全局对象(浏览器中为windownode.js中为global),比如直接调用一个普通函数,它的this指向全局对象
  • 隐式绑定:函数被调用的位置存在上下文对象(obj.foo()),this指向被调用的位置(obj对象)
  • 显式绑定:使用callapplybind改变this指向,this指向第一个参数,无参数为全局对象
  • new:会调用一个构造函数,创建新对象,新对象的this会被重新绑定到新对象上(实例化对象上)
  • 箭头函数:没有自己的this,以其所在上下文的this作为自己的this(不可改变)

如果多个规则同时出现,我们就要像判断CSS选择器优先级一样做优先级的判断了:

new(调用构造函数) >显示绑定(callapplybind) > 隐式绑定(obj.foo())>默认全局对象

如果你依然有些困惑,拿在送你一张图叭!

image-20220414201717719

或许这些你早就熟稔于心,咳咳,我也一样。但,只是你没有遇到像这样的面试官而已 ~

面试官追问 this

我们先举个例子看看

const a = {};
const foo = function () {
  console.log(this);
};
foo.bind().bind(a)(); // window
foo.bind(a).bind()(); // a

这么看不太好观察出来,我们换个形式

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

不难看出,不管我们bind多少次,foo中的this指向始终由第一次bind决定。

2、我们在html标签中做事件绑定的时候,绑定的事件中的this指向哪里?

同样做个简单举例

    <button id="btn" onclick="foo()"></button>
    <script>
        function foo() {
            console.log(this); // window
        }
    </script>

这里只是点击触发了一个函数,这个函数的this没有指定方向,所以默认指向全局对象(window

相信你一定用到过事件绑定同时传值的情况把,我们来看看这又会发生什么

    <button id="btn" onclick="foo(this)"></button>
    <script>
        function foo(arg) {
            console.log(this); // window
            console.log(arg); // button 节点
            console.log(arg.id); // btn
        }
    </script>

image-20220414201916201

打印出的this依然指向window(这个函数的指向当然还是默认的啦),但是,我们可以通过传进来的arg拿到onclick所在DOM节点。其实这里可以理解为this指向绑定的事件对象。

3、不错不错,那我再给你出道代码题,看看你能答对不

const a = function () {
  b();
};
const b = function () {
  console.log(this);
};
window.setTimeout("a()", 3000);

打印的this指向哪里?哈哈,千万不要被吓到了,尽管b被嵌套了几层,但b始终是b,整个过程只是函数之间的普通调用与执行,并不存在this方向的改变,所以这个this依然还是b的,也即普通函数的this指向全局对象(window

另外,定时器函数和立即执行函数,this都是指向window哦!

4、在写函数部分的时候,遇到了一个 this 的问题, 回到这里我们一起来看看

let name = "cc"; // 定义一个作用域内的变量
function foo() {
  this.name = "520";
  setTimeout(function () {
    console.log(this.name);
  }, 3000); // 回调函数直接调用函数
}
new foo(); // cc

function fun() {
  this.name = "huohuo";
  setTimeout(() => console.log(this.name), 3000); // 回调执行箭头函数
}
new fun(); // huohuo

我们发现foo中打印的是cc而不是520,说明回调函数中的this并不是指向函数体内的name,而是全局中的name。而fun中,打印的是函数体内的name。这是因为,箭头函数中的this会保留定义该函数(回调函数)时的上下文(fun函数的内部作用域)。

针对以上这些this问题,首先,我们要清晰上面提到的 5 个规则和优先级排列,并时刻关注是否在哪有发生this 指向的改变,这样就不会出错啦!

刚刚我们一直都在说this指向的改变,那我们主要有哪些方法呢?

改变 this 指向

主要通过三个API来实现,callapplybind。它们都可以改变函数内部的this指向。

面试时一般都会问你,它们的区别是什么?

  • callapply都会调用原函数,bind不会
  • callbind都是以逗号的形式传递参数,apply除第一个参数外的值都包含在一个数组里

我们如何选择使用呢?

  • call :想改变this指向,又想调用原函数。(如继承)
  • apply:跟数组有关系
  • bind只想改变  this `指向

手写 call/apply/bind

这里主要参考了冴羽大佬的文章分享,建议大家点进去阅读完后再回来看(中间加了一点自己的改改进跟理解)。重新研究大佬的分析思路,仍然受益匪浅,感谢!

  1. 分析该函数内部做了什么(功能列举)
  2. 传入函数的参数在函数内部会做哪些处理(传参)
  3. 函数内部语句的执行情况是否会受哪些限制(分情况讨论)

call:使用一个指定的 this 值和若干个指定的参数值的前提下调用某个函数或方法。

call函数内部做了什么?

  • 创建一个函数并将函数设为对象的属性
  • 指定 this 到函数
  • 传入给定参数执⾏函数
  • 删除这个函数
  • 如果不传入参数,默认指向为 window
  • 返回值
Function.prototype.myCall = function (context) {
  // 如果 context 为 null/undefined,context 指向 window(或 global)
  context = Object(context) || window; // 装箱操作将原始类型 context 包装成对象
  context.fn = this; // 通过 this 获取到调用 call 的函数,并给 context 添加一个属性 fn
  let args = [];
  // 将 arguments 对象(类数组)转为数组
  for (let i = 1, len = arguments.length; i < len; i++) {
    args.push(arguments[i]); // 取出第二个到最后一个参数
  }
  // 其实上面这一步也可以用 let args = [...arguments].slice(1);
  let result = context.fn(...args); // 将参数传入调用函数执行
  delete context.fn; // 删除这个中间函数
  return result; // 返回调用函数后的返回值
};

apply:使用一个指定的 this 值和一个数组的前提下调用某个函数或方法。

call类似,但是要注意传入的第二个参数是个数组,处理也就更简单了

Function.prototype.myApply = function (context, arr) {
  // 如果 context 为 null/undefined,context 指向 window(或 global)
  context = Object(context) || window; // 装箱操作将原始类型 context 包装成对象
  context.fn = this; // 通过 this 获取到调用 call 的函数,并给 context 添加一个属性 fn
  let result;
  if (!arr) {
    result = context.fn; // 没有传数组参数的情况下
  } else {
    result = context.fn(...arr); // 将参数传入调用函数执行
  }
  delete context.fn; // 删除这个中间函数
  return result; // 返回调用函数后的返回值
};

bind:bind  方法会创建一个新函数。当这个新函数被调用时,bind  的第一个参数将作为它运行时的 this,之后的一序列参数将会在传递的实参前传入作为它的参数

bind函数内部做了什么?

  • 返回⼀个函数,绑定 this,
  • 参数可以在bind的时候传,还可以在执行返回的函数的时候传其他参数
  • bind返回的函数可以作为构造函数使用,bind时指定的this值会失效,但传入的参数依然生效(修改函数原型,构造实例关系)
Function.prototype.myBind = function (context) {
  if (typeof this !== "function") {
    // 如果调用的 bind 不是函数,就报错
    throw new TypeError(
      "Function.prototype.bind - what is trying to be bound is not callable"
    );
  }
  let args = Array.prototype.slice.call(arguments, 1), // 获取 bind 函数从第二个参数到最后一个参数
    that = this,
    fNOP = function () {}, // 使用一个空函数,帮助修改原型
    fBound = function () {
      // 这个时候的 arguments 是指 bind 返回的函数传入的参数
      let bindArgs = Array.prototype.slice.call(arguments);
      // this instanceof fBound === true 时,说明返回的 fBound 被当做 new 的构造函数调⽤
      // 获取调⽤时(fBound)的传参, bind 返回的函数⼊参往往是这么传递的
      return that.apply(
        this instanceof fBound ? this : context,
        args.concat(bindArgs)
      );
    };
  // 维护原型关系
  if (this.prototype) {
    // fNOP 函数的原型没有 prototype 属性
    fNOP.prototype = this.prototype;
  }
  // 使 fBound.prototype 是 fNOP 的实例
  // 因此,返回的 fBound 若作为 new 的构造函数,new ⽣成的新对象作为 this 传⼊ fBound, 新对象的**proto**就是 fNOP 的实例
  fBound.prototype = new fNOP();
  return fBound;
};

上面我们提到了很多数据,比如对象、类、函数,接下来做个关于它们的梳理。可能面试中不太会问到,但是我觉得非常有必要理解它们,帮助我们在实际敲代码的时候减少知道这样用但是不知道为什么的疑惑。

对象

什么是对象:一组属性(数据或函数)的无序集合,每个属性都由一个名称来标识

创建对象的方式

  • new Object():创建Object的一个实例对象,再给它提那家属性和方法
  • 对象字面量:花括号 { } 里面包含了表达这个具体事物(对象)的属性和方法(开发常用)
  • new + 构造函数:使用new来操作构造函数返回一个实例化对象
  • new + 类:使用new来操作Class类 返回一个实例化对象

对象属性(了解)

我们都会给对象做一些添加、修改、删除属性的操作,这些属性到底有什么样的规则呢?我觉得很有必要了解一下了。

1、对象属性类型(两种)

数据属性,有四个特性:

  • [Configurable]:属性是否可以delete、修改特性、改为访问器属性。默认true
  • [Enumberable]:属性是否可以for in循环,默认为true
  • [Writable]:属性值是否可以修改,默认为true
  • [Value]:属性实际的值,默认undefined

访问器属性(必须使用Object.defineProperty定义):

  • [Configurable]:属性是否可以delete、修改特性、改为访问器属性。默认true
  • [Enumberable]:属性是否可以for in循环,默认为true
  • [Get]:获取函数,读取属性时调用,默认undefined(非必须)
  • [Set]:设置函数,写入属性时调用,默认undefined(非必须)

more:读取属性的特性可以通过Object.getOwnPropertyDescriptor()

看起来远没有那么简单哇,所以JS又引入了几个新语法特性来帮助我们

2、对象语法的增强

属性值简写:属性名跟变量名相同

let person = {
  name: name,
};
// 简写
let person = {
  name,
};

可计算属性:动态属性赋值(运行时作为JS表达式而不是字符串求值)

const name = "cc";
let person = {
  [name]: "huohuo",
};
console.log(person.name); // huohuo

简写方法名:缩短方法声明

let person = {
  myFun: function () {
    console.log("huohuoit");
  },
};
// 简写
let person = {
  myFun() {
    console.log("huohuoit");
  },
};
// 与计算属性兼容
const myFun = "hisName ";
let person = {
  [myFun]() {
    console.log("huohuoit");
  },
};
person.hisName(); // huouhuoit

对象解构(解构赋值):在一条语句中使用嵌套数据实现声明多个变量,同时指向多个赋值操作

let person = {
name: 'huohuo',
age: 3
};
let {name: huohuoName, age: huohuoAge} = person;
console.log(huohuoName); // huohuo
console.log(huohuoAge); // 3

使用解构赋值时,可能会发生引用的值可能不存在的情况,比如上面的ageperson对象中不存在的时候,打印出的huohuoAge就是undefined

不过这个时候有个小技巧,我们可以在解构赋值的同时定义默认值

let {name, age = '18'} = person;

注意:nullundefined不能被解构哦!(具体原因在于解构内部的ToObject函数)

当然你也可以在函数中使用解构赋值

let person = {
name: 'huohuo',
age: 3
};
function fun (foo, {name: huohuoName, age: huohuoAge}, bar) {
console.log(huohuoName); // huohuo
console.log(huohuoAge); // 3
};

另外JS对象还有很多方便的静态方法,可以帮助我们快速开发,以及解决一些头疼的算法问题。它们都静静地躺在迷人的 MDN ,期待你的光顾

ES6中新增加了类的概念,可以使用class关键字声明一个类,以这个类来实例化对象。

  • 类抽象了对象的公共部分,它泛指某一大类(class
  • 对象特指某一个,可以通过new + 类 的形式实例化出的一个具体的对象

或许你会稳类到底是个什么?我们打印下就知道了

image-20220414202539349

类 就是一个(特殊的)函数

创建类的方式

//语法
class Name {
  // class body
}
//创建实例
let cc = new Name();

// 也可以立即实例化
let hh = new (class Foo {
  constructor(e) {
    console.log(e);
  }
})("huohuo");
// huohuo
console.log(hh); // Foo {}

关于类的一些注意点:

  • 类没有变量提升,所以必须先定义类,才能通过类实例化对象
  • 方法之间不能加逗号分隔,同时方法不需要添加function关键字
  • constructor:是类的构造函数,  用于传递参数,返回实例对象  ,通过new命令生成对象实例时 ,自动调用该方法。如果没有显示定义, 类内部会自动给我们创建一个constructor()
  • super关键字:用于访问和调用对象父类上的函数。(包括构造函数和普通函数)
  • 类里面的共有属性和方法一定要加this来使用,且子类在构造函数中使用super, 必须放到this前面 (必须先调用父类的构造函数,再使用子类继承得到的属性和方法) 为了方便理解,这里给出一个使用类实现的继承代码
// Class 类继承
class Father {
  constructor(surname) {
    // 创建类的构造函数(告诉解析器跟 new 搭配使用。非必须)
    this.surname = surname; // 类的属性声明用 this 即可
  }
  saySurname() {
    console.log("My surname is " + this.surname); // 访问类的属性要用 this
  }
}
class Son extends Father {
  // 这样子类就继承了父类的属性和方法
  constructor(surname, firstname) {
    super(surname); // 通过调用 super 来调用父类的构造函数,并初始化父类的属性
    this.firstname = firstname; // 初始化一个子类属性
  }
  sayFirstname() {
    console.log("My firstname is " + this.firstname);
  }
}
const cc = new Son("ai", "huohuo");
cc.saySurname(); // My surname is ai
cc.sayFirstname(); // My firstname is huohuo

函数

每个函数都是Funtion类型的实例,即函数就是对象。而函数名就是指向函数对象的指针,也就是JS中的变量,所以函数可以用在任何可以使用变量的地方(作为方法、作为参数、作为返回值等)。

note:函数名只是保存指针的变量,全局定义的函数(foo())和对象调用的函数(obj.foo())是同一个函数,但是它们执行的上下文不一样

函数的定义方式(4 种)

函数声明(JS引擎在执行代码前会先读取函数声明(函数声明提升),并在执行上下文中生成函数定义)

function foo() {}

函数表达式(只有到代码执行时才会在执行上下文中生成函数定义)

let foo = function () {};

箭头函数(简洁的语法非常适合嵌入函数的场景)

let foo = () => {};

Function 构造函数(Out)

let foo = new Function("a", "b", "return a + b");

注:不推荐使用Function构造函数的方式来定义函数,因为这段代码会被解释两次(一次是作为常规JS代码执行,一次是解释传给构造函数的字符串),会影响性能。

几个脑瓜疼的区别问题

构造函数与普通函数的区别? `

  • 唯一区别:调用方式不同,一个是通过new调用,一个直接调用(pass)

  • 类构造函数和普通构造函数的区别?

  • 主要区别:调用类构造函数必须使用new操作符(否则会报错)。而普通构造函数如果不用new调用,就会以全局的this作为内部对象。

普通函数与类的区别?

  • 函数声明会被提升而类声明不被提升。首先需要声明您的类然后访问它
  • 类具有特殊的关键字-构造函数(只能有一个),或者抛出错误。函数可以有多个名为constructor 的函数变量定义
  • 类具有特殊关键字super,可以调用它来调用父类构造函数
  • 类中可以使用关键字static定义函数

箭头函数与普通函数的区别?

  • 没有自己的this,且不可以改变this 的绑定。箭头函数中没有this绑定,必须通过查找作用域链来决定其值。 如果箭头函数被非箭头函数包含,则this绑定的是最近一层非箭头函数的this,否则this的值则被设置为全局对象
  • 不能用作构造函数
  • 没有原型对象prototype
  • 不能使用superargumentsnew.target
  • 形参名称不能重复

函数参数

JS 的函数不关心传入参数的个数或数据类型,因为函数参数在内部表现为一个数组。(函数的参数只是为了方便才写出来的)

比如可以拿到参数值的arguments类数组对象,第一个参数为arguments[0],第二个参数为arguments[1],同时可以通过arguments对象的length属性检查传入参数个数(arguments只受传入参数影响)。(严格模式下不能修改arguments对象)

注意:

  • 箭头函数中,参数不能使用arguments访问,但可以在包装函数中传给箭头函数。(JS中所有参数都是按值传递的)
  • 函数的默认参数只有在函数被调用时才会求值(因为此时计算默认值的函数才会执行)
  • 参数初始化顺序遵循暂时性死区原则(即前面定义的不能引用后面定义的)
  • 参数也存在于自己的作用域中,不能引用函数体的作用域

参数扩展与收集

场景 1(参数扩展):给函数传参时,有时可能不需要传一个数组,而是分别传入数组的元素

举例实现如下:

const arr = [1, 2, 3, 4];
function getSum() {
  let sum = 0;
  for (let i = 0; i < arguments.length; i++) {
    sum += arguments[i];
  }
  return sum;
}
getSum(arr); // "01,2,3,4"
getSum.apply(null, arr); // 10 正确输出

从上我们可以看到,getSum(arr)是无法正常输出的,因为我们不是传入一个数组,而是一个元素值,这个时候我们传入的是字符串‘1,2,3,4’,与sum(此时为 0)做加法运算,就得出‘01,2,3,4’。这样我们就没有实现将数组值一个一个元素地传进去。

0 + 1,2,3,4 = 01,2,3,4

所以我们可以想到用apply来实现这个操作(使用一个指定的this值和一个数组的前提下调用某个函数或方法。),apply内部会依次把数组元素传入getSum并执行返回最后的值

0 + 1 = 1 // sum
1 + 2 = 3 // sum
3 + 3 = 6 // sum
6 + 4 = 10 // sum

幸运的是,在ES6中,扩展运算符帮助我们简化了这个操作,它可以作为参数传入,将可迭代对象(数组arr)拆分,并将迭代返回的每个值单独传入(getSum执行)

getSum(...arr); // 10

// 甚至可以这样(因为数组长度已知,不影响前后传参)
getSum(-1, ...arr); //9
getSum(...arr, 5); // 15
getSum(-1, ...arr, 5); // 14
getSum(...arr, ...[5, 6, 7]); // 28

注:函数中的arguments并不关心你是否使用了扩展运算符,它只忠心耿耿地关注调用函数时传入的参数(可以理解为两者互不影响)

arguments都能这样利用扩展运算符了,那普通函数和箭头函数中一样可以使用啦!

场景 2(参数收集):定义函数时,使用扩展运算符把不同的参数组合(收集)为一个数传入函数中

举例:

function getSum(...arr) {
  // 按顺序累加 arr 中的值。且初始值总和为 0
  return arr.reduce((x, y) => x + y, 0);
}
getSum(1, 2, 3, 4); // "10"

注意:收集参数时,该方式是收集命名参数以外的所有参数(没有则为空数组),所以只能把它作为最后一个参数哦

function getSum (real, ...arr) {}

let getSum = (prev, ...arr, next) => {}

上面的分析主要是为了帮助我们加快实际开发的效率及对ES6的使用与理解,相信你一定会爱上它的。不信?那你是否记得数组去重呢?

const newArr = [...new Set(arr)]; // 嗯?快不快?

函数内部的特殊对象(了解)

arguments

它是一个类数组对象,包含调用函数时传入的所以参数。且这个对象只有以function关键字定义函数时才会有。

arguments.callee(严格模式下访问会报错) :指向arguments对象所在函数的指针

怎么理解callee 呢?比如我们有一个计算阶乘的递归函数

function factorial (num) {
  if(num <= 1) {
    return 1;
  } else {
    return num \* factorial(num -1);
  }
}

我们可以注意到,这个函数的正确执行必须保证函数名统一(函数名跟函数逻辑都是factorial),这就是高耦合的一种体现。那么如何优化呢?

function factorial (num) {
  if(num <= 1) {
    return 1;
  } else {
    return num \* arguments.callee(num -1);
  }
}

new target()

ES6新增,用于检测函数是否使用new关键字调用。正常调用则返回undefiendnew调用返回被调用的构造函数。

未完待续 ~~~ ​