深入剖析《vue-class-component》源码实现

2,260 阅读15分钟

你好,我是愣锤。

本篇文章我们解读 Vue2 + TS 的开发模式中使用 Class + 装饰器 语法的实现原理。在使用 Vue2 + TS 开发时我们会用到vue-class-component库来支持class的写法,那么它是怎么做到从options的写法到class写法的支持的呢?可能大家第一想法就是走代码编译,把class语法通过AST编译成options语法。然而,并不是......

下面我们将解读vue-class-component库的实现原理,看看他是如何支持optionsclass的神奇原理吧。老规矩,我们还是首先从基本的使用开始。

基本使用

vue官方团队开源的vue-class-component库,让vue2支持了class风格的语法,下面我们对比下与普通的options语法的区别:

import Vue from 'vue'
import Component from 'vue-class-component'

@Component({
    name: 'ComponentName',
    watch: {},
})
export default class Counter extends Vue {
  // data数据
  count = 0

  // 计算属性
  get myCount() {
    return this.count
  }
  set myCount(val) {
    this.count = val;
  }
  
  // 声明周期例子
  created() {}
  
  // render函数例子
  render() {}
}

vue2的常规options写法是下面这样的:

export default {
  data() {
      return {
        count: 0,
      }
  },
  watch: {},
  computed: {},
  created() {},
  render() {},
}

之所以能够支持 class + 装饰器 的写法,是依赖vue-class-component库的核心实现,将class风格的语法转换成Vueoptions的语法。那是怎么做到语法转换的呢?

大家第一想法可能是要走代码编译实现,但是vue-class-component没有这么做。大家知道,ts装饰器Decorator是为在类、类成员上通过元编程语法添加标注的功能。因此我们可以利用ts装饰器的功能,将class转换成vue实例。核心思路如下:

image.png

Component装饰器分析

vue-class-component库核心就是从一个Component函数(应该叫装饰器)开始的,在分析Component装饰器实现实现前,我们先了解两个概念,类装饰器和装饰器工厂:

/**
 * 类装饰器
 * @param target 被装饰类的构造函数
 */
function Decorator(target: Function) {
  // 这里的target其实就是类的构造函数
}

@Decorator
class Demo {}

/**
 * 装饰器工厂
 * 装饰器工厂就是一个返回装饰器的函数
 */
function DecoratorFactor(value: string) {
  // 返回一个装饰器
  return function (target: Function) {
  }
}

我们指定@Component可以直接使用也可以传入参数作为装饰器工厂使用,如下所示:

// 直接使用Component装饰器
@Component
class DemoA extends Vue {}

// 把Component作为装饰器工厂使用
@Component({
  name: 'ComponentName',
  // ...
})
class DemoB extends Vue {}

所以接下来我们直接看Component内部的实现:

function Component <V extends Vue>(options: ComponentOptions<V> & ThisType<V>): <VC extends VueClass<V>>(target: VC) => VC
function Component <VC extends VueClass<Vue>>(target: VC): VC
function Component (options: ComponentOptions<Vue> | VueClass<Vue>): any {
  /**
   * options为函数,说明Component是作为普通的装饰器函数使用
   * 注意,装饰器本身就是一个函数而已,此时的options参数就指代被装饰的类,
   * 把此处的options当成装饰器的target更好理解
   * @example
   *    @Component
   *    class Demo extends Vue {}
   */
  if (typeof options === 'function') {
    return componentFactory(options)
  }

  /**
   * options不是函数时,说明option被传入了值,
   * 则Component是作为装饰器工厂使用
   * @example
   *    @Component({ name: 'DemoComponent', filters: {} })
   *    class Demo extends Vue {}
   */
  return function (Component: VueClass<Vue>) {
    return componentFactory(Component, options)
  }
}

可以看到Component就是一个重载函数,因为要支持装饰器装饰器工厂两者使用方式。其实应该要反过来思考,因为实现根据需求来的,我们期望的是在使用Component时能够让用户可选的传入name、watch等参数,正因为Component可以可选的选择参数,实现时才要既作为装饰器使用也作为装饰器工厂使用,这里就不可避免的要进行重载了。

image.png

function Component <V extends Vue>(
  options: ComponentOptions<V> & ThisType<V>,
): <VC extends VueClass<V>>(target: VC) => VC

第一个重载函数是约束的接收参数的情况,也就是作为装饰器工厂使用。首先约束泛型VVue的子类型,参数options的类型必须要符合Vue的组件格式ComponentOptions<V>,同时利用ThisType<V>修正this的指向是泛型V,返回一个类装饰器。

返回的类装饰器的参数target类型为VC,约束的是VueClass<V>的子类型。下面看VueClass<V>的实现:

/**
 * VueClass
 * @desc 获取Vue类的类型(包括实例部分和静态部分)
 * - typeof Vue拿到的是Vue类的静态部分的类型
 * - {new (...args; any[]): V & Vue}拿到的是实例部分的类型
 *    - 主要该部分通过 & V 的方式交叉了V的类型
 */
export type VueClass<V> = {
  new (...args: any[]): V & Vue
} & typeof Vue

VueClass描述的是Vue类的类型的静态部分和增强的实例部分,实例部分通过V & Vue得到被扩展的Vue类的类型的实例部分。typeof Vue拿到的是类的静态部分,关于此部分不清楚的可以参阅我的另一篇博文《Ts高手篇:22个示例深入讲解Ts最晦涩难懂的高级类型工具》

接下来我们回到Component函数的内部实现,就是根据传入的参数类型是类装饰器还是类装饰器工厂,分别进行不同的处理,所有的实现都是在componentFactory函数中。

要注意的是,类本身是函数,类装饰器的参数target就是类的构造函数,所以typeof options为函数时,我们认为是类装饰器,否则认为是作为装饰器工厂使用。装饰器工厂就是可以接收外部参数,并且返回一个装饰器。

componentFactory实现

上述得知,componentFactory才是最终的装饰器实现。首先看下componentFactory轮廓:

export function componentFactory (
  Component: VueClass<Vue>,
  options: ComponentOptions<Vue> = {}
): VueClass<Vue> {
    // ...
}

接收两个参数,第一个参数是装饰器装饰的类的构造函数本身。第二个参数是可选项,如果传递了该项说明Component是作为装饰器工厂使用的,而options也是部分vue参数,比如options就是下面例子中@Component参数的部分:

@Component({
    name: '',
    watch: {}
    // ... 
})

下面看内部实现:

/**
 * componentFactory
 * @param Component 被装饰的类
 * @param options @Component()装饰器工厂的参数
 *  - options 也是最终格式化的vue组件的options格式参数
 */
export function componentFactory (
  Component: VueClass<Vue>,
  options: ComponentOptions<Vue> = {}
): VueClass<Vue> {
  /**
   * 获取组件名称
   * 通过options.name优先取用户传递的组件name,即@Component({ name: '' })的name
   * 没有传递name时则依次取被装饰类的_componentTag或name属性
   */
  options.name = options.name || (Component as any)._componentTag || (Component as any).name
  
  // 遍历被装饰类原型对象上所有的属性
  const proto = Component.prototype
  Object.getOwnPropertyNames(proto).forEach(function (key) {
    // 排除类的构造函数
    if (key === 'constructor') {
      return
    }

    // created等内置属性、钩子直接赋值
    if ($internalHooks.indexOf(key) > -1) {
      options[key] = proto[key]
      return
    }

    const descriptor = Object.getOwnPropertyDescriptor(proto, key)!
    /**
     * class类的写法,只有get/set属性的descriptor的value值是undefined
     * 所以这里却分get/set和其他实例属性/实例方法
     */
    if (descriptor.value !== void 0) {
      // 将类上所有的实例方法拼接到options.methods
      if (typeof descriptor.value === 'function') {
        (options.methods || (options.methods = {}))[key] = descriptor.value
      } else {
        /**
         * 处理data数据 (class的属性写法还在TC39试验性3阶段)
         * 将类上所有的实例数据(注意不包含get\set)添加到options.mixins
         * 注意,这里采取mixin的方式处理data数据,是因为data可能是函数,无法做merge操作,
         * 所以一律使用mixin的方式
         */
        (options.mixins || (options.mixins = [])).push({
          data (this: Vue) {
            return { [key]: descriptor.value }
          }
        })
      }
    } else if (descriptor.get || descriptor.set) {
      // vue的计算属性
      (options.computed || (options.computed = {}))[key] = {
        get: descriptor.get,
        set: descriptor.set
      }
    }
  })
  
  // ...
}

componentFactory函数内部就是扫描装饰类上定义的所有属性、方法等,然后处理成vue组件的options参数对象,最后生成一个Vue组件类。具体的实现逻辑继续往后看。

首先是确定组件实例的name值,依次从 options.name、_componentTag、Component.name顺序取值

然后遍历被装饰类的原型对象(注意是遍历的原型对象才能拿到实例属性/实例方法),排除constructor;如果是data、created等声明周期函数、render等,则直接给options赋值;否则获取key对应的描述符,根据描述符中的value判断如果是函数,则放到options.methods属性中:

if (typeof descriptor.value === 'function') {
    (options.methods || (options.methods = {}))[key] = descriptor.value
}

如果不是函数则说明是普通的原型对象上的实例属性,则作为vue实例的data中的响应式数据,因此放入options.mixins中:

else {
    (options.mixins || (options.mixins = [])).push({
        data (this: Vue) {
            return { [key]: descriptor.value }
        }
    })
}

这种做法是巧妙的利用了vuemixins属性,在数组中推入了多个data选项,最终vue实例化时会合并mixins

如果上述判断描述的value不存在的时候,则判断描述符是否具有set/get属性,如果存在的话则说明是我们写的classsettergetter,那么我们则把它拼在options.computed属性中:

if (descriptor.get || descriptor.set) {
    // computed properties
    (options.computed || (options.computed = {}))[key] = {
        get: descriptor.get,
        set: descriptor.set
    }
}

构造函数内的属性赋值处理

上面处理完了被装饰类的实例属性的转换,但是被装饰类有可能存在constructor构造函数的,而且在构造函数中可能通过this.xxx = xxx的方式创建实例属性。这部分的实例属性如何转化成响应式数据呢?

;(options.mixins || (options.mixins = [])).push({
    data (this: Vue) {
      return collectDataFromConstructor(this, Component)
    }
})

这里通过mixins的方式混入了一个datadata的内容就是在构造函数中添加的实例属性(响应式数据),具体的实现在collectDataFromConstructor中:

export function collectDataFromConstructor (vm: Vue, Component: VueClass<Vue>) {
  // 重写_init方法
  const originalInit = Component.prototype._init
  Component.prototype._init = function (this: Vue) {
    // proxy to actual vm
    const keys = Object.getOwnPropertyNames(vm)
    /**
     * 将vm上的所有数据代理到Component上
     * 因为在constructor中的this.xxx数据赋值时可能用到其他已初始化的数据
     * 注意:Object.defineProperty添加的属性是不会被Object.keys(data)获取到的
     */
    keys.forEach(key => {
      Object.defineProperty(this, key, {
        get: () => vm[key],
        set: value => { vm[key] = value },
        configurable: true
      })
    })
  }

  // 获取类属性值
  const data = new Component()

  // 将_init重新指回原引用
  Component.prototype._init = originalInit

  // create plain data object
  const plainData = {}
  Object.keys(data).forEach(key => {
    if (data[key] !== undefined) {
      plainData[key] = data[key]
    }
  })

  return plainData
}

这里的核心做法是直接Object.keys(new Component())得到Component实例的所以实例属性,也就是构造函数中this.xxx初始化的响应式数据。这里有个注意点是Object.defineProperty添加的属性是不会被Object.keys(data)获取到的。

但是这里复写了Component类的_init方法,是因为Component在使用时会extends Vue,Vue组件在实例化时背后会调用_init方法在实例上添加一些内容,这里进行复写是为了避免被初始化为Vue实例,从而避免污染。

在复写的_init方法中对vm上的所有属性代理到了Component上,是因为在Componentconstructor中的this.xxx数据赋值时可能用到其他已初始化的数据,所有进行属性的getset的代理。

需要提醒一点的是,虽然vue-class-component帮助我们把constructor中定义的数据也做了正常的转化,但是不建议我们在constructor中做这个事情,data数据的定义还是放在Component中的实例属性定义,同时也是不建议我们使用constructor,如下:

@Component({
    name: 'ComponentName',
    watch: {},
})
export default class Counter extends Vue {
  // data数据
  count = 0
  
  str = 'this is a string'
}

原因是因为会被以为调用两次。参考官网这里的说明

可扩展的插件机制

到上面这里被装饰类上实例属性、get/set转化成datacomputed等逻辑就以及完成了。那么比如我们还有prop、watch等这些逻辑等装饰器写法如果是外部库去实现(这样该库本身内核会轻一些),如何暴露钩子执行这些外部库添加等装饰器呢?

/**
 * 执行外部库创建的装饰器
 */
const decorators = (Component as DecoratedClass).__decorators__
if (decorators) {
    decorators.forEach(fn => fn(options))
    delete (Component as DecoratedClass).__decorators__
}

这里的处理逻辑就是如果被装饰类类上挂有__decorators__属性(__decorators__属性在被装饰类上默认是没有的,由其他基于vue-class-component进一步封装的库挂载到被装饰器类上),则依次执行装饰器。执行完毕移除__decorators__属性。其实就是一个简单的同步队列依次执行,只不过队列每一步都有机会处理options而已。

vue-class-component库核心实现了class到options语法的转化,那么如果想对外暴露可扩展更多的装饰器插件机制怎么办呢?如果没有该插件机制,那么是无法扩展的,所有内容都只能在该库实现。正如vue-property-decorator就基于vue-class-component扩展了很多其他装饰器。

下面我们看下vue-class-component是如何封装插件机制的。我们看下index.ts文件中对外暴露的一个用于创建装饰器的工具函数:

/**
 * 一个抽象工厂函数,用于创建装饰器工厂
 * @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方法会在@Component装饰的类上添加__decorators__属性(如果不存在的话),并在__decorators__数组中添加一个装饰器处理函数,该函数可以拿到options参数对其进行处理。因此第三方库就可以基于此函数封装更多的装饰器用于处理更多的语法转换。

关于此部分更深入详细的介绍,会在下篇分析vue-property-decorator源码实现中结合更具体的例子分析,小伙伴们敬请期待吧~

处理被装饰类上的静态属性/方法

大家知道,在vue2中如果我们直接在vue的对象上添加属性或方法,那么其是非响应式的,但是一般不建议我们这么做。例如:

export default {
  data() {
    return {
      /// 响应式数据
    }
  },
  // 非响应式数据和方法
  someNotReactiveData: 123,
  someNotReactiveFn() {
    // ...
  },
}

这些非响应式数据和方法在vue-class-component则是对应被装饰类的静态属性和静态方法来达到同样的效果:

@Component({
    name: 'ComponentName',
})
export default class Counter extends Vue {
  // 非响应式数据
  static myStaticData = 0
  
  // 非响应式的方法
  static myStaticFn() {}
}

vue-class-component库是如何实现非响应式数据和方法的转化呢?源码如下:

// 查找到继承自Vue的父类,否则就取Vue
const superProto = Object.getPrototypeOf(Component.prototype)
const Super = superProto instanceof Vue
    ? superProto.constructor as VueClass<Vue>
    : Vue

// 利用Vue.extend扩展一个子类
const Extended = Super.extend(options)

// 处理类上的静态属性和静态方法
forwardStaticMembers(Extended, Component, Super)

下面看下forwardStaticMembers的具体实现:

function forwardStaticMembers (
  Extended: typeof Vue,
  Original: typeof Vue,
  Super: typeof Vue
): void {
  // We have to use getOwnPropertyNames since Babel registers methods as non-enumerable
  Object.getOwnPropertyNames(Original).forEach(key => {
    // 排除prototype、arguments、callee、caller
    if (shouldIgnore[key]) {
      return
    }

    // 获取原始类上的属性的描述符
    const descriptor = Object.getOwnPropertyDescriptor(Original, key)!

    Object.defineProperty(Extended, key, descriptor)
  })
}

这里我移除了部分兼容和日志类的代码,核心代码如上所示,就是遍历被装饰类(注意,这里是直接遍历的Component,而不是Component.prototype),这样就可以得到所有的静态属性和方法,但是要排除prototype、arguments、callee、caller这几个属性。然后获取属性对应的描述符,然后通过Object.defineProperty添加到我们上面Vue.extend构造的子类上面。

处理元数据

基本上,到上面的步骤,所有的属性和方法等就已经处理完了,但是如果用户自定义了元数据如何处理呢?我们继续回到componentFactory看他的实现:

if (reflectionIsSupported()) {
  copyReflectionMetadata(Extended, Component)
}

首先要判断当前环境是否支持元数据,不知道元数据的翻阅reflect-metadata文档。判断逻辑也就是看是否支持Reflect.defineMetadataReflect.getOwnMetadataKeys这俩方法,当然了reflect-metadata远不止这俩方法。

export function reflectionIsSupported () {
  return typeof Reflect !== 'undefined' && Reflect.defineMetadata && Reflect.getOwnMetadataKeys
}

那么如果支持元数据,我们也需要处理元数据的转化,转化就是直接把Component中定义的所有元数据全部拷贝到我们Vue.extend构造的子类上面去。逻辑如下:

// 拷贝元数据
export function copyReflectionMetadata (
  to: VueConstructor,
  from: VueClass<Vue>
) {
  // 这里我们先统一两个术语:
  //   - 原始类,即Component装饰的类
  //   - 扩展类,即Vue.extend(options)得到的类

  // 拷贝原始类上的元数据到扩展类上
  forwardMetadata(to, from)

  // 拷贝原始类上的实例属性和实例方法上的元数据到扩展类上
  Object.getOwnPropertyNames(from.prototype).forEach(key => {
    forwardMetadata(to.prototype, from.prototype, key)
  })

  // 拷贝原始类上的静态属性和静态方法上的元数据到扩展类上
  Object.getOwnPropertyNames(from).forEach(key => {
    forwardMetadata(to, from, key)
  })
}

被装饰类、被装饰类的实例属性/方法、被装饰类的静态属性/方法上都可能存在元数据,因此这三种场景都要进行元数据的处理:

  • 拷贝被装饰类上的元数据
  • 拷贝被装饰类中所有的实例属性和实例方法上的元数据
  • 拷贝被装饰类中所有的静态属性和静态方法上的元数据

拷贝的具体实现都在封装在了forwardMetadata函数中,具体实现如下:

/**
 * 元数据的拷贝
 * 核心实现就是:
 *  - 利用 Reflect.getOwnMetadata 获取元数据
 *  - 利用 Reflect.defineMetadata 设置元数据
 */
function forwardMetadata (to: object, from: object, propertyKey?: string): void {
  const metaKeys = propertyKey
    ? Reflect.getOwnMetadataKeys(from, propertyKey)
    : Reflect.getOwnMetadataKeys(from)

  metaKeys.forEach(metaKey => {
    const metadata = propertyKey
      ? Reflect.getOwnMetadata(metaKey, from, propertyKey)
      : Reflect.getOwnMetadata(metaKey, from)

    if (propertyKey) {
      Reflect.defineMetadata(metaKey, metadata, to, propertyKey)
    } else {
      Reflect.defineMetadata(metaKey, metadata, to)
    }
  })
}

可以看到,是利用Reflect.getOwnMetadata获取自身的元数据,再利用Reflect.defineMetadata在目标类上重新定义元数据。

总结

到这里vue-class-component的核心原理实现就分析完了,主要就是通过封装类装饰器和类装饰器工厂来处理被装饰器类,扫描上面的静态属性/方法、实例属性方法、get/set等数据,转换成options参数,然后通过Vue.extends(options)扩展一个Vue子类即可,最后把被装饰类上的元数据再拷贝到子类。