很全面的前端面试题——手写题(上)

前言

在当今竞争激烈的互联网行业,前端开发岗位的面试越来越注重对基础知识和实际动手能力的考察。手写代码环节已经成为大厂面试的标配,它不仅考察开发者对JavaScript核心概念的理解深度,更能真实反映候选人的编程思维和问题解决能力。

我相信学会这些手写题就可以让你在大多数的面试中游刃有余了,这是手写题上,过几天会有手写题下

一、手写instanceof方法

instanceof的作用是检查一个对象是否是某个构造函数的实例 ,其原理是:判断构造函数的prototype属性是否出现在对象的原型链上

为了解决这道题,大家需要了解两个前置知识

1.typeof

typeof 是 JavaScript 中一个常用的一元运算符 ,用于检测变量的数据类型返回一个表示类型的字符串 。它的语法非常简单:typeof 变量typeof(变量)

像如下的方式使用

csharp 复制代码
// 基本数据类型
typeof 123;          // "number"
typeof "hello";      // "string"
typeof true;         // "boolean"
typeof undefined;    // "undefined"

// 引用数据类型
typeof {};           // "object"
typeof [];           // "object"(注意:数组也是对象)
typeof function(){}; // "function"

// 特殊值
typeof null;         // "object"(历史遗留的bug)
typeof Symbol();     // "symbol"(ES6新增)
typeof 123n;         // "bigint"(ES11新增)

使用typeof也有其注意事项

  1. 对 null 的误判

    这是 JavaScript 历史遗留的 bug,typeof null 会返回 "object",而不是预期的 "null" 检测 null 需用严格相等: const a = null; console.log(a === null); // true

  2. 数组的判断
    typeof [] 返回 "object",无法区分普通对象和数组。 判断数组需用: Array.isArray([]); // true

  3. 函数的特殊性
    函数是唯一一种 typeof 能返回非 "object" 的引用类型(返回 "function")。

  4. 未声明变量的安全检测
    对未声明的变量使用 typeof 不会报错,而是返回 "undefined" typeof undeclaredVar; // "undefined"(不会抛ReferenceError)

2.原型链

原型链是 JavaScript 中对象之间通过「原型引用」形成的链式结构,是实现继承和属性共享的核心机制。

这是解决该题的核心,基于该题的题解,我们要了解原型链的下面特性

  • 每个对象都有一个内部属性 __proto__(隐式原型),指向其「构造函数的 prototype 属性」(显式原型)。
  • 当访问对象的属性 / 方法时,JS 会先在对象自身查找,找不到则通过 __proto__ 向上查找,形成的链式查找路径就是「原型链」。
  • 原型链的终点是 nullObject.prototype.__proto__ === null)。

基于上述的两个知识点,我相信手写instanceof对大家来说已经不是很难了,下面是题解

题解

关键点

  1. 首先处理特殊情况 (如null/undefined或非对象)
  2. 循环遍历对象的原型链
  3. 检查是否与构造函数的prototype匹配

完整题解

javascript 复制代码
function myInstanceof(obj, constructor) {
  // 处理基本类型和null/undefined的情况
  if (obj === null || typeof obj !== 'object') {
    return false;
  }
  
  // 获取对象的原型
  let proto = Object.getPrototypeOf(obj);
  
  // 遍历原型链
  while (proto !== null) {
    // 如果找到匹配的原型,返回true
    if (proto === constructor.prototype) {
      return true;
    }
    // 继续向上查找原型链
    proto = Object.getPrototypeOf(proto);
  }
  
  // 遍历完原型链都没找到匹配,返回false
  return false;
}

// 测试示例
function Person() {}
const person = new Person();

console.log(myInstanceof(person, Person)); // true
console.log(myInstanceof(person, Object)); // true
console.log(myInstanceof([], Array)); // true
console.log(myInstanceof([], Object)); // true
console.log(myInstanceof(123, Number)); // false(基本类型)
console.log(myInstanceof(new Number(123), Number)); // true(包装对象)

二、手写new操作符

想要手写new,我们需要先了解new的行为

new 是 JavaScript 中用于创建构造函数实例对象 的操作符,它的核心作用是将一个普通函数以「构造函数模式」执行,从而生成该函数的实例,建立原型链关系,并完成实例的初始化。

我们先来讲解一下前置知识

1.this

在JavaScript中this的绑定规则和类似JAVA这种语言并不同,js中的this是动态绑定的,并不会因为对象的创建就将this绑定到对象上,其绑定取决于其调用位置

但是new()操作符创建出来的对象会强制this指向自身,而且该规则是所有this绑定规则中优先级最高的,所以我们要强制绑定this指向为新创建出来的实例

2.new的特殊行为

对于构造函数的返回值不同,new操作符会有不同的行为

  • 如果构造函数返回的是「对象」或「函数」 ,那么 new 操作符会直接返回这个返回值(而不是原本创建的实例对象)。
  • 如果构造函数返回的是「基本类型」 (如 number/string/boolean/undefined/null),则这个返回值会被忽略,new 最终返回的是最初创建的实例对象。

3.apply

apply 是 JavaScript 函数对象的方法,用于指定函数执行时的 this 指向,并传入参数执行函数

实例

arduino 复制代码
函数.apply(thisArg, [argsArray])
  • thisArg函数执行时 this 要指向的对象 (若为 null/undefined,非严格模式下 this 指向全局对象)
  • argsArray参数数组(或类数组对象),会作为参数传递给函数

4.原型链(省略,前文提到的原型链的知识够用了)

javascript 复制代码
function myNew(constructor, ...args) {
  // 1. 创建一个新的空对象
  const obj = {};
  
  // 2. 将新对象的原型指向构造函数的prototype
  Object.setPrototypeOf(obj, constructor.prototype);
  // 等同于:obj.__proto__ = constructor.prototype
  
  // 3. 调用构造函数,将this绑定到新对象
  const result = constructor.apply(obj, args);
  
  // 4. 如果构造函数返回的是对象或函数,则返回该结果,否则返回新创建的对象
  // 注意:像前文提到的,typeof检测类型时,对于null会返回object,所以要排除result是null这种情况
  return (typeof result === 'object' && result !== null) || typeof result === 'function' 
    ? result 
    : obj;
}

// 测试示例
function Person(name, age) {
  this.name = name;
  this.age = age;
}

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

// 使用自定义的myNew
const person1 = myNew(Person, 'Alice', 25);
console.log(person1.name); // 'Alice'
console.log(person1.age);  // 25
person1.sayHello();        // 正常调用原型上的方法
console.log(person1 instanceof Person); // true,验证原型链关系

// 测试构造函数有返回值的情况
function Car(brand) {
  this.brand = brand;
  return { model: 'SUV' }; // 返回一个对象
}
const car = myNew(Car, 'Toyota');
console.log(car.brand); // undefined,因为构造函数返回了新对象
console.log(car.model); // 'SUV'

三、手写Promise.all

Promise.all的作用是:接收一个 Promise 数组并发执行它们全部成功则返回结果数组(顺序与输入一致),任何一个失败则立即返回该错误

举一个使用Promise.all的例子

typescript 复制代码
// 模拟三个异步请求
const fetchUser = () => {
  return new Promise(resolve => {
    setTimeout(() => resolve({ id: 1, name: 'Alice' }), 1000);
  });
};

const fetchProducts = () => {
  return new Promise(resolve => {
    setTimeout(() => resolve([{ id: 101, name: '手机' }, { id: 102, name: '电脑' }]), 1500);
  });
};

const fetchCart = () => {
  return new Promise(resolve => {
    setTimeout(() => resolve([{ productId: 101, quantity: 1 }]), 800);
  });
};

// 使用Promise.all并发执行
Promise.all([fetchUser(), fetchProducts(), fetchCart()])
  .then(results => {
    const [user, products, cart] = results; // 结果顺序与输入顺序一致
    console.log('用户信息:', user);
    console.log('商品列表:', products);
    console.log('购物车:', cart);
    console.log('所有数据加载完成,开始渲染页面...');
  })
  .catch(error => {
    console.log('请求失败:', error); // 任一请求失败则立即执行
  });

运行结果如下

css 复制代码
用户信息: { id: 1, name: 'Alice' }
商品列表: [ { id: 101, name: '手机' }, { id: 102, name: '电脑' } ]
购物车: [ { productId: 101, quantity: 1 } ]
所有数据加载完成,开始渲染页面...

当然,做这道题前,我们也要学一些前置知识

Promise的基础知识

Promise 是 JavaScript 中处理异步操作的核心机制,它解决了传统回调函数嵌套带来的 "回调地狱" 问题,让异步代码更具可读性和可维护性。

一、为什么需要 Promise?

在 Promise 出现之前,JavaScript 处理异步操作(如网络请求、定时器)主要依赖回调函数,但存在明显缺陷:

  • 回调地狱:多个异步操作嵌套时,代码会像金字塔一样层层嵌套,可读性极差。 // 传统回调嵌套(回调地狱) async1(() => { async2(() => { async3(() => { // 更多嵌套... }); }); });

  • 错误处理混乱:每个异步操作需要单独处理错误,无法统一捕获。

  • 无法同步返回结果 :异步操作的结果无法通过 return 直接获取,必须在回调中处理。

Promise 通过状态管理链式调用解决了这些问题,让异步代码可以像同步代码一样线性书写。

二、Promise 核心概念

1. 三种状态

Promise 有且仅有三种状态,且状态一旦改变就不可逆

  • pending(等待中) :初始状态,异步操作尚未完成。

  • fulfilled(已成功) :异步操作完成,返回结果。

  • rejected(已失败) :异步操作出错,返回错误原因。

状态变化只能是:pending → fulfilledpending → rejected

2. 基本结构

Promise 是一个构造函数,通过 new Promise() 创建实例,接收一个执行器函数作为参数:

javascript 复制代码
const promise = new Promise((resolve, reject) => {
  // 执行器函数:同步执行,用于封装异步操作
  // resolve:成功时调用的函数,将状态改为 fulfilled
  // reject:失败时调用的函数,将状态改为 rejected
  
  // 模拟异步操作(如接口请求)
  setTimeout(() => {
    if (/* 操作成功 */) {
      resolve("成功的结果"); // 传递成功数据
    } else {
      reject("失败的原因"); // 传递错误信息
    }
  }, 1000);
});

三、Promise 的使用:处理结果

通过 thencatchfinally 方法处理 Promise 的结果,这些方法会返回一个新的 Promise,因此可以链式调用。

1. then 方法

用于处理成功状态 (fulfilled)的结果,也可接收第二个参数处理失败状态(但更推荐用 catch)。

javascript 复制代码
promise.then(
  (result) => {
    // 成功回调:接收 resolve 传递的值
    console.log("成功:", result);
  },
  (error) => {
    // 可选:失败回调(不常用)
    console.log("失败:", error);
  }
);

2. catch 方法

专门处理失败状态(rejected)的结果,包括:

  • 执行器中调用 reject() 触发的错误
  • then 回调中抛出的异常(throw new Error()
typescript 复制代码
promise
  .then(result => {
    console.log("成功:", result);
    throw new Error("处理时出错了"); // 抛出异常会被 catch 捕获
  })
  .catch(error => {
    console.log("失败:", error); // 统一处理所有错误
  });

3. finally 方法

无论 Promise 成功或失败,最终都会执行,常用于清理操作(如关闭加载动画)。

typescript 复制代码
promise
  .then(result => console.log("成功:", result))
  .catch(error => console.log("失败:", error))
  .finally(() => console.log("操作结束,清理资源"));

四、链式调用:解决回调地狱

thencatchfinally 都会返回一个新的 Promise,因此可以通过链式调用将多个异步操作按顺序执行,替代嵌套回调。

typescript 复制代码
// 示例:按顺序执行三个异步操作
getUser() // 1. 获取用户信息
  .then(user => {
    console.log("用户信息:", user);
    return getOrders(user.id); // 2. 获取订单(返回新 Promise)
  })
  .then(orders => {
    console.log("订单列表:", orders);
    return getDetails(orders[0].id); // 3. 获取订单详情
  })
  .then(details => {
    console.log("订单详情:", details);
  })
  .catch(error => {
    console.log("任一环节出错:", error); // 统一捕获所有错误
  });

链式调用的核心规则

  • 前一个 then 的返回值,会作为后一个 then 的参数。
  • 如果返回的是 Promise,后一个 then 会等待该 Promise 完成后再执行。

五、Promise 静态方法

Promise 提供了多个静态方法,用于处理多个异步操作的场景。

1. Promise.resolve(value)

快速创建一个已成功 的 Promise,value 可以是普通值或另一个 Promise。

ini 复制代码
// 等价于 new Promise(resolve => resolve(100))
const p = Promise.resolve(100);
p.then(num => console.log(num)); // 输出 100

2. Promise.reject(reason)

快速创建一个已失败的 Promise。

ini 复制代码
const p = Promise.reject("出错了");
p.catch(error => console.log(error)); // 输出 "出错了"

3. Promise.all(iterable)

并发执行 多个 Promise,等待所有都成功后返回结果数组(顺序与输入一致);任一失败则立即返回该错误

ini 复制代码
const p1 = Promise.resolve(1);
const p2 = Promise.resolve(2);
const p3 = Promise.resolve(3);

Promise.all([p1, p2, p3]).then(results => {
  console.log(results); // [1, 2, 3](顺序与输入一致)
});

4. Promise.race(iterable)

返回第一个完成(无论成功或失败)的 Promise 的结果。

javascript 复制代码
const p1 = new Promise(resolve => setTimeout(resolve, 100, "快"));
const p2 = new Promise(resolve => setTimeout(resolve, 200, "慢"));

Promise.race([p1, p2]).then(result => {
  console.log(result); // "快"(第一个完成的结果)
});

5. Promise.allSettled(iterable)

等待所有 Promise 完成(无论成功或失败),返回包含每个结果状态的数组(适合需要知道所有操作结果的场景)。

ini 复制代码
const p1 = Promise.resolve(1);
const p2 = Promise.reject("错了");

Promise.allSettled([p1, p2]).then(results => {
  console.log(results);
  // [
  //   { status: "fulfilled", value: 1 },
  //   { status: "rejected", reason: "错了" }
  // ]
});

六、Promise 关键特性

  1. 状态不可逆:一旦从 pending 变为 fulfilled 或 rejected,就无法再改变。

  2. 异步执行回调thencatchfinally 的回调函数会被放入微任务队列,在同步代码执行完后再执行。

    javascript 复制代码
    console.log(1);
    Promise.resolve().then(() => console.log(2));
    console.log(3);
    // 输出顺序:1 → 3 → 2(then 回调是异步的)
  3. 错误冒泡 :链式调用中,任何一个环节出错,都会跳过后续 then 直接进入最近的 catch

七、Promise 与 async/await 的关系

async/await 是 ES2017 引入的语法糖,基于 Promise 实现,让异步代码更接近同步代码的写法:

javascript 复制代码
// 使用 async/await 重写链式调用
async function fetchData() {
  try {
    const user = await getUser();
    const orders = await getOrders(user.id);
    const details = await getDetails(orders[0].id);
    console.log(details);
  } catch (error) {
    console.log("出错了:", error);
  }
}

await 会等待 Promise 完成,async 函数本身会返回一个 Promise。

Array.isArray()

Array.isArray() 是 JavaScript 中用于判断一个值是否为数组的内置方法,属于 Array 构造函数的静态方法。

基本语法

javascript 复制代码
Array.isArray(value)
  • 参数 value:需要检测的变量或值
  • 返回值:布尔值(true 表示是数组,false 表示不是)

forEach()

按顺序遍历数组的每个元素,对每个元素执行回调函数,适合不需要改变数组结构、仅需处理元素的场景(如打印、统计、简单操作等)。

使用示例

javascript 复制代码
const fruits = ['苹果', '香蕉', '橙子'];

// 基础用法:遍历并打印元素
fruits.forEach(fruit => {
  console.log(fruit);
});
// 输出:
// 苹果
// 香蕉
// 橙子

// 带索引的用法
fruits.forEach((fruit, index) => {
  console.log(`索引 ${index}:${fruit}`);
});
// 输出:
// 索引 0:苹果
// 索引 1:香蕉
// 索引 2:橙子

// 访问原数组
fruits.forEach((fruit, index, arr) => {
  console.log(`数组[${index}] = ${fruit},原数组长度:${arr.length}`);
});

知道了要传入的参数,不同情况的返回结果,我们就能很好的规划手写步骤

  1. 处理非数组参数的异常
  2. 维护计数器和结果数组,保证顺序一致
  3. 所有 Promise 成功则 resolve 结果,任一失败则立即 reject

手写结果

javascript 复制代码
function myPromiseAll(promises) {
  // 返回一个新Promise
  return new Promise((resolve, reject) => {
    // 处理非数组参数
    if (!Array.isArray(promises)) {
      return reject(new TypeError('The input must be an array'));
    }

    const result = []; // 存储成功结果
    let completedCount = 0; // 已完成的Promise数量
    const total = promises.length;

    // 空数组特殊处理
    if (total === 0) {
      return resolve(result);
    }

    promises.forEach((promise, index) => {
      // 确保每个元素都是Promise(非Promise则包装成成功的Promise)
      Promise.resolve(promise).then(
        (value) => {
          result[index] = value; // 按原顺序存储结果
          completedCount++;

          // 所有Promise都成功时resolve
          if (completedCount === total) {
            resolve(result);
          }
        },
        (reason) => {
          // 任一Promise失败立即reject
          reject(reason);
        }
      );
    });
  });
}

// 测试示例
const p1 = Promise.resolve(1);
const p2 = new Promise(resolve => setTimeout(() => resolve(2), 100));
const p3 = Promise.resolve(3);

myPromiseAll([p1, p2, p3]).then(values => {
  console.log(values); // [1, 2, 3](顺序与输入一致)
});

// 测试失败情况
const p4 = Promise.reject('出错了');
myPromiseAll([p1, p4, p3]).catch(reason => {
  console.log(reason); // '出错了'(立即返回错误)
});

为了防止我之前没有讲清楚,我再补充说明一些具体实现的原理

1.非Promise则包装成成功的Promise

原文的Promise.resolve(promise),执行效果是,非Promise会包装成成功的Promise,如果参数是Promise就会直接返回这个Promise,这是Promise.resolve()这个静态方法的特点

2.为何代码逻辑可以判断Promise是否成功,而且可以找到相应的需要执行的代码块?

我们在原文逻辑中可以看到,如果Promise是否成功会执行下面的代码块

scss 复制代码
(value) => {
          result[index] = value; // 按原顺序存储结果
          completedCount++;

          // 所有Promise都成功时resolve
          if (completedCount === total) {
            resolve(result);
          }
        }

如果执行失败会执行下面的代码块

scss 复制代码
(reason) => {
          // 任一Promise失败立即reject
          reject(reason);
        }

这就是.then的特性

如果Promise执行成功会自动执行第一个函数,执行失败则会自动执行第二个函数

四、手写防抖函数

在事件被频繁触发时,只执行一次(或特定时机执行一次)你的函数操作,避免重复执行。这就是防抖

具体的防抖又分为两种一种叫做默认防抖,另一种叫做立即执行防抖,你可能没有听说过,因为这是我编的两个名字,但是这两种防抖是确实存在的,放心使用

1.默认防抖

我们可以拿我们输入框的联想功能来举例,我们在输入框并不是每输入一个字符都会立即触发联想功能的,大概是下面的流程------

1.输入了一个R,输入之后会开始计时3s(这里是假设3s,实际的时间要短),开始数了,3,2....
2.还没有数到1呢,这时输入了一个e,就又要重新开始计时,3,....
3.这次还没有数到2呢,用户又输入了一个a,重新开始计时3,2....
4.就这样,在输入了完整的React后,用户停止了输入,1,2,3!
5.倒计时结束了!执行联想功能!

大家也可以想一想,如果每输入一个字符都要触发输入框联想功能的话,以你的打字速度,1s就要触发近10次联想功能 ,这样太消耗性能 了,所以这种情况我们就要进行默认防抖

2.立即执行防抖

现在想象你在电商网站搜索商品 ,你想买一个"最新款的华为手机" ,你一边思考一边在搜索框里输入关键字,先敲了"华为"两个字,然后停顿了一下想了一下,又敲了"手机"两个字,感觉不太精确,又敲了"最新款"三个字 。你如果这个搜索框输入进行了默认防抖 ,意味着你需要完全停止输入,等待300ms(这里的300ms也是假设)后,才能看到搜索结果。这对于用户体验来说,等待时间较长,不够实时。

那么我们就要进行另外一种防抖的方法,立即执行防抖,以提供更好的用户体验。

执行逻辑如下

1.用户开始思考和输入,想买"华为手机"
2.用户开始在搜索框里输入:
3.第一次输入"华"字,会立即触发一次搜索请求,展示与"华"相关的模糊匹配结果。
4.接下来用户马上输入"为"字。此时,由于"华"字触发了立即搜索后,系统进入了一个预设的冷却期(例如300ms),在这个冷却期内,所有的输入(包括"为")都不会立即触发新的搜索请求。但是,这次"为"字的输入会重置(延长)这个冷却期计时器。
5.同理,用户在冷却期内接着输入"手"、"机"、"最新"、"款"等字时,每一次输入都会不断重置并延长冷却计时器。
6.只有当用户完成输入,"最新款的华为手机"后,并且在300ms冷却期内不再有任何新的输入时,也就是计时器自然走完,才会再次在冷却结束后立即触发一次包含最终完整关键词的搜索请求,并显示最精准的搜索结果。

当然,你也可以看作你在玩手机游戏打怪时的连击点释放:你每次攻击都会积累连击点,当你积攒到足够的连击点后(相当于停顿下来),你的大招就会立即释放。如果你在攒点过程中不断攻击,那么积累大招的时机就会不断延后,直到你停止攻击并满足条件后,大招才会瞬发。

注;这里有默认防抖的"延长冷却机制"(就是300,200...300..300,200,100!这种机制,每一次操作都会延长等待)

知道了防抖的逻辑之后,我们就可以开始手写代码了,当然,还有一些前置的知识

setTimeout(() => {fun}, time)

这里并不全面的讲setTimeout,只讲setTimeout知识中和本题有关的本部分

setTimeout 是 JavaScript 中用于在指定延迟时间后执行一段代码的内置函数

  1. 基本功能

    接收两个必填参数一个回调函数(延迟后要执行的代码)和一个延迟时间(毫秒),作用是让回调函数在延迟时间过后被执行。

  2. 执行机制

    调用 setTimeout 时,回调函数不会立即执行,而是被放入宏任务队列。JavaScript 引擎会先执行完当前所有同步代码,再检查宏任务队列,当延迟时间已到且没有更早的任务时,才执行该回调。因此,实际执行时间可能比设定的延迟时间长(受同步代码执行时长或其他任务影响)。

  3. 返回值与取消
    返回一个数字类型的定时器 ID,可用于通过 clearTimeout 函数取消该定时器 (在回调执行前调用 clearTimeout(ID),会阻止回调执行)。

  4. this 指向
    回调函数中的 this 指向全局对象(浏览器中为 window,Node.js 中为 global),除非使用箭头函数(继承外部作用域的 this)或手动绑定。

  5. 延迟时间特性
    设定的延迟时间是 "最小延迟",而非精确时间。 浏览器中最小延迟通常为 4 毫秒(嵌套层级过深时可能更大),若传入 0,则会按最小延迟处理。

手写实现防抖

javascript 复制代码
/**
 * 防抖函数
 * @param {Function} func - 需要防抖的函数
 * @param {number} wait - 延迟时间(毫秒)
 * @param {boolean} [immediate=false] - 是否立即执行(true:触发时立即执行,false:延迟后执行)
 * @returns {Function} 防抖处理后的函数
 */
function debounce(func, wait, immediate = false) {
  let timer = null; // 定时器标识

  // 返回包装后的函数
  const debounced = function(...args) {
    const context = this; // 保存原函数的this指向

    // 如果已有定时器,清除它(重新计时)
    if (timer) clearTimeout(timer);

    // 立即执行逻辑
    if (immediate) {
      // 首次触发或定时器已执行完,才执行
      const callNow = !timer;
      // 设定定时器,wait时间后清空timer(允许下次立即执行)
      timer = setTimeout(() => {
        timer = null;
      }, wait);
      // 立即执行原函数
      if (callNow) func.apply(context, args);
    } else {
      // 延迟执行逻辑:重新设定定时器,wait时间后执行
      timer = setTimeout(() => {
        func.apply(context, args); // 绑定this和参数
        timer = null; // 执行后清空定时器
      }, wait);
    }
  };

  // 提供取消防抖的方法
  debounced.cancel = function() {
    clearTimeout(timer);
    timer = null;
  };

  return debounced;
}

// 测试示例
function handleInput(value) {
  console.log('处理输入:', value);
}

// 延迟执行版(输入结束后1000ms执行)
const debouncedInput = debounce(handleInput, 1000);
// 立即执行版(输入时立即执行,后续1000ms内输入不重复执行)
const immediateInput = debounce(handleInput, 1000, true);

// 模拟频繁触发
debouncedInput('a');
debouncedInput('ab');
debouncedInput('abc'); // 1000ms后仅执行此调用

下面进行一些代码中难理解的地方的讲解

如何控制立即执行防抖还是默认防抖?

代码中是通过immediate来控制采取哪种防抖的,默认immediate = false为默认防抖,反之是立即执行防抖。

ini 复制代码
 if (immediate) {
      // 首次触发或定时器已执行完,才执行
      const callNow = !timer;
      // 设定定时器,wait时间后清空timer(允许下次立即执行)
      timer = setTimeout(() => {
        timer = null;
      }, wait);
      // 立即执行原函数
      if (callNow) func.apply(context, args);
    } else {
      // 延迟执行逻辑:重新设定定时器,wait时间后执行
      timer = setTimeout(() => {
        func.apply(context, args); // 绑定this和参数
        timer = null; // 执行后清空定时器
      }, wait);
    }

我们可以看你到,立即执行防抖的核心逻辑如下

ini 复制代码
 const callNow = !timer;
      // 设定定时器,wait时间后清空timer(允许下次立即执行)
      timer = setTimeout(() => {
        timer = null;
      }, wait);
      // 立即执行原函数
      if (callNow) func.apply(context, args);

默认防抖的核心逻辑如下

ini 复制代码
 timer = setTimeout(() => {
        func.apply(context, args); // 绑定this和参数
        timer = null; // 执行后清空定时器
      }, wait);

立即执行防抖逻辑

ini 复制代码
 const callNow = !timer;
      // 设定定时器,wait时间后清空timer(允许下次立即执行)
      timer = setTimeout(() => {
        timer = null;
      }, wait);
      // 立即执行原函数
      if (callNow) func.apply(context, args);

默认的timer值为null,!null的值即为true,如果之前没有执行则timer为true,则执行下面的逻辑

  • 1.timer值赋为setTimeout的ID(不再为null)
  • 2.执行func.apply(context, args) 我们再次执行函数的条件有两个
  • 1.再次点击按钮,触发debounced
  • 2.timer的值为null(callNow值为true)

点击的条件我们不用管,因为这时我们肯定点击的很快,那么第二个条件如何满足呢?

就是要执行完下面的定时器

ini 复制代码
timer = setTimeout(() => {
        timer = null;
      }, wait);

但是如果wait时间还没有过,你就又点了一次按钮,就会重新产生一个setTimeout,这个setTimeout会重新开始计时wait,之前的哪个计时器呢?

我们可以看到手写代码的第16行

scss 复制代码
if (timer) clearTimeout(timer);

没错,之前的定时器已经被我们清除了。

所以只有当我们不再继续点击按钮(不再继续产生新的定时器(不再删除之前的计时器)),过去了wait时间才会再次执行函数

默认防抖

嗯,默认防抖的逻辑我认为知识立即执行防抖的一部分,也是十分的简单,让我们把注意力转移到下一个问题,下一个问题可谓是防抖函数的点睛之笔------闭包!

为何上次的防抖函数的timer值会保存下来?

相信大家都意识到了,我们的setTimeOut每次都会换一个新的,但是timer貌似还是会'继承'上次执行防抖函数的值,这是为何?

这就涉及到了一个知识点------闭包,观察一下我们的timer是在哪里定义的?是在debounce中,而我们返回的防抖函数debounced引用了外部的变量(也就是在debounce中的),所以这个变量不会销毁,而且会被一直'继承下去',而我们的setTimeOut就没有这么好运了。

为何要单独设置this的指向

在 JavaScript 中,函数内部的 this 值取决于函数被调用的方式,而非定义的位置。

防抖函数通过 setTimeout 延迟执行,而定时器中的函数默认在全局上下文中调用,但是我们要将this指向目标函数的上下文环境,所以要显示绑定this环境

五、手写节流函数

现在想象你在抢国庆节回家的火车票马上就要到抢票的时间了10,9,8,7.....疯狂的去点击抢票的按钮 ,你如果这个按钮进行了默认防抖(或者立即执行防抖) ,会在你第一次点击抢票按钮之后的3s内你不再点击该按钮才给你执行抢票的逻辑吗(这是因为连续的点击会重置冷却时间)这显然是不合理的

那么我们就要进行另外一种方法,节流

执行逻辑如下

1.马上开始抢票了,5,4,3,2,1!
2.可以抢票了,狂点抢票按钮
3.第一次点击抢票按钮,会立即执行你的抢票逻辑,但是你肯定不会就点一次,你肯定会狂点第二次,第三次第四次...
4.但是是不会响应你的狂点的,第一次执行之后会进入一个3s(这里的3s也是假设)的冷却期,该期间的一切点击都没有用
5.3s冷却器过了你再次点击会再次立即执行抢票逻辑,之后就又有一个3s的冷却....

当然,你也可以看作你打游火影时的技能冷却,超哥的小神罗冷却是5s,你小神罗顶掉对面的散后对面再交熊猫你就没法用小神罗顶了,因为在冷却,但是5s过后就又可以顶其他的东西了,所以小神罗不要随便交

注:这里并没有防抖的"延长冷却机制"(就是3,2...3..3,2,1!这种机制)

节流的模式主要有两种,一种是时间戳节流,另一种是定时器节流,接下来分别介绍一下两种节流模式

时间戳节流

我称之为立即执行节流,当然,可以忽略这个称呼,这是我编的

该节流的特点是,首次触发节流函数会立即执行目标函数,然后等待delay时间,在delay期间触发节流函数,并不会触发目标函数,等到delay时间过去之后才可以再次触发目标函数

多说无益,上代码

ini 复制代码
function throttle(fn, delay) {
  let lastTime = 0;
  return function(...args) {
    const now = Date.now();
    if (now - lastTime >= delay) {
      fn.apply(this, args);
      lastTime = now;
    }
  };
}

这个代码很简单,但我这里做些许的解释

1.基准时间为lastTime,这个可以是第一次触发防抖函数的时间,也可以是上一次执行过目标函数的时间

2.每次触发节流函数都会更新当前的时间const now = Date.now();,当第一次触发时if中的条件是(当前时间戳)-0>=0true,所以会立即执行

3.之后的delay时间内不会触发目标函数,delay时间过去再次触发防抖函数,就会将这时的时间now设置为基准时间lastTime

......

定时器节流

我称之为延迟节流,这个也可以忽略...

该节流的特点是,首次触发节流函数并不会立即执行目标函数,然后等待delay时间,在delay期间触发节流函数,并不会触发目标函数,等到delay时间过去之后才可以首次执行目标函数

下面是代码实现

ini 复制代码
function throttle(fn, delay) {
  let timer = null;
  return function(...args) {
    if (!timer) {
      timer = setTimeout(() => {
        fn.apply(this, args);
        timer = null;
      }, delay);
    }
  };
}

1.首次触发节流函数timer=null,所以!timer为true,那么会设置一个定时器,ID'存到timer之中,接下来的delay时间中其不再为null,!timer值为false,不会在设置新的定时器

2.delay时间过后定时器中的回调函数会被触发(如何同步逻辑都执行完了,这里是另外的知识点,这里的时间我们忽略不计),这是首次触发目标函数

3.首次触发回调函数之后,会再次将timer设置为null,则会再次设置定时器

但是,搞懂了上述的节流知识,对于面试来说好不够,我们要搞清楚面试官的图谋(图谋这个词好像不是很合适,但是我不想改)

我们可以尝试这样问一下,体现出自己的专业性:"节流的核心是控制函数在固定时间内只执行一次 ,对吧?您希望这个节流函数支持哪些特性呢?比如是否需要首次触发立即执行(leading)最后一次触发是否延迟执行 (trailing),或者是否需要考虑上下文绑定(this 指向)和参数传递?"

非常的专业

那么我们在接下来的手写代码中就要体现这些考虑,不然就翻车了

完整手写代码

ini 复制代码
function throttle(fn, delay, { leading = true, trailing = true } = {}) {
  let lastTime = 0; // 记录上次执行时间
  let timer = null; // 定时器标识

  // 包装后的函数
  const throttled = function(...args) {
    const now = Date.now(); // 当前时间戳

    // 若首次触发且不需要立即执行,初始化lastTime
    if (!lastTime && !leading) lastTime = now;

    // 计算剩余时间(距离下次可执行的时间)
    const remaining = delay - (now - lastTime);

    // 情况1:超过间隔时间,立即执行
    if (remaining <= 0) {
      // 清除可能存在的定时器(避免trailing重复执行)
      if (timer) {
        clearTimeout(timer);
        timer = null;
      }
      fn.apply(this, args); // 绑定this和参数
      lastTime = now; // 更新执行时间
    } 
    // 情况2:未超过间隔,且需要trailing执行,设置定时器
    else if (trailing && !timer) {
      timer = setTimeout(() => {
        fn.apply(this, args);
        lastTime = leading ? Date.now() : 0; // 重置lastTime(配合leading)
        timer = null; // 清空定时器
      }, remaining);
    }
  };

  // 提供取消方法(可选,体现完整性)
  throttled.cancel = function() {
    if (timer) clearTimeout(timer);
    timer = null;
    lastTime = 0;
  };

  return throttled;
}

这里讲一些细节

如何控制首次触发立即执行

如果不是首次触发会执行下面的语句,if (!lastTime && !leading) lastTime = now;,会将基准时间设置为当前时间按戳,那么 const remaining = delay - (now - lastTime);就会等于delay-((当前时间戳)-(当前时间戳)),包大于0不会立即执行

如果是首次执行,那么就是remaining=delay-((当前时间戳)-(0)),包小于零

注:当前时间戳是一个很大的数字,类似1620000000000,不等于0

如何控制最后一次延迟执行

首先我们要搞懂什么叫'最后一次触发',首先必须时连续点击的最后一次点击,而且该连续点击的间隔要小于delay,知道这个概念可以方便我们更好的理解最后一次延迟执行的概念

下面我们来分析,我们连续点击了3次该节流按钮(delay=300)

1.第一次点击按钮(t=1000),立即执行了,很好

2.第二次点击(t=1100),进入了else if (trailing && !timer)逻辑(因为此时timer为空),设置了一个定时器,里面的回调函数会在remaining后执行(remaining=300-(1100-1000)=200)

3.第三次点击(t=1200),不会进入else if (trailing && !timer)逻辑(因为此时timer不为空),此时的remaining=100>0也不会进入if (remaining <= 0)逻辑,所以这次点击什么都不会执行,这时还剩100,我们设置的定时器的回调函数就会执行了

4.t=1300时,我们的回调函数执行了,而且因为期间并没有触发防抖函数,所以就算此时的remaining<0也不会触发if (remaining <= 0)的逻辑

纵观上面的执行过程,哪一次点击是最后一次点击?

第三次

延迟了吗?延迟了多长时间?

延迟了,延迟了100(1300-1200=100)

现在我们再来深究一下'最后一次延迟执行'的含义。

我将其理解为,最后一次操作要执行,不要忽略。

如果没有else if (trailing && !timer)逻辑设置的定时器,那么就代表我们的最后一次操作被忽略了

那么我们如此的大费周章整这些限制是为什么呢?有什么应用场景?

这是我们给面试官秀肌肉的最后机会了

1、leading: true(首次触发立即执行)的应用场景

leading: true 表示连续触发事件时,第一次触发会立即执行函数 ,之后按节流间隔(如 300ms)限制执行频率。适用于需要 "快速响应初始动作" 的场景,避免用户操作后因节流延迟而感觉 "无反馈"。

  1. 页面滚动加载(无限滚动)
    当用户滚动到页面底部时,需要加载更多内容。首次触发滚动到底部的事件时,应立即执行加载逻辑(leading: true),避免延迟导致用户等待感。后续快速滚动时,按间隔限制加载频率(防止多次请求),但首次必须快速响应。
  2. 拖拽元素实时定位
    拖拽元素时,第一次拖动的瞬间需要立即更新元素位置(leading: true),让用户感受到 "拖拽即动" 的流畅性。后续拖动过程中按间隔更新位置(减少计算压力),但初始响应必须及时。
  3. 按钮连续点击(防重复提交)
    点击按钮提交表单时,首次点击应立即执行提交逻辑(leading: true),同时用节流限制后续短时间内的点击(防止重复提交)。用户能立即看到反馈(如 "提交中"),避免疑惑。

2、trailing: true(最后一次触发延迟执行)的应用场景

trailing: true 表示连续触发结束后,最后一次触发会在节流间隔结束后执行 ,确保最终状态被处理。适用于需要 "捕捉最终结果" 的场景,避免因节流限制丢失最后一次操作的影响。

  1. 搜索框实时联想
    用户快速输入关键词(如 "苹果手机"),过程中会连续触发输入事件。节流会限制联想请求的频率(如每 300ms 一次),但最后一次输入("手机")可能在间隔内,此时 trailing: true 会在 300ms 后执行联想请求,确保用最终关键词 "苹果手机" 发起搜索,而不是中间的 "苹果"。
  2. 窗口大小调整(resize 事件)
    用户拖动窗口边缘调整大小,会连续触发 resize 事件。节流限制每 500ms 处理一次,但最后一次调整的窗口尺寸才是用户想要的最终大小。trailing: true 会在停止拖动后 500ms 执行处理逻辑(如重新布局页面),确保用最终尺寸计算。
  3. 滑动进度条(视频进度拖拽)
    用户快速拖动视频进度条,过程中会连续触发进度更新事件。节流限制每 200ms 更新一次进度,但最后一次拖动的位置是用户想要的最终进度。trailing: true 会在停止拖动后 200ms 执行进度更新,确保跳转到用户最终选择的时间点。
  4. 鼠标跟随动画(如拖拽时的提示框)
    拖拽元素时,提示框需要跟随鼠标位置。连续拖动时按间隔更新位置,但最后一次拖动停止后,trailing: true 会确保提示框最终停在鼠标停止的位置,而不是中途的某个位置。

3、leadingtrailing 的配合选择

  • 同时开启(leading: true + trailing: true :适用于既需要初始响应,又需要最终状态的场景(如拖拽 + 定位)。但需注意:在节流间隔刚好等于触发间隔时,可能导致首尾各执行一次(需代码处理避免重复)。
  • 只开 leading:适用于 "初始响应优先,中间过程可简化,无需最终处理" 的场景(如按钮点击防重复)。
  • 只开 trailing:适用于 "过程不重要,最终结果才关键" 的场景(如搜索联想、窗口调整)。

那么让我们总结一下,leading和trailing的底层影响是什么

  • leading: true 解决 "初始响应不及时" 的问题,让用户第一时间感受到操作反馈;
  • trailing: true 解决 "最终状态丢失" 的问题,确保最后一次操作的结果被正确处理。

OK,掌握这么多,应该是足够了。

六、手写call函数

call 是 JavaScript 中函数对象的一个方法,主要作用是改变函数执行时的 this 指向,并立即执行该函数。

具体来说,它的核心功能有两个:

  1. 显式绑定 this
    可以指定函数运行时内部 this 关键字所指向的对象 ,突破了函数默认的 this 绑定规则(如全局对象、调用者对象等)。
  2. 传递参数并执行函数
    除了第一个参数用于指定 this 指向外,后续参数会作为函数的实参传入,并且调用后会立即执行该函数。

下面举个例子方便理解

javascript 复制代码
function showInfo(age) {
  console.log(`姓名:${this.name},年龄:${age}`);
}
const person = { name: "张三" };
// 通过 call 让 showInfo 中的 this 指向 person,并传入参数 20
showInfo.call(person, 20); // 输出:姓名:张三,年龄:20

这里通过 callshowInfo 函数的 this 绑定到了 person 对象,同时传递了参数 20,并立即执行了函数。

下面是一些我们手写Call()的一些前置的知识

Symbol

Symbol 是 ES6 引入的一种新的原始数据类型,它的主要特点是唯一性------ 每一个 Symbol 实例都是独一无二的,这一特性使其非常适合作为对象的私有属性键。

在手写 call () 中的应用

在实现 myCall 时,我们需要将目标函数临时挂载到 context 对象上,代码如下:

ini 复制代码
// 生成一个唯一的属性名
const fnKey = Symbol('fn');
// 将当前函数作为context的属性
context[fnKey] = this;

为什么要用 Symbol 而不是普通字符串作为属性名?

  1. 避免属性名冲突 :如果使用普通字符串(如 'fn'),可能会覆盖 context 对象上已有的同名属性,导致不可预期的副作用。而 Symbol 的唯一性保证了不会与任何现有属性键冲突。
  2. 模拟私有属性 :虽然 JavaScript 没有真正的私有属性,但 Symbol 属性不会出现在 for...in 循环或 Object.keys() 结果中,能避免污染对象的可见属性。
  3. 不影响原对象结构 :配合后续的 delete 操作,可以彻底清除临时添加的属性,让 context 对象恢复原状。

Symbol 的特性

  • 通过 Symbol([描述符]) 创建,描述符仅用于调试,不影响唯一性
  • 不能与其他类型的值进行运算
  • 可以作为对象属性键、数组索引,也可用于定义常量

[].slice

[].slice 是 JavaScript 数组原型上的一个方法,用于从数组中提取指定范围的元素,返回一个新的数组,而不会修改原数组。

基本语法

c 复制代码
array.slice(startIndex[, endIndex])
  • startIndex:必需,提取的起始位置(索引)。

    • 若为正数:从数组开头(索引 0)开始计算。
    • 若为负数:从数组末尾开始计算(如 -1 表示最后一个元素)。
  • endIndex:可选,提取的结束位置(不包含该位置的元素)。默认提取到数组末尾。

    • 若省略:提取从 startIndex 到数组末尾的所有元素。
    • 若为负数:同样从数组末尾计算。
  • 返回值:一个包含提取元素的新数组(原数组不变)。

基于上面的知识,我们可以给出手写实现

手写实现

javascript 复制代码
// 在Function原型上添加myCall方法
Function.prototype.myCall = function(context) {
  // 处理context为null/undefined的情况,此时this应指向全局对象
  if (context === null || context === undefined) {
    context = globalThis; // 浏览器环境是window,Node环境是global
  } else {
    // 将非对象类型转换为对象,确保可以添加属性
    context = Object(context);
  }
  // 生成一个唯一的属性名,避免覆盖context原有属性
  const fnKey = Symbol('fn');
  // 将当前函数(this)作为context的属性
  context[fnKey] = this;
  // 获取除了第一个参数之外的其他参数
  const args = [...arguments].slice(1);
  // 调用函数,此时函数内部的this会指向context
  const result = context[fnKey](...args);
  // 删除添加的属性,避免污染原对象
  delete context[fnKey];
  // 返回函数执行结果
  return result;
};
// 测试示例
function greet(greeting, punctuation) {
  return `${greeting}, ${this.name}${punctuation}`;
}
const person = { name: 'Alice' };
// 使用原生call
console.log(greet.call(person, 'Hello', '!')); // 输出: "Hello, Alice!"
// 使用我们实现的myCall
console.log(greet.myCall(person, 'Hi', '?')); // 输出: "Hi, Alice?"
// 测试context为null的情况
console.log(greet.myCall(null, 'Hello', '.')); // 输出: "Hello, undefined." (非严格模式)

下面我们具体分析代码中的细节

边界处理

ini 复制代码
if (context === null || context === undefined) { 
    context = globalThis; // 浏览器环境是window,Node环境是global 
} else { 
// 将非对象类型转换为对象,确保可以添加属性 
    context = Object(context); 
}

和Call()原本的逻辑一样,如果传入的this指向为空,那么就会默认指向全局,而globalThis保证了不论是在浏览器环境还是Node环境都指向全局对象

"将非对象类型转换为对象,确保可以添加属性"是因为我们后续的实现中是基于将所有传参作为一个整体来实现的

存储this指向

我们可以清晰的看到我们存储this的代码

ini 复制代码
context[fnKey] = this;

但是,这个this是什么呢?他代表什么?为什么?

这里是整个手写Call()的核心

我们接下来来分析其中this指向的变化

我们要明确一个概念------

当我们用 对象.属性()对象[属性]() 的语法调用函数时,JavaScript 有一条核心规则: 函数内部的 this 会自动指向调用它的那个对象

那么我们来看下面的代码

ini 复制代码
context[fnKey] = this;

this这是的this指向什么?指向调用它的哪个对象,谁调用的它?就是Function,我们举个例子方便理解

javascript 复制代码
function greet(greeting, punctuation) {
  return `${greeting}, ${this.name}${punctuation}`;
}
const person = { name: 'Alice' };
// 使用我们实现的myCall
console.log(greet.myCall(person, 'Hi', '?')); // 输出: "Hi, Alice?"

这个例子中,this就指向greet()

同时这个this成为了context(即person)的属性,相当于person.greet()

让我们继续往下看

ini 复制代码
const result = context[fnKey](...args);

这句代码又实现了什么?

这句代码相当于下面的代码

arduino 复制代码
person.greet('Hi', '?')

这时的this指向谁?哪个对象调用指向谁!

那就是指向person

至此我们的myCall就完成了它的使命,把this绑定在了person上!

七、手写 apply 函数

我只能说这是一个福利题,apply和call的手写实现十分相像,唯一不同的就是好传参的不同,那么在这个手写题中我会补充一些call中没有提到的细节,也会对比来写

在开始之前,Call和apply的区别是什么

核心区别:参数传递方式不同

特征 call方法 apply方法
参数形式 接收参数列表(逗号分隔的多个参数) 接收参数数组(一个数组或类数组对象)
语法示例 fn.call(context, arg1, arg2, ...) fn.apply(context, [arg1, arg2, ...])
参数处理逻辑 从第二个参数开始,依次作为函数实参 第二个参数必须是数组,数组元素作为函数实参

在call中可以传递很多参数,之间用','隔开,第一个参数是this指向的对象,剩下的参数都作为参数传给调用call的函数

而apply允许传入两个参数,第一个参数是this指向,第二个参数是一个数组,数组的内容一起传给调用apply的函数

那么我们在实际使用时如何选择?

如果我们可以确定参数的个数,可以使用call,但是如果不确定参数的格式,就要使用apply了,但是大多数情况下,我们会选择使用apply,这样就可以避免参数个数带来的问题

javascript 复制代码
// 在Function原型上添加myApply方法
Function.prototype.myApply = function(context, argsArray) {
  // 处理context为null/undefined的情况,此时this应指向全局对象
  if (context === null || context === undefined) {
    context = globalThis; // 浏览器环境是window,Node环境是global
  } else {
    // 将非对象类型转换为对象,确保可以添加属性
    context = Object(context);
  }
  
  // 生成一个唯一的属性名,避免覆盖context原有属性
  const fnKey = Symbol('fn');
  
  // 将当前函数(this)作为context的属性
  context[fnKey] = this;
  
  // 处理参数:如果未传入参数数组或不是数组,使用空数组
  const args = Array.isArray(argsArray) ? argsArray : [];
  
  // 调用函数,此时函数内部的this会指向context,并传入参数数组
  const result = context[fnKey](...args);
  
  // 删除添加的属性,避免污染原对象
  delete context[fnKey];
  
  // 返回函数执行结果
  return result;
};

// 测试示例
function greet(greeting, punctuation) {
  return `${greeting}, ${this.name}${punctuation}`;
}

const person = { name: 'Alice' };

// 使用原生apply
console.log(greet.apply(person, ['Hello', '!'])); // 输出: "Hello, Alice!"

// 使用我们实现的myApply
console.log(greet.myApply(person, ['Hi', '?'])); // 输出: "Hi, Alice?"

// 测试无参数情况
console.log(greet.myApply(person)); // 输出: "undefined, Aliceundefined"

// 测试context为null的情况
console.log(greet.myApply(null, ['Hello', '.'])); // 输出: "Hello, undefined."

下面补充一些之前讲手写call没有提到的说明

ini 复制代码
const args = [...arguments].slice(1);

其中的arguments是每个函数都有的属性,代表参数的集合,...arguments则是使用了展开运算符,讲所有的参数都展开

为什么所有的方法都可以使用我们手写的call|apply?

那是因为我们的方法挂了到了Function.prototype上,而每一个函数都可以通过原型链找到Function.prototype上方法,也就可以使用我们手写的call|apply

八、手写 bind 函数

bind相对于call或者apply会复杂一些,因为其功能更丰富,我们先来介绍一下bind

bind 是 JavaScript 中用于改变函数 this 指向 的方法,其核心特性是延迟绑定 :调用后不会立即执行原函数,而是返回一个新的绑定函数,该新函数的 this 被永久固定为 bind 第一个参数指定的对象,后续参数则会与新函数调用时传入的参数合并(支持柯里化传参)

javascript 复制代码
// 在Function原型上添加myBind方法
Function.prototype.myBind = function(context) {
  // 保存当前函数(this指向调用myBind的函数)
  const self = this;
  
  // 边界检查:确保调用者是函数
  if (typeof self !== 'function') {
    throw new TypeError('The bound object must be a function');
  }
  
  // 提取myBind的参数(除了第一个context外),用于柯里化
  const bindArgs = [...arguments].slice(1);
  
  // 定义返回的绑定函数
  const boundFunction = function() {
    // 提取新函数调用时的参数
    const callArgs = [...arguments];
    
    // 合并绑定参数和调用参数(柯里化)
    const allArgs = bindArgs.concat(callArgs);
    
    // 关键:判断是否通过new调用(实例化)
    // 如果是实例化,this应指向新创建的实例;否则指向context
    const isNew = this instanceof boundFunction;
    const targetContext = isNew ? this : context;
    
    // 调用原函数并返回结果
    return self.apply(targetContext, allArgs);
  };
  
  // 保持原函数的原型链(让实例能访问原函数原型上的属性)
  if (self.prototype) {
    boundFunction.prototype = Object.create(self.prototype);
    // 修复构造函数指向
    boundFunction.prototype.constructor = boundFunction;
  }
  
  return boundFunction;
};

// 测试示例
function Person(name, age) {
  this.name = name;
  this.age = age;
}

Person.prototype.sayHi = function() {
  return `Hi, I'm ${this.name}, ${this.age} years old`;
};

// 测试1:基础绑定
const obj = {};
const BoundPerson = Person.myBind(obj, 'Alice');
BoundPerson(20);
console.log(obj); // { name: 'Alice', age: 20 }

// 测试2:柯里化传参
const BoundPerson2 = Person.myBind(null, 'Bob');
const person2 = new BoundPerson2(25);
console.log(person2.sayHi()); // "Hi, I'm Bob, 25 years old"

// 测试3:实例化场景(this指向实例)
const BoundPerson3 = Person.myBind(obj);
const person3 = new BoundPerson3('Charlie', 30);
console.log(person3.name); // "Charlie"(不影响obj)
console.log(obj.name); // undefined(验证实例化时this正确指向实例)

// 测试4:原型链继承
console.log(person3 instanceof Person); // true(继承原函数原型)

下面我们来分析一下这些代码

为什么要判断调用者是否为function?

我们可以观察到,手写call和apply时并不需要判断是否调用者为函数,而bind却需要判断,这是为何?

这和bind的实际理念有关

  • 原生 bind 有明确的规范,非函数调用必须报错;
  • bind 的核心逻辑(返回绑定函数)依赖调用者是函数,否则整个逻辑无意义。

柯里化实现

bind的传参是允许柯里化的,什么是柯里化?我们可以暂且理解为分段传参 ,就比如我们想传入一个[1,2],但是允许先传一个1,再传一个2,具体的柯里化后文会详细讲解

这里使用了闭包实现了传参柯里化

原型链

javascript 复制代码
  // 保持原函数的原型链(让实例能访问原函数原型上的属性)
  if (self.prototype) {
    boundFunction.prototype = Object.create(self.prototype);
    // 修复构造函数指向
    boundFunction.prototype.constructor = boundFunction;
  }

与call和apply不同的是,bind是返回一个新的函数,所以新的函数的原型链我们需要"接一下",而且接的是原函数的原型链

为什么要判断是否通过new调用

kotlin 复制代码
// 关键:判断是否通过new调用(实例化)
    // 如果是实例化,this应指向新创建的实例;否则指向context
    const isNew = this instanceof boundFunction;
    const targetContext = isNew ? this : context;

判断是否通过 new 调用(即是否作为构造函数实例化),是为了兼容原生 bind 方法的核心特性 ------ 当绑定函数被当作构造函数使用时,this 应指向新创建的实例,而非 bind 时指定的 context

九、实现AJAX请求

我们这里以实现get请求为例,其他的请求方式类似

javascript 复制代码
// 创建XMLHttpRequest对象
var xhr = new XMLHttpRequest();

// 配置请求:请求方式、URL、是否异步
xhr.open('GET', 'https://api.example.com/data', true);

// 设置请求头(可选,根据需要添加)
xhr.setRequestHeader('Content-Type', 'application/json');

// 定义请求完成后的回调函数
xhr.onload = function() {
    // 请求成功(状态码200-299)
    if (xhr.status >= 200 && xhr.status < 300) {
        // 解析响应数据
        var responseData = JSON.parse(xhr.responseText);
        console.log('请求成功:', responseData);
    } else {
        // 请求失败
        console.log('请求失败,状态码:', xhr.status);
    }
};

// 网络错误时的处理
xhr.onerror = function() {
    console.log('网络错误');
};

// 发送请求
xhr.send();

怎么说呢,我将其总结为四部分

  • 配置部分
  • 处理回调部分
  • 处理网络错误部分
  • 发送请求部分

大家可以自己去代码中找一找这几部分,加深理解

十、实现深拷贝

深拷贝 是指创建一个新的对象,完全复制原对象的所有属性和嵌套对象,新旧对象之间完全独立,修改其中一个不会影响另一个
浅拷贝 指的是将一个对象的属性值复制到另一个对象,如果有的属性的值为引用类型的话,那么会将这个引用的地址复制给对象 ,因此两个对象会有同一个引用类型的引用

这里我们来实现深拷贝

预备知识

这里我们还是需要一些前置的知识的

WeakMap

WeakMap 是 JavaScript 中的一种特殊集合类型,主要用于存储键值对,但其特性与普通的 Map 有显著区别 ,尤其在内存管理方面有独特优势

  1. 键必须是对象类型

    WeakMap 的键只能是对象(或继承自 Object 的类型,如数组、函数等),不能是基本类型(number、string、boolean 等)。如果使用基本类型作为键,会直接报错。 const wm = new WeakMap(); wm.set({}, 'value'); // 有效 wm.set(123, 'value'); // 报错:TypeError: Invalid value used as weak map key

  2. 弱引用特性(核心)

    WeakMap 对键的引用是「弱引用」 ,这意味着:当键对象没有其他强引用时,垃圾回收机制 (GC)会自动回收该对象的内存,同时 WeakMap 中对应的键值对也会被自动移除,不会阻碍垃圾回收

    对比普通 Map:

    ini 复制代码
    // 普通 Map(强引用)
    const map = new Map();
    let obj = { name: 'test' };
    map.set(obj, 'value');
    obj = null; // 虽然obj被置空,但Map仍保留对原对象的强引用,原对象不会被GC回收
    
    // WeakMap(弱引用)
    const wm = new WeakMap();
    let obj2 = { name: 'test' };
    wm.set(obj2, 'value');
    obj2 = null; // obj2的强引用消失,原对象会被GC回收,wm中对应的键值对也会被移除
  3. 不可遍历性

    WeakMap 没有 keys()values()entries() 等遍历方法,也没有 size 属性,无法获取其中的键值对数量或遍历所有内容。这是因为键可能随时被 GC 回收,遍历操作没有稳定的结果

  4. 常用方法

    仅支持四种基本操作:

    • wm.set(key, value):添加键值对(返回 WeakMap 实例)
    • wm.get(key):根据键获取值(键不存在则返回 undefined)
    • wm.has(key):判断是否存在指定键(返回 boolean)
    • wm.delete(key):删除指定键值对(返回 boolean,表示是否删除成功)

Reflect.ownKeys(obj)

这是一个获取对象所有自有属性(不包括继承的属性)的方法

返回值是一个包含所有属性键的数组,包括:

  • 可枚举属性
  • 不可枚举属性
  • ymbol 类型的属性

相当于 Object.getOwnPropertyNames(obj).concat(Object.getOwnPropertySymbols(obj)) 的更简洁写法

手写代码实现

javascript 复制代码
function deepClone(obj, hash = new WeakMap()) {
  // 处理null和基本数据类型
  if (obj === null || typeof obj !== 'object') {
    return obj;
  }
  
  // 处理循环引用
  if (hash.has(obj)) {
    return hash.get(obj);
  }
  
  let cloneObj;
  
  // 处理日期对象
  if (obj instanceof Date) {
    cloneObj = new Date();
    cloneObj.setTime(obj.getTime());
    hash.set(obj, cloneObj);
    return cloneObj;
  }
  
  // 处理正则对象
  if (obj instanceof RegExp) {
    cloneObj = new RegExp(obj.source, obj.flags);
    hash.set(obj, cloneObj);
    return cloneObj;
  }
  
  // 处理数组和对象
  cloneObj = Array.isArray(obj) ? [] : {};
  hash.set(obj, cloneObj);
  
  // 递归拷贝属性
  Reflect.ownKeys(obj).forEach(key => {
    cloneObj[key] = deepClone(obj[key], hash);
  });
  
  return cloneObj;
}

// 测试用例
const obj = {
  a: 1,
  b: 'hello',
  c: [1, 2, 3],
  d: { x: 10, y: 20 },
  e: new Date(),
  f: /abc/g,
};
obj.self = obj; // 循环引用

const clonedObj = deepClone(obj);
console.log(clonedObj);
console.log(clonedObj !== obj); // true
console.log(clonedObj.c !== obj.c); // true
console.log(clonedObj.d !== obj.d); // true
console.log(clonedObj.self === clonedObj); // true,正确处理循环引用

这里对代码的逻辑进行一些讲解

首先是对特殊情况的处理

csharp 复制代码
if (obj === null || typeof obj !== 'object') { 
    return obj; 
}

对于null和基本数据类型,我们可以直接返回,因为这些并不是引用属性

怎么就return了

我们可以看到我们的深拷贝方法有很多的return,这是为何,这不会导致方法提前结束吗?

对于处理日期对象和正则对象,我们单独进行了return,这里是处理整个对象,而不是一个对象中的某个方法(对象),相反,这里的return是为了之后的递归做准备

认真看一下我们下面的代码

ini 复制代码
Reflect.ownKeys(obj).forEach(key => { 
    cloneObj[key] = deepClone(obj[key], hash); 
});

这里我们对待拷贝对象的每一个属性进行了递归调用,正确处理了待拷贝对象的每一个属性(包括对象属性)

如何避免的循环引用

什么是循环引用

这里举一个最简单的循环引用的例子

ini 复制代码
onst obj = { name: "测试" };
obj.self = obj; // obj的self属性引用obj自己
// 结构示意:obj → self → obj → self → ...(无限循环)

我们来看一下对这部分的循环引用,我们要拷贝obj.self,我们发现其值是obj,那么我们递归去拷贝obj,我们发现拷贝obj时又碰到了obj.self,于是我们又要去拷贝obj...

那么我们在手写深拷贝时如何去规避这个循环引用问题呢?

kotlin 复制代码
// 处理循环引用 
if (hash.has(obj)) { 
    return hash.get(obj); 
}

规避循环引用的核心机制是通过 WeakMap 记录已拷贝的对象 ,当检测到重复引用时直接返回已拷贝的版本,从而避免无限递归

再用我们之前的例子来推一遍

我们要拷贝obj.self,我们发现其值是obj,那么hash里面有没有obj呢?有的,在我们第一次进入深拷贝时就已经有了

python 复制代码
hash.set(obj, cloneObj);

那么就会直接用之前拷贝过的obj,从而避免了循环引用

当然,我们也可以给面试官讲一些其他的深拷贝知识,展示一下自己的知识面之广,比如

函数库lodash的_.cloneDeep方法

这个方法可以直接进行深拷贝,使用方式如下

ini 复制代码
obj1={
.....
}
obj2=_.cloneDeep(obj1)

十一、实现数组的乱序输出

实现数组的乱序输出的意思,就是实现数组的乱序输出,确实没啥好讲的

实现乱序输出主要有两种方法,可以给面试官介绍这两种方法,然后我们可以给面试官推荐一下使用哪一个

前置知识

Math.floor(Math.random() * (i + 1))

  1. Math.random()
  • 功能:生成一个 [0, 1) 区间的伪随机浮点数(包含 0,不包含 1)
  1. Math.random() * (i + 1)
  • 功能:将随机数范围缩放至 [0, i + 1)
  • 原理:乘法缩放。假设 i = 5,则 i + 1 = 6,结果范围是 [0, 6)
  1. Math.floor(...)
  • 功能:对结果向下取整,得到 [0, i] 区间的整数(包含 0 和 i)
  • 原理:Math.floor 会去掉小数部分,只保留整数部分

i 是一个整数时,Math.floor(Math.random() * (i + 1))随机生成一个 0 到 i 之间(包含 0 和 i)的整数,且每个整数被选中的概率均等。

sort方法

你可能是想了解 JavaScript 中的 sort() 方法 (推测是拼写误差)。sort() 是数组对象的一个核心方法,用于对数组元素进行排序,并返回排序后的数组(会修改原数组)。

一、基本用法

ini 复制代码
const arr = [3, 1, 4, 2];
arr.sort(); 
console.log(arr); // [1, 2, 3, 4](默认升序)
  • 返回值:排序后的原数组(注意:不是新数组,会直接修改原数组)。
  • 默认排序规则:将元素转换为字符串后,按照 Unicode 编码顺序排序。

二、自定义排序(核心)

sort() 可以接收一个比较函数(callback) ,用于自定义排序逻辑。比较函数的返回值决定了元素的排序顺序:

css 复制代码
function compare(a, b) {
  // a 和 b 是数组中相邻的两个元素
  if (a < b) return -1; // a 排在 b 前面
  if (a > b) return 1;  // b 排在 a 前面
  return 0;             // a 和 b 位置不变
}

const arr = [3, 1, 4, 2];
arr.sort(compare); 
console.log(arr); // [1, 2, 3, 4](升序)

简化写法(我们代码中使用的):

  • 升序:arr.sort((a, b) => a - b)
  • 降序:arr.sort((a, b) => b - a)

三、注意事项

  1. 原数组被修改
    sort() 是 "原地排序",会直接改变原数组。如果需要保留原数组,需先复制:

    css 复制代码
    const arr = [3, 1, 4, 2];
    const sortedArr = [...arr].sort((a, b) => a - b); // 复制后排序,不影响原数组
  2. 默认排序的坑

    默认按字符串编码排序,可能导致数字排序异常:

    css 复制代码
    const arr = [10, 2, 100];
    arr.sort(); // 结果:[10, 100, 2](因为 "10" 的首字符 "1" 在 "2" 之前)
    // 正确做法:用比较函数
    arr.sort((a, b) => a - b); // [2, 10, 100]
  3. 复杂对象排序

    可通过比较函数对对象的某个属性排序:

    css 复制代码
    const users = [  { name: 'Bob', age: 25 },  { name: 'Alice', age: 20 }];
    // 按 age 升序
    users.sort((a, b) => a.age - b.age); 

手写实现

scss 复制代码
// 方法1: Fisher-Yates 洗牌算法(推荐)
function shuffleArray(arr) {
  // 先复制原数组,避免修改原数组
  const newArr = [...arr];
  
  // 从数组末尾开始遍历
  for (let i = newArr.length - 1; i > 0; i--) {
    // 生成一个0到i之间的随机索引
    const j = Math.floor(Math.random() * (i + 1));
    
    // 交换第i个和第j个元素
    [newArr[i], newArr[j]] = [newArr[j], newArr[i]];
  }
  
  return newArr;
}

// 方法2: 使用sort方法(不推荐,随机性不均匀)
function shuffleWithSort(arr) {
  // 复制原数组
  const newArr = [...arr];
  
  // 使用sort和随机数进行排序
  newArr.sort(() => Math.random() - 0.5);
  
  return newArr;
}

// 测试
const original = [1, 2, 3, 4, 5, 6, 7, 8, 9];
console.log("原数组:", original);
console.log("Fisher-Yates洗牌结果:", shuffleArray(original));
console.log("sort方法洗牌结果:", shuffleWithSort(original));

// 验证Fisher-Yates算法的均匀性(大量测试)
function testShuffleUniformity() {
  const arr = [0, 1, 2];
  const counts = Array.from({length: 3}, () => Array(3).fill(0));
  
  // 测试100万次
  for (let i = 0; i < 1000000; i++) {
    const shuffled = shuffleArray(arr);
    shuffled.forEach((val, idx) => counts[val][idx]++);
  }
  
  console.log("各元素在各位置出现的次数(应大致相等):");
  console.log(counts);
}

// 运行均匀性测试
testShuffleUniformity();

其实代码很容易看懂,但是我们来讨论一个问题,为什么shot方法会导致随机性不均匀?

  1. sort() 的比较次数是固定的,且与数组长度相关

    对于长度为 n 的数组,sort() 会进行固定次数的元素比较(例如,V8 中对短数组用插入排序,比较次数约为 级别)。每次比较时,随机返回 -11(通过 Math.random() - 0.5 实现,50% 概率返回正数,50% 负数),但固定的比较次数无法覆盖所有可能的排列组合,导致某些元素的位置被 "偏爱"。

    举个例子:

    对于 [a, b, c]sort() 会进行 3 次比较(假设内部用插入排序):

    • 比较 ba
    • 比较 cb
    • 比较 ca(可能)
      这 3 次随机比较的结果,无法让 6 种可能的排列(3! = 6)以均等概率出现。

我将其总结为固定的比较次数和概率相等之间的矛盾

  1. 比较函数的 "传递性" 被破坏

    正常的排序比较函数需要满足传递性 (例如,若 a > bb > c,则 a > c),但随机返回的比较结果(-11)会打破这种传递性。
    sort() 内部算法依赖传递性来优化排序过程,当传递性被破坏时,排序的稳定性会下降,最终导致元素位置的概率分布不均匀。

  2. 不同引擎的实现差异加剧了不均匀性

    不同 JavaScript 引擎(如 V8、SpiderMonkey)的 sort() 实现不同(例如,短数组用插入排序,长数组用快速排序或 Timsort),比较次数和比较顺序会不同,但无论哪种实现,都无法通过随机比较函数得到均匀的概率分布。

十二、实现数组的扁平化

数组扁平化是指将一个多维数组转换为一维数组的过程。例如将 [1, [2, [3, 4], 5]] 转换为 [1, 2, 3, 4, 5]。

当我们对一个问题的解法有多种的时候,我们就可以进行一些深入的交流

"您是否需要版本控制?""您想让我使用什么技术实现?reduce怎么样?"

看着是不是很专业,对吧,面试就是要这种效果。

前置知识

reduce

reduce() 是数组的高阶函数,用于将数组元素通过回调函数 "累积" 为单个值,语法为 arr.reduce(callback, initialValue)

核心逻辑:

  • 遍历数组,每次用回调函数处理当前元素,将结果存入 "累加器"(acc
  • 回调函数返回值作为下一次的累加器值
  • 最终返回累加器的最终结果

用途广泛:求和、数组扁平化、对象分组等。例如数组求和:

javascript 复制代码
[1,2,3].reduce((acc, curr) => acc + curr, 0); // 6

initialValue 是累加器初始值,省略时以数组第一个元素为初始值。

手写代码实现

javascript 复制代码
// 方法1: 使用递归实现(基础版)
function flattenBasic(arr) {
  let result = [];
  for (let i = 0; i < arr.length; i++) {
    // 检查当前元素是否为数组
    if (Array.isArray(arr[i])) {
      // 递归处理子数组,并将结果合并
      result = result.concat(flattenBasic(arr[i]));
    } else {
      // 非数组元素直接添加到结果中
      result.push(arr[i]);
    }
  }
  return result;
}

// 方法2: 带深度控制的递归实现
function flattenWithDepth(arr, depth = Infinity) {
  let result = [];
  for (let i = 0; i < arr.length; i++) {
    // 当还有深度且当前元素是数组时递归处理
    if (depth > 0 && Array.isArray(arr[i])) {
      result = result.concat(flattenWithDepth(arr[i], depth - 1));
    } else {
      result.push(arr[i]);
    }
  }
  return result;
}

// 方法3: 使用reduce实现
function flattenWithReduce(arr, depth = Infinity) {
  return depth > 0 
    ? arr.reduce((acc, val) => 
        acc.concat(Array.isArray(val) ? flattenWithReduce(val, depth - 1) : val), [])
    : arr.slice(); // 当depth为0时,返回原数组的拷贝
}

// 测试
const nestedArray = [1, [2, [3, [4, 5], 6], 7], 8, [9]];
console.log("原始数组:", nestedArray);
console.log("基础递归实现:", flattenBasic(nestedArray));
console.log("带深度控制(2层):", flattenWithDepth(nestedArray, 2));
console.log("reduce实现:", flattenWithReduce(nestedArray));

前两种方法比较容易理解,我们主要来看一下第三种实现(使用reduce实现)

  1. reduce 的初始值[](空数组),作为累加器 acc 的起点。

  2. 回调函数的判断逻辑

    • 对于当前元素 val,先判断是否为数组(Array.isArray(val)):

      • 如果是数组 :递归调用 flattenWithReduce(val, depth - 1),同时将深度减 1(控制扁平化层级),把递归返回的结果合并到 acc 中。
      • 如果不是数组 :直接通过 concatval 添加到 acc 中。
  3. concat 的作用 :无论是合并递归处理后的子数组,还是添加单个元素,都通过 concat 实现数组拼接,最终累积成一个扁平化的数组。

十三、实现数组去重

数组去重仍然有很多的方法可以使用,下面主要讲四个方法,嗯,好像没什么好讲的,都不是很难

方法一二三还是很有必要记住的

手写实现

javascript 复制代码
// 方法1: 使用Set(最简洁,推荐)
function uniqueBySet(arr) {
  // Set自动去重,再转换为数组
  return [...new Set(arr)];
}

// 方法2: 使用indexOf/includes(兼容性好)
function uniqueByIndexOf(arr) {
  const result = [];
  for (let i = 0; i < arr.length; i++) {
    // 检查元素是否已在结果数组中
    if (result.indexOf(arr[i]) === -1) {
      result.push(arr[i]);
    }
  }
  return result;
}

// 方法3: 使用reduce(函数式风格)
function uniqueByReduce(arr) {
  return arr.reduce((acc, curr) => {
    // 如果累加器中没有当前元素,则添加
    if (!acc.includes(curr)) {
      acc.push(curr);
    }
    return acc;
  }, []); // 初始值为空数组
}

// 方法4: 针对对象数组去重(根据指定属性)
function uniqueObjects(arr, key) {
  const seen = new Set();
  return arr.filter(item => {
    const value = item[key];
    // 如果未见过该属性值,则保留并记录
    if (!seen.has(value)) {
      seen.add(value);
      return true;
    }
    return false;
  });
}

// 测试
const primitiveArr = [1, 2, 2, 3, 3, 3, 'a', 'a'];
const objectArr = [
  { id: 1, name: 'A' },
  { id: 2, name: 'B' },
  { id: 1, name: 'A' }
];

console.log("Set去重:", uniqueBySet(primitiveArr)); // [1,2,3,'a']
console.log("indexOf去重:", uniqueByIndexOf(primitiveArr)); // [1,2,3,'a']
console.log("reduce去重:", uniqueByReduce(primitiveArr)); // [1,2,3,'a']
console.log("对象数组去重:", uniqueObjects(objectArr, 'id')); // 保留id=1和id=2的对象

// 特殊情况测试
console.log("包含NaN的去重:", uniqueBySet([NaN, NaN, 1])); // [NaN, 1](Set能正确处理NaN)
console.log("indexOf处理NaN:", uniqueByIndexOf([NaN, NaN, 1])); // [NaN, NaN, 1](indexOf无法识别NaN)

十四、将数字每千分位用逗号隔开

手写实现

javascript 复制代码
// 方法1: 使用原生Intl.NumberFormat(推荐)
function formatWithIntl(num) {
  return new Intl.NumberFormat().format(num);
}

// 方法2: 使用正则表达式
function formatWithRegExp(num) {
  // 处理负数
  const isNegative = num < 0;
  const str = Math.abs(num).toString();
  
  // 分离整数和小数部分
  const parts = str.split('.');
  let integerPart = parts[0];
  const decimalPart = parts[1] || '';
  
  // 正则匹配并插入逗号
  integerPart = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
  
  // 组合结果(包含可能的负数符号和小数部分)
  return `${isNegative ? '-' : ''}${integerPart}${decimalPart ? '.' + decimalPart : ''}`;
}

// 方法3: 循环处理(适合理解原理)
function formatWithLoop(num) {
  const isNegative = num < 0;
  const str = Math.abs(num).toString().split('.');
  let integerPart = str[0];
  const decimalPart = str[1] || '';
  const result = [];
  
  // 从后往前每隔三位添加逗号
  for (let i = integerPart.length; i > 0; i -= 3) {
    const start = Math.max(0, i - 3);
    result.unshift(integerPart.slice(start, i));
  }
  
  // 组合结果
  const formattedInteger = result.join(',');
  return `${isNegative ? '-' : ''}${formattedInteger}${decimalPart ? '.' + decimalPart : ''}`;
}

// 测试
console.log(formatWithIntl(1234567)); // "1,234,567"
console.log(formatWithIntl(1234567.89)); // "1,234,567.89"
console.log(formatWithIntl(-12345)); // "-12,345"

console.log(formatWithRegExp(1234567)); // "1,234,567"
console.log(formatWithRegExp(1234567.89)); // "1,234,567.89"
console.log(formatWithRegExp(-12345)); // "-12,345"

console.log(formatWithLoop(1234567)); // "1,234,567"
console.log(formatWithLoop(1234567.89)); // "1,234,567.89"
console.log(formatWithLoop(-12345)); // "-12,345"

下面我对这些方法进行一些讲解

方法一

formatWithIntl 函数利用 JavaScript 内置的 Intl.NumberFormat API 实现数字的千分位格式化

Intl.NumberFormat 是浏览器处理数字国际化格式化的原生对象,默认会根据环境添加千分位分隔符

调用 format(num) 方法会返回格式化后的字符串,会自动处理:

  • 整数部分每三位加逗号 (如 1234567"1,234,567"
  • 保留小数部分 (如 1234.56"1,234.56"
  • 正确处理负数 (如 -1234"-1,234"

优点:代码极简,无需手动处理复杂逻辑,还支持多语言格式化(通过参数配置),适合现代环境使用。

方法二

方法二主要是下面的地方需要理解

javascript 复制代码
integerPart = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return `${isNegative ? '-' : ''}${integerPart}${decimalPart ? '.' + decimalPart : ''}`;

正则 integerPart = integerPart.replace (/\B (?=(\d {3})+(?!\d))/g, ',');

这行用正则表达式给整数部分添加千分位逗号,核心是正则 /\B(?=(\d{3})+(?!\d))/g

  • \B:匹配非单词边界(避免在数字开头加逗号)
  • (?=(\d{3})+(?!\d)):正向预查,确保当前位置后有3 的倍数个数字(且之后没有其他数字)
  • 整体效果:从右往左,每隔 3 位数字加一个逗号(如 "1234567""1,234,567"

return ${isNegative ? '-' : ''}${integerPart}${decimalPart ? '.' + decimalPart : ''};

这行拼接最终结果,处理三种情况:

  1. 负数符号:如果原数是负数(isNegativetrue),开头加 -
  2. 整数部分:拼接处理好千分位的 integerPart
  3. 小数部分:如果有小数(decimalPart 存在),加 . 和小数部分

当然,如果正则记不住的话也没关系,我也记不住,相信面试官会理解的

十五、实现非负大整数相加

我们当然可以使用BigInt实现非负大整数相加,但是我觉得面试官并不是问这个的,下面是使用字符串模拟非负大数相加的方法

javascript 复制代码
function addBigIntegers(a, b) {
  // 初始化指针(从末尾开始)和进位
  let i = a.length - 1;
  let j = b.length - 1;
  let carry = 0;
  const result = [];
  
  // 遍历两个数字字符串,处理每一位相加
  while (i >= 0 || j >= 0 || carry > 0) {
    // 获取当前位的数字(超出长度则视为0)
    const digitA = i >= 0 ? parseInt(a[i], 10) : 0;
    const digitB = j >= 0 ? parseInt(b[j], 10) : 0;
    
    // 计算当前位总和(包含进位)
    const sum = digitA + digitB + carry;
    
    // 取当前位结果(sum % 10)
    result.push(sum % 10);
    
    // 更新进位(Math.floor(sum / 10))
    carry = Math.floor(sum / 10);
    
    // 移动指针
    i--;
    j--;
  }
  
  // 结果数组反转后拼接成字符串
  return result.reverse().join('');
}

// 测试用例
console.log(addBigIntegers('12345678901234567890', '98765432109876543210')); 
// 输出: "111111111011111111100"

console.log(addBigIntegers('999', '999')); // 输出: "1998"
console.log(addBigIntegers('0', '0')); // 输出: "0"
console.log(addBigIntegers('123', '456789')); // 输出: "456912"

下面介绍一下这个题的核心逻辑

两个大数,从末尾到高位开始加,末尾相加会是一个数字,它的范围是[18,0],然后我们去各位,十位当进位(就是十进一),该进位参与到下一次的倒数第二位的相加

如果一个数字的位数少,那么之后的相加,就假设这个数字在高位上的位数为0

十六、实现 add(1)(2)(3)

这个地方考察的就是函数柯里化,那么什么是柯里化?

函数柯里化 是一种函数转换技术,将接收多个参数的函数,转换为一系列只接收部分参数的函数,这些函数依次调用,最终完成原函数的功能。

下面举个例子

javascript 复制代码
// 普通函数
function add(a, b, c) {
  return a + b + c;
}

// 柯里化后
function curriedAdd(a) {
  return function(b) {
    return function(c) {
      return a + b + c;
    }
  }
}

// 调用方式
curriedAdd(1)(2)(3); // 6

这种就叫做柯里化,但是这种写法太过粗暴,不够优雅,下面的写法才是优雅的写法

javascript 复制代码
function curry(fn) {
  // 记录原函数的参数长度(形参数量)
  const requiredArgs = fn.length;

  // 递归收集参数的内部函数
  function curried(...args) {
    // 情况1:已收集的参数足够,执行原函数
    if (args.length >= requiredArgs) {
      return fn.apply(this, args); // 保持this上下文
    }

    // 情况2:参数不足,返回新函数继续收集
    return function (...nextArgs) {
      // 合并已有参数和新参数,递归调用
      return curried.apply(this, [...args, ...nextArgs]);
    };
  }
  return curried;
}
// 原函数:求三个数的和
function add(a, b, c) {
  return a + b + c;
}

// 柯里化处理
const curriedAdd = curry(add);

// 多种调用方式均生效
console.log(curriedAdd(1)(2)(3));    // 6(分步传参)
console.log(curriedAdd(1, 2)(3));   // 6(先传2个,再传1个)
console.log(curriedAdd(1)(2, 3));   // 6(先传1个,再传2个)
console.log(curriedAdd(1, 2, 3));   // 6(一次性传完)

这种代码看似比较混乱,但是我们深入探究,会发现一个我们的老朋友------闭包

还记得当时我防抖节流是为何可以记住,上次的定时器是否为空的吗?没错,就是闭包

相似的,那么我们这次为什么下一次传参可以记住上次的传参内容,从而完成多次传参呢?还是闭包

让我们把视线移到闭包身上------

当参数不足时,curried 会返回一个新的匿名函数,这个匿名函数引用了:

  • 上层 curried 函数的 args(已收集的参数)
  • 上层的 curried 函数本身
    这使得每次调用时,都能累积参数并继续递归,而这些参数状态不会因函数执行完毕而丢失。

从而完成了可以多次传参的柯里化实现

十七、使用ES5和ES6求函数参数的和

预备知识

arguments 对象

arguments 是 JavaScript 中函数内部的一个类数组对象(array-like object) ,用于存储函数被调用时传入的所有实际参数它在 ES5 及更早版本中是处理不定参数的主要方式

...args

...args 是 JavaScript ES6 引入的剩余参数(Rest Parameters) 语法,用于在函数定义中收集多个传入的参数,将其合并为一个真正的数组 。它解决了 ES5 中 arguments 类数组对象的局限性,是处理不定参数的现代方案

手写实现

javascript 复制代码
// ES5 实现(使用arguments对象)
function sumES5() {
  var total = 0;
  // arguments是类数组对象,包含所有传入的参数
  for (var i = 0; i < arguments.length; i++) {
    total += arguments[i];
  }
  return total;
}

// ES6 实现(使用剩余参数 ...)
function sumES6(...args) {
  // args是真正的数组,包含所有传入的参数
  return args.reduce(function(acc, curr) {
    return acc + curr;
  }, 0);
}

// 更简洁的ES6箭头函数版本
const sumES6Arrow = (...args) => args.reduce((acc, curr) => acc + curr, 0);

// 测试
console.log(sumES5(1, 2, 3, 4)); // 10
console.log(sumES6(1, 2, 3, 4)); // 10
console.log(sumES6Arrow(1, 2, 3, 4, 5)); // 15
console.log(sumES5(10, 20)); // 30
console.log(sumES6(0, 0, 0)); // 0

对于具体的ES5与ES6的代码实现其实并不困难,关键是理解arguments对象与...args

十八、解析URLParams为对象

什么是URLParams?

URLParams(通常称为 URL 参数或查询参数)是 URL 中用于传递额外信息的键值对集合,位于 URL 的 ? 之后,格式为 key=value,多个参数之间用 & 分隔。

基本概念

  • 位置 :URL 中 ? 后面的部分,例如在 https://example.com/search?query=js&page=1 中,query=js&page=1 就是 URL 参数。

  • 作用:向服务器或前端页面传递数据(如查询条件、分页信息、用户标识等),是 HTTP 协议中客户端与服务器交互的常用方式。

格式

  • 单个参数:key=value(如 id=123

  • 多个参数:用 & 连接(如 name=张三&age=20

  • 特殊情况:

    • 无值参数:仅包含键(如 active,等价于 active=
    • 同名参数:允许重复键(如 hobby=篮球&hobby=音乐

特殊字符处理

URL 参数中不能直接包含空格、中文、&= 等特殊字符,需通过 URL 编码 转换为安全格式:

  • 编码:使用 encodeURIComponent() 处理(如空格 → %20,中文 "张三" → %E5%BC%A0%E4%B8%89)。

  • 解码:使用 decodeURIComponent() 还原(如 %20 → 空格)。

例如,name=张 三 编码后为 name=张%20三

手写实现

ini 复制代码
function parseParamsManually(url) {
  // 提取查询字符串(去掉开头的?)
  const queryString = (url || window.location.search).split('?')[1] || '';
  if (!queryString) return {};
  
  const result = {};
  // 按&分割参数
  const paramPairs = queryString.split('&');
  
  paramPairs.forEach(pair => {
    // 处理没有=的参数(如 ?token)
    const [key, value = ''] = pair.split('=');
    // 解码特殊字符(如%20解码为空格)
    const decodedKey = decodeURIComponent(key);
    const decodedValue = decodeURIComponent(value);
    
    // 处理同名参数
    if (result.hasOwnProperty(decodedKey)) {
      result[decodedKey] = Array.isArray(result[decodedKey])
        ? [...result[decodedKey], decodedValue]
        : [result[decodedKey], decodedValue];
    } else {
      result[decodedKey] = decodedValue;
    }
  });
  
  return result;
}

// 测试
const testUrl = 'https://example.com?name=张三&age=25&hobby=篮球&hobby=音乐&active';
console.log(parseParamsUsingAPI(testUrl));
console.log(parseParamsManually(testUrl));
// 输出: 
// {
//   name: "张三",
//   age: "25",
//   hobby: ["篮球", "音乐"],
//   active: ""
// }

这个手写题的核心是理解URLParams的构成,构成在前文有提及

当然还有的实现方式是使用原生API或者正则实现,但是我不推荐,因为记不住

十九、循环打印红黄绿

这一题,我们要巧妙的使用定时器,如果我们要循环打印,则可以使用递归来完成,代码比较简单

手写实现

scss 复制代码
function startTrafficLights() {
  // 定义灯的配置:颜色和对应亮灯时长(毫秒)
  const lights = [    { color: '红', duration: 3000 },    { color: '绿', duration: 1000 },    { color: '黄', duration: 2000 }  ];
  
  let currentIndex = 0; // 当前亮灯的索引
  
  // 定义亮灯函数
  function turnOnLight() {
    // 获取当前灯的配置
    const { color, duration } = lights[currentIndex];
    
    // 打印当前亮灯信息
    console.log(`现在是${color}灯亮,持续${duration/1000}秒`);
    
    // 计算下一个灯的索引(循环切换)
    currentIndex = (currentIndex + 1) % lights.length;
    
    // 经过duration时间后,切换到下一个灯
    setTimeout(turnOnLight, duration);
  }
  
  // 启动第一个灯
  turnOnLight();
}

// 启动交通信号灯
startTrafficLights();

结语

前端手写题上也是到这里就结束了,相信过几天就可以把手写题下发出来了

相关推荐
@大迁世界9 分钟前
TypeScript 的本质并非类型,而是信任
开发语言·前端·javascript·typescript·ecmascript
GIS之路17 分钟前
GDAL 实现矢量裁剪
前端·python·信息可视化
是一个Bug21 分钟前
后端开发者视角的前端开发面试题清单(50道)
前端
Amumu1213823 分钟前
React面向组件编程
开发语言·前端·javascript
学历真的很重要23 分钟前
LangChain V1.0 Context Engineering(上下文工程)详细指南
人工智能·后端·学习·语言模型·面试·职场和发展·langchain
持续升级打怪中1 小时前
Vue3 中虚拟滚动与分页加载的实现原理与实践
前端·性能优化
GIS之路1 小时前
GDAL 实现矢量合并
前端
hxjhnct1 小时前
React useContext的缺陷
前端·react.js·前端框架
冰暮流星1 小时前
javascript逻辑运算符
开发语言·javascript·ecmascript
前端 贾公子1 小时前
从入门到实践:前端 Monorepo 工程化实战(4)
前端