本章将讨论Angular中的高级概念,从全局视角来分析各细节部分是如何协同工作的。

 如果你用过AngularJS,会发现Angular采用了全新的思维模型来构建应用。别担心,作为AngularJS的使用者,我们觉得Angular的设计既简明又熟悉。在本书稍后的章节中,我们会专门讨论如何将AngularJS应用转换成Angular应用。

在后面的章节里,我们会对每一个概念进行深入讲解,但目前只作概述并解释最基础的概念。

第一个重要概念:Angular应用是由组件构成的。可以将组件理解为一种教浏览器认识新HTML标签的方式。如果你有使用AngularJS的经验,那么可以把组件理解为类似于指令的概念。(事实上,Angular中也有指令,我们会在后面讨论具体的差异。)

其实,相比AngularJS中的指令,Angular中的组件有一些重要优势,我们会详细讨论。现在,让我们先来看看最顶级的概念:应用。

3.1 应用

一个Angular应用其实就是一棵由组件构成的树。

在这棵树的根结点,最顶层的组件就是应用本身。它会在浏览器启动(也叫引导)应用的时候被渲染。

组件有一个很棒的特性,那就是它们是可组合的。这意味着我们可以基于小组件构建大组件。应用只是一个会渲染其他组件的组件而已。

由于组件是以树型结构组织起来的,当每个组件被渲染时,它都会递归地渲染下级组件。

举个例子,让我们基于如图3-1所示的原型图创建一个简单的库存管理系统。

图3-1 库存管理系统

拿到这个原型图后,我们应该做的第一件事就是把页面拆分成组件。

在这个例子里,我们可以对页面内容进行分组,并抽象成三个高层级组件:

(1)主导航组件

(2)面包屑导航组件

(3)产品列表组件

3.1.1 主导航组件

这个组件用来展示主导航部分,用户可以通过主导航组件访问应用的其他部分(如图3-2所示)。

图3-2 主导航组件

3.1.2 面包屑导航组件

这个组件用来展示用户在本应用“网站地图”中的当前位置(如图3-3所示)。

图3-3 面包屑导航组件

3.1.3 产品列表组件

产品列表组件用来展示一组产品(如图3-4所示)。

图3-4 产品列表组件

我们还可以继续拆分产品列表组件,从而得到下一级的产品条目组件(如图3-5所示)。

图3-5 产品条目组件

当然,我们可以再进一步,把每个产品条目组件拆分为更小的组件。

产品图片组件用来根据指定的图片名称显示产品图片。

产品分类组件用来展示产品分类树。比如:男装 > 鞋 > 跑鞋。

价格显示组件用来展示产品价格。如果我们对产品价格有定制化需求,比如用户登录后可以获得全局折扣或者包邮,就可以在这个组件中实现。

最后,把以上组件按层级结构进行整理,就得到了如图3-6所示的树状图。

图3-6 应用树状图

在树状图的顶层可以看到我们的应用:库存管理系统

往下细分为主导航、面包屑导航和产品列表组件。

产品列表组件包含一些产品条目组件,每个产品各一个。

产品条目组件又包含三个更下层的组件:一个用于展示图片,一个用于展示分类,一个用于展示价格。

现在,让我们一起来实现这个应用。

 你可以在本书下载内容的how_angular_works/inventory_app目录中找到本章涉及的全部代码。

当我们的应用完成之后,它看起来应该如图3-7所示。

图3-7 完成的库存管理系统

3.2 产品数据模型

关于Angular,有一件事你必须清楚:它不要求使用指定的数据模型库

Angular十分灵活,可以支持多种不同的数据模型(和数据架构)。不过这也意味着你需要决定自己的实现方式。

关于数据结构,我们会在第9章详细讲解。在本章中,我们仅使用普通的JavaScript对象作为数据模型。

code/how_angular_works/inventory_app/app.ts

/**

* Provides a `Product` object

*/

class Product {

 constructor(

  public sku:string,

  public name:string,

  public imageUrl:string,

  public department:string[],

  public price:number){

 }

}

如果你还不熟悉ES6/TypeScript,可能会对这段代码的语法感到陌生。

上面的代码创建了一个名叫Product的类,这个类的构造函数接收5个参数。public sku:string这行代码有两个意思:

●这个类的实例有一个名为sku的公共属性;

●sku的类型是string。

 如果你已经比较熟悉JavaScript,可以通过learnxinyminutes上的教程来快速补充相关知识,比如上面代码中的public constructor简写形式。

上面代码中的Product类不依赖Angular中的任何东西,它只是一个我们会在应用中用到的数据模型。

3.3 组件

前面提到过,组件是构成Angular应用的基本组成部分。“应用”本身就是一个顶层组件,并且我们把应用划分成了细粒度的组件。

 技巧:当开发新的Angular应用时,先画出原型图,然后拆分成组件。

因为我们经常用到组件,所以有必要对组件进行进一步研究。

每个组件都由三个部分组成:

●组件注解

●视图

●控制器

要清楚这些关键概念,就要充分理解组件。我们先来分析顶层的库存管理系统应用,然后再来分析产品列表及其下级组件(如图3-8所示)。

图3-8 产品列表组件

一个基本的顶层应用InventoryApp(库存管理系统)看起来是这样的:

@Component({

 selector:'inventory-app',

 template:`

 <div class="inventory-app">

  (Products will go here soon)

 </div>

 `

})

class InventoryApp {

 // Inventory logic here

}

// module boot here……

如果你用过AngularJS,可能会觉得完全看不懂这段代码。别担心,其实两者的思路还是很相似的,让我们来一步一步地分析。

这段代码中的@Component被称作注解。它给紧随其后的类(InventoryApp)添加了一些元数据。

@Component注解明确了下面两项:

●selector(选择器)用来告诉Angular要匹配哪个HTML元素;

●template(模板)用来定义视图。

组件的控制器是由一个TypeScript类定义的,比如前面代码中的InventoryApp类。

接下来让我们对代码中的各个部分进行更详细的分析。

3.4 组件注解

@Component注解是对组件进行配置的地方。一般来说,@Component会配置你的组件如何与外界交互。

要配置一个组件,有很多种方法(我们会在第14章中进行讲解)。本章只会涉及一些基本配置。

3.4.1 组件selector

通过selector(选择器)配置项,可以指定当HTML模板被渲染时Angular如何找到组件。这个思路与CSS、XPath中的选择器很像。我们可以用选择器来定义HTML中的哪些元素用来与组件匹配。在前面的例子中,selector:inventory-app就表示我们希望在HTML中匹配inventory-app标签。也就是说,我们定义了一个新的HTML标签,每当我们使用这个标签时,它都拥有我们定义的功能。例如,我们把下面这段代码放到HTML中:

<inventory-app></inventory-app>

Angular就会自动使用我们定义的InventoryApp组件来实现这个标签的功能。

此外,这个例子中定义的选择器还可以匹配一个以组件名为属性的普通div元素:

<div inventory-app></div>

3.4.2 组件template

视图是一个组件中可视的部分。我们可以用@Component中的template配置项来定义组件所用的HTML模板:

 @Component({

  selector:'inventory-app',

  template:`

  <div class="inventory-app">

   (Products will go here soon)

  </div>

  `

 })

可以看到,在template配置项里,我们用到了TypeScript中用反引号包裹的多行文本语法。到目前为止,我们的模板还都很简单:只有一个div和一些占位文本。

 如果希望把模板放到一个单独的文件中,可以将组件的template配置项改为templateUrl配置项,把配置的内容设置为模板文件名即可。

3.4.3 添加产品

我们的应用现在还没有产品可展示,需要添加一些。

可以用如下代码创建一个Product:

 let newProduct = new Product(

    'NICEHAT',                 // sku

    'A Nice Black Hat',             // name

    '/resources/images/products/black-hat.jpg', // imageUrl

    ['Men', 'Accessories', 'Hats'],       // department

    29.99);                   // price

Product类的构造函数接收5个参数。新建一个Product实例要用到new关键词。

一般情况下,我们应该不会向一个函数传递超过5个参数。另一种做法是将Product类的构造函数修改为接收一个配置对象,这样就可以不必记住参数的顺序了。如果这样做,我们就可以像这样编写Product类的代码:

new Product({sku:"MYHAT", name:"A green hat"})

但就目前来说,5个参数的构造函数还可以接受。

我们希望在界面中展示这个Product。为了让产品属性在模板中可访问,我们把它们添加到组件的实例变量中

比如,如果希望在视图中访问新产品newProduct,可以这样写:

class InventoryApp {

 product:Product;

 constructor(){

  let newProduct = new Product(

     'NICEHAT',

     'A Nice Black Hat',

     '/resources/images/products/black-hat.jpg',

     ['Men', 'Accessories', 'Hats'],

     29.99);

  this.product = newProduct;

 }

}

也可以更简洁一点:

class InventoryApp {

 product:Product;

 constructor(){

  this.product = new Product(

     'NICEHAT',

     'A Nice Black Hat',

     '/resources/images/products/black-hat.jpg',

     ['Men', 'Accessories', 'Hats'],

     29.99);

 }

}

注意,我们在这里做了三件事。

(1)添加了一个constructor。当Angular创建这个组件的实例时,会调用这个constructor。我们可以在这里对这个组件进行初始化。

(2)声明了一个实例变量。当我们在InventoryApp里写product:Product的时候,是在InventoryApp的实例中定义了一个名叫product的属性,用于保存Product对象。

(3)给product属性赋值了一个Product实例。在constructor中,我们创建了一个Product的实例,并把它赋值给product实例变量。

3.4.4 用模板绑定来查看产品

由于已经给product赋了值,现在我们可以在视图中使用这个变量了。把模板修改成下面这样:

 @Component({

  selector:'inventory-app',

  template:`

  <div class="inventory-app">

    <h1>{{ product.name }}</h1>

    <span>{{ product.sku }}</span>

  </div>

  `

 })

{{……}}语法被称为模板绑定。它告诉视图,我们希望在模板的这个位置使用花括号中表达式的值。

在这个例子中,我们有两个绑定:

●{{ product.name }}

●{{ product.sku }}

product变量来自于InventoryApp组件实例中的实例变量product。

模板绑定有个很灵活的特性:花括号中的内容是一个表达式。这意味着你可以像下面这样写代码:

●{{ count + 1 }}

●{{ myFunction(myArguments)}}

在第一个示例中,我们使用一个操作符改变了count的显示值。在第二个示例中,我们使用myFunction(myArguments)函数的返回值来作为显示内容。使用模板绑定标签是在Angular应用中展示数据的主要方式。

3.4.5 添加更多产品

我们当然不希望应用只展示一个产品;实际上,我们希望展示一个完整的产品列表。因此,把InventoryApp中的一个Product属性修改为Product数组:

class InventoryApp {

 products:Product[];

 constructor(){

  this.products = [];

 }

}

注意,我们还把product变量重命名为products,并且把类型改为了Product[]。后面的[]代表我们希望products是一个Product数组。也可以把它写成Array<Product>。

现在InventoryApp已经可以保存多个Product了,我们在构造函数中多创建一些Product。

code/how_angular_works/inventory_app/app.ts

class InventoryApp {

 products:Product[];

 constructor(){

  this.products = [

   new Product(

    'MYSHOES',

    'Black Running Shoes',

    '/resources/images/products/black-shoes.jpg',

    ['Men', 'Shoes', 'Running Shoes'],

    109.99),

  new Product(

    'NEATOJACKET',

    'Blue Jacket',

    '/resources/images/products/blue-jacket.jpg',

    ['Women', 'Apparel', 'Jackets & Vests'],

    238.99),

  new Product(

    'NICEHAT',

    'A Nice Black Hat',

    '/resources/images/products/black-hat.jpg',

    ['Men', 'Accessories', 'Hats'],

    29.99)

  ];

 }

这段代码会在应用中创建一些产品以备后续使用。

3.4.6 选择一个产品

我们需要应用支持用户交互。比如,用户可能会希望选择一个特定的产品来查看更多信息,或者把它加入购物车,等等。

下面来给InventoryApp定义一个新方法productWasSelected,用来响应用户对产品的选择。

code/how_angular_works/inventory_app/app.ts

 productWasSelected(product:Product):void {

  console.log('Product clicked:', product);

 }

3.4.7 用<products-list>列出产品

顶层的InventoryApp组件已经有了,现在需要创建一个新的组件用来渲染产品列表。接下来,我们会实现使用products-list选择器的ProductsList组件。在我们深入实现细节之前,先看看如何使用它。

code/how_angular_works/inventory_app/app.ts

@Component({

 selector:'inventory-app',

 template:`

 <div class="inventory-app">

  <products-list

   [productList]="products"

   (onProductSelected)="productWasSelected($event)">

  </products-list>

 </div>

 `

})

class InventoryApp {

这里出现了一些新的语法和配置项,我们来逐一说明。

输入/输出

使用products-list组件时,我们会用到Angular组件的一个核心特性:输入/输出。

  <products-list

   [productList]="products"             <!—— input ——>

   (onProductSelected)="productWasSelected($event)"> <!—— output ——>

  </products-list>

方括号[]用来传递输入,圆括号()用来处理输出。

数据通过输入绑定流入你的组件,事件通过输出绑定流出你的组件。

可以将输入与输出绑定理解为对组件定义了一组公有API

方括号传递输入

在Angular中,你可以通过输入把数据传入组件。

在我们的代码中有一段:

  <products-list

   [productList]="products"

这就是在使用ProductsList组件的输入

可能products和productList有点难以理解。这个元素属性(attribute)分为两个部分:

●[productList](=号左边)

●"products"(=号右边)

左边的[productList]是指,我们希望在product-list组件中设置名为productList的输入。

右边的"products"是指,我们希望将输入设置为products表达式的值,即InventoryApp类中的this.products。

你可能会问:“我怎么知道productList是product-list组件的一个合法输入呢?”答案是:需要阅读这个组件的相关文档。inputs(输入)和outputs(输出)是这个组件“公开API”的一部份。

你可以像弄清一个函数有哪些参数一样来弄清一个组件支持哪些输入。

圆括号处理输出

在Angular中,使用输出来将数据传递出组件。

在我们的代码中有一段:

  <products-list

   ……

   (onProductSelected)="productWasSelected($event)">

意思是我们要监听ProductsList组件的onProductSelected输出

也就是说:

●(onProductSelected),即=号左边是我们要监听的输出的名称;

●"productWasSelected",即=号右边是当有新的输入时我们想要调用的方法;

●$event在这里是一个特殊的变量,用来表示输出的内容。

到目前为止,我们还没有讨论过如何在组件中定义输入和输出。别急,我们很快就会在定义ProductsList组件时提到这一点。

完整的InventoryApp代码清单

下面是InventoryApp组件的完整代码清单。

code/how_angular_works/inventory_app/app.ts

@Component({

 selector:'inventory-app',

 template:`

 <div class="inventory-app">

  <products-list

   [productList]="products"

   (onProductSelected)="productWasSelected($event)">

  </products-list>

 </div>

 `

})

class InventoryApp {

 products:Product[];

 constructor(){

  this.products = [

   new Product(

    'MYSHOES',

    'Black Running Shoes',

    '/resources/images/products/black-shoes.jpg',

    ['Men', 'Shoes', 'Running Shoes'],

    109.99),

   new Product(

    'NEATOJACKET',

    'Blue Jacket',

    '/resources/images/products/blue-jacket.jpg',

    ['Women', 'Apparel', 'Jackets & Vests'],

    238.99),

   new Product(

    'NICEHAT',

    'A Nice Black Hat',

    '/resources/images/products/black-hat.jpg',

    ['Men', 'Accessories', 'Hats'],

    29.99)

  ];

 }

 productWasSelected(product:Product):void {

  console.log('Product clicked:', product);

 }

}

3.5 产品列表组件

我们已经有了顶层应用组件,现在是时候编写用来展示产品列表的ProductsList组件了。

我们希望只允许用户选中一个Product,还希望可以知道哪个Product是用户当前选中的。ProductList组件是做这件事的绝佳场所,因为它同时“知道”所有的Product。

让我们分三步把ProductsList组件写完:

●设置ProductsList的@Component配置项;

●编写ProductsList的控制器类;

●编写ProductList的视图模板。

3.5.1 设置ProductsList的@Component配置项

我们来看看ProductsList的@Component配置。

code/how_angular_works/inventory_app/app.ts

/**

* @ProductsList:A component for rendering all ProductRows and

* storing the currently selected Product

*/

@Component({

 selector:'products-list',

 inputs:['productList'],

 outputs:['onProductSelected'],

 template:`

在ProductsList组件代码中,最开始是我们熟悉的selector选择器配置项。这个选择器表示我们可以通过在代码中放置<products-list>标签来使用ProductsList组件。

代码中还有两处inputs和outputs配置项。

3.5.2 组件的输入

我们可以用inputs配置项来指定组件希望接收哪些参数。inputs接收一个字符串数组,用来指定输入的键(名称)。

当我们为组件指定了一个输入时,这个组件的定义类就一定要有一个实例属性来接收这个输入的值。例如,假设我们有以下代码:

  @Component({

   selector:'my-component',

   inputs:['name', 'age']

  })

  class MyComponent {

   name:string;

   age:number;

  }

name和age输入分别对应于MyComponent类的实例中的name和age属性。

指定组件接收一个输入参数的另一种方式是使用@Input注解。你可以先导入Input,然后把@Input()添加到属性声明上,代码如下:

@Component({

 selector:'my-component'

})

class MyComponent {

 @Input()name:string;

 @Input()age:number;

}

如果我们要让该输入属性的内外名字不一样,可以这样写:@Input('firstname')name:String;。但是“Angular风格指南”建议避免这种方式。

 你可以任意选择这两种方式之一来提供输入属性,它们的效果是一样的。在本章中,我们将使用inputs:[]风格,而其他章节中则使用@Input()风格。

如果想使用其他模板中的MyComponent,就可以这样写:

<my-component [name]="myName" [age]="myAge"></my-component>。

注意,name属性对应name输入,也恰好与MyComponent中的name属性对应。不过这些名称并不一定要保持一致。

比如,假如我们希望标签元素的属性和组件实例中的属性使用不同的名称。也就是说,假如我们希望这个组件看起来像这样:

<my-component [shortName]="myName" [oldAge]="myAge"></my-component>

那么可以这样修改inputs配置项的字符串格式:

  @Component({

   selector:'my-component',

   inputs:['name:shortName', 'age:oldAge']

  })

  class MyComponent {

   name:string;

   age:number;

  }

一般而言,inputs输入字符串列表可以使用'componentProperty:exposedProperty'('组件实例属性:标签元素属性')的格式。

例如,我们可以像这样写一个组件:

  @Component({

   //……

   inputs:['name', 'age', 'enabled']

   //……

  })

  class MyComponent {

   name:string;

   age:number;

   enabled:boolean;

  }

然而,如果我们希望组件实例属性enabled在组件标签中对应的标签元素属性名称为isEnabled,就可以使用上面提到的这个语法:

  @Component({

   //……

   inputs:[

    'name:name',

    'age:age',

    'isEnabled:enabled'

   ]

   //……

  })

  class MyComponent {

   name:string;

   age:number;

   isEnabled:boolean;

  }

进一步说,由于只有一个属性需要明确指定从enabled映射到isEnabled,我们可以继续简化:

  @Component({

   //……

   inputs:['name', 'age', 'isEnabled:enabled']

   //……

  })

  class MyComponent {

   name:string;

   age:number;

   isEnabled:boolean;

  }

在inputs输入数组中,当字符串的值是key:value(键:值)格式的时候,含义如下:

(name、age和isEnabled)表示要输入的属性在控制器看来如何(被绑定)

(name、age和enabled)表示属性在外界看来如何

通过inputs配置项传递products

你应该还记得,在InventoryApp中,我们通过[productList]输入将products传到了products-list组件中。

code/how_angular_works/inventory_app/app.ts

/**

* @InventoryApp:the top-level component for our application

*/

@Component({

 selector:'inventory-app',

 template:`

 <div class="inventory-app">

  <products-list

   [productList]="products"

   (onProductSelected)="productWasSelected($event)">

  </products-list>

 </div>

 `

})

class InventoryApp {

 products:Product[];

 constructor(){

  this.products = [

希望你现在理解了:在上面的代码中,我们是通过ProductsList组件类的一个输入参数将this.products传进去的。

3.5.3 组件的输出

如果要从组件中把数据传递出去,应该使用输出绑定

假如我们要编写有一个按钮的组件,并且希望在这个按钮被点击的时候做点什么。

想实现这一点,只要把组件控制器中的一个方法绑定到按钮的点击输出就可以了。写法是(output)="action"。

下面是一个计数器的例子,点击按钮的时候可以对计数器进行增加或减少的操作。

@Component({

 selector:'counter',

 template:`

  {{ value }}

  <button(click)="increase()">Increase</button>

  <button(click)="decrease()">Decrease</button>

 `

})

class Counter {

 value:number;

 constructor(){

  this.value = 1;

 }

 increase(){

  this.value = this.value + 1;

  return false;

 }

 decrease(){

  this.value = this.value - 1;

  return false;

 }

}

在这个例子中,我们希望每次点击第一个按钮的时候,调用控制器中的increase()方法。同样,每次点击第二个按钮的时候,我们希望调用decrease()方法。

圆括号属性的语法是这样的:(output)="action"。这个例子中,我们是在监听按钮的click事件。还有很多内置的事件可以监听,如mousedown、mousemove、dbl-click等。

这个例子中,事件是组件内置的。当我们编写自己的组件时,可以暴露“公开事件”(组件的outputs)来和组件外部通信。

这里要理解的关键是,在视图中,我们可以使用(output)="action"语法来监听事件。

3.5.4 触发自定义事件

上面例子中的click和mousedown等是按钮内置的事件,现在我们要来创建一个可以触发自定义事件的组件。自定义输出,我们需要做三件事:

(1)在@Component配置中,指定outputs配置项;

(2)在实例属性中,设置一个EventEmitter(事件触发器);

(3)在适当的时候,通过EventEmitter触发事件。

可能你对EventEmitter还不太熟悉,不过别担心,它并不难。

EventEmitter只是一个帮你实现观察者模式的对象。也就是说,它是一个管理一系列订阅者并向其发布事件的对象。就是这么简单。

来看一个使用EventEmitter的简单小例子:

let ee = new EventEmitter();

ee.subscribe((name:string)=> console.log(`Hello ${name}`));

ee.emit("Nate");

// -> "Hello Nate"

当我们把一个EventEmitter赋值给一个输出的时候,Angular会自动帮我们订阅事件。我们不需要自己订阅。(当然,如果需要,你仍然可以实现自己的订阅逻辑。)

下面是一段具有outputs的组件示例代码:

@Component({

 selector:'single-component',

 outputs:['putRingOnIt'],

 template:`

  <button(click)="liked()">Like it?</button>

 `

})

class SingleComponent {

 putRingOnIt:EventEmitter<string>;

 constructor(){

  this.putRingOnIt = new EventEmitter();

 }

 liked():void {

  this.putRingOnIt.emit("oh oh oh");

 }

}

可以看到我们做了完整的三步动作:(1)指定outputs配置项;(2)创建一个EventEmitter并把它赋值给我们指定的输出属性putRingOnIt;(3)当liked方法被调用时,触发这个事件。

如果希望在一个父级组件中使用这个输出,可以这样做:

@Component({

 selector:'club',

 template:`

  <div>

   <single-component

    (putRingOnIt)="ringWasPlaced($event)"

    ></single-component>

  </div>

 `

})

class ClubComponent {

 ringWasPlaced(message:string){

  console.log(`Put your hands up:${message}`);

 }

}

// logged -> "Put your hands up:oh oh oh"

再来回顾一下:

●putRingOnIt是在SingleComponent的outputs配置项中定义的;

●ringWasPlaced是ClubComponent中的一个方法;

●$event包含被触发事件参数(输出的内容),在这个例子中是一个字符串。

3.5.5 编写ProductsList的控制器类

回到商店的例子,ProductsList控制器类需要三个实例变量:

●一个用来保存产品列表(来自于 productList 输入);

●一个用来输出事件(由onProductSelected触发);

●一个用来保存当前选中产品的引用。

下面是实现方法。

code/how_angular_works/inventory_app/app.ts

class ProductsList {

 /**

  * @input productList - the Product[] passed to us

  */

 productList:Product[];

 /**

  * @ouput onProductSelected - outputs the current

  *     Product whenever a new Product is selected

  */

 onProductSelected:EventEmitter<Product>;

 /**

  * @property currentProduct - local state containing

  *       the currently selected `Product`

  */

 private currentProduct:Product;

 constructor(){

  this.onProductSelected = new EventEmitter();

 }

可以看到,我们的productList是一个Product类型的数组,它来自于inputs。

onProductSelected是我们的输出。

currentProduct是ProductsList的一个内部属性。你可能知道它有时候被称作“组件本地状态”。它仅在组件的内部才能用到。

3.5.6 编写ProdctsList的视图模板

下面是products-list组件的template。

code/how_angular_works/inventory_app/app.ts

 template:`

 <div class="ui items">

  <product-row

   *ngFor="let myProduct of productList"

   [product]="myProduct"

   (click)='clicked(myProduct)'

   [class.selected]="isSelected(myProduct)">

  </product-row>

 </div>

 `

这里用到了ProductRow组件的product-row标签。我们稍后就来定义它。

我们用ngFor来迭代productsList中的每个Product。本书前面讨论过ngFor,但现在还是提醒一下。let thing of things语法是指,迭代things中的每一个元素,复制并把它赋值到变量thing中去。

因此,我们在这个例子中迭代了productList中的Products,并为每一个元素生成一个myProduct变量。

 从代码风格的角度,我不会在真实应用中把这个变量命名为myProduct,而是把它叫作product甚至 p。但为了把意思表达得更明确,我认为myProduct不太容易引起歧义。

有意思的是,我们甚至可以在同一个标签中使用这个myProduct变量。可以看到,接下来的三行代码里我们就是这么做的。

[product]="myProduct"是指我们要把myProduct(局部变量)传递给product-row的product输入。(我们会在下面定义ProductRow组件的时候定义这个输入。)

(click)='clicked(myProduct)'表示当元素被点击的时候我们希望做什么。click是一个内置事件,当点击宿主元素的时候就会触发。在这个例子中,当点击此元素时,就会执行ProductsList的clicked方法。

[class.selected]="isSelected(myProduct)"很有意思:Angular允许我们通过这种语法来根据不同的情况设置元素的class属性。这个语法的意思是“如果isSelected(myProduct)返回true,就给元素的CSS类增加一个selected类”。如果需要标记出当前选中的产品,这会非常好用。

你可能已经注意到了,我们还没有定义clicked和isSelected方法,那么现在就开始吧(在ProductsList中)。

clicked

code/how_angular_works/inventory_app/app.ts

 clicked(product:Product):void {

  this.currentProduct = product;

  this.onProductSelected.emit(product);

 }

该函数会做两件事:

(1)把this.currentProduct设置为传入的Product;

(2)将用户点击的Product从输出中传出去。

isSelected

code/how_angular_works/inventory_app/app.ts

 isSelected(product:Product):boolean {

  if(!product ||!this.currentProduct){

   return false;

  }

  return product.sku === this.currentProduct.sku;

 }

这个方法接收一个Product。如果这个product的sku与currentProduct的sku一样,就返回true;否则返回false。

3.5.7 完整的ProductsList组件

下面是一份完整的代码清单,我们可以看到代码的所有上下文。

code/how_angular_works/inventory_app/app.ts

/**

* @ProductsList:A component for rendering all ProductRows and

* storing the currently selected Product

*/

@Component({

 selector:'products-list',

 inputs:['productList'],

 outputs:['onProductSelected'],

 template:`

 <div class="ui items">

  <product-row

   *ngFor="let myProduct of productList"

   [product]="myProduct"

   (click)='clicked(myProduct)'

   [class.selected]="isSelected(myProduct)">

  </product-row>

 </div>

 `

})

class ProductsList {

 /**

  * @input productList - the Product[] passed to us

  */

 productList:Product[];

 /**

  * @ouput onProductSelected - outputs the current

  *     Product whenever a new Product is selected

  */

 onProductSelected:EventEmitter<Product>;

 /**

  * @property currentProduct - local state containing

  *       the currently selected `Product`

  */

 private currentProduct:Product;

 constructor(){

  this.onProductSelected = new EventEmitter();

 }

 clicked(product:Product):void {

  this.currentProduct = product;

  this.onProductSelected.emit(product);

 }

 isSelected(product:Product):boolean {

  if(!product ||!this.currentProduct){

   return false;

  }

  return product.sku === this.currentProduct.sku;

 }

}

3.6 产品条目组件

ProductRow组件用于展示Product(如图3-9所示)。ProductRow有自己的模板,但也会被分成三个更小的组件:

●ProductImage,用来展示图片;

●ProductDepartment,用来展示产品分类“面包屑导航”;

●PriceDisplay,用来展示产品价格。

图3-9 一个被选中的ProductRow组件

可以在图3-10中看到这三个组件在ProductRow中的使用。

图3-10 ProductRow的子组件

下面来看看ProductRow的组件配置、定义类和模板。

3.6.1 产品条目的组件配置

code/how_angular_works/inventory_app/app.ts

/**

* @ProductRow:A component for the view of single Product

*/

@Component({

 selector:'product-row',

 inputs:['product'],

 host:{'class':'item'},

 template:`

配置开头定义了product-row的selector。我们已经多次看到这个配置项了,这个定义说明组件会匹配product-row标签。

接下来,我们定义了一个名为product的输入。这个输入就是由父级组件传入的Product。

第三个配置项host让我们可以在宿主元素上配置元素属性。在这个例子中,我们设置了Semantic UI的item样式。host:{'class':'item'}的意思是,我们希望给宿主元素添加一个名为item的CSS类。

 host配置项很有用,因为可以在组件内部配置宿主元素。否则必须在宿主元素的HTML标签中定义CSS等;这样,每次使用该组件时,都需要手工编写CSS类,用起来就不方便了。

我们稍后就会讨论template模板。

3.6.2 产品条目组件的定义类

ProductRow组件的定义类很简明。

code/how_angular_works/inventory_app/app.ts

class ProductRow {

 product:Product;

}

这里我们定义ProductRow会有一个实例属性product。因为我们定义了一个输入product,所以每当Angular创建这个组件的实例时,都会自动帮我们设置好product。我们不需要手动去做,也不需要constructor。

3.6.3 产品条目组件的template

现在来看看template。

code/how_angular_works/inventory_app/app.ts

 template:`

 <product-image [product]="product"></product-image>

 <div class="content">

  <div class="header">{{ product.name }}</div>

  <div class="meta">

   <div class="product-sku">SKU #{{ product.sku }}</div>

  </div>

  <div class="description">

   <product-department [product]="product"></product-department>

  </div>

 </div>

 <price-display [price]="product.price"></price-display>

 `

我们的模板中没有什么新概念。

第一行使用了product-image指令,并把我们的product传递到ProductImage组件的product输入中。我们使用product-department指令时也是一样。

price-display指令的用法略有不同:我们没有直接传递product,而是传递了product.price。

剩下的模板只是带有自定义CSS样式和一些模板绑定的标准HTML元素。

3.6.4 完整的ProductRow代码清单

下面是ProductRow组件的全部代码。

code/how_angular_works/inventory_app/app.ts

/**

* @ProductRow:A component for the view of single Product

*/

@Component({

 selector:'product-row',

 inputs:['product'],

 host:{'class':'item'},

 template:`

 <product-image [product]="product"></product-image>

 <div class="content">

  <div class="header">{{ product.name }}</div>

  <div class="meta">

   <div class="product-sku">SKU #{{ product.sku }}</div>

  </div>

  <div class="description">

   <product-department [product]="product"></product-department>

  </div>

 </div>

 <price-display [price]="product.price"></price-display>

 `

})

class ProductRow {

 product:Product;

}

现在来看看我们用到的三个组件,其代码都很短。

3.7 产品图片组件

首先看看ProductImage。

code/how_angular_works/inventory_app/app.ts

/**

* @ProductImage:A component to show a single Product's image

*/

@Component({

 selector:'product-image',

 host:{class:'ui small image'},

 inputs:['product'],

 template:`

 <img class="product-image" [src]="product.imageUrl">

 `

})

class ProductImage {

 product:Product;

}

这里唯一需要注意的是img标签,请看看我们是怎么使用img中的[src]的。

我们本来可以这么写:

<!—— wrong, don't do it this way ——>

<img src="{{ product.imageUrl }}">

为什么这样写是错的?因为如果浏览器在Angular运行起来之前就加载了这段模板,就会尝试以字符串{{ product.imageUrl }}为url来加载图片,这当然会得到一个“404 not found”错误。在Angular运行起来之前,浏览器会在页面上显示一个破损的图像。

通过[src]元素属性,我们告诉Angular我们希望使用img标签的[src]输入。一旦表达式的值解析完成,Angular就会把src元素属性替换为表达式的值。

3.8 价格展示组件

下面来看看PriceDisplay组件。

code/how_angular_works/inventory_app/app.ts

/**

* @PriceDisplay:A component to show the price of a

* Product

*/

@Component({

 selector:'price-display',

 inputs:['price'],

 template:`

 <div class="price-display">\${{ price }}</div>

 `

})

class PriceDisplay {

 price:number;

}

这非常浅显,但要注意一点,因为在模板字符串中$是用于模板变量的特殊语法,所以在模板中出现$的写法时要进行转义。

3.9 产品分类组件

最后是ProductDepartment组件。

code/how_angular_works/inventory_app/app.ts

/**

* @ProductDepartment:A component to show the breadcrumbs to a

* Product's department

*/

@Component({

 selector:'product-department',

 inputs:['product'],

 template:`

 <div class="product-department">

  <span *ngFor="let name of product.department; let i=index">

   <a href="#">{{ name }}</a>

   <span>{{i <(product.department.length-1)? '>':''}}</span>

  </span>

 </div>

 `

})

class ProductDepartment {

 product:Product;

}

这里要说明一下ProductDepartment组件中的ngFor和span标签。

我们使用了ngFor来迭代product.department中的每个分类,并赋值给name。比较新鲜的写法是第二个表达式let i=index。这是在ngFor中取得迭代序号的方法。

在span标签中,我们使用变量i来判断是否需要显示大于号>。

我们希望像这样展示分类:

Women > Apparel > Jackets & Vests

表达式{{i <(product.department.length-1)? '>':''}}意味着,只要不是最后一级分类,就显示一个'>'号;如果是最后一级分类,就显示一个空字符串 ''。

 格式test ? valueIfTrue:valueIfFalse被称作三元操作符。

3.10 创建NgModule并启动应用

最后要做的就是创建NgModule并启动应用。

code/how_angular_works/inventory_app/app.ts

@NgModule({

 declarations:[

  InventoryApp,

  ProductImage,

  ProductDepartment,

  PriceDisplay,

  ProductRow,

  ProductsList

 ],

 imports:[ BrowserModule ],

 bootstrap:[ InventoryApp ]

})

class InventoryAppModule {}

为了帮助我们组织代码,Angular提供了一个模块化系统。AngularJS中的所有指令本质上都是全局的,但在Angular中必须明确指出你打算在应用中使用哪些组件。

虽然使用模块系统需要更多的配置,但对于较大型的应用来说,这能避免很大的麻烦。

要使用你在Angular中创建的新组件,它们必须对于当前模块是可访问的。也就是说,如果我们要在IntentoryApp的template中通过products-list标签使用ProductsList组件的话,就要保证InventoryApp满足下面的两个条件之一:

(1)和ProductsList组件在同一个模块中;

(2)InventoryApp所在的模块导入(imports)了ProductsList所在的模块。

 记住:如果要在模板中使用,每一个组件都必须在同一个NgModule中声明。

在这个例子里,我们将InventoryApp、ProductsList和应用中的所有其他组件都放在了同一个模块中。这样写容易理解,因为它们彼此之间都是“可见”的。

注意,我们告诉NgModule要以InventoryApp来启动(bootstrap)。这就是说InventoryApp会是顶层组件。

因为我们编写的是浏览器应用,所以也把浏览器模块BrowserModule放到这个NgModule的导入列表imports里。

 要了解NgModule的更多细节,请参考8.10节。

启动应用

我们现在编写的是一个没有用到AoT预编译技术(“ahead-of-time”compilation,本书后面会有详细讲解)的浏览器应用。想启动应用就要像下面这样做。

code/how_angular_works/inventory_app/app.ts

platformBrowserDynamic().bootstrapModule(InventoryAppModule);

3.11 完整的项目

现在我们已经有了让项目运行起来的所有部分!

全部完成后,应用看起来应该如图3-11所示。

图3-11 完成后的应用

 完整的代码可以在how_angular_works/inventory_app里找到,参考其中的README.md文件尝试运行。

现在你可以通过点击来选中一个特定的产品了,选中时会在外面显示一个漂亮的紫色边框。如果你在代码中添加了新的Product,它们也会在页面中展示出来。

3.12 关于数据架构的一点说明

你可能想知道,如果开始给应用添加更多功能,该如何管理数据流呢?

例如,假设我们要加入一个购物车界面以便添加和购买商品。这该如何实现呢?

目前唯一讨论过的方案就是触发输出事件。要在点击“添加到购物车”按钮时直接把addedToCart事件冒泡上去,然后在根节点处理吗?这种做法有点怪。

数据架构是一个庞大的主题,其中存在很多不同的观点。幸运的是,Angular可以广泛适应各种数据架构,但这也意味着你需要自己选择一种。

在AngularJS中,默认选项是双向绑定。双向绑定在开发的起步阶段非常好用:控制器保存数据,表单直接修改数据,视图显示数据。

不过双向绑定的问题是,它经常导致整个应用出现级联效应。随着项目规模的扩大,我们会越来越难于追踪数据的流向。

双向绑定的另一个问题是,由于我们的数据要通过组件下发,一般情况下“数据结构树”将不得不与“DOM结构树”相对应。但在实践中,最好把这两件事分开。

处理这种情况的方法之一是创建数据服务ShoppingCartService,这是一个保存当前购物车中商品列表的单例服务。当有数据变动时,这个服务就会通知所有相关的对象。

这个主意看起来够简单了,但在实践中还有很多需要解决的问题。

Angular中推荐的方式是采用一种叫作单向数据绑定的方案(在其他一些现代Web开发框架中也是一样,例如React)。也就是说,你的数据只会向下流入组件。如果你需要改变数据,就要在顶层触发事件,然后向下流至底层组件。

乍看起来,单向数据绑定可能反而额外增加了一些开销,但实际上它会大幅减轻变更检测相关的复杂度,还会使你的系统行为更具可预测性。

幸运的是,数据架构管理方面只有两个主要流派:

(1)使用基于观察者模式的架构,如RxJS;

(2)使用基于Flux的架构。

我们稍后会讨论如何为应用实现一个可扩展的数据架构,但就目前来说,基于组件的应用已经完成了,先好好享受成功的喜悦吧!

  1. https://learnxinyminutes.com/docs/typescript/
  2. https://angular.io/docs/ts/latest/guide/style-guide.html
  3. https://en.wikipedia.org/wiki/Observer_pattern
  4. http://semantic-ui.com/views/item.html