6

导读

使用过一段时间 class 来定义组件,要用 vue-property-decorator 提供定义好的装饰器,辅助完成所需功能,对这个过程好奇,就研究了源码。内部主要依靠 vue-class-component 实现,所以将重点放在对 vue-class-component 的解读上。

本文主要内容有:

  • 装饰器作用在 class 定义的组件,发生了什么
  • 解读 Component 装饰器实现过程
  • vue-property-decorator 中如何扩展装饰器

装饰器作用在 class 定义的组件,发生了什么

没有使用 class 方式定义组件时,通常导出一个选项对象:

<script>
export default {
  props: {
    name: String
  },
  data() {
    return {
      message: '新消息'
    }
  },
  watch: {
    message(){
      console.log('message改变触发')
    }
  },
  computed:{
    hello: {
      get(){
        return this.message + 'hello';
      },
      set(newValue){}
    }
  },
  methods:{
    clickHandler(){}
  }
  mounted(){
    console.log('挂载完毕');
  }
}
</script>

这个对象告诉 Vue 你要做什么事情,需要哪些功能。 根据字段的不同作用,把需要添加的属性和方法,写在指定的位置,例如,需要响应式数据写在 data 中、计算属性写在 computed 中、事件函数写在 methods中、直接写生命周期函数等 。Vue 内部会调用 Vue.extend() 创建组件的构造函数,以便在模板中使用时,通过构造函数初始化此组件。

如果使用了 class 来定义组件,上面的字段可省略,但要符合 Vue 内部使用数据的规则,就需要重组这些数据。

定义 class 组件:

<script lang="ts">
class Home extends Vue {
  message = '新数据';

  get hello(){
    return this.message + 'hello';
  }
  set hello(newValue){}

  clickHandler(){}
  mounted(){}
}

Home.prototype.age = '年龄'

</script>

message 作为响应式的数据,应该放在 data 中,但问题是 message 写在类中,为初始化后实例上的属性,就要想办法在初始化后拿到 message,放在 data 中。

age 直接写在原型上,值不是函数,也应该放在 data 中。

hello 写了访问器,作为计算属性,写在 computed 中;clickHandler作为方法,写在 methods 中;mounted 是生命周期函数,挂载原型上就可以,不需要动。这三个都是方法,定义在原型上,需要拿到原型对象,找到这三类方法,按照特性放在指定位置。

这就引发一个问题,怎么把这些定义的属性放在 Vue 需要解析的数据中,“上帝的归上帝,凯撒的归凯撒”。

最终处理成这样:

{
  data:{
    message: '新数据',
    age: '年龄'
  },
  methods:{
    clickHandler(){}
  },
  computed:{
    hello:{
      get(){
        return this.message + 'hello';
      }
    }
  },
  mounted(){}
}

最好是无入侵式的添加功能,开发者无感知,正常写业务代码,提供封装好功能来完成归类数据这件事。

装饰器模式,在不改变自身对象的基础上,动态增加额外的功能,这个模式的思路符合上述内容的要求。具体可参考一篇文章详细了解,装饰者模式和TypeScript装饰器

vue-class-component 的代码使用 ts 书写,如果对 ts 语法不熟悉,可以忽略定义的类型,直接看函数体内的逻辑,不影响阅读。或者直接看打包后,没有压缩的代码,也不多,大约200行左右。

本文分析的代码主要文件在:仓库地址

解读 Component 装饰器

先来看大致结构和如何使用:

function Component(options) {
    // options 是 function类型,是要装饰的类
  if (typeof options === 'function') {
    return componentFactory(options);
  }
  
  // 执行后,这个函数作为装饰器函数,接收要装饰的类
  // options 为传入的选项数据。
  return function (Component) {
    return componentFactory(Component, options);
  };
}
// 使用1
@Component
class Home Extend Vue {}

// 使用2
@Component({
  components:{}
  data:{newMessage: '增加的消息'},
  methods:{
    moveHandler(){}
  },
  computed:{
    reveserMessage(){
        return this.newMessage + '翻转'
    }
  }
  // ... vue中选项对象其他值
})
class Home Extend Vue {}

Component 作为装饰器函数,接受的 options 就是要装饰的类 Homejs 中类不过是一种语法糖,typeof Home 得到为 function 类型。

Component 函数作为工厂函数,执行并传入参数 options(为了称呼方便,后面把这个参数叫做 装饰器选项数据),工厂函数执行后,返回装饰器函数,同样是接受要装饰的类 Home

从代码中可以看出来,都调用了 componentFactory ,第一个参数为要装饰的类,第二参数可选,传入的话就是装饰器选项数据。

解读 componentFactory 函数

从名字上可以看出来,componentFactory 用来产生组件的工厂,经过一系列的执行后,返回新的组件函数。省略其他,先看关键代码 代码地址

function componentFactory(Component) {
  // 省略其他代码...
  
  // 参数为两个,说明第二个是传入的部分选项数据;
  var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
  // 得到继承的父类,不出意外为 Vue 
  var superProto = Object.getPrototypeOf(Component.prototype);
  // 如果原型链上确实有 Vue,则得到构造函数;不为 Vue,则直接使用 Vue;
  // 目的是为了找到 extend 函数。
  var Super = superProto instanceof Vue ? superProto.constructor : Vue;
  // 根据选项对象,新建一个组件的构造函数
  var Extended = Super.extend(options);
  // 返回新的构造函数
  return Extended;
}

验证了上面的猜测,调用了 Vue.extend 返回新的组件函数。但在返回之前,要处理原来组件上的属性,和原型上的方法。

归类原型上方法

首先对选项上的方法归类,方法归 methods;非方法归 data;有访问器归 computed

// 需要忽略的属性
const $internalHooks = [
  'data',
  'beforeCreate',
  'created',
  'beforeMount',
  'mounted',
  'beforeDestroy',
  'destroyed',
  'beforeUpdate',
  'updated',
  'activated',
  'deactivated',
  'render',
  'errorCaptured', // 2.5
  'serverPrefetch' // 2.6
]

function componentFactory(Component) {
  // 其他代码省略...
  // 拿到原型对象
  const proto = Component.prototype
  // 返回对象上所有自身属性,包括不可枚举的属性
  Object.getOwnPropertyNames(proto).forEach(function (key) {
    // 构造函数,不做处理
    if (key === 'constructor') {
      return
    }

    // 钩子函数之类的属性,直接赋值到 options对象上,不需要归类
    if ($internalHooks.indexOf(key) > -1) {
      options[key] = proto[key]
      return
    }
    // 拿到对应属性的描述对象,用这个方法能避免继续查找原型链上的属性
    const descriptor = Object.getOwnPropertyDescriptor(proto, key);
    // 如果此属性的值不为 undefined,说明有值
    if (descriptor.value !== void 0) {
      // methods
      // 如果为函数,则直接归为 methods
      if (typeof descriptor.value === 'function') {
        (options.methods || (options.methods = {}))[key] = descriptor.value
      } else {
        // 如果值不为函数,则归为data,这里采用 mixins,混合数据的方式来做
        (options.mixins || (options.mixins = [])).push({
          data (this: Vue) {
            return { [key]: descriptor.value }
          }
        })
      }
    } else if (descriptor.get || descriptor.set) {
     // value 为空,但是有 get或set的访问器,则归为computed
      (options.computed || (options.computed = {}))[key] = {
        get: descriptor.get,
        set: descriptor.set
      }
    }
  })
}

从上述代码可以看出来,拿到属性对应的描述对象,根据属性对应的值,进行类型判断,来决定归为哪一类。

值得注意的是这段代码,目的是把非函数的属性,混合在 data 中:

if(typeof descriptor.value === 'function'){/*省略*/}
else{// 处理原型上不是函数的情况
  (options.mixins || (options.mixins = [])).push({
    data (this: Vue) {
      return { [key]: descriptor.value }
    }
  })
}

一般写在类中的只有是函数才能放在原型上,但有别的方式可以把非函数的值添加到原型上:

// 第一种,直接给原型添加属性
Home.prototype.age = 18;

// 第二种,用属性装饰器
function ageDecorator(prototype, key){
  return {  // 装饰器返回描述对象,会在 prototype增加key这个属性
    enumerable: false,
    value: 18
  }
}
class Home extends Vue {
  @ageDecorator
  age: number = 18;
}

如果用了 ts 的属性装饰器,并返回描述对象,就会在 prototype 增加这个属性,所以在上面 componentFactory 源码中要处理这种情况,一般在项目中比较少见。

处理实例上的属性

写在类中的属性,不添加在原型上,只有通过得到实例后拿到这些值,可以沿着这个思路进行分析。

先看实例上属性的情况:

class Home {
  message: '新消息',
  clickHandler(){}
}
let home = new Home();
console.log(home);

// 打印实例,简化后:
{
  message: "新消息"
  __proto__:
    constructor: class Home
    clickHandler: ƒ clickHandler()
    __proto__: Object
}

componentFactory 中做了单独的处理:

function componentFactory(Component){
    // 省略其他代码
  ;(options.mixins || (options.mixins = [])).push({
    data () {
      return collectDataFromConstructor(this, Component)
    }
  })
}

这里依然使用混合 data 的方式,混合功能很强大,敲黑板记下来。mixins 会在初始化组件时,调用 data 对应的函数,得到要混合的数据,又调用了 collectDataFromConstructor,传入 this,为组件实例,跟平时写项目在 mounted 中使用的那个 this 一样,都为渲染组件的实例;第二参数为 Component,是原来装饰的类,上面例子中就是 Home 类。

分析 collectDataFromConstructor 函数

这个函数的目的是把原来装饰的类,初始之后,拿到实例上的属性组成对象返回。代码地址

来看代码:

// 用来收集被装饰类中定义的属性
// vm 为要渲染的组件实例
// Component 为原来要装饰的组件类

function collectDataFromConstructor(vm, Component) {
  // 先保存原有的 _init,目的是不执行 Vue上的 _init 做其他初始化动作
  var originalInit = Component.prototype._init;

  // 在被装饰的类的原型上手动增加 _init,在Vue实例化事内部会调用
  Component.prototype._init = function () {
    var _this = this;

    // 拿到渲染组件对象上的属性,包括不可枚举的属性,包含组件内定义的 $开头属性 和 _开头属性,还有自定义的一些方法
    var keys = Object.getOwnPropertyNames(vm); 

    // 如果渲染组件含有,props,但是并没有放在原组件实例上,则添加上
    if (vm.$options.props) {
      for (var key in vm.$options.props) {
        if (!vm.hasOwnProperty(key)) {
          keys.push(key);
        }
      }
    }
    // 把给原组件实例上 Vue 内置属性设置为不可遍历。
    keys.forEach(function (key) {
      if (key.charAt(0) !== '_') {
        Object.defineProperty(_this, key, {
          get: function get() {
            return vm[key];
          },
          set: function set(value) {
            vm[key] = value;
          },
          configurable: true
        });
      }
    });
  }; 
  
  // 手动初始化要包装的类,目的是拿到初始化后实例
  var data = new Component(); 
  // 重新还原回原来的 _init,防止一直引用原有的实例,造成内存泄漏
  Component.prototype._init = originalInit;
    
 // 重新定义对象
  var plainData = {};
 // Object.keys 拿到可被枚举的属性,添加到对象中
  Object.keys(data).forEach(function (key) {
    if (data[key] !== undefined) {
      plainData[key] = data[key];
    }
  });
  return plainData;
}

具体要做的话,通过 new Component() 得到被装饰类的实例,但要注意,Component 继承了 Vue 类,初始化后实例上有很多 Vue 内部添加上的属性,比如 &dollar;options&dollar;parent&dollar;attrs&dollar;listeners&dollar;data 等等,还有以 _ 开头的属性,_watcher_renderProxy 等等,还有我们需要的属性。这里只是简单举几个属性,你可以手动初始化,在控制台打印输出看一下。

_ 开头的属性,是内置方法,不可被枚举;以 &dollar; 开头的属性,也是内置方法,但是可被枚举。如果直接循环实例,会拿到以 &dollar; 开头的属性,这并不是我们需要的。

那怎么办呢?代码中给了答案,在初始化一系列组件内置的属性后,组件内部会调用 Component.prototype._init 方法,可通过改写这个方法,来处理属性为不可枚举。

最后通过 Object.keys() 得到能够被遍历的属性。

上面拐的弯比较多,难免看蒙了,根据核心意思,简化如下:

原来有个组件:

class Home {
  message: '新消息'
}

现在有个需要渲染的组件,要把上面定义在 Home 中的 message 写在现有组件的 data 中:

const App = Vue.extend({
  // 混合功能
  mixins:[{
    data(){
        // 初始化后拿到实例,就能拿到 message 属性
        let data = new Home(); 
        let plainData = {};
        Object.keys(data).forEach(function (key) {
          if (data[key] !== undefined) {
            plainData[key] = data[key];
          }
        });
        return plainData;
    }
  }],
  data(){
    return {
        other: '其他data'
    }
  }
})

new App().$mounted('#app');

简化后,是不是清晰很多,本质就是初始类得到实例,拿属性组成对象,混合到渲染的组件中。

小的优化点,简化代码:

// 保留原有的 _init 方法
var originalInit = Component.prototype._init;
Component.prototype._init = function(){
  // 其他代码省略
};
Component.prototype._init = originalInit;

这段代码,在改写的 _init 内部使用了外面的引用 vmComponent,就会一直在内存中,为防止内存泄漏,重新赋回原来的函数。

vue-property-decorator 中如何扩展装饰器

vue-property-decorator 依赖 vue-class-component 实现,主要用了内部提供的 createDecorator 方法。

如果你想增加更多装饰器,也可以通过调用 createDecorator 方法,原理很简单,就是向选项对象上增加所需数据。

执行 createDecorator 添加的装饰函数

vue-class-component 中提供了工具函数 createDecorator 允许添加其他额外的装饰函数,统一挂载在 Component.__decorators__ 上,并把 options 传过去,对 options 增加需要的属性,实际上会调用这些装饰函数,让这些函数有机会处理 options

function componentFactory(Component) {
 // 省略其他代码....
  var decorators = Component.__decorators__;
  if (decorators) {
    decorators.forEach(function (fn) {
      return fn(options);
    });
    delete Component.__decorators__;
  }
}

我们可以利用 createDecorator,扩展其他的装饰器,vue-property-decorator 内部就是利用这个函数扩展了 @Prop@Watch 等装饰器。

function createDecorator(factory) {
  return (target, key, index) => {
     // 是函数类型,则为装饰的类;
     // 否则,为原型,通过constructor拿到构造函数
      const Ctor = typeof target === 'function'
          ? target
          : target.constructor;
      if (!Ctor.__decorators__) {
          Ctor.__decorators__ = [];
      }
      // 当为参数装饰器时,index为number
      if (typeof index !== 'number') {
          index = undefined;
      }
      Ctor.__decorators__.push(options => factory(options, key, index));
  };
}s

从源码中可以看出来,createDecorator 调用后会返回一个函数,这个函数可以作为装饰器函数,接收的 target 如果是函数类型,说明作为类装饰器,target 就是被装饰的类;否则,得到的是原型,通过 constructor 拿到构造函数。

向要装饰的类上添加静态属性 decorators,存入一个函数,获得 options

现在来看 vue-property-decoratorwatch 装饰器的源码,代码地址

function Watch(path, options) {
    if (options === void 0) { options = {}; }
    return createDecorator(function (componentOptions, handler) {
        if (typeof componentOptions.watch !== 'object') {
            componentOptions.watch = Object.create(null);
        }
        var watch = componentOptions.watch;
        if (typeof watch[path] === 'object' && !Array.isArray(watch[path])) {
            watch[path] = [watch[path]];
        }
        else if (typeof watch[path] === 'undefined') {
            watch[path] = [];
        }
        watch[path].push({ handler: handler});
    });
}

传入 createDecorator 的回调函数,会接受两个参数,componentOptions 为一个对象,就是在上面 componentFactory 中调用 Component.__decorators__,传入的对象,目的是向这个对象添加或增加 watch 属性,给要装饰的类使用;handler 是函数名字;

这样使用:

@Component 
class Home extend Vue {
    message='新消息'
    
    @watch('message')
    messageHandler(){
        console.log('当message改变后,执行这里')
    }
}

经过 @watch 装饰器处理后,选项对象上会增加一段数据:

{
  watch: {
   message: 'messageHandler'
  },
  methods:{
    messageHandler(){
        console.log('当message改变后,执行这里')
    }
  }
}

以上便是 vue-property-decorator 增加装饰器的实现方式,对其他装饰器感兴趣,可以看仓库源码,做进一步了解,思路都大同小异。

以上如有偏差欢迎指正学习,谢谢。~~~~

github博客地址:https://github.com/WYseven/blog,欢迎star。

如果对你有帮助,请关注【前端技能解锁】:
qrcode_for_gh_d0af9f92df46_258.jpg


戎马
2.4k 声望346 粉丝

前端码农一枚,上班一族,爱文学一本。ส็็็็็็็็็็็็็็ ส้้้้้้้้้้้้้้้้้้้。