JS 面试题

133 阅读28分钟

JS、ES6

ES6,全称 ECMAScript 6.0,是 JavaScript 语言的下一代标准,于 2015 年 6 月正式发布。它为 JavaScript 带来了许多新的语法特性和功能,使得 JavaScript 语言可以用来编写复杂的大型应用程序,成为企业级开发语言。

ES6 的一些主要新语法特性包括:

  • 新的原始类型和变量声明:let 和 const 关键字用于声明块级作用域的变量和常量。
  • 箭头函数:使用 => 符号定义函数,可以更简洁地编写函数。
  • 模板字符串:使用反引号(`)定义字符串,可以在字符串中嵌入表达式。
  • 解构赋值:允许从数组或对象中提取值并赋值给变量。
  • 类:使用 class 关键字定义类,支持继承、构造函数、静态方法等面向对象编程特性。
  • 模块化:使用 import 和 export 关键字导入和导出模块。
  • Promise:用于处理异步操作的结果。
  • 迭代器生成器:支持迭代器和生成器,可以更方便地遍历数据结构。
  • Set 和 Map 数据结构:新增了 Set 和 Map 数据结构,用于存储唯一值和键值对。

迭代器和生成器的简单示例:

// 简单的迭代器示例,它实现了一个next()方法,用于遍历数组中的元素:
function makeIterator(array) {
    let nextIndex = 0;
    return {
        next: function() {
            return nextIndex < array.length ?
                {value: array[nextIndex++], done: false} :
                {done: true};
        }
    };
}

let it = makeIterator(['a', 'b', 'c']);
console.log(it.next().value); // 'a'
console.log(it.next().value); // 'b'
console.log(it.next().value); // 'c'
console.log(it.next().done);  // true

// 简单的生成器示例,它使用yield表达式来暂停函数执行并返回一个值:
function* idMaker() {
    let index = 0;
    while (true)
        yield index++;
}

let gen = idMaker();
console.log(gen.next().value); // 0
console.log(gen.next().value); // 1
console.log(gen.next().value); // 2

数据类型

分为两大类:包括值类型(基本对象类型)和引用类型(复杂对象类型)
值类型:字符串(String)、数字(Number)、布尔(Boolean)、空(Null)、未定义(Undefined)、Symbol和BigInt。其中,Symbol是ES6引入的一种新的原始数据类型,表示独一无二的值。
引用数据类型:对象(Object)、数组(Array)和函数(Function),还有两个特殊的对象:正则(RegExp)和日期(Date)。

示例:

// 值类型
let myString = 'Hello, World!'; // 字符串
let myNumber = 3.14; // 数字
let myBoolean = true; // 布尔
let myNull = null; // 空
let myUndefined = undefined; // 未定义
let mySymbol = Symbol(); // Symbol
let myBigInt = 123n; // BigInt

// 引用数据类型
let myObject = {name: '小明', age: 25}; // 对象
let myArray = [1, 2, 3]; // 数组
let myFunction = function() {console.log('Hello, World!')}; // 函数
let myRegExp = /hello/i; // 正则表达式
let myDate = new Date(); // 日期

我相信大家很少见过 symbol 和 Bigint 吧,如果面试问到估计只有少部分大佬能聊出来(反正我不行)。

先详细解释一下吧:

  1. Symbol是ES6中新增的一种基本数据类型,它表示独一无二的值。每个通过Symbol()生成的值都是唯一的。Symbol可以用作对象的唯一属性名,这样其他人就不会改写或覆盖你设置的属性值。
  2. BigInt是ES10中新增的一种基本数据类型,它提供了一种方法来表示大于2^53-1的整数。BigInt可以表示任意大的整数。

示例:

let mySymbol = Symbol('mySymbol');
let obj = {};
obj[mySymbol] = 'Hello, World!';
console.log(obj[mySymbol]); // 输出'Hello, World!'

let myBigInt = 1234567890123456789012345678901234567890n;
console.log(myBigInt * 2n); // 输出2469135780246913578024691357802469135780n

好处:

Symbol的好处在于它能够创建独一无二的值,这样就可以避免属性名冲突的问题。例如,当你想要给一个对象添加一个新属性时,你可以使用Symbol来创建一个唯一的属性名,这样就不用担心这个属性名会与对象中已有的属性名冲突。

BigInt的好处在于它能够表示任意大的整数,这样就可以避免整数溢出的问题。例如,在对大整数进行数学运算时,以任意精度表示整数的能力尤为重要。有了BigInt,整数溢出将不再是一个问题。此外,你可以安全地使用高精度时间戳、大整数ID等,而不必使用任何变通方法。

数据类型常用检测方法

1. typeof:typeof操作符可以返回一个字符串,表示未经计算的操作数的类型。优点在于它简单易用,可以快速检测基本数据类型。但它也有一些缺点,例如它无法区分Object、Array和Null,因为都会返回"object"。
示例:

console.log(typeof 'Hello, World!'); // 输出'string'
console.log(typeof 3.14); // 输出'number'
console.log(typeof true); // 输出'boolean'
console.log(typeof undefined); // 输出'undefined'
console.log(typeof null); // 输出'object'
console.log(typeof Symbol()); // 输出'symbol'
console.log(typeof 123n); // 输出'bigint'
console.log(typeof {}); // 输出'object'
console.log(typeof []); // 输出'object'
console.log(typeof function() {}); // 输出'function'

2. instanceof:instanceof操作符主要用于检测引用数据类型,它用于检测构造函数的prototype属性是否出现在某个实例对象的原型链上。因此,它并不适用于检测所有数据类型。优点在于它可以检测引用数据类型,判断一个实例是否属于某个类。但它也有一些缺点,例如它无法检测基本数据类型。
示例:

console.log([] instanceof Array); // 输出true
console.log({} instanceof Object); // 输出true
console.log(function() {} instanceof Function); // 输出true

3. Object.prototype.toString.call() :这种方法可以用来检测对象的类型。优点在于它可以准确地检测所有数据类型,包括基本数据类型和引用数据类型。但它也有一些缺点,例如使用起来比较麻烦,需要调用Object.prototype.toString.call()方法,并传入要检测的值作为参数。
示例:

console.log(Object.prototype.toString.call('Hello, World!')); // 输出'[object String]'
console.log(Object.prototype.toString.call(3.14)); // 输出'[object Number]'
console.log(Object.prototype.toString.call(true)); // 输出'[object Boolean]'
console.log(Object.prototype.toString.call(undefined)); // 输出'[object Undefined]'
console.log(Object.prototype.toString.call(null)); // 输出'[object Null]'
console.log(Object.prototype.toString.call(Symbol())); // 输出'[object Symbol]'
console.log(Object.prototype.toString.call(123n)); // 输出'[object BigInt]'
console.log(Object.prototype.toString.call({})); // 输出'[object Object]'
console.log(Object.prototype.toString.call([])); // 输出'[object Array]'
console.log(Object.prototype.toString.call(function() {})); // 输出'[object Function]'

数据类型转换方法

在JavaScript中,数据类型转换分为两种:隐式类型转换和显式类型转换。
隐式类型转换:指在运算过程中,JavaScript会自动将一种数据类型转换为另一种数据类型,以便进行运算。例如,在字符串和数字相加时,数字会被自动转换为字符串,然后进行字符串拼接。
示例:

let x = '3' + 4; // x的值为'34'
let y = '3' - 4; // y的值为-1

显式类型转换:指通过调用特定的函数或方法来手动进行数据类型转换。例如,可以使用Number()函数将字符串转换为数字,或使用String()函数将数字转换为字符串。
示例:

// 使用Number()函数将字符串转换为整数
let a = Number('3') + 4; // a的值为7

// 使用String()函数将整数转换为字符串
let b = String(3) + 4; // b的值为'34'

// 使用一元加号运算符将字符串转换为数字
let x = +'3'; // x的值为3

// 使用一元减号运算符将字符串转换为数字
let y = -'3'; // y的值为-3

// 使用parseInt()函数将字符串转换为整数
let a = parseInt('3.14'); // a的值为3

// 使用parseFloat()函数将字符串转换为浮点数
let b = parseFloat('3.14'); // b的值为3.14

// 使用toString()方法将数字转换为字符串
let c = (3).toString(); // c的值为'3'

深拷贝和浅拷贝

深拷贝和浅拷贝是针对引用数据类型(如Object和Array)的概念。浅拷贝只复制指向某个对象的指针,而不复制对象本身,新旧对象还是共享同一块内存。但深拷贝会另外创造一个一模一样的对象,新对象跟原对象不共享内存,修改新对象不会改到原对象。

当我们把一个对象赋值给一个新的变量时,赋的其实是该对象的在栈中的地址,而不是堆中的数据。也就是两个对象指向的是同一个存储空间,无论哪个对象发生改变,其实都是改变的存储空间的内容,因此,两个对象是联动的。浅拷贝是按位拷贝对象,它会创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。如果属性是基本类型,拷贝的就是基本类型的值;如果属性是内存地址(引用类型),拷贝的就是内存地址 ,因此如果其中一个对象改变了这个地址,就会影响到另一个对象。

实现方法
  1. 浅拷贝可以通过多种方法实现。例如,可以使用Object.assign()方法进行浅拷贝,也可以使用扩展运算符...进行浅拷贝。此外,还可以使用Array.prototype.concat()和Array.prototype.slice()方法对数组进行浅拷贝。
    示例:
// 使用Object.assign()进行浅拷贝:
let obj1 = { a: 1, b: { c: 2 } };
let obj2 = Object.assign({}, obj1);
obj1.b.c = 3;
console.log(obj2.b.c); // 输出3,因为obj2.b和obj1.b指向同一个对象

// 使用扩展运算符...进行浅拷贝:
let obj1 = { a: 1, b: { c: 2 } };
let obj2 = {...obj1};
obj1.b.c = 3;
console.log(obj2.b.c); // 输出3,因为obj2.b和obj1.b指向同一个对象

// 使用Array.prototype.concat()对数组进行浅拷贝:
let arr1 = [1, 2, { a: 3 }];
let arr2 = arr1.concat();
arr1[2].a = 4;
console.log(arr2[2].a); // 输出4,因为arr2[2]和arr1[2]指向同一个对象

// 使用Array.prototype.slice()对数组进行浅拷贝:
let arr1 = [1, 2, { a: 3 }];
let arr2 = arr1.slice();
arr1[2].a = 4;
console.log(arr2[2].a); // 输出4,因为arr2[2]和arr1[2]指向同一个对象

2. 深拷贝可以通过多种方法实现。例如,可以使用递归的方式实现深拷贝,也可以通过JSON对象实现深拷贝,即先使用JSON.stringify()将对象转换为JSON字符串,再使用JSON.parse()将字符串解析成新的对象。
示例:

// 使用递归实现深拷贝:
function deepClone(obj) {
    if (typeof obj !== 'object' || obj === null) {
        return obj;
    }
    let result = Array.isArray(obj) ? [] : {};
    for (let key in obj) {
        if (obj.hasOwnProperty(key)) {
            result[key] = deepClone(obj[key]);
        }
    }
    return result;
}
let obj1 = { a: 1, b: { c: 2 } };
let obj2 = deepClone(obj1);
obj1.b.c = 3;
console.log(obj2.b.c); // 输出2,因为obj2是obj1的深拷贝,它们之间没有引用关系

// 使用JSON.stringify()和JSON.parse()实现深拷贝:
let obj1 = { a: 1, b: { c: 2 } };
let obj2 = JSON.parse(JSON.stringify(obj1));
obj1.b.c = 3;
console.log(obj2.b.c); // 输出2,因为obj2是obj1的深拷贝,它们之间没有引用关系

此外,还可以通过jQuery的extend方法实现深浅拷贝: extend()方法的第一个参数是一个布尔值,用来指定是否进行深拷贝。如果该参数为true,则进行深拷贝;否则进行浅拷贝。
示例:

let obj1 = { a: 1, b: { c: 2 } };
let obj2 = jQuery.extend(true, {}, obj1);
obj1.b.c = 3;
console.log(obj2.b.c); // 输出2,因为obj2是obj1的深拷贝,它们之间没有引用关系

作用域链和闭包

作用域链

作用域链是指在JavaScript中,变量的查找机制。当代码在一个环境中执行时,会创建变量对象的一个作用域链(scope chain)。这个作用域链保证了对执行环境有权访问的所有变量和函数的有序访问。

作用域链的前端是当前执行环境的变量对象,如果这个执行环境是函数,则将其活动对象作为变量对象。活动对象在最开始时只包含一个变量,即arguments对象(这个对象在全局环境中是不存在的)。作用域链中的下一个变量对象来自包含(外部)环境,再下一个变量对象则来自下一个包含环境。这样一直延续到全局执行环境;全局执行环境的变量对象始终都是作用域链中的最后一个对象。

其实作用域链的理解比较简单,就是当查找变量时,会从作用域链的前端开始,逐级向后查找,直到找到为止。如果在整个作用域链中都没有找到该变量,则该变量未定义。
示例1(查找成功):

let x = 1;

function outer() {
  let y = 2;
  console.log(x + y);
}

outer(); // 输出 3

示例2(查找失败):

function outer() {
  let y = 2;
  console.log(x + y);
}

outer(); // 报错:ReferenceError: x is not defined

闭包

闭包是指一个函数能够访问其定义时的词法作用域,即使这个函数在其定义时的作用域之外执行。闭包可以让你从内部函数访问外部函数作用域。

在JavaScript中,函数在创建时会保存一个指向其定义时的词法作用域的引用。当这个函数被调用时,它会使用这个引用来确定其外部变量的值。这就是闭包。

优点:

  1. 封装:闭包可以用来封装私有变量,防止外部访问。
  2. 记忆:闭包可以用来记忆函数的状态,例如计数器。
  3. 柯里化:闭包可以用来实现柯里化,即将一个多参数函数转换为一系列单参数函数。

缺点:

  1. 内存占用:由于闭包会引用外部函数的变量,因此它会占用更多的内存。如果不需要使用闭包,应该及时释放内存。
  2. 性能问题:由于闭包需要在作用域链中查找变量,因此它的性能可能不如直接访问全局变量。

避免闭包导致的内存泄漏:

  • 及时释放不再使用的闭包,以便垃圾回收器可以回收它们占用的内存。
  • 避免在闭包中捕获不必要的变量,尽量只捕获必要的变量。
  • 注意循环引用,避免在闭包中捕获会导致循环引用的变量。

我们常常使用的定时器、事件处理、Ajax请求等常用于异步操作用了回调函数,但是回调函数其实是可以使用闭包也可以不使用闭包的,并不是说回调一定是在使用闭包。

回调示例1(使用闭包):

let x = 1;

function doSomething(callback) {
  // 执行一些操作
  let result = x + 1;
  // 调用回调函数
  callback(result);
}

doSomething(function(result) {
  console.log(result); // 输出 2
});
<!-- 在这个例子中,我们定义了一个全局变量x和一个函数doSomething。
doSomething函数接受一个回调函数作为参数。
当我们调用doSomething时,它会执行一些操作,然后调用回调函数,并将结果作为参数传递给回调函数。 -->

回调示例2(不使用闭包):

function doSomething(callback) {
  // 执行一些操作
  let result = 1 + 1;
  // 调用回调函数
  callback(result);
}

doSomething(function(result) {
  console.log(result); // 输出 2
});
<!-- 在这个例子中,我们定义了一个函数doSomething,它接受一个回调函数作为参数。
当我们调用doSomething时,它会执行一些操作,然后调用回调函数,并将结果作为参数传递给回调函数。 -->
那么闭包中定义的变量怎么回收呢?

在JavaScript中,内存管理是自动进行的。当一个变量不再被引用时,它所占用的内存就会被垃圾回收器回收。
在闭包中定义的变量也是如此。当闭包不再被引用时,它所引用的外部变量也就不再被引用,因此它们所占用的内存就会被垃圾回收器回收。
所以有两种情况:

  • 第一是当全局变量作为闭包变量的时候,那么闭包变量就会因为上下文的存在(一直被引用)而保存到页面关闭。
  • 第二是当局部变量作为闭包变量的时候,其一是引用完毕立即回收(可以赋予null),其二是可以一直引用依然保存在内存中直到不再被引用则会回收。

第二种情况示例1(立即回收):

function fn() {
  let x = 1;
  return function() {
    console.log(x);
  }
}

for (let i = 0; i < 10; i++) {
  fn()(); // 输出10次1
}
<!-- 在这个例子中,fn是一个函数,它返回一个匿名函数。
当我们在循环中调用fn()时,它每次都会返回一个新的匿名函数,并立即执行这个匿名函数。
由于这些匿名函数在执行完毕后就不再被引用,因此它们所占用的内存就会被垃圾回收器回收。 -->

第二种情况示例2(等到不再引用则回收):

function fn() {
  let x = 1;
  return function() {
    console.log(x++);
  }
}

let closure = fn();
for (let i = 0; i < 10; i++) {
  closure(); // 输出 1,2,3,...,10
}

closure = null; // 释放对闭包的引用

<!-- 在这个例子中,fn是一个函数,它返回一个匿名函数。当我们调用fn()时,它返回inner函数,并将其赋值给closure变量。
当我们在循环中调用closure()时,它每次都会输出递增的值,即fn函数内部定义的变量x的值。
由于x在每次调用闭包时都会自增1,因此每次输出的都是递增的值。在循环结束后,我们将closure变量赋值为null,这样就释放了对闭包的引用。 -->
经典面试题

涉及for循环和闭包:

var data = [];
for (var i = 0; i < 3; i++) {
  data[i] = function () {
    console.log(i);
  };
}
data[0](); // 输出什么?
data[1](); // 输出什么?
data[2](); // 输出什么?

// 连续输出3个3

<!-- 原因:在这段代码中,i 是全局变量,共用一个作用域。当函数被执行的时候,此时的 i 已经变成了3,导致输出的结果都是3。 -->

如果预期输出1、2、3,使用闭包改善:

var data = [];
for (var i = 0; i < 3; i++) {
  (function (j) {
    data[j] = function () {
      console.log(j);
    };
  })(i);
}
data[0](); // 输出1
data[1](); // 输出2
data[2](); // 输出3

<!-- 原因:在这个例子中,我们使用了一个自执行函数和闭包来创建3个互不干扰的私有作用域。
这样,每次循环时都会创建一个新的闭包,并将当前的 i 值传递给闭包,使得每个闭包都有自己独立的 j 值。
因此,当我们调用 data[0]()、data[1]() 和 data[2]() 时,它们分别输出123。 -->

原型和原型链

原型(prototype)是一个对象,它是用来创建其他对象的模板。每个函数都有一个 prototype 属性,它指向该函数的原型对象。

原型链是由一系列原型对象组成的链条。每个对象都有一个原型对象与之关联,这个原型对象也是一个普通对象,它也有自己的原型对象,这样层层递进,就形成了一个链条,这个链条就是原型链。

原型链的作用是实现继承。当访问一个对象的属性时,如果该属性不存在于该对象中,则会沿着原型链向上查找,直到找到该属性或者到达原型链的顶端。

41c283c9cd4e67d1000fc8d30004f579.jpg

原型关系:  指的是对象与其原型对象之间的关系。每个对象都有一个内部属性 [[Prototype]],它指向该对象的原型对象。在 JavaScript 中,可以通过 proto 属性来访问这个内部属性。
示例:

// 假设我们有一个构造函数 Person 和一个实例对象 p:

function Person(name) {
  this.name = name;
}

Person.prototype.sayName = function() {
  console.log(this.name);
}

var p = new Person('Tom');

// 在这个例子中,p 的原型对象就是 Person.prototype。我们可以通过 p.__proto__ 来访问它:

console.log(p.__proto__ === Person.prototype); // true

ES6新语法特性:let && const

ES6之前创建变量用的是var,之后创建变量用的是let/const,当然也会用var,那么区别在哪呢?

var,let和const都是用来声明变量的,但它们之间有一些区别。var声明的变量属于函数作用域,而let和const声明的变量属于块级作用域。此外,var声明的变量存在变量提升现象,而let和const没有。在同一块级作用域中,let变量不能重新声明,而const常量不能修改。简单的来说就是,var定义全局变量且可以覆盖,let定义块级作用域变量且不能再一次进行声明({}),const定义不允许修改的块级作用域常量。

示例:

function exampleVar() {
  var x = 1;
  if (true) {
    var x = 2;
    console.log(x); // 输出2
  }
  console.log(x); // 输出2
}

function exampleLet() {
  let x = 1;
  if (true) {
    let x = 2;
    console.log(x); // 输出2
  }
  console.log(x); // 输出1
}

function exampleConst() {
  const x = 1;
  if (true) {
    const x = 2;
    console.log(x); // 输出2
  }
  console.log(x); // 输出1
}

解释:
在exampleVar函数中,由于var声明的变量属于函数作用域,所以在if语句块中重新声明的变量x会覆盖函数作用域中的变量x。
而在exampleLet和exampleConst函数中,由于let和const声明的变量属于块级作用域,所以在if语句块中声明的变量x不会影响到外部作用域中的变量x。

this指向问题

在JavaScript中,this关键字指向函数执行时的当前对象。this的指向取决于函数调用的方式,而不是函数定义的位置。

  • 在全局作用域中,this指向全局对象(在浏览器中是window对象,在Node.js中是global对象)。
  • 在函数调用中,如果函数不是作为对象的方法被调用,那么this指向全局对象。
  • 在作为对象方法调用时,this指向调用该方法的对象。
  • 在构造函数中,this指向新创建的对象。
  • 在事件处理程序中,this指向触发事件的元素。
    此外,可以使用call()、apply()和bind()方法显式地设置函数调用时的this值。
    示例:
// 1.在全局作用域中,this指向全局对象:
console.log(this === window); // 输出true(在浏览器中)

// 2.在函数调用中,如果函数不是作为对象的方法被调用,那么this指向全局对象:
function foo() {
    console.log(this === window); // 输出true(在浏览器中)
}
foo();

// 3.在作为对象方法调用时,this指向调用该方法的对象:
let obj = {
    myMethod: function() {
        console.log(this === obj); // 输出true
    }
};
obj.myMethod();

// 4.在构造函数中,this指向新创建的对象:
function MyConstructor() {
    this.myProperty = 'Hello World!';
    console.log(this instanceof MyConstructor); // 输出true
}
let myInstance = new MyConstructor();

// 5.在事件处理程序中,this指向触发事件的元素:
<button id="myButton">点击!</button>
<script>
    let button = document.getElementById('myButton');
    button.onclick = function() {
        console.log(this === button); // 输出true
    };
</script>

// 6.使用call()、apply()和bind()方法显式地设置函数调用时的this值:
function foo() {
    console.log(this);
}
let obj = { a: 1 };
foo.call(obj); // 输出{ a: 1 }
foo.apply(obj); // 输出{ a: 1 }
let bar = foo.bind(obj);
bar(); // 输出{ a: 1 }

此外还有一些特殊情况会影响this的指向问题:

  1. 在严格模式下,如果函数不是作为对象的方法被调用,那么this的值为undefined。
  2. 在DOM事件处理程序中,如果使用addEventListener()方法添加事件处理程序,那么事件处理程序中的this指向触发事件的元素。但是,如果使用attachEvent()方法(仅在旧版本的IE中可用),那么事件处理程序中的this指向全局对象。
  3. 在回调函数中,this的指向取决于回调函数被调用的方式。例如,在setTimeout()和setInterval()中,回调函数中的this指向全局对象。在数组方法(如forEach()、map()、filter()等)中,回调函数中的this指向全局对象,除非显式地设置了thisArg参数。
  4. 在箭头函数中,this的值取决于箭头函数定义时所在的上下文。箭头函数不会创建自己的this值,而是从外层作用域继承this值。
  5. 如果使用了ES6的类语法,那么类中的方法默认是在严格模式下执行的,因此类方法中的this指向取决于方法调用的方式。
    示例:
// 1.在严格模式下,函数调用中的this指向undefined:
'use strict';
function foo() {
    console.log(this);
}
foo(); // 输出undefined

// 2.在DOM事件处理程序中,使用addEventListener()方法添加事件处理程序,事件处理程序中的this指向触发事件的元素:
<button id="myButton">Click me!</button>
<script>
    let button = document.getElementById('myButton');
    button.addEventListener('click', function() {
        console.log(this); // 输出<button id="myButton">Click me!</button>
    });
</script>

// 3.在回调函数中,this的指向取决于回调函数被调用的方式:
// 在setTimeout()中,回调函数中的this指向全局对象
setTimeout(function() {
    console.log(this === window); // 输出true(在浏览器中)
}, 1000);

// 在数组方法中,回调函数中的this指向全局对象,除非显式地设置了thisArg参数
let arr = [1, 2, 3];
arr.forEach(function() {
    console.log(this === window); // 输出true(在浏览器中)
});
arr.forEach(function() {
    console.log(this === obj);
}, obj); // 输出true

// 4.在箭头函数中,this的值取决于箭头函数定义时所在的上下文:
let obj = {
    myMethod: function() {
        let arrowFunction = () => {
            console.log(this === obj); // 输出true
        };
        arrowFunction();
    }
};
obj.myMethod();

// 5.在类方法中,this指向取决于方法调用的方式:
class MyClass {
    myMethod() {
        console.log(this);
    }
}
let myInstance = new MyClass();
myInstance.myMethod(); // 输出MyClass实例
let myMethod = myInstance.myMethod;
myMethod(); // 输出undefined(在严格模式下)或全局对象(在非严格模式下)

EventLoop 事件循环

EventLoop 即 事件循环,是指浏览器或 Node 的一种解决 javaScript 单线程运行时不会阻塞的一种机制,也就是我们经常使用异步的原理。

JavaScript 运行时包含了一个待处理消息的消息队列。每一个消息都关联着一个用以处理这个消息的回调函数。在事件循环期间的某个时刻,运行时会从最先进入队列的消息开始处理队列中的消息。被处理的消息会被移出队列,并作为输入参数来调用与之关联的函数。

这个模型与其他语言中的模型截然不同,比如 C 和 Java。它永不阻塞,处理 I/O 通常通过事件和回调来执行,所以当一个应用正等待一个 IndexedDB 查询返回或者一个 XHR 请求返回时,它仍然可以处理其他事情,比如用户输入。

宏任务和微任务

在 JavaScript 引擎中,任务分为两种类型:微任务(microtask)和宏任务(macrotask)。微任务是指在当前任务执行结束后立即执行的任务,它可以看作是在当前任务的“尾巴”添加的任务。常见的微任务包括 Promise 回调和 process.nextTick。宏任务是指在下一轮事件循环中执行的任务。常见的宏任务包括 setTimeout、setInterval、setImmediate、requestAnimationFrame 等。

微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列。比如一个宏任务在执行过程中,产生了 100 个微任务,执行每个微任务的时间是 10 毫秒,那么执行这 100 个微任务的时间就是 1000 毫秒,也可以说这 100 个微任务让宏任务的执行时间延长了 1000 毫秒。

宏任务和微任务与事件循环有着密切的关系。在事件循环中,每个宏任务执行完后,都会检查微任务队列并执行队列中的所有微任务,然后再执行下一个宏任务。这个过程会一直重复,直到队列中没有消息为止。
img
示例:

console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('script end');

// 输出结果:
script start
script end
promise1
promise2
setTimeout

解释:首先,同步代码 console.log('script start') 和 console.log('script end') 被执行。然后,setTimeout 被添加到宏任务队列中。接着,Promise.resolve().then 中的回调被添加到微任务队列中。当同步代码执行完后,事件循环检查微任务队列并执行队列中的所有微任务,即 console.log('promise1') 和 console.log('promise2')。最后,事件循环执行下一个宏任务,即 setTimeout 中的回调。

setTimeout Promise Async/Await 的区别

  1. setTimeout 是 JavaScript 中的一个异步函数,用于在指定的时间间隔后执行一段代码。它属于延迟方法,会被放到最后,也就是主线程空闲的时候才会触发。
  2. Promise 是 JavaScript 中的一种对象,用于处理异步操作的结果。它本身是同步的立即执行函数,当在执行体中执行 resolve() 或者 reject() 的时候,此时是异步操作,会先执行 then/catch 等,等主栈完成后,才会去执行 resolve()/reject() 中的方法。
  3. Async/Await 是 JavaScript 中的一种语法,用于处理异步操作,使代码看起来像同步代码一样。async 用于定义一个异步函数,await 用于等待异步操作的结果。当遇到 await 的时候,会让出主线程,阻塞后面的代码的执行。async 函数需要等待 await 后的函数执行完成并且有了返回结果(Promise 对象)之后,才能继续执行下面的代码。

优先级:
Promise 的回调属于微任务,所以它会在当前宏任务执行完后立即执行。
setTimeout 属于宏任务,所以它会在下一轮事件循环中执行。
Async/Await 是基于 Promise 的语法糖,它能实现的效果都能用 then 链来实现。当遇到 await 的时候,会让出主线程,阻塞后面的代码的执行。所以 await 后面的代码相当于 promise.then() 里面的代码。

示例:

console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

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

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

async1();

console.log('script end');

// 输出结果:
script start
async1 start
async2
script end
promise1
async1 end
promise2
setTimeout

解释: 首先,同步代码 console.log('script start')、console.log('async1 start')、console.log('async2') 和 console.log('script end') 被执行。然后,setTimeout 被添加到宏任务队列中。接着,Promise.resolve().then 中的回调被添加到微任务队列中。当同步代码执行完后,事件循环检查微任务队列并执行队列中的所有微任务,即 console.log('promise1') 、console.log('async1 end')和console.log('promise2')。最后,事件循环执行下一个宏任务,即 setTimeout 中的回调。

节流&&触底加载 防抖&&实时搜索

节流

节流(Throttle)是一种控制函数执行频率的技术。当事件被频繁触发时,节流函数会按照一定的频率来执行函数。它可以保证在一段时间内,不管事件触发了多少次,函数都只会执行一次,且是最先被触发调用的那次。

举个例子,假设你正在滚动一个页面,每滚动一段距离就会触发一个事件。如果这个事件被频繁触发,可能会导致页面卡顿。这时候,你可以使用节流来控制事件的执行频率,让它每隔一段时间才执行一次。

节流通常用于优化性能,避免因为事件触发过于频繁而导致的页面卡顿或浏览器崩溃。

场景:

  • 滚动事件:当用户滚动页面时,可以使用节流来控制滚动事件的执行频率,让它每隔一段时间才执行一次。
  • 窗口大小调整:当用户调整浏览器窗口大小时,可以使用节流来控制调整事件的执行频率,让它每隔一段时间才执行一次。
  • 鼠标移动:当用户移动鼠标时,可以使用节流来控制鼠标移动事件的执行频率,让它每隔一段时间才执行一次。

滚动事件当然是 触底加载 比较多了。现在用这个作为示例:

// 节流函数
function throttle(fn, delay) {
  let timer = null;
  return function() {
    if (!timer) {
      timer = setTimeout(() => {
        fn.apply(this, arguments);
        timer = null;
      }, delay);
    }
  }
}

// 加载函数
function loadMore() {
  // 加载更多内容
  console.log('Loading more content...');
}

// 监听滚动事件
window.addEventListener('scroll', throttle(function() {
  // 滚动到页面底部时触发加载函数
  if (document.documentElement.scrollTop + window.innerHeight === document.documentElement.scrollHeight) {
    loadMore();
  }
}, 500));

解释: 在这个例子中,我们定义了一个节流函数 throttle,它接受两个参数:一个是要执行的函数 fn,另一个是延迟时间 delay。当事件被触发时,节流函数会按照指定的频率来执行函数。然后,我们定义了一个加载函数 loadMore,用来加载更多内容。接着,我们监听了滚动事件,并使用节流函数来控制加载函数的执行频率。当滚动到页面底部时,会触发加载函数。

防抖

防抖(Debounce)是一种控制函数执行频率的技术。当事件被频繁触发时,防抖函数会推迟执行函数。只有当等待一段时间后也没有再次触发该事件,那么才会真正执行函数。

举个例子,假设你正在输入一个搜索关键词,每输入一个字符就会触发一个搜索事件。如果这个事件被频繁触发,可能会导致页面卡顿或浏览器崩溃。这时候,你可以使用防抖来控制搜索事件的执行频率,让它在用户停止输入一段时间后才执行。

防抖通常用于优化性能,避免因为事件触发过于频繁而导致的页面卡顿或浏览器崩溃。

场景:

  • 输入框实时搜索:当用户在输入框中输入内容时,可以使用防抖来控制搜索事件的执行频率,让它在用户停止输入一段时间后才执行。
  • 窗口大小调整:当用户调整浏览器窗口大小时,可以使用防抖来控制调整事件的执行频率,让它在用户停止调整一段时间后才执行。
  • 按钮点击:当用户点击一个按钮时,可以使用防抖来防止用户连续点击,避免重复提交表单。

那么就用 实时搜索 作为示例:

// 防抖函数
function debounce(fn, delay) {
  let timer = null;
  return function() {
    clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(this, arguments);
    }, delay);
  }
}

// 搜索函数
function search(keyword) {
  // 执行搜索操作
  console.log(`Searching for ${keyword}...`);
}

// 获取输入框元素
const input = document.querySelector('input');

// 监听输入事件
input.addEventListener('input', debounce(function(event) {
  // 获取输入框的值
  const keyword = event.target.value;
  // 执行搜索操作
  search(keyword);
}, 500));

解释: 在这个例子中,我们定义了一个防抖函数 debounce,它接受两个参数:一个是要执行的函数 fn,另一个是延迟时间 delay。当事件被触发时,防抖函数会推迟执行函数。如果在等待时间内再次触发该事件,那么会重新计算等待时间。然后,我们定义了一个搜索函数 search,用来执行搜索操作。接着,我们获取了输入框元素,并监听了输入事件。当用户在输入框中输入内容时,会触发输入事件。我们使用防抖函数来控制搜索函数的执行频率,让它在用户停止输入一段时间后才执行。

垃圾回收机制

JavaScript 的垃圾回收机制是用来防止内存泄漏的。内存泄漏指的是当已经不需要某块内存时,这块内存还存在着。在项目中,如果存在大量不被释放的内存(堆/栈/上下文),页面性能会变得很慢。当某些代码操作不能被合理释放,就会造成内存泄漏。垃圾回收机制就是间歇性地、不定期地寻找到不再使用的变量,并释放掉它们所指向的内存。

JavaScript 的垃圾回收算法主要有两种:引用计数(reference counting)和标记清除(mark-and-sweep)。

引用计数算法通过跟踪每个值被引用的次数来工作。当声明了一个变量并将一个引用类型值赋给该变量时,则这个值的引用次数就是 1。如果同一个值又被赋给另一个变量,则该值的引用次数加 1。相反,如果包含对这个值引用的变量又取得了另外一个值,则这个值的引用次数减 1。当这个值的引用次数变成 0 时,则说明没有办法再访问这个值了,因此就可以将其占用的内存空间回收回来。

标记清除算法将“不再使用的变量”定义为“无法访问到这个变量”。垃圾收集器在运行的时候会给存储在内存中的所有变量都加上标记(可以使用任何标记方式)。然后,它会去掉环境中的变量以及被环境中的变量引用的变量的标记。而在此之后再被加上标记的变量即为准备删除的变量,原因是环境中的变量已经无法访问到这些变量了。最后,垃圾收集器完成内存清除工作,销毁那些带标记的值并回收它们所占用的内存空间。

其他

new

过程:

  1. 首先,创建一个全新的对象。然后,将这个对象的原型链(proto)指向函数的 .prototype。
  2. 接着,将这个对象绑定到函数中的 this,然后执行函数,函数内部可以借助 this 给这个对象添加属性。
  3. 最后,如果这个函数没有返回其他对象的话,new 操作符就会将上面步骤创建的对象返回出去。但如果该函数最后返回了一个其他对象的话,new 操作符就会把这个函数返回的对象返回出去。也就是判断函数的返回值类型,如果是值类型,返回创建的对象。如果是引用类型,就返回这个引用类型的对象。

示例:

function Person(name, age) {
  this.name = name;
  this.age = age;
}

var person1 = new Person('小明', 25);
console.log(person1.name); // 输出: 小明
console.log(person1.age); // 输出: 25

三种常用方法实现继承

  1. 使用原型链。
    示例:
function Animal(name) {
  this.name = name;
}

Animal.prototype.sayName = function() {
  console.log(this.name);
}

function Dog(name, breed) {
  Animal.call(this, name);
  this.breed = breed;
}

Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

Dog.prototype.bark = function() {
  console.log('Woof!');
}

let dog = new Dog('Max', 'German Shepherd');
dog.sayName(); // Max
dog.bark(); // Woof!

2. 使用 class 关键字来定义类,并使用 extends 关键字来实现继承。
示例:

class Animal {
  constructor(name) {
    this.name = name;
  }

  sayName() {
    console.log(this.name);
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name);
    this.breed = breed;
  }

  bark() {
    console.log('Woof!');
  }
}

let dog = new Dog('Max', 'German Shepherd');
dog.sayName(); // Max
dog.bark(); // Woof!

3. 使用混入(Mixin)。
示例:

let Animal = {
  sayName: function() {
    console.log(this.name);
  }
}

function Dog(name, breed) {
  this.name = name;
  this.breed = breed;
}

Object.assign(Dog.prototype, Animal);

Dog.prototype.bark = function() {
  console.log('Woof!');
}

let dog = new Dog('Max', 'German Shepherd');
dog.sayName(); // Max
dog.bark(); // Woof!

手写bind方法

// 可以通过在 Function.prototype 上添加一个新方法来手写实现 bind 方法
Function.prototype.myBind = function(context) {
  var self = this;
  var args = Array.prototype.slice.call(arguments, 1);
  return function() {
    var bindArgs = Array.prototype.slice.call(arguments);
    return self.apply(context, args.concat(bindArgs));
  }
}

var obj = {
  name: '小明'
}

function sayName(age) {
  console.log(this.name);
  console.log(age);
}

var boundSayName = sayName.myBind(obj, 25);
boundSayName(); // 输出: 小明 \n 25

解释:在上面的示例中,我们定义了一个 myBind 方法,它接受一个参数 context,表示绑定的上下文对象。然后我们使用 apply 方法将函数的执行上下文绑定到指定的对象上,并传入相应的参数。最后我们可以调用绑定后的函数。

CmmonJS和ESM

CommonJS和ESM是两种不同的JavaScript模块化规范。CommonJS主要用于服务器端,比如Node.js,而ESM是ECMAScript 6中引入的模块化标准,它既可以用于前端,也可以用于后端。

CommonJS和ESM之间有一些主要区别:
首先,它们的语法不同。CommonJS使用 require 和 module.exports 来导入和导出模块,而ESM使用 import 和 export 关键字。
其次,CommonJS模块是运行时加载的,而ESM模块是编译时输出接口的。此外,CommonJS是同步加载模块的,而ESM支持异步加载。

示例:

// CommonJS
var foo = require('foo');
module.exports = foo;

// ESM
import foo from 'foo';
export default foo;

柯里化

在上面的闭包中我们有提到柯里化,那么这里简单介绍一下。要思考柯里化是什么?有什么用?怎么实现?

柯里化(Currying)是一种处理多元函数的方法,它是指将一个多参数的函数转化为单参数函数的方法。它是数学家柯里(Haskell Curry)提出的。

柯里化的主要作用是将一个复杂的函数拆分成多个简单的函数,使得每个函数只接受一个参数。这样做可以让我们更灵活地使用这些函数,比如可以将它们组合起来,或者将它们作为参数传递给其他函数。

示例:

function add(x, y) {
  return x + y;
}

function curriedAdd(x) {
  return function(y) {
    return add(x, y);
  }
}

var add5 = curriedAdd(5);
console.log(add5(3)); // 输出: 8

call bind apply

在解决this指向问题中提到了call、apply 和 bind,那么现在来介绍一下。

call、apply 和 bind 都是JavaScript中的函数方法,它们都可以用来改变函数的执行上下文(即函数内部的 this 指向)。

call 和 apply 的作用相似,它们都可以用来立即调用一个函数,并指定函数内部的 this 指向。它们的区别在于传递参数的方式不同:call 方法接受若干个参数,第一个参数是 this 指向的对象,后面的参数依次传递给函数;而 apply 方法接受两个参数,第一个参数是 this 指向的对象,第二个参数是一个数组,数组中的元素依次传递给函数。

bind 方法与 call 和 apply 不同,它不会立即调用函数,而是返回一个新的函数。这个新函数与原函数具有相同的行为,但是它内部的 this 指向被绑定到了 bind 方法的第一个参数上。除了第一个参数外,bind 方法还可以接受若干个参数,这些参数会被预先传递给新函数。

示例:

function sayName(greeting) {
  console.log(`${greeting}, my name is ${this.name}`);
}

var obj = {
  name: '小明'
}

sayName.call(obj, 'Hello'); // 输出: Hello, my name is 小明
sayName.apply(obj, ['Hello']); // 输出: Hello, my name is 小明

var boundSayName = sayName.bind(obj);
boundSayName('Hello'); // 输出: Hello, my name is 小明