尧图精选

不靠框架手搓AOP:用高阶函数实现前端切面编程

🕒 发布时间:2026/9/14 6:15:59 📁 来源:尧图网络
全站按钮要加埋点、每个请求要统计耗时、关键操作要校验权限——你第一反应是不是CtrlF一个个找调用处然后粘贴三行代码进去我当初也是这么干的直到一个项目里类似的横切逻辑改到第五遍我决定把这事儿彻底解决掉才有了今天这篇文章。标题里有个关键词叫手搓AOP翻译成人话就是不依赖任何框架用ES6自带的高阶函数能力自己写一套工具把那种每个业务函数都要附带一下的逻辑统一抽出来处理。这个思路在前端面试里经常以各种形式出现但真正能在项目里把它用得干净利落的并不多。看完这篇你不仅能手写一个够用的AOP工具还能知道它该用在哪儿、不该用在哪儿遇到面试官聊AOP也不会心里发虚。1. 为什么前端也要会AOP从一个真实需求说起1.1 全站埋点曾经让你改吐血的50个业务函数给你一个特别接地气的场景公司后台管理系统上线了一个新版运营后台产品经理提了个需求——所有按钮点击都要埋点上报用来做行为分析。你打开项目一看好家伙路由菜单里二十多个页面每个页面三五个互不相关的按钮加起来上百个点击事件。常规做法是什么打开每个页面组件找到每个按钮的handleClick在里头加一行track(xxx_button_click)。有的按钮是表单提交有的是弹窗确认有的是Tab切换你一个个找一个个加。听起来也没多难对吧但真正让你崩溃的不是加一次埋点而是后面来的变化埋点方案换了上报字段从event改成action所有埋点代码得全局替换产品说要带上用户来源每个埋点又得增加一个参数接口统计说要区分请求发出和请求返回两个时间点你又要回头把每个请求再改一遍。这种逻辑有一个特点它不属于任何具体业务但每个业务都需要它。专业点叫横切关注点土一点讲就是处处都要掺一脚的事。如果靠手动在每个函数里插入代码业务逻辑会被搅得越来越脏后面想剥离出来比登天还难。1.2 横切关注点到底是什么东西在到处捣乱为了方便理解我给你画个对比。你有一个支付函数async function pay(orderId) { // 这里是真正的业务逻辑 const result await request(/api/pay, { orderId }); return result; }业务逻辑就这一块。但真正上线的时候这个函数周围还要发生多少事用户点了支付按钮系统要校验他有没有登录没有要引导去登录要打一个埋点记录用户点击了支付要统计这个接口耗时超过2秒得报警万一请求失败得弹错误提示如果用户连点两次还得防重复提交。这些逻辑和把钱付掉这件事本身完全没有关系但它们围绕在业务函数的四面八方。如果这些逻辑全部写进pay函数里你会得到一坨分辨不清主次的代码。AOP的思路就是不改变业务函数本身而是把它包装起来在它的前后左右挂上这些横切逻辑。用生活里的类比就是你去车站坐车不用在每节车厢里安排一个乘务员提醒你上车要刷卡。车站入口本来就有闸机你刷卡过闸然后正常找车厢这个过程对你没什么感知但对车站来说检票这件事就被统一、集中地处理了。1.3 其实你早就用过AOP了只是没意识到很多人一听到AOP就觉得是Java Spring框架的专属名词其实前端里你无意中已经用了很多次AOP思想axios拦截器axios.interceptors.request.use()统一给所有请求加token这就是典型的切面。Vue Router全局守卫router.beforeEach()做登录校验不用在每个页面里写权限判断本质就是给路由跳转加了一个前置切面。Express中间件app.use(logger)给所有请求打日志这种洋葱模型就是AOP的经典形态。EventEmitter的包装如果有人用wrap把某个对象的所有方法都加上事件通知也是AOP的味道。所以你看AOP不是后端专属也不是某个框架的私有特性它就是一种把公共逻辑挂到业务函数外面的编程思路。既然你已经会用了为什么不把它抽象成一套自己可以随手调用的工具这样你才敢说真正理解了它。2. 手搓AOP第一步用高阶函数实现before与after2.1 万事从闭包开始高阶函数包裹原函数AOP最基础的两个能力就是在目标函数执行之前和之后插一段逻辑。前端里管这叫before和after。实现思路说白了就一句话用高阶函数把原函数包一层在包出来的新函数里按顺序执行你的切面逻辑和原函数。最原始的版本长这样function before(fn, beforeFn) { return function (...args) { // 先执行前置逻辑 beforeFn.apply(this, args); // 再执行原函数 return fn.apply(this, args); }; } function after(fn, afterFn) { return function (...args) { // 先执行原函数 const result fn.apply(this, args); // 再执行后置逻辑 afterFn.apply(this, args); return result; }; }就这么简单。这个before函数接收两个参数返回一个新函数。新函数被调用的时候先让beforeFn跑一遍然后继续跑原来的fn。因为闭包的存在fn和beforeFn被一直记在返回函数里这就是包裹的本质。用的时候特别顺手function submitOrder() { console.log(提交订单); } const submitWithLog before(submitOrder, () { console.log(开始提交记录日志); }); submitWithLog(); // 输出 // 开始提交记录日志 // 提交订单如果你这时候灵光一闪那我用before包完再用after包不就能同时有前后逻辑了吗——对这就是组合切面的雏形。2.2 为什么返回函数要用function而不是箭头函数这个细节值得单独拿出来讲因为90%的初学者在这里踩坑。上面代码里before返回的是一个普通function而不是箭头函数。这是故意的。原因在于this的指向。普通函数的this是调用时决定的箭头函数的this是定义时决定的。当你的原函数是某个对象的方法时用fn.apply(this, args)里的this必须指向那个对象。如果外层返回的是箭头函数this就会被绑定到定义箭头函数时的外层作用域通常是undefined严格模式下或者全局对象直接导致原函数里拿不到正确的this。举个例子const userService { name: 张三, getUser() { return this.name; } }; const wrappedGet before(userService.getUser, () { // 这个 before 里想访问 this.name但 this 已经丢了 }); // userService.getUser wrappedGet 之后再调用 userService.getUser();如果before返回的是箭头函数this指向外层getUser里的this.name就是undefined。但因为我们用了普通函数并且用fn.apply(this, args)把调用者的this透传了进去getUser()里的this依然是userService完美。这里有个更稳妥的进阶写法如果你想在beforeFn里也能拿到对象的this可以把beforeFn.apply(this, args)里也把this传进去。这样无论是原函数还是切面函数都能共享同一个上下文。2.3 after的第3个参数让切面能做更多事基础版after还有一个可以优化的点beforeFn和afterFn目前只能看到参数拿不到返回值。比如你希望在函数执行后把返回值格式统一包装一下或者根据返回值决定要不要报警只靠上面的after是做不到的。给after增加一个能力把原函数的返回值作为参数传给afterFn同时允许afterFn返回一个新值来替换返回值。function after(fn, afterFn) { return function (...args) { const result fn.apply(this, args); // 把返回值传给 afterFn并用 afterFn 的返回值作为最终结果 return afterFn.call(this, result, ...args); }; }用法function getList() { return [1, 2, 3]; } const getListWithFormat after(getList, (result) { return { code: 0, data: result }; }); getListWithFormat(); // { code: 0, data: [1, 2, 3] }看起来像什么像不像给这个函数套了一层响应格式化没错这就是AOP中around环绕通知的一个变种——你不仅在函数后面加了逻辑还趁机改写了它的返回结果。这个能力在实际项目中非常实用比如统一给兼容接口补字段、统一分页结构、统一脱敏等。3. 手搓AOP第二步从单函数装饰到通用切面工具3.1 把before和after封装成一个可复用的AOP函数上面那种before(fn, beforeFn)的写法有个不够爽的地方如果我既想加before又想加after得一层层嵌套代码可读性会下降const finalFn before( after(submitOrder, () console.log(提交后)), () console.log(提交前) );注意这个顺序实际执行时是先执行after里的前置逻辑...不对是先执行before的前置逻辑再执行after包裹后的原函数即先执行submitOrder再执行after的后置逻辑。嵌套多了以后心智负担就比较重。更优雅的方式是做一个统一入口接收目标函数和切面对象一次性把前置、后置、异常处理都挂上去。我习惯这样设计function createAop(target, { before, after, onError }) { return function (...args) { // 前置切面 if (before) { before.apply(this, args); } // 执行原函数并捕获异常 try { const result target.apply(this, args); // 后置切面 if (after) { return after.call(this, result, ...args); } return result; } catch (err) { if (onError) { onError.call(this, err, args); } else { throw err; } } }; }用的时候一个函数就搞定了const safeSubmit createAop(submitOrder, { before() { console.log(校验token); }, after(result) { console.log(记录耗时); }, onError(err) { console.error(上报异常, err); } });这已经是一个够用的AOP工具了放在一个utils文件里全局导出谁用谁方便。3.2 链式调用把Function.prototype当跳板还有一种风格是把before和after挂到Function.prototype上实现类似fn.before(fn1).after(fn2)的链式调用。这种方法在一些古老的前端框架源码里能看到思路是给所有函数都无痛装上前置和后置的能力Function.prototype.before function (beforeFn) { const original this; return function (...args) { beforeFn.apply(this, args); return original.apply(this, args); }; }; Function.prototype.after function (afterFn) { const original this; return function (...args) { const result original.apply(this, args); return afterFn.call(this, result, ...args); }; };这样你可以写出非常流畅的代码const enhancedSubmit submitOrder .before(() console.log(校验token)) .after((res) console.log(提交结果:, res));但我个人对直接改Function.prototype持保留态度。一是污染了全局的函数对象如果团队里有其他库也这么干可能互相覆盖二是包出来的函数是一个新函数原函数上的一些属性比如自定义的metadata、静态字段不会自动带过去特殊场景需要额外做属性拷贝。相比之下我更推荐把AOP函数封装成一个独立工具函数用的时候显式调用虽然写起来多打几个字符但可读性和可维护性更强。不过你自己玩的时候可以两个都试试哪种顺手用哪种没有绝对对错。3.3 支持async函数别让Promise返回值溜走现代前端项目里几乎没有不用async/await的。如果你拿上面createAop直接包一个async函数问题就出现了target.apply(this, args)返回的是Promise同步的after会在Promise还没resolve时就执行拿不到异步结果。怎么修很简单判断返回值是不是Promise是的话走then链不是的话走同步逻辑。这也顺便把错误捕获逻辑统一了function createAop(target, { before, after, onError }) { return function (...args) { const runBefore () (before ? before.apply(this, args) : undefined); const runTarget () target.apply(this, args); const runAfter (result) (after ? after.call(this, result, ...args) : result); const handleError (err) { if (onError) return onError.call(this, err, args); throw err; }; runBefore(); try { const result runTarget(); if (result typeof result.then function) { // 异步场景 return result.then(runAfter).catch(handleError); } // 同步场景 return runAfter(result); } catch (err) { handleError(err); } }; }这样无论是同步函数还是异步函数after都能拿到最终的结果onError也能同时捕获同步异常和Promise rejection。我想特别提醒一点判断是不是Promise最稳的方式是看它有没有then方法result instanceof Promise在某些环境比如多个Promise库共存的老项目里可能会失手。4. 实战套路一请求层的三重奏切面——鉴权、埋点、异常上报4.1 为什么不用axios拦截器非得自己写你可能会问这不就是axios拦截器的活儿吗确实如果你只是在axios层面统一处理直接用拦截器更省事。但真实项目里不是所有请求都走同一个封装有的是老jQuery ajax有的是SDK内部自带请求有的是第三方接口直接fetch。而且AOP式处理的优势在于可以在任意函数上生效不依赖你改造整个请求库。下面我们用刚才的工具把三个横切逻辑挂到一个业务函数上鉴权、耗时埋点、异常上报。假设项目是一个老后台请求用的是全局的window.ajax// 业务层请求函数夹带了私货但主逻辑很干净 async function loadUserList(params) { const res await window.ajax.get(/api/user/list, params); return res.data; } // 三个切面 const authAspect { before() { const token localStorage.getItem(token); if (!token) { window.location.href /login; throw new Error(未登录); } } }; const trackAspect { before() { this._start performance.now(); }, after(result) { const cost performance.now() - this._start; report(api_cost, { api: /api/user/list, cost }); return result; }, onError(err) { report(api_error, { api: /api/user/list, message: err.message }); } }; // 组合起来 const requestUserList createAop(loadUserList, authAspect); const requestUserListWithTrack createAop(requestUserList, trackAspect);等等这样写有点繁琐得嵌套两三次。更顺手的方法是在工具里支持多个切面执行同一个before/after或者是用合并对象的方式const finalFn createAop(loadUserList, { ...authAspect, ...trackAspect });虽然用展开运算符能合并但注意同名字段会被后者覆盖实战里我更喜欢把多个切面逻辑写在同一个切面对象的不同字段里保持一个函数一个切面。其实更推荐的替代方案是切面对象支持多个处理器数组。这里不展开你可以自己演化。4.2 带上AOP的请求函数日志长什么样实际跑起来你会在控制台清晰地看到逻辑分层[鉴权] 检查token通过 [埋点] 开始时间戳记录 ... 真正的请求发出、等待返回 ... [埋点] 耗时 248ms上报完成这里有一个很关键的体验提升当你用AOP把鉴权和埋点抽出来后loadUserList这个函数本身变得极其干净一眼看过去就是查用户列表没有任何和业务无关的代码。后续再加权限校验、敏感数据脱敏都只需要在切面里加配置业务代码零改动。这个零改动就是AOP带来的最大价值。4.3 什么时候不要在请求层用AOP这里必须泼一盆冷水。如果你的请求封装本来就很统一、很规范所有请求都走一个request函数那你应该直接在那一个地方写拦截逻辑而不是给成百上千个业务函数分别挂AOP。AOP的价值场景是同一个横切逻辑要作用在多个不规则的函数上或者你无法修改某些函数的源码比如第三方SDK的方法。这两类场景下AOP是救命的可如果函数已经统一了你再到处包一层反而是画蛇添足。5. 实战套路二防重复提交 按钮权限老板看了都加薪5.1 防重复提交用闭包做一个状态锁电商后台有一个经典痛点用户在提交订单的接口返回前疯狂点击按钮导致同一个订单被创建了多次。防重复提交的常规解法是做一个请求锁在提交期间禁用按钮或者直接用一个变量标记是否在请求中。用AOP怎么做把锁放在闭包里包裹函数在请求期间拦截后续调用function preventDoubleSubmit(fn, { pendingMessage 正在提交请稍候... } {}) { let isPending false; return function (...args) { if (isPending) { console.warn(pendingMessage); return; } isPending true; try { const result fn.apply(this, args); if (result typeof result.then function) { // 异步场景请求结束才解锁 return result.finally(() { isPending false; }); } // 同步场景执行完立即解锁 isPending false; return result; } catch (err) { isPending false; throw err; } }; }然后你可以非常愉快地使用const submitOrderWithLock preventDoubleSubmit(submitOrder);这个preventDoubleSubmit很有价值的地方在于它不关心你到底是同步函数还是异步函数只要是Promise最终都会解锁如果是同步执行完就解锁。如果你在业务里直接写if (this.isSubmitting) return这种代码防重复逻辑会和业务函数深度耦合后期想复用或者想调整锁的粒度都很麻烦。5.2 按钮权限控制不符合条件直接短路后台管理系统还有一个经典需求有些按钮只有管理员能看到有些按钮只有特定角色能操作。传统做法是在模板里写v-ifhasPermission(xxx)但如果是动态渲染的按钮或者你的项目不是Vue/React而是传统jQuery页面就得写一堆判断。用AOP处理权限逻辑会非常清爽function withPermission(fn, permission) { return function (...args) { const userInfo getCurrentUser(); if (!userInfo.permissions.includes(permission)) { console.warn(用户无权限执行: ${permission}); return; } return fn.apply(this, args); }; } // 使用 const deleteUser withPermission( (userId) window.ajax.post(/api/user/delete, { userId }), user:delete );这个切面能做到未授权时直接短路后面的业务逻辑根本不会执行和很多后端框架里的RequiresPermissions注解思路如出一辙。而且因为切面逻辑是独立函数你可以在任何按钮上复用想给哪个函数加权限只要包一层即可不用写模板指令。5.3 组合切面的执行顺序从洋葱模型谈起当你同时需要权限校验和防重复提交时组合顺序就变得重要了。假设你写const finalFn withPermission( preventDoubleSubmit(submitOrder), order:submit );调用时先执行withPermission返回的函数校验权限通过后再执行preventDoubleSubmit包裹后的函数。也就是说权限校验在最外层防重复提交在里层。这个是符合直觉的先确认你配不配做这件事再确认现在适不适合做这件事。反过来写就是先判断防重复、再判权限逻辑上也不是不行但权限失败也算一次有效点击可能会导致锁被误触发。所以我推荐的习惯是越普适的切面放越外层越贴近业务的切面放越里层。权限是所有人都要过的闸机放外层防重复是针对具体操作的放里层。这个洋葱模型的理解方式和Express中间件的执行顺序是一模一样的前端面试里如果你能把这个类比讲清楚面试官会觉得你是真的理解中间件原理。6. 进阶玩法批量给类的方法加切面以及它和装饰器的关系6.1 遍历原型链让一个类的方法全部套上切面如果你用的是Class风格的业务代码手动给每个方法包一层会想哭。这时候可以写一个工具自动遍历原型链上所有方法统一挂上切面function addAspectToClass(cls, aspect) { const proto cls.prototype; Object.getOwnPropertyNames(proto).forEach((name) { if (name constructor) return; // 跳过构造函数 const descriptor Object.getOwnPropertyDescriptor(proto, name); if (typeof descriptor.value function) { proto[name] createAop(descriptor.value, aspect); } }); return cls; }用法class UserService { async getUser(id) { return window.ajax.get(/api/user/${id}); } async updateUser(id, data) { return window.ajax.put(/api/user/${id}, data); } } // 给 UserService 所有方法统一加上耗时埋点和异常上报 addAspectToClass(UserService, { before() { this._start performance.now(); }, after(result) { report(api_cost, { cost: performance.now() - this._start }); return result; }, onError(err) { report(api_error, { message: err.message }); } });这个工具的核心价值在于新加一个方法不需要额外写埋点自动继承切面逻辑。以后维护人员只要保证方法写在这个类里需求方说所有接口都要统计耗时你不用再去翻新增的代码。6.2 防止切面被重复包裹用标记或者WeakMap这里有一个挺隐蔽的坑如果你把同一个函数在一次会话里多次调用addAspectToClass比如热更新、工具被重复调用函数会被包裹多遍切面逻辑就会叠加执行——埋点打两遍、日志记两行。稳妥的做法是给包裹后的函数打一个标记或者用一个WeakMap缓存已经包装过的函数。我平时喜欢用WeakMap因为它不会造成内存泄漏const wrappedMap new WeakMap(); function wrapWithAop(fn, aspect) { if (wrappedMap.has(fn)) { return wrappedMap.get(fn); } const wrapped createAop(fn, aspect); wrappedMap.set(fn, wrapped); return wrapped; }这样你重复调用wrapWithAop(fn, aspect)拿到的永远是同一个包装函数不会重复叠加。注意WeakMap的key只能是对象函数刚好就是对象完美匹配。在面试里如果被问到AOP工具怎么避免重复包裹这个答案绝对能加分。6.3 装饰器为什么能火本质就是AOP的语法糖现在前端项目里你可能见过log、throttle这样的装饰器写法把它应用在Class的方法上class UserService { log getUser(id) { ... } }很多人以为装饰器和AOP是两个独立的东西其实装饰器底层的机制就是函数包裹装饰器接收原始描述符返回一个新的描述符新描述符里的value被替换成了原函数包了一层切面逻辑的新函数。所以装饰器只是AOP的一种更优雅的语法表达原理和你现在手搓的createAop完全相通。如果你理解了手写AOP再看装饰器会觉得它只是省去了手动调用包裹函数那一步。反过来如果你会装饰器但不会手写AOP遇到不支持装饰器的环境或者需要运行时动态决定切面时就会捉襟见肘。这也就是我鼓励你手搓一遍的原因原理通透了语法永远只是外衣。7. 手撕AOP之后我踩过的那几个坑7.1 五个最常见的翻车现场把AOP从会写到用得稳中间隔着几个反复出现的坑。我直接列出来希望你能少走弯路坑1this丢失。切面函数里用箭头函数访问this拿到的是上一层作用域的this。解决办法是切面函数用普通函数或者手动把this透传进去。判断标准很简单你在切面里写的this是不是你期望的那个对象不是就换普通函数。坑2返回值被吞。早期版本里after没有把result返回出去导致函数调用方拿到的永远是undefined。这种情况经常出现在同步函数 被包装后忘了return的组合里。建议你封装AOP时每次都要记得return原函数的结果即使after里做了改写也要保证最终返回的是改写后的值。坑3异步未处理。盲目用同步逻辑包async函数导致after提前执行、异常捕获失效。AOP工具必须判断返回值是否为Promise并走then/catch否则线上环境会有一堆unhandled promise rejection。坑4重复包裹。热更新、工具被重复调用后切面叠加执行日志重复打、埋点重复报。用WeakMap缓存或者给函数打标记来防重。坑5调用栈可读性变差。AOP包得太多层以后报错堆栈里全是匿名函数排查问题非常难受。我个人的解法是在包装时给新函数重新设置name属性方便调试Object.defineProperty(wrapped, name, { value: aop(${fn.name || anonymous}), configurable: true });这样报错堆栈里至少能看到aop(submitOrder)而不是一个孤零零的anonymous。7.2 前端AOP的适用边界不要为了切面而切面学了AOP以后就像手里拿了把锤子容易看什么都像钉子。这里必须强调适用边界不然你的代码会因为过度设计而变得很难追查。AOP适合的场景是横切逻辑涉及多个不相关的模块、统一逻辑无法通过既有拦截机制落地、业务代码不能或不便修改。典型例子是埋点上报、性能监控、异常上报、权限校验、防重复提交。AOP不适合的场景是逻辑只在一个地方用到、代码本来就写得简洁清晰、切面本身带有复杂的业务判断。如果你为了炫技给一个只在单一页面使用的函数包了三层切面后面接手的人会恨不得顺着网线来找你。一个判断标准是如果你把这个逻辑从函数里抽出来后函数本身的可读性和复用性明显提升且这个逻辑确实会被多个地方复用那AOP就是合理的选择反之就别硬上。7.3 最后分享一个调试技巧如果你在公司项目里已经用了这套思路我强烈建议你在开发环境给AOP工具包一层调试日志开关const AOP_DEBUG process.env.NODE_ENV ! production; function createAop(target, { before, after, onError }) { return function (...args) { if (AOP_DEBUG) { console.debug([AOP] 执行 ${target.name || anonymous} 前置切面); } ... }; }这个日志能帮你很直观地看到每个函数的切面执行顺序。线上环境自动关闭零成本。调试AOP问题的时候你会感谢这个日志的。回头再来看看AOP这件事它的核心理念并不复杂——让业务代码只关心业务让横切逻辑各司其职通过函数的高阶能力把它们优雅地组合起来。前端的世界发展很快但组合优于侵入这个思想无论框架怎么换都是成立的。你手里现在有了自己的AOP工具再遇到所有页面都要加埋点所有接口都要统计耗时的需求应该不会慌了。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →