深入剖析《vue-property-decorator》源码实现

1,885 阅读3分钟

你好,我是愣锤。

书接上文《深入剖析vue-class-component源码实现》主要剖析了 Vue + Class 写法的原理实现。而vue-property-decorator库则是基于vue-class-component的插件机制进一步封装了更多的装饰器用法,例如@Prop、@Watch、@Ref等。

本文将深入剖析vue-class-component的插件机制以及vue-property-decorator的各装饰器实现原理。废话不多说,直接开干!

首先我们来看下vue-property-decorator装饰器的基本用法:

@Component
export default class YourComponent extends Vue {
  // vue的prop属性定义
  @Prop({ default: 'default value' }) readonly propB!: string

  // vue的provide依赖和注入
  @Provide('bar') baz = 'bar'
  @Inject() readonly foo!: string

  // vue的watch属性
  @Watch('child')
  private onChildChanged(val: string, oldVal: string) {}

  // vue的$emit的快捷定义
  @Emit()
  onInputChange(e: Event) {
    return e.target.value
  }
  
  // 更多...
}

了解了基本使用之后,下面我们来分析源码吧,看看到底是如何实现这些美妙的装饰器的。但是呢,在分析该库装饰器源码之前,我们先看下的基本工程目录。

工程构建

package.json文件中的main字段可知库的入口文件是lib/index.umd.js:

{
  "main": "lib/index.umd.js",
}

但是lib/index.umd.js在源码中并不存在,因此这一定是打包后的产物,所以我们再看构建相关的脚本命令:

{
  "scripts": {
    "build": "tsc -p ./src/tsconfig.json && rollup -c"
  }
}

从命令可知构建逻辑是先通过tsc命令编译ts文件,然后再通过rollup打包输出文件。再看rollup.config.js 文件的配置:

export default {
  input: 'lib/index.js',
  output: {
    file: 'lib/index.umd.js',
    format: 'umd',
    name: 'VuePropertyDecorator',
    globals: {
      vue: 'Vue',
      'vue-class-component': 'VueClassComponent',
    },
    exports: 'named',
  },
  external: ['vue', 'vue-class-component', 'reflect-metadata'],
}

由此可知打包的入口lib/index.js也就是源码程序的入口, 输出地址是lib/index.umd.js,这也就和package.json文件中的main字段对应上了。

接下来我们正式分析源码,首先从入口文件开始。入口文件内容如下:

/** vue-property-decorator verson 9.1.2 MIT LICENSE copyright 2020 kaorun343 */
/// <reference types='reflect-metadata'/>
import Vue from 'vue'
import Component, { mixins } from 'vue-class-component'

export { Component, Vue, mixins as Mixins }

export { Emit } from './decorators/Emit'
export { Inject } from './decorators/Inject'
export { InjectReactive } from './decorators/InjectReactive'
export { Model } from './decorators/Model'
export { ModelSync } from './decorators/ModelSync'
export { Prop } from './decorators/Prop'
export { PropSync } from './decorators/PropSync'
export { Provide } from './decorators/Provide'
export { ProvideReactive } from './decorators/ProvideReactive'
export { Ref } from './decorators/Ref'
export { VModel } from './decorators/VModel'
export { Watch } from './decorators/Watch'

从上面的入口文件可以看到,该库直接从vue-class-component直接导出了Component、Mixins方法,然后导出了实现的Prop、Emit、Inject、Provide、Watch等装饰器。源码结构相对简单,就是直接导出了一系列装饰器方法。

从前面得知,这些装饰器的实现都是基于vue-class-component库暴露的插件机制实现的。因此,深入理解vue-class-component插件机制是分析上述装饰器实现的前置条件。下面我们看看vue-class-component的插件机制吧。

核心插件架构

在我们分析vue-class-component源码的时候,我们知道vue-class-component本质原理就是在处理类上静态属性/方法实例属性/方法等,转化成vue实例化所需要的options参数。但是在处理options的时候,其中有下面这点一段代码,我们是只提到它是用于处理例如vue-property-decorator等库的装饰器的:

// vue-class-component库中关于第三方装饰器调用的代码实现
// decorate options
const decorators = (Component as DecoratedClass).__decorators__

if (decorators) {
    decorators.forEach(fn => fn(options))
    delete (Component as DecoratedClass).__decorators__
}

这段代码就是判断Component装饰的原始类上是否存在__decorators__属性(值是一个全是函数的数组,本质是一系列用于创建装饰器的工程函数),如果存在的话就依次调用数组的每一项,并把options参数的控制权交给当前项函数,这样的话外界就能力操作options参数了。

但是Component装饰的原始类本身是不会携带__decorators__属性的,只有在使用了例如vue-property-decorator库暴露的装饰器时,才会在断Component装饰的原始类上添加__decorators__属性。

之所以使用vue-property-decorator库的装饰器后会在Component装饰的原始类上添加__decorators__属性,是因为vue-property-decorator的装饰器中会使用vue-class-component暴露的创建装饰器的方法,该方法的作用就是创建自定义装饰器并且在Component装饰的原始类上添加__decorators__属性后,把自定义装饰器添加到__decorators__中。下面看下vue-class-component暴露的创建自定义装饰器的方法实现:

/**
 * 一个抽象工厂函数,用于创建装饰器工厂
 * @param factory 用于创建装饰器的工厂
 */
export function createDecorator (factory: (options: ComponentOptions<Vue>, key: string, index: number) => void): VueDecorator {
  return (target: Vue | typeof Vue, key?: any, index?: any) => {
    // 获取Component装饰的类的构造函数
    const Ctor = typeof target === 'function'
      ? target as DecoratedClass
      : target.constructor as DecoratedClass
    // 如果该类上不存在__decorators__属性,则设置默认值
    if (!Ctor.__decorators__) {
      Ctor.__decorators__ = []
    }
    if (typeof index !== 'number') {
      index = undefined
    }
    // 在__decorators__加入处理装饰器的逻辑
    Ctor.__decorators__.push(options => factory(options, key, index))
  }
}

这个函数看似不好理解,特别是一开始看该部分源码的同学。其实createDecorator是个闭包,做的事情主要就是接收一个用于处理vue options的函数factory,然后返回一个函数,返回的函数调用后会在@Component装饰的类上添加__decorators__属性并将factory函数包装一层放进__decorators__属性中。

接下来我们再继续看具体的装饰器实现原理。

@Prop装饰器原理

@Prop装饰器的作用就是vue props属性的装饰器写法,其源码实现都在Prop.ts文件中,代码如下:

import Vue, { PropOptions } from 'vue'
import { createDecorator } from 'vue-class-component'
import { Constructor } from 'vue/types/options'
import { applyMetadata } from '../helpers/metadata'

/**
 * 封装的处理props属性的装饰器
 * @param  options @Prop(options)装饰器内的options参数选项
 * @return PropertyDecorator | void
 */
export function Prop(options: PropOptions | Constructor[] | Constructor = {}) {
  return (target: Vue, key: string) => {
    // 如果@Prop(options)的options不存在type属性,
    // 则通过ts元数据获取@Prop装饰器装饰的属性的类型赋值给options.type
    applyMetadata(options, target, key)
    // createDecorator是工具方法
    // 参数才是真正处理prop的逻辑
    createDecorator((componentOptions, k) => {
      /**
       * 给vue-class-component生成的options.props[key]赋值为
       * @Prop的参数options,注意这里两处的options概念不同
       *
       * 再重复下概念:
       *  - componentOptions 是vue-class-component生成的options参数
       *  - k 是@Prop装饰器装饰的属性
       *  - options 是@Prop(options)装饰器的options参数
       */
      ;(componentOptions.props || ((componentOptions.props = {}) as any))[
        k
      ] = options
    })(target, key)
  }
}

我们知道@Prop装饰器在使用时是可以传递参数的,因此其是一个装饰器工厂实现,所以Prop函数的格式是返回一个装饰器。返回的装饰器内部通过调用createDecorator来处理prop属性,applyMetadata的作用等下讲解。我们再看下createDecorator的调用,其接收了一个options处理函数,该函数的两个参数一个是options参数,一个是Prop装饰器装饰的属性k。再接下来的处理逻辑就简单了:

;(componentOptions.props || ((componentOptions.props = {}) as any))[ k ] = options

这就是简单的在options对象挂载一个props属性,将 kk 对应的值添加时就可以了,本质其实还是在拼接vue2options语法。

接下来我们再看applyMetadata部分的实现,先说结论,作用是处理@Prop参数没有type属性时,利用ts元数据获取@Prop装饰器装饰的属性的类型作为vue prop参数的type值。好,我们上源码:

// 判断是否支持ts元数据的Reflect.getMetadata功能
const reflectMetadataIsSupported =
  typeof Reflect !== 'undefined' && typeof Reflect.getMetadata !== 'undefined'

// applyMetadata的实现
export function applyMetadata(
  options: PropOptions | Constructor[] | Constructor,
  target: Vue,
  key: string,
) {
  if (reflectMetadataIsSupported) {
    if (
      !Array.isArray(options) &&
      typeof options !== 'function' &&
      !options.hasOwnProperty('type') &&
      typeof options.type === 'undefined'
    ) {
      // 只有在装饰器参数为对象且不存在type属性时,
      // 才通过ts元数据获取数据类型给options.type赋值
      const type = Reflect.getMetadata('design:type', target, key)
      if (type !== Object) {
        options.type = type
      }
    }
  }
}

这里首先判断是否支持ts元数据的Reflect.getMetadata功能,只有在支持的时候才做如此的hack处理。然后在@Prop没有传入type属性时,利用Reflect.getMetadata获取被装饰属性的类型作为type的值。

不熟悉reflect-metadata用法的小伙伴可以翻阅文档查询,经过上述处理最终实现了类似下面这样的转换:

@Component
export default class MyComponent extends Vue {
  @Prop() age!: number
}

/**
 * 上述代码也就被转化成了下面的代码
 * 是因为上述代码利用元数据获取age的类型是Number拼接为options.type,
 * 然后将options赋值给props.age
 */
export default {
  props: {
    age: {
      type: Number,
    },
  },
}

分析完了@Prop装饰器的源码实现之后,其他装饰器的源码实现便大同小异了,都是在处理被装饰器属性/方法转换成options参数。

@Watch装饰器原理

先对比一下vue-property-decorator的watch写法和vue2watch写法:

/**
 * vue-property-decorator的watch写法
 */
import { Vue, Component, Watch } from 'vue-property-decorator'

@Component
export default class YourComponent extends Vue {
  @Watch('person', {immediate: true, deep: true })
  private onPersonChanged1(val: Person, oldVal: Person) {
    // your code
  }
}

/**
 * vue2的watch写法,
 * 需要注意的是watch是支持数组写法的
 */
export default {
  watch: {
    'path-to-expression': [
      exprHandler,
      {
        handler() {
            // code...
        },
        deep: true,
        immediate: true,
      }
    ]
  }
}

@Watch装饰器就是要处理Component装饰的原生类中的watch语法。

import { WatchOptions } from 'vue'
import { createDecorator } from 'vue-class-component'

/**
 * decorator of a watch function
 * @param  path the path or the expression to observe
 * @param  watchOptions
 */
export function Watch(path: string, watchOptions: WatchOptions = {}) {
  return createDecorator((componentOptions, handler) => {
    /**
     * 获取Component装饰的类上定义的watch参数,没有就赋默认值为空对象
     * 注意a ||= b的写法等同于 a = a || b
     */
    componentOptions.watch ||= Object.create(null)
    const watch: any = componentOptions.watch

    /**
     * 把watch监听的key的回调统一格式化成数组
     * watch的key的回调是支持string | Function | Object | Array的
     */
    if (typeof watch[path] === 'object' && !Array.isArray(watch[path])) {
      watch[path] = [watch[path]]
    } else if (typeof watch[path] === 'undefined') {
      watch[path] = []
    }

    /**
     * 把 @Component@Watch 装饰的函数和装饰器参数组合成vue的watch写法
     */
    watch[path].push({ handler, ...watchOptions })
  })
}

@Watch的实现还是利用createDecorator创建装饰器,其参数是具体的实现逻辑。具体逻辑就是获取@Component装饰的类上处理的watch参数,然后统一成数组格式,然后把@Watch装饰的函数逻辑以vue2的写法添加到其watch的数据的数组中。

@Ref装饰器原理

分析@Ref装饰器的源码之前我们要先知道其使用方式是怎样的。我们通过@Ref定义好组件引用之后,可以直接使用 this.xxx 的方式快速使用,而在options写法中我们则是需要 this.$refs.xxx 的方式调用:

@Component
export default class YourComponent extends Vue {
  // vue的ref的装饰器写法
  @Ref() myDom: HTMLDivElement
  
  myMethod() {
    // vue2 options的写法则是this.$refs.myDom
    this.myDom.style.color = '#f00';
  }
}

现在我们思考下,如何实现this.myDomthis.$refs.myDom的指向呢?猛一想似乎会没有思路,但是再一想,在vue中是不是存在计算属性?那我们是不是可以定义一个计算属性myDom返回this.$refs.myDom,就像下面这样:

export default {
  computed: {
    myDom: {
      get() {
        return this.$refs.myDom;
      }
    }
  }
}

哎,对,顺着这个思路,聪明的小伙伴应该就想到了,我们可以通过将@Ref修饰的属性最终生成options.computed同名的计算属性即可。下面上源码来验证一下:

import Vue from 'vue'
import { createDecorator } from 'vue-class-component'

/**
 * decorator of a ref prop
 * @param refKey the ref key defined in template
 */
export function Ref(refKey?: string) {
  return createDecorator((options, key) => {
    options.computed = options.computed || {}
    options.computed[key] = {
      // cache废弃语法
      cache: false,
      // 通过计算属性的get返回$refs属性
      // 例如 @Ref() readonly myDom: HTMLDivElement
      // 返回的是this.$refs.myDom
      get(this: Vue) {
        // 优先取@Ref装饰器的参数,否则取属性属性名,作为ref的key 
        return this.$refs[refKey || key]
      },
    }
  })
}

核心实现还是获取@Component装饰的类上的computed属性,然后增加一个计算属性,通过计算属性的get返回一个this.$refs的正常写法。这里提一点,cache是已经废弃的语法,这里仍然保留只是向前兼容。

@Emit装饰器原理

分析@Emit的源码还是得先看用法:

@Component
export default class YourComponent extends Vue {
  // vue的emit事件的装饰器写法
  @Emit('myEmitEventName')
  emitMyEvent(value: string) {
  }
  
  myMethod() {
    // vue2 options的写法则是this.$emit('myEmitEventName', 'value');
    this.emitMyEvent('value');
  }
}

这个思路就比较简单了,就是拿到装饰器工厂的参数作为emit事件的事件名称,然后把装饰的函数结果或者参数作为emit事件的值就好。下面就看实现了:

import Vue from 'vue'

// Code copied from Vue/src/shared/util.js
const hyphenateRE = /\B([A-Z])/g
// 字符串大写转连字符
const hyphenate = (str: string) => str.replace(hyphenateRE, '-$1').toLowerCase()

/**
 * decorator of an event-emitter function
 * @param  event The name of the event
 * @return MethodDecorator
 */
export function Emit(event?: string) {
  return function (_target: Vue, propertyKey: string, descriptor: any) {
    // 根据@Emit装饰器的函数名获取连字符的格式
    const key = hyphenate(propertyKey)
    const original = descriptor.value

    // 覆写装饰器函数的逻辑
    descriptor.value = function emitter(...args: any[]) {
      const emit = (returnValue: any) => {
        const emitName = event || key

        // 如果@Emit装饰的函数没有返回值,
        // 则直接emit装饰器函数的所有参数
        if (returnValue === undefined) {
          if (args.length === 0) {
            this.$emit(emitName)
          } else if (args.length === 1) {
            this.$emit(emitName, args[0])
          } else {
            this.$emit(emitName, ...args)
          }
        // 如果@Emit装饰的函数有返回值,
        // 则直接emit的值依次为:返回值、函数参数
        } else {
          args.unshift(returnValue)
          this.$emit(emitName, ...args)
        }
      }

      // 获取返回结果
      const returnValue: any = original.apply(this, args)

      // 如果是返回的promise则在then时emit
      // 否则直接emit
      if (isPromise(returnValue)) {
        returnValue.then(emit)
      } else {
        emit(returnValue)
      }

      return returnValue
    }
  }
}

// 判断是否是promise类型
// 判断手段为鸭式辨型
function isPromise(obj: any): obj is Promise<any> {
  return obj instanceof Promise || (obj && typeof obj.then === 'function')
}

这里的具体Emit内部抛出什么结果就不再分析了,主要是Emit装饰器工厂接收emit事件的名称,因为是装饰的函数,所以对装饰的函数通过descriptor.value = function emitter(...args: any[]) { ... }进行了重写,仅此而已。

@VModel装饰器原理

import Vue, { PropOptions } from 'vue'
import { createDecorator } from 'vue-class-component'

/**
 * decorator for capturings v-model binding to component
 * @param options the options for the prop
 */
export function VModel(options: PropOptions = {}) {
  const valueKey: string = 'value'
  return createDecorator((componentOptions, key) => {
    // 给props.value赋值为装饰器参数
    ;(componentOptions.props || ((componentOptions.props = {}) as any))[
      valueKey
    ] = options
    // 给computed[被装饰的属性key]赋值为get/set
    // get时直接返回props.value, set时触发this.$emit('input')事件
    ;(componentOptions.computed || (componentOptions.computed = {}))[key] = {
      get() {
        return (this as any)[valueKey]
      },
      set(this: Vue, value: any) {
        this.$emit('input', value)
      },
    }
  })
}

其他装饰器的实现基本大同小异,就不再过多介绍。

总结

分析本文装饰器的源码之前还是要先好好了解装饰器以及元数据的基本内容,然后要了解的插件机制和暴露的创建自定义装饰器的方法逻辑,这部分不了解是没办法分析后续装饰器的实现的。至于装饰的实现都大同小异,都是借助createDecorator方法添加装饰器处理逻辑,其处理逻辑也都是对装饰的属性或方法进行处理,转换成vue options的写法,最后还都是回到vue-class-component库进行所有装饰器处理逻辑的调用,生成最终的vue options参数。

希望通过本文的源码分析,让小伙伴们对装饰器的实际使用能多一些了解。