预 (AoT) 编译器

这个烹饪指南描述如何通过在构建过程中进行预编译(Ahead of Time - AOT)来从根本上提升性能。

目录

Contents

概览

Angular应用主要包含组件和它们的HTML模板。 在浏览器可以渲染应用之前,组件和模板必须要被Angular编译器转换为可以执行的JavaScript。

观看编译器作者Tobias Bosch在AngularConnect 2016大会里,对Angular编译器的演讲。

你可以在浏览器中使用即时编译器(Just-in-Time - JIT)在运行期间编译该应用,也就是在应用加载时。 这是本文档中展示过的标准开发方式。 它很不错,但是有自己的缺点。

JIT编译导致运行期间的性能损耗。 由于需要在浏览器中执行这个编译过程,视图需要花更长时间才能渲染出来。 由于应用包含了Angular编译器以及大量实际上并不需要的库代码,所以文件体积也会更大。 更大的应用需要更长的时间进行传输,加载也更慢。

编译可以发现一些组件模板绑定错误。JIT编译在运行时才揭露它们,那样有点太晚了。

预编译(AOT)会在构建时编译,这样可以在早期截获模板错误,提高应用性能。

预编译(AOT) vs 即时编译(JIT)

事实上只有一个Angular编译器,AOT和JIT之间的差别仅仅在于编译的时机和所用的工具。 使用AOT,编译器仅仅使用一组库在构建期间运行一次;使用JIT,编译器在每个用户的每次运行期间都要用不同的库运行一次。

为什么需要AOT编译?

渲染得更快

使用AOT,浏览器下载预编译版本的应用程序。 浏览器直接加载运行代码,所以它可以立即渲染该应用,而不用等应用完成首次编译。

需要的异步请求更少

编译器把外部HTML模板和CSS样式表内联到了该应用的JavaScript中。 消除了用来下载那些源文件的Ajax请求。

需要下载的Angular框架体积更小

如果应用已经编译过了,自然不需要再下载Angular编译器了。 该编译器差不多占了Angular自身体积的一半儿,所以,省略它可以显著减小应用的体积。

提早检测模板错误

AOT编译器在构建过程中检测和报告模板绑定错误,避免用户遇到这些错误。

更安全

AOT编译远在HTML模版和组件被服务到客户端之前,将它们编译到JavaScript文件。 没有模版可以阅读,没有高风险客户端HTML或JavaScript可利用,所以注入攻击的机会较少。

用AOT进行编译

AOT编译需要一些简单的准备步骤。我们先从搭建本地开发环境开始。 只要单独对app.component文件的类文件和HTML文件做少量修改就可以了。

<button (click)="toggleHeading()">Toggle Heading</button>
<h1 *ngIf="showHeading">Hello Angular</h1>

<h3>List of Heroes</h3>
<div *ngFor="let hero of heroes">{{hero}}</div>

用下列命令安装少量新的npm依赖:

npm install @angular/compiler-cli @angular/platform-server --save

你要用@angular/compiler-cli包中提供的ngc编译器来代替TypeScript编译器(tsc)。

ngc是一个tsc的高仿替代品,它们的配置方式几乎完全一样。

ngc需要自己的带有AOT专用设置的tsconfig.json。 把原始的tsconfig.json拷贝到一个名叫tsconfig-aot.json的文件中,然后像这样修改它:

tsconfig-aot.json

{
  "compilerOptions": {
    "target": "es5",
    "module": "es2015",
    "moduleResolution": "node",
    "sourceMap": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "lib": ["es2015", "dom"],
    "noImplicitAny": true,
    "suppressImplicitAnyIndexErrors": true
  },

  "files": [
    "src/app/app.module.ts",
    "src/main.ts"
  ],

  "angularCompilerOptions": {
   "genDir": "aot",
   "skipMetadataEmit" : true
 }
}

compilerOptions部分只修改了一个属性:**把module设置为es2015。 这一点非常重要,我们会在后面的摇树优化部分解释为什么。

ngc区真正新增的内容是底部的angularCompilerOptions。 它的genDir属性告诉编译器把编译结果保存在新的aot目录下。

"skipMetadataEmit" : true属性阻止编译器为编译后的应用生成元数据文件。 当输出成TypeScript文件时,元数据并不是必须的,因此不需要包含它们。

相对于组件的模板URL

AOT编译器要求@Component中的外部模板和CSS文件的URL是相对于组件的。 这意味着@Component.templateUrl的值是一个相对于组件类文件的URL值。 例如,'app.component.html' URL表示模板文件与它相应的app.component.ts文件放在一起。

而JIT应用的URL更灵活,固定写成相对于组件的URL的形式对AOT编译的兼容性也更好。

编译该应用

在命令行中执行下列命令,借助刚安装好的ngc编译器来启动AOT编译:

node_modules/.bin/ngc -p tsconfig-aot.json

Windows用户应该双引号ngc命令:

"node_modules/.bin/ngc" -p tsconfig-aot.json

ngc希望-p选项指向一个tsconfig.json文件,或者一个包含tsconfig.json文件的目录。

ngc完成时,会在aot目录下看到一组NgFactory文件(该目录是在tsconfig-aot.jsongenDir属性中指定的)。

这些工厂文件对于编译后的应用是必要的。 每个组件工厂都可以在运行时创建一个组件的实例,其中带有一个原始的类文件和一个用JavaScript表示的组件模板。 注意,原始的组件类依然是由所生成的这个工厂进行内部引用的。

如果你好奇,可以打开aot/app.component.ngfactory.ts来看看原始Angular模板语法被编译成TypeScript时的中间结果。

JIT编译器在内存中同样会生成这一堆NgFactory,但它们大部分是不可见的。 AOT编译器则会生成在单独的物理文件中。

不要编辑这些NgFactory!重新编译时会替换这些文件,你做的所有修改都会丢失。

引导

AOT也改变了应用的引导方式。

引导的方式从引导AppModule改成了引导生成的模块工厂:AppModuleNgFactory

复制一份main.ts并把它改名为main-jit.ts。 这就是JIT版本,先把它放在一边,我们稍后会用到它。

打开main.ts,并把它改成AOT编译。 从platformBrowserDynamic.bootstrap改成使用platformBrowser().bootstrapModuleFactory并把AppModuleNgFactory的AOT编译结果传给它。

这里是AOT版本main.ts中的引导过程,下一个是你所熟悉的JIT版本。

  1. import { platformBrowser } from '@angular/platform-browser';
  2. import { AppModuleNgFactory } from '../aot/src/app/app.module.ngfactory';
  3. console.log('Running AOT compiled');
  4. platformBrowser().bootstrapModuleFactory(AppModuleNgFactory);

确保用ngc进行重新编译

摇树优化(Tree shaking)

AOT编译为接下来通过一个叫做摇树优化的过程做好了准备。 摇树优化器从上到下遍历依赖图谱,并且摇掉用不到的代码,这些代码就像是圣诞树中那些死掉的松针一样。

通过移除源码和库代码中用不到的部分,摇树优化可以大幅缩减应用的下载体积。 事实上,在小型应用中大部分的缩减都是因为筛掉了那些没用到的Angular特性。

例如,这个演示程序中没有用到@angular/forms库中的任何东西,那么也就没有理由去下载这些与表单有关的Angular代码了。摇树优化可以帮你确保这一点。

摇树优化和AOT编译是单独的步骤。 摇树优化仅仅针对JavaScript代码。 AOT编译会把应用中的大部分都转换成JavaScript,这种转换会让应用更容易被“摇树优化”。

Rollup

这个烹饪宝典中用来示范的摇树优化工具是Rollup

Rollup会通过跟踪importexport语句来对本应用进行静态分析。 它所生成的最终代码中会排除那些被导出过但又从未被导入的代码。

Rollup只能对ES2015模块摇树,因为那里有importexport语句。

回忆一下,tsconfig-aot.json中曾配置为生成ES2015的模块。 代码本身是否用到了ES2015语法(例如classconst)并不重要,重要的是这些代码使用的应该是importexport语句,而不是require语句。

通过下列命令安装Rollup依赖:

npm install rollup rollup-plugin-node-resolve rollup-plugin-commonjs rollup-plugin-uglify --save-dev

接下来,在项目根目录新建一个配置文件(rollup-config.js),来告诉Rollup如何处理应用。 本烹饪书配置文件是这样的:

rollup-config.js

import rollup      from 'rollup'
import nodeResolve from 'rollup-plugin-node-resolve'
import commonjs    from 'rollup-plugin-commonjs';
import uglify      from 'rollup-plugin-uglify'

export default {
  entry: 'src/main.js',
  dest: 'src/build.js', // output a single application bundle
  sourceMap: false,
  format: 'iife',
  onwarn: function(warning) {
    // Skip certain warnings

    // should intercept ... but doesn't in some rollup versions
    if ( warning.code === 'THIS_IS_UNDEFINED' ) { return; }

    // console.warn everything else
    console.warn( warning.message );
  },
  plugins: [
      nodeResolve({jsnext: true, module: true}),
      commonjs({
        include: 'node_modules/rxjs/**',
      }),
      uglify()
  ]
}

这个配置文件告诉Rollup,该应用的入口点是app/main.jsdest属性告诉Rollup要在dist目录下创建一个名叫build.js的捆文件。 它覆盖了默认的onwarn方法,以便忽略由于AOT编译器使用this关键字导致的噪音消息。

下一节我们将深入讲解插件。

Rollup插件

这些可选插件过滤并转换Rollup的输入和输出。

RxJS

Rollup期望应用的源码使用ES2015模块。 但并不是所有外部依赖都发布成了ES2015模块。 事实上,大多数都不是。它们大多数都发布成了CommonJS模块。

可观察对象库RxJS是Angular所依赖的基础之一,它就是发布成了ES5 JavaScript的CommonJS模块。

幸运的是,有一个Rollup插件,它会修改RxJS,以使用Rollup所需的ESimportexport语句。 然后Rollup就可以把该应用中用到的那部分RxJS代码留在“捆”文件中了。 它的用法很简单。把下列代码添加到rollup-config.jsplugins数组中:

rollup-config.js (CommonJs to ES2015 Plugin)

commonjs({
  include: 'node_modules/rxjs/**',
}),

最小化

Rollup做摇树优化时会大幅减小代码体积。最小化过程则会让它更小。 本烹饪宝典依赖于Rollup插件uglify来最小化并混淆代码。 把下列代码添加到plugins数组中:

rollup-config.js (CommonJs to ES2015 Plugin)

uglify()

在生产环境中,我们还应该打开Web服务器的gzip特性来把代码压缩得更小。

运行Rollup

通过下列命令执行Rollup过程:

node_modules/.bin/rollup -c rollup-config.js

Windows用户要把rollup命令放进双引号中:

"node_modules/.bin/rollup"  -c rollup-config.js

加载捆文件

加载所生成的应用捆文件,并不需要使用像SystemJS这样的模块加载器。 移除与SystemJS有关的那些脚本吧。 改用<script>标签来加载这些捆文件:

index.html (load bundle)

<script src="build.js"></script>

启动应用服务器

你需要一个Web服务器来作为应用的宿主。 像与文档中其它部分一样,用lite-server吧:

npm run lite

启动了服务器、打开浏览器,应用就出现了。

AOT快速起步源代码

下面是相关源代码:

  1. <button (click)="toggleHeading()">Toggle Heading</button>
  2. <h1 *ngIf="showHeading">Hello Angular</h1>
  3. <h3>List of Heroes</h3>
  4. <div *ngFor="let hero of heroes">{{hero}}</div>

工作流与便利脚本

每当修改时,我们都将重新构建应用的AOT版本。 那些npm命令太长,很难记。

把下列npm便利脚本添加到package.json中,以便用一条命令就可以完成编译和Rollup打包工作。

package.json (build:aot convenience script)

  1. {
  2. "scripts": {
  3. "build:aot": "ngc -p tsconfig-aot.json && rollup -c rollup-config.js"
  4. }
  5. }

打开终端窗口,并试一下。

npm run build:aot

先用JIT开发,再AOT发布

AOT编译和Rollup打包加起来要花好几秒钟。 用SystemJS和JIT可以让开发期间的迭代更快一点。 同一套源码可以用这两种方式构建。下面是方法之一:

src/index-jit.html (SystemJS scripts)

<script src="node_modules/systemjs/dist/system.src.js"></script>
<script src="systemjs.config.js"></script>
<script>
  System.import('main-jit.js').catch(function(err){ console.error(err); });
</script>

注意,这里稍微修改了一下system.import,现在它指向了src/app/main-jit。 这就是我们以前预留的JIT版本的引导文件。

打开另一个终端窗口,并输入npm start

npm start

它会使用JIT方式编译本应用,并启动服务器。 服务器仍然加载的是AOT版的index.html,我们可以在浏览器的控制台中确认这一点。 在地址栏中改为index-jit.html,它就会加载JIT版,这同样可以在浏览器控制台中确认。

照常开发。服务器和TypeScript编译器都处于“监听模式”,因此我们的修改都可以立刻反映到浏览器中。

要对比AOT版的变化,可以切换到原来的终端窗口中,并重新运行npm run build:aot。 结束时,回到浏览器中,并用浏览器的后退按钮回到默认index.html中的AOT版本。

现在,我们就可以同时进行JIT和AOT开发了。

英雄指南

上面的例子是《快速起步》应用的一个简单的变体。 在本节中,你将在一个更多内容的应用 - 英雄指南上使用从AOT编译和摇树优化学到的知识。

开发器使用JIT, 产品期使用AOT

目前,AOT编译和摇树优化对开发来说,占用的时间太多了。这将在未来得到改变。 当前的最佳实践是在开发器使用JIT编译,然后在发布产品前切换到AOT编译。

幸运的是,如果你处理了几个关键不同点,源代码可以在没有任何变化时,采取两种方式的任何一种都能编译。

index.html

JIT和AOT应用的设置和加载非常不一样,因此它们需要各自的index.html文件。

下面是它们的比较:

  1. <!DOCTYPE html>
  2. <html>
  3. <head>
  4. <base href="/">
  5. <title>Angular Tour of Heroes</title>
  6. <meta name="viewport" content="width=device-width, initial-scale=1">
  7. <link rel="stylesheet" href="styles.css">
  8. <script src="shim.min.js"></script>
  9. <script src="zone.min.js"></script>
  10. </head>
  11. <body>
  12. <my-app>Loading...</my-app>
  13. </body>
  14. <script src="dist/build.js"></script>
  15. </html>

JIT版本依靠SystemJS来加载单个模块,并需要reflect-metadata垫片。 所以它们出现在它的index.html中。

The AOT version loads the entire application in a single script, aot/dist/build.js. It does not need SystemJS, so that script is absent from its index.html

main.ts

AOT版本用一个单独的脚本来加载整个应用 - aot/dist/build.js。它不需要SystemJSreflect-metadata垫片,所以它们不会出现在index.html中。

  1. import { platformBrowser } from '@angular/platform-browser';
  2. import { AppModuleNgFactory } from '../aot/src/app/app.module.ngfactory';
  3. platformBrowser().bootstrapModuleFactory(AppModuleNgFactory);

TypeScript配置

JIT编译的应用编译为commonjs模块。 AOT编译的应用编译为ES2015/ES6模块,用来支持摇树优化。 而且AOT需要它自己的TypeScript配置设置。

你将需要单独的TypeScript配置文件,像这些:

  1. {
  2. "compilerOptions": {
  3. "target": "es5",
  4. "module": "es2015",
  5. "moduleResolution": "node",
  6. "sourceMap": true,
  7. "emitDecoratorMetadata": true,
  8. "experimentalDecorators": true,
  9. "lib": ["es2015", "dom"],
  10. "noImplicitAny": true,
  11. "suppressImplicitAnyIndexErrors": true,
  12. "typeRoots": [
  13. "../../node_modules/@types/"
  14. ]
  15. },
  16. "files": [
  17. "src/app/app.module.ts",
  18. "src/main-aot.ts"
  19. ],
  20. "angularCompilerOptions": {
  21. "genDir": "aot",
  22. "skipMetadataEmit" : true
  23. }
  24. }
@Types和node模块

这个特定的示例项目的文件结构中,node_modules文件恰好比项目根目录高两级。 因此,"typeRoots"必须设置为"../../node_modules/@types/"

在一个更典型的项目中,node_modules位于tsconfig-aot.json同级, 这时"typeRoots"应设置为"node_modules/@types/"。 编辑你的tsconfig-aot.json,使之适合项目的文件结构。

摇树优化

Rollup和以前一样,仍然进行摇树优化。

rollup-config.js

import rollup      from 'rollup'
import nodeResolve from 'rollup-plugin-node-resolve'
import commonjs    from 'rollup-plugin-commonjs';
import uglify      from 'rollup-plugin-uglify'

//paths are relative to the execution path
export default {
  entry: 'src/main-aot.js',
  dest: 'aot/dist/build.js', // output a single application bundle
  sourceMap: true,
  sourceMapFile: 'aot/dist/build.js.map',
  format: 'iife',
  onwarn: function(warning) {
    // Skip certain warnings

    // should intercept ... but doesn't in some rollup versions
    if ( warning.code === 'THIS_IS_UNDEFINED' ) { return; }

    // console.warn everything else
    console.warn( warning.message );
  },
  plugins: [
    nodeResolve({jsnext: true, module: true}),
    commonjs({
      include: ['node_modules/rxjs/**']
    }),
    uglify()
  ]
}

运行应用

面向大众的运行AOT构建的英雄指南应用的说明还没有准备好。

下面的说明假设你克隆了angular.io Github库,并按照该库的README.md准备了开发环境。

英雄指南源代码在public/docs/_examples/toh-6/ts目录。

和其他JIT例子一样,使用npm start命令,运行JIT编译的应用:

AOT编译假设上面介绍的一些支持文件都以准备好。

  1. <!DOCTYPE html>
  2. <html>
  3. <head>
  4. <base href="/">
  5. <title>Angular Tour of Heroes</title>
  6. <meta name="viewport" content="width=device-width, initial-scale=1">
  7. <link rel="stylesheet" href="styles.css">
  8. <!-- Polyfills -->
  9. <script src="node_modules/core-js/client/shim.min.js"></script>
  10. <script src="node_modules/zone.js/dist/zone.js"></script>
  11. <script src="node_modules/systemjs/dist/system.src.js"></script>
  12. <script src="systemjs.config.js"></script>
  13. <script>
  14. System.import('main.js').catch(function(err){ console.error(err); });
  15. </script>
  16. </head>
  17. <body>
  18. <my-app>Loading...</my-app>
  19. </body>
  20. </html>

使用下面的npm脚本,扩展package.json文件的scripts部分:

package.json (convenience scripts)

  1. {
  2. "scripts": {
  3. "build:aot": "ngc -p tsconfig-aot.json && rollup -c rollup-config.js",
  4. "serve:aot": "lite-server -c bs-config.aot.json"
  5. }
  6. }

使用下面的node脚本,拷贝AOT发布文件到/aot/目录:

node copy-dist-files

直到zone.js或者支持老版本浏览器的core-js垫片有更新,你不需要再这样做。

现在AOT编译应用,并使用lite服务器启动它:

npm run build:aot && npm run serve:aot

检查包裹

看看Rollup之后生成的JavaScript包,非常神奇。 代码已经被最小化,所以你不会从中直接学到任何知识。 但是source-map-explorer 工具非常有用。

安装:

npm install source-map-explorer --save-dev

运行下面的命令来生成源映射。

node_modules/.bin/source-map-explorer aot/dist/build.js

source-map-explorer分析从包生成的源映射,并画出一个依赖地图,显示包中包含哪些应用程序和Angular模块和类。

下面是英雄指南的地图:

TOH-6-bundle