通过vue-property-decorator源码学习装饰器

4,485 阅读4分钟

设计模式中有一个结构型设计模式:装饰器模式,定义是在不改变原有逻辑的情况下对其进行包装拓展从而满足更复杂的需求。在Java中有@annotation用来为类或方法添加注解,例如@override用于检查子类是否正确重写父类方法。ES7和TypeScript同样也引入了装饰器,一种特殊类型的声明,它能够被附加到类声明,方法, 访问符,属性或参数上。

前言

在使用TS和Vue开发的过程中我们经常使用vue-property-decorator这个库,它封装了@Component、@Prop、@Watch、@Emit等常用装饰器,用于像原生ES class那样声明基于类的Vue组件,接来下我们就通过vue-property-decorator的源码来学习装饰器。

  • 装饰器语法至今还没有离开stage2,在TS中也是一个实验性的特性。

@Component

@Component装饰器其实是vue-class-component库提供的,首先来看它的定义,@Component是一个类装饰器,类装饰器接收类的构造函数作为入参。

// index.ts
function Component (options: ComponentOptions<Vue> | VueClass<Vue>): any {
  if (typeof options === 'function') {
    return componentFactory(options)
  }
  return function (Component: VueClass<Vue>) {
    return componentFactory(Component, options)
  }
}

@Component有一个入参options,是为了方便用户对组件类进行一些额外属性的声明,内部判断options是否是函数以区分不同的调用方式:

// 默认传入类构造函数
@Component
export default class HelloWorld extends Vue {}

// 传入options对象
@Component({name: 'HelloWorld'})
export default class HelloWorld extends Vue {}

接下来调用了工厂函数componentFactory,实际上componentFactory干了几件事:

  • 将类原型上的属性按照不同类型(data、methods、mixins、computed)添加到options中
  • 将mixins中的data属性依赖收集
  • 返回一个传入options的新构造器
function componentFactory(Component, options = {}) {
    options.name = options.name || Component._componentTag || Component.name;
    // prototype props.
    const proto = Component.prototype;
    // 按类型添加到options
    Object.getOwnPropertyNames(proto).forEach(function (key) {
        if (key === 'constructor') {
            return;
        }
        // 判断传入属性是否在白名单
        if ($internalHooks.indexOf(key) > -1) {
            options[key] = proto[key];
            return;
        }
        const descriptor = Object.getOwnPropertyDescriptor(proto, key);
        if (descriptor.value !== void 0) {
            // methods
            if (typeof descriptor.value === 'function') {
                (options.methods || (options.methods = {}))[key] = descriptor.value;
            }
            else {
                // typescript decorated data
                (options.mixins || (options.mixins = [])).push({
                    data() {
                        return { [key]: descriptor.value };
                    }
                });
            }
        }
        else if (descriptor.get || descriptor.set) {
            // computed properties
            (options.computed || (options.computed = {}))[key] = {
                get: descriptor.get,
                set: descriptor.set
            };
        }
    });
    // 依赖收集
    (options.mixins || (options.mixins = [])).push({
        data() {
            return collectDataFromConstructor(this, Component);
        }
    });
    // 将类中添加装饰器的方法取出来执行后删除__decorators__,填充__decorators__的逻辑下方介绍@Prop时有提及
    const decorators = Component.__decorators__;
    if (decorators) {
        decorators.forEach(fn => fn(options));
        delete Component.__decorators__;
    }
    // 找到父类并创建一个新的构造器
    const superProto = Object.getPrototypeOf(Component.prototype);
    const Super = superProto instanceof Vue
        ? superProto.constructor
        : Vue;
    const Extended = Super.extend(options);
    // 检测options中key值合法性
    forwardStaticMembers(Extended, Component, Super);
    // 元编程相关
    if (reflectionIsSupported()) {
        copyReflectionMetadata(Extended, Component);
    }
    return Extended;
}

从@Component中我们可以看到类装饰器可以通过对构造器的修改来为原有类做无感知的修改,装饰器是在编译时作用的,所以无法影响类的实例,但是可以通过修改类的原型来影响实例。

@Prop

我们通过@Prop来学习属性装饰器,@Prop是一个属性装饰器,实现为一个装饰器工厂,return的函数参数有两个,一个是类的构造函数或者原型对象,一个是装饰的成员名称。

function Prop(options) {
    // 保证options是一个对象
    if (options === void 0) { options = {}; }
    // 返回一个工厂函数,target对于静态成员来说是类的构造函数,对于实例成员是类的原型对象。key是成员的名字
    return function (target, key) {
        // 获取元数据
        applyMetadata(options, target, key);
        // 高阶函数,作用是设置组件的props
        createDecorator(function (componentOptions, k) {
            ;
            (componentOptions.props || (componentOptions.props = {}))[k] = options;
        })(target, key);
    };
}
// 判断是否支持反射API
var reflectMetadataIsSupported = typeof Reflect !== 'undefined' && typeof Reflect.getMetadata !== 'undefined';
function applyMetadata(options, target, key) {
    // 如果支持反射API,取出相应元数据
    if (reflectMetadataIsSupported) {
        if (!Array.isArray(options) &&
            typeof options !== 'function' &&
            typeof options.type === 'undefined') {
            options.type = Reflect.getMetadata('design:type', target, key);
        }
    }
}
// 高阶函数,将装饰器都push到@Component的__decorators__中,@Prop是将设置props的方法push进去
function createDecorator(factory) {
    return (target, key, index) => {
        const Ctor = typeof target === 'function'
            ? target
            : target.constructor;
        if (!Ctor.__decorators__) {
            Ctor.__decorators__ = [];
        }
        if (typeof index !== 'number') {
            index = undefined;
        }
        Ctor.__decorators__.push(options => factory(options, key, index));
    };
}

我看到这个createDecorator方法时震惊于包作者的机智,包中几乎所有的属性装饰器都是基于这个函数实现的,包括Inject、InjectReactive、Provide、ProvideReactive、Model、Prop、PropSync、Watch、Ref,这个高阶函数能将对属性装饰器的操作都Push到一个数组中,再由@Component取出执行并统一修改装饰的类实例,并且由于闭包的关系塞进@Component数组中的方法都拥有外部属性的访问权限,可以将不同的属性装饰器写在各自的方法中。(换我来写这一段大概就是push一个{type: 'Prop'}进去了,然后执行的时候做if判断,在大佬这里学到了策略模式的更高阶玩法,有用的知识增加了!)

@Emit

我们再通过@Emit来学习一下方法装饰器,方法装饰器作用在方法的属性描述符上,可以用来监视,修改或者替换方法定义,接收三个参数,前两个参数和@Prop一样,分别是构造函数(static方法)或原型对象(实例方法)、方法的名称,多了一个参数是参数的属性描述符(descriptor),这个属性描述符很关键,其中的value属性就是被装饰的方法最后真正要执行的方法。

// 用于将驼峰命名的方法名转变为减号连接
var hyphenateRE = /\B([A-Z])/g;
var hyphenate = function (str) { return str.replace(hyphenateRE, '-$1').toLowerCase(); };

function Emit(event) {
    return function (_target, key, descriptor) {
        key = hyphenate(key);
        // 保存原有函数
        var original = descriptor.value;
        descriptor.value = function emitter() {
            var _this = this;
            var args = [];
            // arguments 是触发这个emit方法的事件列表
            for (var _i = 0; _i < arguments.length; _i++) {
                args[_i] = arguments[_i];
            }
            // 执行vue的$emit方法,并传入参数
            var emit = function (returnValue) {
                if (returnValue !== undefined)
                    args.unshift(returnValue);
                _this.$emit.apply(_this, [event || key].concat(args));
            };
            // 执行原方法
            var returnValue = original.apply(this, args);
            // 如果原方法返回了promise,执行.then方法后emit,如果是正常返回值就直接emit
            if (isPromise(returnValue)) {
                returnValue.then(function (returnValue) {
                    emit(returnValue);
                });
            }
            else {
                emit(returnValue);
            }
            // 将原方法的返回值返回
            return returnValue;
        };
    };
}

@Emit的核心思路就是截胡函数的执行,在被装饰的函数被调用后将会进入方法装饰器,在装饰器中进行额外的emit操作,使用者就不再需要手动去调用this.$emit了。

方法装饰器在业务中使用场景比较多,例如我们可以将日志上报或者埋点处理等操作封装在方法装饰器中,再用方法装饰器去修饰相应的方法,这个方法就会得到功能上的增强,而不用改变方法本身的逻辑,装饰器和方法的逻辑解耦,大大增加了代码的可读性和灵活度,也可以少些一些重复的代码了。

访问器装饰器 & 参数装饰器

访问器装饰器和参数装饰器在vue-property-decorator中没有体现,不过我们也同样需要了解他们。

早在es5就已经引入了访问器的概念,可以为一个对象设置setter和getter。

class demo1 {
    private x = 1
    @Log()
    get getX() {
        return this.x
    }
}

function Log() {
    return function (target, propertyKey, descriptor) {
        console.log(descriptor)
    }
}

访问器装饰器和方法装饰器用法完全一样,这里就不过多赘述了。

参数装饰器,用来装饰函数的参数,接收三个参数(构造函数or原型对象、参数的名字、参数在函数参数列表中的索引),

class demo2 {
    Log(@required msg) {
        console.log(msg)
    }
}

function required(target, propertyKey, parameterIndex) {
    // do something
}

参数装饰器只能用来监视一个方法的参数是否被传入,参数装饰器的返回值会被忽略

总结

在项目中合理的使用装饰器能提高代码开发效率,促进代码解耦,提升代码的可读性,大家可以在TS项目中尝试使用,装饰器真香!

不过,装饰器仅提供类的构造器、属性、方法、参数的劫持,本身没有提供附加元数据的功能,如果要使用元数据我们需要使用反射(Reflect),反射提供在类及其属性、方法、入参上存储读取数据的能力,ES6提供反射API还不能够支持元编程,所以TypeScript在1.5+版本中使用了reflect-metadata库来提供元编程支持,不过装饰器元数据在TS中都是实验性功能中的实验性功能,将来可能会有破坏性更新,请慎用。有兴趣的同学可以参考vue-class-component库中的reflect.ts文件。

我是suhangdev,欢迎与我交流前端相关话题,如果文章对你有帮助,请点赞支持噢,谢谢!