小白前端速通:用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)) 看着像天书,其实拆解一下:
fn =>:接收前置函数target =>:接收目标函数,返回新函数(..args) =>:新函数接收任意参数(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 实现虽然代码量不小,但考虑得很周全:
- this 绑定:用
context变量保存 this,确保原函数里的 this 不会丢 - 异步处理:检测返回值是否是 Promise,如果是就等它 resolve/reject
- 错误传播:用 try-catch 捕获同步错误,用 .catch 捕获异步错误
- 参数修改:通过
setArgs让切面有机会修改传给原函数的参数 - 链式调用:返回的函数可以继续被其他切面包装,实现洋葱圈式的多层代理
这种写法在 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,这点延迟连零头都算不上。
真正需要关心性能的场景是:
- 高频调用:比如 requestAnimationFrame 里的逐帧计算,或者对十万级数据的 map/filter
- 极端延迟敏感:比如高频交易、实时音视频处理
- 内存敏感环境:比如 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 的信号:
- 函数本身很简单:如果一个函数就两行代码,加切面就是脱裤子放屁
- 横切逻辑只在单一地方用:如果某个日志逻辑只在这个函数里用,没必要抽成切面
- 团队里只有你懂:如果用了 AOP 导致同事看不懂、不敢改,那宁可不用
- 调试成本大于维护成本:如果为了省 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,我的排查套路是:
- 先确认是不是切面的问题:把切面去掉,看原函数是否正常。如果正常,问题在切面;如果不正常,问题在原函数。
- 打印调用链:在每个切面的入口、proceed 前后、出口都加上
console.log,看执行顺序是否符合预期。 - 检查 this:在切面里
console.log(this),看是否指向正确。如果是 undefined,说明调用时丢了上下文。 - 检查参数:
console.log('原始参数:', args),看参数是否被意外修改。记住 args 是引用,修改会影响原数组。 - 检查返回值:
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 只是工具,重要的是那股"见不得重复代码"的强迫症。保持这股劲儿,你的代码会越来越干净,下班会越来越早。
完。

&spm=1001.2101.3001.5002&articleId=157988194&d=1&t=3&u=89cf66d701c8401f90de39692b89dd2a)
989

被折叠的 条评论
为什么被折叠?



