前言

最近在使用mobx的时候考虑到每个action都要各种try...catch,各种toast loading,觉得好烦,然后想到ES7的装饰器不就是为了少写代码的吗?于是我就立马付诸实践,开始我的装饰器的第一个应用,结果踩了好多坑,瞬间感觉说的跟做得果然不一样!

1、装饰器原理和使用方法简介

废话不多说,毕竟讲装饰器的文章一抓一大把,这里就简单地说一下。装饰器只可以修饰类和类属性,具体写法如下:

修饰类:

@decorator
class A {

}

修饰类方法:

class A {
  @decorator
  method() {}
}

以上的写法等同于:

class A {}

A = decorator(A)

A.prototype.method = decorator(A, 'method', descriptor)

知道这个等同的写法对于理解后面的行为有一定的好处。

我们装饰类属性的时候,就需要在装饰函数中使用三个参数:targetnamedescriptor,分别代表了需要修饰的目标类、目标属性、目标属性的描述符。这个描述符自带了这么一些属性:configurablevalueenumerablewritable__proto__

我们使用下面的例子来说明:

function inject(value) {
  return (target, name, descriptor) => {
    console.log(descriptor)
    Reflect.defineMetadata('childClass', value, target)
    // Reflect.defineMetadata('childClass', value, target.prototype)
  }
}

class A {
  constructor() {
    const childClassMeta = Reflect.getOwnMetadata('childClass', this.constructor)
    // const childClassMeta = Reflect.getOwnMetadata('childClass', this)

    console.log(`we can get the child metakey by getMetadata insteadof getOwnMetadata:[${JSON.stringify(childClassMeta)}]`)
  }
}


class B extends A {
  @inject({
    test: true
  })
  method() {

  }
}

inject方法打个断点,截图如下,可以明显看到对应的值:

看懂上面的基础之后,接下去开始说说遇到的问题。

2、使用修饰器遇到的问题

我在Mobx中之前都是这么写action的(示例代码):

@action actionA = async () {
  try {
    Toast.loading('请稍后')
    const resp = await apiFetch()

    this.data = resp
    Toast.hide()
  } catch (err) {
    Toast.error('出错了.....')
  }
}

然后我现在想将上面那些重复的一模一样的代码抽出去,换成修饰器,于是我做了这么一个修饰器:

export const actionDecorators = () => (target, propertyKey, descriptor) => {
  const oldValue = descriptor.value
  descriptor.value = async (...args) => {
    try {
      Toast.loading('加载中')
      const result = await oldValue.apply(this, args)
      Toast.hide()
      return result
    } catch (err) {
      Toast.hide()
      Toast.error('访问出错,请重试')
      return null
    }
  };
  return descriptor
}

写完这段代码,感觉装饰器就那么一回事嘛,好像很容易的。于是开始把所有的action都加上这么一个装饰器。如下:

@action
@actionDecorator()
actionA = async () => {
  const resp = await apiFetch()

  this.data = resp
}

这么一搞,感觉自己又做了件了不起的事情,这样的代码肯定是没问题的。

但明显是too young to simple。启动项目后,页面空白了,感觉哪里不对劲?loading的动画呢?页面报错的提示呢?

What? 姿势不对?

明显不是,还是因为自己的无知,对装饰器的一些基础知识认识不到位造成的。看到这里的童鞋们能否指出上面的代码有哪些错误呢?如果你心里有数了,那么接下去的内容想是不用再继续阅读了。

3、代码的缺陷

现在揭晓答案的时刻到了,上面的代码存在两处错误以及一处需要优化的地方。

  1. 错误1:actionDecorator装饰了函数表达式
  2. 错误2:错误使用了this
  3. 优化1:没有传值,不需要使用() => () => 这种写法的

4、刨根问底

4.1 错误1

对于错误1,肯定有很多人都好奇,为什么不能使用函数表达式?明明你之前的action使用的就是函数表达式,换成你的装饰器就不行了?我们先来看看mobx源码中对于action装饰器的源码:actiondecorator

namedActionDecorator函数中,源码区别对待了两种写法:

method() {}method = () =>

之前我还没在意到第二种写法的特殊性,现在这么一坑,竟然发现第二种写法是什么?标准规定的吗?

后来谷歌了一下,发现这个语法竟然是ES7(gu)的(lou)标(gua)准(wen):class properties,它允许你使用=号的形式声明类的属性,在babel中可以看到对应的插件babel-plugin-transform-class-properties,语法的相关转换如图:

针对这两种不同的写法,是有不同的处理方式,而我们刚开始写的demo代码却只能支持第一种标准的ES6的shorthand的写法,所以我们写的这个装饰器在第二种写法的情况下是压根不生效的,所以压根就不会出现loading的情况,也不会去捕捉页面的请求错误的情况。

对于这两种写法的区别我在这个代码库特意写了一下:decorator-demo

有兴趣的童鞋可以看看babel对于二者的编译的不同处。

找到问题1的错误之后,我们改成第一种写法:

@action
@actionDecorator()
async actionA() {
  const resp = await apiFetch()

  this.data = resp
}

这下子可以进入我们的装饰器了。

4.2 错误2

接下去继续执行又报错了: Uncaught TypeError: Cannot read property 'data' of undefined

这下子又懵逼了,this是undefined?我不是在执行方法的时候调用这个oldValue.apply(this, args)的吗?难道这里的this指针已经是undefined了?看来遇到了JS语言中this指向哪里的世纪性难题了?

说是难题,应该加上引号:“难题”。如果对js有深入研究的话,其实这个错误应该是早就知道的。恰巧的是我的基础不扎实,导致出现这种低级的错误。现在我们来一层层剖析这个错误的原因。

大家应该注意到我们在代码中使用this的时候外层是一个箭头函数:

descriptor.value = async (...args) => {
  const result = await oldValue.apply(this, args)
};

我们根据装饰器的原理,以及当前使用的场景,简化出这么一个简单的例子:

'use strict'   // 因为Babel在转译的时候都会加上这个,所以为了保持一致,我们也加上了
function inject() {
  return () => {
    console.log('in inject: ', this)
  }
}
class A {
  constructor() {
    this.method = inject() // 这个类似于装饰器原理
  }
}

const inis = new A()
inis.method()

在上文我们提到装饰器的原理无非就是将你传入的方法进行再封装后再返回给你调用,所以这里我们不主动去封装,而是直接赋值给你的方法,在装饰器中,最后调用的是descriptor的value属性,我们这里就将inject当成value属性去调用了。

大家可以将这段代码复制到浏览器,很明显,这个时候打印的就是undefined,为什么呢?

大家试着回忆一下this指向的几种规则:

  1. 当一个函数使用new操作符来调用的时候,在函数内部this指向了新创建的对象。
  2. 当使用callapply调用函数的时候,this指向了callapply指定的第一个参数。如果第一个参数是null或者不是一个对象的时候,this指向全局对象。
  3. 当函数作为对象里面的属性被调用的时候,在函数内部的this指向了对应的对象。
  4. 最后就是默认规则,默认this指向全局对象,在浏览器便是Window。但是在严格模式下是为undefined的。

Tips

参考严格模式的定义: strict Mode

由此可知我们的函数inject执行的时候,this是为undefined的,因为箭头函数的原因,此时this已经被赋值为undefined,即等价于:

function inject() {
  var _this = this
  return () => {
    console.log('in inject: ', _this)
  }
}

所以得到的便是上面的错误了。如果将箭头函数改为正常的function,那么this的指向便取决于当时调用function(){console.log('in inject: ', this)}的上下文,这个时候就可以运用上面this规则的第一条,使用new操作符,所以自然而然this指向了我们期望的目标。

4.3 优化点

如果你不需要往装饰器传递什么值的话,就不再需要使用() => () => {}的形式,直接一个function就够了。使用actionDecorator的时候也不要加括号了。

最后稳定的版本如下:

export function actionDecorator(target, propertyKey, descriptor) {
  const oldValue = descriptor.value
  descriptor.value = async function (...args) {
    try {
      Toast.loading('加载中')
      const result = await oldValue.apply(this, args)
      Toast.hide()
      return result
    } catch (err) {
      Toast.hide()
      Toast.error('访问出错,请重试')
      return null
    }
  };
  return descriptor
}

@action
@actionDecorator
actionA = async () => {
  const resp = await apiFetch()

  this.data = resp
}

后记

经过这次的一个错误的梳理,对于装饰器应该能够达到熟练掌握的程度了,毕竟装饰器在我的各种项目开发中用的还是蛮多的。 多总结,多踩坑,才能知道自己哪里基础不扎实,哪里学的还不够好,与大家共勉。