普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月13日首页

JS手撕:手写Koa中间件与Promise核心特性

作者 Wect
2026年4月13日 09:16

在前端开发中,Koa框架的洋葱模型、Promise的各类静态方法以及异步流程控制,是每个开发者必须掌握的核心知识点。它们看似独立,实则底层逻辑高度关联——都是为了解决异步代码的可读性、可维护性问题。本文将从实战出发,手把手拆解核心代码,用“通俗解释+专业剖析”的方式,让你不仅能看懂手写代码,更能理解背后的设计思想。

一、手写Koa中间件调用(洋葱模型):理解“层层嵌套,反向回流”

用过Koa的同学都知道,它的中间件执行机制被称为“洋葱模型”——就像剥洋葱一样,中间件会从外到内依次执行,执行到最内层后,再从内到外反向执行。这种机制的核心价值的是:让中间件既能处理请求进入时的逻辑(如日志记录、权限校验),也能处理响应返回时的逻辑(如统一异常处理、响应格式化)。

1.1 核心代码实现(可直接运行)

function koa() {
  // 存放所有通过 app.use() 注册的中间件函数
  const middlewares = []
  const app = async (ctx) => {
    // 从第0个中间件开始执行调度
    await dispatch(0, ctx)
  }

  // 注册中间件的方法:将中间件存入数组
  app.use = (middleware) => {
    middlewares.push(middleware)
  }

  // 核心调度函数:递归执行中间件
  const dispatch = async (index, ctx) => {
    // 终止条件:所有中间件都执行完毕,直接返回
    if (index === middlewares.length) return

    // 获取当前索引对应的中间件
    const middleware = middlewares[index]
    // 执行中间件:第二个参数是next函数,调用next()即执行下一个中间件
    await middleware(ctx, () => dispatch(index + 1, ctx))
  }
  return app
}

// 1. 创建 app 实例
const app = koa()

// 2. 注册 3 个中间件(模拟真实开发中的分层逻辑)
app.use(async (ctx, next) => {
  console.log('【中间件 1 开始】—— 日志记录:请求进入')
  console.log('请求URL:', ctx.req.url)
  
  await next() // 放行,执行下一个中间件(核心:交出执行权)
  
  console.log('【中间件 1 结束】—— 日志记录:响应返回')
  console.log(6)
})

app.use(async (ctx, next) => {
  console.log('  【中间件 2 开始】—— 权限校验:通过')
  console.log(2)
  
  await next() // 放行,执行下一个中间件
  
  console.log(5)
  console.log('  【中间件 2 结束】—— 响应处理:添加响应头')
})

app.use(async (ctx, next) => {
  console.log('    【中间件 3 开始】—— 业务逻辑:处理请求')
  console.log(3)
  
  await next() // 没有更多中间件,直接返回(执行终止)
  
  console.log(4)
  console.log('    【中间件 3 结束】—— 业务逻辑:返回结果')
})

// 3. 模拟请求上下文(ctx:Koa的核心,封装请求和响应信息)
const ctx = {
  req: { url: '/' },
  res: {}
}

// 4. 启动执行
app(ctx).then(() => {
  console.log('\n所有中间件执行完毕!')
})

1.2 核心原理拆解(通俗+专业)

通俗理解

把每个中间件想象成一个“关卡”,请求要经过所有关卡才能到达最核心的业务逻辑(中间件3);处理完业务逻辑后,响应要再反向经过所有关卡,才能返回给客户端。比如:

请求进入 → 中间件1(记录日志)→ 中间件2(权限校验)→ 中间件3(处理业务)→ 中间件2(处理响应)→ 中间件1(记录响应日志)→ 响应返回

专业剖析

  • 中间件存储:用数组middlewares存储所有通过app.use()注册的中间件,保证执行顺序与注册顺序一致。

  • 调度函数dispatch:递归实现中间件的依次执行,index参数控制当前执行的中间件索引,当index等于中间件数组长度时,递归终止(最内层执行完毕)。

  • next函数:本质是dispatch(index+1, ctx)的封装,调用next()就相当于“交出执行权”,让下一个中间件执行;await next()则保证“下一个中间件执行完毕后,再继续执行当前中间件的后续逻辑”,这是洋葱模型反向回流的关键。

  • ctx上下文:统一封装请求(req)和响应(res)信息,所有中间件共享同一个ctx,实现数据传递(比如中间件1存储的用户信息,中间件3可以直接使用)。

1.3 执行结果与验证

运行上述代码,控制台输出如下(完美匹配洋葱模型):

【中间件 1 开始】—— 日志记录:请求进入
请求URL: /
2
  【中间件 2 开始】—— 权限校验:通过
3
    【中间件 3 开始】—— 业务逻辑:处理请求
4
    【中间件 3 结束】—— 业务逻辑:返回结果
5
  【中间件 2 结束】—— 响应处理:添加响应头
6
【中间件 1 结束】—— 日志记录:响应返回

所有中间件执行完毕!

二、手写简易co模块:自动执行Generator函数(告别手动.next())

在async/await出现之前,Generator函数是解决异步回调地狱的重要方案,但它有一个痛点:需要手动调用.next()方法才能逐步执行,非常繁琐。co模块的核心作用就是“自动执行Generator函数”,它会自动遍历Generator的迭代器,直到执行完毕。

核心逻辑:Generator函数中,yield后面通常跟Promise(异步操作),co模块会等待Promise完成,将结果传给Generator,再自动执行下一步,直到迭代结束。

2.1 核心代码实现(可直接运行)

// 手写co模块核心函数:自动执行带Promise的Generator
function run(generatorFunc) {
  // 1. 生成Generator迭代器(Generator函数执行后返回迭代器)
  let it = generatorFunc()

  // 2. 第一次启动Generator,获取第一个yield的结果(通常是Promise)
  let result = it.next()

  // 3. 用Promise包装自动执行流程,最终返回一个Promise(方便外部使用.then())
  return new Promise((resolve, reject) => {
    // 递归函数:自动执行下一个yield
    const next = function (result) {
      // 终止条件:Generator执行完毕(done为true),resolve最终返回值
      if (result.done) {
        resolve(result.value)
        return
      }

      // 核心:result.value是yield后面的Promise,等待它完成
      result.value
        .then((res) => {
          // Promise成功:将结果传给Generator(it.next(res)),并继续执行下一步
          let nextResult = it.next(res)
          next(nextResult)
        })
        .catch((err) => reject(err)) // 捕获异步错误,终止执行
    }

    // 启动自动执行流程
    next(result)
  })
}

// 模拟异步请求(真实开发中可能是接口请求、文件读取等)
function fetchData(data) {
  return new Promise(resolve => {
    setTimeout(() => resolve(data), 500) // 延迟500ms模拟异步
  })
}

// 定义一个Generator函数(包含多个异步操作)
function* gen() {
  console.log('开始执行Generator,发起第一个异步请求')
  
  let res1 = yield fetchData('数据1') // 第一个异步请求,等待完成后赋值给res1
  console.log('第一个请求结果:', res1)
  
  let res2 = yield fetchData('数据2') // 第二个异步请求,依赖第一个请求完成
  console.log('第二个请求结果:', res2)
  
  let res3 = yield fetchData('数据3') // 第三个异步请求,依赖第二个请求完成
  console.log('第三个请求结果:', res3)

  return '全部异步请求完成' // Generator最终返回值
}

// 自动执行Generator函数(无需手动调用.next())
run(gen).then(finalVal => {
  console.log('Generator执行完毕,最终返回:', finalVal)
})

2.2 核心原理拆解(通俗+专业)

通俗理解

把Generator函数想象成一个“异步任务清单”,co模块(这里的run函数)就是一个“自动执行者”:它会先拿出清单上的第一个任务(第一个yield),等待任务完成后,把结果记下来,再自动拿出下一个任务,直到所有任务都完成,最后把清单的最终结果返回给你。

专业剖析

  • Generator迭代器:Generator函数(function*)执行后会返回一个迭代器(it),迭代器的next()方法会返回一个对象{ value: ..., done: ... },value是yield后面的值(这里是Promise),done表示Generator是否执行完毕。

  • 自动迭代逻辑:next函数是核心,它接收上一个yield的执行结果,调用it.next(res)将结果传入Generator(赋值给res1、res2等),同时获取下一个yield的结果,递归执行自身,实现自动迭代。

  • Promise封装:run函数最终返回一个Promise,这样外部可以通过.then()获取Generator的最终返回值,也能通过.catch()捕获异步错误,符合异步编程的统一规范。

  • 异步依赖处理:由于每次yield的Promise完成后才会执行下一个yield,因此可以轻松实现异步操作的顺序执行(比如先获取数据1,再用数据1获取数据2)。

2.3 执行结果与验证

运行代码后,控制台每隔500ms输出一次结果,最终输出如下:

开始执行Generator,发起第一个异步请求
第一个请求结果: 数据1
第二个请求结果: 数据2
第三个请求结果: 数据3
Generator执行完毕,最终返回: 全部异步请求完成

三、异步串行/并行加法:理解异步流程控制的核心

异步加法看似简单,却能完美体现“串行”和“并行”两种异步流程控制的差异:

  • 串行:多个异步操作按顺序执行,前一个操作完成后,再执行下一个(适合有依赖关系的场景);

  • 并行:多个异步操作同时执行,无需等待前一个完成(适合无依赖关系的场景,能提升效率)。

我们先实现一个基础的异步加法函数,再基于它分别实现串行和并行求和。

3.1 基础准备:异步加法函数与Promise包装

// 1. 基础异步加法函数(基于回调函数,模拟真实异步场景)
// 接收 a, b 两个数字,callback 是回调函数(错误优先原则:第一个参数是错误,第二个是结果)
const asyncAdd = (a, b, callback) => {
  // 模拟异步操作(延迟 500ms,比如接口请求、计算密集型操作)
  setTimeout(() => {
    // 这里简化处理,不模拟错误,直接返回结果 a+b
    callback(null, a + b);
  }, 500);
};

// 2. 包装函数:将 callback 风格的异步方法,转成 Promise 风格
// 目的:方便在 async/await、Promise 链式调用中使用(更符合现代异步编程规范)
const promiseAdd = (a, b, index) => {
  console.log(`第 ${index} 次计算,参数 ${a}, ${b}`);
  return new Promise((resolve, reject) => {
    // 调用原来的 callback 异步加法
    asyncAdd(a, b, (err, res) => {
      if (err) {
        reject(err); // 出错时,抛出错误
      } else {
        resolve(res); // 成功时,返回计算结果
      }
    });
  });
};

3.2 方式一:异步串行求和(reduce实现)

核心逻辑:用数组的reduce方法,将前一次的计算结果(Promise)作为下一次计算的输入,实现“一步一步按顺序执行”。

// 串行求和:reduce + Promise 链式,实现异步累加
const add1 = (arr) => {
  // reduce参数说明:
  // acc:上一次的Promise结果(累加和),初始值为0
  // val:当前数组要加的数
  // index:当前索引(用于打印日志)
  return arr.reduce((acc, val, index) => {
    // Promise.resolve(acc):确保acc始终是Promise(兼容初始值0)
    return Promise.resolve(acc).then((value) => {
      // 等待上一步累加完成,再和当前值 val 相加
      return promiseAdd(value, val, index + 1); // index+1 是因为索引从0开始
    });
  }, 0); // 初始值 acc = 0(第一次计算:0 + arr[0])
};

// 执行串行求和:1+2+3+...+9,一步一步按顺序执行
add1([1, 2, 3, 4, 5, 6, 7, 8, 9]).then((sum) =>
  console.log("异步串行加法结果", sum)
);

3.3 方式二:异步并行求和(递归+Promise.all实现)

核心逻辑:采用“二叉树式”分组,将数组两两分组,每组同时执行加法(并行),再将每组的结果递归分组,直到得到最终总和。这种方式比“所有数字同时相加”更高效(避免过多并发任务)。

// 并行求和:递归 + Promise.all 实现并行归约求和(二叉树式计算)
async function parallelSum(arr) {
  // 递归终止条件:数组只剩一个数,直接返回(无需再计算)
  if (arr.length === 1) return arr[0];

  const tasks = []; // 存放所有并行执行的异步任务

  // 步长为2,将数组两两分组:[1,2] [3,4] [5,6] ... [9,0](奇数长度时,最后一个补0)
  for (let i = 0; i < arr.length; i += 2) {
    // arr[i+1] || 0:处理奇数长度数组(比如最后一个元素9,没有i+1,补0)
    tasks.push(promiseAdd(arr[i], arr[i + 1] || 0));
  }

  // Promise.all:并行执行所有任务,等待所有任务完成后,返回结果数组
  const results = await Promise.all(tasks);

  // 递归:将上一轮的计算结果,继续两两分组并行计算
  return parallelSum(results);
}

// 执行并行求和:速度比串行快(无需等待上一步完成)
parallelSum([1, 2, 3, 4, 5, 6, 7, 8, 9]).then((sum) =>
  console.log("异步并行加法结果", sum)
);

3.4 核心差异对比(通俗+专业)

对比维度 异步串行 异步并行
执行顺序 按顺序执行,前一个完成再执行下一个 所有任务同时执行,无顺序依赖
执行时间 总时间 = 所有任务时间之和(本例:9*500ms=4500ms) 总时间 = 最长任务时间 * 递归次数(本例:3*500ms=1500ms)
适用场景 任务有依赖(比如下一个任务需要上一个任务的结果) 任务无依赖(比如多个独立的接口请求、计算任务)
实现核心 Promise链式调用 + reduce Promise.all + 递归归约

3.5 执行结果与验证

串行求和会依次打印每次计算的参数,总耗时约4500ms;并行求和会同时打印多组计算参数,总耗时约1500ms,最终两者的求和结果均为45。

四、手写Promise核心静态方法:理解Promise的底层逻辑

Promise的静态方法(all、race、allSettled、any)是异步流程控制的常用工具,它们的底层逻辑都基于Promise的核心特性——状态不可逆(pending→fulfilled/rejected)。下面我们逐个手写实现,拆解它们的核心规则。

4.1 手写Promise.all:“全部成功才成功,一个失败就失败”

核心规则:接收一个Promise数组,只有所有Promise都成功(fulfilled),才返回所有结果的数组;只要有一个Promise失败(rejected),就立即返回该失败原因,终止执行。

// 手写实现 Promise.all 核心方法
function myPromiseAll(promiseArr) {
  // 返回一个新的 Promise(外部可以通过.then()/.catch()获取结果)
  return new Promise((resolve, reject) => {
    const len = promiseArr.length;    // 传入的 Promise 数组长度
    const result = [];                // 存放所有成功结果的数组(按原数组顺序)
    let count = 0;                    // 记录已经成功完成的任务数量

    // 边界处理:如果传入空数组,直接resolve空结果
    if (!len) {
      resolve(result);
      return; // 必须加return,防止后续代码继续执行
    };

    // 遍历所有promise(用entries()获取索引,保证结果顺序与输入一致)
    for (const [i, p] of promiseArr.entries()) {
      // Promise.resolve(p):包装非Promise值(比如普通数字、字符串),统一处理成Promise
      Promise.resolve(p).then(
        (value) => {
          // 成功:按原数组索引存入结果(确保顺序正确)
          result[i] = value;
          count++; // 成功数 +1

          // 所有任务都成功 → 调用resolve,返回结果数组
          if (count === len) {
            resolve(result);
          }
        },
        (reason) => {
          // 任何一个任务失败 → 立刻reject,终止所有任务(失败优先)
          reject(reason);
        }
      );
    }
  });
}

// 测试用例
const p1 = Promise.resolve(1);
const p2 = Promise.resolve(2);
const p3 = Promise.reject(new Error('失败'));

// 测试1:全部成功
myPromiseAll([p1, p2]).then(res => console.log('all成功:', res)).catch(err => console.log('all失败:', err.message));
// 测试2:有一个失败
myPromiseAll([p1, p3]).then(res => console.log('all成功:', res)).catch(err => console.log('all失败:', err.message));

4.2 手写Promise.race:“谁先完成,就返回谁”

核心规则:接收一个Promise数组,不管是成功还是失败,只要有一个Promise先完成(状态变为fulfilled或rejected),就立即返回该结果,其他任务继续执行,但结果会被忽略。

// 规则:谁最先完成(成功/失败),就返回谁
function myPromiseRace(promiseArr) {
  // 返回一个新的 Promise
  return new Promise((resolve, reject) => {
    // 遍历所有传入的promise
    for (const p of promiseArr) {
      // 统一包装成Promise(处理普通值)
      Promise.resolve(p).then(
        (value) => {
          // 任何一个成功 → 立刻resolve(状态不可逆,后续结果不会覆盖)
          resolve(value);
        },
        (reason) => {
          // 任何一个失败 → 立刻reject(状态不可逆,后续结果不会覆盖)
          reject(reason);
        }
      );
    }
  });
}

// 测试用例:模拟快慢不同的Promise
const fastPromise = new Promise((resolve) => setTimeout(() => resolve('快的Promise'), 100));
const slowPromise = new Promise((resolve) => setTimeout(() => resolve('慢的Promise'), 1000));
const errorPromise = new Promise((_, reject) => setTimeout(() => reject('失败的Promise'), 500));

// 测试1:成功的Promise更快
myPromiseRace([fastPromise, slowPromise]).then(res => console.log('race结果:', res));
// 测试2:失败的Promise更快
myPromiseRace([errorPromise, fastPromise]).then(res => console.log('race结果:', res)).catch(err => console.log('race失败:', err));

4.3 手写Promise.allSettled:“无论成败,都返回所有结果”

核心规则:接收一个Promise数组,等待所有Promise都完成(无论成功还是失败),返回一个包含所有任务结果的数组,每个结果对象包含状态(fulfilled/rejected)和对应的值/原因,不会因为某个任务失败而终止。

// 规则:无论成功/失败,都返回所有结果,不会中断
Promise.allSettled = function (promiseArr) {
  return new Promise(function (resolve) {
    const len = promiseArr.length;  // 数组长度
    const result = [];              // 存放所有结果
    let count = 0;                  // 已完成的promise数量

    // 空数组直接返回空
    if (!len) {
      resolve(result);
      return; // 必须加return!
    }

    // 遍历所有promise
    for (let [i, p] of promiseArr.entries()) {
      Promise.resolve(p).then(
        (value) => {
          // 成功:按标准格式存入(status为fulfilled,value为成功结果)
          result[i] = { status: "fulfilled", value };
          count++;
          if (count === len) { // 全部完成就resolve
            resolve(result);
          }
        },
        (reason) => {
          // 失败:按标准格式存入(status为rejected,reason为失败原因)
          result[i] = { status: "rejected", reason };
          count++;
          if (count === len) { // 失败也要计数,确保所有任务都完成
            resolve(result);
          }
        }
      );
    }
  });
};

// 测试用例
const p1 = Promise.resolve(1);
const p2 = Promise.reject(new Error('失败'));
Promise.allSettled([p1, p2]).then(res => {
  console.log('allSettled结果:', res);
  // 输出:[ {status: 'fulfilled', value: 1}, {status: 'rejected', reason: Error} ]
});

4.4 手写Promise.any:“只要有一个成功就成功,全部失败才失败”

核心规则:接收一个Promise数组,只要有一个Promise成功(fulfilled),就立即返回该成功结果;如果所有Promise都失败(rejected),则抛出一个AggregateError(包含所有失败原因)。

注意:与Promise.race的区别——any只关注成功,只有全部失败才会失败;race不管成功失败,谁先完成就返回谁。

// 规则:
// 1. 只要有一个成功,就返回这个成功结果
// 2. 全部失败 → 抛出 AggregateError 错误
function myPromiseAny(promiseArr) {
  return new Promise(function (resolve, reject) {
    const len = promiseArr.length;
    const errors = []; // 收集所有失败原因(全部失败时使用)
    let count = 0;

    // 空数组:标准规定返回 AggregateError
    if (len === 0) {
      return reject(new AggregateError([], "All promises were rejected"));
    }

    // 遍历所有promise
    for (let [i, p] of promiseArr.entries()) {
      Promise.resolve(p).then(
        (value) => {
          // ✅ 任何一个成功 → 直接返回成功结果(状态不可逆)
          resolve(value);
        },
        (reason) => {
          // ❌ 失败:记录错误,计数+1
          errors[i] = reason;
          count++;

          // 全部都失败了 → 抛出 AggregateError(包含所有失败原因)
          if (count === len) {
            reject(new AggregateError(errors, "All promises were rejected"));
          }
        }
      );
    }
  });
}

// 测试用例
const p1 = Promise.reject(new Error('失败1'));
const p2 = Promise.resolve(2);
const p3 = Promise.reject(new Error('失败2'));

// 测试1:有一个成功
myPromiseAny([p1, p2, p3]).then(res => console.log('any成功:', res)); // 输出2
// 测试2:全部失败
myPromiseAny([p1, p3]).then(res => console.log('any成功:', res)).catch(err => {
  console.log('any失败:', err.message); // 输出"All promises were rejected"
  console.log('所有失败原因:', err.errors); // 输出[Error('失败1'), Error('失败2')]
});

五、Promise并发控制(带超时、重传、失败收集):实战级封装

在真实开发中,我们经常会遇到“大量异步任务需要并发执行,但不能无限制并发”(比如同时调用100个接口,会导致服务器压力过大),同时还需要处理“任务超时”“失败重试”“收集失败任务”等需求。下面我们封装一个实战级的Promise并发控制器,满足这些核心需求。

5.1 核心代码实现(可直接复用)

/**
 * Promise 并发控制器(带 并发限制 + 超时 + 自动重试)
 * @param {Array} tasks - 任务数组,每一项是 () => Promise 的函数(必须是函数,确保懒执行)
 * @param {Object} options - 配置参数(均有默认值)
 * @param {number} options.limit - 最大并发数,默认5
 * @param {number} options.timeout - 单个任务超时时间,默认3000ms
 * @param {number} options.maxRetries - 最大重试次数,默认3次
 * @returns {Promise} 最终返回【所有失败的任务列表】(方便后续重试或排查问题)
 */
function promiseConcurrencyControl(tasks, {
  limit = 5,
  timeout = 3000,
  maxRetries = 3
} = {}) {
  return new Promise((resolve) => {
    const results = [];          // 存储所有任务最终结果(成功/失败)
    const failedTasks = [];      // 存储【最终彻底失败】的任务(重试后仍失败)
    let taskIndex = 0;           // 下一个要执行的任务下标(控制任务顺序)
    let runningCount = 0;        // 当前正在运行的任务数量(控制并发数)

    // ==========================================
    // 核心函数:启动下一个任务(调度器)
    // 只要有任务未执行、且当前并发数未达上限,就持续启动任务
    // ==========================================
    function runNextTask() {
      // 终止条件:所有任务都执行完毕(taskIndex >= 任务总数),且没有正在运行的任务
      if (taskIndex >= tasks.length && runningCount === 0) {
        return resolve(failedTasks); // 返回最终失败的任务列表
      }

      // 循环启动任务:只要还有任务,且并发数未达上限
      while (taskIndex < tasks.length && runningCount < limit) {
        const currentIndex = taskIndex++; // 取当前任务下标(避免并发时下标混乱)
        const task = tasks[currentIndex]; // 取出当前任务(函数)
        runningCount++;                   // 正在运行的任务数 +1

        // 执行任务(带超时、重试逻辑)
        executeTaskWithRetry(task, currentIndex, 0);
      }
    }

    // ==========================================
    // 带【超时】和【自动重试】的任务执行器
    // @param task - 任务函数 () => Promise
    // @param index - 任务下标(用于定位任务)
    // @param retryCount - 当前已经重试的次数(初始为0)
    // ==========================================
    function executeTaskWithRetry(task, index, retryCount) {
      // 1. 创建超时Promise:超过指定时间未完成,直接reject(超时错误)
      const timeoutPromise = new Promise((_, reject) => {
        setTimeout(() => {
          reject(new Error(`Task ${index} timed out after ${timeout}ms`));
        }, timeout);
      });

      // 2. 竞速:任务执行 和 超时监控 谁先完成
      Promise.race([
        task(),                  // 执行真实任务(懒执行,避免提前启动)
        timeoutPromise           // 超时监控
      ])
      .then(result => {
        // ======================
        // 任务执行成功
        // ======================
        results[index] = {
          success: true,
          result,
          retries: retryCount // 记录重试次数(0表示未重试)
        };
        runningCount--; // 正在运行的任务数 -1
        runNextTask();   // 启动下一个任务(维持并发数)
      })
      .catch(error => {
        // ======================
        // 任务失败 / 超时
        // ======================
        if (retryCount < maxRetries) {
          // 还有重试次数 → 立即重试,重试次数+1
          console.log(`Task ${index} 失败(原因:${error.message}),重试 ${retryCount + 1}/${maxRetries}`);
          executeTaskWithRetry(task, index, retryCount + 1);
        } else {
          // 重试次数用完 → 标记为彻底失败,存入失败列表
          const failureInfo = {
            taskIndex: index,       // 任务下标(方便定位)
            error: error.message,   // 失败原因
            retries: maxRetries     // 已重试次数
          };
          failedTasks.push(failureInfo);

          results[index] = {
            success: false,
            ...failureInfo
          };

          runningCount--;
          runNextTask(); // 继续启动下一个任务
        }
      });
    }

    // 启动并发控制(入口)
    runNextTask();
  });
}

// ------------------------------
// 测试工具函数(模拟真实场景中的异步任务)
// ------------------------------

/**
 * 创建测试任务(随机成功/失败,可模拟接口请求)
 * @param {number} id - 任务ID(用于区分)
 * @param {number} successProbability - 成功率(0~1,默认0.7)
 * @param {number} delay - 任务执行延迟(默认1000ms)
 */
function createTestTask(id, successProbability = 0.7, delay = 1000) {
  return () => new Promise((resolve, reject) => {
    setTimeout(() => {
      // 随机成功/失败(模拟接口请求的不确定性)
      if (Math.random() < successProbability) {
        resolve(`Task ${id} 成功`);
      } else {
        reject(new Error(`Task ${id} 执行失败`));
      }
    }, delay);
  });
}

// 生成 10 个测试任务(成功率70%,延迟800ms)
const testTasks = Array.from({ length: 10 }, (_, i) =>
  createTestTask(i + 1, 0.7, 800)
);

// 启动并发控制(配置:最大并发3个,超时1500ms,最多重试2次)
promiseConcurrencyControl(testTasks, {
  limit: 3,         // 最多同时运行3个任务
  timeout: 1500,    // 单个任务超过1.5秒超时
  maxRetries: 2     // 每个任务最多重试2次
})
.then(failedTasks => {
  console.log('\n=== 全部执行完成 ==');
  console.log('最终失败的任务:', failedTasks);
});

5.2 核心功能拆解(实战重点)

  • 并发限制:通过runningCount(当前运行任务数)和limit(最大并发数)控制,只有runningCount < limit时,才会启动新任务,避免并发过多导致的性能问题。

  • 任务调度:runNextTask函数作为调度器,循环启动任务,确保并发数维持在limit以内,同时处理任务执行完毕后的“补位”(启动下一个任务)。

  • 超时控制:通过Promise.race将任务执行与超时监控绑定,超过指定时间未完成的任务,直接视为失败,进入重试逻辑。

  • 自动重试:任务失败后,若重试次数未用完,立即重试,重试次数用完后,标记为彻底失败,存入失败列表。

  • 失败收集:最终返回所有彻底失败的任务列表,包含任务下标、失败原因和重试次数,方便后续排查问题或重新重试。

  • 懒执行:任务数组中的每一项是一个返回Promise的函数,而非直接执行的Promise,确保任务只有在被调度时才会启动,避免提前执行导致的并发混乱。

5.3 应用场景

该并发控制器可直接用于真实开发中的场景,比如:

  • 批量接口请求(比如批量获取用户信息、批量上传文件);

  • 批量处理异步任务(比如批量处理文件、批量发送消息);

  • 需要容错的异步场景(比如部分任务失败后,无需终止全部,只需收集失败任务后续处理)。

六、总结:核心知识点串联

本文讲解的所有内容,核心都是围绕“异步流程控制”展开:

  1. Koa洋葱模型:通过递归调度中间件,实现“请求进入→业务处理→响应返回”的分层逻辑,核心是next函数的执行权移交;

  2. co模块:自动迭代Generator函数,解决手动.next()的繁琐,本质是Promise与Generator的结合;

  3. 异步串/并行:串行适合有依赖的任务,并行适合无依赖的任务,核心是Promise链式调用与Promise.all的运用;

  4. Promise静态方法:all、race、allSettled、any,分别对应不同的异步场景,底层都是基于Promise的状态不可逆特性;

  5. 并发控制:在Promise基础上,增加并发限制、超时、重试等实战功能,解决大量异步任务的高效、稳定执行问题。

掌握这些知识点,不仅能看懂框架底层代码,更能在实际开发中灵活处理各类异步场景,写出更高效、更健壮的代码。

昨天 — 2026年4月12日首页

JS手撕:对象创建、继承全解析

作者 Wect
2026年4月12日 09:56

在 JavaScript 中,对象是核心数据类型,几乎所有业务开发都离不开对象的创建与复用。很多新手会困惑“为什么创建对象有这么多种方式?”“哪种继承方式最靠谱?”,本文将用「通俗类比+专业拆解」的方式,把 6 种对象创建方法、7 种继承方式讲透,同时补充底层原理和实战选型建议,兼顾入门理解与面试备考。

一、JS 对象创建方法(6种,按常用度排序)

创建对象的核心是“封装属性和方法”,不同方式的区别在于「代码简洁度」「复用性」「性能」,我们逐个拆解,结合代码示例和场景分析,让你一看就懂。

1. 对象字面量 {}(最常用、最简单,入门首选)

这是最直观、最简洁的创建方式,直接用 {} 包裹键值对,相当于“随手创建一个独立对象”,不用额外定义模板,适合快速创建单个对象。

专业说明:对象字面量是 ES5 引入的语法,底层会隐式调用 Object() 构造函数,但省略了冗余代码,JS 引擎会对其进行优化,执行效率高于显式调用 new Object()

// 基础写法(键名无特殊字符,可省略引号)
const person = {
  name: "张三",
  age: 20,
  // 方法简写(ES6 语法,等价于 sayHello: function() {})
  sayHello() {
    console.log(`你好,我是${this.name}`);
  }
};

// 特殊场景写法(键名含空格、特殊字符,需加引号)
const user = {
  "user-name": "lisi",
  "age+1": 21,
  ["say" + "Hi"]() { // 计算属性名(ES6)
    console.log("Hi~");
  }
};

// 使用方式(两种均可,推荐点语法,更简洁)
person.sayHello(); // 输出:你好,我是张三
user["user-name"]; // 输出:lisi
user.sayHi(); // 输出:Hi~

核心特点

  • ✅ 最简单、代码量最少,上手无门槛,日常开发高频使用

  • ✅ 适合创建「单个独立对象」(比如配置对象、单个用户信息)

  • ❌ 不适合批量创建多个相似对象(比如创建10个用户,会出现大量重复代码,维护成本高)

  • ✅ 支持 ES6 语法糖(方法简写、计算属性名),写法更灵活


2. 构造函数(new 函数名(),批量创建基础方案)

如果需要创建多个结构相似的对象(比如多个用户、多个商品),就需要一个“模板”——构造函数。构造函数本质是一个普通函数,通过 this 绑定属性和方法,配合 new 关键字生成实例,实现批量创建。

通俗类比:构造函数就像“工厂模具”,new 关键字就像“启动模具生产”,每个实例都是模具生产出的“产品”,结构一致但内容可自定义。

// 定义构造函数(首字母大写,约定俗成,区分普通函数)
function Person(name, age) {
  // this 指向当前创建的实例(new 关键字自动绑定)
  this.name = name; // 实例属性(每个实例独有)
  this.age = age;
  // 方法直接写在构造函数内(每个实例都会单独创建一个该方法)
  this.sayHello = function () {
    console.log(`我是${this.name},今年${this.age}岁`);
  };
}

// 创建实例(new 关键字不可省略,否则 this 指向 window)
const p1 = new Person("张三", 20);
const p2 = new Person("李四", 21);

// 使用实例
p1.sayHello(); // 输出:我是张三,今年20岁
p2.sayHello(); // 输出:我是李四,今年21岁

// 验证:两个实例的方法是不同的(内存地址不一样)
console.log(p1.sayHello === p2.sayHello); // 输出:false

核心特点

  • ✅ 适合「批量创建对象」,通过参数传递,实现实例属性自定义

  • ✅ 可通过 instanceof 判断实例类型(比如 p1 instanceof Person → true),便于类型校验

  • ❌ 核心缺点:方法会在每个实例中重复创建(比如上面的 sayHello 方法,p1 和 p2 各有一个,占用额外内存),实例越多,内存浪费越严重

  • ❌ 不适合复杂场景(比如方法复用、继承)


3. 原型模式(构造函数 + 原型,原生最优方案)

为了解决“构造函数方法重复创建”的问题,原型模式应运而生。核心思路:将公共方法挂载到构造函数的原型(prototype)上,所有实例共享同一个原型对象,因此公共方法只需要创建一次,节省内存。

专业原理:JS 中每个函数都有 prototype 属性(原型对象),每个实例都有 __proto__ 属性(隐式原型),实例的 __proto__ 会指向其构造函数的 prototype。当访问实例的方法时,JS 会先在实例自身查找,找不到就去原型对象中查找,这就是“原型链查找”。

// 1. 定义构造函数(只放实例独有属性)
function Person(name, age) {
  this.name = name;
  this.age = age;
}

// 2. 公共方法挂载到原型上(所有实例共享)
Person.prototype.sayHello = function () {
  console.log(`我是${this.name},今年${this.age}岁`);
};
// 原型上也可以添加公共属性
Person.prototype.gender = "男";

// 3. 创建实例
const p1 = new Person("张三", 20);
const p2 = new Person("李四", 21);

// 使用实例
p1.sayHello(); // 输出:我是张三,今年20岁
p2.sayHello(); // 输出:我是李四,今年21岁
console.log(p1.gender); // 输出:男(原型上的公共属性)

// 验证:两个实例的方法是同一个(内存地址相同)
console.log(p1.sayHello === p2.sayHello); // 输出:true

核心特点

  • ✅ 公共方法「共享」,只创建一次,大幅节省内存,性能最优

  • ✅ 兼顾批量创建和复用性,是原生 JS 中「最推荐的批量创建方案」

  • ✅ 支持原型链查找,可灵活扩展公共方法/属性

  • ✅ 企业开发中,原生 JS 场景首选此方案(比如封装原生组件、工具类)


4. Object.create()(基于原型创建,灵活继承方案)

Object.create() 是 ES5 引入的方法,核心功能是「基于一个现有对象作为原型,创建一个新对象」。它跳过了构造函数,直接通过原型对象生成新实例,适合灵活实现原型继承,或创建无原型的空对象。

通俗理解:相当于“复制一个现有对象的‘模板’,再基于这个模板创建新对象”,新对象会继承模板对象的所有属性和方法,同时可以自定义自己的属性。

// 1. 定义原型对象(模板对象)
const personProto = {
  name: "默认名字", // 原型属性(可被实例覆盖)
  sayHello() {
    console.log(`我是${this.name}`);
  },
  // 原型上的方法也可以访问原型上的其他属性
  showDefaultName() {
    console.log("默认名字:", this.name);
  }
};

// 2. 创建新对象(继承 personProto)
const p1 = Object.create(personProto);
p1.name = "张三"; // 覆盖原型上的 name 属性
p1.age = 20; // 新增实例独有属性

// 3. 使用新对象
p1.sayHello(); // 输出:我是张三(访问实例自身的 name)
p1.showDefaultName(); // 输出:默认名字:张三(this 指向 p1)

// 特殊场景:创建空原型对象(无任何继承,适合做纯净的映射表)
const emptyObj = Object.create(null);
console.log(emptyObj.toString); // 输出:undefined(没有继承 Object 的原型方法)

核心特点

  • ✅ 灵活实现「原型继承」,无需定义构造函数,直接复用现有对象的原型

  • ✅ 可创建「空原型对象」(Object.create(null)),避免继承 Object 的原型方法(比如 toString、hasOwnProperty),适合做纯净的数据容器

  • ❌ 不适合批量创建带参数的对象(每次创建都需要手动添加实例属性,无法像构造函数那样通过参数批量赋值)

  • ✅ 适合“基于现有对象扩展”的场景(比如修改某个对象,但不想影响原对象)


5. class 语法(ES6 标准,最现代、最优雅)

class 是 ES6 引入的语法糖,本质还是「构造函数 + 原型」,只是写法更简洁、语义化更强,解决了原生构造函数+原型写法繁琐、可读性差的问题,是现代 JS 开发(Vue、React 等框架)的首选方案。

专业说明class 语法没有改变 JS 原型继承的底层原理,只是对构造函数和原型的封装,让代码结构更清晰,更接近传统面向对象语言(比如 Java、Python)的写法。

// 1. 定义 class(相当于构造函数的语法糖)
class Person {
  // 构造方法(相当于构造函数的函数体,new 时自动执行)
  constructor(name, age) {
    this.name = name; // 实例属性
    this.age = age;
  }

  // 实例方法(自动挂载到 Person.prototype 上,所有实例共享)
  sayHello() {
    console.log(`我是${this.name},今年${this.age}岁`);
  }

  // 静态方法(static 关键字修饰,挂载到类本身,不被实例继承)
  static showClassName() {
    console.log("当前类:Person");
  }

  //  getter/setter(用于控制属性的读取和修改,增强属性安全性)
  get fullInfo() {
    return `${this.name}-${this.age}岁`;
  }
  set fullInfo(info) {
    const [name, age] = info.split("-");
    this.name = name;
    this.age = Number(age);
  }
}

// 2. 创建实例(和 new 构造函数用法一致)
const p1 = new Person("张三", 20);

// 3. 使用实例
p1.sayHello(); // 输出:我是张三,今年20岁
console.log(p1.fullInfo); // 输出:张三-20岁(调用 getter)
p1.fullInfo = "李四-21岁"; // 调用 setter,修改属性
console.log(p1.name); // 输出:李四

// 调用静态方法(只能通过类调用,不能通过实例调用)
Person.showClassName(); // 输出:当前类:Person
p1.showClassName(); // 报错:p1.showClassName is not a function

核心特点

  • ✅ 语法清晰、语义化强,代码结构更规整,可读性远高于原生构造函数+原型

  • ✅ 企业开发「首选方案」,适配所有现代框架(Vue3、React 等)

  • ✅ 原生支持继承(extends 关键字)、静态方法(static)、getter/setter 等高级特性,无需手动操作原型

  • ✅ 底层还是原型继承,兼顾性能和复用性,同时降低了学习和使用成本


6. 工厂函数(返回对象的函数,不推荐使用)

工厂函数是一种“模拟类”的方案,核心思路:在函数内部创建一个空对象,添加属性和方法后返回该对象,无需使用 new 关键字。它是 ES6 之前,为了简化批量创建对象而出现的过渡方案,现在已基本被 class 和原型模式替代。

// 定义工厂函数(普通函数,无需首字母大写)
function createPerson(name, age) {
  // 1. 创建空对象
  const o = {};
  // 2. 给空对象添加属性和方法
  o.name = name;
  o.age = age;
  o.getName = function () {
    console.log(this.name);
  };
  // 3. 返回创建好的对象
  return o;
}

// 使用(不用 new,直接调用函数)
const p1 = createPerson("张三", 20);
const p2 = createPerson("李四", 21);

// 调用方法
p1.getName(); // 输出:张三

核心特点

  • ✅ 不用 new 关键字,使用简单,无需理解原型和构造函数

  • ❌ 核心缺点1:无法识别对象类型(p1 instanceof createPerson → false,因为没有构造函数,无法通过 instanceof 判断实例归属)

  • ❌ 核心缺点2:方法会在每个对象中重复创建,浪费内存(和纯构造函数一样的问题)

  • ❌ 不推荐使用:ES6 之后,class 和原型模式完全可以替代它,仅作为历史知识点了解即可

对象创建选型总结(实战必看)

日常开发中,无需死记所有方法,根据场景选择即可,记住以下 3 条核心规则,覆盖 99% 场景:

  1. 创建「单个对象」(比如配置对象、单个数据对象)→ 用 对象字面量 {}(简洁、高效)

  2. 批量创建对象、追求性能和复用性 → 用 class(现代开发首选,语义化强)

  3. 原生 JS 场景、不依赖 ES6 语法 → 用 构造函数 + 原型(性能最优,兼容所有环境)

补充说明Object.create() 适合特殊场景(比如原型继承、创建空对象);工厂函数、纯构造函数(方法写在内部)尽量避免使用。

一句话记忆:日常开发 90% 场景用「对象字面量 + class」,剩下 10% 用「Object.create()」。


二、JS 继承方式(7种,从基础到最优,面试重点)

继承的核心是“复用父类的属性和方法”,JS 没有传统面向对象的“类继承”,而是通过「原型链」实现继承。下面从基础到最优,逐个拆解 7 种继承方式,分析其优缺点和适用场景,重点掌握“寄生组合式继承”和“class extends”。

1. 原型链继承(最基础,面试常考)

核心原理:子类的原型 = 父类的实例,让子类实例通过原型链,继承父类的实例属性和原型方法。这是最基础的继承方式,也是后续所有继承方式的基础。

// 父类(构造函数)
function Parent() {
  this.colors = ['red', 'blue']; // 引用类型属性
  this.name = "父类";
}

// 父类原型方法
Parent.prototype.sayName = function() {
  console.log(this.name);
};

// 子类(构造函数)
function Child() {}

// 核心:子类原型 = 父类实例(实现继承)
Child.prototype = new Parent();
// 修复子类原型的 constructor 指向(否则指向 Parent)
Child.prototype.constructor = Child;

// 创建子类实例
const c1 = new Child();
const c2 = new Child();

// 测试继承效果
c1.sayName(); // 输出:父类(继承父类原型方法)
console.log(c1.colors); // 输出:['red', 'blue'](继承父类实例属性)

// 问题演示:引用类型属性被所有子类实例共享
c1.colors.push('green');
console.log(c2.colors); // 输出:['red', 'blue', 'green'](c2 的 colors 也被修改了)

核心优缺点

  • ✅ 优点:实现简单,代码量少,能继承父类的实例属性和原型方法

  • ❌ 缺点1:父类的引用类型属性(比如数组、对象)会被「所有子类实例共享」,一个实例修改,其他实例都会受影响(这是最致命的问题)

  • ❌ 缺点2:无法向父类构造函数传递参数(比如创建子类实例时,无法给父类的 name 属性赋值)

  • ❌ 缺点3:子类实例的 constructor 指向会被修改,需要手动修复


2. 构造函数继承(借用 call/apply,解决引用类型共享问题)

核心原理:在子类构造函数内部,通过 call/apply 调用父类构造函数,将父类的 this 绑定到子类实例上,让子类实例单独拥有父类的实例属性,解决原型链继承中“引用类型共享”的问题。

// 父类
function Parent(name) {
  this.name = name; // 实例属性
  this.colors = ['red', 'blue']; // 引用类型属性
}

// 父类原型方法
Parent.prototype.sayName = function() {
  console.log(this.name);
};

// 子类
function Child(name, age) {
  // 核心:借用 call 调用父类构造函数,this 指向子类实例
  Parent.call(this, name); // 相当于给子类实例添加父类的实例属性
  this.age = age; // 子类独有属性
}

// 创建子类实例
const c1 = new Child("张三", 20);
const c2 = new Child("李四", 21);

// 测试:引用类型属性不再共享
c1.colors.push('green');
console.log(c1.colors); // 输出:['red', 'blue', 'green']
console.log(c2.colors); // 输出:['red', 'blue'](不受影响)

// 测试:无法继承父类原型方法
c1.sayName(); // 报错:c1.sayName is not a function

核心优缺点

  • ✅ 优点1:避免了引用类型属性被所有实例共享的问题(每个实例都有独立的父类实例属性)

  • ✅ 优点2:可以向父类构造函数传递参数(比如上面的 name 参数)

  • ❌ 缺点:只能继承父类的「实例属性」,无法继承父类的「原型方法」(父类原型上的方法,子类实例无法访问)

  • ❌ 缺点:父类的实例属性会在每个子类实例中重复创建(和纯构造函数一样,浪费内存)


3. 组合继承(最经典,实战常用过渡方案)

核心原理:构造函数继承 + 原型链继承 合体——用构造函数继承解决“引用类型共享”和“传参”问题,用原型链继承解决“继承原型方法”的问题,兼顾了两者的优点,是 ES6 之前最常用的继承方案。

// 父类
function Parent(name) {
  this.name = name;
  this.colors = ['red', 'blue'];
}

// 父类原型方法
Parent.prototype.sayName = function() {
  console.log(this.name);
};

// 子类
function Child(name, age) {
  // 1. 构造函数继承:继承父类实例属性,解决传参和引用类型共享问题
  Parent.call(this, name);
  this.age = age;
}

// 2. 原型链继承:继承父类原型方法
Child.prototype = new Parent();
// 修复 constructor 指向
Child.prototype.constructor = Child;

// 创建子类实例
const c1 = new Child("张三", 20);
const c2 = new Child("李四", 21);

// 测试:既能继承原型方法,又能避免引用类型共享
c1.sayName(); // 输出:张三(继承原型方法)
c1.colors.push('green');
console.log(c1.colors); // ['red', 'blue', 'green']
console.log(c2.colors); // ['red', 'blue'](不受影响)

核心优缺点

  • ✅ 优点:既能继承父类的实例属性,又能继承父类的原型方法,兼顾了实用性和复用性

  • ✅ 优点:解决了原型链继承和构造函数继承的核心缺点,是 ES6 之前最推荐的继承方案

  • ❌ 缺点:父类构造函数被调用了两次(一次是 new Parent() 给子类原型赋值,一次是 Parent.call(this) 给子类实例赋值),多执行了一遍冗余代码,造成轻微的性能浪费


4. 原型式继承(基于浅拷贝,简化原型链继承)

核心原理:基于现有对象浅拷贝创建新对象,本质是简化版的原型链继承,无需定义构造函数,直接通过一个现有对象作为原型,创建新对象。Object.create() 方法的底层实现就是原型式继承。

// 模拟 Object.create() 的底层实现(原型式继承核心代码)
function createObj(o) {
  // 定义一个空构造函数
  function F() {}
  // 让空构造函数的原型 = 现有对象 o
  F.prototype = o;
  // 返回空构造函数的实例(该实例的 __proto__ 指向 o)
  return new F();
}

// 现有对象(作为原型)
const parent = {
  name: "父对象",
  colors: ['red', 'blue'],
  sayName() {
    console.log(this.name);
  }
};

// 创建新对象(继承 parent)
const c1 = createObj(parent);
const c2 = createObj(parent);

// 测试继承效果
c1.sayName(); // 输出:父对象(继承原型方法)
console.log(c1.colors); // 输出:['red', 'blue'](继承原型属性)

// 问题:引用类型依然共享
c1.colors.push('green');
console.log(c2.colors); // 输出:['red', 'blue', 'green']

核心优缺点

  • ✅ 优点:实现简单,无需定义构造函数,适合“快速复用现有对象”的场景

  • ❌ 缺点1:引用类型属性依然被所有实例共享(和原型链继承一样的问题)

  • ❌ 缺点2:无法向父原型对象传递参数(只能复用现有对象的属性,无法自定义)

  • ✅ 补充:Object.create() 就是对这种方式的标准化实现,日常开发中直接用 Object.create() 即可,无需手动实现 createObj


5. 寄生式继承(原型式继承 + 对象增强)

核心原理:在原型式继承的基础上,给新创建的对象添加额外的属性和方法(增强对象),本质是对原型式继承的扩展,让新对象拥有更多自定义功能。

// 原型式继承 + 对象增强
function createChild(o) {
  // 1. 原型式继承:创建新对象,继承 o
  const clone = Object.create(o);
  // 2. 增强对象:给新对象添加独有属性和方法
  clone.sayHi = function() {
    console.log("Hi~");
  };
  clone.age = 20;
  // 3. 返回增强后的对象
  return clone;
}

// 父原型对象
const parent = {
  name: "父对象",
  sayName() {
    console.log(this.name);
  }
};

// 创建增强后的子类对象
const c1 = createChild(parent);

// 测试
c1.sayName(); // 输出:父对象(继承父原型方法)
c1.sayHi(); // 输出:Hi~(增强的方法)
console.log(c1.age); // 输出:20(增强的属性)

核心优缺点

  • ✅ 优点:简单快捷,能在复用现有对象的同时,给新对象扩展自定义功能

  • ❌ 缺点1:引用类型属性依然被共享(继承了原型式继承的问题)

  • ❌ 缺点2:增强的方法无法复用(每次创建新对象,都会新建一次增强方法,浪费内存)

  • ❌ 适用场景有限:仅适合“一次性创建一个增强对象”的场景,不适合批量创建


6. 寄生组合式继承(最完美、最优,面试必背)

核心原理:构造函数继承 + 原型式继承(Object.create),解决了组合继承“父构造函数被调用两次”的问题,同时保留了组合继承的所有优点,是 JS 继承的最佳实践,也是 class extends 的底层实现原理。

核心改进:用 Object.create(Parent.prototype) 替代 new Parent() 给子类原型赋值,这样只会继承父类的原型,不会调用父类构造函数,避免了冗余代码。

// 父类
function Parent(name) {
  this.name = name;
  this.colors = ['red', 'blue'];
}

// 父类原型方法
Parent.prototype.sayName = function() {
  console.log(this.name);
};

// 子类
function Child(name, age) {
  // 1. 构造函数继承:继承父类实例属性,传参,避免引用类型共享
  Parent.call(this, name);
  this.age = age;
}

// 2. 核心改进:用 Object.create 继承父类原型,不调用父类构造函数
Child.prototype = Object.create(Parent.prototype);
// 修复 constructor 指向
Child.prototype.constructor = Child;

// 创建子类实例
const c1 = new Child("张三", 20);
const c2 = new Child("李四", 21);

// 测试:完美解决所有问题
c1.sayName(); // 输出:张三(继承原型方法)
c1.colors.push('green');
console.log(c1.colors); // ['red', 'blue', 'green']
console.log(c2.colors); // ['red', 'blue'](不共享)
console.log(c1.constructor); // 输出:Child(constructor 指向正确)

核心优缺点

  • ✅ 优点1:只调用一次父类构造函数,避免了组合继承的冗余代码,性能最优

  • ✅ 优点2:引用类型属性不共享,每个子类实例都有独立的父类实例属性

  • ✅ 优点3:能继承父类的实例属性和原型方法,原型链结构正常

  • ✅ 优点4:子类实例的 constructor 指向正确,无需额外修复(修复仅为规范,不影响功能)

  • ❌ 缺点:写法比 class extends 繁琐(这也是 ES6 引入 class 的原因)

  • ✅ 结论:JS 原生继承的「最佳实践」,面试必考,也是 class extends 的底层原理


7. class extends(ES6 语法糖,现代开发首选)

核心原理:ES6 引入的 extends 关键字,本质是「寄生组合式继承」的语法糖,写法更简洁、语义化更强,无需手动操作原型和构造函数,是现代 JS 开发(框架、项目)的首选继承方式。

// 父类(class 语法)
class Parent {
  constructor(name) {
    this.name = name;
    this.colors = ['red', 'blue'];
  }

  // 父类原型方法
  sayName() {
    console.log(this.name);
  }

  // 父类静态方法
  static showParent() {
    console.log("这是父类");
  }
}

// 子类继承父类(extends 关键字)
class Child extends Parent {
  constructor(name, age) {
    // 核心:super() 相当于 Parent.call(this, name),调用父类构造函数
    super(name); // 必须在 this 之前调用,否则报错
    this.age = age; // 子类独有属性
  }

  // 子类原型方法
  sayAge() {
    console.log(`我今年${this.age}岁`);
  }
}

// 创建子类实例
const c1 = new Child("张三", 20);

// 测试继承效果
c1.sayName(); // 输出:张三(继承父类原型方法)
c1.sayAge(); // 输出:我今年20岁(子类自有方法)
Parent.showParent(); // 输出:这是父类(父类静态方法)
Child.showParent(); // 输出:这是父类(子类继承父类静态方法)

// 测试引用类型不共享
const c2 = new Child("李四", 21);
c1.colors.push('green');
console.log(c1.colors); // ['red', 'blue', 'green']
console.log(c2.colors); // ['red', 'blue']

核心特点

  • ✅ 写法最优雅、语义化最强,无需手动操作原型,降低学习和使用成本

  • ✅ 底层是寄生组合式继承,兼顾性能和实用性,无任何明显缺点

  • ✅ 原生支持 super 关键字(调用父类构造函数、访问父类方法),支持静态方法继承

  • ✅ 现代开发首选,适配所有框架(Vue3、React、Node.js 等)

继承方式总结(面试必背)

用一句话总结每种继承的核心特点,面试时直接套用,清晰易懂:

  1. 原型链继承:简单但引用类型共享,无法传参

  2. 构造函数继承:能传参、避免共享,但无法继承原型方法

  3. 组合继承:好用但父构造函数被调用两次,有冗余代码

  4. 原型式继承:浅拷贝继承,适合快速复用现有对象

  5. 寄生式继承:拷贝+增强,方法无法复用

  6. 寄生组合继承:完美继承,无缺点,原生最优方案

  7. class extends:语法糖,底层是寄生组合继承,现代开发首选


三、面试高频补充:instanceof 原理与模拟 new

理解了对象创建和继承,就必须掌握 instanceofnew 的底层原理,这两个是面试高频考点,下面用通俗的语言+代码模拟,帮你彻底搞懂。

1. instanceof 原理(判断对象类型的核心)

作用:判断一个对象(obj)是否是某个构造函数(constructor)的实例,本质是「沿着对象的原型链向上查找,看是否能找到构造函数的 prototype」。

核心逻辑:

  1. 获取构造函数的原型对象(constructor.prototype);

  2. 沿着对象的 __proto__(隐式原型)逐级向上查找;

  3. 如果找到某个原型对象和构造函数的 prototype 相等,返回 true;

  4. 如果查到原型链顶端(null)还没找到,返回 false。

// 模拟 instanceof 底层实现(myInstanceof)
function myInstanceof(obj, constructor) {
  // 1. 边界判断:如果 obj 是 null/undefined,直接返回 false
  if (obj === null || typeof obj !== 'object' && typeof obj !== 'function') {
    return false;
  }
  // 2. 获取构造函数的原型对象
  const prototype = constructor.prototype;
  // 3. 逐级获取 obj 的隐式原型(__proto__),向上查找
  while (true) {
    // 查到顶端 null,说明没找到,返回 false
    if (obj === null) {
      return false;
    }
    // 找到原型相等,说明是实例,返回 true
    if (obj === prototype) {
      return true;
    }
    // 核心:沿着原型链向上走一层(获取下一个原型对象)
    // 推荐用 Object.getPrototypeOf(obj) 替代 obj.__proto__(更规范)
    obj = Object.getPrototypeOf(obj);
  }
}

// 测试
function Person() {}
const p = new Person();

console.log(myInstanceof(p, Person)); // 输出:true(p 是 Person 的实例)
console.log(myInstanceof(p, Object)); // 输出:true(p 也是 Object 的实例,因为 Person.prototype.__proto__ 指向 Object.prototype)
console.log(myInstanceof([], Array)); // 输出:true
console.log(myInstanceof(123, Number)); // 输出:false(123 是基本类型,不是 Number 的实例)

2. 模拟 new 关键字(对象创建的核心)

new 关键字的作用是“通过构造函数创建实例”,其底层逻辑可拆解为 4 步,我们用代码模拟其实现,就能彻底理解。

new 的核心逻辑:

  1. 判断传入的 Constructor 是否是函数,不是则报错;

  2. 创建一个空对象,并且让这个空对象的 __proto__ 指向 Constructor 的 prototype(实现原型继承);

  3. 调用 Constructor 构造函数,将 this 绑定到刚创建的空对象上,并传入参数;

  4. 判断构造函数的返回值:如果返回的是对象/函数,就返回这个返回值;否则返回刚创建的空对象。

// 模拟 new 关键字(myNew)
function myNew(Constructor, ...args) {
  // 1. 边界判断:如果 Constructor 不是函数,报错
  if (typeof Constructor !== 'function') {
    throw new TypeError(Constructor + ' is not a constructor');
  }

  // 2. 创建空对象,并且让其 __proto__ 指向 Constructor.prototype
  const instance = Object.create(Constructor.prototype);

  // 3. 执行构造函数,this 绑定到 instance,传入参数
  const result = Constructor.apply(instance, args);

  // 4. 判断返回值:如果返回引用类型(对象/函数),则返回该值;否则返回 instance
  // 注意:null 是对象类型,但要排除(因为返回 null 时,依然返回 instance)
  return (typeof result === 'object' && result !== null) || typeof result === 'function' 
    ? result 
    : instance;
}

// 测试1:正常情况
function Person(name) {
  this.name = name;
}
const p1 = myNew(Person, '张三');
console.log(p1.name); // 输出:张三
console.log(p1 instanceof Person); // 输出:true

// 测试2:构造函数返回对象(特殊情况)
function Student(name) {
  this.name = name;
  // 手动返回一个对象
  return {
    name: '李四',
    age: 20
  };
}
const s1 = myNew(Student, '张三');
console.log(s1.name); // 输出:李四(返回的是构造函数手动返回的对象)
console.log(s1 instanceof Student); // 输出:false(因为返回的对象不是 Student 的实例)

// 测试3:构造函数返回基本类型(不影响)
function Teacher(name) {
  this.name = name;
  return 123; // 返回基本类型
}
const t1 = myNew(Teacher, '王五');
console.log(t1.name); // 输出:王五(返回的是 instance)


四、最终总结

  1. 对象创建:优先用「对象字面量」(单个对象)和「class」(批量对象),原生场景用「构造函数+原型」,特殊场景用「Object.create()」;

  2. 继承:现代开发首选「class extends」,面试重点掌握「寄生组合式继承」,其他方式作为基础知识点了解;

  3. 核心底层:理解「原型链」「instanceof 原理」「new 原理」,这是 JS 对象和继承的核心,也是面试高频考点;

  4. 一句话实战:日常开发用「对象字面量 + class + extends」,能覆盖所有场景,简洁又高效。

昨天以前首页

JS手撕:函数进阶 & 设计模式解析

作者 Wect
2026年4月11日 10:33

在 JavaScript 开发中,无论是日常业务开发还是面试考察,有一批高频代码片段始终贯穿其中——它们涵盖函数封装、设计模式、异步处理等核心场景,既能提升开发效率,也是理解 JS 底层逻辑的关键。本文将以「通俗解读+专业拆解」的方式,逐一看懂这些实用代码,帮你吃透背后的原理,做到会用也会讲。

一、函数柯里化(Currying)

通俗理解

柯里化就像「分步点餐」:比如点一杯奶茶,不用一次性说清“中杯、少糖、常温”,可以先选“中杯”,再选“少糖”,最后选“常温”,每一步都记录你的选择,等所有选项凑齐,再最终下单(执行函数)。核心是“把多参数函数拆成单参数(或部分参数)的嵌套函数,逐步收集参数,最终执行”。

专业拆解(附代码解析)

柯里化的核心价值是参数复用、延迟执行,下面这段工具函数是面试中最常考的实现方式,逐行拆解其逻辑:

// 定义柯里化工具函数,接收原函数 fn + 初始参数
function curry(fn) {
  // 1. 校验入参:必须是函数,否则抛出类型错误(健壮性处理)
  if (typeof fn !== "function") throw new TypeError("Expected a function");
  
  // 2. 获取原函数【需要的必填参数个数】(函数的 length 属性 = 形参数量)
  // 比如 fn(a,b,c),fn.length 就是 3,代表需要3个参数才能执行
  const requiredArgsLength = fn.length;
  
  // 3. 截取除了第一个参数(fn)之外的所有【初始参数】
  // arguments 是类数组(不能直接用数组方法),用 slice 转成真正的数组
  const initialArgs = [].slice.call(arguments, 1);

  // 4. 内部柯里化核心函数:接收新传入的参数
  function _curry(...newArgs) {
    // 合并:初始参数 + 本次传入的新参数(收集所有已传入的参数)
    const allArgs = [...initialArgs, ...newArgs];
    
    // 5. 判断:参数是否凑够了原函数需要的数量
    if (allArgs.length >= requiredArgsLength) {
      // ✅ 凑够了:执行原函数,传入所有参数(用 apply 绑定 this,保证上下文正确)
      return fn.apply(this, allArgs);
    } else {
      // ❌ 没凑够:递归调用 curry,继续收集参数(把已收集的 allArgs 作为初始参数传入)
      return curry.call(this, fn, ...allArgs);
    }
  }

  // 6. 返回内部收集参数的函数(不立即执行,延迟到参数凑够后执行)
  return _curry;
}

用法示例

// 原函数:求三个数的和(需要3个参数)
function add(a, b, c) {
  return a + b + c;
}

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

// 分步传参(延迟执行)
curryAdd(1)(2)(3); // 6(分步传参,凑够3个执行)
curryAdd(1,2)(3); // 6(部分传参,再补全)
curryAdd(1)(2,3); // 6(任意分步组合)

关键注意点

  • 函数的 length 属性:仅统计“未指定默认值的形参”,如果形参有默认值(如 add(a=0,b)),length 会计算到第一个默认值参数为止(此时 add.length = 0)。

  • 递归收集参数:每次传参不足时,都会返回一个新的 _curry 函数,继续收集参数,直到满足要求。

二、函数组合(Compose)

通俗理解

函数组合就像「流水线作业」:比如生产一瓶饮料,先“加水”,再“加糖”,最后“装瓶”,每个步骤都是一个函数,组合起来就是“加水→加糖→装瓶”的完整流程,前一个函数的输出是后一个函数的输入。核心是“将多个单参数函数组合成一个函数,从右往左依次执行”。

专业拆解(附代码解析)

函数组合是函数式编程的核心技巧,常用于简化多步骤逻辑(如数据处理、中间件),下面是最简洁的实现方式:

function compose(...funcs) {
  // 没有传入函数,直接返回参数本身(边界处理:传入空函数时,不改变输入)
  if (funcs.length === 0) {
    return arg => arg;
  }

  // 只有一个函数,直接返回该函数(边界处理:无需组合,直接执行)
  if (funcs.length === 1) {
    return funcs[0];
  }

  // ✅ 核心:用 reduce 实现函数组合,从右往左执行
  // reduce 遍历 funcs,将前一个函数 a 和当前函数 b 组合成 (args) => a(b(...args))
  // 比如 compose(f1,f2,f3) 最终变成 (args) => f1(f2(f3(...args)))
  return funcs.reduce((a, b) => (...args) => a(b(...args)));
}

用法示例

// 步骤1:将数字转为字符串
const toString = num => num + "";
// 步骤2:给字符串加前缀
const addPrefix = str => "num_" + str;
// 步骤3:将字符串转为大写
const toUpperCase = str => str.toUpperCase();

// 组合函数:从右往左执行 → toString → addPrefix → toUpperCase
const transform = compose(toUpperCase, addPrefix, toString);

// 执行:123 → "123" → "num_123" → "NUM_123"
transform(123); // "NUM_123"

关键注意点

  • 执行顺序:从右往左,这是 compose 的默认规则(与 pipe 相反,pipe 是从左往右)。

  • 参数传递:组合后的函数接收的参数,会全部传给最右边的函数,后续函数仅接收前一个函数的返回值,因此建议每个组合的函数都是“单输入、单输出”。

三、模拟 call 方法

通俗理解

call 方法的作用是「给函数换个“主人”」:比如小明有一个“吃饭”函数,小红想借用这个函数(让函数里的 this 指向小红),就可以用 call 实现。核心是“改变函数内部的 this 指向,并立即执行函数”。

专业拆解(附代码解析)

call 是 Function.prototype 上的方法,所有函数都能调用。其底层逻辑是“将函数挂载到目标对象上,作为对象的方法调用(此时 this 指向该对象),执行后删除临时方法,避免污染原对象”,具体实现如下:

Function.prototype.mycall2 = function (thisArg, ...args) {
  // 1. 校验:调用 mycall2 的必须是函数,否则报错(健壮性处理)
  if (typeof this !== "function") {
    throw new TypeError(this + " is not a function");
  }

  // 2. 确定 this 指向:传入 null/undefined 时,this 指向全局对象(浏览器是 window,Node 是 global)
  let context = thisArg == null ? globalThis : Object(thisArg);

  // 3. 创建唯一 Symbol 属性,防止覆盖对象原有属性(比如对象本身就有 fn 方法,避免冲突)
  const fn = Symbol("fn");

  // 4. 把当前函数(this 指向的就是调用 mycall2 的函数)挂载到 context 上
  context[fn] = this; 

  // 5. 执行函数,传入参数,接收执行结果(作为对象方法调用,this 自然指向 context)
  const result = context[fn](...args);

  // 6. 删掉临时挂载的属性,不污染原对象(核心:用完即删,保持对象纯净)
  delete context[fn];

  // 7. 返回函数执行结果(与原生 call 行为一致,返回函数执行后的结果)
  return result
};

用法示例

function sayHi() {
  console.log(`Hi, 我是 ${this.name},年龄 ${this.age}`);
}

const person1 = { name: "张三", age: 20 };
const person2 = { name: "李四", age: 22 };

// 用自定义的 mycall2 改变 this 指向
sayHi.mycall2(person1); // Hi, 我是 张三,年龄 20
sayHi.mycall2(person2, 123); // Hi, 我是 李四,年龄 22(多余参数不影响,函数不接收即可)

关键注意点

  • thisArg 处理:如果传入 null/undefined,this 指向 globalThis(全局对象);如果传入基本类型(如 123、"abc"),会被 Object() 转成对应包装对象(如 Number、String)。

  • Symbol 作用:确保临时属性唯一,避免覆盖目标对象已有的属性,是实现的关键细节。

四、模拟 apply 方法

通俗理解

apply 和 call 几乎一样,都是“改变函数 this 指向并立即执行”,唯一区别是「传参方式」:call 是“逐个传参”(比如 call(obj, 1, 2, 3)),apply 是“数组传参”(比如 apply(obj, [1,2,3])),相当于“批量传参”。

专业拆解(附代码解析)

apply 的实现逻辑和 call 高度一致,核心差异在于“处理参数的方式”,具体实现如下:

Function.prototype.myapply2 = function (thisArg, argsArray) {
  // 1. 必须是函数才能调用(和 call 一致的健壮性校验)
  if (typeof this !== "function") {
    throw new TypeError(this + " is not a function");
  }

  // 2. 处理 this 指向(和 call 完全一致)
  let context = thisArg == null ? globalThis : Object(thisArg);

  // 3. 处理参数:不传 argsArray / 传 null → 默认为空数组(避免解构报错)
  // ?? 是空值合并运算符,只有当 argsArray 为 null/undefined 时,才返回 []
  const args = argsArray ?? [];

  // 4. 唯一 Symbol 防止属性冲突(和 call 一致)
  const fn = Symbol("fn");
  context[fn] = this;

  // 5. 执行函数:用扩展运算符 ... 将数组参数拆成逐个参数,和 call 逻辑一致
  const result = context[fn](...args);

  // 6. 清理临时属性,不污染原对象(和 call 一致)
  delete context[fn];

  return result;
};

用法示例

function sum(a, b, c) {
  return a + b + c;
}

const obj = { name: "测试" };

// 用 myapply2 传参(数组形式)
sum.myapply2(obj, [1, 2, 3]); // 6
sum.myapply2(obj); // 0(args 为空数组,a、b、c 都是 undefined,相加为 0)

关键注意点

  • 参数处理:argsArray 必须是数组(或类数组),如果传入非数组,会报错(原生 apply 也是如此);如果不传,默认按空数组处理。

  • 与 call 的区别:仅传参方式不同,底层执行逻辑完全一致,二者可相互替代(call 能做的,apply 也能做,只是传参麻烦一点)。

五、模拟 bind 方法

通俗理解

bind 和 call、apply 的区别是「不立即执行」:call/apply 是“改变 this 并马上执行”,bind 是“改变 this 并返回一个新函数,后续需要手动调用这个新函数才会执行”,相当于“提前绑定好 this,后续随时可用”。

专业拆解(附代码解析)

bind 的实现比 call/apply 复杂,核心要处理两个点:「参数柯里化」和「new 调用时的 this 指向」,具体实现如下:

Function.prototype.myBind = function(context, ...args) {
  // 1. 调用者必须是函数(健壮性校验)
  if (typeof this !== 'function') {
    throw new TypeError('The bound object must be a function');
  }

  // 2. 保存原函数(关键!因为后续返回的新函数需要执行原函数,this 会被改变,所以提前保存)
  const self = this; 

  // 3. 返回一个新的绑定函数(不立即执行,等待后续调用)
  function boundFunction(...newArgs) {
    // 4. 合并参数(柯里化:bind 时传入的 args + 后续调用新函数时传入的 newArgs)
    const allArgs = args.concat(newArgs);

    // 5. 执行原函数,判断是普通调用还是 new 调用
    // 用 new 调用 boundFunction 时,this 指向 new 出来的实例,此时要忽略之前绑定的 context
    // 否则,this 指向绑定的 context
    return self.apply(
      this instanceof boundFunction ? this : context,
      allArgs
    );
  }

  // 6. 继承原函数的原型,让 new 能正常工作(关键细节)
  // 比如用 new 调用绑定后的函数,实例能访问原函数原型上的属性/方法
  if (this.prototype) {
    function Empty() {} // 空函数作为中间层,避免原型链污染
    Empty.prototype = this.prototype;
    boundFunction.prototype = new Empty();
  }

  return boundFunction;
};

用法示例

function Person(name, age) {
  this.name = name;
  this.age = age;
  console.log(`我是 ${this.name},年龄 ${this.age}`);
}

const obj = { name: "默认名称" };

// 1. 普通绑定:提前绑定 this 和部分参数
const boundPerson = Person.myBind(obj, "张三");
boundPerson(20); // 我是 张三,年龄 20(this 指向 obj,合并参数 ["张三", 20])

// 2. new 调用:忽略绑定的 context,this 指向新实例
const instance = new boundPerson(22); // 我是 undefined,年龄 22(this 指向 instance,name 未赋值)
console.log(instance.age); // 22(实例能访问 age 属性,原型继承生效)

关键注意点

  • new 调用处理:这是 bind 和 call/apply 最大的区别之一,用 new 调用绑定后的函数时,this 会指向新实例,而非绑定的 context。

  • 原型继承:通过空函数中间层继承原函数原型,避免直接赋值原型导致的污染(如果直接 boundFunction.prototype = this.prototype,修改 boundFunction 原型会影响原函数原型)。

六、实现链式调用

通俗理解

链式调用就像「连环操作」:比如买奶茶时,“点单→加珍珠→加冰→付款”,每一步操作完成后,都能继续下一步,不用重复写对象名。核心是“每个方法执行后,返回当前对象(this),让后续方法能继续调用”。

专业拆解(附代码解析)

链式调用在 JS 中非常常见(如 jQuery、Promise),实现逻辑极其简单,核心就是「return this」,具体实现如下:

// 定义一个类(也可以是构造函数)
class class1 {
  constructor() {
    // 可选:初始化一些属性
    this.data = [];
  }
}

// 给类的原型添加方法,每个方法执行后 return this
class1.prototype.method = function (param) {
  console.log("执行方法,参数:", param);
  this.data.push(param); // 可以做一些业务逻辑
  return this; // 必须 return this,才能实现链式调用
};

// 扩展更多方法,同样 return this
class1.prototype.anotherMethod = function (param) {
  console.log("执行另一个方法,参数:", param);
  this.data.push(param);
  return this;
};

// 使用:创建实例后,链式调用方法
const ins = new class1();
ins.method('a').anotherMethod('b').method('c'); 
// 输出:执行方法,参数:a → 执行另一个方法,参数:b → 执行方法,参数:c
console.log(ins.data); // ['a', 'b', 'c'](业务逻辑生效)

关键注意点

  • 核心要求:每个需要链式调用的方法,必须返回 this(当前实例),如果返回其他值,后续链式调用会报错(因为其他值可能没有对应的方法)。

  • 适用场景:常用于封装工具类、组件方法(如表单验证、DOM 操作),简化代码写法。

七、发布订阅模式(EventEmitter)

通俗理解

发布订阅模式就像「公众号订阅」:你(订阅者)关注了一个公众号(发布者),当公众号发布新文章(发布事件)时,所有关注的人都会收到通知(执行订阅的回调)。核心是“解耦发布者和订阅者,二者互不依赖,通过事件仓库传递消息”。

专业拆解(附代码解析)

发布订阅模式是前端常用的设计模式,常用于组件通信、事件监听(如 Vue 的事件总线),下面是完整的 EventEmitter 实现,包含订阅、取消订阅、发布、一次性订阅四个核心方法:

class EventEmitter {
  // 1. 构造函数:初始化事件仓库(存储事件名和对应的回调函数数组)
  constructor() {
    // 用 Map 存储:key=事件名(字符串),value=回调函数数组(一个事件可以有多个订阅者)
    this.events = new Map();
  }

  // 2. 订阅事件:监听一个事件,添加回调函数
  on(eventName, listener) {
    // 如果事件不存在,先创建一个空数组(避免后续 push 报错)
    if (!this.events.has(eventName)) {
      this.events.set(eventName, []);
    }
    // 把回调函数 push 进数组(一个事件可以订阅多个回调)
    this.events.get(eventName).push(listener);
  }

  // 3. 取消订阅:移除指定事件的指定回调函数
  off(eventName, listener) {
    // 事件不存在,直接返回(无需处理)
    if (!this.events.has(eventName)) return;

    const listeners = this.events.get(eventName);
    // 找到回调函数在数组中的索引
    const index = listeners.indexOf(listener);
    // 找到并删除对应的函数(splice 会修改原数组)
    if (index !== -1) {
      listeners.splice(index, 1);
    }
  }

  // 4. 发布事件:触发指定事件,执行所有订阅的回调函数,并传递参数
  emit(eventName, ...args) {
    // 事件不存在,直接返回(没有订阅者,无需执行)
    if (!this.events.has(eventName)) return;

    const listeners = this.events.get(eventName);
    // 遍历执行所有回调函数,并传入发布时的参数
    listeners.forEach(listener => listener(...args));
  }

  // 5. 只监听一次:订阅事件后,执行一次回调就自动取消订阅
  once(eventName, listener) {
    // 包装一层函数,执行原回调后,立即取消订阅
    const wrappedListener = (...args) => {
      // 先执行原回调函数
      listener(...args);
      // 执行完立刻删除当前包装函数(取消订阅)
      this.off(eventName, wrappedListener);
    };
    // 订阅包装后的函数(而非原函数,确保执行一次后取消)
    this.on(eventName, wrappedListener);
  }
}

用法示例

// 创建 EventEmitter 实例(发布者)
const emitter = new EventEmitter();

// 1. 订阅事件(订阅者1)
function callback1(data) {
  console.log("订阅者1收到消息:", data);
}
emitter.on("message", callback1);

// 2. 订阅事件(订阅者2,只监听一次)
emitter.once("message", (data) => {
  console.log("订阅者2收到消息(只一次):", data);
});

// 3. 发布事件(触发所有订阅者)
emitter.emit("message", "Hello World"); 
// 输出:订阅者1收到消息:Hello World → 订阅者2收到消息(只一次):Hello World

// 4. 再次发布事件(订阅者2已取消订阅,不再执行)
emitter.emit("message", "再次发送消息");
// 输出:订阅者1收到消息:再次发送消息

// 5. 取消订阅者1的订阅
emitter.off("message", callback1);

// 6. 第三次发布事件(没有订阅者,无输出)
emitter.emit("message", "第三次发送消息");

关键注意点

  • 事件仓库:用 Map 存储比对象更灵活,能避免对象属性名的冲突,且能更方便地获取、删除事件。

  • once 实现:核心是“包装回调函数”,执行原回调后立即取消订阅,注意不能直接订阅原函数(否则无法取消)。

  • 取消订阅:必须传入订阅时的同一个回调函数(不能是匿名函数),否则无法找到并删除。

八、单例模式

通俗理解

单例模式就像「公司的 CEO」:整个公司只有一个 CEO,无论你什么时候、在哪里找,找到的都是同一个人。核心是“一个类只能创建一个实例,后续所有创建实例的操作,都返回同一个已存在的实例”。

专业拆解(附代码解析)

单例模式常用于封装全局工具类、数据库连接、全局状态管理等场景,避免重复创建实例造成资源浪费,下面是最简洁的 ES6 实现方式:

class Singleton {
  // 静态属性:存储唯一实例(静态属性属于类,不属于实例,全局唯一)
  static instance = null;

  constructor() {
    // 关键逻辑:如果已经有实例,直接返回旧实例(阻止创建新实例)
    if (Singleton.instance) {
      return Singleton.instance;
    }
    // 没有实例,创建并保存到静态属性中
    Singleton.instance = this;
    // 初始化实例属性(根据业务需求添加)
    this.data = [];
  }

  // 实例方法(业务逻辑):添加数据
  addData(item) {
    this.data.push(item);
  }

  // 实例方法(业务逻辑):获取数据
  getData() {
    return this.data;
  }
}

用法示例

// 多次创建实例
const instance1 = new Singleton();
const instance2 = new Singleton();
const instance3 = new Singleton();

// 验证:所有实例都是同一个
console.log(instance1 === instance2); // true
console.log(instance1 === instance3); // true

// 操作实例1,instance2、instance3 也会受到影响(因为是同一个实例)
instance1.addData("测试数据");
console.log(instance2.getData()); // ["测试数据"]
console.log(instance3.getData()); // ["测试数据"]

关键注意点

  • 静态属性 instance:必须用 static 修饰,确保属于类本身,而非实例,这样才能全局唯一。

  • 构造函数拦截:在 constructor 中判断 instance 是否存在,存在则返回旧实例,阻止新实例创建,这是单例的核心。

  • 适用场景:全局工具类(如日期工具、请求工具)、全局状态管理,避免重复创建实例造成资源浪费。

九、私有变量的实现(闭包+Symbol)

通俗理解

私有变量就像「个人的隐私」:只能自己访问和修改,别人无法直接获取或修改。在 JS 中,没有原生的 private 关键字(ES6 有,但兼容性有限),常用「闭包+Symbol」实现真正的私有变量。

专业拆解(附代码解析)

核心逻辑:用立即执行函数(IIFE)创建闭包,闭包内的 Symbol 变量外部无法访问;类内部用这个 Symbol 作为属性名,实现私有属性,具体实现如下:

const Person = (function() {
  // 1. 闭包内的 Symbol,外部无法访问(真正的私有标识)
  // Symbol 具有唯一性,即使外部也创建同名 Symbol,也和这个不是同一个
  const _name = Symbol('name');

  // 2. 定义类,类内部可以访问闭包内的 _name
  class Person {
    constructor(name) {
      // 3. 用 Symbol 作为属性名,实现私有属性(外部无法通过 obj.name 访问)
      this[_name] = name; 
    }

    // 4. 提供公共方法,供外部间接访问私有属性(可控访问)
    getName() {
      return this[_name];
    }

    // 可选:提供公共方法,供外部间接修改私有属性(可控修改)
    setName(newName) {
      this[_name] = newName;
    }
  }

  // 5. 把类返回出去,外部可以创建实例,但无法访问闭包内的 _name
  return Person;
})();

用法示例

const person = new Person("张三");

// 1. 无法直接访问私有属性(外部没有 _name Symbol,无法获取)
console.log(person.name); // undefined(没有这个公共属性)
console.log(person[_name]); // 报错(_name 是闭包内的变量,外部无法访问)

// 2. 通过公共方法访问和修改私有属性
console.log(person.getName()); // 张三
person.setName("李四");
console.log(person.getName()); // 李四

关键注意点

  • 闭包的作用:隔离作用域,让 _name Symbol 只能在 IIFE 内部访问,外部无法获取,确保私有性。

  • Symbol 的唯一性:即使外部创建 const _name = Symbol('name'),也和闭包内的 _name 不是同一个,无法访问私有属性。

  • 可控访问:通过公共方法(getName、setName)访问和修改私有属性,可以在方法中添加校验逻辑(如判断姓名长度),更安全。

十、函数字符串转成函数(new Function vs eval)

通俗理解

有时候我们会拿到一个「函数字符串」(比如从后端接口获取,或动态拼接),需要把它转成真正的函数才能执行。JS 中有两种常用方式:new Function 和 eval,二者核心区别是「作用域安全」。

专业拆解(附代码解析)

两种方式的实现的逻辑不同,安全性也有差异,下面分别实现并对比:

// 1. 使用 new Function(推荐:作用域独立、更安全)
function stringToFunction(funcStr) {
  try {
    // new Function 接收字符串参数,最后一个参数是函数体,前面是形参
    // 这里用 "return " + funcStr,把函数字符串转成函数表达式,执行后返回函数
    const func = new Function('return ' + funcStr)();
    return func;
  } catch (error) {
    console.error('转换失败:', error);
    return null;
  }
}

// 2. 使用 eval(不推荐:能访问当前作用域、不安全)
function stringToFunctionEval(funcStr) {
  try {
    /**
     * 给函数字符串加括号,转成函数表达式(避免被当作语句执行)
     * 比如 funcStr 是 "function add(){}",加括号后是 "(function add(){})",eval 执行后返回函数
     */
    const func = eval('(' + funcStr + ')');
    return func;
  } catch (error) {
    console.error('转换失败:', error);
    return null;
  }
}

// 测试示例
const funcStr = 'function add(a, b) { return a + b; }';

// 用 new Function 转换
const add1 = stringToFunction(funcStr);
console.log(add1(1, 2)); // 3(转换成功,能正常执行)

// 用 eval 转换
const add2 = stringToFunctionEval(funcStr);
console.log(add2(3, 4)); // 7(转换成功,能正常执行)

核心区别(重点)

方式 作用域 安全性 推荐度
new Function 独立作用域,只能访问全局变量,无法访问当前局部变量 高,不会污染当前作用域,也不会执行恶意代码(相对安全) 推荐
eval 能访问当前作用域的所有变量(局部、全局) 低,可能执行恶意代码,也可能污染当前作用域 不推荐(除非明确知道字符串安全)

关键注意点

  • new Function 转换时,需要给 funcStr 加 "return ",把函数字符串转成函数表达式,否则会返回 undefined。

  • eval 转换时,需要给 funcStr 加括号,避免被 JS 解析器当作语句执行(比如 function add(){} 会被当作函数声明,无法直接返回)。

  • 安全性:如果函数字符串来自不可信来源(如用户输入、未知接口),无论哪种方式都有风险,需先做校验。

十一、模板字符串执行(with + new Function)

通俗理解

有时候我们会有一个「模板字符串」(比如 "a+b,{a+b}, {b}"),需要结合一个对象(比如 {a:1, b:2}),动态替换模板中的变量并执行计算。核心是“用 with 绑定对象作用域,让模板中能直接使用对象的属性”。

专业拆解(附代码解析)

实现逻辑:用 new Function 创建动态函数,结合 with 语句将对象作为作用域,让模板字符串能直接访问对象属性,具体实现两种方式:

// 方式1:使用 with(简洁,兼容性好)
// with 可以把一个对象当作作用域,在代码块里直接用属性名,不用写 对象.属性
const sprintf2 = (template, obj) => {
  // 1. 动态创建函数:参数是 obj,函数体是 with(obj){return `模板字符串`}
  const fn = new Function("obj", `with(obj){return \`${template}\`;}`);
  
  // 2. 执行函数,传入 obj,返回模板执行后的结果
  return fn(obj);
};

// 方式2:使用解构赋值(更安全,避免 with 的副作用)
const sprintf3 = (template, obj) => {
  // 用解构赋值,把 obj 的所有属性变成函数内的局部变量
  // 比如 obj = {a:1,b:2},解构后变成 const {a,b} = obj;
  const fn = new Function(
    "obj",
    `const { ${Object.keys(obj).join(',')} } = obj; return \`${template}\`;`
  );
  return fn(obj);
};

// 测试示例
console.log(sprintf2("a:${a+b},b:${b}", { a: 1, b: 2 }));
// 输出:a:3,b:2(a+b 计算生效,直接使用 obj 的 a、b 属性)

console.log(sprintf3("a:${a*2},b:${b+3}", { a: 1, b: 2 }));
// 输出:a:2,b:5(解构赋值后,直接使用 a、b 变量)

核心区别

  • 方式1(with):简洁高效,但 with 会改变作用域链,可能导致变量查找变慢,且如果模板中使用了未在 obj 中定义的变量,会向上查找全局变量,有一定风险。

  • 方式2(解构赋值):更安全,模板中只能使用 obj 中的属性(未定义的变量会报错),不会向上查找全局变量,推荐使用。

关键注意点

  • 模板字符串转义:动态创建函数时,模板字符串中的 要转义成 \,否则会被 JS 解析器当作函数体的结束。

  • 属性名处理:如果 obj 的属性名包含特殊字符(如 -、空格),解构赋值会报错,需提前处理属性名。

十二、async 优雅处理(错误前置)

通俗理解

async/await 是 JS 处理异步的常用方式,但默认需要用 try/catch 捕获错误,代码会显得繁琐。错误前置的核心是“用一个包装函数,统一捕获异步错误,返回 [错误, 结果] 数组,后续直接判断错误即可,不用写 try/catch”。

专业拆解(附代码解析)

实现逻辑:封装一个异步包装函数,内部用 try/catch 捕获异步函数的错误,成功则返回 [null, 结果],失败则返回 [错误, null],简化错误处理流程:

// 定义一个异步包装函数,接收一个异步函数(或返回 Promise 的函数)
async function errorCaptured(asyncFunc) {
    try {
        // 执行传入的异步函数,等待结果(asyncFunc 是异步函数,用 await 等待)
        let res = await asyncFunc()
        // 成功:返回 [没有错误(null), 执行结果]
        return [null, res]
    } catch(e) {
        // 失败:返回 [错误信息, 没有结果(null)]
        return [e, null]
    }
}

// 模拟一个异步请求(比如接口请求)
function fetchData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      // 模拟成功:resolve("成功数据")
      // 模拟失败:reject("网络错误")
      reject("网络错误")
    }, 500)
  })
}

// 使用:无需写 try/catch,直接判断错误
async function demo() {
  // 调用包装函数,解构出错误和结果
  const [err, data] = await errorCaptured(fetchData)

  // 错误判断:有错误则处理,无错误则使用数据
  if (err) {
    console.log("❌ 错误:", err)
    return // 有错误,终止后续逻辑
  }
  console.log("✅ 成功:", data)
}

demo(); // 输出:❌ 错误:网络错误

核心优势

  • 简化代码:不用在每个 async 函数中写 try/catch,统一由包装函数捕获错误,代码更简洁。

  • 错误前置:先判断错误,再处理业务逻辑,逻辑更清晰,避免错误导致后续代码报错。

  • 通用性强:可用于所有异步场景(接口请求、定时器、文件读取等),只需传入异步函数即可。

关键注意点

  • asyncFunc 要求:必须是异步函数(async 修饰)或返回 Promise 的函数,否则 await 无法等待,会直接返回同步结果。

  • 返回值格式:固定返回 [err, data] 数组,err 为 null 表示成功,data 为 null 表示失败,后续使用需严格遵循这个格式。

十三、实现 Promise 任务调度器

通俗理解

Promise 任务调度器就像「餐厅排队取号」:餐厅一次只能接待2桌客人(最大并发数),后面来的客人排队,等前面的客人吃完(任务执行完),再依次接待下一桌。核心是“控制并发任务的数量,避免同时执行过多任务导致资源耗尽”。

专业拆解(附代码解析)

实际开发中,任务调度器常用于控制接口请求并发数(比如同时请求10个接口,控制最多2个并发),下面实现两种常用版本:通用并发调度器(面试常考)和业务实用版并发请求控制:

// ====================
// 1. 通用并发调度器 Scheduler(面试标准版)
// 核心:控制最大并发数,任务排队执行,执行完一个补一个
// ====================
class Scheduler {
  constructor(maxCount = 2) {
    this.maxCount = maxCount; // 最大并发数(默认2)
    this.queue = [];         // 任务队列(存储等待执行的任务)
    this.running = 0;        // 当前运行中的任务数
  }

  // 添加任务:将任务加入队列(不立即执行)
  add(task) {
    this.queue.push(task);
  }

  // 开始执行任务:初始化启动最大并发数的任务
  start() {
    for (let i = 0; i < this.maxCount; i++) {
      this.run(); // 启动任务执行
    }
  }

  // 执行任务核心逻辑:从队列取任务,执行后补充新任务
  run() {
    // 终止条件:队列空了 或 运行中的任务数 >= 最大并发数
    if (!this.queue.length || this.running >= this.maxCount) return;

    this.running++; // 运行中的任务数+1
    const task = this.queue.shift(); // 从队列头部取出一个任务

    // 执行任务(任务是返回 Promise 的函数),执行完后更新状态
    task().finally(() => {
      this.running--; // 任务执行完,运行中的任务数-1
      this.run(); // 递归调用 run,从队列取下一个任务执行
    });
  }
}

// ====================
// 2. 并发请求控制 multiRequest(业务实用版)
// 核心:控制接口请求并发数,收集所有请求结果,最终统一返回
// ====================
function multiRequest(urls, maxNum) {
  const total = urls.length; // 总请求数
  const result = new Array(total).fill(null); // 存储所有请求结果(按顺序)
  let current = 0; // 当前要执行的请求索引
  let finished = 0; // 已完成的请求数

  // 返回 Promise,所有请求完成后 resolve 结果
  return new Promise((resolve) => {
    // 初始启动:启动最大并发数的请求(不超过总请求数)
    for (let i = 0; i < Math.min(maxNum, total); i++) {
      next();
    }

    // 执行下一个请求的逻辑
    function next() {
      if (current >= total) return; // 所有请求都已启动,终止

      const index = current++; // 记录当前请求的索引(确保结果顺序正确)
      // 执行请求(urls 中的每个元素是返回 Promise 的请求函数)
      urls[index]()
        .then((res) => {
          // 请求成功:存储成功结果
          result[index] = { success: true, data: res };
        })
        .catch((err) => {
          // 请求失败:存储失败信息
          result[index] = { success: false, error: err };
        })
        .finally(() => {
          finished++; // 已完成请求数+1
          if (finished === total) {
            resolve(result); // 所有请求完成,返回结果
          }
          next(); // 执行完一个,启动下一个请求
        });
    }
  });
}

// ====================
// 3. 使用 DEMO(可直接运行)
// ====================
// 模拟任务队列(每个任务是返回 Promise 的函数)
const tasks = [
  () => new Promise(r => setTimeout(() => { console.log("任务1"); r(); }, 1000)),
  () => new Promise(r => setTimeout(() => { console.log("任务2"); r(); }, 500)),
  () => new Promise(r => setTimeout(() => { console.log("任务3"); r(); }, 1200)),
  () => new Promise(r => setTimeout(() => { console.log("任务4"); r(); }, 800)),
];

// 测试通用调度器(最大并发数2)
const scheduler = new Scheduler(2);
tasks.forEach(task => scheduler.add(task));
scheduler.start();
// 输出顺序:任务2(500ms)→ 任务1(1000ms)→ 任务4(800ms)→ 任务3(1200ms)

// 模拟请求队列(每个请求是返回 Promise 的函数)
const urls = [
  () => new Promise(resolve => setTimeout(() => resolve("URL1"), 1000)),
  () => new Promise((_, reject) => setTimeout(() => reject("URL2"), 500)),
  () => new Promise(resolve => setTimeout(() => resolve("URL3"), 2000)),
  () => new Promise(resolve => setTimeout(() => resolve("URL4"), 800)),
];

// 测试业务版并发请求控制(最大并发数2)
multiRequest(urls, 2).then(res => {
  console.log("全部请求完成:", res);
  // 输出:[{success:true,data:"URL1"}, {success:false,error:"URL2"}, {success:true,data:"URL3"}, {success:true,data:"URL4"}]
});

关键注意点

  • 通用调度器(Scheduler):适用于所有 Promise 任务(不局限于请求),核心是“队列+递归补充任务”,控制最大并发数。

  • 业务版(multiRequest):专门用于接口请求,会按请求顺序存储结果(即使某个请求先完成,也会存在对应索引位置),最终统一返回所有结果,符合业务需求。

  • 任务要求:无论是调度器还是请求控制,传入的任务/请求必须是「返回 Promise 的函数」,否则无法监听执行完成的状态。

总结

以上13个代码片段,覆盖了 JavaScript 中「函数封装、设计模式、异步处理、作用域控制」等核心场景,既是日常开发的高频工具,也是面试中的重点考察内容。

学习这些片段的关键,不是死记代码,而是理解背后的原理(比如闭包、this 指向、Promise 机制),这样才能灵活运用到实际业务中,甚至根据需求修改优化。建议结合示例代码亲手运行,感受每个细节的作用,加深理解。

❌
❌