第03课:Angular、TypeScript

第03课:Angular、TypeScript 和其他框架和工具介绍

前面我们提到过,Ionic 应用本质上就是 Angular 应用。Ionic 所提供的是一系列高质量的跨平台的 Angular 组件。Ionic 3.9.2 使用的 Angular 版本是 5.0.3。在开发 Ionic 应用之前,对于 Angular 和 TypeScript 的了解是必要的。Angular 和 TypeScript 的相关知识并不是本课程所要涵盖的内容,因此只会在该文中对一些相关的重点内容进行介绍。更多具体的内容请参考其他资料。

TypeScript

Angular 应用使用 TypeScript。顾名思义,TypeScript 的重要特征是增加了类型系统。类型的引入使得 TypeScript 编译器可以在编译阶段就发现很多潜在的问题,避免了传统 JavaScript 只能在运行时才发现类型错误的问题。这对于提高代码质量和开发人员的效率都有很大好处。

让我们看一个简单的例子。在下面的代码中,变量 port 表示的是端口,很明显只能是整数类型。而在第二行代码中我们把字符串 'localhost' 赋值给了 port。这在JavaScript里面是完全合法的,错误只会在运行时才被发现。

var port = 8080;
port = 'localhost';

而 TypeScript 则不会有这样的问题。同样的代码如果使用 TypeScript 来编写,由于我们已经声明了 port 为 number 类型,第二行的赋值操作无法通过编译。类型系统的存在,让很多潜在的问题可以在开发阶段就得到解决。

let port: number = 8080;
port = 'localhost'; //编译错误

下面对 TypeScript 的基本概念做一下介绍。

类型

TypeScript 中定义了常见的基本类型,包括 boolean、number 和 string,还有数组类型,如 number[] 和 Array<string>。元组(tuple)表示的元素数量固定,但是类型可能不同的数组。下面代码中的元组 result 中包含两个元素,类型分别是 string 和 number。

let result: [string, number] = ['Alex', 35];

enum 表示的是枚举类型。

enum State { NEW, RUNNING, STOPPED }
let s: State = State.NEW;

any 类型的作用是跳过编译器的类型检查。any 主要用来与已有的 JavaScript 代码进行交互,因为 JavaScript 代码中的变量并没有类型信息。any 也可以用来方便已有 JavaScript 代码以渐进式的方式升级到 TypeScript。可以先把已有代码中的所有类型都声明为 any,再逐步地用更具体的类型来替代。

void 通常用在函数声明中,表示一个函数没有返回值。声明为 void 的变量只能接受 undefined 和 null 作为值。在 TypeScript 中,undefined 和 null 都是类型。never 类型表示不可能产生的值,通常作为函数的返回值类型。如果一个函数总是抛出异常,或是内部有无限循环,它的返回值类型就是 never。

接口和类

TypeScript 中的接口主要有两个作用。第一个作用是定义值的结构,解决了 JavaScript 中对象内部结构不透明的问题。下面的代码定义了接口 UserProfile,其中属性 username 和 email 是必需的,profileImageUrl 是可选的。

interface UserProfile {
  username: string;
  email: string;
  profileImageUrl?: string;
}

let userProfile: UserProfile = {
  username: 'alex',
  email: 'alex@example.org',
};

接口的第二个作用与传统面向对象编程语言中的接口的作用相同,即定义类的契约。下面的代码中定义了接口 Shape 和实现类 Circle。

interface Shape {
  area(): number;
}

class Circle implements Shape {
  private radius: number;
  constructor(radius: number) {
    this.radius = radius;
  }
  area() {
    return Math.PI * this.radius * this.radius;
  }
}

let circle = new Circle(10);
circle.area();

函数

TypeScript 中的函数可以有可选参数和带默认值的参数,还可以方便的处理可变数量的参数。在下面的代码中,第一个函数 sayHi 的参数 greeting 有默认值 Hi。第二个函数的第一个参数之后的其他参数都会被收集到 names 数组中。

function sayHi(name: string, greeting = 'Hi') {
  return `${greeting}, ${name}`;
}

sayHi('Alex');

function sayHiToAll(greeting: string, ...names: string[]) {
  return `say ${greeting} to ${names.join(', ')}`;
}

实用功能

下面介绍几个 TypeScript 中的实用功能。第一个是解构赋值(Destructuring Assignment)。

let values = [0, 1];
let [first, second] = values;
console.log(first); // 输出0
console.log(second); //输出1

let obj = {
  a: 1,
  b: 'Alex',
  c: false,
};
let { a, c } = obj;
console.log(a); //输出1

与解构相反的是展开运算符 …(Spread Operator)。

let array1 = [1, 2];
let array2 = [3, 4];
let array = [...array1, ...array2];
console.log(array); //输出[1, 2, 3, 4]

let obj = {
  a: 1,
  b: 2,
  c: 3,
};
let newObj = { ...obj, a: 4 };
let { a } = newObj;
console.log(a); //输出a

Angular

Angular 中有几个重要的概念,接下来结合 Ionic 应用的骨架代码依次进行介绍。

模块

Angular 应用是按照模块的方式来组织的,以 NgModule 来表示。每个 Angular 应用中都至少包含一个 NgModule,称为根模块,通常命名为 AppModule。简单的应用可能就只有根模块,复杂的应用还可能包含其他功能模块。每个 NgModule 都是添加了修饰符(decorator) @NgModule 的 JavaScript 类。@NgModule 接受一个 JavaScript 对象作为参数,其中的属性用来描述该模块。这些属性包括:

  • declarations:该模块包含的视图类,包括组件(Component)、指令(Directive)和管道(Pipe);
  • exports:该模块所导出的,可以在其他模块中使用的声明;
  • imports:该模块所导入的来自其他模块的声明;
  • providers:该模块中注册的服务提供者。这些服务是全局可用的;
  • bootstrap:应用的主视图,称为根组件。只有根模块才能设置该属性。

在 Ionic 应用的骨架代码中,src/app/app.module.ts 是应用的根模块 AppModule,如下所示。在 @NgModule 的属性中:

  • declarations 声明了应用中的三个组件:MyApp 是根模块中的主组件,HomePage 和 ListPage 则是对应两个不同页面的组件;
  • imports 中导入了 Angular 的内置模块 BrowserModule。IonicModule.forRoot(MyApp) 的作用是确保所有 Ionic 框架提供的组件、指令和服务提供者都被导入;
  • bootstrap 中的 IonicApp 声明使用 Ionic 提供的主组件来启动应用;
  • entryComponents 指定当该模块定义时需要编译的组件,这里编译所有声明的组件;
  • providers 中的 StatusBar 和 SplashScreen 是 Ionic Native 中提供的状态栏和启动屏幕相关的服务,{provide: ErrorHandler, useClass: IonicErrorHandler} 表明使用 IonicErrorHandler 作为 Angular 中 ErrorHandler 服务的提供者。
import { BrowserModule } from '@angular/platform-browser';
import { ErrorHandler, NgModule } from '@angular/core';
import { IonicApp, IonicErrorHandler, IonicModule } from 'ionic-angular';

import { MyApp } from './app.component';
import { HomePage } from '../pages/home/home';
import { ListPage } from '../pages/list/list';

import { StatusBar } from '@ionic-native/status-bar';
import { SplashScreen } from '@ionic-native/splash-screen';

@NgModule({
  declarations: [
    MyApp,
    HomePage,
    ListPage
  ],
  imports: [
    BrowserModule,
    IonicModule.forRoot(MyApp),
  ],
  bootstrap: [IonicApp],
  entryComponents: [
    MyApp,
    HomePage,
    ListPage
  ],
  providers: [
    StatusBar,
    SplashScreen,
    {provide: ErrorHandler, useClass: IonicErrorHandler}
  ]
})
export class AppModule {}

指令和组件

指令是 Angular 动态性的来源,使用 @Directive 修饰符。组件用来管理页面上的一个特定的视图区域。组件是一种特殊的指令,有自己的模板。除了组件之外,还有另外两种指令,分别是结构指令和属性指令。结构指令用来修改 DOM 结构,包括 ngFor、ngIf 和 ngSwitch 等。属性指令改变元素的外观或行为,包括 ngModel、ngStyle 和 ngClass 等。

作为一种特殊的指令,组件有自己的修饰符 @Component。和 @NgModule 一样,@Component 也接受一个 JavaScript 对象作为参数,其中的常见属性如下:

  • selector:组件对应的 CSS 选择器。当 Angular 在模板中发现满足选择器的元素时,会创建并插入一个该组件的实例;
  • templateUrl:组件的模板的路径;
  • template :组件的模板;
  • styleUrls :样式文件的路径;
  • styles :样式声明;
  • providers :组件使用的服务提供者。

文件 src/app/app.component.ts 中定义了组件 MyApp。@Component 中的 templateUrl 声明了使用 app.html 作为组件的模板文件。

import { Component, ViewChild } from '@angular/core';
import { Nav, Platform } from 'ionic-angular';
import { StatusBar } from '@ionic-native/status-bar';
import { SplashScreen } from '@ionic-native/splash-screen';

import { HomePage } from '../pages/home/home';
import { ListPage } from '../pages/list/list';

@Component({
  templateUrl: 'app.html'
})
export class MyApp {
  @ViewChild(Nav) nav: Nav;

  rootPage: any = HomePage;

  pages: Array<{title: string, component: any}>;

  constructor(public platform: Platform, public statusBar: StatusBar, public splashScreen: SplashScreen) {
    this.initializeApp();

    // used for an example of ngFor and navigation
    this.pages = [
      { title: 'Home', component: HomePage },
      { title: 'List', component: ListPage }
    ];

  }

  initializeApp() {
    this.platform.ready().then(() => {
      // Okay, so the platform is ready and our plugins are available.
      // Here you can do any higher level native things you might need.
      this.statusBar.styleDefault();
      this.splashScreen.hide();
    });
  }

  openPage(page) {
    // Reset the content nav to have just this page
    // we wouldn't want the back button to show in this scenario
    this.nav.setRoot(page.component);
  }
}

模板

组件的视图内容通过 HTML 模板来定义。在模板中除了标准的 HTML 元素之外,还可以有 Angular 指令。在模板中可以通过数据绑定来关联 DOM 元素和组件。Angular 中有 4 种不同的数据绑定。每种数据绑定有自己独特的语法。

  • {{name}}:从组件到 DOM 的插值(interpolation);
  • [value]:从组件到 DOM 的属性绑定;
  • (click):从 DOM 到组件的事件绑定;
  • [(ngModel)]:DOM 和组件之间的双向数据绑定。

关于插值和属性绑定的区别,插值可以看成是属性绑定的一种特殊形式。如果要把数据值当成字符串来显示,两者并没有本质上的区别;如果要设置元素属性值为非字符串类型,则必须使用属性绑定。

src/app/app.html 是组件 MyApp 的模板,其内容如下。在该模板中,ion-menu、ion-header、ion-toolbar、ion-title、ion-content、ion-list 和 ion-nav 都是 Ionic 提供的组件,以 ion- 作为名称前缀。在 ion-list 中,使用了 ngFor 来遍历所有页面,使用 (click) 来绑定鼠标点击事件到 openPage 方法。openPage 方法在组件类中定义:

<ion-menu [content]="content">
  <ion-header>
    <ion-toolbar>
      <ion-title>Menu</ion-title>
    </ion-toolbar>
  </ion-header>

  <ion-content>
    <ion-list>
      <button menuClose ion-item *ngFor="let p of pages" (click)="openPage(p)">
        {{p.title}}
      </button>
    </ion-list>
  </ion-content>

</ion-menu>

<!-- Disable swipe-to-go-back because it's poor UX to combine STGB with side menus -->
<ion-nav [root]="rootPage" #content swipeBackEnabled="false"></ion-nav>

服务和依赖注入

在 Angular 应用中,组件只负责与用户交互相关的工作,包括展示数据和处理事件等。组件本身并不包含复杂的业务逻辑。复杂的逻辑都应该通过服务来完成。Angular 中的服务是一个抽象的概念,并没有与服务相关的类或修饰符。服务可以是任何的 JavaScript 类或对象。

在有了服务之后,需要一种方式把这些服务的实例提供给需要使用它们的组件。这是通过 Angular 中的依赖注入(Dependency Injection)机制来完成的。具体的来说,每个组件通过其构造方法来声明所需要使用的服务。当 Angular 创建组件时,会首先从依赖注入器(Injector)中请求获取所依赖的服务的实例。依赖注入器在内部维护了所有已经创建的服务实例。如果所请求的服务实例不存在,依赖注入器会首先创建该服务实例并保存。当一个组件所有的服务依赖都被满足后,Angular 会使用这些服务实例来调用组件的构造方法,并完成组件的创建。

为了让依赖注入器能够创建服务的实例,服务的提供者需要首先进行注册。服务的提供者可以创建或者返回一个服务实例,通常来说是服务自身的 JavaScript 类作为提供者,也可以使用单一的固定值。服务提供者通过模块或组件对应的修饰符中的 providers 属性来声明。两者的区别在于,如果服务提供者在模块中声明,模块中的所有组件共享同一个服务的实例;而如果服务提供者在组件中声明,该组件的每个实例都有自己独有的服务实例。

在 MyApp 的构造方法中可以看到依赖注入的示例。构造方法的参数类型 Platform、StatusBar 和 SplashScreen 都是 Ionic 提供的服务。

应用启动

Ionic 应用的启动是由 src/app/main.ts 中的代码来完成的。Ionic 使用 platformBrowserDynamic 来创建一个支持动态编译的浏览器平台,并启动主模块 AppModule。

import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

import { AppModule } from './app.module';

platformBrowserDynamic().bootstrapModule(AppModule);

Jasmine 和 Karma

Jasmine 是测试 JavaScript 代码的行为驱动(Behavior Driven)开发框架。Jasmine 中的测试套件(Test Suite)由方法 describe 来描述。describe 接受两个参数,第一个参数是测试套件的名称,第二个参数是测试套件要执行的函数。测试规格(Test Spec)由方法 it 来描述,表示需要验证的期望。it 也接受与 describe 相同的参数。在测试规格的函数体中包含使用 expect 来描述的期望。只有当一个测试规格中的所有期望都满足时,该测试规格才算通过。expect 的参数是待验证的实际值,后面级联各种不同的匹配函数来进行验证。下面代码是一个简单的测试套件。

describe('A simple test suite', function() {
  it('should verify simple values', function() {
    var v1 = true;
    var v2 = 10;
    expect(v1).toBe(true);
    expect(v2).toBeLessThan(20);
  });
});

Jasmine 的测试套件中还可以包含生命周期方法,如 beforeEach、afterEach、beforeAll 和 afterAll 等。

Karma 用来在实际的浏览器上运行测试。在 Angular 应用中,Karma 可以运行使用 Jasmine 编写的测试套件。

关于 Jasmine 和 Karma 的具体使用,会在下一篇文章中进行介绍。

上一篇
下一篇
目录