阅读(2070) (0)

Angular CLI构建器

2022-07-12 17:23:22 更新

Angular CLI 构建器(Builder)

很多 Angular CLI 命令都要在你的代码上执行一些复杂的处理,比如风格检查(lint)构建或测试。这些命令会通过一个叫做建筑师(Architect)的内部工具来运行 CLI 构建器,而这些构建器会运用一些第三方工具来完成目标任务。

在 Angular 的版本 8 中,CLI 构建器的 API 是稳定的,想要通过添加或修改命令来自定义 Angular CLI 的开发人员可以使用它。比如,你可以提供一个构建器来执行全新的任务,或者更改一个现有命令所使用的第三方工具。

本文档介绍了 CLI 构建器是如何与工作区配置文件集成的,还展示了如何创建你自己的构建器。

可以在这个 GitHub 仓库中的例子中找到代码。

CLI 构建器

内部建筑师工具会把工作委托给名叫构建器的处理器函数。处理器函数接收两个参数:一组 ​options ​输入(JSON 对象)和一个 ​context​(​BuilderContext ​对象)。

这里对关注点的分离和原理图中是一样的,它也适用于其它要接触(touch)代码的 CLI 命令(比如 ​ng generate​)。

  • 此 ​options ​对象是由本 CLI 的用户提供的,而 ​context ​对象则由 CLI 构建器的 API 提供
  • 除了上下文信息之外,此 ​context ​对象(它是 ​BuilderContext ​的实例)还允许你访问调度方法 ​context.scheduleTarget()​。调度器会用指定的目标配置来执行构建器处理函数。

这个构建器处理函数可以是同步的(返回一个值)或异步的(返回一个 Promise),也可以监视并返回多个值(返回一个 Observable)。最终返回的值全都是 ​BuilderOutput ​类型的。该对象包含一个逻辑字段 ​success ​和一个可以包含错误信息的可选字段 ​error​。

Angular 提供了一些构建器,供 CLI 命令使用,如 ​ng build​ 和 ​ng test​ 等。这些内置 CLI 构建器的默认目标配置可以在工作区配置文件 ​angular.json​ 的 ​architect ​部分找到(并进行自定义)。可以通过创建自己的构建器来扩展和自定义 Angular,你可以使用 ​ng run​ CLI 命令来运行你自己的构建器。

构建器的项目结构

构建器位于一个 ​project ​文件夹中,该文件夹的结构类似于 Angular 工作区,包括位于顶层的全局配置文件,以及位于工作代码所在源文件夹中的更具体的配置。比如,​myBuilder ​文件夹中可能包含如下文件。

文件

用途

src/my-builder.ts

这个构建器定义的主要源码。

src/my-builder.spec.ts

测试的源码。

src/schema.json

构建器输入选项的定义。

builders.json

测试配置。

package.json

依赖包。参阅 https://docs.npmjs.com/files/package.json

tsconfig.json

TypeScript 配置文件

将此构建器发布到 ​npm​。如果你将其发布为 ​@example/my-builder​,请使用以下命令安装它。

npm install @example/my-builder

创建构建器

举个例子,让我们创建一个用来复制文件的构建器。要创建构建器,请使用 CLI 构建器函数 ​createBuilder()​,并返回一个 ​Promise<BuilderOutput>​ 对象。

import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect';
import { JsonObject } from '@angular-devkit/core';

interface Options extends JsonObject {
  source: string;
  destination: string;
}

export default createBuilder(copyFileBuilder);

async function copyFileBuilder(
  options: Options,
  context: BuilderContext,
): Promise<BuilderOutput> {
}

现在,让我们为它添加一些逻辑。下列代码会从用户选项中获取源文件和目标文件的路径,并且把源文件复制到目标文件(使用 NodeJS 内置函数copyFile()的 Promise 版本)。如果文件操作失败了,它会返回一个带有底层错误信息的 error 对象。

import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect';
import { JsonObject } from '@angular-devkit/core';
import { promises as fs } from 'fs';

interface Options extends JsonObject {
  source: string;
  destination: string;
}

export default createBuilder(copyFileBuilder);

async function copyFileBuilder(
  options: Options,
  context: BuilderContext,
): Promise<BuilderOutput> {
  try {
    await fs.copyFile(options.source, options.destination);
  } catch (err) {
    return {
      success: false,
      error: err.message,
    };
  }

  return { success: true };
}

处理输出

默认情况下,​copyFile()​ 方法不会往标准输出或标准错误中打印任何信息。如果发生了错误,可能很难理解构建器到底做了什么。可以使用 ​Logger ​API 来记录一些额外的信息,以提供额外的上下文。这样还能让构建器本身可以在一个单独的进程中执行,即使其标准输出和标准错误被停用了也无所谓(就像在 Electron 应用中一样)。

你可以从上下文中检索一个 ​Logger ​实例。

import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect';
import { JsonObject } from '@angular-devkit/core';
import { promises as fs } from 'fs';

interface Options extends JsonObject {
  source: string;
  destination: string;
}

export default createBuilder(copyFileBuilder);

async function copyFileBuilder(
  options: Options,
  context: BuilderContext,
): Promise<BuilderOutput> {
  try {
    await fs.copyFile(options.source, options.destination);
  } catch (err) {
    context.logger.error('Failed to copy file.');
    return {
      success: false,
      error: err.message,
    };
  }

  return { success: true };
}

进度和状态报告

CLI 构建器 API 包含一些进度报告和状态报告工具,可以为某些函数和接口提供提示信息。

要报告进度,请使用 ​context.reportProgress()​ 方法,它接受一个当前值(value)、一个(可选的)总值(total)和状态(status)字符串作为参数。总值可以是任意数字,比如,如果你知道有多少个文件需要处理,那么总值可能是这些文件的数量,而当前值是已处理过的数量。除非传入了新的字符串,否则这个状态字符串不会改变。

你可以看看 ​tslint ​构建器如何报告进度的例子

在我们的例子中,这种复制操作或者已完成或者正在执行,所以不需要进度报告,但是可以报告状态,以便调用此构建器的父构建器知道发生了什么。可以用 ​context.reportStatus()​ 方法生成一个任意长度的状态字符串。

注意:
无法保证长字符串会完全显示出来,可以裁剪它以适应界面显示。

传入一个空字符串可以移除状态。

import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect';
import { JsonObject } from '@angular-devkit/core';
import { promises as fs } from 'fs';

interface Options extends JsonObject {
  source: string;
  destination: string;
}

export default createBuilder(copyFileBuilder);

async function copyFileBuilder(
  options: Options,
  context: BuilderContext,
): Promise<BuilderOutput> {
  context.reportStatus(`Copying ${options.source} to ${options.destination}.`);
  try {
    await fs.copyFile(options.source, options.destination);
  } catch (err) {
    context.logger.error('Failed to copy file.');
    return {
      success: false,
      error: err.message,
    };
  }

  context.reportStatus('Done.');
  return { success: true };
}

构建器的输入

你可以通过 CLI 命令间接调用一个构建器,也可以直接用 Angular CLI 的 ​ng run​ 命令来调用它。无论哪种情况,你都必须提供所需的输入,但是可以用特定目标中预配置的值作为其默认值,然后指定一个预定义的、指定的配置进行覆盖,最后在命令行中进一步覆盖这些选项的值。

对输入的验证

你可以在该构建器的相关 JSON 模式中定义构建器都有哪些输入。建筑师工具会把解析后的输入值收集到一个 ​options ​对象中,并在将其传给构建器函数之前先根据这个模式验证它们的类型。(Schematics 库也对用户输入做了同样的验证)。

对于这个范例构建器,你希望 ​options ​的值是带有两个键的 ​JsonObject​:一个是 ​source​,一个是 ​destination​,它们都是字符串。

你可以提供如下模式来对这些值的类型进行验证。

{
  "$schema": "http://json-schema.org/schema",
  "type": "object",
  "properties": {
    "source": {
      "type": "string"
    },
    "destination": {
      "type": "string"
    }
  }
}

这是一个非常简单的例子,但这种模式验证也可以非常强大。欲知详情,参阅 JSON 模式网站

要把构建器的实现与它的模式和名称关联起来,你需要创建一个构建器定义文件,可以在 ​package.json​ 中指向该文件。

创建一个名为 ​builders.json​ 文件,它看起来像这样。

{
  "builders": {
    "copy": {
      "implementation": "./dist/my-builder.js",
      "schema": "./src/schema.json",
      "description": "Copies a file."
    }
  }
}

在 ​package.json​ 文件中,添加一个 ​builders ​键,告诉建筑师工具可以在哪里找到这个构建器定义文件。

{
  "name": "@example/copy-file",
  "version": "1.0.0",
  "description": "Builder for copying files",
  "builders": "builders.json",
  "dependencies": {
    "@angular-devkit/architect": "~0.1200.0",
    "@angular-devkit/core": "^12.0.0"
  }
}

现在,这个构建器的正式名字是 ​@example/copy-file:copy​。第一部分是包名(使用 node 方案进行解析),第二部分是构建器名称(使用 ​builders.json​ 文件进行解析)。

使用某个 ​options ​是非常简单的。在上一节,你就曾用过 ​options.source​ 和 ​options.destination​。

context.reportStatus(`Copying ${options.source} to ${options.destination}.`);
try {
  await fs.copyFile(options.source, options.destination);
} catch (err) {
  context.logger.error('Failed to copy file.');
  return {
    success: false,
    error: err.message,
  };
}

context.reportStatus('Done.');
return { success: true };

目标配置

构建器必须有一个已定义的目标,此目标会把构建器与特定的输入配置和项目关联起来。

目标是在 CLI 配置文件 ​angular.json​ 中定义的。目标用于指定要使用的构建器、默认的选项配置,以及指定的备用配置。建筑师工具使用目标定义来为一次特定的执行解析输入选项。

angular.json​ 文件中为每个项目都有一节配置,每个项目的 ​architect ​部分都会为 CLI 命令(比如 ​build​、​test ​和 ​lint​)配置构建器目标。默认情况下,​build ​命令会运行 ​@angular-devkit/build-angular:browser​ 构建器来执行 ​build ​任务,并传入 ​angular.json​ 中为 ​build ​目标指定的默认选项值。

{
  "myApp": {
    …
    "architect": {
      "build": {
        "builder": "@angular-devkit/build-angular:browser",
        "options": {
          "outputPath": "dist/myApp",
          "index": "src/index.html",
          …
        },
        "configurations": {
          "production": {
            "fileReplacements": [
              {
                "replace": "src/environments/environment.ts",
                "with": "src/environments/environment.prod.ts"
              }
            ],
            "optimization": true,
            "outputHashing": "all",
            …
          }
        }
      },
      …

该命令会给构建器传递 options 节中指定的一组默认选项。如果你传入了 --configuration=production 标志,它就会使用 production 备用配置中指定的值进行覆盖。可以在命令行中单独指定其它选项进行覆盖,还可以为 build 目标添加更多备用配置,以定义其它环境,比如 stage 或 qa

目标字符串

通用的 ​ng run​ CLI 命令将以下格式的目标字符串作为其第一个参数。

project:target[:configuration]

详情

项目(project)

与此目标关联的 Angular CLI 项目的名称。

目标

angular.json 文件 architect 下的指定构建器配置。

配置(configuration)

(可选)用于覆盖指定目标的具体配置名称,如 angular.json 文件中的定义。

如果你的构建器调用另一个构建器,它可能需要读取一个传入的目标字符串。可以使用 ​@angular-devkit/architect​ 中的工具函数 ​targetFromTargetString()​ 把这个字符串解析成一个对象。

调度并运行

建筑师会异步运行构建器。要调用某个构建器,就要在所有配置解析完成之后安排一个要运行的任务。

在调度器返回 ​BuilderRun ​控件对象之前,不会执行该构建器函数。CLI 通常会通过调用 ​context.scheduleTarget()​ 函数来调度任务,然后使用 ​angular.json​ 文件中的目标定义来解析输入选项。

建筑师会接受默认的选项对象来解析指定目标的输入选项,然后覆盖所用配置中的值(如果有的话),然后再从传给 ​context.scheduleTarget()​ 的覆盖对象中覆盖这些值。对于 Angular CLI,覆盖对象是从命令行参数中构建的。

建筑师会根据构建器的模式对生成的选项值进行验证。如果输入有效,建筑师会创建上下文并执行该构建器。

你还可以通过调用 ​context.scheduleBuilder()​ 从另一个构建器或测试中调用某个构建器。你可以直接把 ​options ​对象传给该方法,并且这些选项值会根据这个构建器的模式进行验证,而无需进一步调整。
只有 ​context.scheduleTarget()​ 方法来解析这些配置和并通过 ​angular.json​ 文件进行覆盖。

默认建筑师配置

让我们创建一个简单的 ​angular.json​ 文件,它会把目标配置放到上下文中。

你可以把这个构建器发布到 npm,并使用如下命令来安装它:

npm install @example/copy-file

如果用 ​ng new builder-test​ 创建一个新项目,那么生成的 ​angular.json​ 文件就是这样的,它只有默认的构建器参数。

{
  // …
  "projects": {
    // …
    "builder-test": {
      // …
      "architect": {
        // …
        "build": {
          "builder": "@angular-devkit/build-angular:browser",
          "options": {
            // … more options…
            "outputPath": "dist/builder-test",
            "index": "src/index.html",
            "main": "src/main.ts",
            "polyfills": "src/polyfills.ts",
            "tsConfig": "src/tsconfig.app.json"
          },
          "configurations": {
            "production": {
              // … more options…
              "optimization": true,
              "aot": true,
              "buildOptimizer": true
            }
          }
        }
      }
    }
  }
  // …
}

添加一个目标

添加一个新的目标,来运行我们的构建器以复制文件。该目标告诉构建器,复制 ​package.json​ 文件。

你需要更新 ​angular.json​ 文件,把这个构建器的目标添加到新项目的 ​architect ​部分。

  • 我们会为项目的 ​architect ​对象添加一个新的目标小节
  • 名为 ​copy-package​ 的目标使用了我们的构建器,它发布到了 ​@example/copy-file​。
  • 这个配置对象为我们定义的两个输入提供了默认值:​source​(你要复制的现有文件)和 ​destination​(你要复制到的路径)
  • 这些配置键都是可选的,但我们先不展开
{
  "projects": {
    "builder-test": {
      "architect": {
        "copy-package": {
          "builder": "@example/copy-file:copy",
          "options": {
            "source": "package.json",
            "destination": "package-copy.json"
          }
        },
        "build": {
          "builder": "@angular-devkit/build-angular:browser",
          "options": {
            "outputPath": "dist/builder-test",
            "index": "src/index.html",
            "main": "src/main.ts",
            "polyfills": "src/polyfills.ts",
            "tsConfig": "src/tsconfig.app.json"
          },
          "configurations": {
            "production": {
              "fileReplacements": [
                {
                  "replace": "src/environments/environment.ts",
                  "with": "src/environments/environment.prod.ts"
                }
              ],
              "optimization": true,
              "aot": true,
              "buildOptimizer": true
            }
          }
        }
      }
    }
  }
}

运行这个构建器

要想使用这个新目标的默认配置运行我们的构建器,请使用以下 CLI 命令。

ng run builder-test:copy-package

这将把 ​package.json​ 文件复制成 ​package-copy.json​。

你可以使用命令行参数来覆盖已配置的默认值。比如,要改用其它 ​destination ​值运行,请使用以下 CLI 命令。

ng run builder-test:copy-package --destination=package-other.json

这将把此文件复制为 ​package-other.json​ 而不再是 ​package-copy.json​。因为我们没有覆盖 source 选项,所以它仍然会从 ​package.json​ 文件复制(提供给该目标的默认值)。

测试一个构建器

对构建器进行集成测试,以便你可以使用建筑师的调度器来创建一个上下文,就像这个例子中一样。

  • 在构建器的源码目录下,你创建了一个新的测试文件 ​my-builder.spec.ts​。该代码创建了 ​JsonSchemaRegistry​(用于模式验证)、​TestingArchitectHost​(对 ​ArchitectHost ​的内存实现)和 ​Architect ​的新实例。
  • 我们紧挨着这个构建器的 ​package.json​ 文件添加了一个 ​builders.json​ 文件,并修改了 ​package.json​ 文件以指向它。

下面是运行此复制文件构建器的测试范例。该测试使用该构建器来复制 ​package.json​ 文件,并验证复制后的文件内容与源文件相同。

import { Architect } from '@angular-devkit/architect';
import { TestingArchitectHost } from '@angular-devkit/architect/testing';
import { schema } from '@angular-devkit/core';
import { promises as fs } from 'fs';

describe('Copy File Builder', () => {
  let architect: Architect;
  let architectHost: TestingArchitectHost;

  beforeEach(async () => {
    const registry = new schema.CoreSchemaRegistry();
    registry.addPostTransform(schema.transforms.addUndefinedDefaults);

    // TestingArchitectHost() takes workspace and current directories.
    // Since we don't use those, both are the same in this case.
    architectHost = new TestingArchitectHost(__dirname, __dirname);
    architect = new Architect(architectHost, registry);

    // This will either take a Node package name, or a path to the directory
    // for the package.json file.
    await architectHost.addBuilderFromPackage('..');
  });

  it('can copy files', async () => {
    // A "run" can have multiple outputs, and contains progress information.
    const run = await architect.scheduleBuilder('@example/copy-file:copy', {
      source: 'package.json',
      destination: 'package-copy.json',
    });

    // The "result" member (of type BuilderOutput) is the next output.
    const output = await run.result;

    // Stop the builder from running. This stops Architect from keeping
    // the builder-associated states in memory, since builders keep waiting
    // to be scheduled.
    await run.stop();

    // Expect that the copied file is the same as its source.
    const sourceContent = await fs.readFile('package.json', 'utf8');
    const destinationContent = await fs.readFile('package-copy.json', 'utf8');
    expect(destinationContent).toBe(sourceContent);
  });
});

在你的仓库中运行这个测试时,需要使用 ts-node 包。你可以把 ​index.spec.ts​ 重命名为 ​index.spec.js​ 来回避它。

监视(watch)模式

建筑师希望构建器运行一次(默认情况下)并返回。这种行为与那些需要监视文件更改的构建器(比如 Webpack)并不完全兼容。建筑师可以支持监视模式,但要注意一些问题。

  • 要在监视模式下使用,构建器处理函数应返回一个 Observable。建筑师会订阅这个 Observable,直到这个 Observable 完成(complete)为止。此外,如果使用相同的参数再次调度这个构建器,建筑师还能复用这个 Observable。
  • 这个构建器应该总是在每次执行后发出一个 ​BuilderOutput ​对象。一旦它被执行,就会进入一个由外部事件触发的监视模式。如果一个事件导致它重启,那么此构建器应该执行 ​context.reportRunning()​ 函数来告诉建筑师再次运行它。如果调度器还计划了另一次运行,就会阻止建筑师停掉这个构建器。

当你的构建器通过调用 ​BuilderRun.stop()​ 来退出监视模式时,建筑师会从构建器的 Observable 中取消订阅,并调用构建器的退出逻辑进行清理。(这种行为也允许停止和清理运行时间过长的构建。)

一般来说,如果你的构建器正在监视一个外部事件,你应该把你的运行分成三个阶段。

阶段

详情

运行

比如 webpack 编译。这会在 webpack 完成并且你的构建器发出 BuilderOutput 对象时结束。

监视

在两次运行之间监视外部事件流。比如,webpack 会监视文件系统是否发生了任何变化。这会在 webpack 重启构建时结束,并调用 context.reportRunning()。这样就会再回到第 1 步。

完成

任务完全完成(比如,webpack 应运行多次),或者构建器停止运行(使用 BuilderRun.stop())。你的退出逻辑被调用了,建筑师也从你的构建器的 Observable 中取消了订阅。

总结

CLI 构建器 API 提供了一种通过构建器执行自定义逻辑,以改变 Angular CLI 行为的新方式。

  • 构建器既可以是同步的,也可以是异步的,它可以只执行一次也可以监视外部事件,还可以调度其它构建器或目标
  • 构建器在 ​angular.json​ 配置文件中指定了选项的默认值,它可以被目标的备用配置覆盖,还可以进一步被命令行标志所覆盖
  • 建议你使用集成测试来测试建筑师的构建器。还可以用单元测试来验证这个构建器的执行逻辑。
  • 如果你的构建器返回一个 Observable,你应该在那个 Observable 的退出逻辑中进行清理