Angular Crash Tutorial


1.Angular 快速入门

这是一个非常基础的快速入门教程,比较注重概念模型的构建。掌握这门框架的精髓,可以这门理解:

  • 当有人提到 Spring 的时候,你的大脑里面第一个想到的一定是 DI、IoC、AOP 这些核心概念;
  • 当有人提到 Hibernate、MyBatis、JPA 的时候,你的大脑里面立即会浮现出 ORM 的概念;
  • 当有人提到 React 的时候,你想到的应该是 VDOM、JSX;
  • 当有人提到 jQuery 的时候,你首先想到的应该是 $,对吧?

因此,可以看到,任何一个成功的框架都有自己独创的“概念模型”,或者叫“核心价值”也可以,这是框架本身存在的价值,这些核心概念是你掌握这门框架应该紧扣的主线,而不是上来就陷入到茫茫多的技术细节里面去。

1.1 Angular 的核心概念模型

既然如此,问题就来了,新版本的 Angular 的核心概念是什么呢?
非常简单,一切都是围绕着“组件”(Component)的概念展开的:

Component

  • Component(组件)是整个框架的核心,也是终极目标。“组件化”的意义有 2 个:第一是分治,因为有了组件之后,我们可以把各种逻辑封装在组件内部,避免混在一起;第二是复用,封装成组件之后不仅可以在项目内部复用,而且可以沉淀下来跨项目复用。
  • NgModule(模块)是组织业务代码的利器,按照你自己的业务场景,把组件、服务、路由打包到模块里面,形成一个个的积木块,然后再用这些积木块来搭建出高楼大厦。
  • Router(路由)的角色也非常重要,它有 3 个重要的作用:第一是封装浏览器的 History 操作;第二是负责异步模块的加载;第三是管理组件的生命周期。

所以,在这个系列的文章里面,Component、NgModule、Router 加起来会占据绝大部分篇幅,而一些琐碎的小特性会被忽略掉。我相信,你只要紧扣“组件化”这个主线,就能站在一个很高的角度统摄全局,从而掌握到这门框架的精髓。

1.2 一些常见的问题

浏览器兼容性

关于 Angular 的浏览器兼容性,请看下图:

有一些国内的开发者会来争论兼容 IE 的问题,下面展示一些事实:

  • 第一个事实是:天猫已经于 2016 年 4 月宣布放弃支持 IE6、7、8。而根据百度流量研究院的统计,截止到 2019 年 5 月,IE8 以下的浏览器在国内也只有 5.69% 的份额了:

数据来源,不值得为了这么少的市场份额付出那么多的研发和维护成本。

  • 第二个事实是:截至 2019 年 5 月底,Chrome 的全球市场份额已经高达 61.06%,加上 Safari 、Firefox 的份额,所有这些能完美支持 Web 标准的浏览器加起来,份额已经远远超过 80%。

数据来源

  • 第三个事实是:微软 2018 年底宣布,后续新的浏览器会采用 Chromium 内核,并且已经与 2019 年初给出了预览版。如果有兴趣,可以到微软的官方网站来下载。这就意味着,到 2019 年底的时候,基于 Chrome 内核的浏览器全球市场份额将会达到 85% 左右。因此,请不要再花那么多钱和时间来解决“浏览器兼容性问题”了,后面根本就不存在这个问题!

你完全可以用以上事实去说服你的客户。

命名约定

老版本使用 AngularJS 指代,所有新版本都叫做 Angular。原因很好理解,因为老版本是用 JS 开发的,所以带一个 JS 后缀,而新版本是基于 TypeScript 的,带 JS 后缀不合适。

关于TypeScript

这个系列的文章不会单独讲 TypeScript,TypeScript 不难,JavaScript 才难。
TypeScript 不该成为学习Angular的障碍。相反,一旦写熟练了之后,TypeScript 可以非常有效地提升编码效率和程序可读性。

关于 Angular 的版本

官方的版本发布计划是:

  • 每 6 个月发布一个主版本(第一位版本号,主版本)
  • 每个主版本发布 1 到 3 个小版本(第二位版本号,feature 版本号)
  • 每周发布一个补丁版本(第三位版本号,hotfix 版本号)

根据官方的解释,Angular 2.0 之后会保证向下兼容,只有升级主版本的时候才会做一些 breaking change。所以这个系列文章里面不再强调版本号,涉及到的所有实例代码都已经升级到了当前最新的 8.0 版本(2019-07)。

1.3 内容结构

本专栏共分 3 大部分:

  • 第一部分:从第 1 篇到第 10 篇,围绕组件、模块、路由三大概念,兼顾服务、RxJS、表单、i18n 等小工具,全面解释 Angular 的基本用法。
  • 第二部分:第 11 篇,专门解释依赖注入,这是 Angular 比较有特色的内容,这部分内容比较有深度,虽然在日常开发中使用不多,但是理解它能够更加深入理解 Angular。
  • 第三部分:从第 12 篇到第 16 篇,介绍产品级案例项目 OpenWMS、PWA 案例、一些参考资源以及三个新版本特性附录

2.快速搭建开发环境

2.1 Node.js

2009 年,Node.js 发布了第一个版本,标志着前端开发正式告别了刀耕火种的原始状态,开始进入工业化时代。

在 Node.js 出现之前,前端开发领域有很多事情我们是做不到的,比如:

  • JS 代码的合并、压缩、混淆
  • CSS 预处理
  • 前端自动化测试

而这一切在 Node.js 出现之后都得到了很好的解决。

  • 对 JS 代码的预处理经历了 Grunt、Gulp 的短暂辉煌之后,终于在 Webpack 这里形成了事实标准的局面。
  • CSS 的预处理也从 LESS 发展到了 SASS 等。
  • 自动化测试一直是前端开发中的一个巨大痛点,由于前端在运行时严重依赖浏览器环境,导致我们一直无法像测试后端代码那样可以去编写测试用例。在有了 Node.js 之后,我们终于有了 Karma + Jasmine 这样的单元测试组合,也有了基于 WebDriverJS 这样的可以和浏览器进行通讯的集成测试神器。

就前端开发目前整体的状态来说,无论你使用什么框架,Node.js、Webpack、SASS、Karma + Jasmine、WebDriverJS 这个组合是无论如何绕不过去的。如果你用过其他前端框架的话,就知道手动配置这些东西有多痛苦了,那一坨配置文件没有半天功夫是搞不定的。

2.2 @angular/cli

在开发 Angular 应用的时候,当然也离不开大量基于 Node.js 的工具,我们需要 TypeScript compiler、Webpack、Karma、Jasmine、Protracter 等模块。

有相关经验的开发者都知道,自己从头开始去搭建一套基于 webpack 的开发环境是一件非常麻烦的事情。很多初学者在搭建环境这一步上面消耗了过多的精力,导致学习热情受到了沉重的打击。

当团队规模比较大的时候,在每个人的机器上配置环境需要消耗大量的时间。有一些团队为了避开这个坑,利用 Docker 来做开发环境的同步和版本升级,看起来也是一个非常不错的方案。

Angular 项目组从一开始就注意到了这个问题,因此有了 @angular/cli 这个神器,它的底层基于 webpack,集成了以上提到的所有 Node.js 组件。你只要装好 @angular/cli 就够了,而不需要自己从头一步一步安装那些 Node.js 插件。

当然,在安装 @angular/cli 之前需要先把 Node.js 安装好,请到官方网站下载安装包 ,安装过程和普通软件没有区别。装好 Node.js 之后就可以安装 @angular/cli 了,由于 npm 会自动访问海外的服务器,因而强烈推荐使用 cnpm 进行安装:

npm i -g cnpm --registry=https://registry.npm.taobao.org

cnpm i -g @angular/cli

cnpm 是淘宝发布的一款工具,会自动把 npm 上面的所有包定时同步到国内的服务器上来(目前大约 10 分钟全量同步一次),cnpm 本身也是一款 Node.js 模块。由于 cnpm 的服务器在国内,因而中文开发者用它装东西比较快。除了定时同步 npm 模块之外,cnpm 还做了一些其他的事情,比如把某些包预先编译好了缓存在服务器上,这样就不用拉源码到你本地进行编译了。有人抱怨使用 cnpm 安装的目录结构和 npm 不同,包括还有其他一些小坑,如果你非常在意这些,可以使用 nrm 来管理多个 registry。nrm 本身也是一个 Node.js 模块,你可以这样安装:

npm i -g nrm

然后你就可以用 nrm 来随时切换 registry 了,比如:

nrm use cnpm

这样就不用每次都用 cnpm 进行安装了,直接使用 npm 即可。

@angular/cli 安装成功之后你的终端里面将会多出一个名叫 ng 的命令,敲下 ng,将会显示完整的帮助文档:

官方文档里面列出了所有这些命令的含义和示例,链接在这里但是请注意,官方提供的 cli 文档过于简单,有非常多的细节都没有提到,所以你懂的,请跟着我的 demo 走。

2.3 创建第一个项目

我们来创建第一个入门项目 HelloAngular,请在你的终端里面运行:

ng new HelloAngular

@angular/cli 将会自动帮你把目录结构创建好,并且会自动生成一些模板化的文件,就像这样:

请特别注意: @angular/cli 在自动生成好项目骨架之后,会立即自动使用 npm 来安装所依赖的 Node 模块,因此这里我们要 Ctrl+C 终止掉,然后自己进入项目的根目录,使用 cnpm 来进行安装。

enter image description here

安装完成之后,使用 ng serve 命令启动项目:

enter image description here

打开你的浏览器,访问默认的 4200 端口,看到以下界面说明环境 OK 了:

请注意以下几点。

  • 这里是 serve,不是 server,serve 是服务,动词;server 是服务器,名词。,我看到一些初学者经常坑在这个地方。
  • 如果你需要修改端口号,可以用 ng serve –port **** 来进行指定。
  • ng serve –open 可以自动打开你默认的浏览器。
  • 如果你想让编译的包更小一些,可以使用 ng serve –prod,@angular/cli 会启用 TreeShaking 特性,加了参数之后编译的过程也会慢很多。因此,在正常的开发过程里面请不要加 –prod 参数。
  • ng serve 是在内存里面生成项目,如果你想看到项目编译之后的产物,请运行 ng build。构建最终产品版本可以加参数,ng build –prod。

ng 提供了很多非常好用的工具,除了可以利用 ng new 来自动创建项目骨架之外,它还可以帮助我们创建 Angular 里面所涉及到的很多模块,最常用的几个如下。

  • 自动创建组件:ng generate component MyComponent,可以简写成ng g c MyComponent。创建组件的时候也可以带路径,如 ng generate component mydir/MyComponent
  • 自动创建指令:ng g d MyDirective
  • 自动创建服务:ng g s MyService
  • 构建项目:ng build,如果你想构建最终的产品版本,可以用 ng build –prod

更多的命令和参数请在终端里面敲 ng g –help 仔细查看,尽快熟悉这些工具可以非常显著地提升你的编码效率。

2.4 一些常见的坑

@angular/cli 这种“全家桶”式的设计带来了很大的方便,同时也有一些人不太喜欢,因为很多底层的东西被屏蔽掉了,开发者不能天马行空地自由发挥。比如,@angular/cli 把底层 webpack 的配置文件屏蔽掉了,很多喜欢自己手动配 webpack 的开发者就感到很不爽。

对于国内的开发者来说,上面这些其实不是最重要的,国内开发者碰到的坑主要是由两点引起的:

  • 第一点是网络问题,比如 node-sass 这个模块你很有可能就安装不上,原因你懂的;
  • 第二点是开发环境导致的问题,国内使用 Windows 平台的开发者比例依然巨大,而 @angular/cli 在 Windows 平台上有一些非常恶心的依赖,比如它需要依赖 Python 环境、Visual Studio 环境,这是因为某些 Node.js 的模块需要下载到你的本地进行源码编译。

因此,如果你的开发平台是 Windows,请特别注意:

  • 如果你知道如何给 npm 配置代理,也知道如何翻墙,请首选 npm 来安装@angular/cli 。
  • 否则,请使用 cnpm 来安装 @angular/cli,原因有三:
    (1)cnpm 的缓存服务器在国内,你装东西的速度会快很多;
    (2)用 cnpm 可以帮你避开某些模块装不上的问题,因为它在服务器上面做了缓存;
    (3)cnpm 还把一些包都预编译好了缓存在服务端,比如 node-sass。使用 cnpm 不需要在你本地进行源码编译,因此你的机器上可以没有那一大堆麻烦的环境。
  • 推荐装一个 nrm 来自动切换 registry:npm i -g nrm。
  • 如果 cli 安装失败,请手动把 node_modules 目录删掉重试一遍,全局的 @angular/cli 也需要删掉重装,cnpm uninstall -g @angular/cli。
  • 如果 node_modules 删不掉,爆出路径过长之类的错误,请尝试用一些文件粉碎机之类的工具强行删除。这是 npm 的锅,与 Angular 无关。
  • 最新版本的 @angular/cli 经常会有 bug,尤其是在 Windows 平台上面,因此请不要追新版本追太紧。如果你发现了莫名其妙的问题,请尝试降低一个主版本试试。这一点非常重要,很多初学者会非常困惑,代码什么都没改,就升级了一下环境,然后就各种编译报错。如果你愿意,去官方提 issue 是个很不错的办法
  • 对于 MAC 用户或者 *nix 用户,请特别注意权限问题,命令前面最好加上 sudo,保证有 root 权限。
  • 无论你用什么开发环境,安装的过程中请仔细看 log。很多朋友没有看 log 的习惯,报错的时候直接懵掉,根本不知道发生了什么。

2.5 VS Code

如你所知,一直以来,前端开发领域并没有一款特别好用的开发和调试工具。

  • WebStorm 很强大,但是吃资源很严重。
  • Sublime Text 插件很多,可惜要收费,而国内的企业还没有养成花钱购买开发工具的习惯。
  • Chrome 的开发者工具很好用,但是要直接调试 TypeScript 很麻烦。

因此,Visual Studio Code(简称 VS Code)才会呈现出爆炸性增长的趋势。它是微软开发的一款前端编辑器,完全开源免费。VS Code 底层是 Electron,界面本身是用 TypeScript 开发的。对于 Angular 开发者来说,当然要强烈推荐 VS Code。最值得一提的是,从 1.14 开始,可以直接在 VS Code 里面调试 TypeScript 代码。

第一步:环境配置

  • 确保 Chrome 安装在默认位置。
  • 确保 VS Code 里面安装了 Debugger for Chrome 这个插件。
  • 把 @angular/cli 安装到全局空间 npm install -g @angular/cli,国内用户请使用 cnpm 进行安装。注意,你最好升级到最新版本的 @angular/cli,避免版本兼容问题。
  • 用 @angular/cli 创建新项目 ng new my-app,本来就已经用 @angular/cli 创建的项目请忽略这一步,继续往下走,因为只要是 cli 创建的项目,后面的步骤都是有效的。
  • 用 VS Code 打开项目,进入项目根目录。

第二步:配置 launch.json

enter image description here

请参照以上步骤打开 launch.json 配置文件。

enter image description here

请把你本地 launch.json 文件里面的内容改成这样:

{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "chrome",
            "request": "launch",
            "name": "Chrome",
            "url": "http://localhost:4200",
            "webRoot": "${workspaceRoot}"
        }
    ]
}

第三步:开始 Debug

在你的 app.component.ts 的构造函数里面打个断点,我本地是这样打断点的:

enter image description here

打开终端,进入项目根目录,运行 ng serve 启动项目,然后从 VS Code 的 debug 界面启动 Chrome

注意,你可能需要 F5 刷新一下 Chrome 才能进入断点!

enter image description here

VSCode 的插件市场上有大量的插件可供选择,比如彩虹缩进、智能提示、自动补齐标签之类的功能,将会大幅度提升你的开发效率,这里列出了 10 款我自己日常使用的插件供你参考,详见这里

2.6 webpack-bundle-analyzer

在真实的业务项目中,我们会用到大量的第三方开源组件,例如图形库 ECharts、组件库 PrimeNG 等。

有很多开发者在引入这些组件库之后,没有注意到体积问题,导致最终编译出来的包体积过大,比如我自己的 OpenWMS 项目,以下是 build 出来的体积:

用 webpack-bundle-analyzer 分析之后可以看到各个模块在编译之后所占的体积:

可以看到,主要是因为 ECharts 和 PrimeNG 占的体积比较大,建议在使用的时候做一下异步,用不到的组件不要一股脑全部导入进来。

webpack-bundle-analyzer 的用法和详细文档详见这里

2.7 小结

目前,无论你使用什么前端框架,都必然要使用到各种 Node.js 工具,Angular 也不例外。

与其他框架不同,Angular 从一开始就走的“全家桶”式的设计思路,因此 @angular/cli 这款工具里面集成了日常开发需要使用的所有 Node 模块,使用 @angular/cli 可以大幅度降低搭建开发环境的难度。

3.Schematics与代码生成器

3.1 @angular/cli 内置的add命令

6.0 的时候 @angular/cli 新增了一个命令 ng add。
以前,如果我们需要引用一些第三方的 UI 库或者工具库,只能自己手动安装配置,过程中需要修改很多配置文件,非常繁琐,ng add 主要就是用来解决这个问题的。
如果你引用的第三方库支持了 ng add,那么整个过程全部是自动化的,以下这些事情 ng add 都会帮你自动完成:

  • 自动修改 package.json
  • 自动使用 npm 安装依赖包
  • 自动修改相关的配置文件,如果有的话
  • 自动修改对应的模块引用文件
  • 自动修改一些 CSS 样式文件

接下来我们就来上手试一试 ng add 命令的用法,以官方的例子为基础,在上面做一些改进。

第一步:创建一个新项目

ng new learn-ng-add
  • 创建过程中选择需要 router 模块
  • 样式语法选择 SCSS

第二步:自动引入 @angular/material

在项目根目录里面执行:

ng add @angular/material

ng Add
可以看到,ng add 帮我们自动修改了这些文件:
ng modify file
国内的开发者请注意:官方提供的 Material 这个 UI 组件库会自动应用 Google 的一些字体文件,这一点比较讨厌,你需要手动把这些字体文件下载到项目里面,然后引用你自己项目中的路径。

第三步:自动创建 Material 风格导航栏

ng generate @angular/material:material-nav --name=my-nav

Navigation

第四步:使用上面的导航栏

稍稍修改一些代码,来使用上面自动生成的导航栏。
把 app.component.html 里面的内容清空,然后使用上面新建的导航栏:

<app-my-nav></app-my-nav>

用 ng serve 启动项目,然后你就可以看到 Material 风格的导航条了:
material navigation

第五步:继续修改,做一个完整的例子

我们在以上内容的基础上继续修改:继续生成两个组件,然后加上异步路由配置,就可以得到了一个典型的项目界面了。
注意:为了使用异步路由,我们这里在官方的例子上面做了一些改进,请参考我下面的步骤。

ng g module myDashboard 
ng g @angular/material:material-dashboard --name=my-dashboard 
ng g module myTable 
ng g @angular/material:material-table --name=my-table

此时我们的项目代码结构如下:
Folder Structure
接下来:

  • 请参考 app.routing.module.ts 里面的路由配置,给上面的两个异步模块都加上独立的路由配置。
  • 修改导航条里面的 routerLink,指向对应的路由配置。

然后就可以得到两个很漂亮的界面了:
NiceUI1
NiceUI2
从这个例子你可以看到,ng add 和 ng generate 的功能非常强大,日常工作中的那些任务大多数能自动完成,只有很少的部分需要手动修改。

3.2 ng add背后的Schematics代码生成器

请注意:初学者可以跳过这一段,这块是比较高级的内容,等你用熟悉了之后再回来研究不迟。单独使用 Schematics 比较麻烦,因为模板本身也是代码,也是需要维护的。另外,Schematics 特性本身还处于“实验”状态,官方提供的文档不全。

@angular/cli 内部用来自动生成代码的工具叫做 Schematics :
Schematics
能支持的所有 Schematics 如下:
Schematics  List
如上图,当我们使用 ng g c <组件名> 的时候,它实际上调用了底层的 Schematics 来生成组件对应的 4 个文件。

Schematics 是框架无关的,它可以脱离 Angular 环境使用,因此你也可以把它单独拿出来,用来自动生成其他框架的代码。为了演示自定义 Schematic 的方法,我已经整合好了 2 个项目,请看运行效果:
Schematics
请特别注意:由于 @angular/schematics 是 cli 工具的组成部分,它的版本号与 cli 之间有对应关系。因此,如果你不确定对应关系是什么,请不要修改以上两个示例项目中的 package.json,升级必挂。 更多文档和模板语法请参考这里
你可以利用 Schematics 来创建自己的代码生成器,可以参考以下步骤:

  • npm i -g @angular-devkit/schematics-cli
  • 用 schematics 命令创建一个新项目 schematics blank –name=learn-schematics
  • 创建 schema.json 和 schema.ts 接口,修改 collection.json,指向自己创建的 shema.json 配置文件
  • 修改 index.ts ,加一些生成代码的逻辑,可以参考 @angluar/cli 内部的代码
  • 创建 files 目录和模板文件,目录名和文件名本身也可以参数化
  • 构建项目:npm run build
  • 链接到全局,方便本地调试:npm link
  • 准备测试 schema ,用 @angular/cli 创建一个全新的项目 test-learn-schematics 并装好依赖。cd 到新项目 test-learn-schematics,链接 npm link learn-schematics,然后尝试用我们自定义的规则来生成一个组件 ng g my-component My –service –name=”damo” –collection learn-schematics –force

3.3 Workspace 与多项目支持

从 6.0 开始,@angular/cli 支持 workspace 特性,之所以能支持 workspace,也是因为背后有 Schematics 这个底层的工具。

有了 workspace 这个机制之后,可以在一个项目里面配置多个子项目,cli 会根据里面的配置进行依赖管理、校验、编译等等操作。

workspace

关于 workspace 官方的完整文档在这里

3.4 参考资源

4.[组件]概述

几乎所有前端框架都在玩“组件化”,而且最近都不约而同地选择了“标签化”这种思路,Angular 也不例外。

对新版本的 Angular 来说,一切都是围绕着“组件化”展开的,组件是 Angular 的核心概念模型。

以下是一个最简单的 Angular 组件定义:

Component

  • @Component:这是一个 Decorator(装饰器),其作用类似于 Java 里面的 Annotation(注解)。Decorator 这个特性目前处于 Stage 2(草稿)状态,还不是 ECMA 的正式规范,具体可参考这里
  • selector:组件的标签名,外部使用者可以这样来使用以上组件:。默认情况下,ng 命令生成出来的组件都会带上一个 app 前缀,如果你不喜欢,可以在 angular-cli.json 里面修改 prefix 配置项,设置为空字符串将会不带任何前缀。
  • templateUrl:引用外部 HTML 模板。如果你想直接编写内联模板,可以使用 template,支持 ES6 引入的“模板字符串”写法,具体可参考这里
  • styleUrls:引用外部 CSS 样式文件,这是一个数组,也就意味着可以引用多份 CSS 文件。
  • export class AppComponent:这是 ES6 里面引入的模块和 class 定义方式。

5.[组件]把 CSS 预编译器改成 SASS

SASS 是一款非常好用的 CSS 预编译器,Bootstrap 官方从 4.0 开始已经切换到了 SASS。

5.1 创建项目的时候指定

@angular/cli 当前(2019-06)最新的版本是 8.0,可以支持多款 CSS 预编译器,你可以在创建项目的过程中指定:

scss

5.2 手动修改

某些老项目可能需要手动修改预编译器的类型,所以我把手动修改的方法也留下来备查。

目前(2019-06),@angular/cli 创建项目的时候没有自动使用 SASS 作为预编译器,我们需要自己手动修改一些配置文件,请按照以下步骤依次修改:

  • angular-cli.json 里面的 styleExt 改成 scss

    styleExt

当后面再使用 ng g c *** 自动创建组件的时候,默认就会生成 .scss 后缀的样式文件了。

  • angular-cli.json 里面的 styles.css 后缀改成 .scss

scss

  • src 下面 style.css 改成 style.scss

    scss

  • app.component.scss

    scss

  • app.component.ts 里面对应修改

    scss

改完之后,重新 ng serve,打开浏览器查看效果。

SASS 的 API 请参考官方网站

SASS 只是一个预编译器,它支持所有 CSS 原生语法。利用 SASS 可以提升你的 CSS 编码效率,增强 CSS 代码的可维护性,但是千万不要幻想从此就可以不用学习 CSS 基础知识了。

6.[组件]模板的使用

模板是编写 Angular 组件最重要的一环,你必须深入理解以下知识点才能玩转 Angular 模板:

  • 对比各种 JS 模板引擎的设计思路
  • Mustache(八字胡)语法
  • 模板内的局部变量
  • 属性绑定、事件绑定、双向绑定
  • 在模板里面使用结构型指令 ngIf、ngFor、ngSwitch
  • 在模板里面使用属性型指令 NgClass、NgStyle、NgModel
  • 在模板里面使用管道格式化数据
  • 一些小 feature:安全导航、非空断言

“深入理解”的含义是:你需要很自如地运用这些 API,写代码的时候不翻阅 API 文档。

因为很多新手之所以编码效率不高,其中一个主要的原因就是在编码过程中不停翻文档、查资料。

6.1 对比各种 JS 模板引擎的设计思路

几乎每一款前端框架都会提供自己的模板语法:

  • 在 jQuery 如日中天的时代,有 Handlebars 那种功能超强的模板
  • React 推崇 JSX 模板语法
  • 当然还有 Angular 提供的那种与“指令”紧密结合的模板语法

综合来说,无论是哪一种前端模板,大家都比较推崇“轻逻辑”(logic-less)的设计思路。
何为“轻逻辑”?
简而言之,所谓“轻逻辑”就是说,你不能在模板里面编写非常复杂的 JavaScript 表达式。比如,Angular 的模板语法就有规定:

  • 你不能在模板里面 new 对象
  • 不能使用 =、+=、-= 这类的表达式
  • 不能用 ++、– 运算符
  • 不能使用位运算符

为什么要“轻逻辑”?
最重要的原因是怕影响运行性能,因为模板可能会被执行很多次。
比如你编写了以下 Angular 模板:

<ul>
    <li *ngFor="let race of races">
        {{race.name}}
    </li>
</ul>

很明显,浏览器不认识 *ngFor 和 这种语法,因此必须在浏览器里面进行“编译”,获得对应的模板函数,然后再把数据传递给模板函数,最终结合起来获得一堆 HTML 标签,然后才能把这一堆标签插入到 DOM 树里面去。

如果启用了 AOT,处理的步骤有一些变化,@angular/cli 会对模板进行“静态编译”,避免在浏览器里面动态编译的过程。

而 Handlebars 这种模板引擎完全是运行时编译模板字符串的,你可以编写以下代码:

//定义模板字符串
var source=`
<ul>
    {{#each races}}
        <li>{{name}}</li>
    {{/each}}
</ul>
`;

//在运行时把模板字符串编译成 JS 函数
var templateFn=Handlebars.compile(source);

//把数据传给模板函数,获得最终的 HTML

var html=templateFn([
    {name:'人族'},
    {name:'神族'},
    {name:'虫族'}
]);`

注意到 Handlebars.compile 这个调用了吧?这个地方的本质是在运行时把模板字符串“编译”成了一个 JS 函数。

鉴于 JS 解释执行的特性,你可能会担忧这里会有性能问题。这种担忧是合理的,但是 Handlebars 是一款非常优秀的模板引擎,它在内部做了各种优化和缓存处理。模板字符串一般只会在第一次被调用的时候编译一次,Handlebars 会把编译好的函数缓存起来,后面再次调用的时候会从缓存里面获取,而不会多次进行“编译”。

上面我们多次提到了“编译”这个词,因此很显然这里有一个东西是无法避免的,那就是我们必须提供一个 JS 版的“编译器”,让这个“编译器”运行在浏览器里面,这样才能在运行时把用户编写的模板字符串“编译”成模板函数。

有一些模板引擎会真的去用 JS 编写一款“编译器”出来,比如 Angular 和 Handlebars,它们都真的编写了一款 JS(TS)版的编译器。而有一些简单的模板引擎,例如 Underscore 里面的模板函数,只是用正则表达式做了字符串替换而已,显得特别简陋。这种简陋的模板引擎对模板的写法有非常多的限制,因为它不是真正的编译器,能支持的语法特性非常有限。

因此,评估一款模板引擎的强弱,最核心的东西就是评估它的“编译器”做得怎么样。但是不管怎么说,毕竟是 JS 版的“编译器”,我们不可能把它做得像 G++ 那么强大,也没有必要做得那么强大,因为这个 JS 版的编译器需要在浏览器里面运行,搞得太复杂浏览器拖不动!

以上就是为什么大多数模板引擎都要强调“轻逻辑”的最根本原因。

对于 Angular 来说,强调“轻逻辑”还有另一个原因:在组件的整个生命周期里面,模板函数会被执行很多次。你可以想象,Angular 每次要刷新组件外观的时候,都需要去调用一下模板函数,如果你在模板里面编写了非常复杂的代码,一定会增加渲染时间,用户一定会感到界面有“卡顿”。

人眼的视觉延迟大约是 100ms 到 400ms 之间,如果整个页面的渲染时间超过 400ms,界面基本上就卡得没法用了。有一些做游戏的开发者会追求 60fps 刷新率的细腻感觉,60 分之 1 秒约等于 16.7ms,如果 UI 整体的渲染时间超过了 16.7ms,就没法达到这个要求了。

轻逻辑(logic-less)带来了效率的提升,也带来了一些不方便,比如很多模板引擎都实现了 if 语句,但是没有实现 else,因此开发者们在编写复杂业务逻辑的时候模板代码会显得非常啰嗦。

目前来说,并没有完美的方案能同时兼顾运行效率和语法表现能力,这里只能取一个平衡。

6.2 Mustache 语法

Mustache 语法也就是你们说的双花括号语法 ,老外觉得它像八字胡子,很奇怪啊,难道老外喜欢侧着头看东西?
好消息是,很多模板引擎都接受了 Mustache 语法,这样一来学习量又降低了不少,开心吧?

关于 Mustache 语法,你需要掌握 3 点:

  • 它可以获取到组件里面定义的属性值
  • 它可以自动计算简单的数学表达式,如加减乘除、取模
  • 它可以获得方法的返回值

请依次看例子。
插值语法关键代码实例:

<h3>
    欢迎来到{{title}}!
</h3>
public title = '假的星际争霸2'; 

//简单的数学表达式求值:
<h3>1+1={{1+1}}</h3>

//调用组件里面定义的方法:
<h3>可以调用方法{{getVal()}}</h3>
public getVal():any{
    return 65535;
}

6.3 模板内的局部变量

<input #heroInput>
<p>{{heroInput.value}}</p>

有一些同学会追问,如果我在模板里面定义的局部变量和组件内部的属性重名会怎么样呢?
如果真的出现了重名,Angular 会按照以下优先级来进行处理:

模板局部变量 > 指令中的同名变量 > 组件中的同名属性。

这种优先级规则和 JSP 里面的变量取值规则非常类似,对比一下很好理解对不对?你可以自己写代码测试一下。

6.4 值绑定

值绑定是用方括号来做的,写法:

<img [src]="imgSrc" />
public imgSrc:string="./assets/imgs/1.jpg";

很明显,这种绑定是单向的。

6.4 事件绑定

事件绑定是用圆括号来做的,写法:

<button class="btn btn-success" (click)="btnClick($event)">测试事件</button>

对应 Component 内部的方法定义:

public btnClick(event):void{
    alert("测试事件绑定!");
}

6.5 双向绑定

双向绑定是通过方括号里面套一个圆括号来做的,模板写法:

<font-resizer [(size)]="fontSizePx"></font-resizer>

对应组件内部的属性定义:

public fontSizePx:number=14;

AngularJS 是第一个把“双向数据绑定”这个特性带到前端来的框架,这也是 AngularJS 当年最受开发者追捧的特性,之一。

根据 AngularJS 团队当年讲的故事,“双向数据绑定”这个特性可以大幅度压缩前端代码的规模。大家可以回想一下 jQuery 时代的做法,如果要实现类似的效果,是不是要自己去编写大量的代码?尤其是那种大规模的表单,一大堆的赋值和取值操作,都是非常丑陋的“面条”代码,而有了“双向数据绑定”特性之后,一个绑定表达式就搞定。

目前,主流的几款前端框架都已经接受了“双向数据绑定”这个特性。

当然,也有一些人不喜欢“双向数据绑定”,还有人专门写了文章来进行批判,也算是前端一景。

6.6 在模板里面使用结构型指令

Angular 有 3 个内置的结构型指令:ngIf、ngFor、ngSwitch。ngSwitch 的语法比较啰嗦,使用频率小一些。

*ngIf 代码实例:

<p *ngIf="isShow" style="background-color:#ff3300">显示还是不显示?</p>
<button class="btn btn-success" (click)="toggleShow()">控制显示隐藏</button>
public isShow:boolean=true;
public toggleShow():void{
    this.isShow=!this.isShow;
}

*ngFor 代码实例:


<li *ngFor="let race of races;let i=index;">
    {{i+1}}-{{race.name}}
</li>
public races:Array=[
    {name:"人族"},
    {name:"虫族"},
    {name:"神族"}
]; 

*ngSwitch 代码实例:

<div [ngSwitch]="mapStatus">
    <p *ngSwitchCase="0">下载中...</p>
    <p *ngSwitchCase="1">正在读取...</p>
    <p *ngSwitchDefault>系统繁忙...</p>
</div>
public mapStatus:number=1;

特别注意:一个 HTML 标签上只能同时使用一个结构型的指令。

因为“结构型”指令会修改 DOM 结构,如果在一个标签上使用多个结构型指令,大家都一起去修改 DOM 结构,到时候到底谁说了算?

那么需要在同一个 HTML 上使用多个结构型指令应该怎么办呢?有两个办法:

  • 加一层空的 div 标签
  • 加一层

6.7 在模板里面使用属性型指令

使用频率比较高的 3 个内置指令是:NgClass、NgStyle、NgModel。

NgClass 使用案例代码:

<div [ngClass]="currentClasses">同时批量设置多个样式</div>
<button class="btn btn-success" (click)="setCurrentClasses()">设置</button>
public currentClasses: {};

public canSave: boolean = true;
public isUnchanged: boolean = true;
public isSpecial: boolean = true;

setCurrentClasses() {
    this.currentClasses = {
        'saveable': this.canSave,
        'modified': this.isUnchanged,
        'special': this.isSpecial
    };
}
.saveable{
    font-size: 18px;
} 
.modified {
    font-weight: bold;
}
.special{
    background-color: #ff3300;
}

NgStyle 使用案例代码:

<div [ngStyle]="currentStyles">
    用NgStyle批量修改内联样式!
</div>
<button class="btn btn-success" (click)="setCurrentStyles()">设置</button>
public currentStyles: {}
public canSave:boolean=false;
public isUnchanged:boolean=false;
public isSpecial:boolean=false;

setCurrentStyles() {
    this.currentStyles = {
        'font-style':  this.canSave      ? 'italic' : 'normal',
        'font-weight': !this.isUnchanged ? 'bold'   : 'normal',
        'font-size':   this.isSpecial    ? '36px'   : '12px'
    };
}

ngStyle 这种方式相当于在代码里面写 CSS 样式,比较丑陋,违反了注意点分离的原则,而且将来不太好修改,非常不建议这样写。

NgModel 使用案例代码:

<p class="text-danger">ngModel只能用在表单类的元素上面</p>
    <input [(ngModel)]="currentRace.name">
<p>{{currentRace.name}}</p>
public currentRace:any={name:"随机种族"};

请注意,如果你需要使用 NgModel 来进行双向数据绑定,必须要在对应的模块里面 import FormsModule 。

6.8 管道

管道的一个典型作用是用来格式化数据,来一个最简单的例子:

{{currentTime | date:'yyyy-MM-dd HH:mm:ss'}}

public currentTime: Date = new Date();

Angular 里面一共内置了 17 个指令(有一些已经过时了):

enter image description here

在复杂的业务场景里面,17 个指令肯定不够用,如果需要自定义指令,请查看这里的例子: https://angular.io/guide/pipes
管道还有另一个典型的作用,就是用来做国际化,后面有一个独立的小节专门演示 Angular 的国际化写法。

7.[组件]组件间通讯

组件就像零散的积木,我们需要把这些积木按照一定的规则拼装起来,而且要让它们互相之间能进行通讯,这样才能构成一个有机的完整系统。

在真实的应用中,组件最终会构成树形结构,就像人类社会中的家族树一样:

component communication

在树形结构里面,组件之间有几种典型的关系:父子关系、兄弟关系、没有直接关系。

相应地,组件之间有以下几种典型的通讯方案:

  • 直接的父子关系:父组件直接访问子组件的 public 属性和方法
  • 直接的父子关系:借助于 @Input 和 @Output 进行通讯
  • 没有直接关系:借助于 Service 单例进行通讯
  • 利用 cookie 和 localstorage 进行通讯
  • 利用 session 进行通讯

无论你使用什么前端框架,组件之间的通讯都离开不以上几种方案,这些方案与具体框架无关。

7.1 直接调用

对于有直接父子关系的组件,父组件可以直接访问子组件里面 public 型的属性和方法,示例代码片段如下:

<child #child></child>
<button (click)="child.childFn()" class="btn btn-success">调用子组件方法</button>

显然,子组件里面必须暴露一个 public 型的 childFn 方法,就像这样:

public childFn():void{
    console.log("子组件的名字是>"+this.panelTitle);
}

以上是通过在模板里面定义局部变量的方式来直接调用子组件里面的 public 型方法。在父组件的内部也可以访问到子组件的实例,需要利用到 @ViewChild 装饰器,示例如下:

@ViewChild(ChildComponent)
private childComponent: ChildComponent;

关于 @ViewChild 在后面的内容里面会有更详细的解释。

很明显,如果父组件直接访问子组件,那么两个组件之间的关系就被固定死了。父子两个组件紧密依赖,谁也离不开谁,也就都不能单独使用了。所以,除非你知道自己在做什么,最好不要直接在父组件里面直接访问子组件上的属性和方法,以免未来一改一大片。

7.2 @Input 和 @Output

我们可以利用 @Input 装饰器,让父组件直接给子组件传递参数,子组件上这样写:

@Input()
public panelTitle:string;

父组件上可以这样设置 panelTitle 这个参数:

<child panelTitle="一个新的标题"></child>

@Output 的本质是事件机制,我们可以利用它来监听子组件上派发的事件,子组件上这样写:

@Output()
public follow=new EventEmitter<string>();

触发 follow 事件的方式如下:

this.follow.emit("follow");

父组件上可以这样监听 follow 事件:

<child (follow)="doSomething()"></child>

我们可以利用 @Output 来自定义事件,监听自定义事件的方式也是通过小圆括号,与监听 HTML 原生事件的方式一模一样。

7.3 利用 Service 单例进行通讯


如果你在根模块(一般是 app.module.ts)的 providers 里面注册一个 Service,那么这个 Service 就是全局单例的,这样一来我们就可以利用这个单例的 Service 在不同的组件之间进行通讯了。

  • 比较粗暴的方式:我们可以在 Service 里面定义 public 型的共享变量,然后让不同的组件都来访问这块变量,从而达到共享数据的目的。
  • 优雅一点的方式:利用 RxJS,在 Service 里面定义一个 public 型的 Subject(主题),然后让所有组件都来 subscribe(订阅)这个主题,类似于一种“事件总线”的效果。

实例代码片段:

import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Subject';

/**
 * 用来充当事件总线的 Service
 */
@Injectable()
export class EventBusService {
  public eventBus:Subject<string> = new Subject<string>();

  constructor() { }

}
import { Component, OnInit } from '@angular/core';
import { EventBusService } from '../service/event-bus.service';

@Component({
  selector: 'child-1',
  templateUrl: './child-1.component.html',
  styleUrls: ['./child-1.component.css']
})
export class Child1Component implements OnInit {

  constructor(public eventBusService:EventBusService) { }

  ngOnInit() {
  }

  public triggerEventBus():void{
    this.eventBusService.eventBus.next("第一个组件触发的事件");
  }
}
import { Component, OnInit } from '@angular/core';
import { EventBusService } from '../service/event-bus.service';

@Component({
  selector: 'child-2',
  templateUrl: './child-2.component.html',
  styleUrls: ['./child-2.component.css']
})
export class Child2Component implements OnInit {
  public events:Array<any>=[];

  constructor(public eventBusService:EventBusService) {

  }

  ngOnInit() {
    this.eventBusService.eventBus.subscribe((value)=>{
      this.events.push(value+"-"+new Date());
    });
  }
}

7.4 利用cookie或者localstorage进行通讯


示例代码片段:

public writeData():void{
    window.localStorage.setItem("json",JSON.stringify({name:'大漠穷秋',age:18}));
}
var json=window.localStorage.getItem("json");
// window.localStorage.removeItem("json");
var obj=JSON.parse(json);
console.log(obj.name);
console.log(obj.age);

很多同学写 Angular 代码的时候出现了思维定势,总感觉 Angular 会封装所有东西,实际上并非如此。比如 cookie、localstorage 这些东西都可以直接用原生的 API 进行操作的。千万别忘记原生的那些 API 啊,都能用的!

7.5 利用 session 进行通讯

session

7.6 小结

组件间的通讯方案是通用的,无论你使用什么样的前端框架,都会面临这个问题,而解决的方案无外乎本文所列出的几种。

8.[组件]生命周期钩子

我不打算在这里罗列 API,在官方网站上面有更详细的描述和例子:

https://angular.io/guide/lifecycle-hooks

在这一节里面我们只讨论以下 4 件事:

  • 什么是 UI 组件的生命周期?
  • Angular 组件的生命周期有什么特别的地方?
  • OnPush 策略的使用方式。
  • 简要介绍脏检查的实现原理。

8.1 UI 组件的生命周期

LifeCycle

无论使用什么样的前端框架,只要编写 UI 组件,生命周期都是必须要考虑的重要内容。请展开你的想象,如果让你来设计 UI 系统,组件有几个重要的阶段一定是绕不开的,比如:

  • 初始化(init)阶段:在这个阶段你需要把组件 new 出来,把一些属性设置上去,等等这些操作。
  • 渲染(render)阶段:在这个阶段需你要把组件的模板和数据结合起来,生成 HTML 标签结构,并且要整合到现有的 DOM 树里面去。
  • 存活阶段:既然带有 UI,那么在组件的存活期内就一定会和用户进行交互。一般来说,带有 UI 的系统都是通过事件机制进行用户交互的。也就是说,这个阶段将会处理大量的用户事件:鼠标点击、键盘按键、手指触摸。
  • 销毁(destory)阶段:最后,组件使用完了,需要把一些资源释放掉。最典型的操作:需要把组件上的所有事件全部清理干净,避免造成内存泄漏。

在组件生命的不同阶段,框架一般会暴露出一些“接口”,开发者可以利用这些接口来实现一些自己的业务逻辑。这种接口在有些框架里面叫做“事件”,在 Angular 里面叫做“钩子”,但其底层的本质都是一样的。

8.2 Angular 组件的生命周期钩子

Hook

  • Angular 一共暴露了 8 个“钩子”,构造函数不算。
  • 并没有组件或者指令会实现全部钩子。
  • 绿色的 1357 会被执行很多次,2468 只会执行一次。
  • Content 和 View 相关的 4 个钩子只对组件有效,指令上不能使用。因为在新版本的 Angular 里面,指令不能带有 HTML 模板。指令没有自己的 UI,当然就没有 View 和 Content 相关的“钩子”了。
  • 请不要在生命周期钩子里面实现复杂的业务逻辑,尤其是那 4 个会被反复执行的钩子,否则一定会造成界面卡顿。
  • 对于 @Input 型的属性,在构造函数里面是取不到值的,在 ngOnInit 里面才有值。
  • 在 ngAfterViewChecked 这个钩子里面不可以再修改组件内部被绑定的值,否则会抛出异常。

特别注意:对于业务开发者来说,一般只用到 ngOnInit 这个钩子,其它几个钩子在日常业务开发中是用不到的。

8.3 OnPush 策略

在真实的业务系统中,组件会构成 Tree 型结构,就像这样:

当某个叶子组件上的数据模型发生变化之后,就像这样:

这时候,Angular 将会从根组件开始,遍历整颗组件树,把所有组件上的 ngDoCheck() 方法都调用一遍:

请注意,默认情况下,无论哪个叶子组件上发生了变化,都会把整个组件树遍历一遍。如果组件树非常庞大,嵌套非常深,很明显会有效率问题。在绝大部分时间里面,并不会出现每个组件都需要刷新的情况,根本没有必要每次都去全部遍历。所以 Angular 提供了一种叫做 OnPush 的策略,只要把某个组件上的检测策略设置为 OnPush,就可以忽略整个子树了,就像这样:

很明显,使用了 OnPush 策略之后,检查效率将会获得大幅度的提升,尤其在组件的数量非常多的情况下:

Angular 内置的两种变更检测策略:

  • Default:无论哪个组件发生了变化,从根组件开始全局遍历,调用每个组件上的 ngDoCheck() 钩子。
  • OnPush:只有当组件的 @Input 属性发生变化的时候才调用本组件的 ngDoCheck() 钩子。

有一些开发者建议 Angular 项目组把 OnPush 作为默认策略,但是目前还没有得到官方支持,或许在未来的某个版本里面会进行修改。

8.4 了解一点点原理

如果你不想看到扯原理的内容,可以跳过这一小段。

大家都知道,AngularJS 是第一个把“双向数据绑定”这种设计带到前端领域来的框架,“双向数据绑定”最典型的场景就是对表单的处理。

双向数据绑定的目标很明确:数据模型发生变化之后,界面可以自动刷新;用户修改了界面上的内容之后,数据模型也会发生自动修改。

很明显,这里需要一种同步机制,在 Angular 里面这种同步机制叫做“变更检测”。

在老版本 AgnularJS 里面,变更检测机制实现得不太完善,经常会出现检测不到变更的情况,所以才有了让大家很厌烦的 $apply() 调用。

在新版本的 Angular 里面不再存在这个问题了,因为新版本的 Angular 使用 Zone.js 这个库,它会把所有可能导致数据模型发生变更的情况全部拦截掉,从而在数据发生变化的时候去通知 Angular 进行刷新。

有一些朋友可能会觉得奇怪,Zone.js 怎么这么牛叉?它内部到底是怎么玩的呢?

实际上要做到这一点并不复杂,因为在浏览器环境下,有可能导致数据模型发生变化的情况只有 3 种典型的回调:

  • 事件回调:鼠标、键盘、触摸
  • 定时器回调:setTimeout 和 setInterval
  • Ajax 回调

Zone.js 覆盖了所有原生实现,当开发者在调用这些函数的时候,并不是调用的原生方法,而是调用的 Zone.js 自己的实现,因此 Zone.js 就可以做一些自己的处理了。

也就是说 Zone.js 会负责通知 Angular:“数据模型发生变化了”!然后 Angular 的 ChangeDetector 就会在下一次 dirty check 的周期里面来检查哪些组件上的值发生了变化,然后做出相应的处理。

如果你的好奇心特别旺盛,这里有一篇非常长的文章,大约二十多页,详细解释了这一话题:

https://blog.thoughtram.io/angular/2016/02/22/angular-2-change-detection-explained.html

9.[组件]动效

9.1 非常重要的说明

Angular 默认的动画模块使用的是 Web Animations 规范,这个规范目前处于 Editor’s Draft 状态(2017-09-22),详情请看这里:

https://w3c.github.io/web-animations/

目前,各大浏览器厂商对 Web Animations 规范的支持并不好,请看下图:

(图片来自:http://caniuse.com/#feat=web-animation)

Web Animations 这套新的规范在 FireFox、Chrome、Opera 里面得到了完整的支持,而其它所有浏览器内核几乎都完全不支持,所以请慎重选择。我的建议是,请优先使用 CSS3 规范里面的 anmimation 方案:

https://www.w3schools.com/css/css3_animations.asp

9.2 用法示范

第一步,导入动画模块:

第二步,编写动效:

flyIn 是这个动效的名称,后面我面就可以在组件里面引用 flynIn 这个名字了。

动效整体上是由“状态”和“转场”两个部分构成的:

  • 以上代码里面的星号()表示“不可见状态”,void 表示任意状态。这是两种内置的状态,=>void 表示是进场动画,而 void=>* 表示离场动画。当然你也可以定义自己的状态名称,注意不要和内置的状态名称发生冲突。
  • keyframes 里面的内容是关键帧的定义,语法和 CSS3 里面定义动画的方式非常类似。

第三步,在组件里面使用 flyIn 这个动效:

这个例子完整的代码在这里:

https://gitee.com/learn-angular-series/learn-component

代码在 animation 分支上面,运行起来你可以看到这个效果界面:

9.3 小结

Angular 官方的动效文档在这里:

https://angular.io/guide/animations

如果你不愿意自己编写动效,推荐这个开源项目,它和 Angular 之间结合得比较紧:

https://github.com/jiayihu/ng-animate

10.[组件]动态组件

我们可以通过标签的方式使用组件,也可以通过代码的方式来动态创建组件。动态创建组件的过程是通过 ViewContainerRef 和 ComponentFactoryResolver 这两个工具类来配合完成的。

我们可以定义一个这样的模板:

<div #dyncomp></div>

在组件定义里面需要首先 import 需要用到的工具类:

import { Component, OnInit,ViewChild,ViewContainerRef,ComponentFactoryResolver, ComponentRef } from  '@angular/core';

组件内部这样写:

//这里引用模板里面定义的 dyncomp 容器标签  
@ViewChild("dyncomp",{read:ViewContainerRef}) dyncomp:ViewContainerRef; 
comp1:ComponentRef<Child11Component>; 
comp2:ComponentRef<Child11Component>; 
constructor(private resolver:ComponentFactoryResolver) { }

然后我们就可以在 ngAfterContentInit 这个钩子里面用代码来动态创建组件了:

ngAfterContentInit(){ 
  const childComp=this.resolver.resolveComponentFactory(Child11Component);     
  this.comp1=this.dyncomp.createComponent(childComp); 
}

对于创建出来的 comp1 这个组件,可以通过代码直接访问它的 public 型属性,也可以通过代码来 subscribe(订阅)comp1 上面发出来的事件,就像这样:

this.comp1.instance.title="父层设置的新标题"; 
this.comp1.instance.btnClick.subscribe((param)=>{ 
  console.log("--->"+param); 
});

对于用代码动态创建出来的组件,我们可以通过调用 destory() 方法来手动销毁:

public destoryChild():void{ 
  this.comp1.destroy(); 
  this.comp2.destroy(); 
}

代码运行起来的效果如下:

注意:用代码动态创建组件这种方式在一般的业务开发里面不常用,而且可能存在一些隐藏的坑,如果你一定要用,请小心避雷。

11.[组件]ShadowDOM

根据 Angular 官方的说法,Angular 组件的设计灵感来源于 Web Component,在 Web Component 里面,ShadowDOM 是重要的组成部分。在底层,Angular 渲染组件的方式有 3 种:

  • Native:采用 ShadowDOM 的模式来进行渲染。
  • Emulated:模拟模式。对于不能支持 ShadowDOM 模式的浏览器,Angular 在底层会采用模拟的方式来渲染组件,这是 Angular 默认的渲染模式
  • None:不采用任何渲染模式。直接把组件的 HTML 结构和 CSS 样式插入到 DOM 流里面,这种方式很容易导致组件互相之间出现 CSS 命名污染的问题。

在定义组件的时候,可以通过 encapsulation 配置项手动指定组件的渲染模式,关键代码如下:

@Component({ 
  selector: 'emulate-mode', 
  encapsulation:ViewEncapsulation.Emulated,//默认模式 
  templateUrl: './emulate-mode.component.html', 
  styleUrls: ['./emulate-mode.component.scss'] 
})

请尝试修改 encapsulation 这个配置项来测试不同的效果。

注意:Angular 官方在 2018 年的 NGConnet 大会上表示,在将来的某个版本中,会在内核里面把 ShadowDOM 设置为默认模式。因为这一变更会在内核层面进行,所以业务开发者不用改代码。

本节案例运行起来的效果如下:

注意点:

12.[组件]内容投影

12.1 最简单的组件模板

你编写了一个这样的面板组件:

组件对应的模板代码是这样的:

<div class="panel panel-primary">
  <div class="panel-heading">标题</div>
  <div class="panel-body">
      内容
  </div>
  <div class="panel-footer">
      底部
  </div>
</div>

12.2 投影一块内容

但是,你希望把面板里面的标题设计成可变的,让调用者能把这个标题传进来,而不是直接写死。这时候“内容投影”机制就可以派上用场了,我们可以这样来编写组件的模板:

<div class="panel panel-primary">
  <div class="panel-heading">
    <ng-content></ng-content>
  </div>
  <div class="panel-body">
      内容
  </div>
  <div class="panel-footer">
      底部
  </div>
</div>

请注意以上模板里面的 <ng-content></ng-content>,你看可以把它想象成一个占位符,我们用它来先占住一块空间,等使用方把参数传递进来之后,再用真实的内容来替换它。使用方可以这样来传递参数:

<test-child-two>
    <h3>这是父层投影进来的内容</h3>
</test-child-two>

运行起来的效果是这样的:

可以看到,标题的部分是由使用方从外部传递进来的。

12.3 投影多块内容

接着,问题又来了,你不仅希望面板的标题部分是动态的,你还希望面板的主体区域和底部区域全部都是动态的,应该怎么实现呢?

你可以这样编写组件的模板:

<div class="panel panel-primary">
  <div class="panel-heading">
      <ng-content select="h3"></ng-content>
  </div>
  <div class="panel-body">
      <ng-content select=".my-class"></ng-content>
  </div>
  <div class="panel-footer">
      <ng-content select="p"></ng-content>
  </div>
</div>

然后使用方可以这样来使用你所编写的组件:

<test-child-two>
    <h3>这是父层投影进来的内容</h3>
    <p class="my-class">利用CSS选择器</p>
    <p>这是底部内容</p>
</test-child-two>

运行起来的效果是这样的:

你可能已经猜出来了,<ng-content></ng-content> 里面的那个 select 参数,其作用和 CSS 选择器非常类似。

这种投影多块内容的方式叫“多插槽模式”(multi-slot),你可以把 <ng-content></ng-content> 想形成一个一个的插槽,内容会被插入到这些插槽里面。

12.4 投影一个复杂的组件

到这里还没完,你不仅仅想投影简单的 HTML 标签到子层组件里面,你还希望把自己编写的一个组件投影进去,那又应该怎么办呢?

请看:

<div class="panel panel-primary">
  <div class="panel-heading">
      <ng-content select="h3"></ng-content>
  </div>
  <div class="panel-body">
      <ng-content select="test-child-three"></ng-content>
  </div>
  <div class="panel-footer">
      <ng-content select="p"></ng-content>
  </div>
</div>

使用方可以这样来使用这个组件:

<test-child-two>
    <h3>这是父层投影进来的内容</h3>
    <test-child-three (sayhello)="doSomething()"></test-child-three>
    <p>这是底部内容</p>
</test-child-two>

运行起来的效果是这样的:

请注意 <ng-content select="test-child-three"></ng-content> 里面的内容,你把 select 属性设置成了子组件的名称。

同时,对于被投影的组件 <test-child-three></test-child-three> 来说,我们同样可以利用小圆括号的方式来进行事件绑定,就像上面例子里的 (sayhello)="doSomething()" 这样。

12.5 内容投影这个特性存在的意义

如果没有“内容投影”特性我们也能活得很好,那么它就没有存在的必要了,而事实并非如此,如果没有“内容投影”,有些事情我们就没法做了,典型的有两类:

  • 组件标签不能嵌套使用。
  • 不能优雅地包装原生的 HTML 标签。

依次解释如下:

比如你自己编写了两个组件 my-comp-1 和 my-comp-2,如果没有内容投影,这两个组件就没办法嵌套使用,比如你想这样用就不行:

<my-comp-1>
    <my-comp-2></my-comp-2>
</my-comp-1>

因为没有“内容投影”机制,my-comp-1 无法感知到 my-comp-2 的存在,也无法和它进行交互。这明显有违 HTML 设计的初衷,因为 HTML 的本质是一种 XML 格式,标签能嵌套是最基本的特性,原生的 HTML 本身就有很多嵌套的情况:

<ul>
  <li>神族</li>
  <li>人族</li>
  <li>虫族</li>
</ul>

在真实的业务开发里面,另一个典型的嵌套组件就是 Tab 页,以下代码是很常见的:

<tab>
    <pane title="第一个标签页"/>
    <pane title="第二个标签页"/>
    <pane title="第三个标签页"/>
</tab>

如果没有内容投影机制,想要这样嵌套地使用自定义标签也是不可能的。

内容投影存在的第二个意义与组件的封装有关。

虽然 Angular 提供了 @Component 装饰器让开发者可以自定义标签,但是请不要忘记,自定义标签毕竟与 HTML 原生标签不一样,原生 HTML 标签上面默认带有很多属性、事件,而你自己定义标签是没有的。原生 HTML 标签上面暴露的属性和事件列表请参见 W3C 的规范:

https://www.w3schools.com/tags/ref_attributes.asp

从宏观的角度看,所有的自定义标签都只不过是一层“虚拟的壳子”,浏览器并不认识自定义标签,真正渲染出来的还是 div、form、input 之类的原生标签。所以,自定义标签只不过是一层逻辑上的抽象和包装,让人类更容易理解和组织自己的代码而已。

既然如此,自定义标签和HTML原生标签之间的关系是什么呢?本质上说,这是“装饰模式”的一种应用,而内容投影存在的意义就是可以让这个“装饰”的过程做得更加省力、更加优雅一些。

12.6 接下来

我们已经学会了内容投影最基本的用法,但是故事并没有结束,接下来的问题又来了:

  • 如何访问投影进来的复杂组件?比如:如何访问被监听组件上的 public 属性?如何监听被投影组件上的事件?接下来的小节就来解决这个问题。
  • 如何访问投影进来的 HTML 元素?比如:如何给被投影进来的 HTML 元素添加 CSS 样式?这个话题反而比访问被投影组件要复杂一些,在讲指令的那一个小节里面给例子来描述。

13.@ContentChild 和 @ContentC

13.1 @ContentChild

我们可以利用 @ContentChild 这个装饰器来操控被投影进来的组件。

<child-one>
    <child-two></child-two>
</child-one>
import { Component, ContentChild, ContentChildren, ElementRef, OnInit, QueryList } from '@angular/core';

//注解的写法
@ContentChild(ChildTwoComponent)
childTwo:ChildTwoComponent;

//在 ngAfterContentInit 钩子里面访问被投影进来的组件
ngAfterContentInit():void{
    console.log(this.childTwo);
    //这里还可以访问 this.childTwo的public 型方法,监听 this.childTwo 所派发出来的事件
}

13.2 @ContentChildren

从名字可以看出来,@ContentChildren 是一个复数形式。当被投影进来的是一个组件列表的时候,我们可以用 @ContentChildren 来进行操控。

<child-one>
    <child-two></child-two>
    <child-two></child-two>
    <child-two></child-two>
    <child-two></child-two>
    <child-two></child-two>
    <child-two></child-two>
    <child-two></child-two>
    <child-two></child-two>
</child-one>
import { Component, ContentChild, ContentChildren, ElementRef, OnInit, QueryList } from '@angular/core';

//这时候不是单个组件,是一个列表了 QueryList
@ContentChildren(ChildTwoComponent) 
childrenTwo:QueryList<ChildTwoComponent>;

//遍历列表
ngAfterContentInit():void{
    this.childrenTwo.forEach((item)=>{
        console.log(item);
    });
}

14.@ViewChild 与 @ViewChildren

14.1 @ViewChild

我们可以利用 @ViewChild 这个装饰器来操控直属的子组件。

<div class="panel panel-primary">
  <div class="panel-heading">父组件</div>
  <div class="panel-body">
    <child-one></child-one>
  </div>
</div>
import { Component, OnInit, ViewChild, ViewChildren, QueryList } from '@angular/core';

@ViewChild(ChildOneComponent,{static:false})
childOne:ChildOneComponent;

//在 ngAfterViewInit 这个钩子里面可以直接访问子组件
ngAfterViewInit():void{
    console.log(this.childOne);
    //用代码的方式订阅子组件上的事件
    this.childOne.helloEvent.subscribe((param)=>{
        console.log(this.childOne.title);
    });
}

注意:8.0 这里有一个 breaking change,@ViewChild 这里提供了第二个参数,增强了一些功能。这里有详细的描述:https://angular.io/api/core/ViewChild

14.2 @ViewChildren

<div class="panel panel-primary">
  <div class="panel-heading">父组件</div>
  <div class="panel-body">
    <child-one></child-one>
    <child-one></child-one>
    <child-one></child-one>
    <child-one></child-one>
    <child-one></child-one>
  </div>
</div>
import { Component, OnInit, ViewChild, ViewChildren, QueryList } from '@angular/core';

@ViewChildren(ChildOneComponent)
children:QueryList<ChildOneComponent>;

ngAfterViewInit():void{
    this.children.forEach((item)=>{
        // console.log(item);
        //动态监听子组件的事件
        item.helloEvent.subscribe((data)=>{
        console.log(data);
        });
    });
}

15.与Polymer 封装组件的方式简单对比

一些开发者认为 Angular 的组件设计不如 Polymer 那种直接继承原生 HTMLElement 的方式优雅。

以下是 Polymer 组件的定义方式:

以下是 Polymer 的根类 Polymer.Element 的源代码:

可以看到,在 Polymer 中,开发者自定义标签的地位与浏览器原生标签完全是平等的,属性、事件、行为,都是平等的,Polymer 组件的渲染由浏览器内核直接完成。

Polymer 的这种封装方式和目前市面上的大部分前端框架都不一样,Polymer 直接继承原生 HTML 元素,而其它大部分框架都只是在“包装”、“装饰”原生 HTML 元素,这是两种完全不同的设计哲学。

https://www.polymer-project.org/

目前,使用 Polymer 最著名的网站是 Google 自家的 YouTube:

16.封装并发布你自己的组件库

16.1 市面上可用的 Angular 组件库介绍

开源免费的组件库:

收费版组件库:

16.2 如何在项目里面引入开源组件库

以 PrimeNG 为例,首先在 package.json 里面定义好依赖:

然后打开终端用 cnpm install 安装 PrimeNG 到你本地,在你自己的业务模块里面 import 需要用到的组件模块就好了:

从 Angular 6.0 开始,@angular/cli 增加了一个 ng add 命令,所有支持 Schematics 语法的组件库都可以通过这个命令自动整合,并且在创建你自己组件的时候可以指定需要哪种风格,详细的例子和解释请参考“1-2Schematics 与代码生成器”这一小节。

16.3 如何把你的组件库发布到 npm 上去

有朋友问过一个问题,他觉得 npm 很神奇,比如当我们在终端里面输入以下命令的时候:

npm install -g @angular/cli

npm 就会自动去找到@angular/cli 并安装,看起来很神奇的样子。

其实,背后的处理过程很简单,npm 官方有一个固定的 registry url,你可以把它的作用想象成一个 App Store,全球所有开发者编写的 node 模块都需要发布上去,然后其他人才能安装使用。

如果你开发了一个很强大的 Angular 组件库,希望发布到 node 上面让其他人也能使用,应该怎么做呢?简略的处理步骤如下:

  • 第 1 步:用 npm init 初始化项目(只要你的项目里面按照 npm 的规范编写一份 package.json 文件就可以了,不一定用 npm init 初始化)。
  • 第 2 步:编写你自己的代码。
  • 第 3 步:到 https://www.npmjs.com/ 去注册一个账号。
  • 第 4 步:用 npm publish 把项目 push 上去。

publish 之后,全球开发者都可以通过名字查找并安装你这个模块了。

16.4一些小小的经验

两个常见的误区:

  • 第一个误区是:开源组件可以满足你的所有需求。我可以负责任地告诉你,这是不可能的!开源组件库都是通用型的组件,并不会针对任何特定的行业或者领域进行设计。无论选择哪一款开源组件库,组件的外观 CSS 你总要重新写一套的吧?组件里面缺的那些功能你总得自己去写吧?组件里面的那些 Bug 你总得自己去改掉吧?所以,千万不要幻想开源组件能帮你解决所有问题,二次开发是必然的。
  • 第二个误区是:开发组件库很简单,分分钟可以搞定。在 jQuery 时代,有一款功能极其强大树组件叫 zTree。你能想到的那些功能 zTree 都实现了,而且运行效率特别高。但是你要知道,zTree 的作者已经花了超过 5 年的时间来维护这个组件。维护一个组件尚且如此,何况要长期维护一个庞大的库?所以,做好一个组件库并不像有些人想象的那么轻松,这件事是需要花钱、花时间的。做开源,最让使用者蛋疼的不是功能够不够强大,而是开发者突然弃坑,这也是很多企业宁愿花钱自己开发组件库的原因。所以,如果你只是单兵作战,最好选一款现有的开源库,在此基础上继续开发。强烈建议你只做一个组件,就像 zTree 的作者那样,把一个组件做好、做透,并且长期维护下去。这比搞一个庞大的组件库,每个组件做得都像个玩具,然后突然弃坑要好很多。

17.指令简介

17.1 组件与指令之间的关系

再看一下核心源代码里面的内容:

根据 Angular 官方文档的描述,Angular 里面有 3 种类类型的指令:

  • Component 是 Directive 的子接口,是一种特殊的指令,Component 可以带有 HTML 模板,Directive 不能有模板。
  • 属性型指令:用来修改 DOM 元素的外观和行为,但是不会改变 DOM 结构,Angular 内置指令里面典型的属性型指令有 ngClass、ngStyle。如果你打算封装自己的组件库,属性型指令是必备的内容。
  • 结构型指令:可以修改 DOM 结构,内置的常用结构型指令有 ngFor、ngIf 和 NgSwitch。由于结构型指令会修改 DOM 结构,所以同一个 HTML 标签上面不能同时使用多个结构型指令,否则大家都来改 DOM 结构,到底听谁的呢?如果要在同一个 HTML 元素上面使用多个结构性指令,可以考虑加一层空的元素来嵌套,比如在外面套一层空的 <ng-container></ng-container>,或者套一层空的 <div>

17.2 有了组件为什么还要指令?

请注意:即使你认真、仔细地看完以上内容,你依然会感到非常茫然。因为有一个最根本的问题在所有文档里面都没有给出明确的解释,这个问题也是很多开发者经常会问的,那就是:既然有了组件(Component),为什么还要指令(Directive)?

我们知道,在很多的 UI 框架里面,并没有指令的概念,它们的基类都是从 Component 开始的。比如:

  • Swing 里面基类名字就叫 Component,没有指令的概念
  • ExtJS 里面基类是 Ext.Component,没有指令的概念
  • Flex 里面基类名字叫 UIComponent,没有指令的概念
  • React 里面的基类名字叫 React.Component,没有指令的概念

以下是 Swing 的类结构图:

以下是 ExtJS 3.2 的 UI 组件继承结构图局部,请注意 Ext.Component 类的位置:

下面是整体缩略图:

以下是Adobe Flex 3的类结构图:

上面这些框架都走的组件化的路子,Swing 和 ExtJS 完全是“代码流”,所有 UI 都通过代码来创建;而 Flex 和 React 是“标签流”,也就通过标签的方式来创建 UI。

但是,所有这些框架都没有“指令”这个概念,为什么 Angular 里面一定要引入“指令”这个概念呢?

根本原因是:我们需要用指令来增强标签的功能,包括 HTML 原生标签和你自己自定义的标签。

举例来说:<div> 是一个常用的原生 HTML 标签,但是请不要小看它,它上面实际上有非常多的属性,这些属性都是 W3C 规范规定好的。

还能支持以下事件属性:

完整的列表请查看 W3C 规范:

https://www.w3schools.com/tags/ref_standardattributes.asp

但是,这些内置属性还不够用,你想给原生的 HTML 标签再扩展一些属性。比方说:你想给 <div> 标签增加一个自定义的属性叫做 my-high-light,当鼠标进入 div 内部时,div 的背景就会高亮显示,可以这样使用 <div my-high-light>。这时候,没有指令机制就无法实现了。

18.自定义指令

这是官方文档里面的一个例子,运行效果如下:

核心代码如下:

import { Directive, ElementRef,HostListener,HostBinding,Input } from '@angular/core';

@Directive({
  selector: '[my-high-light]'
})
export class MyHighLightDirective {
  @Input() 
  highlightColor: string;

  constructor(private el: ElementRef) {
  }

  @HostListener('mouseenter') onMouseEnter() {
    this.highlight(this.highlightColor);
  }

  @HostListener('mouseleave') onMouseLeave() {
    this.highlight(null);
  }

  private highlight(color: string) {
    this.el.nativeElement.style.backgroundColor = color;
  }
}

以上指令的用法如下:

<p my-high-light highlightColor="#ff3300">内容高亮显示!</p>

18.1 自定义结构型指令

这个例子会动态创建 3 个组件,每个延迟 500 毫秒,运行效果如下:

指令代码如下:

import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';

@Directive({
    selector: '[appDelay]'
})
export class DelayDirective {
    constructor(
        private templateRef: TemplateRef<any>,
        private viewContainerRef: ViewContainerRef
    ) { }

    @Input() set appDelay(time: number) {
        setTimeout(() => {
            this.viewContainerRef.createEmbeddedView(this.templateRef);
        }, time);
    }
}

指令的用法核心代码:

<div *ngFor="let item of [1,2,3]">
    <card *appDelay="500 * item">
        第 {{item}} 张卡片
    </card>
</div>

你应该注意到了,结构性指令在使用的时候前面都会带上星号,即使是你自定义的结构性指令,也是一样的。

18.2 小结

强烈建议仔细阅读官方文档里面的关于 Directive 的细节描述:

https://angular.io/guide/attribute-directives

19.直接在组件里面操作 DOM

有一个常见的问题:既然组件是指令的子类,那么指令里面能干的事儿组件应该都能干,我可以在指令里面直接操作 DOM 吗?

答案是肯定的。

我们来修改一下上一节里面的例子,直接在组件里面来实现背景高亮效果,关键代码如下:

@Component({
  selector: 'test',
  templateUrl: './test.component.html',
  styleUrls: ['./test.component.scss']
})
export class TestComponent implements OnInit {
  @Input() 
  highlightColor: string;

  private containerEl:any;

  constructor(private el: ElementRef) {

  }

  ngOnInit() {
  }

  ngAfterContentInit() {
    console.log(this.el.nativeElement);
    console.log(this.el.nativeElement.childNodes);
    console.log(this.el.nativeElement.childNodes[0]);
    console.log(this.el.nativeElement.innerHTML);

    this.containerEl=this.el.nativeElement.childNodes[0];
  }

  @HostListener('mouseenter') onMouseEnter() {
    this.highlight(this.highlightColor);
  }

  @HostListener('mouseleave') onMouseLeave() {
    this.highlight(null);
  }

  private highlight(color: string) {
    this.containerEl.style.backgroundColor = color;
  }
}

组件的标签结构如下:

<div class="my-container">
  鼠标移进来就会改变背景
</div>

这个组件的使用方式如下:

<div class="container">
    <test highlightColor="#F2DEDE"></test>
</div>

可以看到,直接在组件里面操作 DOM 是可以的,但是一旦把操作 DOM 的这部分逻辑放在组件里面,就没法再在其它标签上面使用了。

20.模块@NgModule

这里不想把 NgModule 的所有 API 都列在这里,那样的话,这一部分就没什么存在的意义了。关于 NgModule 的十万个为什么,官方编写了一份很长的文档来做说明:

https://angular.io/guide/ngmodule-faq

但是请特别注意,如果你看完这篇简短的文章之后再去阅读官方的文档,那样你会站在一个高层级的视角去面对那些琐碎的细节,保证不会迷失方向。

20.1 @NgModule 的定义方式

看一个最简单的@ NgModule 的定义:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppComponent } from './app.component';
import { TestViewChildComponent } from './test-view-child/test-view-child.component';
import { ChildOneComponent } from './test-view-child/child-one/child-one.component';

@NgModule({
  declarations: [
    AppComponent,
    TestViewChildComponent,
    ChildOneComponent
  ],
  imports: [
    BrowserModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
  • declarations:用来放组件、指令、管道的声明。
  • imports:用来导入外部模块。
  • providers:需要使用的 Service 都放在这里。
  • bootstrap:定义启动组件。你可能注意到了这个配置项是一个数组,也就是说可以指定做个组件作为启动点,但是这种用法是很罕见的。

20.2 @NgModule 的重要作用

在 Angular 中,NgModule 有以下几个重要的作用:

  • NgModule 最根本的意义是帮助开发者组织业务代码,开发者可以利用 NgModule 把关系比较紧密的组件组织到一起,这是首要的。
  • NgModule 用来控制组件、指令、管道等的可见性,处于同一个 NgModule 里面的组件默认互相可见,而对于外部的组件来说,只能看到 NgModule 导出(exports)的内容,这一特性非常类似 Java 里面 package 的概念。也就是说,如果你定义的 NgModule 不 exports 任何内容,那么外部使用者即使 import 了你这个模块,也没法使用里面定义的任何内容。
  • NgModule 是@angular/cli 打包的最小单位。打包的时候,@angular/cli 会检查所有@NgModule 和路由配置,如果你配置了异步模块,cli 会自动把模块切分成独立的 chunk(块)。这一点是和其它框架不同的,其它框架基本上都需要你自己去配置 webpack,自己定义切分 chunck 的规则;而在 Angular 里面,打包和切分的动作是@angular/cli 自动处理的,不需要你干预。当然,如果你感到不爽,也可以自己从头用 webpack 配一个环境出来,因为@angular/cli 底层也是用的 webpack。
  • NgModule 是 Router 进行异步加载的最小单位,Router 能加载的最小单位是模块,而不是组件。当然,模块里面只放一个组件是允许的,很多组件库都是这样做的。

20.3 @NgModule 的注意点

  • 每个应用至少有一个根模块,按照惯例,根模块的名字一般都叫 AppModule,如果你没有非常特别的理由,就不要随意改这个名字了,这相当于一个国际惯例。
  • 组件、指令、管道都必须属于一个模块,而且只能属于一个模块。
  • NgModule 和 ES6 里面的 Module 是完全不同的两个概念。ES6 里面的模块是通过 export 和 import 来进行声明的,它们是语法层面的内容;而 NgModule 完全不是这个概念,从上面的作用列表你也能看出来。最重要的一点,目前,ES6 里面的 import 只能静态引入模块,并不能异步动态加载模块,而 NgModule 可以配合 Router 来进行异步模块加载,在后面的 介绍Router 时会有实例代码。
  • 模块的定义方式会影响依赖注入机制:对于直接 import 的同步模块,无论你把 @Injectable 类型的组件定义在哪个模块里面,它都是全局可见的。比如:在子模块 post.module.ts 的 providers 数组里面定义了一个 PostListService,你可能会觉得这个 PostListService 只有在 post.module.ts 里面可见。而事实并非如此,PostListService 是全局可见的,就相当于一个全局单例。与此对应,如果你把 PostListService 定义到一个异步加载的模块里面,它就不是全局可见的了,因为对于异步加载进来的模块,Angular 会为它创建独立的 DI(依赖注入)上下文。所以,如果你想让 PostListService 全局可见,应该把它定义在根模块 app.module 里面。同时要特别注意,如果你希望 PostListService 是全局单例的,只能在 app.module 里面的 providers 数组里面定义一次,而不能在其它模块里面再次定义,否则就会出现多个不同的实例。关于 DI 机制更详细的描述请参见这里:https://angular.io/guide/dependency-injection

20.4 Angular 内核自身的模块结构

Angular 本身也是用模块化的方式开发的,当你用 cnpm install 装好了开发环境之后,可以打开 node_modules 目录查看整体结构。左侧第一列 6 个目录比较常用,这个顺序是按照我自己的理解排列的,按照常用程度从上到下。

请注意,一个目录里面可能会放多个模块,比如 forms 目录里面就有 FormsModule 和 ReactiveFormsModule 两个模块。

Form 模块的关键类结构图如下:

这份结构图的源文件在 https://gitee.com/learn-angular-series/learn-form 这个项目的 master 分支上,docs 目录下。

21.路由概述

我尽量把 Router 叫做“路由”或者“路由机制”,而不叫“路由器”。因为我总感觉“路由器”是一种网络设备,在前端开发领域叫“路由器”怪怪的。

请特别注意:Angular 中的 Router 模块会负责模块的加载、组件的初始化、销毁等操作,它是整个乐队的总指挥。

21.1 前端为什么要路由

很多开发者代码写得很溜,但是并不理解为什么要 Router 这个机制。

在目前的前端开发领域,无论你使用哪一种框架,“路由”都是一个绕不开的机制。那么,前端为什么一定要路由机制?举两个简单的例子来帮助理解:

  • 如果没有 Router,浏览器的前进后退按钮没法用。做过后台管理系统的开发者应该遇到过这种场景,整个系统只有一个 login.jsp 和 index.jsp,用户从 login.jsp 登录完成之后,跳转到 index.jsp 上面,然后浏览器地址栏里面的 URL 就一直停留在 index.jsp 上面,页面内部的所有内容全部通过 Ajax 进行刷新。这种处理方式实际上把浏览器的 URL 机制给废掉了,整个系统只有一个 URL,用户完全无法通过浏览器的前进、后退按钮进行导航。
  • 如果没有 Router,你将无法把 URL 拷贝并分享给你的朋友。比如:你在某段子网站上看到了一个很搞笑的内容,你把 URL 拷贝下来分享给了你的朋友。如果这个段子网站没有做好路由机制,你的朋友将无法顺利打开这个链接。

Router 的本质是记录当前页面的状态,它和当前页面上展示的内容一一对应。

在 Angular 里面,Router 是一个独立的模块,定义在 @angular/router 模块里面,它有以下重要的作用:

  • Router 可以配合 NgModule 进行模块的懒加载、预加载操作;
  • Router 会管理组件的生命周期,它会负责创建、销毁组件。

21.2 服务端的配置

很多开发者会遇到这个问题:代码在开发状态运行得好好的,但是部署到真实的环境上之后所有路由都 404。

这是一个非常典型的问题,你需要配置一下 Server 才能很好地支持前端路由。

你想啊,既然你启用了前端路由,也就意味着浏览器地址栏里面的那些 URL 在 Server 端并没有真正的资源和它对应,你直接访问过去当然 404 了。

以 Tomcat 为例,你需要在 web.xml 里面加一段配置:

<error-page>
    <error-code>404</error-code>
    <location>/</location>
</error-page>

这意思就是告诉 Tomcat,对于 404 这种事你别管了,直接扔回前端去。由于 Angular 已经在浏览器里面接管了路由机制,所以接下来就由 Angular 来负责了。

如果你正在使用其它的 WEB 容器,请从以下链接里面查找对应的配置方式:

https://github.com/angular-ui/ui-router/wiki/Frequently-Asked-Questions

在 How to: Configure your server to work with html5Mode 这个小节里面把常见的 Web 容器的配置方式都列举出来了,包括:IIS、Apache、nginx、NodeJS、Tomcat 全部都有,你过去抄过来就行。

21.3 小结

Angular 新版本的路由机制极其强大,除了能支持无限嵌套之外,还能支持模块懒加载、预加载、路由守卫、辅助路由等高级功能,在接下来的几个小节里面就来写例子一一演示。

Angular Router 模块的作者是 Victor Savkin,这是他的个人 Blog:https://vsavkin.com/,他专门编写了一本小薄书来完整描述 Angular 路由模块的设计思路和运行原理,这本书只有 151 页,如果你有兴趣请点这里:https://leanpub.com/router

22.路由基本用法

22.1 路由的基本用法

我们从最简单的例子开始,第一个例子的运行效果如下:

代码结构:

app.routing.module.ts 里面就是路由规则配置,内容如下:

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { JokesComponent } from './jokes/jokes.component';

export const appRoutes: Routes = [
    {
        path: '',
        redirectTo: 'home',
        pathMatch: 'full'
    },
    {
        path: 'home',
        component: HomeComponent
    },
    {
        path: 'jokes',
        component: JokesComponent
    },
    {
        path: '**',
        component: HomeComponent
    }
];

@NgModule({
    imports: [RouterModule.forRoot(appRoutes)],
    exports: [RouterModule]
})
export class AppRoutingModule { }

app.module.ts 里面首先需要 import 这份路由配置文件:

import { AppRoutingModule } from './app.routing.module';

然后 @NgModule 里面的 imports 配置项内容如下:

imports: [
    BrowserModule,
    AppRoutingModule
]

HTML 模板里面的写法:

这个例子的看点:

  • 整个导航过程是通过 RouterModule、app.routing.module.ts、routerLink、router-outlet 这几个东西一起配合完成的。
  • 请点击顶部导航条,观察浏览器地址栏里面URL的变化,这里体现的是Router模块最重要的作用,就是对 URL 和对应界面状态的管理。
  • 请注意路由配置文件 app.routing.module.ts 里面的写法,里面全部用的 component 配置项,这种方式叫“同步路由”。也就是说,@angular/cli 在编译的时候不会把组件切分到独立的 module 文件里面去,当然也不会异步加载,所有的组件都会被打包到一份 JS 文件里面去,请看下图:

你可能会问,如果想要做成异步模块应该怎么做呢?不要着急,下一段里面就会给例子。

  • 注意文件的切分,我看到很多朋友会把路由配置直接写在 app.module.ts 里面,这样做不太好。因为随着项目功能越加越多,路由配置也会变得越来越多,全部写在一起未来不好维护。配置归配置,代码归代码,文件尽量切清晰一些,坑谁也别坑自己对吧?
  • 通配符配置必须写在最后一项,否则会导致路由无效。

特别注意:内容和代码都已经升级到了 8.0 的写法,在 8.0 里面,路由配置已经定义成了独立的模块,代码整体看起来更加合理了。

完整可运行的代码在这里:https://gitee.com/learn-angular-series/learn-router,这个例子对应的代码在 basic分 支上。

22.2 路由与懒加载模块

为什么要做模块的懒加载?

目的很简单:提升 JS 文件的加载速度,提升 JS 文件的执行效率。

对于一些大型的后台管理系统来说,里面可能会有上千份 JS 文件,如果你把所有 JS 全部都压缩到一份文件里面,那么这份文件的体积可能会超过 5M,这是不能接受的,尤其对于移动端应用。

所以,一个很自然的想法就是:我们能不能按照业务功能,把这些 JS 打包成多份 JS 文件,当用户导航到某个路径的时候,再去异步加载对应的 JS 文件。对于大型的系统来说,用户在使用的过程中不太可能会用到所有功能,所以这种方式可以非常有效地提升系统的加载和运行效率。

我们来把上面这个简单的例子改成异步模式,我们把“主页”和“段子”切分成两个独立的模块,并且做成异步加载的模式。

整体代码结构改成这样:

我们给 home 和 jokes 分别加了一个 module 文件和一个 routing.module 文件。

home.routing.module.ts 里面的内容如下:

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { HomeComponent } from './home.component';

export const homeRoutes:Routes=[
    {
        path:'',
        component:HomeComponent
    }
];

@NgModule({
    imports: [RouterModule.forChild(homeRoutes)],
    exports: [RouterModule]
})
export class HomeRoutingModule { }

home.module.ts 里面的内容如下:

import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { HomeComponent } from './home.component';
import { HomeRoutingModule } from './home.routing.module';

@NgModule({
  declarations: [
    HomeComponent
  ],
  imports: [
    HomeRoutingModule
  ],
  providers: [],
  bootstrap: []
})
export class HomeModule { }

jokes 模块相关的代码类似。

最重要的修改在 app.routing.module.ts 里面,路由的配置变成了这样:

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

export const appRoutes:Routes=[
    {
        path:'',
        redirectTo:'home',
        pathMatch:'full'
    },
    {
        path:'home',
        loadChildren: () => import("./home/home.module").then(m => m.HomeModule)
    },
    {
        path:'jokes',
        loadChildren: () => import("./jokes/jokes.module").then(m => m.JokesModule)
    },
    {
        path:'**',
        loadChildren: () => import("./home/home.module").then(m => m.HomeModule)
    }
];

@NgModule({
    imports: [RouterModule.forRoot(appRoutes)],
    exports: [RouterModule]
})
export class AppRoutingModule { }

我们把原来的 component 配置项改成了 loadChildren。

来看运行效果:

请按 F12 打开浏览器里面的开发者工具,查看网络面板,然后点击顶部的导航条,你会看到 0.e7bf37b9868f1788a067.chunk.js 是异步加载进来的。

完整可运行的代码在这里:https://gitee.com/learn-angular-series/learn-router,这个例子对应的代码在 async-module 分支上。

注意:从 Angular 8.0 开始,为了遵守最新的 import() 标准,官方建议采用新的方式来写 loadChildren:

//8.0 之前是这样的:
loadChildren:'./home/home.module#HomeModule'

//从 8.0 开始这样写:
loadChildren: () => import("./blog/home/home.module").then(m => m.HomeModule)

8.0 的路由定义的方式也发生了一些变化,路由定义在自己独立的模块里面,就像这样:

22.3 N层嵌套路由

在真实的系统中,菜单肯定不止一层,我们继续修改上面的例子,给它加一个二级菜单,就像这样:

于是 home 模块的代码结构变成了这样:

重点的变化在 home.routing.module.ts 里面:

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { HomeComponent } from './home.component';
import { PictureComponent } from './picture/picture.component';
import { TextComponent } from './text/text.component';

export const homeRoutes:Routes = [
    {
        path: '',
        component: HomeComponent,
        children: [
            {
                path: '',
                redirectTo: 'pictures',
                pathMatch: 'full'
            },
            {
                path: 'pictures',
                component: PictureComponent
            },
            {
                path: 'text',
                component: TextComponent
            },
            {
                path: '**',
                component: PictureComponent
            }
        ]
    }
];

@NgModule({
    imports: [RouterModule.forChild(homeRoutes)],
    exports: [RouterModule]
})
export class HomeRoutingModule { }

理论上,路由可以无限嵌套,而实际上不可能嵌套得特别深。系统里面有一级、二级、三级菜单很正常,如果你的系统做出了十几级菜单,用户还怎么使用呢?

以上例子完整可运行的代码在这里:https://gitee.com/learn-angular-series/learn-router,代码在 nested-router 分支上。

22.4 共享模块

你刚把嵌套路由的问题搞定,本来以为万事大吉了,这时候产品经理又妖娆地走了过来,他跟你说客户需求改了,需要在页面的侧边栏上面加一个展示用户资料的 Panel(面板),就像这样:

同时还提了另一个要求,这个展示用户资料的 Panel 在“段子”这个模块里面也要用,而且未来还可能在其它地方也要使用。

这时候,该轮到“共享模块”机制出场了。因为根据 Angular 的规定:组件必须定义在某个模块里面,但是不能同时属于多个模块。

如果你把这个 UserInfo 面板定义在 home.module 里面,jokes.module 就不能使用了,反之亦然。

当然,你可能说,这还不简单,把 UserInfo 定义在根模块 app.module 里面不就好了嘛。

不错,确实可以这样做。但是这样会造成一个问题:如果系统的功能不断增多,你总不能把所有共用的组件都放到 app.module 里面吧?如果真的这样搞,app.module 最终打包出来会变得非常胖。

所以,更优雅的做法是切分一个“共享模块”出来,就像这样:

对于所有想使用 UserInfo 的模块来说,只要 import 这个 SharedModule 就可以了。

完整可运行的代码在这里:https://gitee.com/learn-angular-series/learn-router,这个例子对应的代码在 shared-module 分支上。

22.5 处理路由事件

Angular 的路由上面暴露了 8 个事件:

  • NavigationStart
  • RoutesRecognized
  • RouteConfigLoadStart
  • RouteConfigLoadEnd
  • NavigationEnd
  • NavigationCancel
  • NavigationError
  • Scroll

从 Angular 5.0 开始,新增了 8 个路由事件:

  • GuardsCheckStart
  • ChildActivationStart
  • ActivationStart
  • GuardsCheckEnd
  • ResolveStart
  • ResolveEnd
  • ActivationEnd
  • ChildActivationEnd

详细的描述参见这里:

https://angular.io/guide/router#router-events

我们可以监听这些事件,来实现一些自己的业务逻辑,示例如下:

import { Component, OnInit } from '@angular/core';
import { Router,NavigationStart } from '@angular/router';

@Component({
  selector: 'home',
  templateUrl: './home.component.html',
  styleUrls: ['./home.component.scss']
})
export class HomeComponent implements OnInit {

  constructor(private router: Router) {

  }

  ngOnInit() {
    this.router.events.subscribe((event) => {
      console.log(event);
      //可以用instanceof来判断事件的类型,然后去做你想要做的事情
      console.log(event instanceof NavigationStart);
    });
  }
}

完整可运行的代码在这里:https://gitee.com/learn-angular-series/learn-router,这个例子对应的代码在 router-events 分支上。

22.6 如何传递和获取路由参数

在路由上面传递参数是必备的功能,Angular 的 Router 可以传递两种类型的参数:简单类型的参数、“矩阵式”参数。

请注意以下 routerLink 的写法:

<ul class="nav navbar-nav">
    <li routerLinkActive="active" class="dropdown">
        <a [routerLink]="['home','1']">主页</a>
    </li>
    <li routerLinkActive="active" class="dropdown">
        <a [routerLink]="['jokes',{id:111,name:'damo'}]">段子</a>
    </li>
</ul>

在 HomeComponent 里面,我们是这样来获取简单参数的:

constructor(
    public router:Router,
    public activeRoute: ActivatedRoute) { 

}

ngOnInit() {
    this.activeRoute.params.subscribe(
        (params)=>{console.log(params)}
    );
}

在 JokesComponent 里面,我们是这样来接受“矩阵式”参数的:

constructor(
    public router: Router,
    public activeRoute: ActivatedRoute) {

}

ngOnInit() {
    this.activeRoute.params.subscribe(
        (params) => { console.log(params) }
    );
}

“矩阵式”传参 [routerLink]="['jokes',{id:111,name:'damo'}]" 对应的 URL 是这样一种形态:

http://localhost:4200/jokes;id=111;name=damo

这种 URL 形态不常见,很多朋友应该没有看到过,但是它确实是合法的。它不是 W3C 的规范,但是互联网之父 Tim Berners-Lee 在 1996 年的文档里面有详细的解释,主流浏览器都是支持的:https://www.w3.org/DesignIssues/MatrixURIs.html。这种方式的好处是,我们可以传递大块的参数,因为第二个参数可以是一个 JSON 格式的对象。

完整可运行的代码在这里:https://gitee.com/learn-angular-series/learn-router,这个例子对应的代码在 router-params 分支上。

22.7 用代码触发路由导航

除了通过 <a routerLink="home">主页</a> 这种方式进行导航之外,我们还可以通过代码的方式来手动进行导航:

this.router.navigate(["/jokes"],{ queryParams: { page: 1,name:222 } });

接受参数的方式如下:

this.activeRoute.queryParams.subscribe(
    (queryParam) => { console.log(queryParam) }
);

完整可运行的代码在这里:https://gitee.com/learn-angular-series/learn-router,这个例子对应的代码在 router-params 分支上。

23.模块预加载

我们在前面的实例基础上继续修改,为了方便接下来演示“模块预加载”,我们增加了一个一级导航菜单叫做“图片”:

现在我们有 3 个独立的模块:首页、段子、图片。只有当用户点击这些模块的时候,路由才会去异步加载对应的 chunk(块),就像这样:

一切看起来都那么完美!但是,产品经理又妖娆地走过来了,他对你说:小伙子干得不错!但是我有一个想法,你看能不能实现。虽然这种异步加载的方式确实能提升加载和执行的效率,但是用户体验并没有做到极致。你看啊,咱们是一个段子站,根据我们的统计数据,这 3 个模块用户都是一定会点的。所以,在首页模块加载完成之后,如果能把“段子”和“图片”这两个模块预先加载到客户端就好了。这样当用户点击这两个菜单的时候,看起来就像“秒开”一样,这才叫“极致体验”对吧?怎么样,有没有技术上的困难?下班之前能改好吧?

你一听就来劲了:看你说的,在我这儿从来不存在什么“技术上的困难”,下班之前保证搞定!

在这个场景下,“预加载”就派上用场了,你需要修改一下 app.routing.module.ts,相关的内容要改成这样:

import { RouterModule, PreloadAllModules } from '@angular/router';
RouterModule.forRoot(appRoutes,{preloadingStrategy:PreloadAllModules})

改完之后刷一下浏览器,效果看起来挺不错,所有模块都预加载进来了:

Angular 内置了两种预加载策略:PreloadAllModules 和 NoPreloading,PreloadAllModules 的意思是:预加载所有模块,不管有没有被访问到。也就是说,要么就一次预加载所有异步模块,要么就彻底不做预加载。

本来到这里产品经理的要求已经达成了,但是你是一个有情怀的人,一想到产品经理说的“极致体验”,还有他每次走过来的时候那种妖娆的姿势,你的热情又被点燃了起来。

你仔细看了一下上面的代码,总感觉这种“一次预加载所有模块”的方式太简单粗暴了一点儿。而且根据你自己的预测,将来这个系统还会开发更多的模块,如果总是一次性全部预加载,总感觉怪怪的。于是,你想进一步做一些优化,你希望实现自己的预加载策略,最好能在路由配置里面加入一些自定义的配置项,让某些模块预加载、某些模块不要进行预加载,就像这样:

{
    path:'jokes',
    data:{preload:true},
    loadChildren: () => import("./jokes/jokes.module").then(m => m.JokesModule)
},
{
    path:'picture',
    data:{preload:false},
    loadChildren: () => import("./picture/picture.module").then(m => m.PictureModule)
}

当 preload 这个配置项为 true 的时候,就去预加载对应的模块,否则什么也不做。于是你实现了一个自己的预加载策略:

my-preloading-strategy.ts 里面的内容如下:

import { Route,PreloadingStrategy } from '@angular/router';
import { Observable } from "rxjs";
import "rxjs/add/observable/of";

export class MyPreloadingStrategy implements PreloadingStrategy {
    preload(route: Route, fn: () => Observable<any>): Observable<any>{
        return route.data&&route.data.preload?fn():Observable.of(null);
    }
}

当然,别忘记修改一下 app.routing.module.ts 里面的配置,换成你自己的预加载策略:

RouterModule.forRoot(appRoutes,{preloadingStrategy:MyPreloadingStrategy})

OK,这样一来,模块预加载的控制权就完全交到你自己的手里了。你可以继续修改这个预加载策略,比如用加个延时,或者根据其它某个业务条件来决定是不是要执行预加载,如此等等。

产品经理笑嘻嘻地跟你说:你看,我就知道你是我们这里最流弊的,一出手分分钟搞定。

你乐呵呵地说:那必须啊,老将出马,一个顶俩。

而你心里的实际想法是:mmp 站着说话不腰疼,反正不用你写代码,你知不知道为了搞这个破东西害得我改了一大堆东西!幸亏小爷我比较机智,这次还超前做了一些灵活的配置项,就等你小子下回再来改需求了。

24.路由守卫

在实际的业务开发过程中,我们经常需要限制某些 URL 的可访问性。比如:对于系统管理界面,只有那些拥有管理员权限的用户才能打开。

有一些简化的处理方案,比如把菜单隐藏起来。但是这样做是不够的,因为用户还可以自己手动在地址栏里面尝试输入,或者更暴力一点,可以通过工具来强行遍历 URL。

请特别注意:前端代码应该默认被看成是不安全的,安全的重头戏应该放在 Server 端,而前端只是做一些基本的防护。

在 Angular 里面,权限控制的任务由“路由守卫”来负责,路由守卫的典型用法:

  • 控制路由能否激活
  • 控制路由的能否退出
  • 控制异步模块能否被加载

24.1 控制路由能否激活

代码结构:

auth.guard.ts 里面这样写:

import { Injectable } from '@angular/core';
import { CanLoad, CanActivate, CanActivateChild } from '@angular/router';
import { AuthService } from './auth.service';

@Injectable()
export class AuthGuard implements CanLoad,CanActivate,CanActivateChild{

    constructor(private authService:AuthService){

    }

    /**
     * 验证路由是否可以激活
     */
    canActivate(){
        //在真实的应用里面需要写一个 Service 到后端去验证权限
        return this.authService.canActivate();
    }

    /**
     * 验证子路由是否可以激活
     */
    canActivateChild(){
        //在真实的应用里面需要写一个 Service 到后端去验证权限
        return true;
    }
}

别忘记把相关的服务放到 app.module.ts 里面去:

providers: [AuthService,AuthGuard]

然后 app.routing.module.ts 里面这样配置:

{
    path:'jokes',
    data:{preload:true},
    canLoad:[AuthGuard],
    canActivate:[AuthGuard],
    loadChildren: () => import('./jokes/jokes.module').then(m => m.JokesModule)
}

这里的 canActivate 配置项就是用来控制路由是否能被激活的,如果 AuthGuard 里面对应的 canActivate 方法返回 false,jokes 这个路由就无法激活。

在所有子模块的路由里面也可以做类似的配置。

24.2 控制路由的退出

有时候,我们还需要控制路由能否退出。

比如:当用户已经在表单里面输入了大量的内容,如果不小心导航到了其它 URL,那么输入的内容就会全部丢失。很显然,这会让用户非常恼火。

所以,我们需要做一定的防护,避免这种意外的情况。

这个例子的运行界面如下:

我们给 jokes 模块单独写了以守卫:

jokes-guard.ts 里面的内容如下:

import { Injectable } from '@angular/core';
import { CanDeactivate } from '@angular/router';
import { JokesComponent } from './jokes.component';

@Injectable()
export class JokesGuard implements CanDeactivate<any>{
   canDeactivate(component:JokesComponent){
       console.log(component);
       if(!component.saved){
           return window.confirm("确定不保存吗?");
       }
       return true;
   }
}

注意 jokes.module.ts 和 jokes.routing.module.ts 里面相关的配置。

24.3 控制模块能否被加载

除了可以控制路由能否被激活之外,还可以控制模块能否被加载,处理方式类似,在 AuthGuard 里面增加一个处理方法:

/**
 * 验证是否有权限加载一个异步模块
 */
canLoad(){
    //在真实的应用里面需要写一个 Service 到后端去验证权限
    return this.authService.canLoad();
}

如果 canLoad 方法返回 false,模块就根本不会被加载到浏览器里面了。

25.多重出口

到目前为止,在我们所有例子里面,界面结构都是这样的:

但是,有时候我们在同一个界面上需要同时出现两块或者多块动态的内容。比如,你想让左侧的导航栏和右侧的主体区域全部变成动态的,就像这样:

核心代码如下:

app.component.html 里面的内容:

<a [routerLink]="['home', {outlets: {'left-nav': ['leftNav'], 'main-area': ['none']}}]">主页</a>

home.component.html 里面的内容:

<div class="row">
  <div class="col-xs-3">
    <router-outlet name="left-nav"></router-outlet>
  </div>
  <div class="col-xs-9">
    <router-outlet name="main-area"></router-outlet>
  </div>
</div>

left-nav.component.html 里面的核心代码:

<a class="list-group-item" (click)="toogle(1)">只看图片</a>
<a class="list-group-item" (click)="toogle(2)">只看文字</a>

left-nav.component.ts 里面的核心代码:

toogle(id) {
    this.router.navigate(['/home', {outlets: {'main-area': [id]}}]);
}

运行效果:

请注意看浏览器地址栏里面的内容,形式比较复杂,而且代码写起来也比较繁琐,所以,请尽量避开这种用法。

26.表单快速上手

如果没有表单,我们将没有途径收集用户输入。所以,表单是前端开发里面的重头戏。在日常开发中,处理表单会占据你大块的编码时间。

我们先来做一个最简单的用户注册界面:

HTML 模版里面的核心代码:

<input type="email" class="form-control" placeholder="Email" (keyup)="userNameChange($event)">

<input #pwd type="password" class="form-control" placeholder="Password" (keyup)="0">

组件核心代码:

export class FormQuickStartComponent implements OnInit {
  public userName:string;

  public userNameChange(event):void{
    this.userName=event.target.value;
  }
}

这个例子非常简单,里面有两个 input,分别演示两种传递参数的方式:

  • 第一个 input:用事件绑定的方式,把 input 的值传递给组件内部定义的 userName 属性,然后页面上再用 获取数据。
  • 第二个 input:我们定义了一个模板局部变量 #pwd,然后底部直接用这个名字来获取 input 的值 。这里有一个小小的注意点,标签里面必须写 (keyup)=”0”,要不然 Angular 不会启动变更检测机制, 取不到值。

27.双向数据绑定

Angular 是第一个把“双向数据绑定”机制引入到前端开发领域来的框架,这也是当年 AngularJS 最受开发者欢迎的特性。

我们接着上一个例子继续改,先看运行效果:

HTML 模版里面的核心代码:

<input type="email" class="form-control" placeholder="Email" [(ngModel)]="regModel.userName" name="userName">

<input type="password" class="form-control" placeholder="Password" [(ngModel)]="regModel.password" name="password">

<input type="checkbox" name="rememberMe" [(ngModel)]="regModel.rememberMe">记住我

数据模型和组件核心代码:

export class RegisterModel {
    userName: string;
    password: string;
    rememberMe:boolean=false;
}

组件里面的核心代码:

import { RegisterModel } from './model/register-model';

export class FormQuickStartComponent implements OnInit {
  public regModel:RegisterModel=new RegisterModel();
}

一些常见的坑:

  • 要想使用 [(ngModel)] 进行双向绑定,必须在你的 @NgModule 定义里面 import FormsModule 模块。
  • 用双向绑定的时候,必须给 <input> 标签设置 name 或者 id,否则会报错。(这个行为挺奇怪的,吐槽一下!)
  • 表单上面展现的字段和你处理业务用的数据模型不一定完全一致,推荐设计两个 Model,一个用来给表单进行绑定操作,一个用来处理你的业务。

28.表单校验

表单校验一定会牵扯到一个大家都比较头疼的技术点:正则表达式。正则表达式学起来有难度,但是又不可或缺。

强制所有开发者都能精通正则表达式是不太现实的事情,但是有一点是必须要做到的,那就是至少要能读懂别人编写正则。

28.1 先来一个例子

关键 HTML 模板代码如下:

    <form #registerForm="ngForm" class="form-horizontal">
      <div class="form-group" [ngClass]="{'has-error': userName.invalid && (userName.dirty || userName.touched) }">
        <label class="col-xs-2 control-label">用户名:</label>
        <div class="col-xs-10">
          <input #userName="ngModel" [(ngModel)]="regModel.userName" name="userName" type="email" class="form-control" placeholder="Email" required minlength="12" maxlength="32">
          <div *ngIf="userName.invalid && (userName.dirty || userName.touched)" class="text-danger">
            <div *ngIf="userName.errors.required">
              用户名不能为空
            </div>
            <div *ngIf="userName.errors.minlength">
              最小长度不能小于12个字符
            </div>
            <div *ngIf="userName.errors.maxlength">
              最大长度不能大于32个字符
            </div>
          </div>
        </div>
      </div>
    </form>

    <div class="panel-footer">
        <p>用户名:{{userName.value}}</p>
        <p>密码:{{pwd.value}}</p>
        <p>表单状态: {{registerForm.valid}} 
                    {{registerForm.invalid}} 
                    {{registerForm.pending}} 
                    {{registerForm.pristine}} 
                    {{registerForm.dirty}}
                    {{registerForm.untouched}} 
                    {{registerForm.touched}}
        </p>
    </div>

模板和组件里面的关键代码:

export class RegisterModel {
    userName: string;
    password: string;
    rememberMe:boolean=false;
}
import { RegisterModel } from './model/register-model';

export class FormQuickStartComponent implements OnInit {
  public regModel:RegisterModel=new RegisterModel();
}

28.2 状态标志位

Form、FormGroup、FormControl(输入项)都有一些标志位可以使用,这些标志位是 Angular 提供的,一共有 9 个(官方的文档里面没有明确列出来,或者列得不全):

  • valid:校验成功
  • invalid:校验失败
  • pending:表单正在提交过程中
  • pristine:数据依然处于原始状态,用户没有修改过
  • dirty:数据已经变脏了,被用户改过了
  • touched:被触摸或者点击过
  • untouched:未被触摸或者点击
  • enabled:启用状态
  • disabled:禁用状态

Form 上面多一个状态标志位 submitted,可以用来判断表单是否已经被提交。

我们可以利用这些标志位来判断表单和输入项的状态。

28.3 内置校验规则

Angular 一共内置了 8 种校验规则:

  1. required
  2. requiredTrue
  3. minLength
  4. maxLength
  5. pattern
  6. nullValidator
  7. compose
  8. composeAsync

详细的 API 描述参见这里:

https://angular.io/api/forms/Validators

28.4 自定义校验规则

内置的校验规则经常不够用,尤其在需要多条件联合校验的时候,所以我们需要自己定义校验规则。

请看这个例子:

关键 HTML 模板代码:

<div class="form-group"  [ngClass]="{'has-error': mobile.invalid && (mobile.dirty || mobile.touched) }">
  <label class="col-xs-2 control-label">手机号:</label>
  <div class="col-xs-10">
    <input #mobile="ngModel" [(ngModel)]="regModel.mobile" name="mobile" ChineseMobileValidator class="form-control" placeholder="Mobile">
    <div *ngIf="mobile.invalid && (mobile.dirty || mobile.touched)" class="text-danger">
        <div *ngIf="!mobile.errors.ChineseMobileValidator">
          请输入合法的手机号
        </div>
    </div>
  </div>
</div>

自定义的校验规则代码:

import { Directive, Input } from '@angular/core';
import { Validator, AbstractControl, NG_VALIDATORS } from '@angular/forms';

@Directive({
    selector: '[ChineseMobileValidator]',
    providers: [
        {
            provide: NG_VALIDATORS,
            useExisting: ChineseMobileValidator,
            multi: true
        }
    ]
})
export class ChineseMobileValidator implements Validator {
    @Input() ChineseMobileValidator: string;

    constructor() { }

    validate(control: AbstractControl): { [error: string]: any } {
        let val = control.value;        
        let flag=/^1(3|4|5|7|8)\d{9}$/.test(val);
        console.log(flag);
        if(flag){
            control.setErrors(null);
            return null
        }else{
            control.setErrors({ChineseMobileValidator:false});
            return {ChineseMobileValidator:false};
        }
    }
}

可以看到,自定义校验规则的使用方式和内置校验规则并没有什么区别。

当然,也可以把正则表达式传给内置的 pattern 校验器来实现这个效果,但是每次都拷贝正则比较麻烦,对于你的业务系统常见的校验规则,还是把它沉淀成你们自己的校验规则库可复用性更高。

关于校验器更详细的 API 描述参见这里:

https://angular.io/api/forms/Validators

29.模型驱动型表单

前面的例子都是“模板驱动型表单”,我们把表单相关的逻辑,包括校验逻辑全部写在模板里面,组件内部几乎没写什么代码。

表单的另一种写法是“模型驱动型表单”,又叫做“响应式表单”。特点是:把表单的创建、校验等逻辑全部用代码写到组件里面,让 HTML 模板变得很简单。

特别注意:如果想使用响应式表单,必须在你的 @NgModule 定义里面 import ReactiveFormsModule。

这里我们来一个复杂一些的表单,运行起来的效果如下:

如果你想查阅“响应式表单”的详细文档,请参考这里:

https://angular.io/guide/reactive-forms

30.动态表单

有这样一种业务场景:表单里面的输入项不是固定的,需要根据服务端返回的数据动态进行创建。

这时候我们压根没法把表单的 HTML 模板写死,我们需要根据配置项用代码动态构建表单,而这些配置项甚至可能是在服务端动态生成的。

在 NiceFish 里面有一个实际的例子,运行效果如下:

我们把创建表单相关的逻辑全部移到了组件里面:

HTML 模板变得非常简单:

31.服务

在组件的构造函数里面声明,Angular 会在运行时自动把 Service 实例创建出来并注射给组件:

31.1 单例模式

如果你希望 Service 是全局单例的,需要把它定义到根模块里面。

31.2 多实例模式

下面这个例子用来测试 UserListService 是否是单例,第一个组件会向 UserListService 里面塞数据,第二个组件会尝试去读取数据:

核心代码如下:

<pre>
@Component({
  selector: 'order-list',
  templateUrl: './order-list.component.html',
  styleUrls: ['./order-list.component.scss'],
  providers: [UserListService] //如果你在这里提供了 providers 配置,UserListService 就不是全局单例了
})
</pre>

从运行结果可以看出来,因为我们在组件内部的 providers 里面也配置了一个 UserListService,很明显就不是同一个实例了。

31.3 简单解释一下原理

在新版本的 Angular 里面,每个组件上都有自己的注射器(Injector)实例,所以很明显,注射器也构成了一个树形的结构。

我们的 UserListService 是通过依赖注入机制注射给组件的,DI 机制会根据以下顺序查找服务实例:

  • 如果组件内部的 providers 上面配置了服务,优先使用组件上的配置。
  • 否则继续向父层组件继续查找。
  • 直到查询到模块里面的 providers 配置。
  • 如果没有找到指定的服务,抛异常。

所以请特别注意:

  • 在 Component 里面直接引入 Service,就不是单例了,而是会为每个组件实例都创建一个单独的 Service 单例。
  • 如果你在多个模块(@NgModule)里面同时定义 providers,那也不是单例。
  • 如果你在异步加载的模块里面定义 Service,那也不是全局单例的,因为 Angular 会为异步模块创建独立的 Injector 空间。

31.4 关于Service的基本注意点

有很多朋友说:OK,我会写 Service 了,也知道怎么玩注入了,但还有一个最基本的问题没有解决,那就是应该把什么样的东西做成服务?

整体上说,Angular 里面的 Service 与后端框架里面的 Service 设计思想是一致的:

  • Service 应该是无状态的。
  • Service 应该可以被很多组件复用,不应该和任何组件紧密相关。
  • 多个 Service 可以组合起来,实现更复杂的服务。

在 Angular 核心包里面,最典型的一个服务就是 Htpp 服务。

https://en.wikipedia.org/wiki/Service-oriented_architecture

32.RxJS 快速上手

32.1 ReactiveX与RxJS

ReactiveX 本身是一种编程范式,或者叫一种设计思想,目前有Java/C++/Python 等 18 种语言实现了 ReactiveX,RxJS 是其中的 JavaScript 版本。

ReactiveX 的官方网站在这里:http://reactivex.io/,上面有详细介绍、入门文档、技术特性等。

这篇文章不会重复文档上已经有的内容,而是从另外一个视角,带你领略 RxJS 的核心用法。

32.2 回调地狱与 Promise

在使用 Ajax 的过程中,经常会遇到这种情况:我们需要在一个 Ajax 里面嵌套另一个 Ajax 调用,有时候甚至需要嵌套好几层 Ajax 调用,于是就形成了所谓的“回调地狱”:

这种代码最大的问题是可读性非常差,时间长了之后根本无法维护。

Promise 的出现主要就是为了解决这个问题,在 Promise 的场景下,我们可以这样写代码:

new Promise(function(resolve,reject){
    //异步操作之后用 resolve 返回 data
})
.then(function(data){
    //依赖于 Promise 的第一个异步操作
})
.then(function(data){
    //依赖于 Promise 的第二个异步操作
})
.then(function(data){
    //依赖于 Promise 的第三个异步操作
})
.catch(function(reason){
    //处理异常
});

很明显,这样的代码可读性就强太多了,而且未来维护起来也很方便。

当然,Promise 的作用不止于此,如果你想更细致地研究 Promise,请看 MDN 上的这篇资料:

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise

32.3 RxJS 与 Promise 的共同点

RxJS 与 Promise 具有相似的地方,请看以下两个代码片段:

let promise = new Promise(resolve => {
    setTimeout(() => {
        resolve('---promise timeout---');
    }, 2000);
});
promise.then(value => console.log(value));
let stream1$ = new Observable(observer => {
    let timeout = setTimeout(() => {
        observer.next('observable timeout');
    }, 2000);

    return () => {
        clearTimeout(timeout);
    }
});
let disposable = stream1$.subscribe(value => console.log(value));

可以看到,RxJS 和 Promise 的基本用法非常类似,除了一些关键词不同。Promise 里面用的是 then() 和 resolve(),而 RxJS 里面用的是 next() 和 subscribe()。

32.4 RxJS 与 Promise 的 3 大重要不同点

任何一种技术或者框架,一定要有自己的特色,如果跟别人完全一样,解决的问题也和别人一样,那存在的意义和价值就会遭到质疑。

所以,RxJS 一定有和 Promise 不一样的地方,最重要的不同点有 3 个,请看下图:

依次给 3 块代码来示范一下:

let promise = new Promise(resolve => {
    setTimeout(() => {
        resolve('---promise timeout---');
    }, 2000);
});
promise.then(value => console.log(value));
let stream1$ = new Observable(observer => {
    let timeout = setTimeout(() => {
        observer.next('observable timeout');
    }, 2000);

    return () => {
        clearTimeout(timeout);
    }
});
let disposable = stream1$.subscribe(value => console.log(value));
setTimeout(() => {
    disposable.unsubscribe();
}, 1000);

从以上代码可以看到,Promise 的创建之后,动作是无法撤回的。Observable 不一样,动作可以通过 unsbscribe() 方法中途撤回,而且 Observable 在内部做了智能的处理,如果某个主题的订阅者为 0,RxJS 将不会触发动作。

let stream2$ = new Observable<number>(observer => {
    let count = 0;
    let interval = setInterval(() => {
        observer.next(count++);
    }, 1000);

    return () => {
        clearInterval(interval);
    }
});
stream2$.subscribe(value => console.log("Observable>"+value));

以上代码里面我们用 setInterval 每隔一秒钟触发一个新的值,源源不断,就像流水一样。

这一点 Promise 是做不到的,对于 Promise 来说,最终结果要么 resole(兑现)、要么 reject(拒绝),而且都只能触发一次。如果在同一个 Promise 对象上多次调用 resolve 方法,则会抛异常。而 Observable 不一样,它可以不断地触发下一个值,就像 next() 这个方法的名字所暗示的那样。

let stream2$ = new Observable<number>(observer => {
    let count = 0;
    let interval = setInterval(() => {
        observer.next(count++);
    }, 1000);

    return () => {
        clearInterval(interval);
    }
});
stream2$
.pipe(
    filter(val => val % 2 == 0)
)
.subscribe(value => console.log("filter>" + value));

stream2$
.pipe(
    map(value => value * value)
)
.subscribe(value => console.log("map>" + value));

在上述代码里面,我们用到了两个工具函数:filter 和 map。

  • filter 的作用就如它的名字所示,可以对结果进行过滤,在以上代码里面,我们只对偶数值有兴趣,所以给 filter 传递了一个箭头函数,当这个函数的返回值为 true 的时候,结果就会留下来,其它值都会被过滤掉。
  • map 的作用是用来对集合进行遍历,比如例子里面的代码,我们把 Observable 返回的每个值都做了一次平方,然后再传递给监听函数。

类似这样的工具方法在 Observable 里面叫做 operator(操作符),所以有人说 Observable 就相当于异步领域的 Underscore 或者 lodash,这样的比喻是非常贴切的。这也是 Observable 比较强的地方,Promise 里面就没有提供这些工具函数。

Observable 里面提供了数百个这样的“操作符”,完整的列表和 API 文档请参考这里:

http://reactivex.io/documentation/operators.html

RxJS 官方的 GitHub 仓库在这里:

https://github.com/ReactiveX/rxjs.git

特别注意:Angular 5.0之后,修改了 RxJS 的 import 方式,与其它模块的引入格式进行了统一。

import { Observable, Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged, map, filter } from 'rxjs/operators';

我也看到有一些朋友在抱怨,说 RxJS 太过复杂,操作符(operator)的数量又特别多,不知道在什么场景下面应该用什么操作符。

实际上这种担心是多余的,因为在 RxJS 里面最常用的操作符不超过 10 个,不常用的操作符都可以在使用的时候再去查阅文档。

RxJS 和你自己开发的系统一样,常用的功能只有其中的 20%,而剩余 80% 的功能可能永远不会被用到。所以,RxJS 并不像很多人说的那么玄乎,你一定能学会,我相信你。

32.5 RxJS 在 Angular 的典型应用场景 1:HTTP 服务

this.http
.get(url, { search: params })
.pipe(
    map((res: Response) => {
        let result = res.json();
        console.log(result);
        return result;
    }),
    catchError((error: any) => Observable.throw(error || 'Server error'))
);

在新版本的 Angular 里面,HTTP 服务的返回值都是 Observable 类型的对象,所以我们可以 subscribe(订阅)这个对象。当然,Observable 所提供的各种“操作符”都可以用在这个对象上面,比如上面这个例子就用到了 map 操作符。

32.6 RxJS 在 Angular 的典型应用场景 2:事件处理

this.searchTextStream
.pipe(
    debounceTime(500),
    distinctUntilChanged()
)
.subscribe(searchText => {
    console.log(this.searchText);
    this.loadData(this.searchText)
});

这个例子里面最有意思的部分是 debounceTime 方法和 distinctUntilChanged 方法,这是一种“去抖动”效果。“去抖动”这个场景非常能体现 Observable 的优势所在,有一些朋友可能没遇到过这种场景,我来解释一下,以防万一。

在搜索引擎里面,我们经常会看到这样的效果:

这种东西叫做“动态搜索建议”,在用户敲击键盘的过程中,浏览器已经向后台发起了请求,返回了一些结果,目的是给用户提供一些建议。

效果看起来很简单,但是如果没有这个 debounceTime 工具函数,我们自己实现起来是非常麻烦的。这里的难点在于:用户敲击键盘的过程是源源不断的,我们并不知道用户什么时候才算输入完成。所以,如果让你自己来从零开始实现这种效果,你将会不得不使用定时器,不停地注册、取消,自己实现延时,还要对各种按键码做处理。

在 Observable 里面,处理这种情况非常简单,只要一个简单的 debounceTime 加 distinctUntilChanged 调用就可以了。

32.7 小结

  • ReactiveX 本身是一种编程范式,或者叫一种设计思想,RxJS 是其中的一种实现,其它还有 Java/C++/Python 等15种语言的实现版本。ReactiveX 本身涉及到的内容比较多,特别是一些设计思想层面的内容,如果你对它特别有兴趣,请参考官方的站点:http://reactivex.io
  • 关于 RxJS 目前已经有专门的书籍来做介绍,但是还没有中文版。在网络上有各种翻译和文章,如果你想深入研究,请自行搜索。
  • RxJS 是 Angular 内核的重要组成部分,它和 Zone.js 一起配合实现了“变更检测”机制,所以在编写 Angular 应用的过程中应该优先使用 RxJS 相关的 API。
  • RxJS 可以独立使用,它并不一定要和 Angular 一起使用。

33.Reactive Programming与RxJS深入解析

这部分内容有深度,如果你暂时不想了解这么多,可以先跳过去,有需要的时候再来看。

33.1 Reactive Programming—反应式编程

从本质上来说,计算机编程语言分成两大种大的范式:命令式和声明式。

  • 典型的命令式编程语言有:C、C++、Java 等。
  • 典型的声明式编程语言有:SQL、XML、HTML、SVG 等。

为了帮助你更好地理解这两种编程范式的不同点,我用自己的语言来解释一下。比如 SQL 是一种典型的声明式语言,你会写出这样的语句:

select u.* from user u where u.age > 15;

但是,数据库在底层是如何解释并执行这条语句,是由数据库自己决定的,不需要程序员来控制,程序员只是在“描述”自己想要什么,而并不需要告诉计算机具体怎么做。

命令式编程语言刚好相反,程序员必须想好需要什么结果,同时需要提供完整的执行过程。

Reactive Programming 属于声明式编程语言的一种,有很多中文资料把它翻译成“响应式编程”,我认为这不够准确,而且容易和UI设计领域的“响应式编程”发生混淆,翻译成“反应式编程”更加贴切,请参考题图。

33.2 发展历程

Reactive Programming 在 1970 年代就开始发展了,后来微软在 .NET 上面做了第一个实现,后面 2013 年的时候有了 Java 版的实现,然后才有了 ReactiveX 宣言。

目前有 18 种语言实现了 ReactiveX,而 RxJS 是其中的 JS 版本。所以,你可以看到,ReactiveX 本身是和语言无关的,你可以把它看成一种编程思想、一种协议、一种规范。

33.3 典型的业务场景

有人会说,OK,我懂了,这是一种编程思想,但是我为什么要用它呢?它能带来什么好处呢?

我举几个典型的业务场景帮助你理解。

  • 场景一:事件流与“防抖动”

用户连续不断地敲击键盘,如果用户每次按下一个键就发起一个网络请求进行查询,很明显就会产生大量无效的查询。那么,如何才能在用户真正输入完成之后再发起查询请求呢?这个场景用 RxJS 实现起来就非常简单。

  • 场景二:数据流

我有 3 个 Ajax 请求,业务需要 3 个请求全部都成功之后才能继续后面的业务操作。这个场景可以用 Promise 来实现,也可以用 RxJS 来实现。

  • 场景三:数据与 UI 的同步

  • 场景四:Android 中 UI 线程与其它线程的同步问题

对于以上 4 种典型的业务场景,如果完全靠程序员从零自己实现,会非常繁琐,而用 ReactiveX 的思路来做就会非常简单。

33.4 RX中的难点:Operator(操作符)

ReactiveX 所描述的设计思想是非常清晰的,理解起来也不困难。

但是,在工程实践中,有很多人在抱怨 ReactiveX 过于繁琐,这里面最大的一个难点就是所谓的“操作符”(Operator)的用法。

ReactiveX 官方移动定义了 70 个 Operator,分成 11 个大类(各种语言实现 Operator 的数量不一样):

33.5 RxJS

RxJS 是 ReactiveX 的 JavaScript 版实现,它本身是独立的,只是 Angular 选用了它来构建自己的内核。

RxJS 一共实现了 105 个 Operator,分成 10 个大类,完整的分类和列表参见这里:

https://rxjs.dev/guide/operators

请不用担忧,这里面很多 Operator 在日常业务开发里面永远都不会用到。所以你不需要一次性全部掌握,刚开始的时候只要能熟练使用其中的 15 个就可以了。

创建型:

  • ajax
  • empty
  • from
  • of
  • range

join 创建型:

  • concat
  • merge
  • zip

变换型:

  • map
  • scan

过滤型:

  • filter
  • first
  • last
  • throttle

异常处理型:

  • catchError

对于其他 Operator,你可以在用到的时候再查文档,也可以通过类比的方式进行理解和记忆。比如:对于数学运算类的 Operator,当你看到有 max 的时候,就能想到一定有 min。这是非常自然的事情,并不需要额外的努力。

在官方的文档中 https://rxjs.dev/guide/operators,为每一个 Operator 都提供了实例代码,总共有一千多个例子,你可以对照这些例子进行理解。在例子页面上,还提供了弹珠图。我看到有一些初学者还不会看弹珠图,附一张中文版的弹珠图说明如下:

弹珠图是从上向下看的:上方的时间线是输入,中间的方框是 Operator,下方的时间线是输出。由于输入输出都是 Observable,所以可以无限链式调用。

比如下面这张弹珠图:

输入是上方的两条时间线,中间的 merge 是 Operator,下方的时间线是输出,所以 merge 操作的效果就是把两条时间线上的值“合并”成了下方的一条时间线。

33.6 参考资料

34.国际化的用法

34.1 先看运行效果

这里用 NiceFish 这个开源项目来演示国际化的用法,代码在这里:

https://gitee.com/mumu-osc/NiceFish

这是默认中文情况下,用户注册界面:

打开 Chrome 的设置界面,把默认语言设置成“英语”:

刷新一下,可以看到界面变成了英文状态:

34.2 解释具体做法

第一步:在项目的 package.json 里面的 dependencies 配置项中加上 “ng2-translate”: “5.0.0”。

第二步:在 app.module.ts 里面导入需要使用的模块:

import { TranslateModule, TranslateLoader, TranslateStaticLoader } from 'ng2-translate';

在 imports 配置项里面加上以下内容:

TranslateModule.forRoot({
    provide: TranslateLoader,
    useFactory: (http: Http) => new TranslateStaticLoader(http,'./assets/i18n', '.json'),
    deps: [Http]
})

第三步:在 app.component.ts 中的 ngOnInit 钩子里面加上以下内容:

this.translate.addLangs(["zh", "en"]);
this.translate.setDefaultLang('zh');
const browserLang = this.translate.getBrowserLang();
this.translate.use(browserLang.match(/zh|en/) ? browserLang : 'zh');

第四步:在 HTML 模板里面通过管道的方式来编写需要进行国际化的Key:

可以看到,国际化插件本质上是利用了 Angular 的“管道”机制。

第五步:用来编写国际化字符串的 JSON 文件是这样的:

34.3 小结

ng2-translate 的主页在这里:

https://github.com/ngx-translate/core

它是一个第三方提供的 i18n 库,和 Angular 结合得比较好,ngx-translate 是后来改的名字。

35.自动化测试

自动化测试一直是前端开发中的一个巨大痛点,由于前端在运行时严重依赖浏览器环境,导致我们一直没法像测试后端代码那样可以自动跑用例。

在有了 NodeJS 之后,我们终于有了 Karma+Jasmine 这样的单元测试组合,也有了基于 WebDriverJS 这样的可以和浏览器进行通讯的集成测试神器。

目前,无论你使用什么样的前端框架,做单元测试一定会用到 Karma+Jasmine,这个组合已经成为了事实标准。Karma 是一个运行时平台,Jasmine 是用来编写测试用例的一种语法。

集成测试(场景测试)稍微复杂一些,但是一般都会用 WebDriverJS 来实现,它也是事实标准。对于 Angular 来说,集成测试所用的工具叫做 Protractor(量角器),底层也是 WebDriverJS。

如果你使用 @angular/cli 作为开发环境,在前端自动化测试方面会非常简单,因为它已经在内部集成了这些工具。

但是有一件事非常遗憾,在@angular/cli 目前发布的所有版本里面,默认生成的项目和配置文件都无法直接运行单元测试,因为@angular/cli 默认引用的一些 Node.js 模块在 Windows 平台上面有 Bug。

所以,这里不会列举 Jasmine 和 Protractor 的那些语法特性,而是帮你填平这些小坑,让你能把这个机制跑起来。至于 Jasmine 和 Protractor 详细 API 调用方式,需要你自己去研究并熟悉,请参见:

注意,Karma、Jasmine、WebDriverJS 是通用技术,与具体的框架无关;Protractor 是专门针对 Angular 设计的,不能用在其它框架里面。

35.1 单元测试

在 @angular/cli 自动生成的项目结构里面,karma.conf.js 里面有这样一些配置项:

很可惜,这里引用的 karma-jasmine-html-reporter 这个 Node 模块在 Windows 下面有 Bug。

所以我们需要进行一些修改,把报告生成器改成 karma-htmlfile-reporter 和 karma-mocha-reporter。

我们需要修改两份配置文件:package.json 和 karma.conf.js。

第一步,把 package.json 里面的 "karma-jasmine-html-reporter" 这一行删掉,换成以下内容:

"karma-mocha-reporter":"^2.2.3",
"karma-htmlfile-reporter": "~0.3",

第二步,把 karma.conf.js 里面的 require('karma-jasmine-html-reporter') 这一行配置换成以下内容:

require('karma-htmlfile-reporter'),
require('karma-mocha-reporter'),

同时把原来 reporters: ['progress', 'kjhtml'] 这一行替换成下面的一段内容:

reporters: ['progress','mocha','html'],
htmlReporter: {
    outputFile: 'unit-test-report/report.html',

    // Optional 
    pageTitle: '单元测试结果',
    subPageTitle: 'learn-test',
    groupSuites: true,
    useCompactStyle: true,
    useLegacyStyle: true
},

如果你想直接拷贝 karma.conf.js 完整的内容,请参考这个空壳示例项目:

https://gitee.com/learn-angular-series/learn-test

改完这些配置之后,使用 cnpm install 重新安装一下所依赖的 Node 模块,然后在终端里面执行:

ng test

Karma 将会自动把你本地的 Chrome 浏览器拉起来,并且自动运行所有测试用例。

同时,在 unit-test-report 这个目录里面会生成一个 report.html,我本地跑完之后生成的内容如下:

接下来就看你自己的了,你需要去 Jasmine 的主页上面熟悉一下基本语法,然后编写更多的单元测试用例。

35.2 集成测试

在 @angular/cli 自动生成的项目结构里面,有一个 e2e 目录,里面有 3 个文件:

打开 app.po.ts,可以看到下面的内容:

import { browser, by, element } from 'protractor';

export class AppPage {
  navigateTo() {
    return browser.get('/');
  }

  getParagraphText() {
    return element(by.css('app-root h1')).getText();
  }
}

我不打算在这篇文章里面列举 Protractor 的技术特性和 API 列表,借着上面的这段代码,我大概给你介绍一下 Protractor 的整体设计思路和使用方式。

如前所述,Protractor 的底层是 WebDriverJS,从 WebDriverJS 这个名字你可以猜出来,这是一个 Driver(驱动),它是用来和浏览器进程通讯的。

Protractor 在 WebDriverJS 的基础上封装了一层,暴露出了几个非常核心的接口:

  • browser 对象:我们可以利用这个对象来操纵浏览器,比如打开和关闭浏览器窗口、让浏览器窗口最大(小)化、控制浏览器导航到某个 URL 路径。
  • element 和 by 对象:我们可以利用这两个对象来控制浏览器内部的 HTML 元素,而其基本的语法和 CSS 选择器非常类似,并没有太多的学习成本。

35.3 小结

推荐阿里发布的前端自动化测试 f2etest 框架,这是目前较强大的一款前端自动化框架,而且是开源免费的。f2etest 的底层也是用的 Karma+Jasmine 和 WebDriverJS 这套东西,它在此基础上进行了自己的封装,可以利用多台虚拟机实现浏览器云的效果。关于 f2etest 的更多详情请参考这个链接:https://github.com/alibaba/f2etest,里面有详细的文档和上手教程。

虽然已经有了这么多强大的工具,但是国内大多数企业并没有真正去编写测试用例。因为测试用例本身也是代码,而国内大多数企业都会不停地改需求,这就会导致测试用例的代码也需要不停地改。不写测试用例我们已经 996 了,根本没有任何动力去把工作量增加一倍。

所以,如你所知,像TDD 这种东西,还是让它停留在美丽的幻想里面吧。

对于自动化测试这件事,也许只能量力而行,能做就做一些,实在不想做的话,最起码要知道怎么做。

当然,也有少量的企业自己搭建了完善的持续集成平台,如果有这样的技术基础,自动化测试做起来会轻松很多。

36.注射器树基础知识

为了能更方便地理解后面的内容,你需要预先理解以下两个概念:

  • 组件树
  • 注射器树

同时还要介绍一个调试神器 Augury,注意,这货读 [‘ɔ:ɡjuri],是“占卜”、“预言”的意思,不是 angry,不是愤怒!

36.1 组件树

目前,几乎所有前端框架都在玩“组件化”,而且最近都不约而同地选择了“标签化”这种思路,Angular 也不例外。“标签化”会导致一个很自然的结果,组件之间会形成树形结构。例如,对于下面这样一个界面:

用 Angular 实现出来的组件树结构是这样的:

在线查看运行效果:

http://47.104.13.149:4200/

repo 地址:

http://git.oschina.net/mumu-osc/NiceFish

36.2 Injector Tree

如你所知,AngularJS 是第一个把“依赖注入”(Dependency Injection)思想带到前端开发领域的框架。

关于“注射器树”这事儿这里要说得更精确一点:如果一个 DOM 元素上面被创建了 Component 或者 Directive,Angular 就会创建一个对应的注射器实例。

对于上面的组件结构,形成的注射器结构是这样的:

很明显,这些 Injector 实例也构成了树形结构:

请记住这个树形结构,后续的所有内容都是以此为基础展开的。

36.2 利用Augury可视化查看注射器树

Augury 是一款 Chrome 插件,它是调试 Angular 应用的利器,利用它可以可视化展示组件树、路由树,以及服务依赖关系。

比如,对于 NiceFish 首页:

它的服务依赖关系是这样的:

组件依赖关系是这样的:

整体路由树是这样的:

36.3 小结

到这里为止,你知道了:在 Angular 应用运行时,组件之间会构成树形结构,Injector(注射器)的实例也会构成树形结构。

接下来,我们从易到难,就可以把注射器玩儿出花来。

36.4 参考资源

37.Angular依赖注入的基本玩法(1)

Angular 的依赖注入机制很强大,这一节我们玩儿三种最典型的场景:

  • 全局单例模式的 Service
  • 多实例模式的 Service
  • 异步模块上的 Service

37.1 全局单例模式

我们有一个 UserListComponent,它会利用 UserListService 来加载数据,写法如下。

在 UserListComponent 的构造函数里声明 UserListService:

编写 UserListService 的具体实现:

在根模块 AppModule 的 providers 里面配置 UserListService:

运行起来的效果是这样的:

再看一下以上代码,你没有直接 new UserListService 对不对?很明显,Angular 在运行时自动帮你创建了 Service 的实例。

OK,看起来不错,但是如何证明这个 Service 是全局单例呢?

我们在界面上再放一个 UserListComponent 的实例,然后把 UserListService 的 id 打印出来看是否相同,就像这样:

运行起来的效果是这样的:

可以看到,在两个 UserListComponent 实例中,使用的都是同一个 UserListService 实例。

这种全局单例模式很有用,你可以利用它来实现整个 App 范围内的数据共享。

注意:在同步 NgModule 里面配置的 provider 在整个 App 范围内都是可见的,也就是说,即使你在某个子模块里面配置的 provider,它们依然是全局可见的,可以被注射到任意类里面。

37.2 多实例模式

有人会说,如果我想创建多个 UserListService 实例,怎么办?

我们把 UserListComponent 改成这样:

然后在界面上放两个实例:

运行起来的效果是这样的:

可以看到,如果把 UserListService 配置在 UserListComponent 内部的 providers 中,就不再是单例模式了,每个 UserListComponent 都拥有自己独立的 UserListService 实例。

组件内部的 provider 生命周期与组件自身保持一致,当组件被销毁的时候,它内部的 provider 也会被销毁掉。

37.3 异步模块上的注射器

以上都是同步模块,对于懒加载进来的异步模块,注射器是一种什么样的结构呢?

我们来做一个复杂一点的例子,默认展示首页:

点击“用户列表”之后导航到 http://localhost:4200/userlist 展示用户列表:

“用户列表”是一个异步模块,从 Chrome 的网络面板上可以看到这个模块是点击之后才加载进来的:

用 Augury 展示 UserListService 实例的依赖关系:

注意:异步模块里面配置的 providers 只对本模块中的成员可见。如果你在其它模块里面引用异步模块里面配置的 provider,会产生异常。这里的本质原因是,Angular 会给异步加载的模块创建独立的注射器树。

你可以自己尝试修改以上例子继续测试。

37.4 小结

来总结一下这个注入机制,它的运行规则是这样的:

  • 如果组件内部配置了 providers,优先使用组件上的配置来创建注入对象。
  • 否则向父层组件继续查找,父组件上找不到继续向所属的模块查找。
  • 一直到查询到根模块 AppModule 里面的 providers 配置。
  • 如果没有找到指定的服务,抛异常。
  • 同步模块里面配置的 providers 是全局可见的,即使是很深的子模块里面配置的 providers,依然是全局可见的。
  • 异步模块里面配置的 providers 只对本模块中的成员可见。这里的本质是,Angular 会给异步加载的模块创建独立的注射器树。
  • 组件里面配置的 providers 对组件自身和所有子层组件可见。
  • 注射器的生命周期与组件自身保持一致,当组件被销毁的时候,对应的注射器实例也会被销毁。

简而言之,Angular 的 Injector Tree 机制与 JavaScript 的原型查找类似。对于日常的开发来说,知道这些已经足够,可以覆盖 90% 以上的业务场景了。

但是,既然这是在说 DI,我们可以玩儿一些复杂的花样,请继续下一个小节。

37.5 参考资源

38.@Injectable & @Inject(2)

38.1 自动档 @Injectable

在上一小节里面,UserListService 服务直接返回了一个数组字面值。在真实的应用中,我们需要到服务端去加载数据。这就需要用到 Angular 提供的 HttpClient 服务了,这里我们需要把 HttpClient 服务注射到 UserListService 服务里面去,做法如下:

别忘记在 app.module 里面 import 一下 HttpClientModule:

我们注意到,在以上第一段代码里面,UserListService 顶部有一个 @Injectable 装饰器。那么 @Injectable 到底对 UserListService 做了什么猥琐的事情呢?

我们来看 ng build 之后生成的代码:

可以看到,编辑器生成了一些奇怪的东西,看起来像是保留了一些类型信息。

如果我们把 @Injectable 删掉会怎么样呢?来看最终编译出来的代码:

可以看到,去掉 @Injectable 装饰器之后,生成出来的代码发生了很大的变化,而且运行会报错:

OK,我们大概可以猜到 @Injectable 装饰器的作用了:如果存在 @Injectable 装饰器,TS 编译器就会在最终生成的代码里面保留类型元数据(实际上是内核里面定义的 decorator 函数),然后 Angular 在运行时就可以根据这些信息来注射指定的对象。否则,运行时就无法解析参数类型了。

简而言之:如果一个 Service 里面需要依赖其它 Service,需要使用 @Injectable 装饰器进行装饰。

为了不给自己找麻烦,最好所有 Service 都加上 @Injectable 装饰器,这是一种良好的编码风格。用 @angular/cli 生成的 Service 会自动在头部加上 @Injectable 装饰器,不需要你操心。

38.2 手动档:利用@Inject指定类型信息

除了在 UserListService 顶部添加 @Injectable 装饰器之外,还有一种非常不常用的方法,利用 @Inject 装饰器手动指定类型信息,代码如下:

编译之后生成的代码如下:

可以看到,我们自己使用 @Inject 装饰器编译之后也生成了对应的类型元数据,并且运行起来也不会报错。

仔细观察你就会发现,用 @Inject 和用 @Injectable 最终编译出来的代码是不一样的。用 @Inject 生成的代码多了很多东西,如果出现大量这种代码,最终编译出来的文件体积会变大。

38.3 @Inject 的其它用法

在以上例子里面,我们注入的都是强类型的对象。

有人就会问了:如果我想注入弱类型的对象字面值可不可以呢?

当然可以,但是稍微麻烦一点。

比如你想把这样一个配置对象注入给 LiteralService 服务:

app.module 里面是这样配置的:

在 LiteralService 里面使用 @Inject 来注入:

运行起来的结果:

特别注意:这种玩法非常罕见,除非你想自己实现一些特别猥琐的功能才用得到。比如上面这个例子,你可以直接利用 TypeScript 的 import 机制,直接把配置文件 import 进来完事。

38.4 总结

简而言之,@Injectable 与 @Inject 之间的关系,就像自动档和手动档的区别。如果不是有奇怪的癖好,当然是自动档开起来舒服,老司机都懂的。

  • 我们可以自己手动用 @Inject 装饰器来让 TypeScript 编译器保留类型元数据,但是一般来说不需要这么干。(也就是说,@Inject 装饰器一般是用不到的,除非你想做一些猥琐的事情。)
  • 保留类型元数据的另一个简便方法是使用 @Injectable 装饰器,@Injectable 并没有什么神奇的作用,它只是告诉 TS 编译器:请生成类型元数据。然后 Angular 在运行时就知道应该注射什么类型的对象了。
  • 这是 TypeScript 强加的一个规则,如果不加 @Injectable 装饰器,TS 编译器会把参数类型元数据丢弃。
  • 对于 Angular 中的 Service 来说,最好都加上@Injectable 装饰器,这是一种良好的编码风格。

39.@Self 的用法

39.1 使用父层组件上的 UserListService 实例

前面说到Injector 会构成树形结构,就像这样:

这就意味着,如果我们在父层组件里面定义了 UserListService,子层组件可以直接使用同一个实例。

继续前面的例子进行改造,给 UserListComponent 加一层子组件,组件名字就叫 ChildComponent,运行起来的界面效果是这样的:

ChildComponent 里面没有配置 providers,代码如下:

父层的 UserListComponent 配置了 providers,代码如下:

Augury 图形化展示出来的依赖关系是这样的:

从运行效果可以看到:由于 ChildComponent 嵌套在 UserListComponent 内部,而且它自己没有配置 providers,所以它共享了父层的 UserListService 实例。

那么问题就来了,如果 ChildComponent 想要自己独立的 UserListService 实例,应该怎么做呢?

39.2 @Self 装饰器

我们可以利用 @Self 装饰器来提示注射器,不要向上查找,只在组件自身内部查找依赖。

我们给 ChildComponent 内部的 UserListService 声明加上@Self 装饰器:

然后当然就报错啦:

很好理解对吧,我们用 @Self 装饰器把查找依赖的范围限定在 ChildComponent 组件自身内部,但是 ChildComponent 自己并没有配置 UserListService,当然就找不到了。

所以,我们要在 ChildComponent 组件内部补上 providers 配置项:

然后从运行结果可以看到,父层和子层已经是不同的 UserListService 实例了:

Augury 图形化展示出来的依赖关系是这样的:

顺便说一句:很多初学者遇到异常的时候不仔细看堆栈,碰到问题就在群里叫,然后被人鄙视。

像这种“No provider for…”基本上都是因为缺了 providers 配置项导致的,老司机扫一眼就懂,并不需要 Debug,也不需要查文档,知道为什么别人打代码速度辣么快了吧?

40.@Optional 的用法

40.1 @Optional基本用法

我们在 ChildComponent 的构造函数里面加上 @Optional 装饰器进行装饰:

然后父组件 UserListComponent 里面清空,啥也没有:

当然,NgModule 里面也不声明 UserListService。

注射器看到 @Optional 装饰器之后就知道这个服务是可选的,处理逻辑如下:

  • 沿着 Injector Tree 向上找一遍,如果找到了需要注入的类型,就创建实例。
  • 如果啥都没找到,直接赋值为 null,不抛异常

40.2 @Self与@Optional组合使用

注意:组合使用的方式在官方文档里面没有详细说明,请对照例子仔细理解一下。

装饰器是可以组合使用的,所以 ChildComponent 的构造函数里面可以写成这样:

constructor(
    @Self() @Optional() public userListService:UserListService
) { }

这种用法的含义是:

  • 因为 @Self 装饰器限定了查找范围,所以只在 ChildComponent 自身内部进行查找。父层组件有没有定义对应的服务,不会产生任何影响。
  • 因为有 @Optional 装饰器,所以如果 ChildComponent 自身内部提供了对应的服务,就创建实例,否则就直接赋值为 null,不抛异常。

怎么样,挺清晰的对吧?

你还可以自己测试一下更复杂的玩法,组合使用 3 个以上的装饰器看看。

41.@SkipSelf的用法

41.1 @SkipSelf基本用法

从名字可以猜出来它的含义:跳过组件自身,然后沿着 Injector Tree 向上查找。

继续在前面的例子上改造 ChildComponent,改成这样:

父组件是这样的:

可以看到,我们在 ChildComponent 和它的父层组件 UserListComponent 上都配置了 UserListService。但是, ChildComponent 上有 @SkipSelf 装饰器,所以 ChildComponent 上的配置并没有起作用,使用的还是 UserListComponent 上的实例:

41.2 @SkipSelf与@Optional组合使用

同样,我们可以组合使用 @SkipSelf 与 @Optional:

@SkipSelf() @Optional() public userListService: UserListService

这样写的含义是:

  • 因为使用了 @SkipSelf 装饰器,所以直接跳过 ChildComponent 组件自身,从 Injector Tree 的父层节点向上进行查找。也就是说,不管 ChildComponent 组件自己有没有配置 UserListService 都不起作用,因为跳过去了。
  • 因为使用了 @Optional 装饰器,如果在父层上面找到了指定的类型,那就创建实例;否则,直接设置为 null,不抛异常。

42.@Host 的使用

Host 这个单词有“宿主”的意思,就像病毒和 OS 之间的关系。

你可以意会一下 @Host 这个装饰器的特性。

42.1 @Host的基本用法

默认情况下,@Host 装饰器会指示注射器在组件自己内部去查找所依赖的类型,就像这样:

如果 @Host 只有这一个特性的话,它就没什么存在的必要了,实际上它更核心的功能与所谓的 Content Projection(内容投影)机制有关。

42.2 Content Projection(内容投影)

有时候,组件内部放什么内容并不固定,而是需要调用方在使用组件的时候去指定,这是 Content Projection 最核心的一个作用。

我们继续修改 ChildComponent 这个组件,在上面使用 @Host 装饰器,但是不配置 UserListService,就像这样:

父层组件 UserListComponent 的改动幅度比较大,首先我们还是给它配置了 UserListService,就像这样:

然后我们还修改了 UserListComponent 的 HTML 模板代码,这里跟前面的例子都不一样,我们不再把 写死在模板内部,而是在模板里面使用了一个 标签。ng-content 的本质是一个占位符,这个占位符会被真正投影进来的内容替换掉。

现在 UserListComponent 的模板代码如下:

在 AppComponent 的 HTML 模板里面,使用 UserListComponent 的方式也发生了改变, 标签的内部嵌套了一个子层标签,这在之前的例子里面是没有出现过的,就像这样:

运行起来的效果是这样的:

可以看到,投影进来的子组件和父层的宿主组件共用了同一个 UserListService 实例。

用 Augury 图形化展示出来是这样的:

“内容投影”的优点在于:UserListComponent 的 HTML 模板没有和 ChildComponent 紧密耦合在一起,因为 ChildComponent 是被“投影”进来的,未来如果你觉得不爽,可以投影一个另外的组件实例进来(当然这里的代码还得小改一番才能实现)。于是,两个组件都变得比较灵活了,而不会出现谁也离不开谁的情况。

@Host 装饰器会提示注射器:要么在组件自己内部查找需要的依赖,要么到 Host(宿主)上去查找。

怎么样,能理解这里 Host 一词的意味了吧?

简而言之:@Host 装饰器是用来在被投影的组件和它的宿主之间构建联系的。

“内容投影”机制还有很多非常重要的作用,在 《Angular 初学者快速上手指南》里面有非常琐碎的描述,如果你还没有深入理解它,请移步过去阅读。

42.3 @Host 和 @Optional 组合使用

如上所述,@Host 会尝试到宿主组件上去查找依赖,那么问题就来了,如果宿主上面并没有所需要的东西,怎么办呢?

借着上面的例子,我们把 UserListComponent 里面配置的 UserListService 注释掉:

然后理所当然就报错了,因为现在宿主上并没有所需要对象:

如果不想出现这种报错,而且你认为对于 ChildComponent 来说, UserListService 并不是在构造的时候就必须的,@Optional 装饰器就可以派上用场了:

@Host() @Optional() public userListService: UserListService

43.手动操作注射器实例

官方文档特别强调:开发者可以手动操作 Injector 的实例,但是这种情况非常罕见。

所以那部分文档隐藏了一些黑魔法,这里我们自己揭开盖子来玩儿。

43.1 注入Injector实例

我们继续在前面的例子上改进,来尝试手动操作 Injector 的实例:

你可以自己用 Chrome 打开开发者工具看看 Injector 实例上面都有些什么属性:

很明显,Injector 本身也是一个服务。

43.2 手动创建注射器实例

在上面的例子里面,Injector 实例是 Angular 帮我们自动创建的。如果我们自己创建注射器,可不可以呢?

当然是 OK 的,Angular 内核默认提供了 3 种 Injector 的实现,:

  • _NullInjector 是内部使用的私有类,外部无法引用。
  • StaticInjector 可以在外部使用,但是文档里面没有描述。
  • ReflectiveInjector,反射型注射器。如果你学过 Java 里面的反射机制,从 ReflectiveInjector 这个名字你可以猜测到它内部是怎么运行的。

测试 Demo 的核心代码如下:

import { Component, OnInit, Injector, ReflectiveInjector } from '@angular/core';
import { TestService } from './service/test.service';

ngOnInit() {
    //尝试自己手动创建 userListService 实例
    this.userListService=this.injector.get(UserListService);
    console.log(this.userListService);

    this.userListService.getUserList().subscribe((userList:Array<any>)=>{
        this.userList=userList;
    });

    //尝试自己创建注射器,然后利用注射器自己注射 TestService 服务实例
    let myInjector = ReflectiveInjector.resolveAndCreate([
        { provide: "TestService", useClass: TestService }
    ]);

    console.log(myInjector);

    this.testService = myInjector.get("TestService");

    console.log(this.testService);
}

运行效果如下:

尝试自己创建注射器,然后利用注射器自己创建了 TestService 服务实例。

注意:从 Angular 5.x 开始,ReflectiveInjector 被标记成了过时的,官方建议使用静态方法 Injector.create。

44.综合案例OpenWMS介绍

通过前面的小节,我们已经熟悉了 Angular 的方方面面,最后,我们来一个综合的大例子。

OpenWMS 也是一个开源项目,同时提交在 GithuHb 和 Gitee 上:

这个项目的技术特性如下:

  • Angular 核心包:7.0.0
  • 组件库:PrimeNG 6.1.5
  • 图表:ngx-echarts
  • 国际化:ngx-translate
  • 字体图标:font-awesome

OpenWMS 为你提供了一个可以借鉴的项目模板,把真实业务开发过程中的模块都配置好了。

44.1 模块分析

以下是项目 build 出来的体积:

用 webpack-bundle-analyzer 分析之后可以看到各个模块在编译之后所占的体积:

可以看到,主要是因为 ECharts 和 PrimeNG 占的体积比较大,建议您在使用的时候做一下异步,用不到的组件不要一股脑全部导入进来。

44.2 效果截图

45.快速上手 PWA

45.1 PWA 是什么?

PWA 是 Google 在 2015 年提出的一种全新的 Web 应用开发规范。

PWA 这个缩写是由 Google Chrome 团队的 Alex Russell 提出来的。

PWA 的全称是 Progressive Web Apps,翻译成中文是“渐进式WEB应用”。

PWA 不针对特定的语言,也不针对特定的框架,它本身只是一种规范,只要你的应用能满足 PWA 提出的规范,那么它就是一款 PWA 应用。

PWA 需要具备的关键特性有:

  • 应用无需安装,无需发布到应用市场
  • 可以在主屏幕上创建图标
  • 可以离线运行,利用后台线程与服务端通讯(由 ServiceWorker 特性来支持)
  • 对搜索引擎友好
  • 支持消息推送
  • 支持响应式设计,支持各种类型的终端和屏幕
  • 方便分享,用户可以方便地把应用内部的 URL 地址分享出去

如果你想知道自己的应用是否是 PWA,官方提供了一份清单可供核对:

https://developers.google.com/web/progressive-web-apps/checklist

Google 官方对 PWA 的描述是这样的:

Progressive Web Apps are just great web sites that can behave like native apps—or, perhaps, Progressive Web Apps are just great apps, powered by Web technologies and delivered with Web infrastructure.

45.2 三大厂商已经全部支持 PWA

目前,Apple、Microsoft、Google 已经全部支持 PWA 技术。

Google 的 Android 平台、Chrome 平台、Chrome Book 平台已经能全部支持 PWA:

iOS 11.3 开始内置支持 PWA:

Windows 10 已经全面支持 PWA,目前 Windows 10 的应用商店里面已经有非常多的 PWA 应用了:

三大厂商齐心合力支持同一种技术规范是非常罕见的现象,从目前的情况看,PWA 将会成为一个比较大的热点。

注意:国内外的互联网生态完全不同,国内移动互联网基本上被微信、今日头条所把持,目前微信小程序的影响力比 PWA 更大,微信小程序的数量已经超过 100 万个。另外,很多消息推送服务在国内用不了。

45.3 中国厂商的“快应用”与PWA的区别

2018 年 3 月 20 日,国内 10 大手机厂商共同参会,支持“快应用”标准,这些厂商包括:华为、中兴、小米、Oppo、Vivo、魅族、联想等。

从技术层面看,“快应用”与 ReactNative 类似,它和 PWA 完全不同。PWA 是完全的 Web 技术,借助于浏览器渲染,是“页面”。而“快应用”是类似于 RN 的“原生渲染”模式,JS 相关的代码运行在 JSCore 里面,然后通过 Bridge 驱动原生代码渲染 UI 界面,整体思路如下图:

从目前的发展情况来看,小米 8 已经对“快应用”做了很好的支持。

45.4 参考资源


文章作者: Chaoqiang
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Chaoqiang !
评论
 上一篇
TypeScript Basic Tutorial (9) TypeScript Basic Tutorial (9)
模块模块的的概念(官方): 关于术语的一点说明: 请务必注意一点,TypeScript 1.5里术语名已经发生了变化。 “内部模块”现在称做“命名空间”。 “外部模块”现在则简称为“模块” 模块在其自身的作用域里执行,而不是在全局作用域里
下一篇 
DotNet-Advanced-Series-5-1-NetCore31Start DotNet-Advanced-Series-5-1-NetCore31Start
介绍基本的Net Core跨平台知识 搭建一个小的Net Core项目 入门Net Core
  目录