前端AOP编程实战:用高阶函数轻松实现切面逻辑
2026/9/15 4:49:41 网站建设 项目流程

1. 组件代码里的横切关注点:先定位痛点

如果你写的业务代码里,到处散落着日志、权限判断、异常上报、loading控制这类与业务无关的逻辑,那么这篇文章就是写给你的。我在后端写Java时,Spring AOP用得顺手,切日志、切权限、切缓存,改一行配置就能让所有接口生效;后来转做前端,发现类似的横切关注点其实一样存在——每个接口请求要打日志,每个删除按钮要弹确认,每个提交动作要防连点,每个详情页要埋性能点。绝大多数前端项目处理这些需求的方式,是复制粘贴同一段逻辑到每一个方法里。

刚接手一个中后台项目时,我统计过一份“垃圾代码占比”:仅登录态校验这一件事,就散落在41个函数里,每个函数开头都是差不多的6行。这还不是最离谱的,最离谱的是有一次需求要把“统一埋点”从上报当前页面改成上报操作名,我全局搜、逐个改,花了差不多一个下午,改完了42处,还有2处漏了。后来我在代码评审里跟队友说:这不是经验问题,是缺少切面能力,也就是AOP(Aspect Oriented Programming,面向切面编程)——把横切逻辑从业务函数里抽出去,在函数执行前后统一织入。前端没有Spring,但前端有高阶函数,配合ES6的语法特性,实现一套轻量AOP工具完全可行。

先对齐几个概念,避免后面看代码发懵。AOP里有几个词:切面(Aspect)就是你要抽离的那段横切逻辑,比如日志模块;通知(Advice)是具体执行时机,常见的有before前置通知、after后置通知、around环绕通知、afterThrowing异常通知;切入点(Pointcut)决定了在哪些函数上织入;织入(Weaving)就是把通知绑定到目标函数的过程。前端场景大多数不需要把术语分得那么细,你只需记住一句话:AOP做的不是把逻辑塞进函数里,而是给函数外面套一层壳,壳里统一处理横切逻辑。

术语前端语境下的理解例子
切面一段横切逻辑模块日志切面、鉴权切面
通知在哪个时机执行切面调用前、调用后、出错时
切入点作用在哪些函数上所有API请求函数
织入把切面包到目标函数外高阶函数包装
连接点单个可被切的目标getUserInfo()

2. 高阶函数凭什么成为AOP的天然实现载体

搞清楚AOP思想后,下一个问题就是:前端怎么实现织入?答案就是高阶函数(Higher-Order Function)。JavaScript的函数是对象,能像值一样传来传去,也能作为返回值再回来。高阶函数指的就是:接收函数作为参数,或者返回一个新函数的函数。这两个特性凑在一起,天然就是一个“包裹器”——你递给我一个原函数,我还你一个带有附加行为的增强函数。

这个思路写出来极其简单:

function withLogging(targetFn) { return function(...args) { console.log('[LOG] before:', args); const result = targetFn.apply(this, args); console.log('[LOG] after:', result); return result; }; }

每次调用withLogging(someFunction)的时候,我们就把“日志”这个切面织入到了someFunction身上。调用方拿到的仍然是一个函数,签名看起来差不多,可以继续传给其他地方用,原函数本身没有被修改。这就是AOP里“非侵入”的精髓:不改业务代码,业务代码也不感知切面存在。

但这里有一个新手极其容易犯的错:为什么用apply(this, args),而不是直接targetFn(...args)?如果你包装的是一个普通函数,两者差别不大;但如果你包装的是一个对象的方法,比如:

const userService = { name: 'user center', getUser() { return this.name; } }; const wrapped = withLogging(userService.getUser); wrapped.call(userService); // 输出 undefined

直接执行targetFn(...args)时,targetFn内部的this丢失,在严格模式下甚至会变成undefined。只有通过targetFn.apply(this, ...)把外层包装函数的this透传进去,才能确保被包装的方法在作为对象方法调用时行为不变。这也是为什么包装函数必须用普通函数function而不是箭头函数——箭头函数没有自己的this,无法完成这个透传动作。

再往深看一层,高阶函数能成为AOP载体,还有一个关键点:闭包。每次调用withLogging都会生成一个独立的闭包环境,用来保存切面状态。比如防重复提交要用的 lock 变量、性能埋点要用的开始时间,都存在这个闭包里。多个切面组合时,每层闭包互不干扰。这种能力让高阶函数比简单的工具函数封装要强得多——它不只是复用逻辑,还能为一次调用保留完整上下文。

ES6为这个玩法提供了几个特别顺手的辅助能力:rest参数...args让不定长参数传递毫无负担;模板字符串让日志输出干净易读;Promise 原生支持和async/await让异步切面的时序处理变得可控;后面还会提到的Proxy和装饰器语法,更是把AOP的体验往“声明式”推进了一大截。可以说,ES6之后的前端,搞AOP已经不需要任何框架。

3. 手写一个ES6 AOP工具:从最简到可用的三个版本

3.1 第一版:before/after最简实现

先写最直白的一版。核心思路就是上面那个模板,拆成三个独立的高阶函数,分别处理前置、后置和环绕:

function before(targetFn, beforeFn) { return function(...args) { beforeFn.apply(this, args); return targetFn.apply(this, args); }; } function after(targetFn, afterFn) { return function(...args) { const result = targetFn.apply(this, args); afterFn.call(this, result); return result; }; }

用法是这样的:

const saveOrder = (orderData) => { // 业务逻辑:校验并保存订单 }; const safeSaveOrder = before(saveOrder, function validate(orderData) { if (!orderData.items?.length) { throw new Error('订单不能为空'); } });

注意beforeFn里如果throw了异常,原函数根本不会执行——这正好是实现参数校验、权限拦截的基础。after里的afterFn接收原函数的返回值,可以用来做日志、更新状态,但不能直接改写返回值。

3.2 第二版:支持异步、异常和环绕通知

第一版有两个明显缺陷:不支持异步函数,异常发生时也没有统一处理入口。升级版把这三件事一起解决:

function around(targetFn, { before, after, onError } = {}) { return function(...args) { const beforeResult = before ? before.apply(this, args) : undefined; let result; try { result = targetFn.apply(this, args); } catch (syncErr) { if (onError) return onError.call(this, syncErr, args, beforeResult); throw syncErr; } // 关键:判断返回值是否为 Promise if (result && typeof result.then === 'function') { return Promise.resolve(result).then( (data) => after ? after.call(this, data, args, beforeResult) : data, (asyncErr) => onError ? onError.call(this, asyncErr, args, beforeResult) : Promise.reject(asyncErr) ); } return after ? after.call(this, result, args, beforeResult) : result; }; }

这里的关键点在于第12行的判断:result && typeof result.then === 'function'。如果不判断这一点,after通知会在异步函数真正执行完之前就被调用,返回的是一个 pending 的 Promise,这会造成非常难排查的时序Bug。加了判断后,同步函数走同步分支,异步函数自动切换到 then 回调里执行 after,时序完全正确。

beforeResult是before阶段返回的上下文,它可以传递一些信息给after阶段,比如“开始时间”“前置状态”。这在性能埋点和链路追踪里很实用。

3.3 第三版:组合多个切面

业务中很少只加一个切面,往往是日志、鉴权、埋点、错误上报叠加。所以提供一个组合函数,把多个高阶函数串起来:

function composeAspects(targetFn, aspects) { return aspects.reduceRight((acc, aspect) => aspect(acc), targetFn); }

这个写法参考了Redux的compose思路。reduceRight从右往左遍历,最后一个传入的切面最先执行。例如:

const enhanced = composeAspects( placeOrder, [ logAspect, requireRoleAspect('admin'), measureTimeAspect, errorReportAspect ] );

最终调用enhanced(orderData)时,执行顺序是:logAspect先进入 →requireRoleAspect('admin')进入 →measureTimeAspect进入 →errorReportAspect进入 → 此时才执行真正的placeOrder。返回时逆序走完各自的 after 逻辑。如果你希望某个切面最先执行,就把它放在数组末尾。这个顺序规则值得记住,我见过不止一个人在组合顺序上栽跟头。

3.4 完整工具库的一角:给AOP工具本身加保护

作为一个给团队用的工具函数,还有几个硬细节要处理:

function wrapMethod(target, methodName, aspect) { const originalFn = target[methodName]; // 防止同一个方法被重复包装 if (originalFn.__isWrapped) { return originalFn; } const wrapped = aspect(originalFn); Object.defineProperties(wrapped, { __isWrapped: { value: true }, __original: { value: originalFn } }); // 把原函数的静态属性复制到包装函数上 Object.defineProperties(wrapped, Object.getOwnPropertyDescriptors(originalFn)); target[methodName] = wrapped; return wrapped; }

这段代码解决的是工程化问题:__isWrapped避免热更新或者重复初始化时把已有切面再包一层;__original保留原函数引用,方便调试和临时绕过切面;Object.getOwnPropertyDescriptors把静态属性、getter、setter同步到新函数上,避免包装后fn.lengthfn.name、自定义标志位丢失。前端很多AOP方案在简单demo里跑得好好的,一上生产就出各种幺蛾子,基本都是栽在这些工程细节上。

4. 五个可以直接抄的业务切面

4.1 请求层日志与参数脱敏

前端埋点最常踩的坑是:把敏感参数打进了日志,比如密码、token。日志切面的职责不只是打印,还要负责脱敏:

const SENSITIVE_KEYS = ['password', 'token', 'authorization']; function sanitize(value, depth = 0) { if (depth > 3 || value === null || typeof value !== 'object') return value; if (Array.isArray(value)) return value.map((item) => sanitize(item, depth + 1)); const result = {}; for (const key of Object.keys(value)) { if (SENSITIVE_KEYS.includes(key)) { result[key] = '***'; } else { result[key] = sanitize(value[key], depth + 1); } } return result; } function logAspect(targetFn) { return function(...args) { console.log(`[API LOG] ${targetFn.name} 参数:`, args.map(sanitize)); const start = performance.now(); const result = targetFn.apply(this, args); if (result && typeof result.then === 'function') { return Promise.resolve(result).then((data) => { console.log(`[API LOG] ${targetFn.name} 耗时: ${(performance.now() - start).toFixed(1)}ms`); return data; }); } console.log(`[API LOG] ${targetFn.name} 耗时: ${(performance.now() - start).toFixed(1)}ms`); return result; }; }

在实际项目里,日志切面一般放在最外层,因为它是观察者,不应该影响任何业务行为。注意这里没有包裹try/catch,是因为错误会由专门的错误处理切面去管——切面之间也要各司其职,别一个大而全的切面把什么都干了,否则后面想看日志还得从一堆报错堆栈里翻。

4.2 按钮级防重复提交

服务端一般会做幂等控制,但前端提前拦截能让用户体验好一大截。这个切面的核心是利用闭包保存一个lock:

function preventDoubleSubmit(targetFn, { timeout = 3000 } = {}) { let lock = false; return function(...args) { if (lock) { console.warn('[防连点] 已有请求进行中,本次调用已忽略'); return Promise.resolve({ code: -1, message: '请求进行中,请勿重复提交' }); } lock = true; // 兜底:超过 timeout 强制释放锁,避免极端情况下永久锁死 const timer = setTimeout(() => { lock = false; }, timeout); try { const result = targetFn.apply(this, args); if (result && typeof result.then === 'function') { return Promise.resolve(result).finally(() => { clearTimeout(timer); lock = false; }); } clearTimeout(timer); lock = false; return result; } catch (err) { clearTimeout(timer); lock = false; throw err; } }; }

三个细节值得注意:第一,返回值也走了Promise分支,如果原函数是同步的但返回了一个thenable,也能正确解锁;第二,finally保证成功和失败都会释放锁;第三,timeout是保险丝——万一Promise永远不resolve,锁不至于一直占用。第一次写这个切面时我没加timeout,结果某个接口在极端情况下挂死,用户整个页面所有按钮都点不动,只能刷新。

4.3 权限校验切面

按钮级权限控制和路由级权限是两套逻辑。路由级一般在路由守卫里做,按钮级则需要作用到具体的操作函数上:

function requireRole(...roles) { return function(targetFn) { return function(...args) { const currentUser = getCurrentUser(); const userRole = currentUser?.roles ?? []; const hasPermission = roles.some((role) => userRole.includes(role)); if (!hasPermission) { // 这里抛错,或者返回一个统一的无权限对象 return Promise.reject({ code: 403, message: '没有权限执行此操作' }); } return targetFn.apply(this, args); }; }; } // 使用 const deleteUser = requireRole('admin')(function deleteUser(id) { return api.delete(`/user/${id}`); });

这里的关键点是:权限校验不通过时,原函数根本没有执行。因为在调用targetFn.apply(this, args)之前就 return 了。如果想更严格,直接在 before 里 throw,业务方就必须处理异常;如果希望调用方像处理普通业务失败一样处理,就返回一个带 code 的 rejected Promise。不同团队风格不同,我倾向于返回rejected Promise,因为这样调用方可以统一走catch

4.4 性能埋点与慢任务告警

性能监控的切面除了计算耗时,更实用的能力是“超过阈值才上报”,避免数据量过大:

function measureTime(targetFn, { threshold = 200, reportName } = {}) { return function(...args) { const start = performance.now(); const fnName = reportName || targetFn.name || 'anonymous'; const handleEnd = () => { const duration = performance.now() - start; if (duration > threshold) { // 上报给监控系统,比如 window.__monitor?.report console.warn(`[慢任务] ${fnName} 耗时 ${duration.toFixed(1)}ms (阈值 ${threshold}ms)`); } return duration; }; const result = targetFn.apply(this, args); if (result && typeof result.then === 'function') { return Promise.resolve(result).then((data) => { handleEnd(); return data; }); } handleEnd(); return result; }; }

要注意把handleEnd写在 Promise 的 resolve 回调里,否则测量的是“发起请求的耗时”而不是“请求完成的耗时”。另外给这个切面单独加一个reportName参数很重要——函数名经常在压缩混淆后变成单字母,线上根本认不出来。真名映射应该在构建时生成配置表,而不是运行时推断。

4.5 错误上报与优雅降级

错误处理切面值得单独写一个,因为前端错误的处理姿势和业务状态强相关:

function errorAspect(targetFn, { fallback, report = true } = {}) { return function(...args) { try { const result = targetFn.apply(this, args); if (result && typeof result.then === 'function') { return Promise.resolve(result).catch((err) => { return handleError.call(this, err, args); }); } return result; } catch (err) { return handleError.call(this, err, args); } function handleError(err, callArgs) { if (report) { // 上报错误给监控平台 reportErrorToMonitor(err, { source: targetFn.name, args: callArgs }); } if (fallback) { // 提供降级数据,比如返回缓存 return typeof fallback === 'function' ? fallback.call(this, err, callArgs) : fallback; } throw err; } }; }

一个容易忽略的设计点:降级函数是在处理完错误后调用的,而且report默认打开。如果你不想每个函数都传一遍,可以把默认值抽成全局配置。另外,错误上报切面不应该吃掉错误——如果没提供fallback,它还是要re-throw,否则调用方的catch永远捕获不到失败,业务逻辑会静默出错,这种Bug特别隐蔽。

5. AOP边界:这些坑我都替你踩过

5.1 this与箭头函数的恩怨

前面提到过,包装函数必须用普通函数写法才能透传this。但很多人会图省事写成箭头函数:

const after = (fn, afterFn) => (...args) => { const result = fn(...args); // 这里this丢了 afterFn(result); return result; };

跑几个基本用例都正常,一旦包装对象方法就崩。这个问题我在自己的工具库里踩过一次后学乖了:写AOP工具时,凡是需要透传this的包装函数,一律用function关键字声明。同时建议团队里其他人在业务侧调用时也保持原来的调用形式,不要用解构后单独调用的方式,不然this还是会丢。

5.2 函数静态属性丢失

包装后的新函数是一个全新对象,原函数上挂载的静态属性(比如fn.$$cache = {...})不会自动带过去。有些库会依赖函数的lengthname,你包一层之后这些信息就变了。解决方案前面给过一版Object.getOwnPropertyDescriptors复制属性,但注意lengthname本身是函数的非可配置属性,无法通过Object.defineProperties覆盖。如果你特别依赖fn.length(函数形参个数),建议在切面包装器的配置里显式传入原函数名,不要在运行时去猜。

5.3 异步切面时序

判断一个函数是否异步,不能在运行时绝对确定,只能通过返回值有没有then方法来判断。但这会有一个边界情况:如果原函数本身就是返回一个thenable对象的同步函数,切面会把它当成Promise处理。大多数情况下这是好事,因为thenable大概率符合Promise行为。可如果你在after里需要同步拿到对象而不是Promise,就必须先知道目标函数是不是异步。我的办法是:给切面加一个asyncMode配置项,默认自动检测,遇到特殊情况允许调用方手动指定。看似多了一个配置,但对线上稳定性的提升是实打实的。

5.4 原型方法不要随便包

新手最容易上头的地方是:既然AOP这么好用,那我直接把Array.prototype.push包一层做全局埋点?不行,这属于monkey patch,会污染全局原型,任何第三方库push元素都会触发你的切面,一旦切面里出了异常,整个应用崩溃。真正要做全局方法的观测,请用Proxy包裹实例,而不是改原型。如果是自己类的原型方法想统一加切面,可以在类方法上显式装饰,或者用下面这种“实例化时织入”的方式:

class UserService { async getUser(id) { /* ... */ } async save(data) { /* ... */ } } const service = new UserService(); Object.getOwnPropertyNames(Object.getPrototypeOf(service)).forEach((name) => { if (typeof service[name] === 'function' && name !== 'constructor') { service[name] = logAspect(service[name].bind(service)); } });

注意这里用了.bind(service),不要再用apply透传方法内部的this了,因为bind是一次性绑定死的。

5.5 构造函数和async构造函数的特例

普通函数能包装,构造函数不行。原因很简单:constructorFn.apply(this, args)this调用一个构造函数时,this不是新创建的对象,包装函数会直接返回undefined。真要对类做切面,首选Proxy

function aspectClass(cls, methodAspects) { return new Proxy(cls, { construct(Target, args) { const instance = new Target(...args); Object.entries(methodAspects).forEach(([name, aspect]) => { if (typeof instance[name] === 'function') { instance[name] = aspect(instance[name].bind(instance)); } }); return instance; } }); }

这个例子把“织入”放在了new之后的实例上,对类本身无侵入。缺点是每次new都会重新包装一次,性能敏感场景需要评估。绝大多数业务类不会有这种量级的实例化频率,可以接受。

6. 扩展:从装饰器、Proxy到IoC容器

ESNext装饰器语法是AOP最直观的表现形式。如果你项目里能正常编译Stage 3装饰器,代码可以写成这样:

function log(target, name, descriptor) { const original = descriptor.value; descriptor.value = function(...args) { console.log(`[Decorator] ${name}`, args); return original.apply(this, args); }; return descriptor; } class PaymentService { @log pay(amount) { // 支付逻辑 } }

装饰器的本质仍然是高阶函数包装,只是把“手动包”变成了“声明式标注”。需要留意的是装饰器的执行顺序:多个装饰器从下往上执行,靠近函数名的装饰器先执行,这跟composeAspectsreduceRight是同理的。

Proxy也能做切面,而且更“语言级”。它可以在get拦截里给对象方法自动加切面:

function withAspects(target, aspects) { return new Proxy(target, { get(obj, prop, receiver) { const value = Reflect.get(obj, prop, receiver); if (typeof value === 'function' && prop !== 'constructor') { return aspects.reduceRight((acc, aspect) => aspect(acc), value.bind(target)); } return value; } }); }

Proxy方案的优势是:不需要在方法声明时就知道要加哪些切面,运行中可以随时换一套切面;缺点是:每次get都可能重新包装,访问频率高时会浪费性能。所以实际项目中我建议:固定切面用高阶函数/装饰器,动态切面用Proxy

再往大了说,AOP和IoC(控制反转)经常被人放一起讨论。前端轻量DI容器做依赖注入时,顺带完成切面织入是很自然的事:

class Container { constructor() { this.registry = new Map(); } register(name, factory) { this.registry.set(name, { factory, aspects: [] }); return this; } addAspect(name, aspect) { const item = this.registry.get(name); if (item) item.aspects.push(aspect); return this; } resolve(name) { const { factory, aspects } = this.registry.get(name) || {}; if (!factory) throw new Error(`未注册: ${name}`); let instance = factory(); if (aspects.length) { instance = aspects.reduceRight((acc, aspect) => aspect(acc), instance); } return instance; } } // 注册服务时不写切面 container.register('orderService', () => new OrderService()); // 在应用启动集中织入切面 container.addAspect('orderService', logAspect); container.addAspect('orderService', errorAspect); // 业务代码拿到的已经是增强后的服务 const orderService = container.resolve('orderService');

这样做的好处是:业务类完全不用感知AOP的存在,切面在容器层统一配置,想换一组切面环境只改容器配置就行。这种模式在中小型前端项目里用起来很舒服,比其他DI框架轻得多。

最后说点个人体会。AOP不是银弹,它解决的是横切关注点问题,不是所有代码复用问题。如果两个函数只是偶然有相似逻辑,强行上切面只会增加理解成本。我自己的判断标准是:同一个横切逻辑在业务代码中出现了三次以上,且每次都是复制粘贴,就有必要抽成切面;如果只有一两次,直接写个工具函数调用反而更清爽。另一个心得是:切面本身也要有良好的分层,日志、权限、错误上报各自独立,别写一个万能切面一句话把所有事都干了,否则一个切面出问题,排查范围是全站所有业务函数,那酸爽程度谁试谁知道。希望这篇文章能让你迈出前端AOP的第一步,后面遇到重复横切逻辑时不再复制粘贴,而是先想一句:这里是不是该套一层壳?

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询