sivagao / temp-articles

0 stars 0 forks source link

angular2-book-preview #4

Open sivagao opened 7 years ago

sivagao commented 7 years ago

前言

2016 年 9 月 15 日,Angular 2 横空出世。鉴于 Angular 1.x 的巨大成功,加上 Angular 2 自身超前而颠覆式的设计,使其市场关注度水涨船高。本书是一本帮助读者对 Angular 2 (后文如无需特殊区分,也称为 Angular ) 进行快速了解、深入熟悉到项目实战的书籍。本书主要分为快速入门、深入理念、实战开发这三大部分来进行总体介绍。

谁适合这本书

本书的主要读者是有 Angular 1.x 相关经验的开发者,有一定 JavaScript 开发能力的新人,有 Java、C# 等后端语言编程经验的人,想通过快速了解 Angular 2 掌握更多新鲜理念的资深工程师。

本书概述

本书主要分为三大部分,共十七个章节。

第一部分:从第一章到第四章,主要讲述整个前端发展史的演进;Angular 相关的历史、总体介绍与周边工具;快速熟悉 Angular 最核心的开发语言 TypeScript;最后以一个通讯录示例介绍如何搭建相关开发环境并快速上手 Angular。

第二部分:从第五章到第十二章,主要围绕第四章介绍的通讯录示例进行深入理解Angular 的相关内容,包括 Angular 的运行机理、整体架构介绍,组件与变化监测相关内容,模板与管道,指令的总体介绍,服务与响应式编程 RxJS,强大的依赖注入,灵活高可用的路由机制,团队开发中测试相关介绍。

第三部分:从第十三章到第十七章,主要以搭建一个问卷调查网站为目标,进行一些项目实战的介绍,包括项目背景介绍,开发环境搭建,整体技术架构分析,用户管理与问卷管理页面开发教程,项目回顾总结。

如何阅读此书

本书按照由浅入深的难度变化思路进行撰写。第一部分适合新接触 Angular 的人细致阅读,如有相关基础或比较熟悉 Angular 的同学可以跳过第一部分,直接学习第二部分深入理解或者第三部分实战开发。

插图方面统一了整体的绘图风格,以水墨中国风的形式表现出来,力求简洁表示,如遇部分难懂之处可配合上下文进行解读。

本书包含诸多代码段,这些分为两类情况,一类是可以在电脑端跟着一起敲打练习,并能看到运行效果的示例代码;另一类是辅助学习的代码段,以介绍概念知识点为主,力求减少不相关代码的干扰,通常只截取最核心的片段,并以伴有省略号的形式出现。

为了加强对知识点的理解,本书也加入了大量旁注,对内容进行相关补充。部分较为深入但不常用的知识点,将以外链旁注的形式体现出来。

本书起源

致谢

1. 前端风云

从上世纪 70 年代互联网的出现,富有创造力的人们便开始了各种有趣的尝试。站在悠长的前端演进史上,从被放大了的时间线上看,许多框架与工具们都是那么的渺小,而缩放到它们辉煌的那些年上,却又是那么浓重的一笔。

本章作为本书的开篇,主要介绍各种前端技术的背景与作用以及 JavaScript 的出现及其发展的几个重要时期,最后再讲述新时代的前端技术的理念及趋势。

1.1 故事的起点

在 1995 年某个著名的十天里,身在网景公司的 Brendan Eich 为 Netscape Navigator 2.0 创造出了 JavaScript(简称 JS,起初称为 LiveScript )。在最初的一段时间里,受限于当时网站的开发模式,JavaScript 能发挥价值的地方非常有限,一直被业界当成小玩具般的编程语言。后来微软 IE3.0 带着 JScript 的出现,此时几种不同版本的浏览器端脚本语言同时存在,这就促使了后续 JavaScript 语言标准化的建立。

1997 年 6 月,ECMA 的第 39 号技术专家委员会( Technical Committee 39,简称 TC39 )制订出了第一版的 ECMA-262 标准,它定义了 ECMAScript (简称 ES )这门全新的脚本语言。接着在 1999 年,带着多种新特性的 ECMAScript 3 与 IE5 正式发布了,同年末 HTML 4.01 也闪耀登场。在种种的新机遇下,前端领域逐渐发展了起来。

1.2 AJAX王者归来

2004 年 4 月 1 日,Google 公司发布了 Gmail,这是 Paul Buchheit 主导的团队花费近三年时间开发的大型网页应用,在愚人节的这一天带给了 Web 世界意义非凡的大变化。 而 90 年代中期设计的 Hotmail 和雅虎电邮,都是使用了原始的 HTML 语言来编写界面,服务器处理每一次请求都需要重新加载网页,这使得响应速度与用户体验都非常糟糕,特别是在网速缓慢的年代里。而在 Gmail 中,使用了与服务器高度互动的 JavaScript 脚本,实现了体验更良好的局部刷新,让用户体验更加接近常规软件。后来在 2005 年的一篇文章中将这些技术命名为 AJAX (Asynchronous Javascript And XML),它也成了现在 Web 开发的标准做法。

AJAX 的出现不仅仅是加载速度提升那么简单,而是让前端可以承担更多的工作,为前后端开发的解耦创造了可能。虽然 AJAX 是一些旧有技术的集合,但却以一个新的名称,披着荣光归来。随着 AJAX 的崛起与 Web2.0 的出现,终于迎来了前端开发史上的一轮发展高潮,人们越来越关注到前端这一领域,并开始纷纷为之添砖加瓦。

1.3 工具库的流行

在这高速发展的时期里,前端项目变得越来越复杂,把这阶段遇到的一些前端开发问题如浏览器兼容,操作 DOM 的复杂度等逐渐放大了出来。于是乎社区内部也涌现出了一批如 Dojo、Prototype、MooTools、jQuery 等代码库来对其进行各种补充修正,而其中以 John Resig 在 2006 年发布的 jQuery 尤为出彩。jQuery 以其巧妙的接口封装,简洁的链式写法和高效的选择器实现,再加上丰富的插件体系,不需要关注不同浏览器的接口差异问题,大大提升了前端开发的生产力。在一段时期里很多前端工程师的招聘要求里都提及到需要熟悉甚至精通 jQuery,可见其使用的广泛性与重要性。

伴随着各种 DOM 操作库与模板引擎的出现,再加上相应的 UI 组件库的普及,前端社区内也出现各类前端架构化的尝试与小范围的实践。不少公司的项目也由原先后端主导的模式转向富前端化,将更多的逻辑交由前端来实现,而后端仅提供更为底层的数据处理与部署运维。

1.4 百家争鸣

随着前端生态社区日渐蓬勃发展,大型前端架构的深入实践与工程化等新问题被不断提出来。而在这风起云涌的大时代背景下,旧有的 jQuery 时代技术早已有心却无力来应对这些新问题了。彼时,人们为了追求更快页面访问体验,提出过单页 Web 应用(single page application,SPA)的概念,在前端社区中也是不断出现各类架构概念的迁移与实践。

1979 年提出的 MVC(Model-View-Controller)架构在各类编程语言中都有相应实现,在 JavaScript 中也不例外。在 2010 年左右,一批实现了 MVC 架构的优秀框架如 Backbone、Batman、CanJS 等不断涌现。其中以 Jeremy Ashkenas 创建的 Backbone 最为出名,一度火遍大江南北,可以称之为实现 SPA 的利器。

而在 2005 年,微软提出了一个新的架构模式 - MVVM(Model-View-ViewModel),其最主要的特点是双向绑定技术,解决了 Model 层和 View 层的强耦合问题。在 JavaScript 中也有一批实现了这种架构的框架如 AngularJS、Knockout、Ember、Vue 等。在这众多框架中以诞生于 2009 年的 AngularJS 尤为出名,其自身定位为 MVW (Model-View-Whatever)模式,并以双向数据绑定技术,简洁易用的模板语法,强大的依赖注入功能吸引了众多拥趸。各大框架之间长年累月的角逐,也在用户的选择中逐渐分出了高低。在 AngularJS 正式发布后的一段时期里, 无疑就是当中的王者,这一用户量最大的前端框架,也成为了实现 SPA 的最佳选择。

AngularJS 诞生的 2009 年,在前端史上无疑是意义非凡的一年。

在这一年里,ECMAScript 5 发布了,这宣告了 ECMAScript 4 的最终“流产”。ECMAScript 4 的“流产”事件却也为后续的 ECMAScript 6 的定案做下了基础铺垫。同年,技术社区内发布了 一项名为 CommonJS 的规范,解决了 JavaScript 模块化的问题,而后也衍生出了 AMD (代表库 RequireJS)、CMD (代表库 Sea.js)等模块化规范,使得前端模块化越来越流行。也在同一年,Ryan Dahl 基于 CommonJS 规范和 Google V8 引擎创造出了 Node.js。它是服务端 JavaScript 的运行环境,可以让前端工程师更简单地进行后端开发。Node.js 的出现,也让大量前端自动化工具如 Grunt、Gulp、Webpack 等陆续被创造出来。同年底,CoffeeScript 这一 JavaScript 的转译语言出现了,它只保留 JavaScript 语言的精髓,提供大量的语法糖,跟进 ECMAScript 的部分新标准特性,让前端代码书写变得更为简洁,很大地提升了开发效率。与之类似的还有在 2012 年发布的 TypeScript,身为 JavaScript 的超集,更是把强类型特性带到了前端社区来,通过类型检查更容易发现问题。TypeScript 紧跟着 ECMAScript 标准的实现,吸引了大量粉丝,火爆度逐日递增。

1.5 走进前端新时代

2014 年 9 月,HTML5 发布了,标志着大量功能强大的接口特性被确定下来。而在发布之前,这其中很多特性已被大量地使用与实现。在这样的大背景下,激进而又爱好捣鼓新玩意的前端界也涌现出大量实用且具颠覆性的新东西:使用 Ionic、React Native、NativeScript 等技术,前端工程师就可以进行移动(iOS、Android)APP 开发;使用 nw.js、Electron,前端工程师就可以进行桌面(Windows、Mac、Linux)应用开发;使用 Node.js 技术,前端工程师就可以进行同构(Universal)服务器端应用开发。由此前端工程师的工作能力范畴被放大,市场价值就更增加了很多。

正当 MVVM 框架大行其道之时,2013年 Facebook 也推出 React.js 这一富有市场竞争力的杀手锏式的项目。不同于 MVVM 框架,React.js 只是一个视图层的 JavaScript 库,其数据更新机制来源自游戏开发领域的理念,采用了”整体全局刷新”的模式,但由于使用了自身的 Virtual DOM 技术,避免了昂贵的 DOM 操作开销,加上其高效的 DOM Diff 算法能精准地对发生变化的节点进行局部更新,使得整体性能非常优秀。

在构建网页程序的架构设计上,Facebook 提出了 Flux 的概念。不同于 MVC 架构,Flux 是一个包括了Action、Dispatcher、Store 和 View(React 组件)的单向数据流的架构(模式)。市场上有很多实现 Flux 架构的库,其中以 React Hot Reload 的作者写的 Redux 尤为出名,Redux 将自身定位为一个可以预测的状态容器,并且大量采用了新标准的语法,提升了开发效率。

React 社区吸收了很多函数式编程的理念,而函数式编程的一大特点是尽可能减少可变动的部分。过去有人提出 Om(用 ClojureScript 写的 React)在速度上比原生 JavaScript版本的 React 快了非常多,震惊了整个 React 社区。究其背后,其中一个很重要的原因是 ClojureScript 这门函数式编程语言使用了不可变数据类型 (Immutable Data)。它的优点在于节省了内存,并降低了可变数据带来的复杂度等等。受此启发,React 社区也冒出了 immutable.js,弥补了 JavaScript 这门弱类型语言对数据对象的先天性不足,也更好地提升了性能。

伴随着前端社区快速的迭代发展,在 2015 年 6 月 17 日,ECMAScript 6 正式发布了。此新标准从开始制定到最终发布历时 15 年,带来了大量新特性与语法糖,以及众多已有特性的强有力扩展。这是继 ECMAScript 3 之后,JavaScript 语言的又一次意义重大的革新,意味着前端界即将迎来一个崭新的时代。同年 TC39 决定以后每年将当前已经标准化的特性发布出来,并以年份进行版本命名,所以 ECMAScript 6 也被称为 ECMAScript 2015。在新标准未发布之前,前端社区内早已流行起一些 ECMAScript 标准的编译转换工具如 Babel(起初称为 6to5)和 Google 的 Traceur 等,这让开发者可直接在项目中使用未来的标准语法来编写代码,最终再通过这些编译工具转换为 ES5、ES3 的代码。其中做为后起之秀的 Babel 是由当时还是高中生身份的 Sebastian McKenzie 在 2014 年 9 月着手开发的,到如今以其更全的兼容性、更强的生态圈还有丰富的插件体系显得更为耀眼,并成为了当前的主流。

在其他前端框架的社区高速发展与新标准诞生的时期里,AngularJS 正背负着臃肿的架构、过时的概念和低效的性能问题等沉重的历史包袱,而在时间线的另一头里,又有新的故事发生 - Angular 2 正缓缓地走入我们的视线。

1.6 小结

通过本章的学习,我们了解到了 JavaScript 与其标准 ECMAScript 的发展历史,也了解到了前端社区发展历程的一些标志性事件及其影响。随着前端领域新技术的流行,学习成本变得越来越高,这同时也意味着能做的事情也越来越多了。新的机遇与挑战并存,只有扬帆起航,战胜困难,才能抵达顺利的彼岸。下一章将深入去了解 Angular 2 的历史与架构介绍、周边工具等内容,那么让我们开始这一趟 Angular 2 之旅吧。

2 Angular 简介

通过上一章的介绍,我们对近几年 Web 开发现状和趋势有了较为完整的认识。我们了解了很多概念,如 MVC、MVVM、Flux 和 Immutable Data 等, 这些概念也慢慢地融入到我们的开发实践中。在本章,我们会正式进入 Angular 的讲解。看看 Angular 受到哪些思维的影响,以及 Angular 本身如何引领了一代开发风潮。

我们将首先回顾 Angular 的历史,谈谈它怎么从个人项目 GetAngular 转型为 Google 内部流行的官方项目,接着通过 1.x 各个版本迭代引入各种特性,最后我们聊一聊 Angular 诞生到发布正式版的历程。此外,我们将对 Angular 的主要特点和概念进行简单讲解,便于读者在后续章节的详细论述前有个直观的全局人认识。

2.1 历史回顾

getAngular 起源

2009 年,Misko Hevery 和 Adam Abrons 在业余时间创造了 Angularjs 1.x。起初,它叫做 GetAngular,是个让 Web 设计师和前后端工程师来沟通的端到端设计开发工具。

随后,Misko Hevery 在 Google 内接手 Feedback 的开发,经过 6 个月将近 1.7 万行的代码量的功能迭代,代码库越来越大,维护开发变得举步维艰。Misko 找到了他的经理 Brad Green,打赌用 GetAngular 两周的时间重写该项目。最后 Misko 也仅花了三周时间,并且代码行数从原来的 1.7 万行代码精简到 1500 行。Brad 十分看好 GetAngular 项目,把它改名为 Angularjs ,在 Google 内组建了团队专职来开发和维护。

后来适逢 DoubleClick 被 Google 收购, Angularjs 被用来重写它的部分业务逻辑, 其效率之高令人称奇,至此 Angularjs 在公司内部一鸣惊人,Google 管理层觉得应该投入更多的人力和资源到 Angularjs 团队上,让他们集中精力开发 Angularjs 框架和周边工具,并且在公司内外做产品开发,进行大力地运营和推广。

1.x 的迭代之路

在进入开发快车道后,Angularjs 1.x 的版本迭代有条不紊的进行着。

2012 年 3 月,Angularjs 进入 1.0.0 发行候选版开发,前前后后的 12 个 rc 版本历时 3 个月,才在 6 月中旬发布了 1.0.0 版本(temporal-domination)。这个正式版本已经集成了 Angular 应用习以为常的诸多概念:

在 1.0 版本发布后,核心团队在接下来几个月采用了 1.1 和 1.2 同时开发的版本迭代方式,其中 1.1 开发新功能特性,引入不兼容的改造,待稳定运行发版后合入 1.2 版本。

2013 年 11 月,整合了 1.1 版本功能的 1.2 版本(timely-delivery)姗姗来迟,给我们带来的功能和调整如下:

同时,Angular 团队花了大量时间去优化,简化并提升了文档质量和官方网站的浏览体验。

2014 年的 10 月中旬,在 1.3 版本(superluminal-nudge)中,Angularjs 1.x 宣布了不再继续支持 IE8。同时提供了如下的新功能:

在同年的 4 月,微软宣布停止对 Windows XP 系统的支持,这也意味着微软不再支持主要运行于该系统上的 IE8 浏览器。

在 15 年 5 月底,Angular 团队终于发布了 1.4 版本(jaracimrman-existence)。该版本中有超过 400 条提交,优化了文档,修改了超过 100 个 bug,新增超 30 个新特性,其中包括:

在 16 年 2 月份,Angular 团队又发布了 Angularjs 1.5 版本,该版本的主题就是要和 Angular 做进一步整合,提供更接近于 Angular 应用的书写体验。如组件式的开发,定义组件指令,生命周期 Hook 等等。

初生的 Angular

在 14 年 3 月份,官方博客就有提及在为新的 Angular 做设计和开发,所有的设计文档都公布在 Google 云盘中。博客宣称该框架有以下特点:优先为移动应用设计、更快速的变化监测、ES6 原生模块化引入、采用状态、支持整合认证授权和缓存视图的新路由、有持久化支持离线使用的存储层等等,大家为之兴奋不已!

在 14 年 9 月下旬的 NG-Europe 大会上,Angular 首次亮相,与公众见面。它的接口和概念变化在很多 Angularjs 1.x 开发者中引起了不小的争议。这些变化包括:

这些大的改变抛弃了 Angularjs 1.x 这几年来的一些历史包袱,让经验老到的开发团队能够重新设计,结合老 Angular 的经验教训和外界引入的思潮(如参考 React 的 Virtual DOM 方案分离出的渲染来获得性能提升和平台扩展性,向 Web Components 的标准看齐等等)。不过这些激进的转变让 1.x 的开发者感到不习惯,很多人开玩笑说:” 现在用 Angularjs 1.x 这个注定很快要淘汰的框架开发业务代码...”。但长远来看,尽管当时被各种抨击,但这个破釜沉舟的决定还是非常正确的,它成就了 Angular 现在的高性能,高开发效率,丰富扩展能力的特点。

在 15 年的 4 月 30 号,Angular 团队宣布它从最初的 alpha 版本转到开发者预览版。

同时官方先后引入了 ngUpgrade 和 ngForward(官方已经不再维护),支持向现有的 Angularjs 1.x 应用中集成 Angular 的代码,为那些从 Angularjs 1.x 到 Angular 迁移的应用提供了解决方案。

快速发展的 Angular

通过 alpha 版本和开发者预览版,Angular 团队和 Google 内部多个大项目组的同事紧密配合,也在真实项目需求中检验着 Angular,包括了 AdWords 广告团队、GreenTea 内部客户关系管理软件的团队和 Google 光纤团队。事实上,2015 年底,Google 光纤产品上线,背后的代码库就是以 Angular 代码为基准的。

在 2015 年 12 月,Angular 开始进入 beta 版本。多个外部项目开始整合以适应新的 Angular。如 Ionic 框架推出 ionic2 计划,Telerik 加紧对 NativeScript 的整合,Rangle.io 着手开发 Batarangle 开发者工具等。同时 Angular.io 官网正式上线,添加了入门教程、开发者指南、参考手册等使用文档,开发者可以跟着文档一步步体验并使用 Angular。

16 年 5 月,Angular 发布 rc1 版本,正式进入发行候选阶段。 6 月中旬发布 rc2 版本,它的动画模块从底层支持多平台,合入了超过 100 项社区贡献的代码。一周后,rc3 版本发布,把新路由项目合入主代码库中(但路由模块仍然会保持独立的版本和发布周期)。7 月初发布的 rc4 版本,官方对暴露出来的公共接口进行清理,并且大幅提升了测试的灵活性和易用性。8 月中的 rc5 版本中,又引入了不少新功能,如:路由支持懒加载、组件和服务支持 Ahead-of-time (AoT) 编译、新的 NgModules 封装方式等。在随后 9 月里,官方先后发布了 rc6 和 rc7 版本,其中 rc6 为表单引入更多功能(如 validator 指令绑定等),同时增强了国际化支持;而 rc 7 主要集中在问题的修复上。

通过这七次 rc 版本的迭代,Angular 已经基本趋于稳定:

所以 9 月中旬,在 Google HQ 的见面会上,官方正式发布了 Angular 2 版本。

官方团队会继续在 Angular Material 2,Angular Universal 等周边项目上发力,同时提供更好用的动画模块以及小问题修复。

开发语言之选

优秀框架的开发离不开强大的编程语言,尤其像是 Angular 这样的大型框架。在 Angular 框架开发之路上,使用过 Dart,AtScript, TypeScript 这三种 JavaScript 相关的开发语言,它们都在一定程度上弥补了 JavaScript 的不足,提供更多的语法糖和新的功能特性。我们可以看到在严谨工程实现上,对语言的特性和功能的要求都是苛刻的。

Dart 是 Google 寄予厚望的用于替代 JavaScript 而开发的编程语言。在 Google 内部,它被用于把 Angularjs 1.x 重写来试用检验这门语言的适用性。这个重写项目叫做 AngularDart,它是 Hevery 在 14 年一直在集中精力实施的项目。非常有意思的是,根据 Google 团队说,AngularDart 项目效果出乎意料的好,因为这让核心团队能够接触和产生很多新的想法,譬如最早借鉴于 Dart 而引入的 Zone 等特性。在 Angular 项目中,核心团队从最早的编译到 Dart 方案,改为了目前以独立的 Dart 版代码仓库形式的方案(不满意之前的编译状态和 fix bug 速度)。

AtScript 基于 JavaScript,并且扩展了微软的 TypeScript,也是一门可以最终转译到 JavaScript 的脚本语言。它最早在 2014 年的 NG-Europe 大会上被 Angular 团队核心开发人员宣布为后续 2.0 版本的主要构建语言。 起初 AtScript 被设计运行在 TypeScript 之上,同时从 Dart 引入一些有用的新特性。

不过在 2015 年 3 月的盐湖城会议上,微软 TypeScript 和谷歌 Angular 开发团队一起宣布了会把 AtScript 中不少新的功能特性在 TypeScript 的 1.5 版本中发布,同时 Angular 将放弃 AtScript,而仅仅使用 TypeScript。这是影响业界的大事,强大框架和语言的珠联璧合,给 Web 前端开发带来了新的可能。

2.2 Angular 简述

在本小节我们会进一步走近 Angular,从七大核心概念看其背后的设计亮点,通过分析其从框架到平台的演进过程中,观察发展趋势。

Angular 核心概念

Angular 框架的关键理念有如下七个部分,它们是驱动 Angular 的核心。

图 2-1 Angular 的七个关键理念

在 Web 开发中,我们通过依赖全局状态 / 变量,和保证引入 JavaScript 文件顺序来正确加载我们的类库。比如说: $ 代表着 jQuery,当在引入 $.superAwesomeDatePicker 类库来实现日期选择控件前,需要确保 jQuery 已经正常载入。随着我们的程序越来越大,文件切分越来越细,就会需要一个成熟的模块系统(如之前的 AMD,CommonJS 等)来帮助我们管理依赖。在新的语言标准 ES6 中,我们有了 import 来导入在其它文件中定义的模块,且用 export 导出的诸如 jQuery 或 moment 这样的依赖到我们的业务代码模块中,后续的 TypeScript 章节中会对该语法进行更详细的讲解。还需要注意的是,TypeScript 的语言特性还给我们带来了 Class 类,Inherit 继承和注解(它为底层的 Angular 框架提供了元数据信息)等。

以组件为基础的架构模式是现在 Web 前端开发中的主流方式。不仅仅在 Angular 中,在类似的 React, Ember 或 Polymer 等框架中也是很常见的。这种开发方式就是构建一个个小的组织代码单元,每个代码单元职责定义清晰,并且可以在多个应用中被复用。举例来说:如想使用 Google 地图组件,就在页面引入 <google-map pointer="46.471089,11.332816"></google-map> 这样语义化的标签。

Angular 全面支持这样的开发方式,在 Angular 中,组件是第一等的公民。伴随组件而来的是组件树的概念。每个 Angular 的程序都有一个组件树,由应用组件或者叫顶层的根组件和许多子组件和兄弟组件组成。组件树是很重要的概念,后续章节还会继续讲解。它有很多作用,如它会展示出你的 UI 界面是怎么组成的,这种树形结构也体现了一个组件到另一个组件间的数据流动,Angular 也依赖于组件树做出合适的变化监测策略。

图 2-2 一个博客模块的组件树例子

变化监测是 Angular 在应用的数据变化后,用于决定哪个组件需要随之刷新的机制。

  • 模板和数据绑定

当使用组件标签的时候,我们通过 templatetemplateUrl 属性引入 HTML 来描述让 Angular 渲染显示的界面内容。 我们需要数据绑定机制来实现把数据映射到模板上或者从模板(如 input 控件)中取回数据。

在 Angular 中,如果说组件是用于处理界面和交互相关的,那么服务就是开发者用于书写和放置可重用的公共功能(如 log 处理,权限管理等)和复杂业务逻辑的地方。服务可以被共享从而被多个组件复用。在 Angular 中,一个服务就是一个简单的 ES6 的类。通常我们在组件中引用服务来处理数据和实现逻辑。我们通过对实现服务的类加上 @Injeactable 注解标注,同时把它注册到 Provider(可以在应用,根组件,或需要注入服务的上层组件中实施)

Angular 平台

Angular 的项目经理 Brad 说过 Angular 现在更像是一个平台而不是简单的类库或者单一的框架。它从最早的大而全的代码切分为多个模块化的部分,并在此之上构建其它好用的工具。

图 2-3 Angular 平台一览

框架核心包含:

其中,Zones 可以独立于 Angular 使用在其它地方,并且已经提交 TC39 考虑进入 ECMA 的标准。而渲染引擎也是平台独立的,从而可以方便实施在桌面软件和原生的移动客户端中。

在此之上还有不少其它外部工具库,类似于:

除了这些外,Angular 周边也有完善的工具体系:

当然为了开发强大的应用,Angular 在功能开发上也提供了不少辅助模块。如:

这些模块都是可以同时在 1.x 和 2.0 同时使用的,减少开发者的迁移成本。Angular 和 Angularjs 1.x 不是孤立的,通过 ngUpgrade 和 ngForward 两个模块能够从今天在 1.x 开发的应用仓库中立即使用上 2.0 的功能,面向未来编码。

Angular 特点

我们先后看了 Angular 核心概念和 Angular 平台提供的各式各样的功能,那么 Angular 相对于其它前端技术有什么特点呢?

它拥有超快的性能:

它支持完善流畅的开发体验。除了上面提到的 CLI 工程化的命令行工具、Augury 审查工具和 TypeScript 语言服务外, 也包括:

图 2-4 Angular CLI 工程化流程

它的社区和周边也强大多样。除了上面已经提到的 Material Design UI、Mobile Toolkit,还包括:

这就是你应该立即使用 Angular 的原因了!

小结

通过本章的学习,相信读者对 Angular 有了较为直观的认识,清楚了它历史发展的轨迹,也了解了新生的 Angular 框架逐步成长为颇具潜力的大平台的故事。我们使用 Angular 内核中提供的模块、组件、模板数据绑定、服务、依赖注入和注解等概念来实施应用开发,它的平台也提供了各种辅助周边、功能模块和开发工具等。这些特性最终形成了性能强劲快速、开发体验完善和社区周边强大的 Angular。

在下一章,我们会学习 TypeScript 语言,它是构建 Angular 框架的基石语言,也是官方推荐的开发语言。那么,我们继续吧!

3 TypeScript 介绍

通过上一节的介绍,相信读者已经对 Angular 的历史有了全局的认识,Angular 最终选择 TypeScript 作为其官方最主要的构建语言,对开发者来说,这意味着掌握 TypeScript 语言将更有利于高效地开发 Angualr 应用。本章将结合 Angular 的使用场景来介绍 TypeScript 这门语言,相信读完本章后读者应该具备可以用 TypeScript 开发 Angular 应用的能力了。

3.1 概述

3.1.1 介绍

TypeScript 是由 C# 语言之父 Anders Hejlsberg 主导开发的一门定位为 JavaScript 超集的编程语言。TypeScript 本质上是向 JavaScript 语言添加了可选的静态类型,它是基于类的面向对象编程语言,支持诸如类、继承、接口、命名空间等特性。关于 ES5、ES6、TypeScript 的关系,如下图所示:

Dialog

图 3-1 ES5、ES6 跟 TypeScript 关系

JavaScript 这种弱类型,简单自由(从另一种角度来说是随意散漫)的编写模式对开发者的技术水平要求较高,初学者跟资深开发者之间的代码质量可能差别很大,这不利于项目的维护。ES6 引入了变量增强、模板字符串、箭头函数、类、迭代器、生成器、模块和 Promises 等新特性,极大地增强 JavaScript 语言的开发能力。另一方面,2009 年开始设计的 TypeScript 语言,经历了几年的发展后,最终向 ECMAScript 靠拢,实现了其标准,并在此基础了上做了进一步增强,主要有类型校验、接口、装饰器等特性,这使得代码编写更规范化,本章后续将会介绍这些增强特性。

本章并不会刻意去突出 ES6 与 TypeScript 的异同,有 ES6 基础的读者学习成本会很低,甚至可以只关注接口、装饰器等部分内容即可。但 TypeScript 的核心是增强类型的处理,建议还是把所有知识点学习下,感受 TypeScript 带给我们的美妙编程体验。

3.1.2 安装 TypeScript

TypeScript 工具一般是通过 npm 进行安装,要查看 npm 是否已经安装,可以运行如下命令:

$ npm -v

这里我们将使用 TypeScript 2.0 版本,安装 TypeScript 命令如下:

$ npm install -g typescript

安装完成后 ,我们来编写第一个 TypeScript 程序,并保存到文件hello.ts 中,文件的代码如下所示:

console.log('Hello TypeScript!');

在浏览器中要运行 TypeScript 程序,必须先编译成浏览器能识别的 JavaScript 代码,我们可以通过 tsc 编译器来编译 TypeScript 文件,生成与之对应的 JavaScript 文件,编译过程如下:

$ tsc hello.ts

此时会在目录下面看到一个文件 hello.js,同时可以看到命令行的运行结果:

$ Hello TypeScript!

3.2 基本类型

TypeScript 提供了布尔值(boolean)、数字(number)、字符串(string)、数组(array)、元组(tuple)、枚举(enum)、任意值(any)、无类型值(void)、空值(null 和 undefined)和 never 这些基本类型。其中元组、枚举、无类型值和 never 是 TypeScript 有别于 ES6 的特有类型。

在 TypeScript 中声明变量,需要加上类型声明,如 boolean、number 或 string 等。通过静态类型约束,在编译时执行类型检查,这样可以避免一些低级的错误。下面将介绍这些基本类型。

3.2.1 布尔值

布尔值是最简单的数据类型,只有 true 和 false 两种值。下面定义了一个 boolean 类型的 flag 变量,并赋值为 true。由于 flag 被初始化为 boolean 类型,如果再赋值为非 boolean的其它类型值,编译时会抛出错误。

let flag: boolean = true;
flag = 1; // 报错,不能把 number 类型的值赋给 boolean 类型的变量。

3.2.2 数字

TypeScript 的数字都是浮点型,也支持 ES6 中的二进制和八进制字面量。

let decLiteral: number = 6;
let hexLiteral: number = 0xf00d;
let binaryLiteral: number = 0b1010;
let octalLiteral: number = 0o744;

3.2.3 字符串

跟 JavaScript 中的字符串一样,用单引号(')或双引号(“)来表示。另外,TypeScript 还支持模板字符串,可以定义多行文本和内嵌表达式,放到反引号(`)内,以 ${ expr } 的形式嵌入变量,在处理拼接字符串的时候很有用。

let name: string = `Angular`;
let years: number = 5;
let words: string = `你好,今年是 ${ name } 成立 ${ years + 1 } 周年`;

3.2.4 数组

TypeScript 数组的操作类似于 JavaScript 中数组的操作,TypeScript 建议开发者最好只为数组元素赋一种类型的值。TypeScript 有两种数组定义方式,如下所示:

let array: number[] = [1,2]; 
// 或者使用数组泛型
let array: Array<number> = [1,2];

3.2.5 元组

元组类型用来表示一个已知元素数量和类型的数组,各元素的类型不必相同。下面定义了一对值分别为 string 和 number 类型的元组,代码如下:

let x: [string, number];
x = ['Angular', 25]; // 运行正常
x = [10, 'Angular']; // 报错
console.log(x[0]); //  'Angular'

3.2.6 枚举

枚举(enum)是一个可被命名的整型常数的集合,枚举类型为集合成员赋予有意义的名称,增强可读性。

enum Color {Red, Green, Blue};
let c: Color = Color.Blue; // 输出:2

枚举默认下标是 0,可以手动修改默认下标值,示例如下:

enum Color {Red = 2, Blue, Green = 6};
let c: Color = Color.Blue; // 输出:3 

3.2.7 任意值

任意值(any)是 TypeScript 针对编程时类型不明确的变量使用的一种数据类型,它常用于以下三种情况:

  1. 变量的值会动态变化时,比如来自用户输入或第三方代码库,any 可以让这些变量跳过编译阶段的类型检查。
let x: any = 1; // number 类型
x = "I am a string"; // string类型
x = false; // boolean类型
  1. 改写现有代码时,any 允许在编译时可选择地包含或移除类型检查。
let x: any = 4;
x.ifItExists(); // 正确
x.toFixed(); // 正确
  1. 定义存储各种类型数据的数组时
let arrayList: any[] = [1, false, "fine"];
arrayList[1] = 100;

3.2.8 无类型值

void 表示没有任何类型。当一个函数没有返回值时,意味着返回值类型是 void。

function hello(): void {
  alert("hello world");
}

3.2.9 Null 和 Undefined

默认情况下,null 跟 undefined 是其它类型的子类型,可以赋值给其它类型如 number 等,此时赋值后的类型会变成 null 或 undefined,致力于类型校验的 TypeScript 设计者们显然不希望这种类型变化给开发者带来额外的困扰。在 TypeScript 中启用 严格的空校验(--strictNullChecks) 特性,就可以使得 null 跟 undefined 只能被赋值给 void 或本身对应的类型,例子如下:

// 启用 --strictNullChecks
let x: number;
x = 1;  // 运行正确
x = undefined;  // 运行错误
x = null;  // 运行错误

上面例子中 x 只能是 number 类型。如果一个类型可能出现 null 或者 undefined,可以用 | 来支持多种类型,如下面例子:

// 启用 --strictNullChecks
let x: number;
let y: number | undefined;
let z: number | null | undefined;
x = 1;  // 运行正确
y = 1;  // 运行正确
z = 1;  // 运行正确
x = undefined;  // 运行错误
y = undefined;  // 运行正确
z = undefined;  // 运行正确
x = null;  // 运行错误
y = null;  // 运行错误
z = null;  // 运行正确
x = y;  // 运行错误
x = z;  // 运行错误
y = x;  // 运行正确
y = z;  // 运行错误
z = x;  // 运行正确
z = y;  // 运行正确

上面例子中 y 允许 number 跟 undefined 类型的数据值, 而 z 还额外支持 null。TypeScript 官方建议在新开发的应用中,都启用 --strictNullChecks 特性,有利于开发更健壮的代码。

3.2.10 Never

never 是其它类型(包括 null 和 undefined)的子类型,代表从不会出现的值。这意味着声明为 never 类型的变量只能被 never 类型所赋值,在函数中它通常表现为抛出异常或无法执行到终止点(例如无限循环),如下面例子:

let x: never; 
let y: number;
x = 123; // 运行错误:数字类型不能转为never

// 运行正确:never 类型可以赋值给 never 类型
x = (() => { throw new Error('exception occur') })();

// 运行正确:never 类型可以赋值给 number 类型
y = (() => { throw new Error('exception occur') })();

// 返回 never 的函数必须抛出异常
function error(message: string): never {
  throw new Error(message);
}

// 返回 never 的函数必须有无法被执行到的终止点
function loop(): never {
  while (true) {
  }
}

3.3 声明命令

在 TypeScript 中,支持 let 和 const 的声明命令。let 与 var 相似,但可以避免以往开发中常见的一些问题,如变量的全局污染。const 能够声明一个常量,防止二次赋值。

3.3.1 let 声明

let 与 var 声明变量的写法类似,如:

let hello = "Hello!";

不同于 var,let 声明的变量只在块级作用域内有效,如:

function f(input: boolean) {
  let a = 100;
  if (input) {
  let b = a + 1; // 运行正确
    return b;
  }
  return b; // 错误: b 没有被定义
}

这里定义了 2 个变量 a 和 b,a 的作用域是在 f 函数体内,而 b 的作用域是在 if 语句块里。块级作用域还有一个问题,就是变量不能在它声明之前被读取或赋值,如下代码所示:

a++; // 在声明之前使用是不合法的
let a;

块级作用域变量的重复定义

在使用 var 声明里,不管声明几次,最后都只会得到最近一次声明的那个值,例如:

var x = 2;
console.log( x + 3 ) // 输出:5
var x= 3
console.log( x + 3 ) // 输出:6

上面的用法不会报错,let 声明对此做了限制,如下:

let x = 2;
let x = 3; // 不能在一个作用域里多次声明

注意下面两种情况的 let 声明的对比:

function f(x) {
  let x = 100; // 报错, x已经在函数参数处声明
}
function f(condition, x) {
  if (condition) {
    let x = 100;  // 运行正常
      return x;
    }
    return x;
}

f(false, 0); // return 0
f(true, 0);  // return 100

下面再看一个块级作用域变量的获取例子:

function funcCity() {
    let getCity;
    if (true) {
       let city = "Shenzhen";
       getCity = function() {
         return city;
       }
    }
    return getCity();
}

每次进入一个块级作用域,它就创建了一个变量的执行环境,就算作用域内代码已经执行完毕,这个环境与其捕获的变量依然存在。上例已经在 getCity 的环境里获取到了 city,所以就算 if 语句执行结束后仍然可以访问它。所以,当执行 funcCity() 的时候,将输出 “Shenzhen”。

3.3.2 const 声明

const 与 let 声明相似,它与 let 拥有相同的作用域规则,但 const 声明的是常量,不能重新赋值,否则将编译错误,请看下面的例子:

const CAT_LIVES_NUM = 9;
const kitty = {
  name: "Aurora",
  numLives: CAT_LIVES_NUM
}

// 错误
kitty = {
  name: "Danielle",
  numLives: CAT_LIVES_NUM
};

kitty.name = "Kitty";  // 正确
kitty.numLives--;  // 正确

3.3.3 解构

解构是 ES6 的一个重要特性,TypeScript 在 1.5 版本中也开始增加了对解构的支持。所谓解构,就是将声明的一组变量与相同结构的数组或者对象的元素数值一一对应,并将变量相对应元素进行赋值。解构可以帮助开发者非常容易地实现多返回值的场景,这样不仅写法简洁,也增强代码的可读性。

TypeScript 支持以下形式的解构:

数组解构

数组结构是最简单的解构类型,如下例所示:

let input = [1, 2];
let [first, second] = input;
console.log(first); // 相当于input[0]: 1
console.log(second); // 相当于input[1]: 2

也可作用于已声明的变量:

[first, second] = [second, first]; // 交换位置

或作用于函数参数:

function f([first, second]= [number, number]) {
  console.log(first);
  console.log(second);
}

f([1, 2]);

也可使用 “...name” 语法创建一个剩余变量列表,“...” 三个连续小点是展开操作符,用于创建可变长参数列表,使用起来非常方便,例子如下:

let [first, ...rest] = [1, 2, 3, 4];
console.log(first); // 输出:1
console.log(rest); // 输出:[ 2, 3, 4 ]

对象解构

对象解构有趣的地方是一些原本需要多行编写的代码,变得可以用一行完成,代码很简洁,可读性强,对象解构的例子如下:

var test = { x: 0, y: 10, width: 15, height: 20 };
var {x, y, width, height} = test;
console.log(x, y, width, height); // 输出:0,10,15,20

解构虽然很方便,但使用时还得多注意,特别是深层嵌套的场景,是比较容易出错的。

3.4 函数

不管什么编程语言,都少不了函数这个重要的概念,它用于定义一个特定的行为。TypeScript 为 JavaScript 函数添加了额外的功能,使函数变得更易用。

3.4.1 函数类型

TypeScript 提供了两种函数类型:命名函数和匿名函数。

命名函数

function max(x: number, y: number): number {
  return x > y ? x : y;
}

匿名函数

let myMax = function(x: number, y: number): number { return x > y ? x : y;};

在上例中,参数类型和返回值类型这两部分都是必须的。在调用时,只做参数类型和个数的匹配,不做参数名的校验。

3.4.2 可选参数和默认参数

JavaScript 里,被调函数的每个参数都是可选的。 TypeScript 却不一样,被调函数的每个参数都是必传的,在编译时,会检查函数每个参数是否传入了值。简而言之,传递给一个函数的参数个数必须和函数定义时的参数个数一致,例如:

function max(x: number, y: number): number {
  return x > y ? x : y;
}

let result1 =  max(2);  // 报错
let result2 =  max(2, 4, 7);  // 报错
let result3 =  max(2, 4);  //  运行正常 

但是经常会遇到根据实际需要来决定是否传入某个参数的情况,Typescript 提供了 “?”,即在参数名旁边加上 “?” 来使其变成可选参数,如:

function max(x: number, y?: number): number {
    if(y)
        return x > y ? x : y;
    else
        return x;
}

let result1 =  max(2); // 运行正常   
let result2 =  max(2, 4, 7);  // 报错
let result3 =  max(2, 4);  // 运行正常  

需要注意的是可选参数必须位于必要参数的后面

TypeScript 还支持初始化默认参数,如果函数的某个参数设置了默认值,当该函数被调用时,如果没给这个参数传值或者传的值为 undefined 时,此时这个参数的值就为设置的默认值。如代码所示:

function max(x: number, y = 4): number {
  return x > y ? x : y;
}

let result1 =  max(2); // 运行正常
let result2 =  max(2, undefined);  // 运行正常
let result3 =  max(2, 4, 7);  // 报错
let result4 =  max(2, 4);   // 运行正常   

带默认值的参数不必放在必要参数的后面,但如果默认值参数放到了必要参数的前面,用户必须显示的传入 undefined,例如:

function max(x=2, y: number): number {
  return x > y ? x:y;
}

let result1 =  max(2);      // 报错   
let result2 =  max( undefined, 4);  // 运行正常
let result3 =  max(2, 4, 7);  // 报错
let result4 =  max(2, 4);   // 运行正常

3.4.3 剩余参数

上面介绍了必要参数、默认参数和可选参数,它们的共同点是只能表示某一个参数,当需要同时操作多个参数,或者并不知道会有多少参数传递进来时,这就需要用到了 TypeScript 里的剩余参数。在 TypeScript 里,所有的可选参数都可以放到了一个变量里,如下代码所示:

function sum(x:number, ...restOfNumber:number[]): number {
  let result = x;
  for(let i = 0; i < restOfNumber.length; i++){
    result += restOfNumber[i];
  }
  return result;
}
let result = sum(1, 2, 3, 4, 5);

注意:剩余参数可以理解为个数不限的可选参数,即剩余参数包含的参数个数可以为零到多个。

3.4.4 重载

函数重载通过为同一个函数提供多个函数类型定义来达到实现多种功能的目的,TypeScript 支持函数的重载,示例如下:

function css(config: {});
function css(config: string, value: string);
function css(config: any, value?: any) {
    if (typeof config == 'string') {
        ...
    } else if (typeof config == 'object') {
        ...
    }
}

这个函数有三个重载,编译器会根据参数类型来判断该调用哪个函数。TypeScipt 重载通过查找重载列表来实现匹配,根据定义的优先顺序来依次匹配,所以在定义重载的时候,建议把最精确的定义放在最前面。

3.4.5 this 和箭头函数

JavaScript 的 this 是个重要的概念,学习 TypeScrip 有必要弄清楚 this 工作机制,这能帮助我们避免一些隐蔽的 bug。

let gift = {
  gifts: ["teddy bear", "spiderman", "dinosaur", "Thomas loco",
  'toy bricks', 'Transformers'],

  giftPicker: function() {
  return function() {
    let pickedNumber = Math.floor(Math.random() *6);

    return this.gifts[pickedNumber];
  }
  }
}

let pickGift = gift.giftPicker();
console.log("you get a : " + pickGift());

运行程序,发现对话框并没有弹出来,这是因为 giftPicker 返回的函数里的 this 被设置成了 window 而不是 gift 对象。因为这里没有对 this 进行动态绑定,因此这里的 this 就指向了 window。

TypeScript 提供的箭头函数( => )很好地解决了这个问题,它在函数创建的时候就指定了 this 值,而不是在函数调用的时候。

let gift = {
  gifts: ["teddy bear", "spiderman", "dinosaur", "Thomas loco",'toy bricks','Transformers'],

  giftPicker: function() {
  return () => {
    let pickedNumber = Math.floor(Math.random() *6);

    return this.gifts[pickedNumber];
  }
  }
}

let pickGift = gift.giftPicker();
console.log("you get a : " + pickGift());

3.5 类

传统的 JavaScript 程序使用函数和基于原型的继承来创建可重用的类,这对于习惯了面向对象方式编程的程序员来说不是很友好。而在 TypeScript 中可以支持使用基于类的面向对象方法。

3.5.1 类的说明

下面看一个定义类的例子:

class Car {
  engine: string;
  constructor(engine: string) {
    this.engine = engine;
  }
  drive(distanceInMeters: number = 0) {
  console.log( `A car runs ${distanceInMeters}m  powered by` + this.engine);
  }
}

上面声明一个 Car 类,这个类有三个类成员: 类属性 engine、构造函数和 drive 方法,其中类属性 engine,可通过 this.engine 访问。下面实例化一个 Car 的新对象,并执行构造函数初始化。

let car = new Car("petrol");

调用成员方法并输出结果:

car.drive(100); // 输出:A car runs 100m powered by petrol

3.5.2 继承与多态

封装、继承、多态是面向对象的三大特性。上面的例子中把汽车的行为写到一个类中,即所谓的封装。在 TypeScript 中,用 extends 关键字即可方便实现继承,例子如下:

class MotoCar extends Car {
  constructor(engine: string) { super(engine); }
}

class Jeep extends Car {
  constructor(engine: string) { super(engine); }
  drive(distanceInMeters:number = 100) { 
    console.log("Jeep...");
    return super.drive(distanceInMeters);
  }
}

let tesla = new MotoCar("electricity"); 
let landRover: Car = new Jeep("petrol"); // 多态

tesla.drive(); // 调用父类的 drive 方法
landRover.drive(200); // 调用子类的 drive 方法

从上面的例子可以看到,MotoCar 和 Jeep 是基类 Car 的子类,通过 extends 来继承父类,子类可以访问父类的属性和方法,子类也可以重写父类的方法。Jeep 的 drive 方法重写了 Car 的 drive 方法,这样 drive 方法在不同的类中具有不同的功能,这就是所说的多态。

即使 landRover 被声明为 Car 类型,它依然是子类 Jeep,landRover.drive(200) 调用的是 Jeep 里的重写方法。派生类构造函数必须调用 super(),它会执行基类的构造方法。

3.5.3 修饰符和参数属性

在类中的修饰符可以分为公共(public)、私有(private)和受保护(protected)的类型。

public 修饰符

在 TypeScript 里,每个成员默认为 public,所以上面的例子中,我们可以自由的访问类里定义的成员。给 Car 类加上 public 后,如下所示:

class Car {
  public engine: string;
  public constructor(engine: string) {
    this.engine = engine;
  }
  public drive(distanceInMeters: number = 0) {
    console.log( `A car runs ${distanceInMeters}m  powered by` + this.engine);
  }
 }

private 修饰符

当成员被标记成 private 时,就不能在类的外部访问它,如代码所示:

class Car {
  private engine: string;
    constructor(engine: string) {
      this.engine = engine;
    }
}

new Car("petrol").engine; // 报错: 'engine' is private;

ES6 并没有提供对私有属性的语法支持,但是可以通过闭包来实现私有属性。

protected 修饰符

protected 修饰符与 private 修饰符的行为很相似,但有一点不同,protected 成员在派生类中仍然可以访问,例如:

class Car {
  protected engine: string;
  constructor(engine: string) {
    this.engine = engine;
  }
  drive(distanceInMeters: number = 0) {
    console.log( `A car runs ${distanceInMeters}m  powered by`+ this.engine);
  }
}

class MotoCar extends Car {
  constructor(engine: string) { super(engine); }
  drive(distanceInMeters: number = 50) {
    super.drive(distanceInMeters);  
  }
}

let tesla = new MotoCar("electricity");
console.log(tesla.drive()); // 运行正常
console.log(tesla.engine); //  报错

注意,由于 engine 被声明为 protected,所以不能在外部访问它,但是仍然可以通过它的继承类 MotoCar 来访问。

参数属性

参数属性是通过给构造函数参数添加一个访问限定符(public、protected 和 private)来声明。参数属性可以方便地让我们在一个地方定义并初始化一个成员。使用参数属性对 Car 类进行改造,如下所示:

class Car {
  constructor(protected engine: string) {}
  drive(distanceInMeters: number = 0) {
    console.log(`A car runs ${distanceInMeters}m  powered by` + this.engine);
  }
}

在构造函数里通过 protected engine: string 来创建和初始化 engine 成员,从而把声明和赋值合并至一处。

3.5.4 静态属性

类的静态成员存在于类本身而不是类的实例上,类似在实例属性上使用 this. 来访问属性,我们使用 类名. 来访问静态属性。

class Grid {
  static origin = {x: 0, y: 0};
  calculateDistanceFromOrigin(point: {x: number; y: number;}) {
    let xDist = (point.x - Grid.origin.x);
    let yDist = (point.y - Grid.origin.y);
    return Math.sqrt(xDist * xDist + yDist * yDist) / this.scale;
  }
  constructor (public scale: number) { }
}

let grid1 = new Grid(1.0);
let grid2 = new Grid(5.0);

console.log(grid1.calculateDistanceFromOrigin({x: 10, y: 10}));
console.log(grid2.calculateDistanceFromOrigin({x: 10, y: 10}));

3.5.5 抽象类

TypeScript 有抽象类的概念,它是供其它类继承的基类,不能直接被实例化。不同于接口,抽象类必须包含一些抽象方法,同时也可以包含非抽象的成员。abstract 关键字用于定义抽象类和抽象方法。抽象类中的抽象方法不包含具体实现并且必须在派生类中实现,例子如下:

abstract class Person {
  abstract speak(): void; // 必须在派生类中实现
  walking(): void {
    console.log('Walking on the road');
  }
}

class Male extends Person {
  speak(): void {
    console.log('How are you?');
  }
}

let person: Person; // 创建一个抽象类引用
person = new Person(); // 报错:不能创建抽象类实例
person = new Male(); // 创建一个 `Male` 实例
person.speak();
person.walking();

在面向对象设计中,抽象类和接口是经常讨论的话题,TypeScript 中也一样,简单来说,接口更注重功能的设计,抽象类更注重结构内容的体现。

3.6 模块

ES6 引入了模块的概念,在 TypeScript 中也支持模块的使用。

3.6.1 介绍

模块是自声明的,两个模块之间的关系是通过在文件级别上使用 import 和 export 来建立的。TypeScript 与 ES6 一样,任何包含顶级 import 或者 export 的文件都被当成一个模块。

模块在其自身的作用域里执行,而不是在全局作用域里,这意味着定义在一个模块里的变量、函数和类等在模块外部是不可见的,除非明确地使用 export 导出它们。类似的,如果想使用其它模块导出的变量、函数、类和接口时,必须先通过 import 导入它们。

模块使用模块加载器去导入它的依赖,模块加载器在代码运行时去查找并加载模块间的所有依赖,在 Angular 中,常用的模块加载器有 SystemJS 和 Webpack。

3.6.2 导出

模块可以通过导出的方式来提供变量、函数、类、类型别名和接口给外部模块调用,导出的方式分为如下三种:

导出声明

任何声明都能够通过 export 关键字来导出。

export const COMPANY = "GF"; // 导出变量

export interface IdentityValidate { // 导出接口
  isGfStaff(s: string): boolean;
}

export class ErpIdentityValide implements IdentityValidate { // 导出类
  isGfStaff(erp: string) {
    return erpService.contains(erp); // 判断是否为内部员工
  }
}

导出语句

当我们需要对导出的模块进行重命名时,就用到了导出语句,上面的例子改写如下:

class ErpIdentityValide implements IdentityValidate { // 导出类
  isGfStaff(erp: string) {
    return erpService.contains(erp);
  }
}

export { ErpIdentityValide };
export { ErpIdentityValide as gfIdentityValide };

模块包装

有时候需要修改、扩展已有的模块,并导出给其它模块使用,这时可以使用模块包装来再次导出,如:

// 导出原先的验证器,但做了重命名
export { ErpIdentityValide as RegExpBasedZipCodeValidator } from "./ErpIdentityValide";

有时一个模块可以包裹多个模块,并把新的内容以一个新的模块导出,如:

export * from "./IdentityValidate"; 
export * from "./ErpIdentityValide";  

3.6.3 导入

导入与导出相对应,可以使用 import 来导入当前模块中依赖的外部模块。导入有如下几种方式:

导入一个模块

import { ErpIdentityValide } from "./ErpIdentityValide";

let erpValide = new ErpIdentityValide();

别名导入

import { ErpIdentityValide as ERP } from "./ErpIdentityValide";
let erpValidator = new ERP();

将整个模块导入到一个变量,并通过它来访问模块的导出部分

import * as validator from "./ErpIdentityValide";
let myValidate = new validator.ErpIdentityValide();

3.6.4 默认导出

模块可以用 default 关键字实现默认导出的功能,每个模块可以有一个默认导出。类和函数声明可以直接省略导出名来实现默认导出。默认导出有利于使用方减少调用的层数,省去一些冗余的模块前缀书写,接下来看看几类默认导出的例子:

默认导出类

// erpIdentityValide.ts
export default class ErpIdentityValide implements IdentityValidate {  
  isGfStaff(erp: string) {
    return erpService.contains(erp);
  }
}

// test.ts      
import validator from "./erpIdentityValide";
let erp = new validator();

默认导出函数

// nameServiceValidate.ts
export default function (s: string) {
  return nameService.contains(s);
}

// test.ts
import validate from "./nameServiceValidate";
let name = “zhangsan”;

// 使用导出函数
console.log(`"${name}" ${validate(name) ? " matches" : " does not match"}`);

默认导出值

// constantService.ts
export default "Angular";

// test.ts  
import name from "./constantService";
console.log(name); // "Angular"

3.6.5 模块结构设计

在模块化开发中,共同遵循一些设计原则有利于代码更好地被使用、维护,下面列出几点模块化设计的原则:

尽可能的在顶层导出

顶层导出可以降低调用方使用的难度,过多的 "." 操作使得开发者要记住过多的细节,所以尽量使用默认导出,顶层导出;

明确地列出导入的名字

在导入的时候尽可能明确指定导入的变量,这样只要接口不变,调用方式就可以不变,降低了导入跟导出模块的耦合度,做到面向接口编程;

使用命名空间模式导出,如下所示:

// MyLargeModule.ts
export class Dog { ... }
export class Cat { ... }
export class Tree { ... }
export class Flower { ... }
// Consumer.ts
import * as myLargeModule from "./MyLargeModule.ts";
let x = new myLargeModule.Dog();

使用重新导出进行扩展

我们可能经常需要去扩展一个模块的功能,推荐的方案是不要去改变原来的对象,而是导出一个新的实体来提供新的功能。

3.7 接口

3.7.1 接口概述

接口在面向对象设计中具有极其重要的作用,在 Gof 的 23 种设计模式中,基本上都可见到接口的身影。长期以来,接口模式一直是 JavaScript 这类弱类型语言的软肋,虽然有类似“鸭式辨型”等各种伪实现,并有诸如 《JavaScript设计模式》等书籍的问世,但在使用起来还是略为繁琐。TypeScript接口的使用类似于 Java,甚至还增加了更灵活的接口类型,包括属性、函数、可索引和类类型。

3.7.2 属性类型

在 TypeScript 中,使用 interface 关键字来定义接口。下面通过一个简单示例来了解属性接口,代码如下所示:

interface FullName {
  firstName: string;
  secondName: string;
}

function printLabel(name: FullName) {
  console.log(name.firstName + " " + name.secondName);
}
let myObj = {age: 10, firstName: “Jim”, secondName: “Raynor”};
printLabel(myObj);

上例中接口 FullName 包含两个属性:firstName 和 secondName,且都为 string 类型。这里有两点需要注意:

  1. 传给 printLabel 的对象只要 “形式上” 满足接口的要求即可,例如上例中对象 myObj 必须包含一个 firstName 属性和 secondName 属性,且类型都为 string;
  2. 接口类型检查器不会去检查属性的顺序,但要确保相应的属性存在且类型匹配。

可选属性

可选属性对可能存在的属性进行预定义,并兼容不传值的情况。带有可选属性的接口与普通接口的定义方式差不多,区别是在定义的可选属性名后面加一个 ? 符号。如下例所示:

interface FullName {
  firstName: string;
  secondName?: string;
}

function printLabel(name:FullName) {
  console.log(name.firstName + " " + name.secondName);
}

let myObj = {firstName: ”Jim”, secondName: “Raynor”};
printLabel(myObj);

3.7.3 函数类型

接口除了描述带有属性的普通对象外,也能描述函数类型。定义函数类型接口时,需要明确定义函数的参数列表和返回值类型,且参数列表的每个参数都要有参数名和类型,如下例所示:

interface encrypt {
  (val:string, salt:string):string
}

定义好函数类型接口 encrypt 之后,接下来将通过一个例子来展示如何使用函数类型接口,代码如下所示:

let md5:encrypt;
md5 = function(val:string, salt:string){
  console.log("origin value:" + val);
  let encryptValue = doMd5(val,salt); // doMd5只是个 mock 方法
  console.log("encrypt value:" + encryptValue);
  return encryptValue;
}
let pwd = md5("password","loveGf");

对于函数类型的接口要注意下面两点:

  1. 函数的参数名:使用时参数个数需与接口定义的参数相同,对应位置的数据类型需保持一致,参数名可以不一样;
  2. 函数返回值:函数的返回值类型与接口定义的返回值类型要一致。

    3.7.4 可索引类型

可索引类型接口包含一个索引签名和相应索引的返回类型,它表示通过特定的索引来得到指定类型的返回值,可索引类型接口的定义如下所示:

interface UserArray {
  [index: number]: string;
}

let users: UserArray;
users = ["张三", "李四"];

索引签名有两种数据类型,即 string 和 number,索引类型接口使用这两种类型的最终效果是一样的,即当使用 number 类型来索引时,JavaScript 最终也会将它转换成 string 类型后再去索引对象的。

3.7.5 类类型

类类型接口与 C#、Java 里接口类似,用来规范一个类的内容,示例如下所示:

interface Animal {
  name: string;
}

class Dog implements Animal {
  name: string;
  constructor(n: string) { }
}

可以在接口中描述一个方法,并在类里去具体实现它的功能,如同下面的 setName 方法一样:

interface Animal {
  name: string;
  setName(): string;
}

class Dog implements Animal {
  name: string;
  setName(n: string) {
    this.name = n;
  }
  constructor(n: string) { }
}

3.7.6 扩展接口

和类一样,接口也可以实现相互扩展,即能将成员从一个接口复制到另一个里面,这样可以更灵活地将接口拆分到可复用的模块里,示例代码如下:

interface Animal {
  eat(): void;
}

interface Person extends Animal {
  talk(): void;
}

class Programmer {
  coding():void {
    console.log('wow, TypeScript is the best language');
  }
}

class ITGirl extends Programmer implements Person{
  eat(){
    console.log('animal eat');
  }
  talk(){
    console.log('person talk');
  }
  coding():void {
    console.log('I am a girl, but i like coding.');
  }
}

let seky = new ITGirl(); // 通过组合集成类来实现接口扩展,可以更灵活复用模块
seky.coding();

3.8 装饰器

3.8.1 装饰器介绍

装饰器(Decorators) 是一种特殊类型的声明,它可以被附加到类声明、方法、属性、或参数上。 装饰器由@符号紧接一个函数名称,形如 @expression,expression 求值后必须为一个函数,在函数执行的时候装饰器的声明方法会被执行。正如名字所示,装饰器是用来给附着的主体进行装饰,添加额外的行为、校验等。

在 TypeScript 的源码中,可以看到官方提供了如下几种类型的装饰器:

// 方法装饰器
declare type MethodDecorator = <T>(target: Object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<T>) => TypedPropertyDescriptor<T> | void;

// 类装饰器
declare type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;

// 参数装饰器
declare type ParameterDecorator = (target: Object, propertyKey: string | symbol, parameterIndex: number) => void;

// 属性装饰器
declare type PropertyDecorator = (target: Object, propertyKey: string | symbol) => void;

如上所示,每种装饰器类型传入的参数不大相同,下面将分别介绍。

3.8.2 方法装饰器

方法装饰器是在声明在一个方法之前被声明的(紧贴着方法声明),它会被应用到方法的属性描述符上,可以用来监视、修改或者替换方法定义。方法装饰器的声明如下:

declare type MethodDecorator = <T>(target: Object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<T>) => TypedPropertyDescriptor<T> | void;

方法装饰器表达式会在运行时当作函数被调用,传入下列三个参数:

  1. target:类的原型对象
  2. propertyKey:方法的名字
  3. descriptor:成员属性描述

其中,descriptor 的类型为 TypedPropertyDescriptor,在 TypeScript 中定义如下:

interface TypedPropertyDescriptor<T> {
  enumerable?: boolean; // 是否可遍历
  configurable?: boolean; // 属性描述是否可改变或属性是否可删除
  writable?: boolean; // 是否可修改
  value?: T; // 属性的值
  get?: () => T; // 属性的访问器函数(getter)
  set?: (value: T) => void; // 属性的设置器函数(setter)
}

想了解更多关于 descriptor 可以到 MDN 查看更多 Object.defineProperty() 的介绍

下面是一个方法装饰器的例子:

class TestClass {
  @log
  testMethod(arg: string) { 
    return "logMsg: " + arg;
  }
}

下面是装饰器 @log 的实现:

function log(target: Object, propertyKey: string, descriptor: TypedPropertyDescriptor<any>) {
  let origin = descriptor.value;
  descriptor.value = function(...args: any[]) {
    console.log("args: " + JSON.stringify(args)); // 调用前
    let result = origin.apply(this, args);// 调用方法
    console.log("The result-" + result); // 调用后
    return result; // 返回结果
  };

  return descriptor;
}

测试代码如下:

new TestClass().testMethod("test method decorator");

输出如下:

args: ["test method decorator"]
The result-logMsg: test method decorator

当装饰器 @log 被调用时,它会打印 log 信息。

3.8.3 类装饰器

类装饰器是在声明一个类之前被声明的,它应用于类构造函数,可以用来监视、修改或替换类定义,在 TypeScript 中定义如下:

declare type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;

如上所示,类的构造函数作为其唯一的参数。类装饰器在运行时会被当作函数的形式来调用。

假如类装饰器返回了一个值,那么它会使用它提供的构造函数来替换类的声明。下面是使用类装饰器 (@Component) 的例子,应用到 Person 类:

@Component({
  selector: 'person',
  template: 'person.html'
})
class Person {
  constructor(
    public firstName:string,
    public secondName:string
  ){}
}

类装饰器Component的定义如下:

function Component(component) {
  return (target:any) => {
    return componentClass(target, component);
  }
}

// componentClass 的实现
function componentClass(target:any, component?:any):any {
  var original = target;
  function construct(constructor, args) { // 处理原型链
   let c:any = function () {
     return constructor.apply(this, args);
   };
   c.prototype = constructor.prototype;
   return new c;
 }

  let f:any = (...args) => { // 打印参数
    console.log("selector:" + component.selector);
    console.log("template:" + component.template);
    console.log(`Person: ${original.name}(${JSON.stringify(args)})`);
    return construct(original, args);
};

f.prototype = original.prototype;
  return f; // 返回构造函数
}

测试代码如下:

let p = new Person("GF", "Security");

结果输出如下:

selector:person
template:person.html
Person: Person(["GF","Security"])

如上,代码看起来有点繁琐,因为我们返回了一个新的构造函数,必须自己处理好原来的原型链。

3.8.4 参数装饰器

参数装饰器是在声明一个参数之前被声明的,它用于类构造函数或方法声明。参数装饰器在运行时会被当作函数的形式来调用,定义如下:

declare type ParameterDecorator = (target: Object, propertyKey: string | symbol, parameterIndex: number) => void;

如上所述,包含 3 个参数:

  1. target:对于静态成员来说是类的构造函数,对于实例成员是类的原型对象。
  2. propertyKey:参数名称。
  3. parameterIndex:参数在函数参数列表中的索引。

下面是参数装饰器的一个简单例子:

class userService {
  login(@inject name: string) {}
}

// @inject 装饰器的实现
function inject(target: any, propertyKey: string | symbol, parameterIndex: number) {
  console.log(target);   // userService prototype
  console.log(propertyKey);  // "login"
  console.log(parameterIndex); // 0
}

输出如下:

Object 
login
0

参数装饰器在 Angular 中广泛被使用,特别是结合 reflect-metadata 库来支持实验性的 metadata API,读者可到官网了解更多相关知识。

3.8.5 属性装饰器

属性装饰器的定义如下:

declare type PropertyDecorator = (target: Object, propertyKey: string | symbol) => void;

如上所述,包含2个参数:

  1. target:对于静态成员来说是类的构造函数,对于实例成员是类的原型对象
  2. propertyKey:属性名字

属性装饰器是用来修饰类的属性,声明和被调用方式跟其它装饰器类似,具体内容不展开细讲。

3.8.6 装饰器组合

TypeScript 支持多个装饰器同时应用到一个声明上,实现多个装饰器的复合使用,语法可以从左到右的书写如下例所示:

@decoratorA @decoratorB param

或从上到下的书写:

@decoratorA @decoratorB functionA

在 TypeScript 里,当多个装饰器应用在一个声明上时会进行如下步骤的操作:

  1. 从左到右(从上到下)依次执行装饰器函数,得到返回结果
  2. 返回结果会被当作函数,从左到右(从上到下)依次调用

下面是两个类装饰器复合应用的例子,注意看输出结果所显示的执行顺序,代码如下:

function Component(component) {
  console.log("selector:" + component.selector);
  console.log("template:" + component.template);
  console.log('component init');
  return (target: any) => {
    console.log('component call');
    return target;
  }
}

function Directive(directive) {
  console.log("directive init");
  return (target: any) => {
    console.log('directive call');
    return target;
  }
} 

@Component({
  selector: 'person',
  template: 'person.html'
})
@Directive()
class Person {
}

let p = new Person();

控制台输出如下:

selector:person
template:person.html
component init
directive init:
component call
directive call

Angular 框架在依赖注入,组件等部分中有多个复合装饰器应用的场景,读者在本书后续的学习中可以进一步掌握这部分知识。

装饰器是ES7的草案标准,在 Angular 中,装饰器主要场景之一是使用 元数据(Metadata) 的来定义组件。。因此,读者掌握了装饰器的原理后可以实现类似 Angular 装饰器的语法糖。

3.9 泛型

在实际开发中,我们定义的 API 不仅仅需要考虑功能的健全,还需要考虑到它的复用性,更多的时候需要支持不特定的数据类型,泛型(Generic)就是用来实现这样的效果。

比如我们有个最小堆算法,需要同时支持 number 和 string,可以通过把集合类型改为 any 来实现,但是这样就等于放弃了类型检查,其实我们希望的是返回的类型需要和参数类型一致,接下来通过代码来说明一下:

class MinHeap<T> {
  list: T[] = [];
  add(element: T): void {
    ...
  }

  min(): T {
    return this.list.length ? this.list[0] : null;
  }
}

var heap1 = new MinHeap<number>();
heap1.add(3);
heap1.add(5);
console.log(heap1.min());

var heap2 = new MinHeap<string>();
heap2.add('a');
heap2.add('c');
console.log(heap2.min());

上面的例子中分别声明一个适用于 number 类型和一个适用于 string 类型的最小堆实例,给 MinHeap 类增加了类型变量 T,用于帮助捕获用户输入的数据类型以便于后边的跟踪和使用。

泛型也支持函数,下面实现一个 zip 函数用于把两个数组压缩到一起,其中声明了两个泛型类型 T1T2,具体的代码示例如下:

function zip<T1, T2>(l1: T1[], l2: T2[]): [T1, T2][] {
  var len = Math.min(l1.length, l2.length);
  var ret = [];
  for (let i = 0; i < len; i++) {
    ret.push([l1[i], l2[i]]);
  }
  return ret;
}

console.log(zip<number, string>([1,2,3], ['Jim', 'Sam', 'Tom']));

3.10 TypeScript 周边

3.10.1 tsconfig.json

tsc 编译器有很多命令行参数,都写在命令行上会十分繁琐。tsconfig.json 文件正是用来解决这个问题的,它使得编译参数能在文件中维护。

当运行 tsc 时,编译器从当前目录向上搜索 tsconfig.json 文件来加载配置,类似于 package.json 文件的搜索方式。

我们可以从一个空的 tsconfig.json 文件开始配置。

{}

tsc 有合理的默认设置,具体的配置可到官网上查看详解介绍。下面是一个更为复杂的 Angular 环境用的 tsconfig.json 配置。

{
"compilerOptions": {
   "target": "es5",
   "module": "commonjs",
   "declaration": false,
   "noImplicitAny": false,
   "removeComments": true,
   "noLib": false,
   "emitDecoratorMetadata": true,
   "experimentalDecorators": true,
   "sourceMap": true
},
"exclude": [
   "node_modules",
   "typings/browser.d.ts",
   "typings/browser/**"
],
"compileOnSave": false
}

3.10.2 DefinitelyTyped

因为 TypeScript 是强类型语言,在使用第三方非 TypeScript 开发的库的时候,会需要 .d.ts 外部接口描述文件。

幸运的是很多这样的描述已经被开发了,并开源在 https://github.com/DefinitelyTyped/DefinitelyTyped 上,可以使用 typings 工具对这些描述文件进行管理,类似 npmnuget,输入简单的命令行即可方便地安装 .d.ts 文件。

首先安装 typings 工具:

$ npm install typings --global

然后安装 d.ts 文件:

$ typings install ds-jQuery --save

3.10.3 编码工具

优秀的编码工具(IDE、编辑器)可以提高开发者的编程效率,编码工具基于 TypeScript 类型的特点可以很方便的实现智能提示,查找引用,查找定义等功能,下面介绍几款优秀的编码工具,以飨读者:

提到 TypeScript,很容易跟另一个著名 JavaScript 的转译型语言 CoffeeScript 做对比,曾经的 CoffeeScript 也是风靡前端开发,随着 ES6 的崛起,CoffeeScript 已经有点过时,甚至成为那些使用 CoffeeScript 作为开发语言项目的累赘了,各种 CoffeeScript to ES6 的工具已经被频繁使用。

TypeScript 会不会成为下一个 CoffeeScript 呢?这也许是一直萦绕在 TypeScript 开发者心里的一个困扰,事实上,从微软对 TypeScript 的定位就看出来,TypeScript 拥抱 ECMAScript 标准并实现所有规范,这使得无论 JavaScript 的未来如何变化,开发者编写的 TypeScript 语言始终可以运行在 JavaScript 的环境里,不用担心兼容性问题,加上有大厂商微软做背书,可以预见 TypeScript 社区的繁荣。

小结

本章首先介绍了 TypeScript 的背景,接着介绍了 TypeScript 的主要特性,主要包括基本类型、函数、类、接口、装饰器、模块和泛型等内容,在本章的最后介绍了 TypeScript 涉及的其它知识点,包括编译、开发工具、Typings 和未来展望。

通过本章的学习我们基本上掌握了 TypeScript 的相关知识点,具备使用 TypeScript 开发 Angular 应用的能力,接下来,让我们开始快速入门 Angular 吧。

4 快速开始

通过前面的章节简介,相信大家对 TypeScript 与 Angular 已经有了个初步的了解。在接下来的内容中,首先会介绍 Angular 环境搭建需要准备一些工具环境,包括安装 Node.js、npm,接着介绍搭建 Angular 开发环境,包括项目所依赖的一些配置文件,Angular 所依赖的一些库等,当环境搭建完成后就可以在此基础上进行开发了。然后,在搭建的环境上通过编写一个简单的 Hello World 例子来开始第一个 Angular 应用程序的开发,并通过 Webpack 打包工具打包编译项目,用命令将整个项目运行起来。最后再进一步介绍通讯录例子的其它内容。

4.1 准备工作

接下来将开始一步一步地搭建一个简单的 Angular 开发环境。

安装 Node.js

在开始搭建环境前,还需要做一些准备工作,包括安装 Angular 所依赖的基础环境 Node.js。可以去官网( https://nodejs.org/en/download/ )下载安装,本例子使用的 Node.js 版本是 v5.11.0。安装完成后,可以在 cmd 窗口上或者终端上输入命令:

$ node -v

该命令是查看 Node.js 的版本,高版本的 Node.js 已经集成了 npm,查看 npm 的版本可以输入命令:

$ npm -v

需要确认 Node.js 版本为 v5.x.x 以上,npm 版本为 3.x.x 以上,否则在低版本中项目程序会运行出错。当可以看到 Node.js 的版本后,说明机器上的 Node.js 环境搭建成功,这样就可以继续往下搭建 Angular 开发环境了。

环境搭建

当 Node.js 安装完成后就可以正式开始 Angular 项目开发环境的搭建了,下面通过九个步骤来搭建一个最简单的 Hello World 例子,并使其能运行起来。

第一步:创建一个新的文件夹

整个项目文件都存放到这个新文件夹下。Angular 有一系列基础的依赖包,在项目运行起来前需要先把这些基础的依赖包下载到根目录文件夹下,方便项目的引入。而这些包是怎么管理的呢?通常情况下,我们创建一个 package.json 的文件放在根目录下,该文件描述了一个 npm 包的所有相关信息,包括作者、简介、包依赖、构建等信息,格式必须是严格的 JSON 格式。Angular 的依赖包就配置在这个 package.json 中,文件的配置内容如下代码所示:

{
  "name": "HelloWorld",
  "version": "1.0.0",
  "description": "A simple starter Angular project",
  "scripts": {
    "server": "webpack-dev-server --inline --colors --progress --display-error-details --display-cached --port 3000  --content-base src",
    "start": "npm run server"
  },
  "license": "MIT",
  "devDependencies": {
    "awesome-typescript-loader": "~0.16.2",
    "typescript": "~1.8.9",
    "typings": "^0.7.9",
    "webpack": "^1.12.9",
    "webpack-dev-server": "^1.14.0"
  },
  "dependencies": {
    "@angular/common": "2.0.0",
    "@angular/compiler": "2.0.0",
    "@angular/core": "2.0.0",
    "@angular/platform-browser": "2.0.0",
    "@angular/platform-browser-dynamic": "2.0.0",
    "@angular/upgrade": "2.0.0",
    "core-js": "^2.4.1",
    "rxjs": "5.0.0-beta.12",
    "zone.js": "^0.6.23",
    "bootstrap": "^3.3.6"
  }
}

看完上面 package.json 的内容配置,这里简单的介绍下一些字段的意义:

tsconfig.json 配置了 TypeScript 编译器的编译参数,来生成我们需要的格式。tsconfig.json 放在根目录下,配置如下:

{
  "compilerOptions": {
    "module": "commonjs",
    "target": "es5",
    "outDir": "dist",
    "rootDir": ".",
    "sourceMap": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true
  }
}

其中配置参数含义如下:

前面 tsconfig.json 的配置可以控制 TypeScript 如何编译,但编译器并不能识别一些原生的 JavaScript 的环境变量以及语法,需要 TypeScript 类型定义文件来解决这些兼容性问题。typings.json 文件放在根目录中,配置如下:

{
  "name": "quickstart",
  "version": false,
  "ambientDependencies": {
    "es6-collections": "registry:dt/es6-collections#0.5.1+20160316155526",
    "node": "registry:dt/node#4.0.0+20160412142033"
  }
}

其中配置参数含义如下:

到这一步,Angular 项目的基本配置文件搭建完成,接下来创建一个 src 文件夹,这个文件夹将存放项目的业务代码文件。然后创建模板文件 seed-app.html,最终当项目运行起来后,浏览器上会显示出该模板内容: Hello World, 该模板文件的代码内容如下:

<h3>
  Hello World
</h3>
第五步:创建 seed-app.ts 文件

在 src 目录下创建 seed-app.ts 文件:

import {Component} from '@angular/core';

@Component({
  selector: 'seed-app',
  templateUrl: 'seed-app.html'
})
export class SeedApp {
}

在这个文件内容中,首先通过 import 从 Angular 的基础包 core 中引入组件(Component),通过 @Component 装饰器来告诉 Angular 使用哪个组件,以及怎样创建这个组件。在组件中可以定义该组件的 DOM 元素名称,如 selector: 'seed-app',也可以给组件引入所需要的模板,如 templateUrl: 'seed-app.html'。最后,定义一个组件类并对外输出该类,这样在其他的文件中就可以通过这个类名引用该组件。关于组件的具体内容可以在后续章节中深入了解学习。

第六步:创建 app-module.ts 文件

在 Angular 应用中需要有模块来组织一些功能紧密相关的代码块,每个应用至少有一个模块,习惯上把它叫做 AppModule。在 src 目录下创建一个 app-module.ts 文件来定义 AppModule,代码如下:

import {NgModule} from '@angular/core'
import {SeedApp} from "./seed-app";
import {BrowserModule} from "@angular/platform-browser";

@NgModule({
  declarations: [SeedApp],
  imports: [BrowserModule],
  bootstrap: [SeedApp]
})
export class AppModule {
}

其中,

在 Angular 项目中,都有一个自己的入口文件,通过这个文件来串联起整个项目。在 src 目录下,创建一个这样的入口文件 app.ts,其代码如下:

import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
import {AppModule} from './app-module';

platformBrowserDynamic().bootstrapModule(AppModule)
  .catch(err => console.error(err));

app.ts 文件引入了两样东西来启动本应用:

  1. Angular 的浏览器 platformBrowserDynamic 函数
  2. 应用模块 AppModule

然后调用 platformBrowserDynamic().bootstrapModule 函数,并且把 AppModule 传进去。

第八步:创建 vendor.ts 文件

从上面的这些文件中,可以看出每个文件都有依赖一些特定的包,为了能使每个文件引入的模块生效,我们需要引入 Angular 的基础包,这样才能驱动整个项目的运行。创建一个 vendor.ts 文件放在 src 目录下,它里面引入整个项目所依赖的 Angular 基础包,代码如下:

import '@angular/platform-browser-dynamic';
import '@angular/platform-browser';
import '@angular/core';
第九步:创建 index.html 宿主页面

在 src 目录下创建 index.html 宿主页面,代码如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset=UTF-8>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Angular Seed</title>
    <base href="/">
  </head>
  <body>
    <seed-app>
      Loading...
    </seed-app>
    <script src="vendor.ts"></script>
    <script src="app.js"></script>
  </body>
</html>

其中的 seed-app 标签就是我们在根组件 SeedApp 中定义的 selector。到这里,整个 Hello World 项目基本搭建完成,但是怎样将这个项目运行起来呢?首先需要有打包工具,将项目打包编译。我们采用 Webpack 来完成打包编译的工作,当然,还可以使用其他的工具,例如 Gulp,Grunt,fis3 等。在之前的 package.json 文件中有这样一段配置:

"devDependencies": {
   ...
  "webpack": "^1.12.9",
  "webpack-dev-server": "^1.14.0"
}

其中,webpack是打包工具,webpack-dev-server 是一个小型的服务器,这样开发时项目就可以在这个服务器上运行。Webpack 这个打包工具也有自己的配置文件,在根目录下创建一个 webpack.config.js 文件,配置如下:

var webpack = require('webpack');
var path = require('path');
module.exports = {
  entry: {
    'vendor': './src/vendor.ts'
    'app': './src/app.ts',
  },

  output: {
    path: './dist',
    filename: '[name].js'
  },

  resolve: {
    root: [ path.join(__dirname, 'src') ],
    extensions: ['', '.ts', '.js']
  },

  module: {
    loaders: [
      //.ts files for TypeScript
      { test: /\.ts$/, loader: 'awesome-typescript-loader' }
    ]
  }
};

其中配置参数含义如下:

最后,当项目搭建完成后,目录结构如下:

├─package.json
├─tsconfig.json
├─typings.json
├─webpack.config.js
└─src
   │  app-module.ts
   │  app.ts
   │  index.html
   │  seed-app.html
   │  seed-app.ts
   │  vendor.ts

到此,Hello World 项目搭建完成。接下来让我们运行这个项目看看最终的效果,运行步骤如下:

  1. 运行命令 npm install,在对应项目的根目录 cmd 窗口或终端上运行即可,npm 会根据 package.json 配置文件,安装所有依赖的包,在安装的过程中,会自动在根目录上创建一个 node_modules 文件夹,项目依赖的包都安装在其中;
  2. 运行 npm start 命令启动服务;
  3. 在浏览器中输入localhost:3000(默认端口号为3000),最终看到的效果,如下图 所示:

05

图 4-1 Hello World

4.2 通讯录背景介绍

通过上面搭建的 Hello World 项目例子,相信大家对 Angular 有了进一步的认知,接下来我们将使用 Angular 搭建一个简单的通讯录例子 ,并实现一些功能如获取和显示好友名单、编辑好友、添加好友、收藏好友等。

读者通过预览图可以了解整个 app 的情况,首先展示的是好友列表页,如下图所示:

01

图 4-2 好友列表

好友列表页会显示所有的好友,在每个好友信息中,会显示出好友的头像,姓名与电话号码。点击每个好友,都会跳转到详情页面,显示好友更详细的信息。在页面的右上角,有个添加的按钮,点击会跳转到添加好友的页面。在页面的底部,有个导航控件,可以实现在好友列表页与好友收藏页间的互相切换。

好友收藏页面,如下图所示:

02

图 4-3 好友收藏

收藏的好友都会通过列表的形式显示在该页面,并且点击每个好友,都会跳转到好友详情页面。

好友详情页面,如下图所示:

03

图 4-4 好友详情

在好友详情页面,最上面会显示好友的头像,姓名和一个星型的收藏按钮,点击这个按钮就会收藏或者取消收藏该好友,收藏的好友将会在收藏页面显示出来。好友详情页面下方是好友的详细信息表单,包括姓名,电话,生日,住址等等。这些信息都是可以编辑的,点击页面右上角的编辑按钮,就会跳转到编辑页面。

编辑页面,如下图所示:

04

图 4-5 编辑页或者添加页

该页面是编辑与添加好友共用的页面:当处于编辑状态,会显示好友的信息,并可以在上面修改或者删除好友;当处于添加状态,表单是空白的,可以填写并提交内容。如果修改或者添加的内容格式不对,当输入框失去焦点,就会在其右边显示一条红色的竖线作为提示,格式正确则是条绿色的竖线。

上面只是介绍了每个页面的功能,而页面之间的交互联系则如下图所示:

05

图 4-6 通讯录页面交互图

4.3 通讯录项目实战

通过上面的介绍,了解了通讯录的功能与交互,接着在搭建好的 Hello World 例子的基础上,我们就可以开始通讯录项目的实战了。

这里先列出通讯录项目的目录结构,如下所示:

├─src
│  │  app.ts
│  │  index.html
│  │  vendor.ts
│  ├─app
│  │  ├─components
│  │  │  │  contact-app.css
│  │  │  │  contact-app.html
│  │  │  │  contact-app.ts
│  │  │  ├─collection
│  │  │  │      collection.css
│  │  │  │      collection.html
│  │  │  │      collection.ts
│  │  │  ├─contact-detail
│  │  │  │      contact-detail.css
│  │  │  │      contact-detail.html
│  │  │  │      contact-detail.ts  
│  │  │  └─contact-list
│  │  │          contact-list.css
│  │  │          contact-list.html
│  │  │          contact-list.ts
│  │  │          list-li.css
│  │  │          list-li.html
│  │  │          list-li.ts
│  │  ├─pipes
│  │  │      date-reform.pipe.ts
│  │  ├─router
│  │  │      app-router.ts
│  │  │      app-module.ts
│  │  ├─services
│  │  │      contact-service.ts
│  │  │      contacts.json
│  │  ├─utils
│  │  │      utils.ts
│  │  └─widget
│  │          footer.css
│  │          footer.html
│  │          footer.ts
│  │          operate.css
│  │          operate.html
│  │          operate.ts
│  └─images
├─typings
├─package.json
├─tsconfig.json
├─typings.json
├─webpack.config.js

在通讯录项目中,主要分为三大模块:联系人列表模块 contact-list,联系人详情模块 contact-detail 以及收藏模块 collection。在三大模块中,页面的跳转交互是怎么实现的呢?在 Angular 中提供了一个 router 的基础包,它就是路由,用来完成页面间的跳转。在项目中,创建有一个单独的文件,位于 src/app/router/app-router.ts,配置了项目的所有路由,代码如下:

import { Routes } from "@angular/router";
import { ContactList } from "../components/contact-list/contact-list";

...

export const rootRouterConfig: Routes = [
  {
    path: "",
    redirectTo: "contact-list",
    pathMatch: “full”
  },
  {
    path: "contact-list",
    component: ContactList
  }

  ...

];

配置好的路由可以在 app-module.ts 文件中引入使用,关于路由的介绍可以在后续章节中深入学习。app-module.ts 文件代码如下:

import {NgModule} from '@angular/core'
import {RouterModule} from "@angular/router";
import {rootRouterConfig} from "./app-routes";
import {ContactApp} from "../components/contact-app";
import {ContactService} from "../services/contact-service";
...

@NgModule({
  declarations: [
    ContactApp,
    ContactList,
    ContactDetail,
    Anotation,
    Collection,
    Operate,
    Footer,
    ListChildrenComponent,
    DateReform,
    BtnClickDirective
  ],
  imports     : [BrowserModule, FormsModule, HttpModule, RouterModule.forRoot(rootRouterConfig)],
  providers   : [ContactService],
  bootstrap   : [ContactApp]
})
export class AppModule {

}

整个项目的组成部分都会被引入到 app-module.ts 文件中统一使用。

项目开发中,数据操作是很重要的部分。在 Angular 中,对数据的增删改查是通过特定的服务实现的,并将其注入到 NgModule ,这样在 NgModule 中引入的组件模块就可以调用其中的方法,从而达到数据交互的目的。服务的基本写法如下:

import {Injectable} from '@angular/core';
import {Http, RequestOptions, Headers} from '@angular/http';

@Injectable()
export class ContactService {
  constructor(
    private _http:Http
  ) {}

  ...

}

其中 @Injectable() 表示 ContactService 需要注入所依赖的其他服务(Http 服务)。关于服务与依赖注入,在后续章节中会着重介绍。

通过上面的内容,我们介绍了通讯录示例的主要页面,实现这些页面跳转所用到的路由和与页面组件内数据交互的服务。下面再通过两张图来总览整个通讯录示例的技术点与内容,如图 4-7,图 4-8 所示:

06

图 4-7 NgModule组成图

图 4-7 主要介绍了在通讯录示例中,将所涉及到的组件、路由、服务和管道等引入到 NgModule 中,并组成一个整体的可以运行起来的大模块。

07

图 4-8 NgModule详细图

图 4-8 则详细的剖析了 NgModule 里面的各个知识点之间的关系与运行机制,路由控制各个组件的跳转,管道和服务可以在组件中直接使用等。

通过这两张图,可以了解通讯录示例所用到的相关 Angular 知识点。至此,我们介绍完了通讯录示例的整体结构,对 Angular 应用开发的过程有了较为直观的感受,接下来就需要针对每个组件模块编写对应的业务逻辑,这里就不具体展开讲解,完整的代码读者可以到 github 上查看。

小结

在本章节中,我们主要学习了 Angular 基础开发环境的搭建,以及该环境中各个文件的具体内容与作用,同时也了解了 Webpack 这个打包工具的用法。随后通过通讯录实战项目,进一步了解 Angular 所包含一些的知识点,包括 NgModule、路由、组件、服务等。在接下来第二部分的章节中,我们将会具体地介绍这些知识点,使读者深入了解,最终掌握 Angular 应用的开发。

5 Angular 概述

前述篇节已经带领读者入门了 Angular ,包括其历史发展、周边生态、TypeScript 语法、并通过例子上手了 Angular 开发。接下来本书的第二部分会将重心回归到 Angular 框架本身,开始去深入揭开 Angular 的技术内幕。

本章首先从总览的角度去分析 Angular 的各个核心组成部分,让读者对框架有个整体的认知,然后对 Angular 项目源码模块构造作简要分析。

Angular 彻底地重写了 Angularjs 1.x,虽然重写这多少会给社区带来了不便,好在 Angular 团队在无缝升级方面下了不少功夫,而且更重要的是重写能让 Angular 抛掉老版的包袱。采用新架构设计的 Angular 代码更简洁易读、性能更高,而且更加贴合新时代前端的发展趋势,如基于组件的设计,响应式编程等。除此之外,Angular 适用场景更广,如支持服务端渲染,能更好地适配 Mobile 应用(Mobile Toolkit,离线编译)等。

5.1 架构总览

一个完整的 Angular 应用主要由 六 个重要部分构成,分别是:组件、模板、指令、服务、注入和路由。这些组成部分各司其职,而又紧密协作,它们的关系如图 5-1 所示:

图 5-1 Angular 核心模块关联图

与用户直接交互的是模板视图,模板视图并不是独立的模块,它是组成组件的要素之一,另一要素是组件类,用以维护组件的数据模型及功能逻辑。路由的工作是控制组件的创建和销毁,从而驱使应用界面变更切换。指令与模板相互关联,它最重要的作用是增强模板特性,间接扩展了模板的语法。服务是封装若干功能逻辑的单元,这个功能逻辑可以通过注入机制引入至组件内部,作为组件功能的扩展。

在 Angular 应用接收用户指令,加工处理后输出相应视图的过程中,组件始终处于这个交互的出入口,这正是 Angular 基于组件设计的体现。组件承载着 Angular 的核心功能,所以接下来的内容将从组件开始,逐步揭开 Angular 架构的神秘面纱。

组件

Angular 框架基于组件设计,其应用由一系列大大小小的松耦合的组件构成,我们谈及组件,到底组件意味着什么?下面列举通讯录例子来说明,其 Demo 的效果图如下所示:

图 5-2 组件形态

实际上所有框起来的部分均是由相应的组件所渲染,并且这些组件层层嵌套着,自上而下构成组件树。如最外层的方框为根组件,包含了 Header、ContactList 以及 Footer 三个子组件,其中 ContactList 又有自己的子组件。

图 5-3 应用的组件树

树状结构的组件关系意味着每个组件并不是孤立的存在,父子组件之间存在着双向的数据流动。要理解数据是怎样流动的,首先要了解组件间调用方式。简单说,组件的外在形态就是自定义标签,所以组件的调用实际体现在模板标签里的引用。Contact 组件的代码片段如下:

@Component({
  selector: 'contact',
  template: '<div>xxx</div>' // 省略部分内容
})
export class ContactComponent {
  @Input() item: Contact;
  @Output() update: EventEmitter;
  constructor() {}
  // ...
}

@Input()@Output 声明了组件 Contact 对外暴露的接口,item 变量用来接收来自父组件的数据源输入,update 接口用于向父组件发送数据,那么父组件是如何引用子组件并调用这些接口呢?父组件 ContactList 组件代码片段如下:

import {ConcatComponnet} from './contact.ts';
@Component({
  selector: 'contact-list',
  providers: [ConcatComponnet],
  template: `
    ...
    <contact [item]="listItem" (update)="doUpdate(newItem)"></contact>
    ...
  `
})
export class ContactListComponent {
  constructor() {}
  listItem: IContact[],
  doUpdate(item: IContact) {
    // ...
  }
}

关于 @Component 里的 providers 属性机制,后续组件章节会介绍,这里只需了解引入 ConcatComponent 组件是通过这种声明模式。

template 属性值可见,父子组件之间通过类似于 HTML 属性的方式传递数据,其中 [item] 称为属性绑定,数据从父组件流向子组件,(update) 称为事件绑定,数据从子组件流向父组件。

图 5-4 父子组件间的数据流动

细心的读者可能已经发现,Angular 的模板里是可以直接引用组件里的成员属性,如 listItemdoUpdate。组件类和模板之间的数据交互称为数据绑定,前面所说的属性绑定和事件绑定也属于数据绑定的范畴,属性绑定和事件绑定既可用于父子组件的数据传递,也可用于组件数据模型和模板视图之间的数据传递。所以在父子组件通信的过程中,模板充当类似于桥梁的角色,连接着二者的功能逻辑。

图 5-5 数据流动

这就是 Angular 的数据流动机制,然而流动并不是自发形成,流动需要一个驱动力,这个驱动力即是 Angular 的变化监测机制。Angular 是一个响应式系统,每个组件都关联着一个独立的变化监测器。当监测到组件数据变化后,监测器会触发变化事件并在组件树内传播。

那么 Angular 是如何感知数据对象发生变动呢?ES5 提供了 getter/setter 语言接口来捕捉对象变动,然而 Angular 并没有采用。Angular 采用的是以适当的时机去检验对象的值是否被改动,这个适当的时机并不是以固定某个频率去执行,而通常是在用户点击,setTImeout 或 XHR 回调等这些异步的浏览器事件触发之后,覆盖所有这些浏览器事件的捕获工作是通过 Zones 库实现的(关于 Zones 在组件章节会展开原理讲述)。变化监测事件图如下所示:

图 5-6 变化检测

当 Zones 捕获了浏览器的事件后,通知所属组件的变化监测器(如上图所示每个组件背后都有一个独立的变化监测器),若检查到数据对象有变动,即触发变化事件,并从该组件开始,以深度优先的原则向子组件传播。

变化监测机制使得开发者不必关心数据何时变动,结合数据绑定实现模板视图实时更新,这就是 Angular 强大的数据变化监测机制。变化监测机制提供数据自动更新功能,若此时需要手动捕获变化事件做一些额外处理,可以么?答案是肯定的。Angular 还提供了完善的生命周期钩子给开发者调用,如 ngOnChanges 可以满足刚提到的捕获变化事件的要求,又如 ngOnDestroy 可以在组件销毁前做一些清理工作等等。

以上是组件的简述,组件在 Angular 框架里处于最核心的位置,更多的关于组件的运作机理会在第二章继续剖析。

模板

Angular 模板基于 HTML,普通的 HTML 亦可作为模板输入,如:

@Component({
  selector: 'contact',
  template: `
    <div>
      <span> 张三 </span>
    </div>
  `
})
export class ContactComponent { }

但 Angular 模板不止于此,Angular 为模板定制出一套强大的语法体系,涉及内容颇多,这也是为什么将模板单独列出的原因。数据绑定是模板最基本的功能,除了前述提到的属性绑定和事件绑定,插值也是很常见的数据绑定语法,如下所示:

@Component({
  selector: 'contact',
  template: `
    <div>
      <span>{{ item.name }}</span>
    </div>
  `
})
export class ContactComponent {
  @Input() item: Contact;
  // ...
}

插值语法是由一对双大括号组成,插值的变量上下文是组件类本身,如上例中的 item,插值是一种单向的数据流动 —— 从数据模型到模板视图。

上面提到的三种数据绑定语法的数据流动都是单向的,在某些场景下需要双向的数据流动支持(如表单)。结合属性绑定和事件绑定,Angular 模板可实现双向绑定的功能,如:

<input [(ngModel)]="contact.name"></input>

[()] 是实现双向绑定的语法糖,ngModel 是辅助双向绑定实现的内置指令。上述代码执行后,Input 控件和 contact.name 之间就形成双向的数据关联,Input 的值发生变更时,可自动赋值至 contact.name,而 contact.name 的值被组件类改变时,亦可实时更新 Input 的值。关于更多双向绑定实现细节,后续模板章节会详细展开。

由上可知,数据绑定负责数据的传递与展示,而针对数据的格式化显示,Angular 提供了一种叫管道的功能,使用竖线 | 来表示,如:

<span>{{ contact.telephone | phone }}</span>

假设上述contact.telephone 的值是 18612345678,这一串数字并不太直观,管道命令 phone 可以将其进行美化输出,如 186-1234-5678,而不影响 contact.name 本身的值。管道支持开发者定制开发,phone 即属于自定义管道,Angular 也提供了一些基本的内置管道命令,如格式化数字 number、格式化日期 date 等。

Angular 模板还有很多强大的语法特性,包括上述提到的组件所封装的自定义标签,如 <contact></contact>,此外还提供了一套强大的 “指令” 机制,来简化一些特定的交互场景,如样式处理,数据遍历,以及表单处理等,这些强大的语法特性将会在模板章节一一罗列。

指令

指令与模板关系密切,指令可以与 DOM 进行灵活交互,它或是改变样式,或是改变布局。指令的范畴很广,实际上组件也是指令的一种。组件与一般指令的区别在于:组件是带有单独的模板,即 DOM 元素,而一般的指令是作用在已有的 DOM 元素上。一般的指令分为两种:结构指令和属性指令。

结构指令能够添加、修改或删除 DOM,从而改变布局,如 ngIf

<button *ngIf="canEdit"> 编辑 </button>

canEdit 的值为 true 时,button 按钮会显示到视图上;若 canEditfalse 时,button 按钮会从 DOM 树上移除。注意 * 号不能丢掉,这是语法的重要部分。

属性指令用来改变元素的外观或是行为,使用起来跟普通的 HTML 元素属性非常相似,如 ngStyle 指令,用于动态计算样式值,如下:

<span [ngStyle]="setStyles()">{{ contact.name }}</span>

span 标签的样式由 setStyles 函数计算得出,setStyles 是其组件类的成员函数,返回一个计算好的样式对象,如下所示:

class ContactComponent {

  private isImportant: boolean;

  setStyles() {
    return {
      'font-size': '14px',
      'font-weight': this.isImportant ? 'bold' : 'normal'
    }
  }
}

上面列举的 ngIfngStyle 都是 Angular 的内置指令,类似的还有 ngForngClass 等。这些内置指令的作用更偏向于为模板提供语法支持,所以在模板章节里会连带讲述内置指令的用法。指令更具吸引力的地方在于支持开发者自定义,自定义指令能最大限度地实现 UI 层面的逻辑复用,详细的构建过程将会在指令章节讲述。

服务

服务是封装单一功能的单元,类似于工具库,常被引用于组件内部,作为组件的功能扩展。那服务包含什么?它可以是一个简单的字符串或是 JSON,也可以是一个函数甚至是一个类,几乎所有的对象都可以封装成服务。以日志服务为例,一个简单的日志服务如下所示:

export class LoggerService {
  info(msg: any)   { console.log(msg); }
  warn(msg: any)  { console.warn(msg); }
  error(msg: any) { console.error(msg); }
}

组件需要记录日志时只”注入” LoggerService 即可调用其接口,封装成独立模块的日志服务使其能被所有的组件所复用,这就是服务设计的原则。

Http 是 Angular 里常用的内置服务,它封装了一系列的异步数据请求接口,但与一般的接口不同,Http 服务暴露的是 Reative Programming 规范的接口,基于 RxJS 实现,严格贯彻响应式编程思想。所以第九章服务章节除了介绍 Http 的使用方法之外,还会有专门的小节讲解 RxJS 这个流行的响应式编程框架。

依赖注入

在服务小节里已经提到过 “注入 “这个概念,依赖注入一直都是 Angular 的卖点。通过注入机制,服务等模块可以被引入到任何一个组件中,而开发者无须关心这些模块是如何被初始化。因为 Angular 已经帮你处理好,包括该模块本身依赖的其他模块也会被初始化。如下图所示,当组件注入日志服务后,日志服务以及它所依赖的基础服务都会被初始化。

图 5-7 依赖注入

可以说,依赖注入是一种帮助开发者管理模块依赖的设计模式。在 Angular 中,依赖注入与 TypeScript 相结合提供了更好的开发体验。在 TypeScript 中,对象通常明确赋以类型,通过类型匹配,组件类便可知道该用哪种类型实例去赋值变量。一个简单的注入例子如下所示:

import {LoggerService} from './logger-service'
@Component({
  selector: 'contact-list',
  providers: [LoggerService]
})
export class ContactListComponent {
  constructor(private logger: LoggerService) {

  }

  doSomething() {
    this.logger.info('xxx');
  }
}

private logger: LoggerService 创建了一个类的私有属性,详细信息可参考 TypeScript 章节。

@Component 装饰器中的 providers 是注入操作的关键,它会为该组件创建一个注入器对象,并新建 LoggerService 实例存储到这个注入器里。组件在需要引入 LoggerService 实例时,通过 TypeScript 的类型匹配即可从注入器取出相应的实例对象,无须再重复显式实例化。

需要注意的是,服务的每一次注入(也就是使用 providers 声明),该服务都会被创建出新的实例,组件的所有子组件均默认继承父组件的注入器对象,复用该注入器里存储的服务实例。这种机制可保证服务以单例模式运行,除非某个子组件再次注入(即通过 providers 声明),如图所示:

图 5-8 注入机制

ContactList 组件注入的 LoggerService 是可以被 ContactList 及其子组件使用。不过当子组件 Contact 又重新注入了新的 LoggerService 后,Contact及它的子组件使用的将会是新创建的 LoggerService 服务实例。这种灵活注入方法可以适应多变的应用情景,既可配置全局单例服务(在应用的根组件注入即可),亦可按需注入不同层级的服务,彼此数据状态不会相互影响。更多关于注入的细节会在依赖注入章节继续讲解。

路由

Angular 作为一个单页应用框架,前端路由功能必不可少。在 Angular 中,路由的作用是建立 URL 路径和组件之间的对应关系,根据不同的 URL 路径匹配出相应的组件并渲染。假设通讯录应用里需要添加一个通话记录页面,一个简单的路由配置大概如下:

[
  {path: '', component: ContactListComponent},
  {path: 'record', component: RecordListComponent},
  // ...
]

注意到该配置的第一项 path 的值为空,这表示默认路由。上面的配置表明:

组件树的节点会不断发生变化,如图所示:

图 5-9 基本路由功能

原来的组件树中多了一个路由组件节点(组件标签名为 <router-outlet>),Application 组件的模板大致如下:

<div>
  <header></header>
  <router-outlet></router-outlet>
  <footer></footer>
</div>

路由组件 router-outlet 起着类似于” 插座 “功能,根据当前的 URL 路径,匹配插入对应的组件节点,实现了主体内容(页面)的刷新,这就是 Angular 路由最基本的功能。路由组件还支持多重嵌套,实现子路由功能。假设通讯录应用的通话记录页面需要新增标签页切换功能,来切换显示全部来电及未接来电,可以修改路由配置如下:

[
  {path: 'list', component: ContactListComponent},
  { path: 'record', component: RecordListComponent, children: [
      {path: '', component: AllRecordsComponent},
      {path: 'miss', component: MissRecordsComponent}
    ]
  },
  // ...
]

record 条目新增了一个 children 的配置项,用以设置子路由的信息。当路由至 http://www.abc.com/record 时,显示的是 AllRecordsComponent 组件视图,通话记录组件结构变成下面这样:

图 5-10 子路由功能

路由还支持路径参数,如 http://www.abc.com/list/123123 为联系人 ID,从而实现类似 RESTful 风格的 URL 形式。另外路由组件的同一层节点还可以放置多个路由组件,实现从属路由功能,更多更强大的路由功能会在路由章节进一步剖析。

5.2 应用模块

上一小节读者了解了 Angular 应用中的六个主要组成部分,那么这些不同的组成部分如何组织起来,构成一个完整的功能单元甚至是完整应用的呢? Angular 引入模块机制,它是对某些特定功能特性的封装,可能包含若干组件、指令、服务等,甚至拥有独立的路由配置,关系示意图如下:

图 5-11 模块内关系图

每个 Angular 应用至少有一个模块,因为需要有一个模块作为应用的入口,这个入口模块称为根模块(Root Module)。当然我们可以把整个应用的逻辑(指所有的组件指令以及服务等)都封装到这个根模块里,但这通常并不是好的设计。比较推荐的做法是把应用功能分区设计,不同的功能特性由不同的独立模块负责,这种设计显然耦合性更低,更容易维护。在 Angular 中,除了根模块外的其他模块均称为特性模块(Feature Module)。

图 5-12 模块间关系图

从上图看到,通过将特性模块导入到根模块里即可实现对该特性功能的引入,而模块间如何交互不用开发者去关心,Angular 已经处理好了。这种交互关系对于不同的构件各不相同:

Angular 已经封装了不少常用的特性模块,如:

我们知道 Angular 是基于 TypeScript 编写,TypeScript 又是 ES6 的超集,而 ES6 的给开发者带来的一个新特性是文件级别的模块功能。利用这个特性,整个 Angular 项目的源码是基于 ES6 模块来组织。注意这里的模块和上一节提到的模块并不是同一个概念,上节提到的是应用级别的模块,是以功能特性为划分依据,而本节的模块是语言级别的模块,是以物理文件或文件夹为划分依据。如下源码结构图所示:

图 5-13 源码结构图

上图标红的为常用的一级模块(一级文件夹):

这些语言层面的模块和应用层面的模块非常相似,实际上他们是有相互协作地方,如 CommonModule 本身就存放在 @angular/common 文件夹模块里,当开发者需要引用 @angular/common 里的诸多指令或者组件时,只需引入 CommonModule 即可,CommonModule 的作用是打包在 @angular/common 下的零散的组件指令并作为该模块的 API 暴露出来,方便开发者一次性引入。

其他模块并没有一一列出,有兴趣的可以到 Angular 的 github 库上查阅。

总结

Angular 的各个组成部分以组件作为桥梁关联起来,对组件的深度剖析将作为本书深入讲解的开篇。之后是与组件密切相关的模板章节,它囊括了 Angular 所有内置的模板语法,仔细阅读完后可对 Angular 的视图特性有一个系统的认识。接着,指令、服务、依赖注入以及路由这四个相对比较独立的概念会分别开辟新章节讲述。最后会加入 测试 这个特殊章节,测试是应用质量保证的关健环节,该章节会详细介绍 Angular 的各个部分是如何实施自动化测试。

这就是 Angular 的总体架构,实际上它已不仅仅是简单的框架,更像是个平台。精心的架构设计,成熟的 Angular 生态,对标准的拥抱,还有 Google 和微软的联手支持,这些都给了我们足够的信心——Angular 将会是一个非常棒的平台,那么让我们马上开始进入 Angular 的学习吧!