十、用于生产的捆绑包

在将 JS 应用部署到生产环境中时,捆绑是一种重要的性能实践。通过将资源(主要是 JS 代码、HTML 模板和 CSS 表)合并到一个文件中,我们可以大大减少浏览器为应用服务而进行的 HTTP 调用的数量。

CLI 始终绑定其运行的应用,即使在开发环境中也是如此。这使得将应用部署到服务器非常简单;这只是一个构建它然后复制一堆文件的问题。

但随之而来的是版本控制问题。在部署新版本的应用时,如果捆绑包保持相同的名称,则缓存的捆绑包可能无法刷新,从而导致用户运行过时版本的应用。我们如何处理这个问题?

在本章中,我们将了解如何定制联系人管理应用的绑定。我们还将了解如何利用 CLI 的修订功能对捆绑包进行版本化,以便尽可能从 HTTP 缓存中获益。最后,为了便于部署,我们将向项目中添加一个新的构建任务。

配置捆绑包

默认情况下,使用 CLI 创建的项目包含两个捆绑包:第一个名为vendor-bundle.js,包含应用使用的所有外部库,第二个名为app-bundle.js,包含应用本身。

捆绑包在aurelia_project/aurelia.json文件中的 build 部分下进行配置。以下是它在典型应用中的外观:

"bundles": [ 
  { 
    "name": "app-bundle.js", 
    "source": [ 
      "[**/*.js]", 
      "**/*.{css,html}" 
    ] 
  }, 
  { 
    "name": "vendor-bundle.js", 
    "prepend": [ 
      "node_modules/bluebird/js/browser/bluebird.core.js", 
      "scripts/require.js" 
    ], 
    "dependencies": [ 
      "aurelia-binding", 
      "aurelia-bootstrapper", 
      "aurelia-dependency-injection", 
      "aurelia-framework", 
      //Omitted snippet... 
    ] 
  } 
] 

每个捆绑包都有一个唯一的名称,并且必须定义其内容,这些内容可以来源于应用和外部依赖项。通常,app-bundle包含来自应用源的所有 JS、HTML 和 CSS,vendor-bundle包含外部依赖项。

这通常是中小型应用的最佳配置。通常不会经常更改的外部依赖项被分组在自己的包中,因此用户不必在每次发布应用的新版本时下载这些依赖项。在大多数情况下,他们只需下载新的app-bundle

将应用合并到单个捆绑包中

但是,如果出于某种原因,您希望应用能够包含在单个捆绑包中,包括应用本身及其依赖项,那么这样做相当容易。您只需定义一个包,其中包含应用源和外部依赖项:

下一节中的片段摘自该书资产的chapter-10/samples/app-single-bundle样本。

aurelia_project/aurelia.json

"bundles": [ 
  { 
    "name": "app-bundle.js", 
    "prepend": [ 
      "node_modules/bluebird/js/browser/bluebird.core.js", 
      "scripts/require.js" 
    ], 
    "source": [ 
      "[**/*.js]", 
      "**/*.{css,html}" 
    ], 
    "dependencies": [ 
      "aurelia-binding", 
      "aurelia-bootstrapper", 
      //Omitted snippet... 
    ] 
  } 
] 

由于 Aurelia 应用的入口点是aurelia-bootstrapper库,入口点包必须是包含bootstrapper的那个。默认情况下,这是vendor-bundle。如果您在此处更改入口点包,它将成为app-bundle;你需要改变一些事情。

首先,仍然在aurelia_project/aurelia.jsonbuild下,必须为新的入口点捆绑更改加载程序的configTarget属性:

aurelia_project/aurelia.json

"loader": { 
  "type": "require", 
  "configTarget": "app-bundle.js", 
  // Omitted snippet... 
}, 

此外,index.html的主script标记也必须引用新的入口点包:

index.html

<!-- Omitted snippet... --> 
<body aurelia-app="main"> 
  <script src="scripts\app-bundle.js" 
          data-main="aurelia-bootstrapper"></script> 
</body> 
<!-- Omitted snippet... --> 

如果此时运行应用,您将看到生成了单个捆绑包,并且浏览器在启动应用时仅加载此捆绑包。

将应用拆分为多个捆绑包

在某些场景中,将整个应用源放在一个app-bundle中是次优的。我们可以很容易地想象一个应用是建立在高度隔离的用户故事之上的。用户仅使用此应用的特定部分,具体取决于其角色。

这样的应用可以分成多个较小的捆绑包,每个捆绑包对应一个与角色相关的部分。这样,用户就不会为他们从未使用过的应用部分下载捆绑包。

以下章节中的片段摘自该书资产的chapter-10/samples/ app-with-home sample

让我们通过将应用的contacts功能移动到它自己的包中来尝试一下。为此,我们首先需要将contacts目录中的所有内容从app-bundle中排除:

aurelia_project/aurelia.json

{ 
  "name": "app-bundle.js", 
  "source": { 
    "include": [ 
      "[**/*.js]", 
      "**/*.{css,html}" 
    ], 
    "exclude": [ 
      "**/contacts/**/*" 
    ] 
  } 
} 

source属性支持一个全局模式数组,或者支持一个具有include和可选exclude属性的对象,这两个属性都应该包含一个全局模式数组。

在这里,我们只需将前面的source值向下移动到include属性,并添加一个与contacts目录中的所有内容匹配的exclude属性。

接下来,我们需要定义新的捆绑包:

aurelia_project/aurelia.json

{ 
  "name": "app-bundle.js", 
  //Omitted snippet... 
}, 
{ 
  "name": "contacts-bundle.js", 
  "source": [ 
    "[**/contacts/**/*.js]", 
    "**/contacts/**/*.{css,html}" 
  ] 
},

这个名为contacts-bundle.js的新包将包括contacts目录中的所有 JS、HTML 和 CSS 文件。

如果此时运行应用,首先应该看到scripts目录现在包含三个捆绑包:app-bundle.jscontacts-bundle.jsvendor-bundle.js。如果您在浏览器中打开应用并检查调试控制台,您应该看到在加载应用时,浏览器首先加载vendor-bundle,然后加载app-bundle,最后加载contacts-bundle

当主configure功能在应用启动过程中加载contacts功能时,加载contact-bundle。这是 Aurelia 特性的限制之一:很难将特性分离到一个不同的包中。事实上,功能的index文件及其所有依赖项都应该捆绑在app-bundle中。单独绑定它是没有用的,因为无论如何启动时都会加载另一个绑定。但是,功能中的所有其他内容都可以单独捆绑。

在我们的应用中,即使您进行了此更改,contacts-bundle仍会在应用启动时加载,因为app组件会自动将用户重定向到联系人默认路径,即联系人列表。

如果您在应用中添加一个主组件作为默认路由,并确保该主组件包含在app-bundle中,则您应该看到只有在导航到contacts-bundle时才会加载该contacts-bundle

版本控制包

默认情况下,捆绑包是使用静态名称生成的。这意味着已经缓存了捆绑包副本的浏览器无法知道其副本是否是新的。如果发布了应用的新版本,该怎么办?

解决此问题的一个(糟糕的)解决方案是将缓存持续时间设置为非常短的时间跨度,这会迫使所有用户频繁下载所有捆绑包,或者接受某些用户可能运行过时版本的应用这一事实,这意味着相应地管理与后端、web 服务等的兼容性。这似乎是一个伟大的噩梦食谱。

更好的解决方案是在每个包的名称中添加某种修订号,并将index.html离开的缓存时间设置为非常短的时间跨度,甚至完全禁用其缓存。由于index.html与捆绑包相比非常小,这是一个有趣的权衡,因为每次给定用户访问应用时,他都会下载index.html的新副本,而该副本又会引用捆绑包的最新版本。这意味着捆绑包可以永久缓存,因为给定捆绑包名称的内容永远不会更改。用户不会多次下载给定版本的捆绑包。

Aurelia CLI 通过向文件名添加后缀来支持捆绑包版本控制。此后缀是根据文件内容计算的哈希。默认情况下,版本控制处于禁用状态。要启用它,请打开aurelia_project/aurelia.json文件,并在build部分下设置optionsrev属性:

aurelia_project/aurelia.json

"options": { 
  "minify": "stage & prod", 
  "sourcemaps": "dev & stage", 
  "rev": "stage & prod" 
}, 

修订机制是基于每个环境启用的。通常,它将在暂存和生产中启用。但是,它不应该在开发环境中使用,因为当watch开关与au运行一起使用时,它不能很好地使用浏览器重新加载和包重建机制。此外,由于大多数开发人员在禁用缓存的浏览器中进行系统测试,因此它没有什么价值。

您还必须始终确保在aurelia_project/aurelia.jsonbuild下,targets中的第一个条目的index属性设置为index.html

aurelia_project/aurelia.json

"targets": [ 
  { 
    "id": "web", 
    "displayName": "Web", 
    "output": "scripts", 
    "index": "index.html" 
  } 
], 

这让 bundler 知道加载应用的 HTML 文件的名称,因此它可以更新加载入口点 bundle 的script标记。

现在,您可以通过在项目目录中打开控制台并运行以下命令来测试这一点:

> au build --env stage

命令完成后,您应该在scripts目录中看到捆绑包的名称中包含一个哈希。您应该看到类似于app-bundle-ea03d27d90.jsvendor-bundle-efd8bd9cd8.js的内容,可能有不同的散列。

此外,在index.html中,主体内的script标记的src属性现在应该引用文件名中包含哈希的vendor-bundle文件。

部署应用

此时,部署我们的应用相当容易。我们需要将以下文件复制到托管它的服务器:

  • index.html
  • favicon.ico
  • locales/
  • styles/
  • scripts/
  • node_modules/bootstrap/
  • node_modules/font-awesome/

现在,大多数项目使用某种软件工厂来构建和部署应用。当然,我们可以轻松地将该文件列表放在工厂的构建任务中。但是,这意味着每次向该列表添加文件或目录时,我们都需要更改构建任务。

在处理 Aurelia 项目时,我喜欢做的一件事是在aurelia_project/aurelia.json文件中创建一个新的deploy部分,我将其设置为与要包含在部署包中的文件相匹配的全局模式列表:

aurelia_project/aurelia.json

{ 
  //Omitted snippet... 
  "build": { 
    //Omitted snippet... 
  }, 
  "deploy": { 
    "sources": [ 
      "index.html", 
      "favicon.ico", 
      "locales/**/*", 
      "scripts/*-bundle*.{js,map}", 
      "node_modules/bootstrap/dist/**/*", 
      "node_modules/font-awesome/{css,fonts}/**/*" 
    ] 
  } 
} 

除此之外,我还通常在项目中创建一个deploy任务。此任务只是构建应用,然后将要部署的文件复制到目标目录,该目录作为参数传递给任务。

让我们首先创建任务定义:

aurelia_project/tasks/deploy.json

{ 
  "name": "deploy", 
  "description": "Builds, processes and deploy all application assets.", 
  "flags": [ 
    { 
      "name": "out", 
      "description": "Sets the output directory (required)", 
      "type": "string" 
    }, 
    { 
      "name": "env", 
      "description": "Sets the build environment (uses debug by default).", 
      "type": "string" 
    } 
  ] 
} 

接下来,我们创建一个copy任务,该任务将被deploy任务使用:

aurelia_project/tasks/copy.js

import gulp from 'gulp'; 
import {CLIOptions} from 'aurelia-cli'; 
import project from '../aurelia.json'; 

export default function copy() { 
  const output = CLIOptions.getFlagValue('out', 'o'); 
  if (!output) { 
    throw new Error('--out argument is required'); 
  } 

  return gulp.src(project.deploy.sources, { base: './' }) 
    .pipe(gulp.dest(output)); 
} 

此任务首先检索作为out参数传递的目标目录,如果省略,则会失败,然后使用aurelia_project/aurelia.json中新deploy部分的 glob 模式列表,并将每个匹配文件复制到提供的目标目录。

最后,我们可以创建部署任务本身:

aurelia_project/tasks/deploy.js

import gulp from 'gulp'; 
import build from './build'; 
import copy from './copy'; 

export default gulp.series( 
  build, 
  copy 
); 

此任务只是按顺序执行buildcopy。我们甚至可以在buildcopy之间运行单元测试任务。

gulp任务大大简化了软件工厂中的构建任务。典型的软件工厂构建过程将首先从版本控制中检出代码,然后运行以下命令:

> npm install
> au deploy --env $(env) --out $(build-artifacts)

最后,它会将$(构建工件)下的所有内容复制到 web 服务器。

在此场景中,$(env)$(build-artifacts)是某种环境或系统变量。第一个包含完成构建的环境,例如stageprod,而第二个包含一些临时文件夹,要从中复制要部署到 web 服务器的工件。例如,它可能只是工作目录中的一个dist文件夹。

这个解决方案的优点之一是,与构建和部署应用相关的大多数细节现在都在项目本身中。软件工厂不依赖于来自应用源的文件结构和文件名,而只依赖于gulp任务。

总结

由于 CLI 一直在捆绑模式下运行应用,部署 Aurelia 应用一开始似乎非常简单。然后,您开始考虑 HTTP 缓存过期问题,事情变得有点复杂。

谢天谢地,CLI 已经提供了解决这些问题的工具。这与一些良好的实践一起,使得为真实世界准备应用变得非常简单。