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





5.00/5 (1投票)
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 方法的参考。此外,如果被装饰的目标不是属性,而是类或参数,参数可能会有所不同。
装饰器列表
以下是我目前实现的装饰器
- @publish - 替换 Meteor.publish
- @method - 替换 Meteor.methods
- @route - 替换 Router.route (Iron Router)
- @helper - 替换 Template.<name>.helpers
- @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 没有提供参数,它应该在类中以
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”结尾,它可以从类名推断出模板名称。
代码
请随时根据需要使用和修改代码!
您也可以使用右侧的“浏览”按钮在线浏览此文件。
结论
上述方法,至少对我来说,被证明是一种非常方便且灵活的方式,可以在进行 MeteorJs 开发时充分利用 TypeScript。它恢复了 TypeScript 的优势,实现了完整的智能感知,同时也保留了灵活性:您可以拥有任意数量的类,并根据自己的喜好组织助手和方法。
显然,这种方法也可以应用于其他 Meteor 和社区功能,以“TypeScript 化”它们。
如果您有更多关于如何使这些装饰器变得更好的想法或建议,请欢迎在评论区留言!:)