本文翻译自链接
本文目的是学习如何构建一个Angular schematic来添加库
起步教程
如果你对于Angular Schematics是完全的新手,我推荐你从Angular Schematics Tutorial开始
AngularFire Schematics
安装AngularFire很容易:
- 使用
npm
或者yarn
来安装firebase
和@angular/fire
模块 - 把你的Firebase工程的配置加到Angular的enviroment.ts文件中
- 将AngularFire模块import到根模块中
尽管这个很简单,但是新建一个schematic帮你做所有的事情,包括为你的Firebase配置进行命令行提示,会很有趣。
ng add
试一下这个:
$ ng new demo
$ cd demo
$ ng add angular-fire-schematics
demo:
目标
我的目标是你能学到:
- 如何新建一个schematic能添加一个库到一个Angular工程(应用或库)
- 使用Typescript抽象语法树(AST)的初步
- 如何在
Tree
中提交文件升级
我们的目标是构建一个schematic能使用Angular CLI加入。
创建Schematic Collection
如果你还没有安装schematics CLI,首先你得通过npm
或者yarn
安装它:
$ npm install -g @angular-devkit/schematics-cli
$ yarn add -g @angular-devkit/schematics-cli
第一步是使用blank schematic来创建一个新的schematics:
$ schematics blank --name=angular-fire-schematics
这会新建一个angular-fire-schematics目录,添加必要的文件和文件夹来构建一个schematic,然后安装必要的依赖。
为我们的工程初始化Git仓库:
$ cd angular-fire-schematics
$ git init
下一步,使用你喜欢的编辑器或者ide打开工程。这里是VS Code:
$ code angular-fire-schematics
创建ng-add目录
快速清理src目录:
$ rm -rf src/angular-fire-schematics
$ mkdir src/ng-add
$ touch src/ng-add/schema.json
$ touch src/ng-add/index.ts
向Collection中添加Schematic
打开src/collection.json文件,并向collection中添加ng-add
schematic:
{
"$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
"schematics": {
"ng-add": {
"description": "Adds Angular Firebase to the application without affecting any templates",
"factory": "./ng-add/index",
"schema": "./ng-add/schema.json",
"aliases": ["install"]
}
}
}
下面这些属性是之前都说明了的:
-
description
schematic的描述 -
factory
作为schematic的入口函数调用的函数。 在这里是src/ng-add/index.js文件的默认输出函数 schema
-
aliases
schematic的别名们。这就是为什么你可以在Angular CLI中使用缩写"c"来生成组件
注意:如果你并没有输出默认函数,你可以使用下面的语法,在#后你指明一个要调用的函数,比如:"factory": "./ng-add/index#functionName
。
定义Schematic Schema
下一步,定义ng-add schematic的schema。schema会包含新的在v7中引入的x-prompt
属性。这会提示用户输入他们的Firebase配置。
创建新文件src/ng-add/schema.json:
{
"$schema": "http://json-schema.org/schema",
"id": "angular-firebase-schematic-ng-add",
"title": "Angular Firebase ng-add schematic",
"type": "object",
"properties": {
"project": {
"type": "string",
"description": "The name of the project.",
"$default": {
"$source": "projectName"
}
},
"apiKey": {
"type": "string",
"default": "",
"description": "Your Firebase API key",
"x-prompt": "What is your project's apiKey?"
},
"authDomain": {
"type": "string",
"default": "",
"description": "Your Firebase domain",
"x-prompt": "What is your project's authDomain?"
},
"databaseURL": {
"type": "string",
"default": "",
"description": "Your Firebase database URL",
"x-prompt": "What is your project's databaseURL?"
},
"projectId": {
"type": "string",
"default": "",
"description": "Your Firebase project ID",
"x-prompt": "What is your project's id?"
},
"storageBucket": {
"type": "string",
"default": "",
"description": "Your Firebase storage bucket",
"x-prompt": "What is your project's storageBucket?"
},
"messagingSenderId": {
"type": "string",
"default": "",
"description": "Your Firebase message sender ID",
"x-prompt": "What is your project's messagingSenderId?"
}
},
"required": [],
"additionalProperties": false
}
值得注意的选项:
-
type
Typescript中的类型 -
default
默认值 -
description
描述 -
x-prompt
当选项没有在命令行中指定时提示用户的文本
定义Schema
我喜欢给用户会指明的选项加上强类型,这能让我在处理schematic的入口函数的options
参量有类型安全。
创建新文件src/app/schema.ts,输出Schema
接口:
export interface Schema {
/** Firebase API key. */
apiKey: string;
/** Firebase authorized domain. */
authDomain: string;
/** Firebase db URL. */
databaseURL: string;
/** Name of the project to target. */
project: string;
/** Firebase project ID. */
projectId: string;
/** Firebase storage bucket. */
storageBucket: string;
/** Firebase messaging sender ID. */
messagingSenderId: string;
}
创建默认函数
搭建工程脚手架的最后一步是创建所谓的入口函数。这是当schematic被执行时所调用的函数。回想一下,我们在collection.json文件中指明了文件src/index.js。
在src/ng-add/index.ts中添加默认函数:
export default function(options: Schema): Rule {
return (tree: Tree, _context: SchematicContext) => {
return tree;
};
}
构建和运行
虽然现在我们的schematic还只是个空壳,我们先构建并运行这个schematic来看看到现在我们新建的东西:
$ yarn build
$ schematics .:ng-add
沙盒测试
虽然一个schematic应该包含一个单元测试套件来提供必要的覆盖率来保证schematic的质量,我喜欢使用沙盒测试来可视化schematic运行之后我的Angular应用发生的变化。
首先,使用ng new
创建一个新Angular应用。这里使用"sandbox"这个名字:
$ ng new sandbox
避免沙盒有内嵌的git仓库,移除sandbox中的.git目录。然后为我们做的所有变化创建一个新提交:
$ rm -rf sandbox/.git
$ git add -A
$ git commit -m "Initial commit"
然后,定义一些脚本来在Angular沙盒的上下文中使用Angular CLI链接,清理和测试我们的schematic:
{
"scripts": {
"build": "tsc -p tsconfig.json",
"clean": "git checkout HEAD -- sandbox && git clean -f -d sandbox",
"link:schematic": "yarn link && cd sandbox && yarn link \"angular-fire-schematics\"",
"sandbox:ng-add": "cd sandbox && ng g angular-fire-schematics:ng-add",
"test": "yarn clean && yarn sandbox:ng-add && yarn test:sandbox",
"test:unit": "yarn build && jasmine src/**/*_spec.js",
"test:sandbox": "cd sandbox && yarn lint && yarn test && yarn build"
}
}
最后,链接angular-fire-schematics包到沙盒中:
$ yarn link:schematic
工具
我们会使用很多很多工具函数,在@angular/schematics
和@angular/cdk
模块中有。大多数函数位于utility目录。这些工具能允许我们完成:
- 在工程package.json文件中升级依赖
- 安装依赖
- 确认Angular工程中的workspace设置
- 升级Angular模块
- 更多
安装两个模块:
$ yarn add @angular/schematics @angular/cdk
$ npm install @angular/schematics @angular/cdk
连锁Rules
我们构建ng-add schematic的第一个任务是添加必要的依赖到package.json文件中。
升级src/index.ts中的默认函数,使用chain()
来连锁多个rules:
export default function(options: Schema): Rule {
return (tree: Tree, _context: SchematicContext) => {
return chain([
addPackageJsonDependencies(),
installDependencies(),
setupProject(options)
])(tree, _context);
};
}
声明一下这三个函数:
function addPackageJsonDependencies(): Rule {}
function installDependencies(): Rule {
return (tree: Tree, _context: SchematicContext) => {
return tree;
}
}
function setupProject(options: Schema): Rule {
console.log(options);
return (tree: Tree, _context: SchematicContext) => {
return tree;
}
}
添加依赖
下一步,我们完成src/index.ts文件中的addPackageJsonDependencies()
函数:
import { addPackageJsonDependency, NodeDependency, NodeDependencyType } from '@schematics/angular/utility/dependencies';
import { getLatestNodeVersion, NpmRegistryPackage } from '../util/npmjs';
function addPackageJsonDependencies(): Rule {
return (tree: Tree, _context: SchematicContext): Observable => {
return of('firebase', '@angular/fire').pipe(
concatMap(name => getLatestNodeVersion(name)),
map((npmRegistryPackage: NpmRegistryPackage) => {
const nodeDependency: NodeDependency = {
type: NodeDependencyType.Default,
name: npmRegistryPackage.name,
version: npmRegistryPackage.version,
overwrite: false
};
addPackageJsonDependency(tree, nodeDependency);
_context.logger.info('✅️ Added dependency');
return tree;
})
);
};
}
回看一下:
- 首先,
addPackageJsonDependencies()
是一个接收Tree
和SchematicContext
对象的,返回Rule
的工厂函数 - 我们使用
of()
函数创建一个新的Observable
,发出两个字符串,'firebase'和'@angular/fire' - 我们使用工具函数
getLatestNodeVersion()
, 返回的是一个Observable
,包含来从npm的API中获得每个依赖的NpmRegistryPackage
- 我们使用从API中获得的信息新建一个
NodeDependency
对象,然后调用addPackageJsonDependency()
函数。这些类型和函数都来自@schematics/angular模块 -
addPackageJsonDependency()
函数在package.json文件中添加依赖 - 最后,我们使用
SchematicContext
来打出日志
然后,运行构建和测试脚本:
$ yarn build
$ yarn test
看一下package.json文件的差别:
$ git diff sandbox/package.json
@angular/fire和firebase依赖都被添加进了沙盒引用的package.json文件中
安装依赖
package.json升级之后,下一步就是安装新的依赖
完成installDependencies()
:
import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks';
function installDependencies(): Rule {
return (tree: Tree, _context: SchematicContext) => {
_context.addTask(new NodePackageInstallTask());
_context.logger.debug('✅️ Dependencies installed');
return tree;
};
}
使用从@angular-devkit/schematics/tasks模块中输出的NodePackageInstallTask
来完成我们的要求就很简单。
接下来要构建和运行schematic,结果如下:
设置Schemtic
ng-add schematic添加了必要依赖并安装。下一步是创建schematic来进行必要的AngularFire的设置和配置。
打开src/collection.json文件,定义一个新schematic,命名为ng-add-setup-project。完成版像这样:
{
"$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
"schematics": {
"ng-add": {
"description": "Adds Angular Firebase to the application without affecting any templates",
"factory": "./ng-add/index",
"schema": "./ng-add/schema.json",
"aliases": ["install"]
},
"ng-add-setup-project": {
"description": "Sets up the specified project after the ng-add dependencies have been installed.",
"private": true,
"factory": "./ng-add/setup-project",
"schema": "./ng-add/schema.json"
}
}
}
接下来,新建文件src/ng-add/setup-project.ts:
$ touch src/ng-add/setup-project.ts
起手我们干掉默认函数,变成这样:
import {
chain,
Rule,
SchematicContext,
Tree
} from '@angular-devkit/schematics';
import { Schema } from './schema';
export default function(options: Schema): Rule {
return (tree: Tree, _context: SchematicContext) => {
return chain([
addEnvironmentConfig(options),
importEnvironemntIntoRootModule(options),
addAngularFireModule(options)
])(tree, _context);
};
}
function addEnvironmentConfig(options: Schema): Rule {
return (tree: Tree, _context: SchematicContext) => {
return tree;
};
}
function addAngularFireModule(options: Schema): Rule {
return (tree: Tree, _context: SchematicContext) => {
return tree;
};
}
function importEnvironemntIntoRootModule(options: Schema): Rule {
return (tree: Tree, _context: SchematicContext) => {
return tree;
};
}
计划是:
- 在environment.ts文件中添加配置信息
- 把enviroment.ts import到app.moduls.ts文件中
- 将
AngularFireModule.initializeApp()
方法添加到AppModule
类的@NgModule()
装饰器的imports
数组中
RunSchematicTask
继续工作之前,我们需要从ng-add schematic中运行新创建的ng-add-setup-project。我们要使用RunSchematicTask
类来做这个事。
我们来完成src/ng-add/index.ts文件中的setupProject()
函数:
function setupProject(options: Schema): Rule {
return (tree: Tree, _context: SchematicContext) => {
const installTaskId = _context.addTask(new NodePackageInstallTask());
_context.addTask(new RunSchematicTask('ng-add-setup-project', options), [
installTaskId
]);
return tree;
};
}
回看一下:
- 在我们执行任务之前,我们需要等待之前的任务安装完依赖。所以我们首先需要得到我们新任务依赖的任务的
installTaskId
- 接着我们调用
SchematicContext
类中的addTask()
方法,指明了新的RunSchematicTask
和依赖的任务id的数组 - 注意当运行另外一个schematic时,我们指明了schematic名字和这个schematic的选项。我们传递了ng-add schematic的所有选项。
升级environment.ts
打开文件src/setup-project.ts,并完成addEnviromentConfig()
函数:
function addEnvironmentConfig(options: Schema): Rule {
return (tree: Tree, context: SchematicContext) => {
const workspace = getWorkspace(tree);
const project = getProjectFromWorkspace(workspace, options.project);
const envPath = getProjectEnvironmentFile(project);
// verify environment.ts file exists
if (!envPath) {
return context.logger.warn(
`❌ Could not find environment file: "${envPath}". Skipping firebase configuration.`
);
}
// firebase config to add to environment.ts file
const insertion =
',\n' +
` firebase: {\n` +
` apiKey: '${options.apiKey}',\n` +
` authDomain: '${options.authDomain}',\n` +
` databaseURL: '${options.databaseURL}',\n` +
` projectId: '${options.projectId}',\n` +
` storageBucket: '${options.storageBucket}',\n` +
` messagingSenderId: '${options.messagingSenderId}',\n` +
` }`;
const sourceFile = readIntoSourceFile(tree, envPath);
// verify firebase config does not already exist
const sourceFileText = sourceFile.getText();
if (sourceFileText.includes(insertion)) {
return;
}
// get the array of top-level Node objects in the AST from the SourceFile
const nodes = getSourceNodes(sourceFile as any);
const start = nodes.find(
node => node.kind === ts.SyntaxKind.OpenBraceToken
)!;
const end = nodes.find(
node => node.kind === ts.SyntaxKind.CloseBraceToken,
start.end
)!;
const recorder = tree.beginUpdate(envPath);
recorder.insertLeft(end.pos, insertion);
tree.commitUpdate(recorder);
context.logger.info('✅️ Environment configuration');
return tree;
};
}
有点长!我们细看:
- 我们使用了一些
@schematics/angular
模块中的工具函数 - 首先我们使用函数
getWorkspace()
来得到WorkspaceSchema
,包含Angular workspace的元数据 - 然后我们使用
getProjectFromWorkspace()
函数来得到WorkspaceProject
,包含Angular project(lib或app)的元数据:根目录,默认组件前缀等。 - 然后我们得到工程的environment.ts文件的路径。我们需要知道这个来升级这个文件。
- 然后我们构建
insertion
字符串,包含用户输入的firebase的配置信息 -
readIntoSourceFile()
函数很重要,我们会在随后细讲它。它返回了Typescript文件的抽象语法树(AST)顶层节点 - 我们也想知道firebase配置是否已经存在在environment.ts文件中,我们简单地使用
getText()
方法然后确定要插入的东西是否已经存在。这个方法有点原始,有其他方法可以用来确认 - 使用
getSourceNodes()
工具函数来得到SourceFile
AST中的一个数组的Node
对象 - 我们使用
Array.prototype.find()
方法来找到OpenBraceToken
节点。记住environment.ts文件包含一个输出的environment
常量对象。我们就找左中括号({)的节点来找到环境配置对象的开始 - 我们也要找右中括号(})作为结束节点
- 使用
tree.beginUpdate()
方法,我们可以开始记录tree中的指定文件的变化 - 我们在
environment
对象的末尾插入insertion
字符串 - 最后,我们打一下日志
什么是AST
再进一步之前,我们快速地复习一下什么是抽象语法树(AST): 据Wikipedia
在计算机科学中,抽象语法树(AST),或者语法树,是使用编程语言写的源代码的抽象语法结构的树状表示。树的每个节点都代表源码中出现的一个构造。
你也许猜到了,Typescript在lib/typescript.d.ts中提供了声明文件,包含了Typescript抽象语法树中所有的类型定义。
为什么这个抽象语法树这么重要?使用AST我们可以使用可靠的编程接口来改变Typescript源文件。
得到SourceFile
SourceFile
是Typescript AST的顶层节点。这是处理AST的起点。我们会使用Typescript提供的createSourceFile()
方法来得到SourceFile
:
function readIntoSourceFile(host: Tree, fileName: string): SourceFile {
const buffer = host.read(fileName);
if (buffer === null) {
throw new SchematicsException(`File ${fileName} does not exist.`);
}
return ts.createSourceFile(
fileName,
buffer.toString('utf-8'),
ts.ScriptTarget.Latest,
true
);
}
首先我们使用Tree
的read()
函数来得到文件Buffer
。然后我们调用createSourceFile()
方法,提供Typescript文件的路径和内容。
Import environment
回到创建AngularFire的ng-add schematic的任务上,下一步我们要完成importEnvironmentIntoRootModule()
函数。这个函数负责使用标准es6 import语法将environment常量对象import到根AppModule
中。
跟之前一样,我们会使用一些@schematics/angular
模块提供的工具函数。
打开src/setup-project.ts并完成importEnvironmentIntoRootModule()
函数:
function importEnvironemntIntoRootModule(options: Schema): Rule {
return (tree: Tree, context: SchematicContext) => {
const IMPORT_IDENTIFIER = 'environment';
const workspace = getWorkspace(tree);
const project = getProjectFromWorkspace(workspace, options.project);
const appModulePath = getAppModulePath(tree, getProjectMainFile(project));
const envPath = getProjectEnvironmentFile(project);
const sourceFile = readIntoSourceFile(tree, appModulePath);
if (isImported(sourceFile as any, IMPORT_IDENTIFIER, envPath)) {
context.logger.info(
'✅️ The environment is already imported in the root module'
);
return tree;
}
const change = insertImport(
sourceFile as any,
appModulePath,
IMPORT_IDENTIFIER,
envPath.replace(/\.ts$/, '')
) as InsertChange;
const recorder = tree.beginUpdate(appModulePath);
recorder.insertLeft(change.pos, change.toAdd);
tree.commitUpdate(recorder);
context.logger.info('✅️ Import environment into root module');
return tree;
};
}
细看一下:
- 我们会使用一些熟悉函数来得到有关workspace和执行环境的信息
-
getAppModulePath()
函数返回Angular工程中的app.module.ts的路径字符串 - 使用我们的有关环境文件和根模块的信息,我们做好了添加import语句的准备
- 我们首先确认
environment
还没有被import。如果被import的话,就到此为止 - 否则,我们需要插入import。我们使用
@angular/schematics
模块中导出的insertImport()
函数来创建一个新的InsertChange
对象 - 使用
change
对象,我们开始并提交了app模块的一个升级,使用了UpdateRecorder
Import模块
最后一步是升级AppModule
的@NgModule()
装饰器来import Firebase模块。
打开src/setup-project.ts,完成addAngularFireModule()
函数:
function addAngularFireModule(options: Schema): Rule {
return (tree: Tree, context: SchematicContext) => {
const MODULE_NAME = 'AngularFireModule.initializeApp(environment.firebase)';
const workspace = getWorkspace(tree);
const project = getProjectFromWorkspace(workspace, options.project);
const appModulePath = getAppModulePath(tree, getProjectMainFile(project));
// verify module has not already been imported
if (hasNgModuleImport(tree, appModulePath, MODULE_NAME)) {
return console.warn(
red(
`Could not import "${bold(MODULE_NAME)}" because "${bold(
MODULE_NAME
)}" is already imported.`
)
);
}
// add NgModule to root NgModule imports
addModuleImportToRootModule(tree, MODULE_NAME, '@angular/fire', project);
context.logger.info('✅️ Import AngularFireModule into root module');
return tree;
};
}
回看一下:
- 依然,用一些工具函数得到workspace和工程信息
- 首先我们使用
hasNgModuleImport()
函数确认模块是否被引入。这是在@angular/cdk模块中 - 然后我们使用函数
addModuleImportToRootModule()
来将Firebase模块添加到@NgModule()
装饰器的import
数组中
结论
我希望这个对那些有兴趣学习构建schematics的人有用,不仅是为了自己的工程或组织,还是为了Angular社区。
我已经在很多会议上做过有关构建Angular schematics的演讲,我被问到的最多的问题之一是:
你怎么知道去哪里去找这么工具函数?
通过学习Angular CLI,Angular Material和Angular CDK的schematics我学到了很多别人用来构建schematics的工具函数。我推荐你们去看看那些schematics的源文件来帮助你们构建schematics和发现他们提供并使用的众多工具函数。