下载APP

Angular/TypeScript中那些值得了解的知识——注解

Angular中无处不在的注解(当然,更准确地说,注解是属于TypeScript而非Angular,只是通过Angular体现),它为何产生,又有何妙用?

注解的产生背景和意义——给你想要处理的程序打个标

上一篇文章中我介绍了“依赖注入”,说到了依赖注入框架,其实是个巨大的Map。那么,它如何记录他的键值对呢,比如一个在src/app/car/carService的服务要告诉框架,它需要被管理,被在应用启动时就被构造出来,以Java为例,需要放在配置文件中,类似这样:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xmlns:jee="http://www.springframework.org/schema/jee"
  xsi:schemaLocation="
    http://www.springframework.org/schema/beans
    http://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/jee
    http://www.springframework.org/schema/jee/spring-jee.xsd">
​
  <jee:jndi-lookup id="dataSource" jndi-name="jdbc/jpetstore"/>
 <!-- 这就是被管理的对象 -->
  <bean id="txManager" class="org.springframework.transaction.jta.JtaTransactionManager" />
​
  <!-- other <bean/> definitions here -->
​
</beans> 

其中的bean的id就是键,class的路径,就是要让框架管理的全路径(唯一)的对象。

这样的作法当需要依赖注入框架管理的对象越来越多时,每加一个bean都记录它的全路径,填在这里,很复杂,这也是10年前的人难以入门JavaWeb的原因,过多的配置、以及引如jar包、排除jar包冲突等事情要做,自学的话,把项目启动起来都费劲。

而Java在JDK 1.5版本中刚好提供了注解的能力。设计它的本意是一种注释机制,比如,我的一个方法过时了,不希望别人再用了,可以这样写

// 添加这个注解表明此方法已过时
@Deprecated
public void getReadedDirPath() {
 // ...
}
​
public void getReadDirPath() {
​
}

这样别人调用getReadedDirPath这个方法时,就会有编译警告,IDE上也有相应提示,如图

deprecated.png

也就是说,注解的实质是标注,作用是可以在程序运行的任何时刻获取你标注的类、方法、属性的信息,而借助这个能力,很多框架开始优化它的配置文件,以Spring为例,它是一个依赖注入框架,既然要管理的对象需要知道它的全路径,是否可以以注解的手段代替它,进而省去繁杂的配置文件?当然是可以的,于是Spring这么做了,Hibernate、Mybatis等等所有现在还会被使用到的JavaWeb开发框架,都这么做了。于是,代码就有了这样的变化。

compare.png

别的不说,至少代码清晰多了,要配置的内容也少了。

来都来了,只打个标吗——代码增强

上面介绍了Java中的注解,可以实现打标的能力,那么打标有什么好的使用场景吗?

有的。那就是代码增强。

既然可以在程序运行过程中拿到打标的内容,我们可以在这个时机加入一些我们的逻辑,就是代码增强。举个例子,比如你需要在所有Java的控制器方法中加入日志,最佳实践是这样做:

// 先定义一个自定义注解,不用在乎代码的含义,知道他定义了个SysLog的注解就好
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SysLog {
​
  String value() default "";
}

在某些代码中打标

  /**
  * 一个修改登录用户密码的方法,把@SysLog注解加在这里
  */
  @SysLog("修改密码")
  @RequestMapping("/password")
  public R password(String password, String newPassword){
    // 业务逻辑
   ...
 }

找到打标内容,执行增强的代码

// 不需要看懂代码,只需要看我的注释
@Aspect
@Component
public class SysLogAspect {
  @Autowired
  private SysLogService sysLogService;
  
  // 处理哪个标,也即处理哪种注解
  @Pointcut("@annotation(com.esurer.common.annotation.SysLog)")
  public void logPointCut() { 
    
 }
​
  // 遇到上面的注解,用这个方法处理
  @Around("logPointCut()")
  public Object around(ProceedingJoinPoint point) throws Throwable {
    long beginTime = System.currentTimeMillis();
    Object result = point.proceed();
    long time = System.currentTimeMillis() - beginTime;
    saveSysLog(point, time);
    return result;
 }
​
  private void saveSysLog(ProceedingJoinPoint joinPoint, long time) {
    MethodSignature signature = (MethodSignature) joinPoint.getSignature();
    Method method = signature.getMethod();
​
    SysLogEntity sysLog = new SysLogEntity();
    SysLog syslog = method.getAnnotation(SysLog.class);
    if(syslog != null){
      // 拿到注解上的描述
      sysLog.setOperation(syslog.value());
   }
    String className = joinPoint.getTarget().getClass().getName();
    String methodName = signature.getName();
    sysLog.setMethod(className + "." + methodName + "()");
​
    Object[] args = joinPoint.getArgs();
    try{
      String params = new Gson().toJson(args[0]);
      sysLog.setParams(params);
   }catch (Exception e){
​
   }
​
    HttpServletRequest request = HttpContextUtils.getHttpServletRequest();
​
    sysLog.setIp(IPUtils.getIpAddr(request));
​
    String username = ((SysUserEntity) SecurityUtils.getSubject().getPrincipal()).getUsername();
    sysLog.setUsername(username);
​
    sysLog.setTime(time);
    sysLog.setCreateDate(new Date());
    //保存系统日志
    sysLogService.save(sysLog);
 }
}
​

上面的例子说明,在Java中,注解的作用就是打标,它的最佳实践是给代码增强“打辅助”,它不能单独完成代码增强, 那么,在TypeScript中,注解的使用又是怎样的?

TS中的注解怎么理解——打标+代码增强,我全都要

我相信看完上面的涵盖注解的Java代码,前端的同学都会觉得过于复杂了,其实它复杂的原因只有一个——Java是不能把函数作为参数的。打过的标,要怎么处理,需要另外引入一个类(类似上个例子中最后一段很长的代码),将他们关联起来处理。那么,在灵活的JS/TS中,函数可以作为参数,它写起代码来是怎么样的呢?

举个很经典的例子:我需要用两个😂的表情去包裹某个属性的值,当然,可以手动去改,只是如果要改的地方很多,可能比较麻烦。如果你将它看作是一种增强,可以这样写:

export class AppComponent {
 // 想对flavor这个属性做个增强
  @Emoji()
 flavor = 'vanilla';
}

将注解打标和代码增强合起来,写这样一个方法:

function Emoji() {
  // target相当于是上面的AppComponent,TS中的类都类似一个字典对象,key相当于是其索引值(属性值)
 return function(target: Object, key: string | symbol) {
  // 所以这样可以拿到上述目标对象类的被打标属性
   let val = target[key];
​
  const getter = () => {
    return val;
 };
  const setter = (next) => {
    // 这里就相当于是代码增强
    val = `😂${next}😂`;
 };
​
   // 这里类似MVVM框架,令你可以实现对象劫持,对它的设值方法劫持、增强
  Object.defineProperty(target, key, {
   get: getter,
   set: setter,
   enumerable: true,
   configurable: true,
 });
​
 };
}

这样写代码的好处往小了说,就是修改所有被@Emoji()修饰的表情时,很方便,比如需要把😂替换成😎的时候,只需要改上述一处代码即可。简单来说,TS中的注解,实现了打标+代码增强的能力,可以把它看成一个函数,它也可以接收函数参数。 。既然可以接收函数参数,也用一个经典的例子演示下:

需求:假如你的前端代码有些重要业务,现在要让他们在触发前都出现一个二次确认是否继续的弹窗。

实现:通过注解增强

export class AppComponent {
​
 @Confirmable('Are you sure?')
 handleClick() {
  // 业务逻辑
   console.log("had clicked.");
 }
​
}

添加它的相应方法

export function Confirmable(message: string) {
  // 类似的,这里需要返回一个函数,多了个入参,descriptor,属于js中的知识,通过它可以拿到一个对象中每个属性的描述符,拿到它
  // 就可以拿到这个方法执行的“时机”,进而对它进行增强
 return function (target: Object, key: string | symbol, descriptor: PropertyDescriptor) {
  const original = descriptor.value;
​
   descriptor.value = function( ... args: any[]) {
 // 这里就是增强,添加弹窗
     const allow = window.confirm(message);
​
     if (allow) {
       // 弹窗中点击确认,继续执行原本的方法
      const result = original.apply(this, args);
      return result;
    } else {
      return null;
    }
 };
​
  return descriptor;
 };
}

代码增强到底是什么——持续优化

上面我提到很多次代码增强,那么到底代码增强是什么,还有前面举得Java代码的例子,熟悉Java的同学都知道,它叫AOP,它的本质又是什么?

可以从一个很简单的例子做解释:

我相信很多同学走上程序员这条路的第二步(第一步是打印helloworld)都是编写这样一道题:三角形打印

题目类似这样

给定一个n,打印n阶三角形(类似下图,n=5)

triple.png

考虑到题目的严谨性,这个n应该有个范围,比如是1-5,那么这道题我们完全可以使用穷举法,把每种n的取值对应的结果直接通过print之类的函数打出来就行了。但是为什么大家都不这样写?

因为太麻烦了,而且,没有什么乐趣,反之,如果你观察到它的规律,发现根据n的值可以组合空格和星号排列,这样写出的代码,很简练,也易于在需求变化时修改

for (i = 0; i < n; i++) {
  for (j = 0; j < (n - i); j++) {
  printf(" ");
 }
​
  for (j = 0; j < 2 * i + 1; j++) {
  printf("*");
 }
  
  printf("\n");
}

同样的道理,如果你写了很多业务代码,举个例子,很多业务都需要写查询数据库的代码,如果之前的代码都是直接查询数据库的,而现在鼓励把缓存当数据库,查询数据库之前先查缓存,相当于要在原本的代码基础上通过修改做个增加,当然,你可以把所有业务对象的代码都改下,亦或者观察它的规律,对于所有同样要增加的操作,是不是可以通过更简练的方式增强它的能力,而不是每个业务代码的多次复制粘贴?如下图,所有的原本业务之前之后可以统一的增强。

java_aop.JPG

这就是标准的AOP(面向切面编程),其中切面的意思就是,把你的业务想象成一个类似“汉堡”的结构,通过纵切,可以看清里面的每一层都是什么

hanbao.png

观察整个汉堡的切面,就相当于在观察你的代码逻辑了,如果有个逻辑,需要添加到你的多处业务中,不妨试试使用注解去增强你的代码。

没有最好,只有最适合你的

总结一下:这篇文章主要想给大家介绍注解的用途、原理,

注解的最佳实践就是打标加代码增强。实现这个的步骤往往是:

  1. 找到注解打标的代码;
  2. 找到要执行增强代码的时机;
  3. 织入增强代码;

其中“找到要执行增强代码的时机”这一步,往往源于你对业务的理解,比如“纵切”你的代码,通过观察切面寻找规律,进而通过注解引起上述三个动作的触发。

不过,以我最开始举的使用注解替代配置文件的例子来说,使用注解是比配置文件简化很多,但是同样它多了耦合。因为配置文件本来是跟代码分离的,使用注解则是将这个关系耦合到代码中了,所以,不是任何时刻注解都优于配置文件的。同样,后面介绍的AOP,本质上是对代码的增强,这个可以理解为一种模式,我觉得这种模式是值得大家了解的,但不是任何时候都必须要使用模式,没有必要生搬硬套。就像前两天我们团队的同学在讨论,个别设计模式有什么异同,其实我感觉并不需要区分的那么清楚,知道每种模式为什么那么写代码就好。比如责任链模式,它的特点是能写出类似obj.handle().handle() 的代码,那么可以不关注一个场景是不是责任链模式,而是在你像写出类似代码的时候,知道需要通过继承,实现handle方法,并返回obj对象,即可,这样就能保证你的链式调用继续下去。你可以反模式,可以用你喜欢的方式去解决你遇到的问题,寻找最适合你的方式去“野蛮生长”。说到模式,Rx.js中倒是有不少设计模式,下一篇文章我们会给大家简单介绍下Rx.js如何理解,以及它的设计理念。

最后

如果你对这些WEB前沿技术也有兴趣,欢迎你对我们的文章一键三联,以及关注我们接下来的开源项目————OpenTiny。欢迎微信搜索我们的小助手:opentiny-official,拉你进群,了解它最新的动态

在线举报