Skip to content

一、JavaScript 基础

1. 手写 Object.create

思路:将传入的对象作为原型

js
function create(obj) {
  function F() {}
  F.prototype = obj
  return new F()
}

2. 手写 instanceof 方法

instanceof 运算符用于判断构造函数的 prototype 属性是否出现在对象的原型链中的任何位置。

实现步骤:

  1. 首先获取类型的原型
  2. 然后获得对象的原型
  3. 然后一直循环判断对象的原型是否等于类型的原型,直到对象原型为 null,因为原型链最终为 null

具体实现:

js
function myInstanceof(left, right) {
  let proto = Object.getPrototypeOf(left), // 获取对象的原型
  let prototype = right.prototype; // 获取构造函数的 prototype 对象

  // 判断构造函数的 prototype 对象是否在对象的原型链上
  while (true) {
    if (!proto) return false;
    if (proto === prototype) return true;

    proto = Object.getPrototypeOf(proto);
  }
}

3. 手写 new 操作符

在调用 new 的过程中会发生以上四件事情:

(1)首先创建了一个新的空对象 (2)设置原型,将对象的原型设置为函数的 prototype 对象。 (3)让函数的 this 指向这个对象,执行构造函数的代码(为这个新对象添加属性) (4)判断函数的返回值类型,如果是值类型,返回创建的对象。如果是引用类型,就返回这个引用类型的对象。

js
function objectFactory() {
  let newObject = null;
  let constructor = Array.prototype.shift.call(arguments);
  let result = null;
  // 判断参数是否是一个函数
  if (typeof constructor !== "function") {
    console.error("type error");
    return;
  }
  // 新建一个空对象,对象的原型为构造函数的 prototype 对象
  newObject = Object.create(constructor.prototype);
  // 将 this 指向新建对象,并执行函数
  result = constructor.apply(newObject, arguments);
  // 判断返回对象
  let flag = result && (typeof result === "object" || typeof result === "function");
  // 判断返回结果
  return flag ? result : newObject;
}
// 使用方法
objectFactory(构造函数, 初始化参数);

4. 手写 Promise

js
const PENDING = "pending";
const RESOLVED = "resolved";
const REJECTED = "rejected";

function MyPromise(fn) {
  // 保存初始化状态
  var self = this;

  // 初始化状态
  this.state = PENDING;

  // 用于保存 resolve 或者 rejected 传入的值
  this.value = null;

  // 用于保存 resolve 的回调函数
  this.resolvedCallbacks = [];

  // 用于保存 reject 的回调函数
  this.rejectedCallbacks = [];

  // 状态转变为 resolved 方法
  function resolve(value) {
    // 判断传入元素是否为 Promise 值,如果是,则状态改变必须等待前一个状态改变后再进行改变
    if (value instanceof MyPromise) {
      return value.then(resolve, reject);
    }

    // 保证代码的执行顺序为本轮事件循环的末尾
    setTimeout(() => {
      // 只有状态为 pending 时才能转变,
      if (self.state === PENDING) {
        // 修改状态
        self.state = RESOLVED;

        // 设置传入的值
        self.value = value;

        // 执行回调函数
        self.resolvedCallbacks.forEach(callback => {
          callback(value);
        });
      }
    }, 0);
  }

  // 状态转变为 rejected 方法
  function reject(value) {
    // 保证代码的执行顺序为本轮事件循环的末尾
    setTimeout(() => {
      // 只有状态为 pending 时才能转变
      if (self.state === PENDING) {
        // 修改状态
        self.state = REJECTED;

        // 设置传入的值
        self.value = value;

        // 执行回调函数
        self.rejectedCallbacks.forEach(callback => {
          callback(value);
        });
      }
    }, 0);
  }

  // 将两个方法传入函数执行
  try {
    fn(resolve, reject);
  } catch (e) {
    // 遇到错误时,捕获错误,执行 reject 函数
    reject(e);
  }
}

MyPromise.prototype.then = function(onResolved, onRejected) {
  // 首先判断两个参数是否为函数类型,因为这两个参数是可选参数
  onResolved =
    typeof onResolved === "function"
      ? onResolved
      : function(value) {
          return value;
        };

  onRejected =
    typeof onRejected === "function"
      ? onRejected
      : function(error) {
          throw error;
        };

  // 如果是等待状态,则将函数加入对应列表中
  if (this.state === PENDING) {
    this.resolvedCallbacks.push(onResolved);
    this.rejectedCallbacks.push(onRejected);
  }

  // 如果状态已经凝固,则直接执行对应状态的函数

  if (this.state === RESOLVED) {
    onResolved(this.value);
  }

  if (this.state === REJECTED) {
    onRejected(this.value);
  }
};

5. 手写 Promise.then

then 方法返回一个新的 promise 实例,为了在 promise 状态发生变化时(resolve / reject 被调用时)再执行 then 里的函数,我们使用一个 callbacks 数组先把传给then的函数暂存起来,等状态改变时再调用。

那么,怎么保证后一个 then 里的方法在前一个 then(可能是异步)结束之后再执行呢? 我们可以将传给 then 的函数和新 promiseresolve 一起 push 到前一个 promisecallbacks 数组中,达到承前启后的效果:

  • 承前:当前一个 promise 完成后,调用其 resolve 变更状态,在这个 resolve 里会依次调用 callbacks 里的回调,这样就执行了 then 里的方法了
  • 启后:上一步中,当 then 里的方法执行完成后,返回一个结果,如果这个结果是个简单的值,就直接调用新 promiseresolve,让其状态变更,这又会依次调用新 promisecallbacks 数组里的方法,循环往复。。如果返回的结果是个 promise,则需要等它完成之后再触发新 promiseresolve,所以可以在其结果的 then 里调用新 promiseresolve
js
then(onFulfilled, onReject){
    // 保存前一个promise的this
    const self = this; 
    return new MyPromise((resolve, reject) => {
      // 封装前一个promise成功时执行的函数
      let fulfilled = () => {
        try{
          const result = onFulfilled(self.value); // 承前
          return result instanceof MyPromise? result.then(resolve, reject) : resolve(result); //启后
        }catch(err){
          reject(err)
        }
      }
      // 封装前一个promise失败时执行的函数
      let rejected = () => {
        try{
          const result = onReject(self.reason);
          return result instanceof MyPromise? result.then(resolve, reject) : reject(result);
        }catch(err){
          reject(err)
        }
      }
      switch(self.status){
        case PENDING: 
          self.onFulfilledCallbacks.push(fulfilled);
          self.onRejectedCallbacks.push(rejected);
          break;
        case FULFILLED:
          fulfilled();
          break;
        case REJECT:
          rejected();
          break;
      }
    })
   }

注意:

  • 连续多个 then 里的回调方法是同步注册的,但注册到了不同的 callbacks 数组中,因为每次 then 都返回新的 promise 实例(参考上面的例子和图)
  • 注册完成后开始执行构造函数中的异步事件,异步完成之后依次调用 callbacks 数组中提前注册的回调

6. 手写 Promise.all

  • 接收参数:接收一个 Promise 实例的数组或具有 Iterator 接口的对象作为参数。
  • 返回实例:这个方法必须返回一个新的 Promise 对象。
  • 兼容处理:遍历传入的参数,用 Promise.resolve() 将参数"包一层",使其变成一个 Promise 对象(兼容传入普通值的情况)。
  • 成功条件(保序):参数所有回调成功才是成功,返回值数组与参数顺序必须绝对一致(不能用 push,必须按索引赋值)。
  • 失败条件(一票否决):参数数组其中一个失败,则触发失败状态,第一个触发失败的 Promise 错误信息作为 Promise.all 的错误信息。

💻 实现代码

js
Promise.myAll = function (iterator) {
  return new Promise((resolve, reject) => {
  
    // 1. 参数校验:确保传入的是可迭代对象(如数组、Set)
    if (iterator == null || typeof iterator[Symbol.iterator] !== 'function') {
      return reject(new TypeError('Arguments must be iterable'));
    }

    // 2. 将可迭代对象转换为数组,获取长度
    const promises = Array.from(iterator);
    const len = promises.length;

    // 3. 边界处理:空数组直接 resolve
    if (len === 0) {
      return resolve([]);
    }

    // 4. 初始化状态变量
    const results = new Array(len); // 用于按传入顺序存放结果
    let resolvedCount = 0;          // 记录成功的 Promise 数量

    // 5. 并发遍历执行
    for (let i = 0; i < len; i++) {
      // 核心:用 Promise.resolve 包一层,兼容 promises[i] 是普通值的情况
      Promise.resolve(promises[i])
        .then((res) => {
          // 【关键点】必须按索引存入结果,保证输出顺序与输入顺序一致
          // 绝对不能用 results.push(res),因为异步完成的顺序是未知的
          results[i] = res;
          resolvedCount++;

          // 如果成功的数量等于总长度,说明全部成功,整体 resolve
          if (resolvedCount === len) {
            resolve(results);
          }
        })
        .catch((err) => {
          // Fail-Fast 机制:只要有一个失败,整体立即 reject
          reject(err);
        });
    }
  });
};

✅ 测试验证

js
// 模拟异步请求
const delay = (val, time, shouldReject = false) => 
  new Promise((resolve, reject) => 
    setTimeout(() => shouldReject ? reject(val) : resolve(val), time)
  );

const p1 = delay('数据1(慢)', 2000);
const p2 = delay('数据2(快)', 500);
const p3 = '普通非Promise值';

// 测试成功情况
Promise.myAll([p1, p2, p3]).then(console.log); 
// 约 2000ms 后输出: [ '数据1(慢)', '数据2(快)', '普通非Promise值' ]

// 测试失败情况
const p4 = delay('致命错误', 1000, true);
Promise.myAll([p1, p2, p4])
  .then(res => console.log('不会执行这里'))
  .catch(err => console.error('捕获错误:', err)); 
// 约 1000ms 后输出: 捕获错误: 致命错误

📝 业务思考与扩展 一般来说,Promise.all 用来处理多个并发请求,也是为了页面数据构造的方便,将一个页面所用到的在不同接口的数据一起请求过来。

痛点与风险: 如果其中一个接口失败了,多个请求也就全部宣告失败。如果核心逻辑没做好容错,页面可能啥也出不来(白屏),这就看当前页面的耦合程度了。

现代解决方案: 如果是“非强依赖”的多个请求(比如某一个模块挂了不影响其他模块展示),推荐使用 ES2020 引入的 Promise.allSettled。它没有一票否决权,会等待所有请求结束,并返回各自的成功或失败状态,从而有效避免单一接口报错拖垮整个页面的问题。 这是你手写 Promise 系列的第 8 篇。Promise.allSettled 是 ES2020 引入的重要特性,它补齐了 Promise.all 在处理“非全胜”场景下的短板。


7. 手写 Promise.allSettled

💡 核心思路

  • 全量等待:无论每个子 Promise 是成功还是失败,allSettled 都会等待它们全部执行完毕。
  • 永不报错:返回的 Promise 状态永远是 fulfilled(除非参数不可迭代导致报错),它不会像 all 那样因为某个子项失败而触发自身的 reject
  • 结果格式:结果数组中的每一项都是一个固定格式的对象:
    • 成功时:{ status: 'fulfilled', value: 数据 }
    • 失败时:{ status: 'rejected', reason: 错误原因 }
  • 实现关键:利用计数器记录“已完成”(不论成败)的数量,并在所有任务完成后 resolve 结果数组。

💻 实现代码

js
Promise.myAllSettled = function (iterator) {
  return new Promise((resolve, reject) => {
    // 1. 参数校验
    if (iterator == null || typeof iterator[Symbol.iterator] !== 'function') {
      return reject(new TypeError('Arguments must be iterable'));
    }

    const promises = Array.from(iterator);
    const len = promises.length;

    // 2. 边界处理
    if (len === 0) return resolve([]);

    const results = new Array(len);
    let completedCount = 0; // 记录已结算(不论成败)的数量

    // 3. 遍历执行
    for (let i = 0; i < len; i++) {
      Promise.resolve(promises[i])
        .then((value) => {
          // 成功:记录状态和值
          results[i] = { status: 'fulfilled', value };
        })
        .catch((reason) => {
          // 失败:同样记录状态和原因,不触发最外层的 reject
          results[i] = { status: 'rejected', reason };
        })
        .finally(() => {
          // 4. 无论成败,计数器加一
          completedCount++;
          // 当所有任务都结算后,resolve 结果数组
          if (completedCount === len) {
            resolve(results);
          }
        });
    }
  });
};

✅ 测试验证

js
const p1 = new Promise((resolve) => setTimeout(() => resolve('成功1'), 1000));
const p2 = new Promise((_, reject) => setTimeout(() => reject('失败2'), 500));
const p3 = '非 Promise 值';

Promise.myAllSettled([p1, p2, p3]).then((results) => {
  console.log('结算结果:', results);
  /* 预期输出:
  [
    { status: 'fulfilled', value: '成功1' },
    { status: 'rejected', reason: '失败2' },
    { status: 'fulfilled', value: '非 Promise 值' }
  ]
  */
});

📝 业务思考与对比 在面试中,通常会被问到 allallSettled 的选择问题。

方法核心机制适用场景
Promise.all一票否决:一个失败即整体失败。强依赖场景。如:同时请求用户的姓名、头像、权限,任何一个拿不到都无法渲染页面。
Promise.allSettled各行其是:等待所有结果,不因失败而中断。弱依赖场景。如:批量请求 10 个商品的评价信息,其中 2 个请求失败了,剩下的 8 个依然可以正常展示给用户。

为了更直观地理解这几种 Promise 并发方法的执行差异,你可以通过下面的模拟器观察它们在不同成功/失败组合下的行为。

8. 手写 Promise.race

💡 核心思路 正如你总结的那样,Promise.race 的核心就是“竞速机制”“状态固化”**:

  • 接收参数:接收一个 Promise 实例的数组或具有 Iterator 接口的对象。
  • 返回实例:返回一个新的 Promise 对象。
  • 先到先得:遍历传入的参数,用 Promise.resolve() 包一层以兼容普通值。
  • 状态注入(最核心):因为 Promise 的状态只能改变一次(一旦从 pending 变为 fulfilledrejected 就永远定型),我们只需要把外层新 Promise 的 resolvereject 方法,直接挂载到每个子 Promise 的 .then().catch() 中。谁第一个执行完,谁就掌握了外层 Promise 的最终命运。

💻 实现代码

js
Promise.myRace = function (iterator) {
  // 必须返回一个新的 Promise
  return new Promise((resolve, reject) => {
    // 1. 参数校验:确保传入的是可迭代对象
    if (iterator == null || typeof iterator[Symbol.iterator] !== 'function') {
      return reject(new TypeError('Arguments must be iterable'));
    }

    // 2. 将可迭代对象转换为数组
    const promises = Array.from(iterator);

    // 【注意边界情况】:如果是空数组 [],原生的 Promise.race 会永远保持 pending 状态。
    // 这里因为是空数组,for 循环不会执行,resolve/reject 永远不被调用,完美契合原生表现。

    // 3. 遍历执行
    for (let i = 0; i < promises.length; i++) {
      // 核心:用 Promise.resolve 包一层兼容普通值
      // 然后直接把最外层的 resolve 和 reject 当作回调传进去
      // 等价于: .then(res => resolve(res)).catch(err => reject(err))
      Promise.resolve(promises[i]).then(resolve, reject);
    }
  });
};

✅ 测试验证

js
// 辅助函数:模拟网络请求延迟
const delay = (val, time, shouldReject = false) => 
  new Promise((resolve, reject) => 
    setTimeout(() => shouldReject ? reject(val) : resolve(val), time)
  );

// --- 测试 1:成功竞速 ---
const p1 = delay('跑得慢的请求', 2000);
const p2 = delay('跑得快的请求', 500);

Promise.myRace([p1, p2]).then(res => {
  console.log('测试1获胜者:', res); 
  // 预期输出 (500ms后): 测试1获胜者: 跑得快的请求
});

// --- 测试 2:失败竞速 (Fail-Fast) ---
const p3 = delay('普通请求', 1000);
const p4 = delay('快速崩溃的请求', 300, true);

Promise.myRace([p3, p4])
  .then(res => console.log('这行不会执行'))
  .catch(err => console.error('测试2捕获错误:', err)); 
  // 预期输出 (300ms后): 测试2捕获错误: 快速崩溃的请求

📝 业务应用场景 Promise.race 在实际开发中最经典的应用场景是:网络请求超时控制

例如,前端的 fetch 请求默认没有超时时间,我们可以人为制造一个“定时炸弹” Promise,让它和正常的网络请求去 race。如果 5 秒内接口没返回,“定时炸弹”就会先爆炸(reject),从而接管整个流程,提示用户“请求超时”。

9. 手写 Promise.any

💡 核心思路

  • 一票肯定 (短路机制):只要参数中的任何一个 Promise 成功(fulfilled),外层的 Promise 就立刻成功,并返回那个成功的值。
  • 全军覆没才算输:只有当所有的 Promise 都失败(rejected)时,外层的 Promise 才会失败。
  • AggregateError (聚合错误):当所有 Promise 都失败时,它不会只返回最后一个错误,而是抛出一个特殊的错误对象 AggregateError,里面包含了所有失败的原因(并且需要保持原本传入的顺序)。
  • 【高频考点】空数组边界:原生 Promise.any([]) 遇到空数组时,由于“不可能有任何一个成功”,所以会立即触发 rejected,并抛出 AggregateError。(这与 all 立刻成功、race 永远挂起形成了鲜明对比)。

💻 实现代码

js
Promise.myAny = function (iterator) {
  return new Promise((resolve, reject) => {
    // 1. 参数校验
    if (iterator == null || typeof iterator[Symbol.iterator] !== 'function') {
      return reject(new TypeError('Arguments must be iterable'));
    }

    const promises = Array.from(iterator);
    const len = promises.length;

    // 2. 边界处理:空数组直接 reject (这是 ES 规范要求的特殊行为)
    if (len === 0) {
      // AggregateError 接收两个参数:错误原因数组 和 错误信息提示
      return reject(new AggregateError([], 'All promises were rejected'));
    }

    // 初始化状态变量
    const errors = new Array(len); // 用来按序存放所有的失败原因
    let rejectedCount = 0;         // 记录失败的 Promise 数量

    // 3. 并发遍历执行
    for (let i = 0; i < len; i++) {
      Promise.resolve(promises[i])
        .then((res) => {
          // 核心 1:只要有一个成功,立刻 resolve 最外层的 Promise
          resolve(res);
        })
        .catch((err) => {
          // 核心 2:失败了先别急着外抛,把错误按索引存起来
          errors[i] = err;
          rejectedCount++;

          // 核心 3:当失败的数量等于总长度时,说明全军覆没,抛出 AggregateError
          if (rejectedCount === len) {
            reject(new AggregateError(errors, 'All promises were rejected'));
          }
        });
    }
  });
};

✅ 测试验证

js
// 辅助函数:模拟异步请求
const delay = (val, time, shouldReject = false) => 
  new Promise((resolve, reject) => 
    setTimeout(() => shouldReject ? reject(val) : resolve(val), time)
  );

// --- 测试 1:有成功的情况 (一票肯定) ---
const p1 = delay('错误1', 500, true);
const p2 = delay('成功2(较快)', 1000);
const p3 = delay('成功3(较慢)', 2000);

Promise.myAny([p1, p2, p3])
  .then(res => console.log('测试1成功:', res)) // 预期 1000ms 后输出: 测试1成功: 成功2(较快)
  .catch(err => console.error(err));

// --- 测试 2:全军覆没的情况 ---
const p4 = delay('服务器挂了', 500, true);
const p5 = delay('跨域错误', 1000, true);

setTimeout(() => {
    Promise.myAny([p4, p5])
      .then(res => console.log('这行不会执行'))
      .catch(err => {
        console.error('\n测试2失败:');
        console.dir(err); 
        // 预期输出 AggregateError 对象
        // 且 err.errors 数组为: ['服务器挂了', '跨域错误']
      });
}, 2500);

// --- 测试 3:边界情况 (空数组) ---
Promise.myAny([]).catch(err => {
    console.log('\n测试3空数组拦截:', err.message); // 输出: All promises were rejected
});

10. 手写防抖函数 (Debounce)

💡 核心思路

  • 形象比喻(电梯原理 / 回城打断)
    • 电梯原理:你进电梯后,电梯会在 10 秒后关门。如果这 10 秒内又有人进来,10 秒就要重新计时。直到连续 10 秒没有人进电梯,门才会真正关上。
    • 游戏回城:按 B 键回城需要 8 秒,如果在这 8 秒内你稍微移动了一下或者被打断,回城倒计时必须重新开始
  • 代码核心:利用闭包保存一个 timer 变量。每次触发事件时,先 clearTimeout(timer) 清除之前的定时器,然后重新 setTimeout

💻 实现代码 版本一:现代 ES6 基础版(日常够用) 相比于传统的 arguments,使用 ES6 的剩余参数 ...args 语法更清晰。

js
function debounce(fn, wait) {
  let timer = null;
  
  return function(...args) {
    // 1. 如果存在定时器,说明在 wait 时间内又触发了,直接打断重新开始
    if (timer) {
      clearTimeout(timer);
    }
    
    // 2. 设置新的定时器
    timer = setTimeout(() => {
      // 3. 确保 this 指向正确(这里箭头函数没有自己的 this,自动继承外层环境的 this)
      fn.apply(this, args);
    }, wait);
  };
}

版本二:面试进阶版(支持立即执行 + 取消防抖)

有时候业务需要:用户点第一下的时候立刻发请求(无需等待),但如果他疯狂连击,后续的点击都要防抖。

js
function debounce(fn, wait, immediate = false) {
  let timer = null;

  const debounced = function(...args) {
    if (timer) clearTimeout(timer);

    if (immediate) {
      // 如果 timer 为空,说明是第一次触发,或者距离上次触发已经超过了 wait 时间
      const callNow = !timer;
      
      // 设置定时器,在 wait 时间后将 timer 清空,使得之后又能触发 "立即执行"
      timer = setTimeout(() => {
        timer = null;
      }, wait);

      // 如果是立即执行的条件,马上执行函数
      if (callNow) {
        fn.apply(this, args);
      }
    } else {
      // 走普通防抖逻辑
      timer = setTimeout(() => {
        fn.apply(this, args);
      }, wait);
    }
  };

  // 添加取消方法:适用于比如用户快速点击后,又立刻切换了页面,我们需要取消未执行的请求
  debounced.cancel = function() {
    clearTimeout(timer);
    timer = null;
  };

  return debounced;
}

✅ 测试验证

js
// 模拟一段非常消耗性能的操作
function handleInput(val) {
  console.log(`发送搜索请求: ${val},执行时间: ${new Date().toLocaleTimeString()}`);
}

// 包装成防抖函数,等待时间为 1 秒
const debouncedInput = debounce(handleInput, 1000);

// 模拟用户疯狂输入(500ms 触发一次,小于我们设定的 1000ms)
debouncedInput('a');      // 被打断
setTimeout(() => debouncedInput('ab'), 500);    // 被打断
setTimeout(() => debouncedInput('abc'), 1000);  // 成功执行!

// 预期结果:前两次都不会输出,只有在最后一次输入 'abc' 的 1 秒后,才会真正输出结果。

📝 业务应用场景

  1. 搜索框自动联想 (Autocomplete):用户连续输入拼音/汉字时,停止输入 n 秒后才向后端发送搜索请求。
  2. 按钮防二次点击:提交表单、点赞按钮,防止用户手抖连续快速点击发送多条一模一样的请求。
  3. 窗口大小调整 (window.onresize):用户拖拽改变浏览器窗口大小时,只在拖拽停止后才执行页面的重新渲染计算,极大优化性能。

9. 手写节流函数 (Throttle)

节流函数通常有两种主流实现方式:时间戳版定时器版。它们在执行时机上有一个细微但重要的区别:时间戳版会“立即执行”(Leading),而定时器版会“延迟执行”(Trailing)。

💡 核心思路

  • 形象比喻(技能 CD / 水龙头滴水)
    • 技能 CD:在游戏中释放了一个技能后,必须等待 5 秒的冷却时间(CD)。在 CD 期间,你无论怎么按键,技能都不会触发,直到冷却结束。
    • 水龙头:不管你把水龙头开多大,只要加了节流阀,它就保证每秒钟只滴出一滴水。
  • 与防抖的区别
    • 防抖:只执行最后一次(回城打断重来)。
    • 节流:在一段连续的时间内,固定频率执行(技能冷却)。

💻 实现代码

版本一:时间戳版(立即执行)

第一下触发立刻执行,但最后一次触发如果不在周期内,则会被丢失。

js
function throttle(fn, delay) {
  let lastTime = 0; // 上次执行的时间

  return function(...args) {
    const nowTime = Date.now();
    // 如果两次触发的间隔大于设定的频率
    if (nowTime - lastTime >= delay) {
      fn.apply(this, args);
      lastTime = nowTime; // 更新最后执行时间
    }
  };
}

版本二:定时器版(延迟执行) 第一下触发不会立刻执行,而是在 delay 秒后执行;停止触发后,如果还有一个定时器在等待,它最终会执行完最后一次。

js
function throttle(fn, delay) {
  let timer = null;

  return function(...args) {
    if (!timer) {
      timer = setTimeout(() => {
        fn.apply(this, args);
        timer = null; // 执行完后清空定时器,允许下一次触发
      }, delay);
    }
  };
}

版本三:面试官最爱(双剑合璧版)

结合两者优点:第一次点击立即执行,最后一次点击也会在冷却结束后执行。

js
function throttle(fn, delay) {
  let timer = null;
  let lastTime = 0;

  return function(...args) {
    const nowTime = Date.now();
    const remaining = delay - (nowTime - lastTime);

    // 如果没有剩余时间(冷却结束)或者系统时间被调整
    if (remaining <= 0 || remaining > delay) {
      if (timer) {
        clearTimeout(timer);
        timer = null;
      }
      lastTime = nowTime;
      fn.apply(this, args);
    } else if (!timer) {
      // 如果还在冷却中,且没有设置定时器,则设置一个定时器来处理最后一次触发
      timer = setTimeout(() => {
        lastTime = Date.now();
        timer = null;
        fn.apply(this, args);
      }, remaining);
    }
  };
}

✅ 测试验证

js
// 模拟滚动事件处理
function onScroll() {
  console.log('页面滚动处理:', new Date().getMilliseconds());
}

const throttledScroll = throttle(onScroll, 1000);

// 模拟连续不断的滚动触发
const interval = setInterval(() => {
  throttledScroll();
}, 100); // 每 100ms 尝试触发一次

// 5秒后停止模拟
setTimeout(() => clearInterval(interval), 5000);

// 预期结果:尽管每 100ms 都在触发,但控制台大约每隔 1000ms 才会输出一次日志。

📝 业务应用场景

  1. 滚动监听 (window.onscroll):比如计算瀑布流布局的位置或监听“回到顶部”按钮的显示,不需要每 10ms 计算一次,每 100-200ms 计算一次足够了。
  2. 搜索联想词的高频请求:虽然常用防抖,但有些实时性要求极高的搜索(如股票代码实时查询)会采用节流来保证用户输入时每隔固定时间发一次请求。
  3. 抢购点击:在抢购倒计时结束时,用户会疯狂点击,节流可以保证在极短时间内只向后端发送固定频率的请求。

10. 手写类型判断函数 (getType)

核心思路

  • typeof 的局限性typeof 只能准确判断基础数据类型(除了 null 会历史遗留性地返回 'object'),以及 function。对于所有的引用类型(数组、正则、日期等),它都会无脑返回 'object'
  • 终极武器 Object.prototype.toString.call():所有的内置对象都重写了 toString 方法。只有调用 Object 原型链最顶端的 toString,才能返回最准确的内部属性标签,格式为 "[object Type]"(例如 "[object Array]""[object String]")。
  • 优化截取方式:拿到 "[object Array]" 后,我们可以直接使用 .slice(8, -1),剔除前面的 [object 和后面的 ],直接提取出 Array,然后转小写即可。

💻 实现代码

版本一:性能混合版(基于你的逻辑优化) 基础类型直接走 typeof(性能略好一点点),引用类型走 toString

js
function getType(value) {
  // 1. 判断基础数据类型和函数
  // 注意 typeof null 是 'object',需要单独拎出来排除
  if (typeof value !== "object" || value === null) {
    return value === null ? "null" : typeof value;
  }
  
  // 2. 判断引用类型
  // 返回格式如 "[object Array]"
  // .slice(8, -1) 截取第 8 位到倒数第 1 位的内容 -> "Array"
  return Object.prototype.toString.call(value).slice(8, -1).toLowerCase();
}

版本二:万能极简版(面试官最爱) 其实,Object.prototype.toString.call() 是可以完美处理所有数据类型的,包括基础类型、nullundefined。我们可以用一行代码秒杀:

js
function getTypePro(value) {
  return Object.prototype.toString.call(value).slice(8, -1).toLowerCase();
}

✅ 测试验证

js
console.log('--- 基础类型 ---');
console.log(getType(123));          // "number"
console.log(getType("abc"));        // "string"
console.log(getType(true));         // "boolean"
console.log(getType(undefined));    // "undefined"
console.log(getType(Symbol()));     // "symbol"
console.log(getType(123n));         // "bigint"

console.log('\n--- 特殊类型 ---');
console.log(getType(null));         // "null" (原生 typeof 会错认成 'object')

console.log('\n--- 引用类型 ---');
console.log(getType({}));           // "object"
console.log(getType([]));           // "array"
console.log(getType(function(){}));// "function"
console.log(getType(new Date()));   // "date"
console.log(getType(/regex/));      // "regexp"
console.log(getType(new Error()));  // "error"

📝 面试高频延伸:四种类型判断方式的对比

在手写完这个函数后,面试官 100% 会问你:“除了 toString,JS 还有哪些判断类型的方法?它们有什么缺点?”

  1. typeof

    • 优点:快,写起来简单。
    • 缺点:判断不了 null(返回 object),区分不开引用类型(数组、正则都返回 object)。
  2. instanceof

    • 优点:能准确判断引用类型的具体归属(如 [] instanceof Arraytrue)。
    • 缺点:判断不了基础类型;如果页面里有多个 iframe(不同的全局环境),instanceof 会失效,因为不同 iframe 里的 Array 构造函数不是同一个。
  3. constructor

    • 用法value.constructor.name
    • 缺点:非常不可靠!开发者可以轻易地手动修改对象的 prototype.constructor 指向。
  4. Object.prototype.toString.call()

    • 绝对的金标准。兼容性极好,防伪造,跨 iframe 依然准确。唯一的缺点就是写起来稍微有点长。

11. 手写函数柯里化 (Currying)

你提供的 ES5 版本逻辑非常严谨,ES6 版本则极其炫技(利用了 bind 的特性)。为了让你的笔记在面试中既能体现扎实的基础,又能展现现代 JS 的优雅,我为你梳理了更具结构化的表达,并补充了经典的面试标准解答。

💡 核心思路

  • 概念:把原本需要一次性传入所有参数的函数 fn(a, b, c),变成可以分批次传参的函数 fn(a)(b)(c)。只要参数没传够,它就一直返回一个新函数等着你继续传;一旦参数传够了,它就立刻执行原函数。

  • 概念通俗化(分期付款机制):买一个 3000 块的东西(执行一个需要 3 个参数的函数),你可以一次性付清(直接传 3 个参数),也可以分 3 个月每次付 1000(每次传 1 个参数)。只要总数没齐,商家就给你打个欠条(返回一个新函数继续收集参数);一旦齐了,立刻发货(执行原函数)。

  • 核心属性 fn.length:在 JavaScript 中,函数的 length 属性代表它形参的个数(即定义时预期接收的参数数量)。这是柯里化判断“参数是否收集齐了”的唯一标准。

  • 执行逻辑

    1. 接收一个函数 fn
    2. 返回一个具有收集参数能力的包裹函数 curried
    3. 每次调用时,比较已接收的参数总数fn.length
    4. 如果够了,直接 apply 执行原函数;如果不够,利用闭包保存当前参数,并返回一个新的函数等待接收下一批参数。

💻 实现代码 这个版本避免了 ES5 中繁琐的 arguments 处理,利用 ES6 的剩余参数(...)让逻辑极其清晰,兼顾了可读性和深度。

js
function curry(fn) {
  // 返回一个包裹函数
  return function curried(...args) {
    // 判断当前已经接收的参数个数,是否大于等于原函数所需的形参个数
    if (args.length >= fn.length) {
      // 数量满足,直接执行原函数,并绑定 this
      return fn.apply(this, args);
    } else {
      // 数量不满足,返回一个新的函数,继续等待接收后续参数
      return function(...args2) {
        // 将之前收集的 args 和新传入的 args2 拼接起来,递归调用 curried
        return curried.apply(this, [...args, ...args2]);
      };
    }
  };
}

版本二:极简装逼版(基于你笔记中的 ES6 优化)

这个版本虽然只有一行代码,但对面试官要求较高,因为它巧妙地利用了 bind 会预设参数并返回新函数的特性。

js
const curryPro = (fn, ...args) => 
  args.length >= fn.length 
    ? fn(...args) 
    : (...args2) => curryPro(fn, ...args, ...args2);
    // 注:你笔记中用的是 bind,其实用展开语法递归调用更容易让人看懂,且避免了 this 绑定的干扰。

✅ 测试验证

js
// 1. 定义一个需要 3 个参数的原函数
function sum(a, b, c) {
  console.log('执行结果:', a + b + c);
  return a + b + c;
}

// 2. 将其柯里化
const curriedSum = curry(sum);

// 3. 见证奇迹的时刻
console.log('--- 柯里化调用测试 ---');
curriedSum(1, 2, 3);    // 一次性付清:输出 6
curriedSum(1)(2)(3);    // 每次付一点:输出 6
curriedSum(1, 2)(3);    // 混合支付:输出 6
curriedSum(1)(2, 3);    // 混合支付:输出 6

📝 业务应用场景与价值

面试官一定会问:“我们为什么要费这么大劲把 fn(a, b, c) 写成 fn(a)(b)(c)?有什么实际用处?”

  1. 参数复用(延迟执行): 假设你有一个打印日志的函数 log(date, importance, message)
js
const logCurry = curry(log);

// 我们今天只需要输出今天的日志,date 参数就可以固定下来
const logToday = logCurry(new Date()); 

// 我们在这个模块只输出 ERROR 级别的日志,importance 也可以固定
const logTodayError = logToday("ERROR");

// 业务代码里只需要传最后一个 message 参数了,大大简化了调用
logTodayError("内存不足!");
logTodayError("网络超时!");
  1. 前端框架中的事件绑定:在 React 或 Vue 中,我们经常需要给点击事件传递额外的业务 ID,柯里化是极其优雅的解法:onClick={handleClick(userId)}

12. 网络请求

随着技术的发展,实现 AJAX 请求主要有三种方式:经典 XMLHttpRequest基于 Promise 封装的 XMLHttpRequest(面试最常考),以及现代的 fetch API

以下是这三种方式的完整实现:

1. 经典版:使用原生 XMLHttpRequest

这是最底层、最原生的写法。虽然现在业务中很少直接这么写,但了解它是理解底层原理的基础。

js
function ajax(url, method = 'GET', data = null, successCallback, errorCallback) {
  // 1. 创建 XMLHttpRequest 对象
  const xhr = new XMLHttpRequest();

  // 2. 初始化请求参数
  // open(method, url, async)
  xhr.open(method, url, true);

  // 3. 监听状态变化
  // xhr.readyState 有 5 个状态 (0-4),4 表示请求已完成,且响应已就绪
  xhr.onreadystatechange = function () {
    if (xhr.readyState === 4) {
      // xhr.status 表示 HTTP 状态码,200-299 表示成功
      if (xhr.status >= 200 && xhr.status < 300) {
        // 请求成功,执行成功回调
        successCallback(JSON.parse(xhr.responseText));
      } else {
        // 请求失败,执行失败回调
        errorCallback(xhr.status, xhr.statusText);
      }
    }
  };

  // 监听网络层面错误
  xhr.onerror = function () {
    errorCallback('Network Error');
  };

  // 4. 发送请求
  if (method.toUpperCase() === 'POST') {
    // POST 请求需要设置请求头
    xhr.setRequestHeader('Content-Type', 'application/json');
    xhr.send(JSON.stringify(data));
  } else {
    // GET 请求没有请求体
    xhr.send(null);
  }
}

// 使用示例:
ajax(
  'https://jsonplaceholder.typicode.com/posts/1',
  'GET',
  null,
  (res) => console.log('请求成功:', res),
  (err, msg) => console.error('请求失败:', err, msg)
);

2. 面试最爱版:使用 Promise 封装 XMLHttpRequest

在现代前端开发中,回调函数嵌套(回调地狱)已经被淘汰,面试官通常会要求你用 Promise 包装原生的 XHR。这也是 axios 库最核心的底层原理。

js
function myAjax({ url, method = 'GET', data = null }) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    
    xhr.open(method, url, true);

    // 推荐使用 onload 替代 onreadystatechange,它只在请求完成时触发一次
    xhr.onload = function () {
      if (xhr.status >= 200 && xhr.status < 300) {
        try {
          resolve(JSON.parse(xhr.responseText));
        } catch (e) {
          resolve(xhr.responseText); // 如果不是 JSON 格式,直接返回文本
        }
      } else {
        reject(new Error(`请求失败,状态码:${xhr.status}`));
      }
    };

    // 处理网络错误
    xhr.onerror = function () {
      reject(new Error('网络请求错误 (Network Error)'));
    };

    // 处理跨域等安全报错导致的中断
    xhr.onabort = function () {
      reject(new Error('请求被中断'));
    };

    // 发送请求参数
    if (method.toUpperCase() === 'POST') {
      xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8');
      xhr.send(JSON.stringify(data));
    } else {
      xhr.send(null);
    }
  });
}

// 使用示例 (结合 async/await 极其优雅):
async function getData() {
  try {
    const res = await myAjax({
      url: 'https://jsonplaceholder.typicode.com/posts/1',
      method: 'GET'
    });
    console.log('Promise 版请求成功:', res);
  } catch (error) {
    console.error('Promise 版请求失败:', error);
  }
}

getData();

3. 现代原生 API:Fetch API

fetch 是浏览器自带的现代网络请求 API,它天生就是基于 Promise 的。日常开发中,如果不使用 axios,我们通常直接使用 fetch

js
// GET 请求
async function fetchGet() {
  try {
    // fetch 默认就是 GET 请求
    const response = await fetch('https://jsonplaceholder.typicode.com/posts/1');
    
    // fetch 的特殊之处:哪怕 HTTP 状态码是 404 或 500,它也不会抛出 reject
    // 只有网络断开才会 reject,所以必须手动检查 response.ok
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    
    // response.json() 也是一个异步的 Promise
    const data = await response.json();
    console.log('Fetch GET 成功:', data);
  } catch (error) {
    console.error('Fetch GET 失败:', error);
  }
}

// POST 请求
async function fetchPost() {
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        title: 'foo',
        body: 'bar',
        userId: 1,
      })
    });

    if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
    
    const data = await response.json();
    console.log('Fetch POST 成功:', data);
  } catch (error) {
    console.error('Fetch POST 失败:', error);
  }
}
  1. readyState 的生命周期:要知道 4 代表什么(请求完成),onreadystatechange 会触发多次,而 onload 只在请求结束时触发一次。

  2. 状态码校验:成功不仅仅是 status === 200,严格来说是 status >= 200 && status < 300 或者是 304 (缓存)。

  3. Fetch 的坑fetch 在遇到 400500 等 HTTP 错误状态码时,依然会进入 then (resolve 状态),只有在遇到网络故障导致请求无法发出时才会进入 catch。必须手动用 response.ok 判断。

14. 实现浅拷贝 (Shallow Copy)

💡 核心思路

  • 什么是浅拷贝? 浅拷贝会创建一个新对象。如果属性是基本类型,拷贝的就是基本类型的值;如果属性是引用类型(比如对象里的子对象/数组),拷贝的就是内存地址
  • 致命弱点:因为引用类型拷贝的是地址,所以新旧对象共享同一个内部嵌套对象。改动新对象中的嵌套对象,原对象也会跟着变。
  • 原生 API 替代方案:日常开发中通常直接使用 Object.assign({}, obj)、展开语法 {...obj}、数组的 slice()

💻 实现代码

js
function shallowCopy(target) {
  // 1. 如果是基础数据类型(或者是 null),直接返回
  if (typeof target !== 'object' || target === null) {
    return target;
  }

  // 2. 根据 target 是数组还是对象,创建全新的外壳对象
  const cloneTarget = Array.isArray(target) ? [] : {};

  // 3. 遍历原对象的第一层属性
  for (let key in target) {
    // 确保只拷贝对象自身的属性,忽略原型链上的属性
    if (target.hasOwnProperty(key)) {
      cloneTarget[key] = target[key];
    }
  }

  return cloneTarget;
}

15. 实现深拷贝 (Deep Copy) 👑

深拷贝是面试中的“重头戏”,写出一个完美的深拷贝可以瞬间拉满面试官对你的好感度。

💡 核心思路

  • 什么是深拷贝? 彻底的拷贝。不管是第一层还是内部嵌套了几十层,全部都会在内存中开辟新的空间。新旧对象完全断绝关系,互不影响。

  • 原生 API (JSON.parse(JSON.stringify(obj))) 的致命缺陷

    1. 会忽略 undefinedsymbol函数
    2. 不能处理正则表达式 (RegExp) 和日期对象 (Date),会变成空对象或字符串。
    3. 无法处理循环引用(比如对象 A 有个属性指向对象 A 自己),直接报错抛出异常。
  • 手写完美版的关键点

    1. 递归:遇到嵌套对象就往下钻。
    2. 特定类型单独处理:比如 Date 和 RegExp 不能简单当作普通对象遍历。
    3. 打破循环引用 (最核心):必须使用 WeakMap 来记录“我已经拷贝过哪些对象了”。

💻 实现代码

js
// 使用 map 来记录出现过的对象,解决循环引用问题
function deepClone(target, map = new WeakMap()) {
  // 1. 基础数据类型或 null,直接返回
  if (target === null || typeof target !== 'object') {
    return target;
  }

  // 2. 处理特殊引用类型 (如 Date、RegExp)
  // 如果面试官要求处理 Map 和 Set,也可以在这里加 if 判断
  if (target instanceof Date) return new Date(target);
  if (target instanceof RegExp) return new RegExp(target);

  // 3. 解决循环引用:如果这个对象之前已经被拷贝过了,直接返回之前缓存的新对象地址
  if (map.has(target)) {
    return map.get(target);
  }

  // 4. 初始化全新的数组/对象
  const cloneTarget = Array.isArray(target) ? [] : {};
  
  // 5. 【非常关键的一步】把当前 target 和新生成的对象存入 map 中
  // 必须在递归开始前存进去,否则还是会无限死循环
  map.set(target, cloneTarget);

  // 6. 递归遍历拷贝
  for (let key in target) {
    if (target.hasOwnProperty(key)) {
      // 对属性值递归调用 deepClone,并把 map 传递下去
      cloneTarget[key] = deepClone(target[key], map);
    }
  }

  return cloneTarget;
}

✅ 测试验证 (包含所有坑点)

js
// 准备一个极其复杂,充满陷阱的对象
const originalObj = {
  num: 1,
  str: 'hello',
  bool: true,
  un: undefined,        // JSON 方法会丢弃
  sym: Symbol('foo'),   // JSON 方法会丢弃
  func: function() { console.log('I am a function'); }, // JSON 方法会丢弃
  date: new Date(),     // JSON 方法会变成字符串
  reg: /\d+/,           // JSON 方法会变成空对象
  arr: [1, 2, { a: 3 }],
  nestedObj: { info: 'secret' }
};

// 制造循环引用
originalObj.loop = originalObj; 

// --- 测试深拷贝 ---
const clonedObj = deepClone(originalObj);

// 1. 验证基础拷贝
console.log('原对象与新对象地址不同:', clonedObj !== originalObj);

// 2. 验证深层嵌套已经断开联系
clonedObj.nestedObj.info = 'changed';
console.log('原对象嵌套未被篡改:', originalObj.nestedObj.info === 'secret'); 

// 3. 验证特殊类型
console.log('日期对象被正确拷贝:', clonedObj.date instanceof Date);
console.log('正则对象被正确拷贝:', clonedObj.reg instanceof RegExp);

// 4. 验证循环引用
console.log('成功处理循环引用:', clonedObj.loop === clonedObj);

📝 面试高频延伸 (必背话术) 面试官提问:在深拷贝中,为什么用来解决循环引用的缓存要用 WeakMap 而不是普通的 Map 或对象?

满分回答: > 主要是为了防止内存泄漏。 > > Map 对存入的键对象是强引用。哪怕原对象 target 已经在业务代码中没用了、被销毁了,但因为 Map 里还存着它的引用,垃圾回收机制(GC)就永远不敢回收它,导致内存一直被占着。 > > 而 WeakMap 的键是弱引用。一旦原对象 target 在外部没有其他引用了,垃圾回收器会自动把它从内存中清理掉,连带着 WeakMap 里的这条记录也会悄无声息地消失。在深拷贝这种可能处理庞大对象的场景中,使用 WeakMap 是极其优雅且安全的内存管理方式。

在日常业务开发中,如果没有特殊需求,我们很少会去手写上面那个带 WeakMap 的完美版深拷贝。

通常,我们会直接使用下面这两种极简写法

方法一:经典的“JSON 序列化”大法 (最常用): 这是全宇宙最普遍、写起来最快的深拷贝方式,只需一行代码。

js
const clonedObj = JSON.parse(JSON.stringify(originalObj));

⚠️ 面试必考的致命缺陷(避坑指南):

虽然它在 90% 的业务场景(纯展示数据流)中都能完美工作,但它有极其明显的局限性:

  1. 自动忽略:会自动忽略 undefinedsymbol函数(直接在拷贝结果中消失)。
  2. 类型突变:会将 Date 对象变成字符串;会将 RegExp (正则) 和 Error 对象变成空对象 {}
  3. 报错奔溃:如果对象存在循环引用(自己引用自己),JSON.stringify 会直接抛出错误导致程序崩溃。

方法二:现代浏览器原生 API structuredClone (最推荐) 在以前,为了弥补 JSON 大法的缺陷,大家只能引入 lodash 库的 _.cloneDeep。但现在,现代浏览器和 Node.js(Node 17+)已经原生支持了一个完美的全局 API。

js
const clonedObj = structuredClone(originalObj);

✨ 它的超强优势:

  1. 完美支持各种类型:能无损拷贝 DateRegExpMapSetArrayBuffer 等复杂类型。
  2. 天生支持循环引用:即使对象内部自己引用自己,它也能完美处理,不会报错死循环。

⚠️ 唯一需要注意的坑:

它依然不支持克隆函数和 DOM 节点。如果你尝试克隆一个包含函数的对象,它不会像 JSON 那样默默忽略,而是会直接抛出 DataCloneError 异常

评论区

欢迎留言、补充或勘误。

xiaoba.blog