65.9K
CodeProject 正在变化。 阅读更多。
Home

在 Meteor 中重新启用 TypeScript,借助 ES7 装饰器

starIconstarIconstarIconstarIconstarIcon

5.00/5 (1投票)

2015 年 11 月 25 日

CPOL

6分钟阅读

viewsIcon

13933

downloadIcon

58

MeteorJs 的 TypeScript 装饰器集合。

引言

本文演示了如何通过使用 TypeScript 装饰器来改进在 MeteorJs 中使用 TypeScript 的方式。

背景

TypeScript 为 JavaScript 增加了出色的智能感知、静态类型以及许多其他有用的功能。我使用它已有多年,并取得了巨大成功。

然而,所有这些绝妙之处在 MeteorJs 中并非“开箱即用”。例如,考虑调用 Meteor 方法。您必须使用 Meteor.call 并传递方法的字符串标识符,而不是直接调用方法!显然,您在这里无法从 TypeScript 中受益。

动态参数列表和全局魔术字符串通常在 Meteor 中非常流行,同样还有将当前上下文或其他有用内容绑定到函数 this 变量。这些内容的使用非常广泛,以至于 TypeScript 的智能感知几乎从不起作用,其大部分优势都被浪费了。

我忍不住还要提到,在我习惯了 C# 的眼中,最终的代码,带着那些数不清的大括号,看起来相当杂乱无章。所以我必须做些什么。而且我做到了!:)

改造

我能够将我杂乱的 Meteor 代码改造成可读且灵活的类,具备完整的智能感知功能,并且能够直接调用方法,而不是使用像 Meteor.call 这样的包装器。

例如,我关于发布与帖子相关数据的服务器代码看起来是这样的

Meteor.publish('postsOfTopic', function(topicId: string, page: number) {
    return [
        Posts.find({ topicId: topicId },
                   { sort: { date: 1 }, skip: perPage*(page-1), limit: perPage }),
        Likes.find({ topicId: topicId })
    ]
});


它被改造成了这样

class PostsController
{
    @publish
    public static subscribeToPostsOfTopic (topicId: string, page: number)
    {
        return [
            Posts.find({ topicId: topicId }, 
                       { sort: { date: 1 }, skip: perPage*(page-1), limit: perPage }),
            Likes.find({ topicId: topicId })
        ];
    }

    // ... other methods ...
}

this.PostsController = PostsController;

正如您所见,我不再是 Meteor.publish、“魔术字符串”值和匿名函数的组合,而是可以使用类中的普通方法。其余的一切都隐藏在 @publish 装饰器之下。

使用此订阅的路由也发生了变化

曾是

Router.route('/forum/topics/:_id', function() {

    var topicId = this.params._id;
    var page = this.params.query.page || 1;

    Meteor.subscribe('postsOfTopic', topicId, page);
    Meteor.subscribe('postsOfTopic_count', topicId);

    this.render("posts", {
        data: function() {
            return {
                // ... skipped ...
            };
        }
    });
    
});

变成

class PostsRoutes
{
    @route("/forum/topics/:_id")
    public static showPostsOfTopic(routeInfo: RouteInfo)

        var topicId = routeInfo.params['_id'];
        var page = routeInfo.params.query['page'] || 1;

        PostsController.subscribeToPostsOfTopic(topicId, page);
        PostsController.subscribeToPostsOfTopic_count(topicId);

        routeInfo.render("posts", {

            data: function() {
                return {
                    // ... skipped ...
                };
            }
        });
    }

}

请注意,订阅现在是通过直接方法调用执行的。例如,这使得重命名它们变得容易,而无需担心更改硬编码的字符串值。当然,通过这种方式,我可以获得有关这些方法参数的有用提示。

另请注意,现在使用 routeInfo 参数而不是 this。因为我可以为参数定义其类型,以便它接收智能感知。

为了理解它是如何实现的,这里有一个关于 ES7 装饰器的非常简短的介绍

TypeScript 装饰器

TypeScript 装饰器是标记具有特殊角色的方法(例如 Meteor 方法或模板助手)的理想选择。

装饰器自从 TypeScript 1.5 起就被添加了,实际上它们是 ES7 提案的一部分。您可能也听说过 AngularJs 2 积极使用它们。它们也是 C# 中属性的直接模拟。

下面是 TypeScript 中装饰器的样子

class MyClass {

   @log
   public MyField: string;

}

@log 是装饰器。

装饰器的实现只是一个函数

function log(target, propertyKey, descriptor)
{
    console.log(target); // will log the MyClass object
    console.log(propertyKey); // will log "MyField"
    console.log(descriptor); // will log the descriptor of MyField property
    return descriptor;
}

该函数在创建装饰的类或方法时会自动执行。

return descriptor 部分特别有趣,因为它本质上允许包装或完全替换被装饰的方法。因此,装饰器不仅提供了一个在定义方法时执行某些代码的机会,还改变了该方法的行为。

有关参数的更多详细信息,请参阅 Object.defineProperty 方法的参考。此外,如果被装饰的目标不是属性,而是类或参数,参数可能会有所不同。

装饰器列表

以下是我目前实现的装饰器

  1. @publish - 替换 Meteor.publish
  2. @method - 替换 Meteor.methods
  3. @route - 替换 Router.route (Iron Router)
  4. @helper - 替换 Template.<name>.helpers
  5. @eventHandler - 替换 Template.<name>.events

现在让我们逐一介绍这些装饰器

@publish

publish = function(target: any, propertyKey: string, descriptor: TypedPropertyDescriptor<any>) {
    var originalMethod = descriptor.value;
    var publicationName = target.toString().slice(9,-5) + "." + propertyKey;
    
    if (Meteor.isServer)
    {
        Meteor.publish(publicationName, originalMethod);
    }
    descriptor.value = function(...args: any[]) {
        args.unshift(publicationName);
        return Meteor.subscribe.apply(target, args);
    };

    return descriptor;
}

那个 hacky 的片段

target.toString().slice(9,-5)

简单地生成当前类的名称。

所以 publicationName 将例如是“PostsController.subscribeToPostsOfTopic”。为方法名称添加类名很重要,因为 Meteor 的发布(以及方法)是全局的。

有了合适的名称,我们现在就可以使用 Meteor.publish 来发布我们的方法了

    if (Meteor.isServer)
    {
        Meteor.publish(publicationName, originalMethod);
    }

接下来,我们用我们的包装方法替换原始方法,这样每当调用它时,Meteor.subscribe 都会被调用,而不是直接调用方法

    descriptor.value = function(...args: any[]) {
        args.unshift(publicationName);
        return Meteor.subscribe.apply(target, args);
    };

args.unshift 部分将发布名称放在初始参数的前面,因为 Meteor API 需要,所以像这样的调用

PostsController.subscribeToPostsOfTopic(topicId, page);

在执行时将被转换为这样

Meteor.subscribe("PostsController.subscribeToPostsOfTopic", topicId, page);

apply 确保 this 上下文是 PostsController 类,而不是其他任何东西。

@method

method = function(target: any, propertyKey: string, descriptor: TypedPropertyDescriptor<any>) {
    var originalMethod = descriptor.value;
    var methodName = target.toString().slice(9,-5) + "." + propertyKey;

    if (Meteor.isServer)
    {
        var methodsObj: any = {};
        methodsObj[methodName] = originalMethod;
        Meteor.methods(methodsObj);
    }

    descriptor.value = function(...args: any[]) {
        args.unshift(methodName);
        return Meteor.call.apply(target, args);
    };

    return descriptor;
}

正如您所见,@method 装饰器与 @publish 非常相似,唯一的区别是我们应该将一个字典传递给 Meteor.methods 而不是单个参数。

@route

这个装饰器结构略有不同,因为它有参数。在这种情况下,装饰器实现函数就像一个基于提供的参数的装饰器工厂

route = function(url: string) {
    return (target: Object, propertyKey: string, descriptor: TypedPropertyDescriptor<Function>) => {
        Router.route(url, function() {
            descriptor.value.call(target, this);
        });
        return descriptor;
    };
}

装饰器本身非常简单。它使用 url 参数和一个调用我们原始方法的函数来调用 Iron Router 的 Router.route 方法。

希望您能清楚 target(例如 PostsRoutes 类)在原始方法中变成了 this,而 Router.route 中的 this 变成了第一个参数,我通常称之为 routeInfo

    @route("/forum/topics/:_id")
    public static showPostsOfTopic(routeInfo: RouteInfo): void

为了更好地实现智能感知,我在我的 ironrouter.d.ts 文件中添加了两个简单的接口

interface RouteParams {
    [key: string]: any,
    query: {
        [key: string]: string
    },
    hash: {
        [key: string]: string
    }
}

interface RouteInfo {
    render(templateName: string, options?: any): void;
    params: RouteParams;
}

@route 就到这里。

@helper

helper = function (templateName?:string)
{
    var templateNameParam = templateName;
    var noParams = arguments.length > 0 && typeof arguments[0] != 'string';
    if (noParams)
        templateNameParam = null;
    
    var helperDecorator = function (target: any, propertyKey: string, descriptor: TypedPropertyDescriptor<any>) {
        if (Meteor.isClient)
        {
            var helpersObj: any = {};
            if (!templateNameParam && target.constructor.name.endsWith("Template"))
            {
                templateNameParam = target.constructor.name.slice(0,-8);
                templateNameParam = templateNameParam.substr(0, 1).toLowerCase() + templateNameParam.substr(1); 
            }
            if (!templateNameParam)
                throw new Error("Please specify templateName for @helper decorator of " + propertyKey + " method!");
                
            helpersObj[propertyKey] = function(...args: any[]) {
                args.unshift(this);
                return descriptor.value.apply(target, args);
            };
            Template[templateNameParam].helpers(helpersObj);
        }
        return descriptor;
    };
    
    if (noParams)
        return helperDecorator.apply(this, arguments);
    else
        return helperDecorator;
    
}

@helper 稍微难理解一些。

您需要立即了解的一件事是,@helper 装饰器既可以带参数使用,也可以不带参数使用。因此,它实际上根据其参数实现了两种行为。

我就是这样区分的

var noParams = arguments.length > 0 && typeof arguments[0] != 'string';

如果 @helper 没有提供参数,它应该在类中以 Template 的名称声明

class PostTemplate
{
   
    @helper
    public userCanEdit(post: Post)
    {
        return Meteor.user() && (Roles.userIsInRole(Meteor.userId(), SiteRoles.admin) || Meteor.userId() == post.authorId);
    }

    // ... skipped ...

}

在这个例子中,您可以看到类名为 PostTemplate,所以装饰器会推断出模板名称为 "post"。请注意,首字母会自动小写

templateNameParam = templateNameParam.substr(0, 1).toLowerCase() + templateNameParam.substr(1); 

例如,如果您的类名为 PostFormTemplate,那么模板名称将被推断为 "postForm"

当然,模板名称对于调用 Meteor 的 Template.<name>.helpers 是必不可少的。

使用 @helper 的另一种方法是手动为其提供模板名称。当您有许多小型模板并且不想为每个模板创建一个类时,这非常方便

class SectionsPage
{
    
    @helper("section")
    public shorten(section: Section, text: string)
    {
        if (text == null)
            return "";
        return text.length < 30 ? text : text.substr(0, 30) + "..."; 
    }

    @helper("sectionsButtons")
    public editModeForButtons (context: any) 
    {
        return _editMode.get();
    }

    // ... skipped ...
}

使用此装饰器的另一个重要方面是,它也和 @route 一样,将常规的 this 变量弹出到一个参数中。因此,我们可以在助手方法中获得全功能的 TypeScript 智能感知,这是一个非常好的功能!:)

@eventHandler

eventHandler = function(selector: string, templateName?: string) {
    return (target: any, propertyKey: string, descriptor: TypedPropertyDescriptor<any>) => {
        if (Meteor.isClient)
        {
            var eventsObj: any = {};
            if (!templateName && target.constructor.name.endsWith("Template"))
            {
                templateName = target.constructor.name.slice(0,-8);
                templateName = templateName.substr(0, 1).toLowerCase() + templateName.substr(1); 
            }
            if (!templateName)
                throw new Error("Please specify templateName for @eventHandler decorator of " + propertyKey + " method!");

            eventsObj[selector] = function(...args: any[]) {
                args.unshift(this);
                return descriptor.value.apply(target, args);
            };
            Template[templateName].events(eventsObj);
        }
        return descriptor;
    };
}

@eventHandler 又与上面的其他装饰器非常相似。它可以接受一个或两个参数,第一个参数始终是选择器,例如 "submit form""click #button-ok",第二个参数是模板名称。与 @helper 类似,如果类名以“Template”结尾,它可以从类名推断出模板名称。

代码

请随时根据需要使用和修改代码!

下载 decorators.zip

您也可以使用右侧的“浏览”按钮在线浏览此文件。

结论

上述方法,至少对我来说,被证明是一种非常方便且灵活的方式,可以在进行 MeteorJs 开发时充分利用 TypeScript。它恢复了 TypeScript 的优势,实现了完整的智能感知,同时也保留了灵活性:您可以拥有任意数量的类,并根据自己的喜好组织助手和方法。

显然,这种方法也可以应用于其他 Meteor 和社区功能,以“TypeScript 化”它们。

如果您有更多关于如何使这些装饰器变得更好的想法或建议,请欢迎在评论区留言!:)

© . All rights reserved.