Angular CLI构建器
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
|
|
tsconfig.json
|
将此构建器发布到 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 项目的名称。 |
目标 |
|
配置(configuration) |
(可选)用于覆盖指定目标的具体配置名称,如 |
如果你的构建器调用另一个构建器,它可能需要读取一个传入的目标字符串。可以使用 @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 完成并且你的构建器发出 |
监视 |
在两次运行之间监视外部事件流。比如,webpack 会监视文件系统是否发生了任何变化。这会在 webpack 重启构建时结束,并调用 |
完成 |
任务完全完成(比如,webpack 应运行多次),或者构建器停止运行(使用 |
总结
CLI 构建器 API 提供了一种通过构建器执行自定义逻辑,以改变 Angular CLI 行为的新方式。
- 构建器既可以是同步的,也可以是异步的,它可以只执行一次也可以监视外部事件,还可以调度其它构建器或目标
- 构建器在
angular.json
配置文件中指定了选项的默认值,它可以被目标的备用配置覆盖,还可以进一步被命令行标志所覆盖 - 建议你使用集成测试来测试建筑师的构建器。还可以用单元测试来验证这个构建器的执行逻辑。
- 如果你的构建器返回一个 Observable,你应该在那个 Observable 的退出逻辑中进行清理