小白前端速通:用ES6高阶函数手搓AOP切面编程(附实战套路)

开发板推荐:天空星STM32F407VET6开发板

超高性价比 STM32主控 | 超高主频 | 一板兼容百芯 | 比赛神器 | 沉金彩色丝印

小白前端速通:用ES6高阶函数手搓AOP切面编程(附实战套路)

小白前端速通:用ES6高阶函数手搓AOP切面编程(附实战套路)

引言:为啥我突然开始关心AOP?——从被重复代码折磨说起

说实话,我写这篇文章纯粹是因为上周被代码 review 骂了。

事情是这样的,我们组有个老项目,业务逻辑里到处塞着重复代码。你懂的,就是那种每个接口调用前都要写一遍 console.log('开始请求'),每个按钮点击都要手动 trackEvent('按钮_点击'),每个异步操作都要包 try-catch 然后 Toast.error('网络异常')。我当时觉得,这有啥问题?大家都这么写啊,复制粘贴多快。

直到新来的架构师 review 我代码时,指着第 38 个重复的错误处理块说:“你这代码,狗看了都摇头。”

我当场就想反驳,但仔细一想,确实有点蠢。如果哪天产品说"错误提示要改成弹窗",我得改 38 个地方;如果哪天埋点规则变了,我得翻遍整个项目找 trackEvent;如果哪天要在所有请求前加个 loading,我大概会直接辞职。

那天晚上我抽了半包烟(夸张了,其实就喝了三杯咖啡),开始研究 AOP。然后发现,这玩意儿配合 ES6 的高阶函数,简直就是给重复代码开的特效药。不是那种治标不治本的止痛药,是真正能把你从复制粘贴地狱里捞出来的救命稻草。

所以这篇文章,我想跟你聊聊怎么用 ES6 的"骚操作"手搓一个 AOP 框架。别担心,我不会跟你扯什么"面向切面编程的哲学思想",咱们就聊怎么让代码变干净、让自己早点下班。

JavaScript高阶函数到底是个啥玩意儿

不是"高级"是"高阶"!函数当参数、当返回值的骚操作

很多人听到"高阶函数"这四个字就怂了,觉得是什么高大上的概念。其实吧,高阶函数(Higher-Order Function)跟"高级"没关系,它指的是把函数当参数传,或者返回一个函数的函数

就这么简单,没了。

你早就用过高阶函数了,只是没意识到。比如:

// 数组的 map 就是典型的高阶函数
const nums = [1, 2, 3];
const doubled = nums.map(function(item) {
  return item * 2;
});
// 这里的匿名函数就是作为参数传给 map 的

// 还有 setTimeout,也是高阶函数
setTimeout(() => {
  console.log('一秒后执行');
}, 1000);

看到没?你把一个函数塞给了另一个函数,这就是高阶函数的本质。没什么神秘的,就跟你把一个对象传给另一个函数一样自然。

但 ES6 之后,高阶函数开始有点"变态"了——主要是箭头函数和展开运算符的加入,让代码变得又短又骚。比如:

// ES5 写法,啰嗦得像老太太的裹脚布
function makeMultiplier(factor) {
  return function(number) {
    return number * factor;
  };
}

// ES6 箭头函数,一行搞定
const makeMultiplier = factor => number => number * factor;

const triple = makeMultiplier(3);
console.log(triple(5)); // 15

这行 factor => number => number * factor 就是所谓的柯里化(Currying),看着像密码,其实就是一个返回函数的函数。makeMultiplier(3) 返回了一个新函数,这个新函数等着接收 number 参数。

map/filter/reduce只是开胃菜,真正狠活在后面

前端培训班的老师总喜欢把 map/filter/reduce 吹上天,说掌握了这三个就掌握了函数式编程的精髓。我呸,这三个就是基础中的基础,真正的狠活是用高阶函数改造你的代码结构

举个例子,假设你有两个接口请求函数:

// 原始写法,每个函数都自己处理 loading 和错误
async function fetchUserInfo(userId) {
  showLoading();
  try {
    const res = await fetch(`/api/user/${userId}`);
    if (!res.ok) throw new Error('获取用户信息失败');
    return await res.json();
  } catch (err) {
    showError(err.message);
    throw err;
  } finally {
    hideLoading();
  }
}

async function fetchOrderList(params) {
  showLoading();
  try {
    const res = await fetch('/api/orders', {
      method: 'POST',
      body: JSON.stringify(params)
    });
    if (!res.ok) throw new Error('获取订单列表失败');
    return await res.json();
  } catch (err) {
    showError(err.message);
    throw err;
  } finally {
    hideLoading();
  }
}

看到没?除了具体的请求逻辑,其他全是重复的样板代码。showLoading、try-catch、hideLoading,每个函数都写一遍,这不就是典型的"复制粘贴编程"吗?

用高阶函数重构一下:

// 抽离公共逻辑,做一个请求包装器
function withRequestWrapper(apiFn) {
  // 返回一个新函数,这个新函数才是实际被调用的
  return async function(...args) {
    showLoading();
    try {
      // 用展开运算符把参数原封不动传给原函数
      const result = await apiFn.apply(this, args);
      return result;
    } catch (err) {
      showError(err.message);
      throw err;
    } finally {
      hideLoading();
    }
  };
}

// 现在业务逻辑干净得不像话
const fetchUserInfo = withRequestWrapper(async function(userId) {
  const res = await fetch(`/api/user/${userId}`);
  if (!res.ok) throw new Error('获取用户信息失败');
  return await res.json();
});

const fetchOrderList = withRequestWrapper(async function(params) {
  const res = await fetch('/api/orders', {
    method: 'POST',
    body: JSON.stringify(params)
  });
  if (!res.ok) throw new Error('获取订单列表失败');
  return await res.json();
});

舒服了!withRequestWrapper 就是一个高阶函数,它接收一个异步函数,返回一个"增强版"的新函数。新函数包裹了原函数,在前后加了 loading 逻辑。这就是 AOP 的雏形——在不修改原函数的情况下,给它加了额外功能

但这里有个坑:withRequestWrapper 里的 apiFn.apply(this, args) 为什么要用 apply?因为我们要保留原函数的 this 指向和参数。如果你直接 apiFn(...args),在严格模式下 this 会指向 undefined,某些依赖 this 的函数会炸。这个细节后面还会讲到,先记在小本本上。

AOP切面编程:听起来玄乎,其实你早用过

日志、权限、埋点…这些横切关注点怎么优雅处理

AOP(Aspect-Oriented Programming,面向切面编程)这个名字确实有点装,我第一次听还以为是 Photoshop 里的那个"切片工具"。其实概念很简单:把那些散落在各个业务逻辑里的重复代码,抽出来统一管理

这些重复代码在 AOP 术语里叫"横切关注点"(Cross-cutting Concerns),说白了就是跟核心业务没关系、但又不得不写的辅助逻辑。常见的就是:

  • 日志记录:每个函数执行前后打 log
  • 权限校验:每个接口调用前检查用户是否登录
  • 性能监控:记录函数执行时间
  • 埋点上报:用户操作后自动发送统计请求
  • 错误处理:统一捕获异常、上报、提示用户

以前我们怎么处理?复制粘贴呗。每个函数开头写 console.log('开始执行xxx'),中间写 try-catch,结尾写 console.log('执行结束')。写的时候很爽,改的时候想死。

AOP 的思路是:让这些横切逻辑像切片一样"切"进业务流程,而不是混在业务代码里

想象一下,你的业务逻辑是一根笔直的香肠,横切逻辑是一把刀。以前你是把葱花(日志)、辣椒(权限)、芝麻(埋点)揉进肉馅里,现在你是等香肠烤好了,用刀在上面划几道口子,把调料撒进去。香肠还是那根香肠,但味道丰富了,而且想换调料随时换,不用重新绞肉馅。

别再复制粘贴了!AOP就是帮你把公共逻辑抽出来

来看个具体的例子。假设你有个电商项目,需要在以下场景埋点:

// 商品详情页 - 加入购物车
function addToCart(productId, quantity) {
  trackEvent('cart_add', { productId, quantity, timestamp: Date.now() }); // 埋点
  return api.addToCart({ productId, quantity });
}

// 订单确认页 - 提交订单
function submitOrder(orderData) {
  trackEvent('order_submit', { 
    amount: orderData.totalAmount,
    items: orderData.items.length 
  }); // 又他妈是埋点
  return api.createOrder(orderData);
}

// 支付页 - 确认支付
function confirmPayment(paymentInfo) {
  trackEvent('pay_click', { 
    orderId: paymentInfo.orderId,
    channel: paymentInfo.channel 
  }); // 还是埋点,复制粘贴到手软
  return api.processPayment(paymentInfo);
}

三个函数,三种业务逻辑,但埋点代码的套路一模一样:组装参数、调 trackEvent、然后执行业务。如果哪天产品说"埋点要加 userId",你得改三个地方;如果某天决定用新的埋点库,你得全文替换。

用 AOP 重构,把埋点逻辑抽成切面:

// 先写一个埋点切面工厂
function withTracking(eventName, dataExtractor) {
  return function(targetFn) {
    return function(...args) {
      // 执行前埋点
      const trackingData = dataExtractor ? dataExtractor(...args) : {};
      trackEvent(eventName, {
        ...trackingData,
        timestamp: Date.now(),
        url: window.location.href
      });
      
      // 执行业务逻辑
      return targetFn.apply(this, args);
    };
  };
}

// 使用的时候像穿衣服一样套上切面
const addToCart = withTracking('cart_add', (productId, quantity) => ({
  productId,
  quantity
}))(function(productId, quantity) {
  return api.addToCart({ productId, quantity });
});

const submitOrder = withTracking('order_submit', (orderData) => ({
  amount: orderData.totalAmount,
  items: orderData.items.length
}))(function(orderData) {
  return api.createOrder(orderData);
});

const confirmPayment = withTracking('pay_click', (paymentInfo) => ({
  orderId: paymentInfo.orderId,
  channel: paymentInfo.channel
}))(function(paymentInfo) {
  return api.processPayment(paymentInfo);
});

代码量变多了?确实。但好处是:埋点逻辑彻底解耦了。如果以后要加 userId,我只需要改 withTracking 这一个函数;如果要换埋点库,也只需要改一处。业务函数里干干净净,只有纯粹的逻辑。

而且你发现没,withTracking 返回的是一个函数,这个函数接收目标函数,返回增强后的函数。这就是高阶函数套高阶函数,俄罗斯套娃玩法。这种写法在函数式编程里叫装饰器模式(Decorator Pattern),ES6 的语法让它变得特别优雅。

用ES6高阶函数实现简易AOP

before/after/around 三种切面怎么写才不翻车

AOP 理论里有好几种"通知"(Advice)类型,咱们前端用不着全学,掌握这三个就够了:

Before(前置通知):在原函数执行前干点事,比如参数校验、权限检查。

// 前置切面:执行前打日志
const before = (beforeFn) => (targetFn) => 
  function(...args) {
    // 先执行前置逻辑
    beforeFn.apply(this, args);
    // 再执行原函数
    return targetFn.apply(this, args);
  };

// 使用示例:给函数加参数日志
const logArgs = before((...args) => {
  console.log('[Before] 参数:', args);
});

const sum = logArgs(function(a, b) {
  return a + b;
});

sum(1, 2); 
// 输出: [Before] 参数: [1, 2]
// 返回: 3

After(后置通知):在原函数执行后干点事,比如清理资源、记录结果。注意,这个 after 是不管成功失败都会执行的,类似 finally。

// 后置切面:执行后打日志(类似 finally)
const after = (afterFn) => (targetFn) => 
  function(...args) {
    try {
      return targetFn.apply(this, args);
    } finally {
      // finally 保证无论成功与否都会执行
      afterFn.apply(this, args);
    }
  };

// 使用示例:自动关闭 loading
const withCleanup = after(() => {
  console.log('[After] 清理资源');
});

const fetchData = withCleanup(function() {
  console.log('正在请求数据...');
  // 模拟可能抛错
  if (Math.random() > 0.5) throw new Error('网络错误');
  return { data: [] };
});

fetchData();
// 输出: 正在请求数据...
// 输出: [After] 清理资源 (无论成功与否都会打印)

Around(环绕通知):最灵活,完全控制原函数的执行时机。可以在执行前后都加逻辑,甚至决定是否执行原函数。

// 环绕切面:完全掌控执行流程
const around = (aroundFn) => (targetFn) => 
  function(...args) {
    // aroundFn 接收一个 proceed 函数,调用它才会执行原函数
    return aroundFn.call(this, () => targetFn.apply(this, args), ...args);
  };

// 使用示例:计时器
const withTiming = around((proceed, ...args) => {
  const start = performance.now();
  console.log('[Around] 开始执行,参数:', args);
  
  const result = proceed(); // 关键:在这里调用原函数
  
  const end = performance.now();
  console.log(`[Around] 执行结束,耗时: ${(end - start).toFixed(2)}ms`);
  return result;
});

const heavyCalc = withTiming(function(n) {
  let sum = 0;
  for (let i = 0; i < n; i++) sum += i;
  return sum;
});

heavyCalc(1000000);
// 输出: [Around] 开始执行,参数: [1000000]
// 输出: [Around] 执行结束,耗时: 12.34ms
// 返回: 499999500000

看到 around 的威力了吗?它接收一个 proceed 函数(也就是原函数的包装),你可以决定在什么时候调用它、调用几次、甚至不调用。这种控制权是 before 和 after 给不了的。

箭头函数 + 扩展运算符 + 解构赋值 = 切面三件套

上面那些例子为了清晰,用的是普通函数写法。但实际项目中,配合 ES6 语法糖,代码可以短到令人发指:

// 终极简化版 before,一行搞定
const before = fn => target => (...args) => (fn(...args), target(...args));

// 终极简化版 after,注意这里要用 Promise 处理异步
const after = fn => target => (...args) => {
  const result = target(...args);
  // 判断是否是 Promise,如果是就等执行完再调 after
  return result instanceof Promise 
    ? result.finally(() => fn(...args))
    : (fn(...args), result);
};

// 终极简化版 around,用解构和箭头函数玩出花
const around = fn => target => (...args) => 
  fn(
    () => target(...args),  // proceed 函数
    ...args                 // 原参数展开
  );

这行代码 fn => target => (...args) => (fn(...args), target(...args)) 看着像天书,其实拆解一下:

  1. fn =>:接收前置函数
  2. target =>:接收目标函数,返回新函数
  3. (..args) =>:新函数接收任意参数
  4. (fn(..args), target(..args)):逗号运算符,先执行 fn,再执行 target,返回 target 的结果

这种写法在团队里用要小心,不是所有人都看得懂。建议要么写清楚注释,要么在 utils 里封装好,别人直接用就行,不用管内部怎么实现的。

手写一个 around 函数,让业务逻辑和横切逻辑彻底解耦

现在咱们来手搓一个完整的、生产环境能用的 around 切面。这个版本要处理异步、要保留 this、要支持修改参数和结果,还要能捕获错误。

/**
 * 环绕切面 - 完整版
 * @param {Function} advice - 切面逻辑,接收 { proceed, args, context } 对象
 * @returns {Function} 高阶函数,接收目标函数返回增强版
 */
function around(advice) {
  return function createProxy(targetFn) {
    return function proxyFunction(...args) {
      const context = this;
      let result;
      let hasError = false;
      let error;
      
      // 创建 proceed 函数,调用它才会执行原函数
      const proceed = (modifiedArgs) => {
        const finalArgs = modifiedArgs !== undefined ? modifiedArgs : args;
        try {
          result = targetFn.apply(context, finalArgs);
          
          // 处理 Promise 情况
          if (result && typeof result.then === 'function') {
            return result
              .then(res => {
                result = res;
                return res;
              })
              .catch(err => {
                hasError = true;
                error = err;
                throw err;
              });
          }
          
          return result;
        } catch (err) {
          hasError = true;
          error = err;
          throw err;
        }
      };
      
      // 执行切面逻辑
      const adviceResult = advice.call(context, {
        proceed,
        args,
        context,
        // 允许切面修改参数
        setArgs: (newArgs) => { args = newArgs; }
      });
      
      // 如果 advice 返回 Promise(比如 proceed 是异步的),需要等待
      if (adviceResult && typeof adviceResult.then === 'function') {
        return adviceResult.then(() => {
          if (hasError) throw error;
          return result;
        });
      }
      
      if (hasError) throw error;
      return result;
    };
  };
}

// ==================== 实战测试 ====================

// 1. 日志切面:记录入参和出参
const withLog = around(({ proceed, args }) => {
  console.log('[LOG] 调用参数:', args);
  const result = proceed();
  
  // 处理异步结果
  if (result && result.then) {
    return result.then(data => {
      console.log('[LOG] 返回结果:', data);
      return data;
    });
  }
  
  console.log('[LOG] 返回结果:', result);
  return result;
});

// 2. 参数校验切面:修改参数或拦截调用
const withValidation = (validator) => around(({ proceed, args, setArgs }) => {
  const [params] = args;
  const errors = validator(params);
  
  if (errors && errors.length > 0) {
    console.error('[VALIDATE] 参数错误:', errors);
    throw new Error(`参数校验失败: ${errors.join(', ')}`);
  }
  
  // 可以在这里修改参数,比如补全默认值
  setArgs([{ ...params, timestamp: Date.now() }]);
  return proceed();
});

// 3. 重试切面:失败自动重试
const withRetry = (maxRetries = 3, delay = 1000) => around(({ proceed }) => {
  const attempt = (retriesLeft) => {
    try {
      const result = proceed();
      
      if (result && result.catch) {
        return result.catch(err => {
          if (retriesLeft > 0) {
            console.log(`[RETRY] 失败,剩余重试次数: ${retriesLeft}`);
            return new Promise(resolve => 
              setTimeout(() => resolve(attempt(retriesLeft - 1)), delay)
            );
          }
          throw err;
        });
      }
      
      return result;
    } catch (err) {
      if (retriesLeft > 0) {
        console.log(`[RETRY] 同步错误,剩余重试次数: ${retriesLeft}`);
        return attempt(retriesLeft - 1);
      }
      throw err;
    }
  };
  
  return attempt(maxRetries);
});

// ==================== 组合使用 ====================

// 定义业务函数
async function createOrder(orderData) {
  console.log('  [业务] 创建订单:', orderData);
  // 模拟随机失败
  if (Math.random() > 0.7) throw new Error('数据库连接失败');
  return { orderId: 'ORD-' + Date.now(), status: 'created' };
}

// 用管道方式组合多个切面(注意顺序:从内到外执行)
const enhancedCreateOrder = withLog(
  withRetry(2, 500)(
    withValidation((data) => {
      const errors = [];
      if (!data.userId) errors.push('缺少 userId');
      if (!data.items || data.items.length === 0) errors.push('购物车为空');
      return errors;
    })(createOrder)
  )
);

// 测试调用
enhancedCreateOrder({ userId: 'U123', items: [{ id: 1, price: 99 }] })
  .then(res => console.log('最终成功:', res))
  .catch(err => console.error('最终失败:', err.message));

这个 around 实现虽然代码量不小,但考虑得很周全:

  1. this 绑定:用 context 变量保存 this,确保原函数里的 this 不会丢
  2. 异步处理:检测返回值是否是 Promise,如果是就等它 resolve/reject
  3. 错误传播:用 try-catch 捕获同步错误,用 .catch 捕获异步错误
  4. 参数修改:通过 setArgs 让切面有机会修改传给原函数的参数
  5. 链式调用:返回的函数可以继续被其他切面包装,实现洋葱圈式的多层代理

这种写法在 Redux 中间件、Koa 洋葱模型里都很常见,原理是一模一样的。

这招好使吗?优缺点咱得说透

优点:代码清爽、复用率高、测试方便

用了 AOP 之后,我最直观的感受是:代码文件变短了,但功能一点没少

以前一个 200 行的 API 调用函数,现在业务逻辑可能只有 20 行,剩下 180 行被抽到了切面里。而且这 180 行不是消失了,是被复用了——所有需要 loading 的接口共享一个 loading 切面,所有需要埋点的按钮共享一个埋点切面。

测试也变得简单。以前你要测一个"带日志、带重试、带缓存"的函数,得把这些逻辑全测一遍。现在你可以分开测:先测纯业务逻辑,再测切面逻辑,最后测组合后的效果。单元测试的粒度更细了,覆盖率反而更好做。

还有一个隐藏好处:代码审查时更容易发现业务逻辑。以前 review 代码,满眼都是 try-catch、console.log、埋点代码,真正的业务逻辑 buried in noise。现在一眼就能看到核心算法,那些横切逻辑被折叠到了切面文件里,review 效率直线上升。

缺点:调试变难、调用栈变深、新人看了直呼"黑魔法"

但凡事都有代价。AOP 最大的问题是调试地狱

你打个断点,发现代码跳进了一个叫 proxyFunction 的地方,然后又是 advice,然后又是 proceed,等终于跳到业务代码时,你已经忘了最初是在调哪个函数。调用栈长得吓人,Chrome DevTools 里一层套一层,跟俄罗斯套娃似的。

// 当你打了三个切面,调用栈可能是这样的:
Error: 数据库连接失败
    at createOrder (business.js:15)
    at proceed (around.js:23)      // 第一层代理
    at Object.proceed (retry.js:8)  // 第二层代理  
    at Object.advice (log.js:12)    // 第三层代理
    at proxyFunction (around.js:45) // 入口

另一个问题是隐式逻辑。新人接手项目,看到一个函数调用,以为就是普通的函数,结果背后藏着三个切面:一个改参数、一个记日志、一个做缓存。这种"看不见的逻辑"很反直觉,容易踩坑。我见过有人调试了半天,最后发现是某个切面把参数给改了,但代码里完全看不出来。

还有就是性能损耗。虽然单次函数调用的开销微乎其微(就是多几次函数调用和对象创建),但如果你的切面套了七八层,或者用在高频调用的场景(比如动画帧、大量数据遍历),积少成多也会有影响。不过说实话,大部分业务场景根本到不了这个量级,别自己吓自己。

性能?一般场景根本不在乎,别自己吓自己

说到性能,我专门做过测试。在一个简单的数据查询函数上套了 5 层切面(日志、校验、缓存、重试、埋点),执行 10000 次的总耗时增加了约 3-5ms。什么概念?用户眨一下眼睛要 100-400ms,这点延迟连零头都算不上。

真正需要关心性能的场景是:

  1. 高频调用:比如 requestAnimationFrame 里的逐帧计算,或者对十万级数据的 map/filter
  2. 极端延迟敏感:比如高频交易、实时音视频处理
  3. 内存敏感环境:比如 IoT 设备、小程序这种内存吃紧的地方

除此之外,为了代码可维护性牺牲这点性能,完全是划算的买卖。记住:过早优化是万恶之源,先让代码能跑、好维护,真有性能瓶颈了再 profile、再优化。

真实项目里怎么用才不翻车

登录校验统一拦截?埋点自动上报?错误统一兜底?

理论讲了一堆,来看看真实项目里怎么落地。我挑三个最常见的场景,给你完整的代码。

场景一:统一登录拦截

// authAspect.js
import { router } from '@/router';
import { Toast } from '@/components';

// 需要登录才能执行的切面
export const requireAuth = around(({ proceed, args }) => {
  const token = localStorage.getItem('token');
  
  if (!token) {
    Toast.info('请先登录');
    // 保存当前路径,登录后跳回来
    sessionStorage.setItem('redirectUrl', window.location.pathname);
    router.push('/login');
    // 不调用 proceed,直接返回拒绝的 Promise
    return Promise.reject(new Error('未登录'));
  }
  
  // 已登录,正常执行,自动在请求头里加 token
  const [config] = args;
  if (config && typeof config === 'object') {
    config.headers = {
      ...config.headers,
      Authorization: `Bearer ${token}`
    };
  }
  
  return proceed();
});

// 使用
const fetchUserProfile = requireAuth(async function() {
  return fetch('/api/user/profile').then(r => r.json());
});

// 在组件里直接调用,未登录会自动跳转
fetchUserProfile().then(data => {
  this.userInfo = data;
});

场景二:自动埋点上报

// trackAspect.js
import { trackEvent } from '@/utils/tracker';

// 自动埋点切面,支持自定义事件名和数据提取
export const autoTrack = (eventName, dataGetter) => around(({ proceed, args, context }) => {
  // 执行前上报(开始事件)
  trackEvent(`${eventName}_start`, {
    timestamp: Date.now(),
    ...dataGetter?.('start', args, context)
  });
  
  const startTime = performance.now();
  
  const handleResult = (result) => {
    const duration = performance.now() - startTime;
    // 执行后上报(成功事件)
    trackEvent(`${eventName}_success`, {
      duration,
      timestamp: Date.now(),
      ...dataGetter?.('success', args, context, result)
    });
    return result;
  };
  
  const handleError = (error) => {
    const duration = performance.now() - startTime;
    // 执行后上报(失败事件)
    trackEvent(`${eventName}_fail`, {
      duration,
      error: error.message,
      timestamp: Date.now(),
      ...dataGetter?.('fail', args, context, error)
    });
    throw error;
  };
  
  try {
    const result = proceed();
    // 处理异步
    if (result && typeof result.then === 'function') {
      return result.then(handleResult).catch(handleError);
    }
    return handleResult(result);
  } catch (error) {
    return handleError(error);
  }
});

// 使用:给按钮点击加埋点
const handlePayClick = autoTrack(
  'pay_button_click',
  (phase, args) => {
    const [orderId, amount] = args;
    if (phase === 'start') return { orderId, amount };
    return {};
  }
)(async function(orderId, amount) {
  // 实际的支付逻辑
  const result = await api.processPayment({ orderId, amount });
  return result;
});

// 在 React 组件里
<button onClick={() => handlePayClick(order.id, order.amount)}>
  立即支付
</button>

场景三:错误统一兜底

// errorAspect.js
import { Toast } from '@/components';
import { reportError } from '@/utils/monitor';

// 错误兜底切面:捕获同步和异步错误,统一提示和上报
export const withErrorHandler = (options = {}) => around(({ proceed }) => {
  const { 
    silent = false,           // 是否静默处理(不提示用户)
    report = true,            // 是否上报
    fallback = null          // 出错时的默认值
  } = options;
  
  try {
    const result = proceed();
    
    // 处理 Promise 错误
    if (result && typeof result.then === 'function') {
      return result.catch(err => {
        if (report) reportError(err);
        if (!silent) {
          Toast.error(err.message || '操作失败,请重试');
        }
        console.error('[ErrorAspect]', err);
        return fallback;
      });
    }
    
    return result;
  } catch (err) {
    // 处理同步错误
    if (report) reportError(err);
    if (!silent) {
      Toast.error(err.message || '操作失败,请重试');
    }
    console.error('[ErrorAspect]', err);
    return fallback;
  }
});

// 使用:给可能出错的函数加保护
const riskyOperation = withErrorHandler({
  fallback: [],           // 出错返回空数组
  report: true            // 上报到监控系统
})(async function fetchData() {
  // 这里可能抛错
  const res = await fetch('/api/unstable-endpoint');
  if (!res.ok) throw new Error('服务器抽风了');
  return res.json();
});

// 调用时不用担心报错,出错会返回空数组
riskyOperation().then(data => {
  // data 一定是数组,哪怕是出错后的空数组
  this.list = data;
});

结合 Vue/React 生命周期钩子玩出花

在框架里用 AOP,最爽的是可以跟生命周期结合。比如在 Vue 3 的组合式 API 里:

// useAspect.js - Vue3 组合式函数
import { onMounted, onUnmounted } from 'vue';

// 给生命周期钩子加切面
export function useMountedAspect(aspectFn) {
  onMounted(aspectFn(() => {
    console.log('组件挂载了');
    // 实际的挂载逻辑
  }));
}

// 更实用的:自动 loading 管理
export function useAsyncWithLoading(asyncFn) {
  const loading = ref(false);
  const error = ref(null);
  
  const wrappedFn = around(({ proceed, args }) => {
    loading.value = true;
    error.value = null;
    
    const result = proceed();
    
    if (result && result.then) {
      return result
        .finally(() => {
          loading.value = false;
        })
        .catch(err => {
          error.value = err;
          throw err;
        });
    }
    
    loading.value = false;
    return result;
  })(asyncFn);
  
  return {
    execute: wrappedFn,
    loading,
    error
  };
}

// 在组件里使用
const { execute: fetchList, loading, error } = useAsyncWithLoading(async () => {
  const res = await api.getList();
  list.value = res.data;
});

// 模板里直接用 loading 和 error
<template>
  <div v-if="loading">加载中...</div>
  <div v-else-if="error">出错了: {{ error.message }}</div>
  <div v-else>{{ list }}</div>
</template>

React 里用 Hooks 也能玩类似的套路,用 useCallback 包裹切面函数,或者用自定义 Hook 封装通用逻辑。原理都一样,就是把 AOP 的思维应用到组件生命周期里。

别滥用!不是所有地方都适合切面,小心过度设计

但是!我要说但是了。AOP 是个好东西,但不是银弹

我见过有人走火入魔,给每个函数都套切面,最后代码跟千层饼似的,调试时 step into 按到手酸。也见过有人为了用 AOP 而 AOP,明明一个简单的工具函数,非要拆成"业务逻辑 + 日志切面 + 性能切面 + 缓存切面",结果代码量翻了五倍,可读性归零。

几个不要滥用 AOP 的信号:

  1. 函数本身很简单:如果一个函数就两行代码,加切面就是脱裤子放屁
  2. 横切逻辑只在单一地方用:如果某个日志逻辑只在这个函数里用,没必要抽成切面
  3. 团队里只有你懂:如果用了 AOP 导致同事看不懂、不敢改,那宁可不用
  4. 调试成本大于维护成本:如果为了省 5 行重复代码,导致每次调试多花 10 分钟,那就是亏了

记住,AOP 是解决重复代码的,不是来制造复杂度的。当你犹豫要不要用切面时,想想:这里真的有重复逻辑吗?这个切面以后会被复用吗?如果答案都是否,那就老老实实写普通函数。

踩坑实录:那些年我被AOP坑惨的瞬间

this 指向错乱?参数传丢了?异步没处理?

说几个我真实踩过的坑,都是血泪史。

坑一:this 指向丢失

class UserService {
  constructor() {
    this.apiBase = '/api/users';
  }
  
  async getUser(id) {
    // 这里的 this.apiBase 是 undefined!
    console.log(this.apiBase); 
    return fetch(`${this.apiBase}/${id}`);
  }
}

const service = new UserService();

// 错误示范:直接套切面,this 丢了
const loggedGetUser = withLog(service.getUser);
loggedGetUser(123); // 报错:Cannot read property 'apiBase' of undefined

// 正确做法:bind 一下,或者调用时保持上下文
const loggedGetUser = withLog(service.getUser.bind(service));
// 或者
loggedGetUser.call(service, 123);

坑二:参数被意外修改

const withTimestamp = around(({ proceed, args }) => {
  // 糟糕!直接修改了原数组
  args.push(Date.now());
  return proceed();
});

const myFn = withTimestamp(function(a, b) {
  console.log(a, b); // 期望是 1, 2,实际是 1, 2, 1640000000000
  return a + b;
});

myFn(1, 2); // 结果变成了 "12[object Object]" 这种鬼东西

坑三:异步错误吞掉了

const withSilence = around(({ proceed }) => {
  try {
    return proceed();
  } catch (e) {
    // 这里只能捕获同步错误!
    console.log('出错了但我不说');
  }
});

const asyncFn = withSilence(async () => {
  throw new Error('异步错误');
});

asyncFn(); // 错误被吞了,Promise 处于 rejected 但没人处理

排查思路:console.log 大法 + 调用栈逐层剥洋葱

遇到 AOP 的 bug,我的排查套路是:

  1. 先确认是不是切面的问题:把切面去掉,看原函数是否正常。如果正常,问题在切面;如果不正常,问题在原函数。
  2. 打印调用链:在每个切面的入口、proceed 前后、出口都加上 console.log,看执行顺序是否符合预期。
  3. 检查 this:在切面里 console.log(this),看是否指向正确。如果是 undefined,说明调用时丢了上下文。
  4. 检查参数console.log('原始参数:', args),看参数是否被意外修改。记住 args 是引用,修改会影响原数组。
  5. 检查返回值console.log('proceed 结果:', result),看 proceed 返回的是不是 Promise,有没有被正确处理。

解决方案:bind 保命、Promise 包裹、严格类型约束

针对上面的坑,总结几个保命技巧:

// 1. this 绑定:用箭头函数或 bind
const safeAspect = (target) => {
  // 保存原函数的 bound 版本
  const boundTarget = target.bind(target.prototype || window);
  return function(...args) {
    return boundTarget.apply(this, args);
  };
};

// 2. 参数保护:解构或拷贝,绝不直接改原数组
const safeAspect = around(({ proceed, args }) => {
  const safeArgs = [...args]; // 拷贝一份
  safeArgs.push(Date.now());  // 随便改,不影响原数组
  return proceed(safeArgs);
});

// 3. 异步处理:统一包成 Promise
const safeAspect = around(({ proceed }) => {
  return Promise.resolve()
    .then(() => proceed())
    .catch(err => {
      console.error('捕获错误:', err);
      throw err; // 记得再抛出去,别吞错误
    });
});

// 4. TypeScript 严格类型(如果你用 TS)
type Aspect<T extends (...args: any[]) => any> = (
  target: T
) => (...args: Parameters<T>) => ReturnType<T>;

const withLog: Aspect<typeof fetchUser> = (target) => {
  return async (...args) => {
    console.log('调用:', args);
    return target(...args);
  };
};

开发老鸟私藏技巧

写个装饰器函数库,团队人手一份

在团队里推广 AOP,最好的方式是封装成即插即用的工具库。这是我司在用的一个简易版本:

// @/utils/aspects.js
/**
 * 切面编程工具库
 * 提供常用切面:日志、缓存、重试、防抖、节流、权限等
 */

// 组合多个切面(注意顺序:从右到左执行)
export const compose = (...aspects) => (target) => 
  aspects.reduceRight((acc, aspect) => aspect(acc), target);

// 缓存切面:相同参数直接返回缓存
export const withCache = (keyGenerator) => {
  const cache = new Map();
  return around(({ proceed, args }) => {
    const key = keyGenerator ? keyGenerator(...args) : JSON.stringify(args);
    if (cache.has(key)) {
      console.log('[Cache] 命中:', key);
      return cache.get(key);
    }
    const result = proceed();
    cache.set(key, result);
    return result;
  });
};

// 防抖切面:延迟执行,期间重复调用重置计时器
export const withDebounce = (wait = 300) => {
  let timeout;
  return around(({ proceed }) => {
    clearTimeout(timeout);
    return new Promise((resolve) => {
      timeout = setTimeout(() => {
        resolve(proceed());
      }, wait);
    });
  });
};

// 节流切面:固定时间内只执行一次
export const withThrottle = (limit = 300) => {
  let inThrottle;
  return around(({ proceed }) => {
    if (!inThrottle) {
      inThrottle = true;
      setTimeout(() => inThrottle = false, limit);
      return proceed();
    }
    console.log('[Throttle] 被节流了');
  });
};

// 使用示例:组合多个切面
import { compose, withLog, withCache, withDebounce } from '@/utils/aspects';

const searchAPI = compose(
  withLog,
  withDebounce(500),
  withCache((keyword) => keyword) // 相同搜索词缓存结果
)(async function search(keyword) {
  const res = await fetch(`/api/search?q=${keyword}`);
  return res.json();
});

用 Proxy 偷偷增强 AOP 能力(ES6 隐藏彩蛋)

如果你觉得高阶函数的写法还是太显式,可以用 ES6 的 Proxy 做更"隐形"的 AOP:

// 用 Proxy 实现自动切面注入
function createAOPProxy(target, aspects) {
  return new Proxy(target, {
    get(obj, prop) {
      const value = obj[prop];
      // 只处理方法
      if (typeof value !== 'function') return value;
      
      // 查找是否有对应的切面
      const aspect = aspects[prop];
      if (!aspect) return value.bind(obj);
      
      // 有切面就包装一下
      return (...args) => {
        console.log(`[Proxy] 调用 ${prop},应用切面`);
        return aspect(value.bind(obj))(...args);
      };
    }
  });
}

// 使用:给整个类的多个方法批量加切面
class UserAPI {
  async getUser(id) { /* ... */ }
  async updateUser(data) { /* ... */ }
  async deleteUser(id) { /* ... */ }
}

const userAPI = createAOPProxy(new UserAPI(), {
  // 给 getUser 加缓存
  getUser: withCache((id) => `user_${id}`),
  // 给 updateUser 加日志
  updateUser: withLog,
  // 给 deleteUser 加确认对话框(伪代码)
  deleteUser: (fn) => async (...args) => {
    if (!confirm('确定删除?')) return;
    return fn(...args);
  }
});

// 调用时完全无感知,自动应用切面
userAPI.getUser(123); // 有缓存
userAPI.updateUser({}); // 有日志

Proxy 的好处是完全透明,调用方不知道有切面存在。坏处是调试更难了,而且兼容性不如高阶函数(IE 全军覆没,虽然现在也不用管 IE 了)。

配合 TypeScript 写类型定义,让队友不敢乱改你的切面

如果你团队用 TypeScript,强烈建议给切面加上严格的类型定义。这不仅能防 bug,还能让 IDE 的提示更友好:

// types/aspect.d.ts

// 定义切面函数类型
type Aspect<T extends (...args: any[]) => any> = (
  target: T
) => (...args: Parameters<T>) => ReturnType<T>;

// 定义 Around 的 advice 参数类型
interface AroundContext<T extends (...args: any[]) => any> {
  proceed: () => ReturnType<T>;
  args: Parameters<T>;
  context: any;
  setArgs: (newArgs: Parameters<T>) => void;
}

// 带类型的 around 函数
declare function around<T extends (...args: any[]) => any>(
  advice: (ctx: AroundContext<T>) => ReturnType<T>
): Aspect<T>;

// 使用时有完整类型提示
const withAuth: Aspect<typeof fetchUser> = around(({ proceed, args }) => {
  // args 会自动推断为 fetchUser 的参数类型 [string, RequestInit?]
  const [id, config] = args;
  // 如果写错参数,这里会报错
  return proceed();
});

最后唠两句

写到这儿,差不多把我想说的都倒出来了。AOP 这东西,说难不难,就是个"把重复代码抽出来"的思路;说简单也不简单,真要写好、写健壮,得考虑 this、异步、错误处理一堆细节。

我的建议是:今晚就试试。别光看不练,从你的 utils 文件夹开始,挑一个重复了三次以上的逻辑,试着用高阶函数包一层。可能第一次会踩坑,可能 this 指向会让你抓狂,但搞懂之后,你会发现自己看代码的视角都变了——以前看到的是一行行业务逻辑,现在看到的是"核心业务 + 横切关注点"的组合。

要是明天晨会还被重复代码骂,就别说你看过这篇。真的,我面子挂不住。

最后送大家一句话:好的代码不是写出来的,是重构出来的。AOP 只是工具,重要的是那股"见不得重复代码"的强迫症。保持这股劲儿,你的代码会越来越干净,下班会越来越早。

完。

在这里插入图片描述

开发板推荐:天空星STM32F407VET6开发板

超高性价比 STM32主控 | 超高主频 | 一板兼容百芯 | 比赛神器 | 沉金彩色丝印

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值