├── .DS_Store ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── SUMMARY.md ├── chapter-001 ├── 001-hello-world.md ├── 002-modern-javascript.md ├── 003-typescript.md ├── 004-dev-tools.md └── README.md └── chapter-002 ├── 000-conceptions.md ├── 001-data-binding.md ├── 002-dependency-injection.md └── README.md /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trotyl/learn-angular/7dde13fc04bdcc4e5986d6f9546896b69b0043c3/.DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Deployed apps should consider commenting this line out: 24 | # see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git 25 | node_modules 26 | 27 | _book/ 28 | book.pdf 29 | book.epub 30 | book.mobi 31 | 32 | playbooks/**/*.js 33 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "playbooks/**/playbook.js": true 5 | } 6 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Travis](https://img.shields.io/travis/trotyl/learn-angular.svg)](https://travis-ci.org/trotyl/learn-angular) 2 | 3 | # Learn Angular the hard way 4 | 5 | 早在 2009 年,谷歌发布了 AngularJS[^1],引发了 Web 开发模式的重大变革,其创新性的双向数据绑定让开发 Web 应用的难度极大简化,开发效率极大地提高,同时也大大推进了前后端分离的浪潮。 6 | 7 | 虽然具备着重大的历史意义,但随着时间的推移,AngularJS 中的很多设计及实现也逐渐演变为历史负担,其中的缺陷也不断显现出来,其中的问题包括但不仅限于性能问题、缺乏跨平台运行能力以及臃肿的 API 设计等。 8 | 9 | 与其同时,Web 前端开发环境也在不断演进,前端开发领域已经由原先的网页脚本逐步转变为一套完整的工程实践。 10 | 11 | 为此,谷歌于 2016 年正式发布了 Angular 框架,作为 AngularJS 的后继者,在保持了 AngularJS 开发风格的基础上,不仅解决了 AngularJS 现有的遗留问题,同时扩展了开发领域[^2]并且提供了更完善的功能和性能支持。 12 | 13 | 本书的目的在于对 Angular 提供超过官方文档的详细介绍以及完整剖析,同时会尽可能保证绝对的严谨性,不会为了简化内容而混淆概念,需要特别注意的部分以及相关的外部内容都会在脚注中给出。 14 | 15 | 如果发现这里很多内容你在其它地方都没看过,那么没错,其它地方能看到的就只是冰山一角而已。不过是否真的有必要看到(或者撞到)完整的冰山需要根据自己的实际情况考虑。 16 | 17 | 因此,**本书并不是快速入门(QuickStart),而是慢速入门(SlowStart),请根据自己的实际需要阅读**。 18 | 19 | --- 20 | 21 | [^1]: 谷歌于 2009 年发布的 JavaScript 框架叫做 AngularJS,官网为 [angularjs.org](https://angularjs.org),代码库为 [angular/angular.js](https://github.com/angular/angular.js);而 2016 年发布的 JavaScript 开发平台叫做 Angular,官网为 [angular.io](https://angular.io),代码库为 [angular/angular](https://github.com/angular/angular)。关于两者名称的使用可以参考 [Branding Guidelines for Angular and AngularJS](http://angularjs.blogspot.hk/2017/01/branding-guidelines-for-angular-and.html)。 22 | 23 | [^2]: Angular 的定位为开发平台而非 Web 框架,例如 Angular 也可用于移动端应用的开发等,可以参考 [NativeScript](https://www.nativescript.org/)、[ionic](http://ionicframework.com/) 等。 24 | 25 | -------------------------------------------------------------------------------- /SUMMARY.md: -------------------------------------------------------------------------------- 1 | # 本书目录 2 | 3 | * [第一章:环境准备](chapter-001/README.md) 4 | * [Hello World](chapter-001/001-hello-world.md) 5 | * [现代化的 JavaScript 语言](chapter-001/002-modern-javascript.md) 6 | * [命令行工具](chapter-001/003-cli-tooling.md) 7 | * [第二章:基础知识 (In Progress)](chapter-002/README.md) 8 | 9 | * [数据绑定](chapter-002/001-data-binding.md) 10 | * [依赖注入 (In Progress)](chapter-002/002-dependency-injection.md) 11 | * 结构型指令 (In Design) 12 | * 模版语法 (In Design) 13 | * 内容格式化 (In Design) 14 | * 模块划分 (In Design) 15 | * 第三章:实现原理 (In Design) 16 | * Angular Compiler (In Design) 17 | * 元数据收集 (In Design) 18 | * 事件绑定 (In Design) 19 | * 模块加载 (In Design) 20 | * 第四章:实用功能(In Design) 21 | * HTTP (In Design) 22 | * 路由(In Design) 23 | * 表单 (In Design) 24 | * I18n (In Design) 25 | * 第五章:进阶内容 (In Design) 26 | * 应用启动 (In Design) 27 | * 组件交互 (In Design) 28 | * 视图操作 (In Design) 29 | -------------------------------------------------------------------------------- /chapter-001/001-hello-world.md: -------------------------------------------------------------------------------- 1 | # Hello World 2 | 3 | 从示例来学习是一种很有效的方式,毕竟人类的学习过程就是基于大规模的模式匹配。这里,我们将 *不使用任何开发工具和语言扩展*,从零开始打造一个 Angular 的 Hello World 应用。 4 | 5 | 既然承诺不使用任何开发工具,因此也就无法推荐编辑器,读者可以自己选择任何具备文本编辑功能的程序。 6 | 7 | ## HTML 入口 8 | 9 | 现在,我们需要建立一个 **HTML** 文件,为了简单起见,我们只使用必要的 HTML 标签*: 10 | 11 | > **必要的 HTML 标签**:在 HTML 中,``、`` 和 `` 等都是可选标签,但是大部分情况下为了保持页面的结构清晰我们仍然会使用这些标签。详见:[HTML 5.1: 8. The HTML syntax](https://www.w3.org/TR/html/syntax.html#optional-tags)。 12 | 13 | ```html 14 | 15 | Hello Angular 16 | Loading... 17 | ``` 18 | 19 | 用浏览器*打开这个 HTML 文件,我们能够看到一行(并不怎么好看的)`Loading...` 字样。 20 | 21 | > **浏览器**:浏览器并不属于开发工具,只是普通的日常 App,所以并不违反上面不使用开发工具的承诺。当然,这里要求浏览器能够支持 ES2015。 22 | 23 | 这里使用了一个名为 `` 的元素,类似于 [Custom Elements](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements)。虽然现在还没有任何功能,不过最终我们会将其作为 Angular 应用的启动入口。 24 | 25 | ## 组件定义 26 | 27 | 我们知道(如果不知道那现在就知道了)Angular 使用 **组件化** 的方式来组织应用内容*,所以一个应用里面必须要有 **组件(Component)**。相比于 AngularJS 而言,Angular 的设计更加面向现代化的 JavaScript 语言特性,每个组件就是一个 **类(class)**,这里我们定义一个名为 `AppComponent`* 的类作为我们应用的 **根组件(Root Component)***(也是唯一组件): 28 | 29 | > **组件化**:从技术角度而言 Angular 可用于视图无关应用(例如纯服务端应用或者命令行工具),这种情况下将不会用到组件。不过将 Angular 纯粹当作 DI 支持库来使用有过度浪费的嫌疑,一般并不推荐,本节暂不考虑这种情况。 30 | 31 | > **AppComponent**:在 Angular 项目中,对于 Component 类型的 class 而言,其命名通常以 `Component` 结尾(不适用于组件库)。此外,对于应用的根元素,通常采用 `AppComponent` 作为其名称。不过这些都只是 Angular 团队推荐的代码风格,对功能不会产生任何影响。 32 | 33 | > **根组件**:组件本身并不会有任何「作为根组件」的标记,任何作为根组件的组件也都可以同时作为内嵌组件使用,因此从技术角度而言将一个组件称为「根组件」是不严谨的。但在某个组件仅用作应用入口的情况下,通常会使用根组件指代。 34 | 35 | *本文假设读者已经完全了解 ES2015,对于 ES2016+ 以及暂未进入规范的实验特性会专门说明。* 36 | 37 | ```javascript 38 | class AppComponent { } 39 | ``` 40 | 41 | 于是 HTML 文件将新增一个 inline ` 50 | 51 | ``` 52 | 53 | 如果我们现在刷新浏览器的话,就会发现 — 什么也没有发生(昨晚是一个平安夜)。 54 | 55 | 接下来我们使用 **Error-Driven Development** 的方式来进行开发,即:**写代码,运行,出了问题再修**。 56 | 57 | 这里我们可以简单思考一下,既然我们希望 `AppComponent` 作为一个组件(可复用的视图单元*),那么就需要定义它应该 *有什么样的视图*,以及要 *如何复用*。像 AngularJS 一样,Angular 仍然基于 **模【mú】版(Template)** 定义视图,为此,我们需要为我们的组件提供模板内容。 58 | 59 | > **视图单元**:组件中可能包含自治的「视图逻辑」,由于本节内容尚未涉及「逻辑」,因此组件仅包含纯视图内容。 60 | 61 | 与 AngularJS 所不同的是,Angular 并不使用全局注册的 API,从而保证对 Tree-Shaking 的绝对友好。提供模板的方式很简单,直接把相应的 **元数据(Metadata)** 附加到类上即可。 62 | 63 | 如果我们要为一个类添加实例无关的固定内容,显然一个最简单的方式就是添加 **静态属性(Static Property)**,为此我们为 `AppComponent` 添加一个叫做 `annotations` 的静态属性*: 64 | 65 | > **静态属性**:ES2015 中并没有提供值属性的语法,只有访问器属性和方法的声明语法。目前有一个 Stage 3 的 ES Proposal 中给出了类的值属性语法的相应提案,详情参见:[tc39/proposal-class-fields](https://github.com/tc39/proposal-class-fields)。静态属性支持被分离至[独立提案](https://github.com/tc39/proposal-static-class-features)中,目前仍然处于 Stage 3。 66 | 67 | ```javascript 68 | /* change start */ 69 | const { Component } = ng.core 70 | /* change end */ 71 | 72 | class AppComponent { } 73 | 74 | /* change start */ 75 | AppComponent.annotations = [ 76 | new Component({ 77 | template: '

Hello Angular

' 78 | }) 79 | ] 80 | /* change end */ 81 | ``` 82 | 83 | 上面 `AppComponent` 的 `annotations` 静态属性被初始化为一个数组。显然,既然是复数名词,那么应当可以不止一项内容,而且从逻辑上也很容易知道,一个类型自然应该能够附加多项元数据*。目前我们的数组中只有一个元素,是一个 `ng.core.Component` 类型的实例,带有一个对象字面量作为参数。该对象具备一个 `template` 属性,其值即为我们所要定义的组件模版。 84 | 85 | > **多项元数据**:由于实现上的限制,同一个类型无法在同一份 Angular 实现中承担不同职责,例如一个应用中不能有既是组件又是 NgModule 的类。 86 | 87 | 不过,当我们再次刷新浏览器,会看到控制台中出现了报错: 88 | 89 | ```text 90 | Uncaught ReferenceError: ng is not defined 91 | ``` 92 | 93 | 是的,我们并没有任何地方定义了 `ng` 这个全局变量。这时候我们就需要引入 Angular 本身的库代码了,在 HTML 中添加一个新的 ` 100 | 101 | 102 | ``` 103 | 104 | 这里的 `unpkg.com` 是一个提供在线 NPM 包访问的 CDN 站点,也就是说,只要是 NPM public registry 里面的内容,都能通过该站点在线访问。因此无需使用 `npm install` 就能获取到 `@angular/core` 这个 scoped package* 中的内容,其 `main` 入口*设定为 UMD bundle,因此可以直接省略文件路径。完整的文件路径为 `https://unpkg.com/@angular/core/bundles/core.umd.js`。 105 | 106 | > **scoped package**:[Scoped Packages](https://docs.npmjs.com/getting-started/scoped-packages) 是 NPM 提供的服务,用于提供自定义的命名空间从而解决全局名称冲突的问题。 107 | 108 | > **main 入口**:位于 `package.json` 中的字段,[Node.js 的 Module Resolution 过程](https://nodejs.org/dist/latest-v10.x/docs/api/modules.html#modules_all_together)会自动根据其内容确定需要引入的实际文件,也被一些静态构建工具所支持。 109 | 110 | 可能需要注意的是,这里 ` 138 | 139 | 140 | 141 | ``` 142 | 143 | 在这之后,我们就可以继续期待新的错误了。 144 | 145 | 然而遗憾的是,并没有新的错误出现,为什么呢?因为并没有任何代码来启动我们的应用,我们仅仅完成了一个组件定义而已。等等,组件定义真的完成了么? 146 | 147 | 前面说到,对于组件我们需要考虑两个问题:*有什么样的视图* 和 *如何复用*。*有什么样的视图* 这个问题我们已经解决了,那么要如何进行复用,或者说,如何确定什么时候使用这个组件呢? 148 | 149 | 为了保持和 Web Components 的一致性*,Angular 采用了自定义 **选择器(selector)** 的方式来配置何时应用该组件*,这里的选择器和之前的模板一样都是组件的元数据。 150 | 151 | > **Web Components 一致性**:早期的 Angular 设计方案中也有过基于 Web Components 实现的方案,类似于现在的 Polymer。详情参见:[Angular 2: Emulated Components](https://docs.google.com/document/d/1NFmp2ptjFfzTEYf0OPcpkacRV2_7LgTIrP5nWfJQL0o/edit#heading=h.z2blzd2pdtwt)。 152 | 153 | 我们继续添加 `selector` 属性: 154 | 155 | ```javascript 156 | /* change start */ 157 | const { Component } = ng.core 158 | /* change end */ 159 | 160 | class AppComponent { } 161 | 162 | AppComponent.annotations = [ 163 | new Component({ 164 | /* change start */ 165 | selector: 'ng-component', 166 | /* change end */ 167 | template: '

Hello Angular

', 168 | }) 169 | ] 170 | ``` 171 | 172 | 现在组件的定义已经完成了,接着来考虑如何启动应用。 173 | 174 | ## NgModule 定义 175 | 176 | 从 `2.0.0-rc.5` 版本开始,Angular 中新引入了一个 **NgModule** 的概念*,用于作为应用的基本组织单元。每个组件都必须从属于某个 NgModule。在启动之前,必须完成完成组件与 NgModule 的关联。 177 | 178 | > **引入 NgModule**:实际上,只有 TypeScript 版本的 Angular 才引入了 NgModule 的概念,而 Dart 版本的 Angular 并不具备 NgModule 概念,仍然使用类似于 2.0.0-rc.4 及之前的方式进行开发。详情参考:[About AngularDart](https://webdev.dartlang.org/angular)。不过绝大多数情况下我们默认说的都是 Angular 的 TypeScript 实现。 179 | 180 | 与组件类似,NgModule 也是类,这里我们定义一个叫 `AppModule` 的 NgModule: 181 | 182 | ```javascript 183 | const { Component/* change start */, NgModule/* change end */ } = ng.core 184 | 185 | /* ... */ 186 | 187 | /* change start */ 188 | class AppModule { } 189 | 190 | AppModule.annotations = [ 191 | new NgModule({ 192 | declarations: [ 193 | AppComponent, 194 | ], 195 | }) 196 | ] 197 | /* change end */ 198 | ``` 199 | 200 | 这里我们提供的元数据与之前的不同,是一个 `ng.core.NgModule` 的实例。而其中的 `declarations` 属性用于声明从属于这个 NgModule 的类型*。 201 | 202 | > **从属于 NgModule 的类型**:包括 **指令(Directive)** 和 **管道(Pipe)**,本节中用到的组件是一类特殊的指令。需要注意的依赖注入是完全不同的机制,与此无关,本节中暂不涉及。 203 | 204 | 由于 Angular 平台无关的设计理念,因此平台相关支持也以 NgModule 的方式提供。例如对于浏览器应用,需要引入 `BrowserModule`: 205 | 206 | ```javascript 207 | const { Component, NgModule } = ng.core 208 | /* change start */ 209 | const { BrowserModule } = ng.platformBrowser 210 | /* change end */ 211 | 212 | /* ... */ 213 | 214 | AppModule.annotations = [ 215 | new NgModule({ 216 | /* change start */ 217 | imports: [ 218 | BrowserModule, 219 | ], 220 | /* change end */ 221 | declarations: [ 222 | AppComponent, 223 | ], 224 | }) 225 | ] 226 | ``` 227 | 228 | 其中 `imports` 属性是一个数组,用于指定这个 NgModule 所依赖的其它 NgModule,例如这里的 `BrowserModule`,其中包含了浏览器平台的相关基础设施(如 DOM 操作逻辑等)。 229 | 230 | 再次刷新浏览器,我们终于得到了新的错误: 231 | 232 | ```text 233 | Uncaught TypeError: Cannot read property 'BrowserModule' of undefined 234 | ``` 235 | 236 | 很容易就能知道,既然 `ng.core` 是通过 `@angular/core` 引入的,很显然 `ng.platformBrowser` 也需要通过单独的 **包(package)** 来引入,也就是 `@angular/platform-browser`。 237 | 238 | 为此我们添加一个新的 ` 243 | 244 | 245 | 246 | 247 | ``` 248 | 249 | 又进入到了没有错误的状态,不过仍然什么也没有发生。现在已经有了 NgModule,我们可以考虑来启动我们的 Angular 应用。启动一个 Angular 应用有很多种方式,其中最简单的方式就是通过 NgModule 元数据中的 `bootstrap` 属性来设置自启动: 250 | 251 | ```javascript 252 | /* ... */ 253 | 254 | AppModule.annotations = [ 255 | new NgModule({ 256 | imports: [ 257 | BrowserModule, 258 | ], 259 | declarations: [ 260 | AppComponent, 261 | ], 262 | /* change start */ 263 | bootstrap: [ 264 | AppComponent, 265 | ], 266 | /* change end */ 267 | }) 268 | ] 269 | ``` 270 | 271 | 这样就将 `AppComponent` 设置为 `AppModule` 的自启动组件。那么又要如何启动 `AppModule` 这个 NgModule 呢? 272 | 273 | ## 应用启动 274 | 275 | 我们首先尝试使用 `platformBrowser` 提供的 API: 276 | 277 | ```javascript 278 | const { Component, NgModule } = ng.core 279 | const { BrowserModule/* change start */, platformBrowser/* change end */ } = ng.platformBrowser 280 | 281 | /* ... */ 282 | 283 | /* change start */ 284 | platformBrowser().bootstrapModule(AppModule) 285 | /* change end */ 286 | ``` 287 | 288 | 这时会出现错误: 289 | 290 | ```text 291 | Uncaught TypeError: Cannot read property 'DOCUMENT' of undefined 292 | Uncaught TypeError: Cannot read property 'ɵPLATFORM_BROWSER_ID' of undefined 293 | Uncaught TypeError: ng.platformBrowser.platformBrowser is not a function 294 | ``` 295 | 296 | 第三个错误其实是前两个错误引发的结果,并不需要关心。检查报错位置: 297 | 298 | ```javascript 299 | var DOCUMENT$1 = /* here */common.DOCUMENT; 300 | 301 | /* and */ 302 | 303 | var INTERNAL_BROWSER_DYNAMIC_PLATFORM_PROVIDERS = [ 304 | platformBrowser.ɵINTERNAL_BROWSER_PLATFORM_PROVIDERS, 305 | { 306 | provide: core.COMPILER_OPTIONS, 307 | useValue: { providers: [{ provide: compiler.ResourceLoader, useClass: ResourceLoaderImpl, deps: [] }] }, 308 | multi: true 309 | }, 310 | { provide: core.PLATFORM_ID, useValue: /* here */common.ɵPLATFORM_BROWSER_ID }, 311 | ]; 312 | ``` 313 | 314 | 所以问题在于 `common` 的缺失。检查 UMD 文件头: 315 | 316 | ```javascript 317 | (function (global, factory) { 318 | typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('@angular/compiler'), require('@angular/core'), require('@angular/common'), require('@angular/platform-browser')) : 319 | typeof define === 'function' && define.amd ? define('@angular/platform-browser-dynamic', ['exports', '@angular/compiler', '@angular/core', '@angular/common', '@angular/platform-browser'], factory) : 320 | (factory((global.ng = global.ng || {}, global.ng.platformBrowserDynamic = {}),global.ng.compiler,global.ng.core,global.ng.common,global.ng.platformBrowser)); 321 | }(this, (function (exports,compiler,core,common,platformBrowser) { /* ... */ } 322 | ``` 323 | 324 | 可以快速确定 `common` 在 Global fallback 模式*下即为 `ng.common`,由 `@angular/common` 这个 package 提供。 325 | 326 | > **Global fallback 模式**:UMD 格式提供 CommonJS、AMD 和全局变量三种模块化方案的支持,由于全局变量仅仅在不支持 CommonJS 以及 AMD 的情况下使用,因此可以视作 fallback。 327 | 328 | 继续添加一个 ` 333 | 334 | 335 | 336 | 337 | ``` 338 | 339 | 正如我们所期望的那样,新的错误为: 340 | 341 | ```text 342 | StaticInjectorError(Platform: core)[CompilerFactory]: 343 | NullInjectorError: No provider for CompilerFactory! 344 | ``` 345 | 346 | 很明显,我们缺少 `CompilerFactory` 这个内容。事实上,Angular 和 AngularJS 很大的一点不同是,AngularJS 的模版是基于 DOM 的,HTML 内容会被交给浏览器解析,随后 AngularJS 遍历 DOM 节点来实现自己的功能扩展;而 Angular 中的模版是平台无关的,HTML 内容会被事先编译成视图相关的 JavaScript 代码*,而浏览器永远也见不到模版的 HTML 内容。 347 | 348 | > **Angular 的模版编译**:2.0 的早期 Alpha 版本中采用过基于浏览器的 Parser 实现,之后被废弃。此后均使用平台无关的自有实现。 349 | 350 | 因此,Angular 使用了一个功能强大的模版编译器,在不依靠预处理工具的情况下,便需要在浏览器中引入这个编译器,位于 `@angular/compiler`: 351 | 352 | ```html 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | ``` 361 | 362 | 不过,很遗憾的是,这次错误并没有消失也没有变化。事实上,`@angular/platform-browser` 的 API 仅仅适用于对已经预编译过的 NgModule*,而对于无构建工具的在线运行,我们需要使用另一个 API,位于 `@angular/platform-browser-dynamic`。其中的 `PlatformBrowserDynamic` 能够在运行时融合 `PlatformBrowser` 和 `Compiler`,提供运行时动态编译的功能。因此,继续添加一个 ` 369 | 370 | 371 | 372 | 373 | ``` 374 | 375 | 并将我们的启动代码修改为: 376 | 377 | ```javascript 378 | /* ... */ 379 | 380 | /* change start */ 381 | const { platformBrowserDynamic } = ng.platformBrowserDynamic 382 | /* change end */ 383 | 384 | /* ... */ 385 | 386 | /* change start */ 387 | platformBrowserDynamic().bootstrapModule(AppModule) 388 | /* change end */ 389 | ``` 390 | 391 | 在此之后,我们得到了最后一个错误(八年抗战到最后一年了?): 392 | 393 | ```text 394 | Error: In this configuration Angular requires Zone.js 395 | ``` 396 | 397 | 这里就十分浅显易懂了,而且解决方案就是如其所述*,修改启动配置为: 398 | 399 | > **In this configuration Angular requires Zone.js**:实际上显然还有另一种方案,就是引入 Zone.js。此处出于教学目的暂不使用以便于理解 Zone.js 的作用。当前实现中不使用 Zone.js 的方式[仍然存在 BUG](https://github.com/angular/angular/issues/23428),会产生额外报错。 400 | 401 | ```javascript 402 | platformBrowserDynamic().bootstrapModule(AppModule/* change start */, { ngZone: 'noop' }/* change end */) 403 | ``` 404 | 405 | 同时,为了解决上述 BUG,我们在引导代码之前自行定义一个全局的 Zone 变量: 406 | 407 | ```javascript 408 | window['Zone'] = { 409 | get current() { return this }, 410 | assertZonePatched() { }, 411 | fork() { return this }, 412 | get() { return true }, 413 | run(fn) { return fn() }, 414 | runGuarded(fn) { return fn() }, 415 | } 416 | ``` 417 | 418 | 不过需要理解这里只是为了绕过类型检查,事实上并不会用到 Zone.js。 419 | 420 | 之后,我们就能看到我们想要的内容: 421 | 422 | ```text 423 | Hello Angular 424 | ``` 425 | 426 | ## 内容精简 427 | 428 | 最后,我们可以尝试移除 `AppComponent` 的 `selector`: 429 | 430 | ```javascript 431 | AppComponent.annotations = [ 432 | new Component({ 433 | /* change start */ 434 | /* change end */ 435 | template: '

Hello Angular

', 436 | }) 437 | ] 438 | ``` 439 | 440 | 发现应用仍然能够继续工作,这是因为组件默认的 `selector` 即为 `ng-component`,实际应用中并不推荐使用默认选择器。 441 | 442 | ## 总结 443 | 444 | 至此,我们在没有使用任何开发工具或语言扩展的情况下,完成了一个最为传统的(只使用 ` 453 | 454 | 455 | 456 | 457 | 458 | 459 | 499 | ``` 500 | 501 | ## 在线示例 502 | 503 | 504 | 505 | ## 可能的疑惑 506 | 507 | #### Angular 是否能够使用 JavaScript 开发? 508 | 509 | 要不要再看一遍呢? 510 | 511 | #### Angular 是否不推荐使用 JavaScript 开发? 512 | 513 | 是的,强烈不推荐。 514 | 515 | #### 为什么 AppComponent 和 AppModule 类内部没有内容? 516 | 517 | 因为这只是 Hello World。 518 | 519 | #### 是否能够以不用 ES2015(类)的方式使用? 520 | 521 | 可以,后文中会提及。 522 | 523 | #### Angular 是否能够使用 CDN 引入? 524 | 525 | 要不要再看一遍呢? 526 | 527 | #### Angular 是否提供官方 CDN 站点? 528 | 529 | 不提供,而且也不推荐运行时引入的方式。Angular 的设计理念就是对构建友好,为此具备极佳的 Tree-Shaking 支持,构建后大小与构建前差异很大。 530 | 531 | #### Angular 需要多少外部依赖? 532 | 533 | 1 个或 2 个,取决于是否使用 Zone.js,这里在没有使用 Zone.js 的情况下仅需要 RxJS 一个依赖。 534 | 535 | #### Angular 的元数据有几种提供方式? 536 | 537 | 后文中会提及。 538 | 539 | #### NgModule 的意义是什么? 540 | 541 | 后文中会提及。 542 | 543 | #### Angular 有哪些启动方式? 544 | 545 | 后文中会提及。 546 | -------------------------------------------------------------------------------- /chapter-001/002-modern-javascript.md: -------------------------------------------------------------------------------- 1 | # 现代化的 JavaScript 语言 2 | 3 | 上一节中我们已经使用 ES2015 完成了 Angular 的 Hello World,本节中会使用现代化的 JavaScript 语言特性进行重构。 4 | 5 | ## 分离 JavaScript 文件 6 | 7 | 首先,我们将 JavaScript 文件从 HTML 文件中分离,命名为 `main.js`,内容为: 8 | 9 | ```javascript 10 | /* main.js */ 11 | const { Component, NgModule, enableProdMode } = ng.core 12 | const { BrowserModule } = ng.platformBrowser 13 | const { platformBrowserDynamic } = ng.platformBrowserDynamic 14 | 15 | class AppComponent { } 16 | 17 | AppComponent.annotations = [ 18 | new Component({ 19 | template: '

Hello Angular

', 20 | }) 21 | ] 22 | 23 | class AppModule { } 24 | 25 | AppModule.annotations = [ 26 | new NgModule({ 27 | imports: [ 28 | BrowserModule, 29 | ], 30 | declarations: [ 31 | AppComponent, 32 | ], 33 | bootstrap: [ 34 | AppComponent, 35 | ], 36 | }) 37 | ] 38 | 39 | platformBrowserDynamic().bootstrapModule(AppModule, { ngZone: 'noop' }) 40 | ``` 41 | 42 | 相应的 HTML 中的内容为: 43 | 44 | ```html 45 | 46 | Hello Angular 47 | Loading... 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | ``` 56 | 57 | 现在,我们有了单独的 JavaScript 文件。 58 | 59 | ## 模块化 JavaScript 文件 60 | 61 | 将所有代码放在一个 JavaScript 文件中显然不利于后期维护,为此我们借助自 ES2015 开始引入的 Module 特性,将 `main.js` 拆分为多个 Module 形式的 JavaScript 文件: 62 | 63 | + 将 AppComponent 的相关内容提取到 `app.component.js` 中; 64 | + 将 AppModule 的相关内容提取到 `app.module.js` 中; 65 | + 将剩下的内容保留在 `main.js` 中。 66 | 67 | 之后我们得到: 68 | 69 | ```javascript 70 | /* app.component.js */ 71 | const { Component } = ng.core 72 | 73 | class AppComponent { } 74 | 75 | AppComponent.annotations = [ 76 | new Component({ 77 | template: '

Hello Angular

', 78 | }) 79 | ] 80 | 81 | export { AppComponent } 82 | 83 | /* app.module.js */ 84 | import { AppComponent } from './app.component.js' 85 | 86 | const { NgModule } = ng.core 87 | const { BrowserModule } = ng.platformBrowser 88 | 89 | class AppModule { } 90 | 91 | AppModule.annotations = [ 92 | new NgModule({ 93 | imports: [ 94 | BrowserModule, 95 | ], 96 | declarations: [ 97 | AppComponent, 98 | ], 99 | bootstrap: [ 100 | AppComponent, 101 | ], 102 | }) 103 | ] 104 | 105 | export { AppModule } 106 | 107 | /* main.js */ 108 | import { AppModule } from './app.module.js' 109 | 110 | const { platformBrowserDynamic } = ng.platformBrowserDynamic 111 | 112 | platformBrowserDynamic().bootstrapModule(AppModule, { ngZone: 'noop' }) 113 | ``` 114 | 115 | 同时修改 HTML 中 `main.js` 的 `type`: 116 | 117 | ```html 118 | 119 | 120 | 121 | 122 | ``` 123 | 124 | 这时候需要进一步确认浏览器支持,选项有: 125 | 126 | 1. 安装最新版本的 Chrome* (>= 61.0); 127 | 2. 安装最新版本的 Firefox* (>= 60.0); 128 | 3. 安装最新版本的 Safari* (>= 10.1); 129 | 4. 安装最新版本的 Edge* (>= 16)。 130 | 131 | > **Chome**:的下载地址 [Chrome Web Browser](https://www.google.com/chrome/)。 132 | 133 | > **Firefox**:下载地址 [Download Firefox — Free Web Browser](https://www.mozilla.org/en-US/firefox/new/)。 134 | 135 | > **Safari**:下载地址 [Apple - Support - Downloads](https://support.apple.com/downloads/safari)。 136 | 137 | > **Edge**:下载地址 [Web Browser for Desktop & Mobile | Microsoft Edge](https://www.microsoft.com/en-us/windows/microsoft-edge)。 138 | 139 | 这里可以看出的好消息是四大主流浏览器都提供了 ES Module 的原生支持,坏消息是仍然有大量用户使用的不是最新版本。 140 | 141 | 然后再次用刚刚准备好的浏览器打开我们的 `index.html` 文件,发现出现了一条报错(以 Chrome 为例): 142 | 143 | ```text 144 | Access to Script at 'file:///.../main.js' from origin 'null' has been blocked by CORS policy: Invalid response. Origin 'null' is therefore not allowed access. 145 | ``` 146 | 147 | 这是因为使用 `file://` 协议的时候对于 **Origin(源)** 的判断上会有些问题(当然从安全层面而言是完全正确的处理),任何一个 Web 前端工程师都应该知道相应的解决方案 —— 启动服务器。 148 | 149 | 我们可以使用: 150 | 151 | ```bash 152 | yarn global add http-server 153 | ``` 154 | 155 | 来快速安装*一个静态文件服务器(如果有其它的 Server 或者其它的包管理器,自行调整即可,对结果没有影响)。 156 | 157 | > **Yarn**:一款 Facebook 推出的包管理器,基于 NPM Registry,相比 NPM 而言对功能和性能进行了一些增强。官网为 [Yarn](https://yarnpkg.com/)。 158 | 159 | 这时我们在 `index.html` 所在的路径使用 `http-server` 启动一个服务器,然后在浏览器中访问 `http://localhost:8080/`(以自己的实际端口为准),又一次得到了同样的内容: 160 | 161 | ```text 162 | Hello Angular 163 | ``` 164 | 165 | 目前为止我们使用的都是能够直接在浏览器中运行的没有使用任何预处理的普通 JavaScript。 166 | 167 | ## 模块化依赖 168 | 169 | 更进一步,借助预处理工具,我们把外部依赖也改用 Module 的形式引入,不再使用 `ng` 全局变量: 170 | 171 | ```javascript 172 | /* app.component.js */ 173 | 174 | /* change start */ 175 | import { Component } from '@angular/core' 176 | /* change end */ 177 | 178 | class AppComponent { } 179 | /* ... */ 180 | 181 | /* app.module.js */ 182 | import { AppComponent } from './app.component.js' 183 | /* change start */ 184 | import { NgModule } from '@angular/core' 185 | import { BrowserModule } from '@angular/platform-browser' 186 | /* change end */ 187 | 188 | class AppModule { } 189 | /* ... */ 190 | 191 | /* main.js */ 192 | import { AppModule } from './app.module.js' 193 | /* change start */ 194 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic' 195 | /* change end */ 196 | 197 | platformBrowserDynamic().bootstrapModule(AppModule, { ngZone: 'noop' }) 198 | ``` 199 | 200 | 这里能够很清晰地看出 **模块(Module)** 的组成方式,分为: 201 | 202 | + 导入; 203 | + 内容; 204 | + 导出。 205 | 206 | 三个部分*,实际项目中不一定按照顺序书写。 207 | 208 | > **模块组成部分**:按照规范,一个 Module 可以既没有 `import`,也没有 `export`,但这种情况下仍然和 Script 有本质区别。由于规范并未指定区分 Module 和 Script 的方式,而是由实现自行决定,最终 Web 使用 ` 218 | 221 | 222 | ``` 223 | 224 | 这时候会得到: 225 | 226 | ```text 227 | Error: Unable to dynamically transpile ES module 228 | A loader plugin needs to be configured via `SystemJS.config({ transpiler: 'transpiler-module' })`. 229 | ``` 230 | 231 | 这里的错误内容浅显易懂,但在处理这个错误之前,我们首先可能会有疑问,为什么在浏览器已经原生支持了 ES Module 的情况下,会需要使用预处理工具? 232 | 233 | 由于这个话题内容太大,因此直接提供外部链接作为答案:[为什么 ES Module 的浏览器支持没有意义](https://zhuanlan.zhihu.com/p/25046637)。 234 | 235 | 这里我们按照错误要求,提供自定义的转译工具: 236 | 237 | ```html 238 | 239 | Hello Angular 240 | Loading... 241 | 242 | 243 | 276 | 277 | 280 | ``` 281 | 282 | 看似内容很多,其实只有两点: 283 | 284 | 1. 使用 TypeScript 作为转译工具*,基于 [plugin-typescript](https://github.com/frankwallis/plugin-typescript) 实现; 285 | 2. 提供模块名称映射,使用 `unpkg.com` 提供的 CDN 作为依赖源。 286 | 287 | > **使用 TypeScript 转译**:此处仅需要处理 ES Module,因此 TypeScript 并不是必须要求,也可以使用 Babel 等工具。 288 | 289 | ## 静态属性 290 | 291 | 有了 TypeScript 作为转译工具,我们便可以使用浏览器中尚未实现的 ES 特性,例如静态属性,现在将元数据位置移到类的内部: 292 | 293 | ```javascript 294 | /* app.component.js */ 295 | 296 | /* ... */ 297 | class AppComponent { 298 | /* change start */ 299 | static annotations = [ 300 | new Component({ 301 | template: '

Hello Angular

', 302 | }) 303 | ] 304 | /* change end */ 305 | } 306 | 307 | /* change start */ 308 | /* change end */ 309 | export { AppComponent } 310 | 311 | /* app.module.js */ 312 | 313 | /* ... */ 314 | class AppModule { 315 | /* change start */ 316 | static annotations = [ 317 | new NgModule({ 318 | imports: [ 319 | BrowserModule, 320 | ], 321 | declarations: [ 322 | AppComponent, 323 | ], 324 | bootstrap: [ 325 | AppComponent, 326 | ], 327 | }) 328 | ] 329 | /* change end */ 330 | } 331 | 332 | /* change start */ 333 | /* change end */ 334 | export { AppModule } 335 | ``` 336 | 337 | 上面用到的语法就是当前 Stage 3 的[静态属性](https://github.com/tc39/proposal-static-class-features),在 TypeScript 中已经得到支持。 338 | 339 | ## 精简导出语法 340 | 341 | 不过我们仅仅需要导出类型本身,单独列出 `export` 未免过于繁琐,为此可以把 `export` 声明* inline 化,得到: 342 | 343 | > **export 声明**:ES 规范中 `import` 和 `export` 这类内容并不属于 **语句(Statement)**,类似于「import 语句」是完全错误的说法。这里使用 TypeScript AST 使用的 `ExportDeclaration` 进行称呼。 344 | 345 | ```javascript 346 | /* app.component.js */ 347 | 348 | /* ... */ 349 | /* change start */export /* change end */class AppComponent { 350 | /* ... */ 351 | } 352 | 353 | /* change start */ 354 | /* change end */ 355 | /* EOF */ 356 | 357 | /* app.module.js */ 358 | 359 | /* ... */ 360 | /* change start */export /* change end */class AppModule { 361 | /* ... */ 362 | } 363 | 364 | /* change start */ 365 | /* change end */ 366 | /* EOF */ 367 | ``` 368 | 369 | 这样代码组织上会显得更加简洁,不过仍然还有发展空间。 370 | 371 | ## 装饰器 372 | 373 | 由于元数据都是静态内容,使用命令式的属性赋值仍然存在语法噪音,为此将此改为更加偏向声明式的 **装饰器(Decorator)** 语法: 374 | 375 | > **静态属性改为装饰器**:将静态属性改为 Decorator 的过程前后文件的语义是发生了变化的,在 JavaScript 语言层面并不等价,只是在 Angular 的功能实现上等价。 376 | 377 | ```javascript 378 | /* app.component.js */ 379 | 380 | /* ... */ 381 | /* change start */ 382 | @Component({ 383 | template: '

Hello Angular

', 384 | }) 385 | /* change end */ 386 | export class AppComponent { 387 | /* change start */ 388 | /* change end */ 389 | } 390 | 391 | /* app.module.js */ 392 | 393 | /* ... */ 394 | /* change start */ 395 | @NgModule({ 396 | imports: [ 397 | BrowserModule, 398 | ], 399 | declarations: [ 400 | AppComponent, 401 | ], 402 | bootstrap: [ 403 | AppComponent, 404 | ], 405 | }) 406 | /* change end */ 407 | export class AppModule { 408 | /* change start */ 409 | /* change end */ 410 | } 411 | ``` 412 | 413 | 之后会得到: 414 | 415 | ```text 416 | plugin.js:406 TypeScript [Error] Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option to remove this warning. (TS1219) 417 | ``` 418 | 419 | 这是由于装饰器在 TypeScript 中仍然作为实验特性*,需要主动开启: 420 | 421 | > **装饰器实验特性**:[装饰器特性](https://github.com/tc39/proposal-decorators)目前仍处于 Stage 2,而 TypeScript 实现的为更加早期版本的提案内容,详情参见 [TypeScript中的装饰器(Decorators)的本质是什么(或者说它具体做了什么工作)?](https://www.zhihu.com/question/68257128/answer/261502855)。 422 | 423 | ```html 424 | 425 | 436 | 437 | ``` 438 | 439 | 刷新浏览器,发现一切正常。 440 | 441 | ## 总结 442 | 443 | 现在我们就成功地将整个项目 Script 形式的单文件 JavaScript 逐步迁移成了 Module 形式的多文件 JavaScript,并且通过现代化的 JavaScript 语言特性让内容更加清晰易懂。目前我们 **并没有用到任何 TypeScript 语言** 的内容,仅仅是使用到了 TypeScript 工具来作为 JavaScript 的降级编译器。 444 | 445 | ## 代码归档 446 | 447 | #### index.html 448 | 449 | ```html 450 | 451 | Hello Angular 452 | Loading... 453 | 454 | 490 | 493 | ``` 494 | 495 | #### app.component.js 496 | 497 | ```javascript 498 | import { Component } from '@angular/core' 499 | 500 | @Component({ 501 | template: '

Hello Angular

', 502 | }) 503 | export class AppComponent { 504 | } 505 | ``` 506 | 507 | #### app.module.js 508 | 509 | ```javascript 510 | import { NgModule } from '@angular/core' 511 | import { BrowserModule } from '@angular/platform-browser' 512 | import { AppComponent } from './app.component.js' 513 | 514 | @NgModule({ 515 | imports: [ 516 | BrowserModule, 517 | ], 518 | declarations: [ 519 | AppComponent, 520 | ], 521 | bootstrap: [ 522 | AppComponent, 523 | ], 524 | }) 525 | export class AppModule { 526 | } 527 | ``` 528 | 529 | #### main.js 530 | 531 | ```javascript 532 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic' 533 | import { AppModule } from './app.module.js' 534 | 535 | platformBrowserDynamic().bootstrapModule(AppModule, { ngZone: 'noop' }) 536 | ``` 537 | 538 | ## 在线示例 539 | 540 | 541 | 542 | ## 可能的疑惑 543 | 544 | #### 为什么 Dart 版本的 Angular 没有流行起来? 545 | 546 | 因为 Dart 没有流行起来。 547 | 548 | #### 现在是否还有办法使用 AtScript? 549 | 550 | Github 上有一个 PlayGround 的 Repo:[angular/atscript-playground](https://github.com/angular/atscript-playground)。 551 | 552 | #### Angular 的编译器是如何工作的? 553 | 554 | 会在后文中覆盖。 555 | 556 | #### 既然 AOT 编译的要求是 TypeScript 工具和 Decorator 语法,那是否可以对使用 Decorator 语法的 JavaScript 文件进行 AOT 编译? 557 | 558 | 理论上可行,Decorator 本身是(提案中的)JavaScript 语言特性,但是 TypeScript 工具对 JavaScript 文件的支持(Salsa)与 TypeScript 文件的支持略有差异,需要使用额外的构建步骤将 `.js` 文件转制为 `.ts` 文件,另外可能还需要设置忽略相应的类型检查错误。 559 | 560 | 另外,不建议在没有相关实力的情况下主动踩坑。 561 | 562 | #### 既然 JIT 编译也会在运行时生成相应的 JavaScript 文件,那是否可以将浏览器中所生成的 JavaScript 文件拷贝出来当做源码使用,从而避免运行时编译? 563 | 564 | 理论上可行,JIT 编译除了输出的语言级别和使用的模块机制外,与 AOT 编译的结果并无本质差异。但这样做会导致模版中的内容无法被正确地进行类型检查,可能产生不必要的错误隐患。 565 | 566 | 另外,不建议在没有相关实力的情况下主动踩坑。 567 | 568 | #### 为什么 file 协议会有跨域问题? 569 | 570 | Web 开发基础不在本书的覆盖范围内。请自行搜索其它外部资源。 571 | 572 | #### 为什么 TypeScript 工具的 JavaScript 支持部分叫做 Salsa? 573 | 574 | 内部项目代号,大家后来习惯了就都这么叫。 575 | 576 | #### 哪里能查到 TypeScript CLI 的所有编译器选项? 577 | 578 | 这里:[Compiler Options · TypeScript](http://www.typescriptlang.org/docs/handbook/compiler-options.html)。 579 | -------------------------------------------------------------------------------- /chapter-001/003-typescript.md: -------------------------------------------------------------------------------- 1 | # TypeScript 2 | 3 | // TODO: everything 4 | 5 | 当我们说到 Angular 的开发语言的时候,我们可能指: 6 | 7 | 1. Angular 源代码所使用的语言; 8 | 2. 基于 Angular 的项目所需要使用的语言。 9 | 10 | 大部分时候我们往往指代的是后者。 11 | 12 | Angular 项目本身是用 TypeScript* 或 Dart* 开发的。咦,为什么是「或」?其实在 `2.0.0-rc.5` 版本之前,Angular 的 TypeScript 版本和 Dart 版本是共用的同一个 Code Base*,绝大部分公共代码采用 TypeScript 编写,并编译*成 Dart 代码,另有少部分非共用代码分别使用 TypeScript 和 Dart 实现。 13 | 14 | > **TypeScript**:TypeScript 是 Microsoft 推出的一门基于 JavaScript 语言扩展的类 JavaScript 语言,用于增强 JavaScript 工程中的静态类型检查效果。官网为:[TypeScript - JavaScript that scales](http://www.typescriptlang.org/),语言规范为:[TypeScript Language Specification(不是最新)](https://github.com/Microsoft/TypeScript/blob/master/doc/spec.md)。在本文中,我们可能不严格区分 TypeScript 这门语言与其 [官方实现](https://github.com/Microsoft/TypeScript)。 15 | 16 | > **Dart**:Dart 是 Google 推出的一门通用编程语言,主要面向 Web 开发。官网为:[Dart programming language | Dart](https://www.dartlang.org/),语言规范为:[Standard ECMA-408](http://www.ecma-international.org/publications/standards/Ecma-408-arch.htm)。在本文中,我们可能不严格区分 Dart 这门语言与其 [官方实现](https://github.com/dart-lang/sdk)。 17 | 18 | > **同一 Code Base**:分离前 Dart 版实现的源码也位于 [angular/angular](https://github.com/angular/angular) 的 GitHub 当中,同时 [dart-lang/angular2](https://github.com/dart-lang/angular2) 充当编译后的纯 Dart 代码的存档。之后即作为 Dart 实现的源码 Repo 使用。 19 | 20 | > **编译至 Dart**:Angular 团队自行开发了 TypeScript 到 Dart 的编译工具,详情参考:[Angular 2 Dart Transformer](https://docs.google.com/document/d/1Oe7m96QnOrilxpH1B5o9G_PnfBGovhH-n_o7RU6LYII/),相应的实现在 [angular/ts2dart](https://github.com/angular/ts2dart)。 21 | 22 | 之后由于各种原因,Angular 的 Dart 版本不再与 TypeScript 版本共用代码,从而成为了独立实现。不过事实上,除了 Google 的内部产品外,很少有其它项目会使用 Dart 版本的 Angular 来进行开发。 23 | 24 | 因此,在本书中,如无特殊说明,所有用到 Angular 的地方均指代 TypeScript 实现的版本。如果有 Dart 版本明显不一致的地方,会在脚注中表明(前提是我知道的话)。 25 | 26 | 在说到 TypeScript 之前,不得不提及的一门语言是 AtScript*。 27 | 28 | > **AtScript**:AtScript 是专门为开发 Angular 所设计的语言,因此在 Angular 团队决定迁移到 TypeScript 之后,该语言即被宣布废弃。官方文档在 [AtScript Primer](https://docs.google.com/document/d/11YUzC-1d0V1-Q3V0fQ7KSit97HnZoKVygDxpWzEYW0U/edit),扩展名仍为 `.js`。 29 | 30 | Angular 的一个重要设计理念就是 **声明式(Declarative)** 编程,为了落实这一理念,Angular 在 API 设计上的一个重大改进就是很大程度上基于* `@Something` 的 **注解(Annotation)**/**装饰器(Decorator)** 的声明语法。 31 | 32 | > **基于装饰器**:正如我们在上一节中尝试过的那样,Angular 并不要求使用 Decorator 语法,只是在使用该语法的情况下能够大量提高代码可读性,提高开发效率。 33 | 34 | 那么,这个语法到底是注解还是装饰器呢? 35 | 36 | 事实上,在 Angular 早期设计的时候,ES6 都还不知道是不是 ES2015,更不用说之后的各种语言提案。因此,AtScript 自行扩展一个叫做 **Metadata Annotation*** 的语法,用于附加额外的元数据,实现也非常简单: 37 | 38 | > **Metadata Annotation**:Annotation 并不仅仅指代 `@Something` 这样的语法,这种情况下被 AtScript 中叫做 Metadata Annotation,通常译作「注解」;而 `name: string` 也是 Annotation,更具体一点称为 Type Annotation,通常译作「类型标注」。 39 | 40 | ```typescript 41 | /* AtScript */ 42 | @Component() 43 | class MyApp { 44 | server: Server; 45 | @Bind('name') name: string 46 | @Event('foo') fooFn: Function 47 | 48 | @Inject() 49 | constructor(@parent server: Server) { } 50 | 51 | greet(): string { } 52 | } 53 | ``` 54 | 55 | 等价于: 56 | 57 | ```javascript 58 | /* JavaScript in ES2015 */ 59 | class MyApp() { 60 | constructor(server) { } 61 | 62 | greet(): string { } 63 | } 64 | 65 | MyApp.properties = { 66 | server: { is: Server }, 67 | name: { is: String, annotate: [new Bind('name'] }, 68 | fooFn: { is: Function, annotate: [new Event('foo')] }, 69 | } 70 | 71 | MyApp.annotations = [ 72 | new Component(), 73 | new Inject(), 74 | ] 75 | 76 | MyApp.parameters = [ 77 | { is: Server, annotate: [parent] }, 78 | ] 79 | 80 | MyApp.prototype.greet.returns = String 81 | ``` 82 | 83 | 可以看出,所有的标注信息都被附加到运行时*中,即具备 **内省(Type Introspection)** 的能力。当然,这么做仅仅是附加信息,本身并不能提供任何功能,而是由其它使用该内容的代码根据相应的数据来动态确定行为。这种方式非常类似于 Java 的 **Annotation** 或者 C# 的 **Attribute**。 84 | 85 | > **运行时类型标注**:AtScript 的一个独特的功能就是运行时的类型检查,这点和 TypeScript 纯粹的编译时检查不同,即便是直接使用编译后的 JavaScript 代码也同样能保证类型安全性。 86 | 87 | 随后在 NG-Conf 2015 上,Angular 团队宣布了迁移至 TypeScript 的消息*。在 Angular 团队与 TypeScript 团队的合作计划中,TypeScript 将增加 **Metadata Annotation** 的语法以及对应的 **Introspection API***。 88 | 89 | > **迁移至 TypeScript**:Twitter 链接为 [ng-conf on Twitter: "AtScript is Typescript #ngconf" 90 | ](https://twitter.com/ngconf/status/573521849780305920)。 91 | 92 | > **Introspection API**:原计划中的 TypeScript Introspection API 设计文档:[TypeScript Introspection API](https://docs.google.com/document/d/1fvwKPz7z7O5gC5EZjTJBKotmOtAbd3mP5Net60k9lu8/edit#heading=h.v7s5x1d7wo5j)。 93 | 94 | 不过,这件事最后并未发生。随着 ES2015 的正式发布,JavaScript 语言开始进入稳定的持续迭代发展阶段,TypeScript 也不再接受新的语言特性,而是仅仅提供对 JavaScript 语言特性的支持以及提供相应的类型检查。于是 TypeScript 最后增加了对语法相似(但是语义完全不同)的装饰器特性的支持(装饰器本身是 JavaScript 的语言提案,并不是 TypeScript 的扩展内容),而 Angular 也将相应的 API 改用装饰器实现*。不过对于一般用户而言,这个重大的改动似乎并没有什么实际影响(以及在 2015 年的时候实际上也没有多少用户存在)。 95 | 96 | > **改用装饰器实现**:AtScript 原有的 Metadata Annotation 的功能基本可以通过 JavaScript 的 Decorator 模拟实现,改动后的 Re-design 文档可以参见:[Decorators vs Metadata Annotations](https://docs.google.com/document/d/1QchMCOhxsNVQz2zNvmzy8ibDMPT46MLf79X1QiDc_fU/edit)。 97 | 98 | 此外,为了解决 Angular 需要运行时获取构造函数参数信息的问题(关于 **依赖注入(Dependency Injection)** 的内容会在之后的部分详述),TypeScript 提供了一个新的编译器选项 `emitDecoratorMetadata`,为具备装饰器的类暴露构造函数参数信息,默认情况下是基于 **Metadata Reflection API*** 所实现的,后者是一个还没有提交的「语言提案」。 99 | 100 | > **Metadata Reflection API**:所在 Repo 为 [rbuckton/reflect-metadata](https://github.com/rbuckton/reflect-metadata),文档位于 [Metadata Proposal - ECMAScript](https://rbuckton.github.io/reflect-metadata/)。目前并未提交给 TC39,所以不属于任何 Stage。 101 | 102 | 于是现在我们解决了第一个问题,Angular(我们所关心的那个实现)是使用 TypeScript 所开发的。那么接下来的另一个问题是,基于 Angular 的项目是否需要使用 TypeScript 开发呢? 103 | 104 | 是也不是。在上一节中我们已经尝试过使用 Pure JavaScript 来开发一个简单的 Angular 应用,所以使用 JavaScript 来开发在技术上是切实可行的。但是我们知道,TypeScript 具备很多优势,例如提供了编译时的静态类型检查,提供了最新的(以及提案中的)的 JavaScript 语言特性的转译支持,提供了完善的语言服务集成等等。 105 | 106 | 不过其实这些都不是重点,最重要的地方时,Angular 的静态编译工具是基于 TypeScript 封装实现的,也就是说,在不使用 TypeScript 工具链的情况*下,便无法在开发时使用 Angular 的模版编译器*,从而无法构建出适合生产环境使用的发行版本。 107 | 108 | > **无法 AOT 的情况**:更确切地说 AOT 编译的限制还有必须使用 Decorator 语法。 109 | 110 | > **开发时编译**:对于 Angular 而言,在应用执行前(通常为开发时)预先编译模版内容的方式叫做 AOT(Ahead-of-time)编译,在应用执行过程中(运行时)编译模版内容的方式叫做 JIT(Just-in-time)编译,如无特殊说明,本文中的编译方式均指代 Angular 模版编译器的编译方式。 111 | 112 | 所以说,就目前的客观事实下,如果想用 Angular 开发实际项目,那么就应该使用 TypeScript。 113 | -------------------------------------------------------------------------------- /chapter-001/004-dev-tools.md: -------------------------------------------------------------------------------- 1 | # 开发工具 2 | 3 | // TODO: 内容待更新 4 | 5 | 在本章前面的内容中,我们已经体验了纯粹的手写 JavaScript 代码的方式[^1],也介绍了使用 TypeScript 编译并通过 Webpack 打包的简单构建步骤。随着项目规模的不断增大、性能要求的不断增高,传统的手写 JavaScript 方式已经很难满足现代工程项目的需要。 6 | 7 | 为此,我们需要使用大量的工具,以及使用工具来管理用到的工具。 8 | 9 | 就上一节中的例子而言,为了减少构建所需的步骤,我们可以把 `tsc` 和 `webpack` 的命令写在同一个 NPM Script 中,这样构建时就只需要使用一条命令;实际上,我们还可以做得更好,例如直接使用 Webpack 的 Loader 来处理 TypeScript 文件,而我们只需要操作 Webpack;不过,对于开发人员而言,每个项目自行编写 Webpack 配置文件仍然过于繁琐,鉴于大部分的 Angular 项目所使用的构建方式都极其相似,我们可以直接使用封装好的 Angular CLI 工具来构造和构建 Angular 项目。 10 | 11 | 在此之前我们可能好奇的一个问题是:**Angular 的构建方式是什么?** 12 | 13 | 实际上,这里依然是两个问题,即: 14 | 15 | 1. Angular 项目本身的构建方式是什么? 16 | 2. 使用 Angular 的最终应用需要什么样的构建方式? 17 | 18 | 对于第一个问题,虽然我们可能不会直接用到,但是仍然有了解的必要。官方的 Angular 模块(的主体部分)有 4 个发布版本[^2]: 19 | 20 | + ES2015 版本的 FESM[^3] 格式文件; 21 | + ES5 版本[^4]的 FESM 格式文件; 22 | + ES2015 版本的 UMD[^5] 格式文件; 23 | + ES5 版本的 UMD 格式文件。 24 | 25 | 其中,ES2015 的 FESM 格式是使用 Angular Compiler(命令行下为 `ngc`)编译的,对应的 `tsconfig` 文件(以 `@angular/core` 为例)位于:[angular/packages/core/tsconfig-build.json](https://github.com/angular/angular/blob/master/packages/core/tsconfig-build.json);而 ES2015 的 UMD 格式是使用 Rollup.js[^6] 构建的,对应的 `rollup.config` 文件(以 `@angular/core` 为例)位于:[angular/packages/core/rollup.config.js](https://github.com/angular/angular/blob/master/packages/core/rollup.config.js);至于 ES5 版本的文件,不再采用上述工具重新构建,而是直接使用 ES2015 版本的文件降级编译而成,该过程通过 TypeScript Compiler(命令行下为 `tsc`)实现。 26 | 27 | Angular 的每个构建后的 Package 有专门的 Repo,例如 `@angular/core` 的 Repo 位于:[angular/core-builds: @angular/core build artifacts](https://github.com/angular/core-builds),和 NPM Registry 上不同的是,这个 Repo 提供对应到每个 Commit 的版本,而 NPM Registry 中只会按照发布计划每周发布一次带有语义化版本号的相应版本。 28 | 29 | 另外,Angular 团队目前已有引入谷歌官方 Bazel[^7] 构建工具的计划[^8]。 30 | 31 | 接下来就是另一个我们更为关心的问题,我们使用 Angular 开发的最终应用需要使用什么样的构建方式呢? 32 | 33 | 事实上,除了 `ngc` 之外,其它步骤都没有任何特殊要求,选择自己熟悉的构建方案即可。 34 | 35 | 不过,即便不考虑 `ngc`,从零构造一个同时具备调试和生产环境构建方案也有不小的成本,更不用说对 `ngc` 的整合。为此,对于简单项目而言,我们可以直接使用 Angular 团队所提供的解决方案——Angular CLI。 36 | 37 | 现在我们将完全抛弃之前的成果,本节及之后的内容都会基于 Angular CLI 来进行。 38 | 39 | 首先我们需要安装 Angular CLI,这里以 Yarn 为例: 40 | 41 | ```bash 42 | yarn global add @angular/cli 43 | ``` 44 | 45 | 紧接着我们可以使用 `ng` 命令来快速创建项目: 46 | 47 | ```bash 48 | ng new --skip-install learn-angular 49 | ``` 50 | 51 | 这条命令应当能够在 1 秒之内完成。要注意的是这里我们使用了 `--skip-install` 选项,这个参数很重要,这样做能够帮助我们了解 Angular CLI 的职责,仅仅是根据预设的模版创建项目。 52 | 53 | 当然,现在的项目显然是不能用的,不过我们在 `package.json` 中定义了一些依赖,我们需要安装这些依赖[^9]: 54 | 55 | ```bash 56 | yarn install 57 | ``` 58 | 59 | 需要注意的是,安装 NPM 上的依赖与 Angular CLI 并无直接关联,如果无法正常安装的话,那可能是一些众所周知的其它原因所导致。 60 | 61 | 安装完成之后(并且安装成功的情况下),我们可以使用 Angular CLI 内置的调试服务器来启动服务: 62 | 63 | ```bash 64 | ng serve 65 | ``` 66 | 67 | Angular 使用将 HTML 模版编译成 JavaScript 代码的方式来实现视图层的相关操作。 68 | 69 | 不过,现在我们所生成的代码都在内存当中。不过对于生产环境,我们并不会使用 Webpack Dev Server 作为应用服务器,为此我们需要把生成的静态文件取出。为此我们可以使用另一个命令: 70 | 71 | ```bash 72 | ng build --prod 73 | ``` 74 | 75 | 这里 `--prod` 正确是作为 `--configuration=prodution` 的简写,并不是一个独立选项。 76 | 77 | 然后我们可以在 `dist` 目录中得到全部的静态文件内容,拷贝到服务器相应的静态文件目录中即可正常工作。 78 | 79 | 最后,Angular CLI 提供的功能仍然有限,我们可能需要定制化部分构建方式。为此我们可以将 Angular CLI 项目转化为普通的 Webpack 项目: 80 | 81 | ```bash 82 | ng eject 83 | ``` 84 | 85 | 这样就能看到对应的 `webpack.config.js` 文件[^12]并对其进行任意修改。 86 | 87 | 其实 Angular CLI 还有其它的很多功能,我们可以通过 `ng help` 来浏览全部可用命令。 88 | 89 | 不过一个很严峻的问题是,在没有 Angular CLI 的情况下,我们要如何构建一个使用了 Angular 的最终应用呢? 90 | 91 | 前面我们已经提到过,除了 `ngc` 之外,其它的步骤都没有任何的特殊性,因此,我们只要能够自行完成 `ngc` 就足够了。 92 | 93 | 现在我们对 `src/tsconfig.app.json` 文件增加一部分内容: 94 | 95 | ```json 96 | { 97 | "extends": "../tsconfig.json", 98 | "compilerOptions": { 99 | "outDir": "../out-tsc/app", 100 | "module": "es2015", 101 | "baseUrl": "", 102 | "types": [] 103 | }, 104 | "exclude": [ 105 | "test.ts", 106 | "**/*.spec.ts" 107 | ], 108 | "angularCompilerOptions": { 109 | "genDir": "../out-aot" 110 | } 111 | } 112 | ``` 113 | 114 | 上面的配置中增加了 `angularCompilerOptions` 的部分内容,其中带有一个 `genDir` 属性,用于指定 AOT 编译时的输出目录[^13]。 115 | 116 | 然后执行以下命令(for *nix): 117 | 118 | ```bash 119 | ./node_modules/.bin/ngc -p src/tsconfig.app.json 120 | ``` 121 | 122 | 这样就会出现 `out-aot` 和 `out-tsc` 两个文件夹。 123 | 124 | `out-aot` 中的内容为 Angular Compiler 所生成的额外内容,核心内容为 Component 与 NgModule 的 NgFactory 文件,所有 HTML 模版中的信息都转移至此,因此我们不再需要用到 HTML 模版文件。 125 | 126 | `out-tsc` 中的内容为 Angular Compiler 将原有的 TypeScript 代码编译为 JavaScript 的结果,可能与 TypeScript 自身的编译结果略有差异。 127 | 128 | 现在我们尝试不使用 Angular CLI 来完成 AOT 方式下的构建,为了避免复杂的路径映射,我们将所有文件原地输出,修改 `src/tsconfig.app.json` 文件为: 129 | 130 | ```json 131 | { 132 | "extends": "../tsconfig.json", 133 | "compilerOptions": { 134 | "outDir": ".", 135 | "target": "es2015", 136 | "module": "es2015", 137 | "baseUrl": "", 138 | "types": [] 139 | }, 140 | "exclude": [ 141 | "test.ts", 142 | "**/*.spec.ts" 143 | ] 144 | } 145 | ``` 146 | 147 | 之后执行同样的 `ngc` 命令: 148 | 149 | ```bash 150 | ./node_modules/.bin/ngc -p src/tsconfig.app.json 151 | ./node_modules/.bin/ngc -p src/tsconfig.app.json 152 | ``` 153 | 154 | 是的,没有看错,这里执行了两次,目的是为了把第一次构建时才生成的 `.ngfactory.ts` 文件等编译为 JavaScript 文件,所以说第二次命令原理上也可以用 `tsc` 替代。这个额外的步骤在当前 Angular Compiler 的工作机制下是需要的,不过在自动化构建方式时,不会产生可观测的影响。 155 | 156 | 不过,这样同一个过程做两次难免带来不便(其实自动化之后没什么区别),为此 Angular 的 Offline Compiler 也提供了一步完成的支持,为此需要在 `tsconfig.app.json` 中增加一个配置项: 157 | 158 | ```json 159 | { 160 | "angularCompilerOptions": { 161 | "alwaysCompileGeneratedCode": true 162 | } 163 | } 164 | ``` 165 | 166 | 这样,Offline Compiler 就会同时将新生成的 `.ngfactory.ts` 文件也编译为相应的 `.ngfactory.js` 文件: 167 | 168 | ```bash 169 | ./node_modules/.bin/ngc -p src/tsconfig.app.json 170 | ``` 171 | 172 | 接着修改 `src/main.js` 文件为: 173 | 174 | ```javascript 175 | import { enableProdMode } from '@angular/core'; 176 | import { platformBrowser } from '@angular/platform-browser'; 177 | import { AppModuleNgFactory } from './app/app.module.ngfactory'; 178 | import { environment } from './environments/environment'; 179 | 180 | enableProdMode(); 181 | 182 | platformBrowser().bootstrapModuleFactory(AppModuleNgFactory); 183 | ``` 184 | 185 | 也就是将 `platformBrowserDynamic` 替换为 `platformBrowser`,`Module` 替换为 `ModuleNgFactory`。`@angular/platform-browser-dynamic` 是专门为 JIT 编译方式所设置的平台,具备运行时的编译器;而 `@angular/platform-browser` 为 AOT 编译方式所使用的平台,不具备运行时编译器,只能用于启动已经编译完成的 NgModuleFactory。 186 | 187 | 得到了所有的 JavaScript 文件之后,我们可以使用上一小节中介绍过的 Webpack 工具进行打包: 188 | 189 | ```bash 190 | webpack src/main.js out-webpack/bundle.js 191 | ``` 192 | 193 | 再添加一个 `index.html` 文件至 `out-webpack` 目录: 194 | 195 | ```html 196 | 197 | Hello Angular 198 | Loading... 199 | 200 | 201 | ``` 202 | 203 | 打开该 HTML 文件,即可看到 `app works!` 的字样。 204 | 205 | 至此,我们已经能够不借助于 Angular CLI 实现完整的 AOT 编译方式下应用的构建过程。 206 | 207 | ## 可能的疑惑 208 | 209 | #### 为什么前端开发的工具在越来越复杂? 210 | 211 | 因为前端开发的业务内容在越来越复杂。 212 | 213 | #### 为什么要使用 FESM 格式的文件? 214 | 215 | 参见 [The cost of small modules | Read the Tea Leaves](https://nolanlawson.com/2016/08/15/the-cost-of-small-modules/)。 216 | 217 | #### ngc 相对于 tsc 而言有哪些扩展配置? 218 | 219 | 会在后文中详述。 220 | 221 | #### Angular 为什么不使用 Webpack 进行打包? 222 | 223 | Webpack 在打包过程中会引入额外的内容,增加不必要的运行时大小,基本只适用于最终应用,不适合类库。Webpack 3.0 以上版本增加的 Scope Hoisting 功能能够一定程度上缓解该问题,Angular CLI 也已经在尝试借助该功能来进一步减小应用体积([位于 webpack-next 分支](https://github.com/angular/angular-cli/tree/webpack-next))。 224 | 225 | #### 为什么我的 NPM Package 经常安装失败? 226 | 227 | 不宜公开讨论的政治内容。 228 | 229 | #### 将 Webpack Dev Server 用于生产环境是否可行? 230 | 231 | 功能上可行,性能和稳定性上不一定可行。 232 | 233 | #### ng 命令是哪来的? 234 | 235 | 由 `@angular/cli` 提供,通常作为全局依赖(以及同时作为局部依赖)。 236 | 237 | #### ngc 命令是哪来的? 238 | 239 | 由 `@angular/compiler-cli` 提供,通常作为局部依赖。 240 | 241 | #### (Angular Compiler 的)JIT 编译方式有什么优势? 242 | 243 | 可以配合 Online Editor 制作在线示例。 244 | 245 | #### Angular CLI 是否适用于构造 Angular 的第三方类库? 246 | 247 | 不适用。 248 | 249 | #### 如何构建一个 Angular 的第三方类库? 250 | 251 | 官方文档正在增加该内容,详情参见:[WIP - AIO: Third party library guide](https://github.com/angular/angular/pull/16486)。 252 | 253 | #### Angular 编译器的工作原理的怎样的? 254 | 255 | 会在后文中详述。 256 | 257 | #### enableProdMode 的影响有哪些? 258 | 259 | 不添加调试用的 class、attribute 和 comment;不暴露调试用的 `ng.probe` 方法;不进行确保单项数据流稳定性的额外变化监测。 260 | 261 | #### angularCompilerOptions 的所有选项在哪里可以找到? 262 | 263 | 这里:[angular/options.ts at master · angular/angular](https://github.com/angular/angular/blob/master/tools/%40angular/tsc-wrapped/src/options.ts)。 264 | 265 | --- 266 | 267 | [^1]: 这里的手写 JavaScript 代码指的是直接将开发人员书写的项目源码作为生产环境运行时中所实际使用的执行内容。 268 | 269 | [^2]: Angular 相关 Package 的发布格式规范可以参见:[Angular Package Format](https://docs.google.com/document/d/1CZC2rcpxffTDfRDs6p1cfbmKNLA6x5O-NtkJglDaBVs/edit)。 270 | 271 | [^3]: FESM 为 Flattened ES Module 的缩写,即单文件形式的 ES Module,不再依赖自身的子路径,有利于提升构建速度。 272 | 273 | [^4]: 从概念上而言 ES Module 是 ES2015 开始才引入的,ES5 并不支持 Module 的语法,所以准确地说这种方式是在 ES2015 格式下使用(除模块外)兼容 ES5 的语法子集。不过由于模块化本身和其它语言特性相正交,加之构建工具的实际支持情况也与语言版本无关,所以通常被成为 ES5 版本的 ES Module 也并无大碍。 274 | 275 | [^5]: UMD 的全称为 Universal Module Definition,是一种通用的发行格式,能够兼容 [CommonJS 规范](http://wiki.commonjs.org/wiki/CommonJS) 和 [AMD 规范](https://github.com/amdjs/amdjs-api/wiki/AMD),以及在不具备模块系统的情况下 Fallback 到全局变量实现。「官方」规范在:[umdjs/umd: UMD (Universal Module Definition) patterns for JavaScript modules that work everywhere.](https://github.com/umdjs/umd)。 276 | 277 | [^6]: Rollup.js 为一款面向 ES Module 的打包工具,也可通过插件来支持其它模块格式或内联预处理步骤。官网为:[rollup.js • guide](https://rollupjs.org/),Github 地址为:[rollup/rollup: Next-generation ES6 module bundler](https://github.com/rollup/rollup)。 278 | 279 | [^7]: Bazel 为谷歌推出的通用构建工具。官网为:[Home - Bazel](https://bazel.build/),Github 地址为:[bazelbuild/bazel: a fast, scalable, multi-language and extensible build system](https://github.com/bazelbuild/bazel)。 280 | 281 | [^8]: 目前已有引入 Bazel 的 PR:[build: Introduce Bazel build rules by alexeagle · Pull Request #16972 · angular/angular](https://github.com/angular/angular/pull/16972)。概念上而言该工具的功能与当前使用的编译及打包工具相正交,并不会发生取代的情况。 282 | 283 | [^9]: 这里并没有给出 `cd` 的步骤,如果没有能力自行切换工作路径的话,那可能不是特别适合当前的练习。 284 | 285 | [^12]: 不过需要注意的是,`ng eject` 只能够根据参数生成某个特定方式的 Webpack 配置,如果需要得到一个通用的 Webpack 项目,可能需要多次进行 `ng eject` 然后提取 Webpack 配置中的通用部分。 286 | 287 | [^13]: 这里 `genDir` 的配置并不是必须的,但如果不配置该项的话 AOT 编译的结果会直接在原目录中产生,影响代码结构和自动化工具的识别。 288 | -------------------------------------------------------------------------------- /chapter-001/README.md: -------------------------------------------------------------------------------- 1 | # 环境准备 2 | 3 | 本章内容用于开发 Angular 的「环境准备」。 4 | 5 | 虽然叫做「环境准备」,但是与实际项目开发无关,只是让开发人员理解基本的 Angular 运行方式,从而在使用高度集成的开发环境时也能理解相应的处理过程,遇到问题时能够理解出现原因。 6 | 7 | 简而言之,这里并不是在准备「机器」,而是在准备「人」。 8 | -------------------------------------------------------------------------------- /chapter-002/000-conceptions.md: -------------------------------------------------------------------------------- 1 | # 概念辨析 2 | 3 | Angular 作为一个「大而全」的一体化前端框架, 4 | 5 | //TODO 6 | 7 | ## 可能的疑惑 8 | 9 | #### 类库、框架有什么区别和联系? 10 | 11 | 两者并没有严格的界定标准,尤其是在 Web 前端范围内。用词上的区分往往主要取决于自身的定位和体量,不过其自身定位未必与在应用中承担的实际角色相符。 12 | 13 | 当然也有一些特例,例如 [Svelte](https://github.com/sveltejs/svelte),由于其 0KB 的运行时体积,基本只能被叫做框架(没有被引入到应用的部分)。 14 | 15 | //TODO 16 | 17 | --- 18 | 19 | // TODO 20 | -------------------------------------------------------------------------------- /chapter-002/001-data-binding.md: -------------------------------------------------------------------------------- 1 | # 数据绑定 2 | 3 | 类似于其它很多 MV* 框架,Angular 也是基于模版来定义视图,通过数据绑定来保障模版与组件间的交互。Angular 的模版严格基于 HTML 语法[^1],并且扩展了 HTML 的语义以实现更丰富的功能。 4 | 5 | 显然,我们的目的并不是学习 HTML,所以仅仅需要考虑 Angular 所扩展的部分及其所具备的语义。为了方便起见,我们再次使用 Angular CLI 创建一个新的 Angular 项目[^2]: 6 | 7 | ```bash 8 | ng new learn-angular 9 | ``` 10 | 11 | ## 插值/Interpolation 12 | 13 | 在建成的项目中,存在一个名为 `AppComponent` 的默认组件,我们可以发现在它的模版(`app.component.html`)中,有一处特殊的地方: 14 | 15 | ```html 16 |

Welcome to {{title}}!!

17 | ``` 18 | 19 | 这里的 `{{title}}` 就是一个 **插值(Interpolation)** 语法。 20 | 21 | 既然叫做插值,某种意义上来说也就是字符串的拼接,所以假设上面的内容作为字符串模版的话,实现类似于: 22 | 23 | ```typescript 24 | const { title } = context 25 | const html = `

Welcome to ${title}!!

` 26 | ``` 27 | 28 | 如果使用 Vritual DOM 实现,效果类似于: 29 | 30 | ```typescript 31 | const element = createElement('h1', {}, `Welcome to ${title}!!`) 32 | ``` 33 | 34 | 所以,不论如何,`Welcome to {{title}}!!` 部分都将作为一个内容整体称为 `

` 元素的内容,而 `{{title}}` 会被某个名为 `title` 的变量所替换。 35 | 36 | 不过,在 Angular 中,插值语法是一个可配置内容,双花括号(`{{ }}`,Double Curly Braces)仅仅是默认的选项。 37 | 38 | 打开 `app.component.ts`,在 `AppComponent` 的元数据中增加一项 `interpolation` 属性: 39 | 40 | ```typescript 41 | @Component({ 42 | /* ... */ 43 | interpolation: ['%start%', '%end%'], 44 | }) 45 | export class AppComponent { /* ... */ } 46 | ``` 47 | 48 | 再次启动应用,我们会发现现在浏览器控制台有报错: 49 | 50 | ```text 51 | Unexpected character "EOF" (Do you have an unescaped "{" in your template? Use "{{ '{' }}") to escape it.) 52 | ``` 53 | 54 | 这个提示确实非常不友好,出现这个报错是由于除了(可选的)双花括号语法用于插值外,Angular 模版中还有单花括号(`{ }`)语法用于 ICU Format[^3],所以在双括号不用于插值后,我们原有的绑定就变成了一个非法的 ICU Format。解决起来也非常简单,把 `app.component.html` 模版里的双花括号替换成新的插值语法即可: 55 | 56 | ```html 57 |

Welcome to %start%title%end%!!

58 | ``` 59 | 60 | 虽然这里的新语法非常浮夸,也完全没有任何美感,不过现在我们的应用确实能够正常使用了。不过,虽然插值语法可以自由配置,但是大多数时候完全没有必要,在本文的其它部分以及其它文章中如无特殊说明的情况下均使用默认的插值语法。 61 | 62 | 上面我们在 Text Node 当中使用了插值语法,不过这并不是唯一能够使用插值的地方,事实上,Attribute Value 中也能进行插值。 63 | 64 | 由于我们的 `AppComponent` 已经被奇怪的语法所污染,这里我们创建一个新组件,通过 CLI 可以很方便地完成这一操作: 65 | 66 | ```bash 67 | ng g c data-binding 68 | ``` 69 | 70 | 这里的 `g` 和 `c` 分别是 `generate` 和 `component` 的简写,完整的 CLI 命令列表可以参考:[Home · angular/angular-cli Wiki](https://github.com/angular/angular-cli/wiki)。 71 | 72 | 之后在组件模版中添加: 73 | 74 | ```html 75 | 76 | ``` 77 | 78 | 并在类定义中添加: 79 | 80 | ```typescript 81 | /* ... */ 82 | export class DataBindingComponent { 83 | avatarId = 6059170 84 | } 85 | ``` 86 | 87 | 之后我们可以看到,什么都没有发生。是的,因为我们并没有任何地方使用这一组件,为此我们在 `AppComponent` 的模版中增加该组件 Selector 对应的元素: 88 | 89 | ```html 90 | 91 | ``` 92 | 93 | 之后就能在应用中看到我们新增的图像了。 94 | 95 | 如果具备 AngularJS 基础的话,我们可能会怀疑这么做是否合适。在 AngularJS 中,如果我们直接在 `src` 中使用插值的话,会导致一个额外的无效请求,为此推荐使用 `ngSrc` 指令。而在 Angular 中,我们不再有此顾虑,所有插值操作均在实际 DOM 建立之前完成,所以上面的代码并没有任何不妥的地方。 96 | 97 | 上面的模版代码中,大部分内容都是当作普通的 HTML 处理的,但是当使用了插值语法时,插值符号中的部分被称为 **模版表达式(Template Expression)**,其内容将不再作为字面值,而是作为 JavaScript[^4] 表达式进行处理,整个表达式的值会被转换成字符串,继而与插值符号以外的内容相连接,从而产生实际内容。并且插值符号本身也不会出现在实际内容中,仅仅用于指示插值的存在。模版表达式的一个特性是无副作用,即对模版表达式的执行不会改变组件的状态,这是 Angular 的要求之一。 98 | 99 | ### 属性绑定/Property Binding 100 | 101 | 对于 DOM Element 而言,除了对其 HTML Attribute 进行插值外,我们也可以直接对其 Property 绑定表达式: 102 | 103 | ```html 104 | 105 | ``` 106 | 107 | 或者 108 | 109 | ```html 110 | 111 | ``` 112 | 113 | 这样的情况下,HTML Attribute 所对应的 Value 部分直接作为模版表达式,而不再需要使用插值语法。 114 | 115 | 对于 **表达式属性绑定**,Angular 提供了两种方式,一种是使用方括号 `[]`,另一种是 `bind-` 前缀。通常来说,使用方括号要更加简洁美观[^5],因此在没有特殊说明的情况下,我们的表达式属性绑定均使用方括号的语法来进行。 116 | 117 | 这里也是和 AngularJS 有一定的区别的地方。在 AngularJS 中,一个属性值作为字面值还是表达式执行是由指令本身所预设的,通过 DDO 中的 `scope` 或 `bindToController` 属性中使用相应的符号[^5] 来决定。而在 Angular 中,使用了更为通用的模版设计,组件本身仅仅要求属性存在与否,而使用者可以根据需实际情况选择使用的方式。 118 | 119 | 那么我们可能会好奇的是: 120 | 121 | ```html 122 | 123 | ``` 124 | 125 | 与 126 | 127 | ```html 128 | 129 | ``` 130 | 131 | 是否等价呢? 132 | 133 | 不过答案的否定的。 134 | 135 | 事实上,我们可以认为: 136 | 137 | ```html 138 | 139 | ``` 140 | 141 | 等价于: 142 | 143 | ```html 144 | 145 | ``` 146 | 147 | 即 **使用插值的 Attribute Value 所对应的属性绑定结果一定是字符串**。而属性绑定本身(对于 Angular Directive 而言)可以是任何类型,并且 Angular 会对属性绑定进行类型检查,确保输入的类型相符。 148 | 149 | 有一点需要注意的是,**属性绑定中的 target 是 DOM Property 而非 HTML Attribute**,对于 `` 的 `src` 来说,由于其 HTML Attribute 和 DOM Property 的形式完全相同,所以这里没有差异。 150 | 151 | 为此我们也可以使用更强大的功能,例如直接绑定 HTML 文档。例如在 `data-binding.component.html` 中使用: 152 | 153 | ```html 154 |
155 | ``` 156 | 157 | 以及在 `data-binding.component.ts` 中定义相应的属性: 158 | 159 | ```typescript 160 | content = ` 161 |
    162 |
  • 1 163 |
  • 2 164 |
  • 3 165 |
166 | ` 167 | ``` 168 | 169 | 这样[^6]就能在组件视图中添加一个列表元素。 170 | 171 | 除了 **表达式属性绑定**,我们也可以使用 **字面值属性绑定**,简单地说也就是不加方括号,看起来就类似于普通的 HTML Attribute,不过针对的不是 HTML Element,而仅限于 Angular Directive[^7]。 172 | 173 | 为此我们将 `data-binding.component.ts` 调整为: 174 | 175 | ```typescript 176 | /* ... */ 177 | export class DataBindingComponent { 178 | @Input() 179 | content: string 180 | /* ... */ 181 | } 182 | ``` 183 | 184 | 其中 `Input` 是 `@angular/core` 中定义的一个 Decorator Factory,其构造的 Property Decorator 用于标示输入属性的存在(及其属性名的配置)。如无特殊说明,下文中的 Decorator Factory 均从 `@angular/core` 引入。 185 | 186 | 这样我们为 `DataBindingComponent` 定义了一个 **输入属性(Input Property)**,这时如果我们刷新浏览器,会发现我们并没有看到任何额外内容,因为没有任何地方真的传递了 `content` 的内容。 187 | 188 | 为此我们修改 `app.component.html`,增加 `content` 这个实际上作为 Property 的 Attribute: 189 | 190 | ```html 191 | 192 | ``` 193 | 194 | 接着就能在浏览器中重新观察到我们的列表了。 195 | 196 | 需要注意的是,这里因为绑定的内容就是 HTML Attribute Value 的字面值,所以并没有使用方括号语法。 197 | 198 | 如果使用方括号的话,就可以写成: 199 | 200 | ```html 201 | 202 | ``` 203 | 204 | 因为这时的 HTML Attribute Value 是作为一个表达式,所以只有引号内的部分才是字符串。 205 | 206 | 我们可以认为: 207 | 208 | ```html 209 | 210 | ``` 211 | 212 | 等价于: 213 | 214 | ```html 215 | 216 | ``` 217 | 218 | 因此对于本身可接受字符串类型的输入属性,我们可以不使用方括号进行绑定,例如常见的 [`RouterLink`](https://angular.io/api/router/RouterLink) 等。 219 | 220 | 实际上,对于 Angular Directive 的输入属性,我们也能够自定义外部使用时的 Property Name。例如,如果将 `data-binding.component.ts` 再次调整为: 221 | 222 | ```typescript 223 | /* ... */ 224 | export class DataBindingComponent { 225 | @Input('foo-bar') 226 | content: string 227 | /* ... */ 228 | } 229 | ``` 230 | 231 | 那么 `content` 这个 Property 所对应的 HTML Attribute 就是 `foo-bar` 了,所以我们同样需要修改 `app.component.html` 为: 232 | 233 | ```html 234 | 235 | ``` 236 | 237 | 以保证名称相对应。 238 | 239 | 目前为止,在 `app.component.html` 中,使用的 `content` 和 `foo-bar` 这两个 HTML Attribute 都是作为 `DataBindingComponent` 这个 Directive 的 Property。不过,我们也可以仅仅作为 Attribute 使用,修改 `data-binding.component.ts` 为: 240 | 241 | ```typescript 242 | /* ... */ 243 | export class DataBindingComponent { 244 | content: string 245 | 246 | constructor(@Attribute('foo-bar') fooBar: string) { 247 | this.content = fooBar 248 | } 249 | /* ... */ 250 | } 251 | ``` 252 | 253 | 我们可以发现应用仍然是正常工作的,不过我们这里用的是注入的 Attribute 而非绑定的 Property,所以外部模版中也无法使用 `[foo-bar]="..."` 的形式。另外,对于 Attribute 而言,值只能是字符串而不能是别的类型。 254 | 255 | ### 特性绑定/Attribute Binding 256 | 257 | *翻译 Property 和 Attribute 是一件很麻烦的事情,这里参照微软(Microsoft)的规范,将 Property 译为「属性」,将 Attribute 译为「特性」。* 258 | 259 | 上面我们介绍了属性(Property)的动态绑定方式,那如果我们需要动态绑定一个特性(Attribute)该怎么办呢? 260 | 261 | 一种办法是直接使用插值语法,这一点在上面已经介绍过,不过很多时候并不直观(也不美观)。 262 | 263 | 事实上,Angular 也允许我们直接将表达式绑定到特性上,仍然使用的方括号语法,不过需要一个额外的 `attr.` 前缀。 264 | 265 | 所以,我们能够将之前的用来绑定 `` 对应 URL 的 `[src]` 直接修改为 `[attr.src]`,之前已经提到过 `src` 的 Property Name 和 Attribute Name 相同,而且不仅如此,其接收的内容也依然相同。 266 | 267 | 但如果我们妄图通过这样来的形式来绑定 `foo-bar` 的话,形如: 268 | 269 | ```html 270 | 271 | ``` 272 | 273 | 会发现这样并不能成功,这是由于依赖注入是一次性的,从而不受动态绑定内容的影响。 274 | 275 | 如果我们需要获取动态绑定的 Attribute,只能够使用 `ElementRef` 来动态获取,并不方便。所以对于指令间的交互,应当尽可能使用 Property。 276 | 277 | ### 类绑定/Class Binding 278 | 279 | 与特性绑定类似,不过使用的前缀不再是 `attr.` 而是 `class.`,于是我们可以绑定某个特定 CSS Class 的存在与否,例如: 280 | 281 | ```html 282 |

283 | data-binding works! 284 |

285 | ``` 286 | 287 | 在审查元素中我们能够看到 `

` 元素上的 class 为 `foo baz`。 288 | 289 | ### 样式绑定/Style Binding 290 | 291 | 与特性绑定类似,不过使用的前缀不再是 `attr.` 而是 `style.`,于是我们可以绑定某个特定 CSS Class 的存在与否,例如: 292 | 293 | ```html 294 | 295 | ``` 296 | 297 | 这样就能够得到 style 为 `margin: 5px;`。 298 | 299 | 不过我们还可以进一步简化,直接将单位提前到 target 部分(如果有的情况下): 300 | 301 | ```html 302 | 303 | ``` 304 | 305 | 这样,我们就可以直接绑定数值而非字符串(如果本身是纯数值的情况下)。 306 | 307 | ### 事件绑定/Event Binding 308 | 309 | *事件监听是否算作 Binding 在不同的领域中略有差异,本文中为了简化概念将此作为一种绑定类型。* 310 | 311 | 除了属性绑定外,还有一个很方便的语法称为 **事件绑定(Event Binding)**,使用圆括号 `()`或者 `on-` 前缀[^8]定义,我们可以为我们的图片绑定 `click` 事件: 312 | 313 | ```html 314 | 315 | ``` 316 | 317 | 或者 318 | 319 | ```html 320 | 321 | ``` 322 | 323 | 事件绑定中的执行环境被称为 **模版语句(Template Statement)**,相比于模版表达式而言,允许了副作用的存在,例如我们这里使用的赋值操作。 324 | 325 | 当然,由于我无法得知之后的用户都用的什么头像,所以如果出现不适宜的内容也与本文无关哦。 326 | 327 | 实际上,模版中的事件绑定不仅能够针对该元素本身,也能指定其它的 target,例如,在 `

` 元素上增加一个全局的 `scroll` 事件监听: 328 | 329 | ```html 330 |
331 | ``` 332 | 333 | 这里我们制造了一个(假的)无限滚动列表,每当滚动发生时,就对内容进行扩展。通过指定 `window` 这个 target,即使滚动事件不发生在该元素上,也能够监听到该事件的发生并进行处理。 334 | 335 | 这里的 target 仅限于针对 DOM 事件,也只有三个可能的选项:`window`,`document` 和 `body`。不过在使用时应当慎重,应当保证事件处理过程仅直接影响该模版部分的内容,否则可能会破坏可维护性。 336 | 337 | ### 双向绑定/Two-way Data Binding 338 | 339 | 不论是属性绑定还是事件绑定,数据[^9]的传递都是单向的。而有时候,为了使用上的便利,我们会把属性绑定和事件绑定这两者使用语法糖来结合,而结合后的语法,就是两者的语法之和。我们可以在图片之前增加一个 `input` 元素: 340 | 341 | ```html 342 | 343 |
344 | ``` 345 | 346 | 或 347 | 348 | ```html 349 | 350 |
351 | ``` 352 | 353 | 不过,这时候我们会看到控制台的报错: 354 | 355 | ```text 356 | Can't bind to 'ngModel' since it isn't a known property of 'input'. 357 | ``` 358 | 359 | 这是因为我们并没有任何指令定义了 `ngModel` 这个属性输入。 360 | 361 | 其实这里也是和 AngularJS 相比明显改善了的地方,在 Angular 模版中,属性绑定是 **强类型** 的,即所有指令都会定义自己所需的属性输入,而如果我们在某个地方使用了任何指令都没有定义过的属性,Angular 就能在编译时发现错误,有效地缩短了反馈时间,提高开发效率。 362 | 363 | 可是问题来了,为什么 Angular 自带的 `ngModel` 会找不到呢? 364 | 365 | 如果我们浏览 Angular 的 [API](https://angular.io/api),就能发现 [NgModel](https://angular.io/api/forms/NgModel) 处于 `forms` 这个 package 当中,并由 [FormsModule](https://angular.io/api/forms/FormsModule) 所声明。 366 | 367 | 为此,如果我们需要使用 `ngModel` 的话,就必须要导入 `FormsModule` 这个 NgModule。为此,在 `app.module.ts` 中,添加相应的内容: 368 | 369 | ```typescript 370 | /* ... */ 371 | import { FormsModule } from '@angular/forms' 372 | 373 | /* ... */ 374 | 375 | @NgModule({ 376 | /* ... */ 377 | imports: [ 378 | /* ... */ 379 | FormsModule 380 | ], 381 | /* ... */ 382 | }) 383 | export class AppModule { } 384 | ``` 385 | 386 | 现在我们就能看到页面中的输入框,其中的默认值就是我们初始化的 `avatarId` 的值。 387 | 388 | 如果我们修改该数值,就能看到图片发生变化。反过来,如果我们点击图片,也会看到该数值发生变化。 389 | 390 | 为此这种形式就叫做 **双向绑定**。 391 | 392 | 所以,刚刚究竟发生了些什么呢? 393 | 394 | 对于双向绑定的语法糖,编译器会首先将其拆分成独立的属性绑定和事件绑定,其中的事件名增加 `Change` 后缀,也就是说: 395 | 396 | ```html 397 | 398 | ``` 399 | 400 | 等价于: 401 | 402 | ```html 403 | 404 | ``` 405 | 406 | 所以说,双向绑定中的表达式必须可以同时作为左值和右值,实际可用的运算符非常有限,仅限于 [属性访问](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Property_accessors)。 407 | 408 | 由于双向绑定只是一个普通的语法糖,我们随时可以新建一个支持双向绑定的指令,但是出于工程上的考虑,大部分情况下往往会让自定义指令来支持 `ngModel`,从而复用相应逻辑,这部分内容会在表单部分覆盖。 409 | 410 | 事件绑定的模版语句中,永远会有一个 `$event` 变量可用。对于 DOM 事件而言,这就是事件本身;对于 `@Output()` 定义的事件绑定,则为相应 `EventEmitter` 或 `Subject` 的泛型类型的实例。 411 | 412 | 因此,我们所使用的 `[(ngModel)]` 就被处理成: 413 | 414 | ```html 415 | 416 | ``` 417 | 418 | 所以我们也可以仅仅使用 `[ngModel]` 而不带 `(ngModelChange)` 来实现单向的属性绑定。 419 | 420 | `ngModelChange` 是由 [`NgModel`](https://angular.io/api/forms/NgModel) 这个 Directive 所定义的输出属性,对应的 `$event` 就是更新后的值[^10]。 421 | 422 | ### 宿主绑定/Host Binding 423 | 424 | 这里的类别划分方式与之前的内容并不相同,准确地说,这里的 **宿主绑定(Host Binding)** 与之前所有绑定类型的总和相并列,因为之前的绑定都可以被列入 **模版绑定(Template Binding)**。也就是说,这里的绑定方式不借助于模版。 425 | 426 | 在 `data-binding.component.ts` 中增加一个 `foo` 属性: 427 | 428 | ```typescript 429 | /* ... */ 430 | export class DataBindingComponent { 431 | /* ... */ 432 | @HostBinding('class.foo') 433 | foo = true 434 | } 435 | ``` 436 | 437 | 接着在审查元素中,就能看到在 `` 元素上增加了 `foo` 这个 CSS Class。不过,这里并不是通过外部组件绑定的,而是通过该组件来绑定到自身的宿主元素上。 438 | 439 | 能使用的绑定类型也与之前的方式相似: 440 | 441 | + 不使用前缀时绑定到 DOM Property; 442 | + 使用 `attr.` 前缀时绑定到 HTML Attribute; 443 | + 使用 `class.` 前缀时绑定到 CSS Class; 444 | + 使用 `style.` 前缀时绑定到 Inline CSS Style。 445 | 446 | 除了绑定值外,当然也能够绑定事件,通过 `@HostListener()` 来实现。再次增加一个方法: 447 | 448 | ```typescript 449 | /* ... */ 450 | export class DataBindingComponent { 451 | /* ... */ 452 | @HostListener('mouseover') 453 | onMouseOver(): void { 454 | this.avatarId = Math.floor(Math.random() * 1e6) 455 | } 456 | } 457 | ``` 458 | 459 | 这样就能监听宿主元素的 `mouseover` 事件了。类似的,我们也可以使用 `@HostListener()` 来监听全局事件。 460 | 461 | ## 可能的疑惑 462 | 463 | #### 为什么 AngularJS 中不适合在 src 属性中插值? 464 | 465 | AngularJS 使用的是基于 DOM 的模版,也就是说,模版会先被浏览器渲染成 DOM 树,之后 AngularJS 通过 DOM API 来寻找使用了插值的地方并进行修改。而浏览器对 img 内容的获取是在页面渲染过程中自动进行的,所以在这种模式下,会产生对包含模版内容的错误地址(例如 https://avatars0.githubusercontent.com/u/{{avatarId}}?v=3&s=460 )进行请求,从而引发不必要的错误。 466 | 467 | #### 为什么属性绑定和事件绑定也叫输入绑定和输出绑定? 468 | 469 | 我们知道(不知道的下一节也会知道),指令的属性绑定和事件绑定分别使用 `@Input()` 和 `@Output()`(或元数据中的 `host` 属性)来定义,所以可以简单地意会为输入和输出。不过从功能上,属性绑定也是完全可以实现输出功能的,例如主动传入一个 `EventEmitter` 作为输入。 470 | 471 | 但是问题来了,这是只表明了 **输入** 和 **输出**,那么 **属性** 和 **事件** 的概念是哪里来的呢? 472 | 473 | 事实上,在现有的语法之前,Angular 使用过 `@Property()` 和 `@Event()` 作为装饰器的名称,之后才 [改为](https://github.com/angular/angular/pull/4435) 现有语法的,而且在语法的最终版本上也经过了很多激烈的讨论。除此之外,称做属性绑定和事件绑定也符合语义上的行为,便于理解。 474 | 475 | #### 属性绑定和方括号语法之间有什么关系? 476 | 477 | 概率上的相关性。属性绑定不一定使用方括号语法(或 `bind-` 前缀,下同),方括号语法也不一定是属性绑定。 478 | 479 | 不过在日常交流时,很难要求用户保持绝对的严谨性,因此在其它地方,很可能会用属性绑定来指代方括号语法或者反之。 480 | 481 | #### 方括号绑定的 target 是 Property 还是 Attribute? 482 | 483 | 在不使用 `attr.` 的情况下,绑定的都是 Property,不论是对 DOM Element 还是 Angular Directive。虽然话是这么说,不过由于太多用户分不清 HTML Attribute 和 DOM Property,也有个别特例对用户妥协了,比如 `htmlFor`,所以现在事实上也可以使用 `