使用 TypeScript 的 JavaScript UI 控件套件






3.20/5 (4投票s)
一个使用 TypeScript 编写的可用 JavaScript UI 控件套件。
引言
附件是我开发的一套 JavaScript UI 控件。每个控件的文档,包括如何使用它们,都可以在相应的 .ts 文件中找到。目前,有四个控件:列表框、复选框列表、日历和网格。所有这些控件都支持一些基本功能,例如弹出选项、滚动条,以及将选择项写入“输出”HTML 元素。此外,所有这些控件都可以在三大主流浏览器中运行:Internet Explorer 8 及以上版本、Firefox 和 Chrome。
这些控件的主要目标是通过让开发人员免受 HTML 复杂性和浏览器兼容性问题的困扰,来简化客户端开发。结果,这也成了我首次涉足 TypeScript 的尝试。完成项目后,我想,既然 TypeScript 如此新颖,我在此项目中的经验或许能帮助其他刚接触 TypeScript 的开发人员。
我同时包含了 TypeScript 代码以及编译后生成的相应 JavaScript 文件。
背景
几个月前,我开始完全用纯 JavaScript(即不使用任何框架或库,如 jQuery)开发这个项目。我正进行得顺风顺水,突然间我的世界就天翻地覆了。就在那时,我打开邮箱,发现微软刚刚发布了一种名为 TypeScript 的新“语言”。
无论你怎么称呼它,也无论你是否认为它是新的,我立刻意识到我的工作将变得更轻松了。
通常,当出现一种看起来很有前景的新语言时,我会戴上决策者的帽子,分析它是否值得我花时间去深入研究。影响这一决定的因素包括:它是否易于使用、动态性如何(当然,指的是动态运行时)、有哪些可用的开发工具(用于类型检查、调试等),以及它是否有可能流行起来并长久存在。幸运的是,TypeScript 满足所有这些要求,如下所述。
- 由于 TypeScript 的主要目标是简化 JavaScript 的应用程序级开发,而我已经了解 JavaScript,这足以让我相信 TypeScript 会更容易使用。
- JavaScript 本质上是一种动态语言,而且根据微软的说法,任何 JavaScript 代码都被视为 TypeScript 代码(即它可以与新的 TypeScript 结构一起编译),因此很明显,我可以根据需要让 TypeScript 代码具有足够的动态性。
- 作为微软的产品,很有可能会有必要的开发工具可用(尽管你最终可能要花点钱)。果然,在 TypeScript 公开发布的当天,我就可以下载一个用于 Visual Studio 的 TypeScript 插件,它提供了智能感知、代码导航、静态错误消息和重构功能。(当然,这要求你确实拥有 Visual Studio,而我正好有,且我主要关心的是自己,所以这个要求通过了。)
- 最后,或许也是最重要的,如果你有一个有远见的经理,问题就变成了语言的生命周期。也就是说,如果我决定在 43 岁退休,我的老板能找到另一个 TypeScript 开发者吗?虽然无法预测未来(而且我可能不知道自己在说什么),但很明显,JavaScript 在其已经非常受欢迎的基础上正变得越来越流行。考虑到 JavaScript 是 Windows 8 开发的标准语言之一,并且 JavaScript 开始被认为是服务端开发(例如 Node.js)的一种有价值的语言。
对我来说,这个决定既简单又直接。即使是我那讨厌微软的老板也很难反驳这个逻辑。
所以,现在我已经使用 TypeScript 开发了近两个月,发现它和我所希望的一样好。我想分享一些我的经验,以及我认为这种新语言最有益的方面。
重要的是要认识到,由于 TypeScript 只是 JavaScript 的一个“门面”,并且 TypeScript 的编译输出只是可运行的 JavaScript,因此这里描述的任何 TypeScript 功能都可以用纯 JavaScript 编写。然而,除非你是专家级的 JavaScript 程序员,否则用纯 JavaScript 编写这些功能中的大部分会困难得多。对于已经熟悉几乎任何主流语言(如 C#、Java 和 PHP)的开发者来说尤其如此,因为 TypeScript 拥有与这些语言相似的面向对象结构。这才是重点……让 JavaScript 编码更容易!
Using the Code
每个控件的文档,包括如何使用它们,都可以在相应的 .ts 文件中找到。这里是一个使用其中一个控件的例子,但它们的使用方式都类似。
var cbList = new Zenith.CheckBoxList('baseElement'); cbList.NumColumns = 2; cbList.ColumnSpace = 15; cbList.MaximumHeight = 100; cbList.PopUpControlId = 'testPopup'; cbList.PopUpPosition = 'right'; cbList.PopUpDirection = 'down'; cbList.OutputElementId = 'output1'; cbList.addZenithEventListener(Zenith.ZenithEvent.EventType.Selected, function (value, text, checked) { alert(text + ' ' + (checked ? 'selected' : 'unselected')); }); cbList.addZenithEventListener(Zenith.ZenithEvent.EventType.Close, function () { }); // There are multiple ways to add the data to this control. This is the simplest way: cbList.AddItem(1, "Blue"); cbList.AddItem(2, "Yellow"); cbList.AddItem(3, "Red"); cbList.AddItem(4, "Green"); cbList.AddItem(5, "Turqoise"); cbList.AddItem(6, "Orange"); cbList.AddItem(7, "Black"); cbList.AddItem(8, "White"); cbList.AddItem(9, "Aqua"); cbList.AddItem(10, "Gray"); cbList.AddItem(11, "Purple"); cbList.Build();
工作原理
在这里,我们将只分析其中一个控件,即 CheckBoxList
控件。其他每个控件的构建方式都类似。
所有控件都派生(继承)自一个名为 ControlBase
的基类,该基类处理了它们之间大部分的通用逻辑,例如弹出逻辑、添加滚动条、执行通用事件,以及派生类需要的一些“protected
”方法。
这是类定义的样子:
export class CheckBoxList extends Zenith.ControlBase
“Export
”基本上表示该类应该在封闭模块的外部可用,“Zenith.ControlBase
”引用了“Zenith
”模块中的 ControlBase
类。请注意,即使每个控件都在其自己的文件中,所有控件都位于“Zenith
”模块内。
每个控件类的构造函数都接受一个 HTML DIV
元素的 id,该元素被用作控件的“基础”元素,这意味着构成控件的所有 UI 元素都在这个 DIV
元素内部。每个构造函数都会调用基类的构造函数,在基类构造函数中,会从文档中检索该元素并将其分配给一个类属性,并围绕这个 DIV
元素创建一个边框。
以下是基类构造函数的实现:
constructor (baseDivElementId: string) { if (baseDivElementId.length <= 0) throw Error("The id of a 'div' HTML element must be passed to the Zenith control when creating."); this.BaseElement = document.getElementById(baseDivElementId); if (!this.BaseElement) throw Error("The id of the 'div' HTML element passed in is not valid."); if (!(this.BaseElement instanceof HTMLDivElement)) throw Error("The element associated with the Zenith control must be a div element."); this.BaseElement.style.borderColor = '#B6B8BA'; this.BaseElement.style.borderWidth = '1px'; this.BaseElement.style.borderStyle = 'solid'; }
每个控件还有一个 Build
方法,该方法应在控件被构造并且在创建的对象上设置了适当的属性之后调用(参见上面的示例)。Build
方法(可能与其他 private
方法一起)将实际创建并定位构成控件的所有元素。元素的布局是通过一个 HTML 表格元素来处理的;也就是说,“基础”元素(DIV
)下的“parent
”元素是一个 <table>
元素。下面是 CheckBoxList
控件的 Build
方法的实现:
public Build(): void { if (this.ItemList.Count() <= 0) throw new Error("The item list is empty."); this.Clear(); var table: HTMLTableElement = <HTMLTableElement>document.createElement('table'); this.BaseElement.appendChild(table); table.className = 'ZenithCheckBoxTable'; var tbody: HTMLElement = document.createElement('tbody'); table.appendChild(tbody); var trow: HTMLTableRowElement, tcell: HTMLTableCellElement; var colIndex: number = 0; for (var index = 0; index < this.ItemList.Count(); index++) { if (!trow || colIndex >= this.NumColumns) { trow = <HTMLTableRowElement>document.createElement('tr'); tbody.appendChild(trow); colIndex = 0; } tcell = <HTMLTableCellElement>document.createElement('td'); trow.appendChild(tcell); if (colIndex > 0) tcell.style.paddingLeft = this.ColumnSpace + "px"; this.addEventListener(tcell, 'click', (event) => { this.selectedEventHandler(event); }); var itemCheckbox: HTMLInputElement = <HTMLInputElement>document.createElement('input'); itemCheckbox.type = 'checkbox'; itemCheckbox.name = 'ZenithControlCheckBox'; itemCheckbox.value = this.ItemList.ElementAt(index).Value; itemCheckbox.id = 'chk_' + this.ItemList.ElementAt(index).Value; tcell.appendChild(itemCheckbox); var label:HTMLLabelElement = <HTMLLabelElement>document.createElement('label'); label.htmlFor = 'chk_' + this.ItemList.ElementAt(index).Value; label.className = 'ZenithCheckBoxLabel_Unselected'; label.textContent = this.ItemList.ElementAt(index).Text; label.innerHTML = this.ItemList.ElementAt(index).Text; tcell.appendChild(label); colIndex++; } this.ParentElement = table; if (this.IsPopup()) super.SetPopup(); super.Build(); }
请注意,在构建 UI 之后,我们会检查此控件是否应为弹出控件,如果是,则调用基类中的 SetPopup
方法。这两行代码需要包含在每个控件的 Build
方法中,以提供弹出功能。SetPopup
方法包含了处理弹出
功能所需的所有逻辑,例如相对于指定的“popup
”控件的位置,以及用于“打开
”(显示)和“关闭
”(隐藏)控件的事件处理。当关联的弹出
控件被“点击”时,控件会显示;在以下任何事件发生时,控件会关闭:鼠标移出控件客户区、用户在控件客户区外按下鼠标按钮,或者按下“ctrl”键。
事件处理是 TypeScript 的箭头函数结构真正发挥作用的地方。请看 SetPopup
方法中的以下代码:
this.addEventListener(this.BaseElement, 'mouseout', (event) => { var mouseEvent: MouseEvent = <MouseEvent>event; var targetElement: HTMLElement = <HTMLElement>mouseEvent.toElement; if (!targetElement) targetElement = <HTMLElement>document.elementFromPoint(mouseEvent.clientX, mouseEvent.clientY); if (targetElement) { // The onmouseout event will happen event when leaving an element inside the element // the event is tied to. while (targetElement && targetElement != this.BaseElement) targetElement = targetElement.parentElement; if (targetElement != this.BaseElement) this.Close(); } });
通过使用箭头函数作为 mouseout
事件的监听器,我们可以使用“this
”关键字调用这段代码所在类的 Close
方法,以隐藏该控件。
基类中 Build
方法的主要目的是将基础 DIV
元素的大小设置为父 TABLE
元素的大小,否则 DIV
元素的宽度将扩展到页面的客户区宽度。这就是为什么基类的 Build
方法需要在派生类的 Build
方法末尾调用的原因,以便在调整外部 DIV
元素的大小之前,UI 已经被构建好。
每个控件也都有自己的自定义事件和自定义事件处理。目前唯一支持的自定义事件
是“Selected
”和“Close
”,可以在 ZenithEvent
类中找到。以下是 ZenithEvent
类的完整实现:
export class ZenithEvent { public static EventType = { Selected: 1, Close: 2 }; public eventType: number; public listener: Function; constructor (eventType: number, listener: Function) { this.eventType = eventType; this.listener = listener; } }
EventType
对象字面量充当一种 enum
(枚举),这样事件的类型就可以通过预定义的
文本来识别,如下面的代码所示,这段代码用于为“cbList
” CheckBoxList
添加一个函数处理程序:
cbList.addZenithEventListener(Zenith.ZenithEvent.EventType.Selected, function (value, text, checked) { alert(text + ' ' + (checked ? 'selected' : 'unselected')); } );
“Selected
”自定义事件根据不同的控件支持不同组的监听器参数。例如,对于 CheckBoxList
控件,如上例所示,会向监听器函数传递三个参数:选中项的值、选中项的文本,以及该项是被选中还是取消选中。“Close
”事件对于所有控件都不会向其关联的监听器传递任何参数。
还有一些其他值得注意的好东西。
Calendar
控件使用了一个自定义的 DateHelper
类,该类可以在 Calendar
代码文件 ZenithCalendar.ts 中找到。这个小类提供了一些 JavaScript Date
类型“缺失”的功能,例如长、短格式的星期和月份名称,以及一个返回任何月份天数的函数。下面是它的全部代码。
class DateHelper { public static MonthNames: string[] = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; public static MonthShortNames: string[] = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; public static DayOfWeekNames: string[] = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; public static DayOfWeekShortNames: string[] = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; public static DaysInMonth(iMonth, iYear): number { return 32 - new Date(iYear, iMonth, 32).getDate(); } public static toShortDate(date: Date): string { return date.getFullYear().toString() + '-' + (date.getMonth() + 1).toString() + '-' + date.getDate().toString(); } public static toLongDate(date: Date): string { return MonthNames[date.getMonth()] + ' ' + date.getDate().toString() + ', ' + date.getFullYear().toString(); } public static toShortDisplayDate(date: Date): string { return (date.getMonth() + 1).toString() + '/' + date.getDate().toString() + '/' + date.getFullYear().toString(); } }
CheckBoxList
类使用了一个名为 List
的自定义类,该类可以在 ZenithList.ts 代码文件中找到。这是我今年早些时候用 JavaScript 创建的一个类,并将其移植到了 TypeScript。由于 CheckBoxList
控件并没有真正使用这个类中提供的大部分功能,我在这里就不详细介绍了,只想说它的意图是模仿 .NET 的 List
集合类,并内置了一些类似 LINQ 的功能。
关注点
以下主题不一定按任何顺序排列;但是,它们通常是按照我认为最重要的好处的顺序(从上到下)排列的,尽管这是一个部分主观的话题。此外,这显然不打算成为一个关于 TypeScript 的全面教程或参考。
命名空间
尽管“namespace
”这个词在 TypeScript 规范中很少被使用,但 TypeScript 中的“module
”结构基本上提供了这种能力。
示例
module Zenith
{
export class ListBox extends Zenith.ControlBase
{
public NumColumns: number = 1;
}
}
在这个例子中,类 ListBox
不属于全局命名空间,而是 Zenith 命名空间中的一个类型。因此,在该模块外部引用这个类时,需要在类型前加上“Zenith
”,如下所示:
var lstList = new Zenith.ListBox('baseElement');
类型转换
为了提供完整的类型检查和智能感知,一门语言必须有某种形式的类型转换。TypeScript 确实使用 <>
分隔符提供了这个功能,如下所示:
var table: HTMLTableElement = <HTMLTableElement>document.createElement('table');
现在,智能感知当然可以提供 HTML 表格的可用属性和函数集合。
引用其他文件
提供类型检查和智能感知所必需的语言的另一个方面是,需要一种方式来“包含”或“引用
”另一个包含你的代码所使用的类型、变量、方法等的文件。这是通过“reference
”声明来提供的。
///<reference path='ZenithControlBase.ts'/>
OOP
在这一点上,重申上面关于面向对象的免责声明可能是一个好主意:这里描述的任何 OOP 功能都可以用纯 JavaScript 编写。然而,正是在这里,大多数开发者(非 JavaScript 专家)会感到困惑,而且理由充分,因为 JavaScript 提供面向对象功能的方式与其他任何语言都如此不同。
示例
class ListBox extends Zenith.ControlBase
{
static x: string = 'Test';
public NumColumns: number = 1;
public ColumnSpace: number = 10;
private ItemList = new Zenith.List();
constructor (baseDivElementId: string)
{
super(baseDivElementId);
}
}
大多数熟悉 OOP 的开发者应该能够轻松理解这段代码及其中的 OOP 结构。“class
”关键字标识一个类,“extends
”提供继承,“constructor
”标识构造函数,“super
”引用基类,“static
”提供静态
类成员,而“public
”和“private
”关键字提供封装。
这里有几点值得注意:
- 只允许一个构造函数。
- 没有办法“保护”基类的成员。这是我真正怀念的一点,但我猜想一旦 ECMAScript 语言规范第 6 版被批准,这将是第一个实现的功能。
函数作用域
TypeScript 的一个非常方便的特性涉及函数作用域和“this
”关键字。作为类一部分的普通函数声明的行为符合预期,其中“this
”关键字引用类的对象实例。更出乎意料的是,TypeScript 提供了所谓的“箭头”函数,其中“this
”的含义被改变为引用封闭的脚本。你可能会问自己,这与在普通类函数中使用“this
”有何不同。当在类中使用回调函数时,答案就变得很清楚了;例如,当为事件声明一个回调函数时。请看这个来自类函数内部的例子。
在 JavaScript 中,第二个“this
”会引用事件被分配到的 HTML 元素,在本例中是“tcell
”,并且没有简单的方法来获取对对象实例的引用,而这在使用 OOP 时是一个更有价值的引用(事件对象本身就有一个对源 HTML 元素的引用)。
历史
- 初始版本上传 - 2012年11月26日
- 添加“工作原理”部分 - 2012年11月30日
- 新控件- 02/11/2013
我为这个套件添加了三个新控件:
- 日期选择器 - 允许用户使用月、日和年下拉列表选择日期
- 菜单 - 一个(目前仅限水平)具有无限层级的层级菜单
- 上下文菜单 - 一个通过鼠标右键点击启动的、具有无限层级的层级上下文菜单
两种菜单控件都使用相同的菜单类,默认为普通菜单,但你可以通过构造函数的第二个参数告诉它成为一个上下文菜单。
就像这个套件中的所有其他控件一样,这些新控件也继承自同一个基类,并且使用方式与所有其他控件类似(实例化、设置适当的属性,并调用 Build
方法)。