2025年了,再被面试官问到这些问题就该自信吟唱了 —— JS 篇

1,370 阅读19分钟

写这篇文章主要记录一些自己在面试过程中被问到的高频问题以及自己的一些理解,因为是面试题,难免会写得比较无聊,在努力改进,本文结合目录食用更佳。

(2年多React工作经验,经验丰富的掘友们可以划走啦 :p

如果有写的不准确的地方,欢迎评论区指出~


Q:JS相关问题

对Promise的理解

Promise 是为了解决 callback 造成的 “回调地狱” 问题而提出的,它是异步编程的一种解决方案,可以更好的处理链式调用。

当一个回调函数嵌套一个回调函数的时候就会出现一个嵌套结构,当嵌套的多了就会出现回调地狱的情况。

Promise 对象

Promise 对象有以下两个特点:

  1. 对象的状态不受外界影响。Promise 对象代表一个异步操作,有三种状态:pending(进行中)、fulfilled(已成功)和 rejected(已失败)。只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态。

  2. 一旦状态改变,就不会再变,任何时候都可以得到这个结果。Promise 对象的状态改变,只有两种可能:从 pending 变为 fulfilled 和从 pending 变为 rejected 。只要这两种情况发生,状态就凝固了,不会再变了,会一直保持这个结果,这时就称为 resolved(已定型)。如果改变已经发生了,你再对 Promise 对象添加回调函数,也会立即得到这个结果。这与事件(Event)完全不同,事件的特点是:如果你错过了它,再去监听,是得不到结果的。

Promise 语法代码

new Promise(function (resolve, reject) {
  // function 是构造函数的回调函数
  // resolve 表示成功的回调,reject 表示失败的回调
  // resolve 和 reject 是两个函数
}).then(function (res) {
  // 成功的函数
}).catch(function (err) {
  // 失败的函数
})

Promise 实例生成以后,可以用then 方法分别指定resolve 状态和reject 状态的回调函数。

代码示例:

//Promise是一个构造函数,new之后等于说调用了构造函数
const promise = new Promise((resolve,reject)=>{
    //构造函数中传的参数是一个函数
    //这个函数内的两个参数(resolve,reject))分别又是两个函数
    //异步代码
    setTimeout(()=>{
        // resolve(['111','222','333'])
        reject('error')
        },2000)
    })

promise.then((res)=>{
    //兑现承诺,这个函数被执行
    console.log('success',res);
    }).catch((err)=>{
    //拒绝承诺,这个函数就会被执行
    console.log('fail',err);
})

Promise 链式,then 方法返回一个新的Promise 实例(注意:不是原先的Promise 实例),可以通过 then 方法实现链式调用。

Promise 方法

  1. all:all 方法是与 then 同级的另一个方法,该方法提供了并行执行异步操作的能力,并且在所有异步操作执行完后并且执行结果都是成功的时候才执行回调。
Promise
	.all([promiseClick3(), promiseClick2(), promiseClick1()])
	.then(function(results){
		console.log(results);
	});

all 接收一个数组参数,这组参数为需要执行异步操作的所有方法,里面的值最终都算返回Promise 对象。 这样,三个异步操作的并行执行的,等到它们都执行完后才会进到then里面。

那么,三个异步操作返回的数据哪里去了呢?

都在then里面,all 会把所有异步操作的结果放进一个数组中传给then,然后再执行then方法的成功回调将结果接收.

Promise.all () 返回的 Promise 异步地变成完成状态, 其 resolve 回调的结果是一个数组,包含了所有Promise 的 resolve 回调的结果。

然而,只要任何一个输入的 Promise 变为拒绝状态(即失败),Promise.all () 返回的 Promise 就会立刻中断执行变为拒绝状态,其 reject 回调执行,并且 reject 的是第一个抛出的错误信息。

  1. allSetted:  allSetted 区别于 all 方法的是,不会中断执行,而是等待所有 Promise 都解决或拒绝,然后返回结果。

  2. race:race 与 allSetted 方法相反,谁先执行完成就先执行回调。先执行完的不管是进行了race 的成功回调还是失败回调,其余的将不会再进入race 的任何回调。

小思考:如果想用 setTimeOut 和 await 实现一个计时器功能该如何实现呢?

5

4

3

2

1

我们可以考虑用 promise 实现

答案揭晓:

function delay(ms) {  
  return new Promise(resolve => setTimeout(resolve, ms));  
}  
  
async function delayedAction() {  
  console.log('Starting action...');  
  await delay(1000); // 等待1秒  
  console.log('Action completed after 1 second.');  
}  
  
delayedAction();

对原型和原型链有什么理解?

我们经常会看到一些代码 A.prototype.B,看起来像是链式调用?什么时候会用到?到底是什么意思? 首先我们要了解下它的概念。

原型是什么? 原型链是什么?

原型(Prototype)
  • 定义: 每个 JavaScript 对象都有一个内部属性 [[Prototype]],指向其原型对象(prototype)。 原型对象本身也是一个对象,也有自己的 [[Prototype]],直到找到原型链的顶端 null

  • 作用: 用于实现对象之间的属性和方法共享(继承)。如果一个对象找不到某个属性或方法,会沿着其原型链向上查找,直到找到或者到达顶端 null

代码示例

const obj = {};
console.log(obj.__proto__); // 打印 obj 的原型
console.log(Object.prototype); // obj.__proto__ === Object.prototype

每一个类都有一个显示原型 prototype (本质是一个对象)
每一个实例都有一个隐式原型 _proto_
注:_proto_ 属性已经从 Web 标准中删除,现在更推荐使用 Object.getPrototypeOf() 来获取参数对象的原型

构造函数与 prototype 属性

每个函数都有一个默认的 prototype 属性(除了箭头函数), prototype 是一个对象,所有由该函数创建的实例对象都共享这个原型对象。通过 new 关键字生成的实例对象,[[Prototype]] 会指向构造函数的 prototype

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

Person.prototype.sayHello = function () {
  console.log(`Hello, I'm ${this.name}`);
};

const conor = new Person('Conor');
conor.sayHello(); // 输出: Hello, I'm Conor

//类显式原型的 `prototype` 等于其创建的实例的隐式原型 **\__proto\__**
console.log(conor.__proto__ === Person.prototype); // true
原型链(Prototype Chain)
  • 定义:原型链是由对象及其原型([[Prototype]])层层连接起来的一条链路。

当访问对象的某个属性或方法时,JavaScript 会沿着原型链向上查找(沿着 _proto_ 向上查找,我们把 _proto_ 形成的链条关系称原型链(实现了js继承)),直到找到对应属性或到达顶端 null。

查找规则
  1. 先查找当前对象自身是否有该属性。
  2. 如果没有,沿着原型链查找父级原型。
  3. 如果最终到达 null(原型链顶端)仍未找到,则返回 undefined。

代码示例:

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

Person.prototype.sayHello = function () {
  console.log(`Hello, I'm ${this.name}`);
};

const conor = new Person('Conor');

console.log(conor.name); // 查找 conor 本身,输出: Conor
conor.sayHello(); // 查找 conor.__proto__ 上的方法,输出: Hello, I'm Conor

console.log(conor.toString()); 
// `toString` 不在 conor 或 Person.prototype 上,但存在于 Object.prototype
// 输出: [object Object]
原型链顶端
  • 所有对象最终都会继承自 Object.prototype
  • Object.prototype.__proto__ === null,表示原型链的终点。
原型和原型链的关系
原型链结构图

假设创建一个 Person 构造函数实例:

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

const conor = new Person('Conor');

对应的原型链关系:

conor.__proto__            -> Person.prototype
Person.prototype.__proto__ -> Object.prototype
Object.prototype.__proto__ -> null

原型是动态的,修改原型会立刻反映到所有相关的实例对象中

Person.prototype.sayGoodbye = function () {
  console.log(`Goodbye from ${this.name}`);
};

conor.sayGoodbye(); // 输出: Goodbye from Conor

我们发现 conor 的原型链上多了 sayGoodbye 方法,如果想要某个属性或方法需要保持不可变,可以使用 Object.freeze

const obj = { name: "Conor" };
Object.freeze(obj);

obj.name = "Bob";   // 不会生效
obj.age = 25;       // 无法添加新属性
delete obj.name;    // 无法删除属性

console.log(obj);   // { name: "Conor" }

对象的 [[Prototype]] 指向其原型, 下图中的 Perspn() 叫原型对象,Person.prototype 叫原型, per1 叫实例对象。

image.png

New关键字都做了什么?

执行步骤

假设有以下代码:

function Person(name) {
  this.name = name;
}
const person = new Person("Conor");

new Person("Conor")的执行流程如下:

  1. 创建一个新的空对象:首先,JavaScript引擎会创建一个新的空对象,这个对象会继承自构造函数的 prototype 对象,会被用作构造函数的上下文(即this)。
这个新对象的原型会被设置为构造函数的 prototype 属性

const newObj = {};
Object.setPrototypeOf(newObj, Person.prototype);
  1. 设置原型链:新创建的对象的_proto_属性(在ES6中,通过Object.getPrototypeOf()访问)会被设置为构造函数的 prototype 对象,即这个对象的原型会指向构造函数的 prototype 属性。构造函数内部的 this 会指向这个新创建的对象,这样,新创建的对象就可以继承构造函数原型上的属性和方法。
Person.call(newObj, "Conor");
  1. 执行构造函数中的代码:以新对象为上下文,执行构造函数的代码,这通常包括给 this 添加属性和方法,也就是给新创建的对象添加属性和方法。在上例中,this.name = name 会将 name 属性添加到 newObj 上。

  2. 返回新对象:如果构造函数没有显式地返回一个对象,那么 new 表达式的结果就是新创建的对象。如果构造函数显式地返回了一个对象,那么 new 表达式的结果就是这个返回的对象。需要注意的是,如果构造函数返回了一个非对象类型的值(比如 null、undefined、number、string、boolean 等),那么这个非对象类型的值会被忽略,new 表达式的结果仍然是新创建的对象。

function Test() {
  return { message: "I am explicit" };
}
const obj = new Test(); // 返回的是 { message: "I am explicit" }

用JS代码模拟New的过程

我们把上面的代码连起来更直观的来理解下

function myNew(constructor, ...args) {
  // 1. 创建一个新对象,并将其原型设置为构造函数的 prototype
  const obj = Object.create(constructor.prototype);

  // 2. 调用构造函数,并将其 this 绑定到新对象上
  const result = constructor.apply(obj, args);

  // 3. 返回构造函数的返回值(如果返回的是对象),否则返回新对象
  return typeof result === "object" && result !== null ? result : obj;
}

// 测试
function Person(name) {
  this.name = name;
}
const person = myNew(Person, "Conor");
console.log(person.name); // Conor
console.log(person instanceof Person); // true

防抖和节流

防抖

防抖的核心思想是:防抖是指在某个事件触发后,只有在规定的时间内没有再次触发该事件,处理函数才会执行。如果在这个时间内又触发了该事件,则重新计算执行时间。

例如:如果你有一个搜索框,并且希望在用户停止输入一段时间后才发送搜索请求,那么防抖就非常有用。在用户持续输入的过程中,防抖函数会不断重置执行时间,只有当用户停止输入一段时间后,才会真正执行事件处理函数。

代码实现
function debounce(fn, delay) {
  let timer = null; // 定时器变量
  return function (...args) {
    if (timer) {
      clearTimeout(timer); // 清除上一次的定时器
    }
    timer = setTimeout(() => {
      fn.apply(this, args); // 执行函数并绑定上下文和参数
    }, delay);
  };
}

function handleInputChange(e) {
  console.log('Input value:', e.target.value);
}

const debouncedInput = debounce(handleInputChange, 500);

document.getElementById('input').addEventListener('input', debouncedInput);

节流

节流的核心思想是:每隔一定的时间就执行一次事件处理函数,不管事件触发得有多频繁。

以滚动事件为例,如果你希望在用户滚动页面时,每隔一段时间就执行一些操作(比如更新某个元素的位置),那么节流就很合适。无论用户滚动得有多快,事件处理函数都会按照设定的时间间隔执行。

代码实现
  • 基于时间戳实现
 function throttle(fn, interval) {
  let lastTime = 0; // 上次执行的时间
  return function (...args) {
    const now = Date.now(); // 当前时间
    if (now - lastTime >= interval) {
      fn.apply(this, args); // 执行函数
      lastTime = now; // 更新上次执行时间
    }
  };
}

function handleScroll() {
  console.log('Scroll event:', new Date().toISOString());
}

// 使用节流函数包装
const throttledScroll = throttle(handleScroll, 200);

window.addEventListener('scroll', throttledScroll);
  • 基于定时器实现
function throttle(fn, interval) {
  let timer = null; // 定时器变量
  return function (...args) {
    if (!timer) {
      timer = setTimeout(() => {
        fn.apply(this, args); // 执行函数
        timer = null; // 清除定时器
      }, interval);
    }
  };
}

function handleScroll() {
  console.log('Scroll event:', new Date().toISOString());
}

// 使用节流函数包装
const throttledScroll = throttle(handleScroll, 200);

window.addEventListener('scroll', throttledScroll);

对比总结

特性防抖节流
定义一段时间内只执行最后一次事件处理一段时间内只执行一次事件处理
场景输入框输入、搜索框、点击按钮等滚动、拖拽、鼠标移动、窗口调整等
实现延时触发,清除之前的定时器通过时间戳或定时器限制触发频率

防抖适合 避免多次重复触发 的场景,更侧重于延迟执行

而节流适合 控制频繁触发 的场景,更侧重于间隔执行

执行栈和执行上下文

执行上下文

执行上下文 是代码产生于编译阶段的,用于描述代码执行的环境。抽象地讲,可以理解为一个运行环境盒子,JavaScript 代码在这个盒子里运行

执行上下文的类型
  1. 全局执行上下文:默认的上下文,任何不在函数内部的代码都在全局上下文中。它会创建一个全局的window 对象,并且设置this 的值等于这个全局对象。(一个程序中只会有一个全局执行上下文)
  2. 函数执行上下文:每当一个函数被调用时,都会为该函数创建一个新的上下文。每个函数都有它自己的执行上下文,不过是在函数被调用的时候创建的。函数上下文可以有任意多个,每当一个新的执行上下文被创建,它会按定义的顺序执行一系列步骤。
  3. Eval 函数执行上下文 (不常用)

只有全局执行上下文中的变量、方法,能够被其它任何上下文访问。但是可以有任意多个函数执行上下文,每次调用函数创建一个新的上下文,会创建一个私有作用域,函数内部声明的任何变量都不能在当前函数作用域外部直接访问。

执行上下文的特点
  • 单线程
  • 同步执行
  • 只有一个全局上下文
  • 可有无数个函数上下文
  • 每个函数调用都会创建一个新的 执行上下文,哪怕是递归调用
执行上下文的内容
  1. 变量对象
    • 全局上下文中,变量对象是 window
    • 函数上下文中,变量对象包括函数参数、局部变量和函数声明
  2. 作用域链
    • 用于变量分析
    • 包括当前上下文的变量对象和父级上下文的变量对象
  3. this 绑定
    • 决定 this 的值(全局上下文中, this 指向全局对象

说到执行上下文我们不免就会想到执行栈,一个个运行环境盒子就存储在执行栈中。

执行栈

执行栈 可以理解为其他编程语言中的 “调用栈”,是一种拥有 LIFO (后进先出)数据结构的栈,被用来存储代码运行时创建的所有执行上下文。

当 js 引擎第一次遇到你的脚本时,它会创建一个全局执行上下文并压入当前执行栈。每当引擎遇到一个函数调用,它会为该函数创建一个新的执行上下文并压入栈的顶部。

引擎会执行那些执行上下文位于栈顶的函数。当函数执行结束时,执行上下文从栈中弹出,控制流程到达当前栈中的下一个上下文。

执行栈的流程

举个例子:

function foo() {
  console.log('foo');
  bar();
}

function bar() {
  console.log('bar');
}

foo();
  1. 上面这段代码在执行的时候,会先创建一个全局执行上下文,压入执行栈中,index = 0。
  2. 当运行 foo() 时会创建一个函数上下文,再压入执行栈中,index = 1。
  3. 运行到 bar() 时会创建一个函数上下文,再压入执行栈中,index = 2。
  4. bar() 执行完毕,上下文出栈。
  5. foo() 执行完毕,上下文出栈。
  6. 全局上下文出栈。

创建执行上下文

创建执行上下文分为:1) 创建阶段2) 执行阶段

执行上下文在创建阶段会进行以下操作:

  1. 创建变量对象:执行上下文有两个组成部分,分别为词法环境letconst 声明的变量)和变量环境var 声明的变量)。

  2. 确定this的值:在全局执行上下文中,this指向全局对象(在浏览器中是window,在Node.js中是global)。在函数执行上下文中,this的值取决于函数的调用方式(例如,直接调用、作为对象的方法调用、使用call/apply/bind调用等)。

  3. 创建作用域链:作用域链是一个包含多个变量对象的列表,用于解析标识符(如变量名和函数名)。全局执行上下文的作用域链只包含一个元素,即全局对象本身。函数执行上下文的作用域链包含当前函数的变量对象、父执行上下文的变量对象,依此类推,直到全局执行上下文。

上面提到了作用域和作用域链,这里我们一起介绍下

什么是作用域,什么是作用域链呢?

作用域

函数身上的属性 \[\[scope]],用于存储函数中的有效标识符,也就是变量与函数的可访问范围。

  1. 词法作用域的特点:词法作用域在代码编写阶段就确定了,不会在运行时改变;内部作用域可以访问外部作用域中的变量,但外部作用域不能访问内部作用域中的变量。

    • 我们来看个例子:下面的代码中,首先执行 printName() 函数,在 printName() 函数中执行 addFirstName() 函数, addFirstName() 函数中输出 'jack' + surName 的值。它首先会查找当前作用域中是否有 surName ,如果没有,则会向外一层查找,在 printName() 函数中找到了 surName 的值,所以输出 'jack zhao' ,如果没有找到的话,则会再向外一层查找(即到了全局作用域),最后会输出 'jack wu'
function printName(){
    var surName = 'zhao';
    //通过闭包获取'zhao'
    function addFirstName(){
        console.log('jack ' + surName);
    }
    addFirstName(surName);
}

var surName = 'wu';
printName();
  1. 全局作用域:在代码任何地方都能访问到的对象拥有全局作用域,具体来说全局作用域指的是在代码中任何函数、类或语句块之外定义的变量和函数的作用域。

    • 最外层函数和在最外层函数外面定义的变量
    • 所有未定义直接赋值的变量
    • window 对象的属性
  2. 函数作用域: 指在函数内部定义的变量和函数只能在该函数内及其嵌套的子函数内被访问,函数外部无法直接访问。

  3. 块级作用域: 指在代码块(如 if 语句、for 循环、while 循环等)内定义的变量和函数只能在该代码块内被访问,一旦离开该代码块,这些变量和函数将不再可用。块级作用域可通过 let 和 const 声明,声明后的变量在指定块级作用域外无法被访问。

作用域链

作用域是执行上下文对象的集合,这种集合呈链式连接,我们把这种链关系叫做作用域链。

  • 当所需要的变量在所在的作用域中查找不到的时候,它会一层一层向上查找,直到找到全局作用域还没有找到的时候,就会放弃查找。这种一层一层的关系,就是作用域链。

  • 不过并不是在调用栈中从上往下查找,而是当前执行上下文变量环境中的 outer 指向来定,而 outer 指向的规则是,我的词法作用域在哪里, outer 就指向哪里。

再举个例子捋一下作用域和作用域链的关系: 想一下这段代码会输出什么?

function bar(){
    console.log(myName);
}
 
function foo(){
    var myName = "小红"bar();
}
 
var myName = "小明"foo();

分析一下:

  1. 预编译产生全局执行上下文,由于 bar 函数与 foo 函数声明在全局,因此是属于全局的有效标识符,同时全局作用域已经没有外层了,因此 outer 属性为null。

  2. 调用 foo 函数,foo 的函数执行上下文入栈,由于myName声明在函数 foo 中,因此是属于 foo 作用域的有效标识符。同时,由于函数 foo 声明在全局,因此foo执行上下文中的 outer 指向全局,表明 foo 的外层作用域是全局。

    形成 foo 作用域 --> 全局作用域 的作用域链。

  3. 调用 bar 函数,bar 函数的函数执行上下文入栈,由于函数 bar 声明在全局,因此 bar 执行上下文中的 outer 指向全局,表明 bar 的外层作用域是全局。

    形成 bar 作用域 --> 全局作用域 的作用域链。

  4. 由于调用函数 bar 执行上下文的 outer 指向全局,因此最后的输出为“小明”。 image_362.png

闭包

定义:闭包是指 一个函数能够记住并访问它的词法作用域,即使这个函数在它的词法作用域之外执行。

官方定义读起来比较晦涩,简单来说,闭包就是能够访问当前作用域以外的变量。上面输出 'jack wu' 的例子其实就用到了闭包,简单的部分我们略过,说点特别的。

修改函数内部变量

如何在不改变下面代码的情况下修改对象obj

const fn = (() => {
    const obj = {
        a: 1,
        b: 2,
    }
    return {
        get: (k) => {
          return obj[k]
        },
    }
})()

这里是一个立即执行函数(IIFE),在执行时返回一个对象,其中包含 get 方法

当前我们只能通过fn.get('a')去获得obj属性a的值,要想更改a或者b或者给obj添加其他属性该怎么办呢?

  • 给对象原型上添加属性,使用访问器返回当前对象
    Object.defineProperty(Object.prototype, 'modify', {
      get() {
        return this
      },
    })
    不能直接获取obj,可以通过上述方式在其原型上添加任意属性(modify)
    const _obj = fn.get('modify'); // 现在obj上找 modify 属性找不到,所以会沿着原型链去找
    console.log(_obj)--->{a:1,b:2}
    _obj.a='修改后的a';
    _obj.b='修改后的b';
    _obj.c='添加属性c'console.log(fn.get('a'))--->'修改后的a'
    console.log(fn.get('b'))--->'修改后的b'
    console.log(fn.get('c'))--->'添加属性c'

需要注意的是,使用 Object.prototype.modify ,是将一个全局访问器属性添加到所有对象,允许通过 modify 属性直接访问自身,这是一个比较危险的操作。在实际应用中我们应该避免修改 Object.prototype,尤其是添加访问器属性。

  • 那么如何实现禁止修改obj呢?
 const fn = (() => {
      const obj = {
        a: 1,
        b: 2,
      }
      //方法一
      // 当不需要用到obj原型对象时,设置原型对象为null,
      Object.setPrototypeOf(obj, null) 
      return {
        get: (k) => {
        // 方法二
        // 判断读取对象是否在obj本身
          if (obj.hasOwnProperty(k)) {
            return obj[k]
          }
          return undefined
        },
      }
    })()