jsaop
jsaop 是一个前端 AOP 工具。用于在面向对象编程开发模式中,对目标方法织入通知(Advice)。从而实现业务代码和功能性代码分离解耦。
版本日志 2021-09-29
- 解决循环依赖问题
版本日志 2021-07-13
- 调整属性检查会触发目标类的 get 执行问题
适用场景
- 面向对象开发模式,基于 typescript 或者 es6 均可
- 埋点、日志、异常收集等需要跟业务逻辑分离的逻辑代码
准备工作
安装
npm i --save @jsaop/jsaop
或者
yarn add --save @jsaop/jsaop
应用
开启 decorator 支持
- ts 文件配置 tsconfig.json,js 文件配置 jsconfig.json
{
"compilerOptions": {
// ...
// 启用装饰器
"experimentalDecorators": true
// ...
}
}
- 配置 babel
babel 需要配置 decorator 和 class 语法支持,需要用到下面两个 plugin
yarn 安装 plugin
yarn add @babel/plugin-proposal-decorators -D
npm 安装 plugin
npm i @babel/plugin-proposal-decorators -D
配置信息(.babelrc / babel.config.js / babel.config.js)
@babel/plugin-proposal-decorators 需要开启 legacy(值为 true),同时启用 setPublicClassFields、setClassMethods,以支持和使用 stage1 的 decorator 语法。
具体可以参考babel 官网相关信息
{
"assumptions": {
"setClassMethods": true,
"setComputedProperties": true,
"setPublicClassFields": true
},
"presets": [
[
"@babel/preset-env",
{
"modules": false
}
]
],
"plugins": [["@babel/plugin-proposal-decorators", { "legacy": true }]]
}
1. 基本用法
import { Aspect, Before, After, Around, Pointcut, Weaving } from '@jsaop/jsaop'
@Aspect()
class TestAspect {
@Pointcut()
get pointcut() {
return 'app.tsa.pages:TestPage.do*'
}
@Before({ value: 'pointcut' })
beforeAction(jp) {
console.log('before action:', jp)
}
@After({ value: 'pointcut' })
afterAction(jp, rst, err) {
console.log('after action:', jp, rst, err)
}
}
@Weaving({ namespace: 'app.tsa.pages' })
class TestPage {
doSomeThing() {
console.log('doSomeThing')
return 'success'
}
}
new TestPage().doSomeThing()
2. 支持异步 async/await 和 Promise
import {Aspect, Before, After, Around, Pointcut, Weaving} from '@jsaop/jsaop'
@Aspect()
class TestAspect{
//...
}
@Weaving()
class TestPage{
async doSomeThing(){
console.log('doSomeThing')
await return Promise.resolve('success')
}
}
new TestPage().doSomeThing()
API 文档说明
切面(Aspect)
切面由切点(PointCut)和增强(Advice)组成,它既包括了横切逻辑的定义,也包括了连接点(JoinPoint)的定义,AOP 就是将切面所定义的横切逻辑织入到切面所制定的连接点中。
这是一个完整的 Aspect 定义实例,包括切点 pointcut,增强 beforeAction 和 afterAction,以及连接点匹配逻辑'app.tsa.pages:TestPage.do*'
@Aspect()
class TestAspect {
@Pointcut()
get pointcut() {
return 'app.tsa.pages:TestPage.do*'
}
@Before({ value: 'pointcut' })
beforeAction(jp) {
console.log('before action:', jp)
}
@After({ value: 'pointcut' })
afterAction(jp, rst, err) {
console.log('after action:', jp, rst, err)
}
}
Weaving(织入)
把切面织入目标位置,语法@Weaving(opts:WeavingOpts)
interface WeavingOpts {
blackList?: Array<string> // 排除掉方法名,跳执行效率
namespace?: string // 命名空间
}
实例
@Weaving({namespace:'app.fe.pages'})
class TestPage{
async doSomeThing(){
await return Promise.resolve('success')
}
}
连接点(JoinPoint)
程序执行的某个特定位置,如某个方法调用前,调用后,方法抛出异常后,这些代码中的特定点称为连接点。通常作为通知(Advice)的参数出现
interface JoinPoint {
target: any // 目标类
args: any[] // 目标方法参数
thisArg: any // this指向,目标类的实例,目标方法的上下文context
value: any // 目标方法
// ...
}
切入点(Pointcut)
每个程序的连接点有多个,如何定位到某个感兴趣的连接点,就需要通过切点来定位。
定义 Pointcut 语法格式
@Pointcut([type])
get [pointcut name](){
return [pointcut rules]
}
- type:'prototype'|'static'指方法类型,原型方法(prototype)和静态方法(static);
- pointcut name 切点名称;
- pointcut rules 匹配规则: 命名空间?:类名.方法名称(namespace?:className.methodName)
例如
@Pointcut('prototype')
get pointcut(){
return 'app.tsa.pages:TestPage.do*'
}
rules:PointcutRules 匹配说明
- 匹配类型
type PointcutRuleType = {
namespace?: RegExp | string
className: RegExp | string
methodName: RegExp | string
}
type PointcutRules = string | RegExp | PointcutRuleType | Array<PointcutRuleType | RegExp | string>
- string 类型匹配
匹配命名空间 app.tsa.pages,TestPage 类,以 do 开头的任意方法
'app.tsa.pages:TestPage.do*'
多个匹配规则可以用 && 分割
'app.tsa.pages:TestPage.do* && app.tsa.pages:ArticlePage.submit*'
- 正则匹配
任意命名空间(可省),SomeClass 类,以 do 开头的任意方法
;/^([\d\w][_./-\w\d]*[:]?)?SomeClass.do[\w\d]+$/
- PointcutRuleType 匹配
@Pointcut()
get pointcut() {
return {
className: 'SomeClass',
methodName: 'submit*'
}
}
- 支持数组放置上述单个或者多个匹配规则
@Pointcut()
get pointcut() {
return [
'app.tsa.pages:TestPage.do*',
'app.tsa.pages:ArticlePage.submit*',
/^([\d\w][_./-\w\d]*[:]?)?SomeClass.do[\w\d]+$/
]
}
-
"*"匹配多个字符,"?"匹配单个字符
-
namespace 可以省略,但不建议这么做,因为匹配基于 className 和 methodName,极易发生冲突
~
通知(Advice)- 前置通知(Before Advice) 语法@Before({value:[pointcut name]}),目标动作执行之前织入通知。 前置通知只有一个参数,即:连接点 jp:JoinPoint
@Before({value:'pointcut'})
beforeAction(jp){
console.log('Before action:',jp)
}
- 后置通知(After Advice) 语法@After({value:[pointcut name]}),目标动作执行之后织入通知,无论成功,还是发生异常都会执行。 后置通知有三个参数,分别是:连接点 jp:JoinPoint, 返回值 rst:any, 异常 err: Error。
@After({value:'pointcut'})
afterAction(jp, rst, err){
console.log('After action:',jp, rst, err)
}
- 返回结果通知(AfterReturning Advice) 语法@AfterReturning({value:[pointcut name]}),目标动作执行成功之后执行 后置通知有两个参数,分别是:连接点 jp:JoinPoint, 结果 rst:any
@AfterReturning({value:'pointcut'})
afterReturningAction(jp, rst){
console.log('AfterReturning action:',jp, rst)
}
- 异常通知(AfterThrowing Advice) 语法@AfterThrowing({value:[pointcut name]}),目标动作执行发生异常后执行 后置通知有两个参数,分别是:连接点 jp:JoinPoint, 结果 err:Error
@AfterThrowing({value:'pointcut'})
afterThrowingAction(jp, err){
console.log('AfterThrowing action:',jp, err)
}
- 环绕通知(Around Advice) 语法@Around({value:[pointcut name]}),目标动作执行发生异常后执行 后置通知有一个参数,即:连接点 jp:ProceedJoinPoint。ProceedJoinPoint 继承 JoinPoint,含有一个 procced 方法,缓存了目标动作的执行,以及其他通知的执行
interface ProceedJoinPoint extends JoinPoint {
new(jp: ProceedJoinPointType)
procced(): any
}
执行 jp.procced(),才会触发执行动作
@Around({value:'pointcut'})
aroundAction(jp){
console.log('Before Around action:',jp)
let rst = jp.procced()
console.log('After Around action:',jp)
return rst
}
- 通知执行顺序
- 正常执行顺序:
Around => Before => target method => AfterReturning => After => Around
- 发生异常执行顺序:
Around => Before => target method => AfterThrowing => After => Around
Tree Shaking 问题
Aspect 类文件和目标类文件,属于隐式的依赖关系,很容易被 Tree Shaking 清理掉。有几种办法解决这个问题
- package.json 添加 sideEffects 清单,使文件不受 tree shaking 影响
{
"sideEffects": ["./src/aspects/**/*.ts", "./src/assets/**/*.js", "./src/assets/**/*.scss", "./src/assets/**/*.css"]
}
- babel-loader 添加 sideEffects 清单, 使文件不受 tree shaking 影响
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /(node_modules|bower_components)/,
use: {
loader: 'babel-loader'
},
sideEffects: ['"./src/aspects/**/*.ts"']
}
]
}
代码压缩开启混淆,造成 Pointcut 的 rules 匹配失效问题
- 排除掉 className 和 methodName 混淆,以 Terser 为例
const TerserPlugin = require('terser-webpack-plugin')
new TerserPlugin({
cache: true, // 开启缓存,提升编译速度
parallel: true, // 开启多进程,提升编译速度
terserOptions: {
mangle: true, // 混淆代码
keep_classnames: true, // 保持classname不混淆(解决AOP动态匹配)
keep_fnames: true // 函数、方法名称不混淆(解决AOP动态匹配)
}
})