本文翻譯自鏈接
本文目的是學(xué)習(xí)如何構(gòu)建一個Angular schematic來添加庫
起步教程
如果你對于Angular Schematics是完全的新手,我推薦你從Angular Schematics Tutorial開始
AngularFire Schematics
安裝AngularFire很容易:
- 使用
npm或者yarn來安裝firebase和@angular/fire模塊 - 把你的Firebase工程的配置加到Angular的enviroment.ts文件中
- 將AngularFire模塊import到根模塊中
盡管這個很簡單,但是新建一個schematic幫你做所有的事情,包括為你的Firebase配置進(jìn)行命令行提示,會很有趣。
ng add
試一下這個:
$ ng new demo
$ cd demo
$ ng add angular-fire-schematics
demo:

目標(biāo)
我的目標(biāo)是你能學(xué)到:
- 如何新建一個schematic能添加一個庫到一個Angular工程(應(yīng)用或庫)
- 使用Typescript抽象語法樹(AST)的初步
- 如何在
Tree中提交文件升級
我們的目標(biāo)是構(gòu)建一個schematic能使用Angular CLI加入。
創(chuàng)建Schematic Collection
如果你還沒有安裝schematics CLI,首先你得通過npm或者yarn安裝它:
$ npm install -g @angular-devkit/schematics-cli
$ yarn add -g @angular-devkit/schematics-cli
第一步是使用blank schematic來創(chuàng)建一個新的schematics:
$ schematics blank --name=angular-fire-schematics
這會新建一個angular-fire-schematics目錄,添加必要的文件和文件夾來構(gòu)建一個schematic,然后安裝必要的依賴。
為我們的工程初始化Git倉庫:
$ cd angular-fire-schematics
$ git init
下一步,使用你喜歡的編輯器或者ide打開工程。這里是VS Code:
$ code angular-fire-schematics
創(chuàng)建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"]
}
}
}
下面這些屬性是之前都說明了的:
-
descriptionschematic的描述 -
factory作為schematic的入口函數(shù)調(diào)用的函數(shù)。 在這里是src/ng-add/index.js文件的默認(rèn)輸出函數(shù) schema-
aliasesschematic的別名們。這就是為什么你可以在Angular CLI中使用縮寫"c"來生成組件
注意:如果你并沒有輸出默認(rèn)函數(shù),你可以使用下面的語法,在#后你指明一個要調(diào)用的函數(shù),比如:"factory": "./ng-add/index#functionName。
定義Schematic Schema
下一步,定義ng-add schematic的schema。schema會包含新的在v7中引入的x-prompt屬性。這會提示用戶輸入他們的Firebase配置。
創(chuàng)建新文件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
}
值得注意的選項:
-
typeTypescript中的類型 -
default默認(rèn)值 -
description描述 -
x-prompt當(dāng)選項沒有在命令行中指定時提示用戶的文本
定義Schema
我喜歡給用戶會指明的選項加上強類型,這能讓我在處理schematic的入口函數(shù)的options參量有類型安全。
創(chuàng)建新文件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;
}
創(chuàng)建默認(rèn)函數(shù)
搭建工程腳手架的最后一步是創(chuàng)建所謂的入口函數(shù)。這是當(dāng)schematic被執(zhí)行時所調(diào)用的函數(shù)?;叵胍幌?,我們在collection.json文件中指明了文件src/index.js。
在src/ng-add/index.ts中添加默認(rèn)函數(shù):
export default function(options: Schema): Rule {
return (tree: Tree, _context: SchematicContext) => {
return tree;
};
}
構(gòu)建和運行
雖然現(xiàn)在我們的schematic還只是個空殼,我們先構(gòu)建并運行這個schematic來看看到現(xiàn)在我們新建的東西:
$ yarn build
$ schematics .:ng-add
沙盒測試
雖然一個schematic應(yīng)該包含一個單元測試套件來提供必要的覆蓋率來保證schematic的質(zhì)量,我喜歡使用沙盒測試來可視化schematic運行之后我的Angular應(yīng)用發(fā)生的變化。
首先,使用ng new創(chuàng)建一個新Angular應(yīng)用。這里使用"sandbox"這個名字:
$ ng new sandbox
避免沙盒有內(nèi)嵌的git倉庫,移除sandbox中的.git目錄。然后為我們做的所有變化創(chuàng)建一個新提交:
$ 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
工具
我們會使用很多很多工具函數(shù),在@angular/schematics和@angular/cdk模塊中有。大多數(shù)函數(shù)位于utility目錄。這些工具能允許我們完成:
- 在工程package.json文件中升級依賴
- 安裝依賴
- 確認(rèn)Angular工程中的workspace設(shè)置
- 升級Angular模塊
- 更多
安裝兩個模塊:
$ yarn add @angular/schematics @angular/cdk
$ npm install @angular/schematics @angular/cdk
連鎖Rules
我們構(gòu)建ng-add schematic的第一個任務(wù)是添加必要的依賴到package.json文件中。
升級src/index.ts中的默認(rèn)函數(shù),使用chain()來連鎖多個rules:
export default function(options: Schema): Rule {
return (tree: Tree, _context: SchematicContext) => {
return chain([
addPackageJsonDependencies(),
installDependencies(),
setupProject(options)
])(tree, _context);
};
}
聲明一下這三個函數(shù):
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()函數(shù):
import { addPackageJsonDependency, NodeDependency, NodeDependencyType } from '@schematics/angular/utility/dependencies';
import { getLatestNodeVersion, NpmRegistryPackage } from '../util/npmjs';
function addPackageJsonDependencies(): Rule {
return (tree: Tree, _context: SchematicContext): Observable<Tree> => {
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的工廠函數(shù) - 我們使用
of()函數(shù)創(chuàng)建一個新的Observable,發(fā)出兩個字符串,'firebase'和'@angular/fire' - 我們使用工具函數(shù)
getLatestNodeVersion(), 返回的是一個Observable,包含來從npm的API中獲得每個依賴的NpmRegistryPackage - 我們使用從API中獲得的信息新建一個
NodeDependency對象,然后調(diào)用addPackageJsonDependency()函數(shù)。這些類型和函數(shù)都來自@schematics/angular模塊 -
addPackageJsonDependency()函數(shù)在package.json文件中添加依賴 - 最后,我們使用
SchematicContext來打出日志
然后,運行構(gòu)建和測試腳本:
$ yarn build
$ yarn test
看一下package.json文件的差別:
$ git diff sandbox/package.json
@angular/fire和firebase依賴都被添加進(jìn)了沙盒引用的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來完成我們的要求就很簡單。
接下來要構(gòu)建和運行schematic,結(jié)果如下:

設(shè)置Schemtic
ng-add schematic添加了必要依賴并安裝。下一步是創(chuàng)建schematic來進(jìn)行必要的AngularFire的設(shè)置和配置。
打開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
起手我們干掉默認(rèn)函數(shù),變成這樣:
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數(shù)組中
RunSchematicTask
繼續(xù)工作之前,我們需要從ng-add schematic中運行新創(chuàng)建的ng-add-setup-project。我們要使用RunSchematicTask類來做這個事。
我們來完成src/ng-add/index.ts文件中的setupProject()函數(shù):
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;
};
}
回看一下:
- 在我們執(zhí)行任務(wù)之前,我們需要等待之前的任務(wù)安裝完依賴。所以我們首先需要得到我們新任務(wù)依賴的任務(wù)的
installTaskId - 接著我們調(diào)用
SchematicContext類中的addTask()方法,指明了新的RunSchematicTask和依賴的任務(wù)id的數(shù)組 - 注意當(dāng)運行另外一個schematic時,我們指明了schematic名字和這個schematic的選項。我們傳遞了ng-add schematic的所有選項。
升級environment.ts
打開文件src/setup-project.ts,并完成addEnviromentConfig()函數(shù):
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;
};
}
有點長!我們細(xì)看:
- 我們使用了一些
@schematics/angular模塊中的工具函數(shù) - 首先我們使用函數(shù)
getWorkspace()來得到WorkspaceSchema,包含Angular workspace的元數(shù)據(jù) - 然后我們使用
getProjectFromWorkspace()函數(shù)來得到WorkspaceProject,包含Angular project(lib或app)的元數(shù)據(jù):根目錄,默認(rèn)組件前綴等。 - 然后我們得到工程的environment.ts文件的路徑。我們需要知道這個來升級這個文件。
- 然后我們構(gòu)建
insertion字符串,包含用戶輸入的firebase的配置信息 -
readIntoSourceFile()函數(shù)很重要,我們會在隨后細(xì)講它。它返回了Typescript文件的抽象語法樹(AST)頂層節(jié)點 - 我們也想知道firebase配置是否已經(jīng)存在在environment.ts文件中,我們簡單地使用
getText()方法然后確定要插入的東西是否已經(jīng)存在。這個方法有點原始,有其他方法可以用來確認(rèn) - 使用
getSourceNodes()工具函數(shù)來得到SourceFileAST中的一個數(shù)組的Node對象 - 我們使用
Array.prototype.find()方法來找到OpenBraceToken節(jié)點。記住environment.ts文件包含一個輸出的environment常量對象。我們就找左中括號({)的節(jié)點來找到環(huán)境配置對象的開始 - 我們也要找右中括號(})作為結(jié)束節(jié)點
- 使用
tree.beginUpdate()方法,我們可以開始記錄tree中的指定文件的變化 - 我們在
environment對象的末尾插入insertion字符串 - 最后,我們打一下日志
什么是AST
再進(jìn)一步之前,我們快速地復(fù)習(xí)一下什么是抽象語法樹(AST): 據(jù)Wikipedia
在計算機科學(xué)中,抽象語法樹(AST),或者語法樹,是使用編程語言寫的源代碼的抽象語法結(jié)構(gòu)的樹狀表示。樹的每個節(jié)點都代表源碼中出現(xiàn)的一個構(gòu)造。
你也許猜到了,Typescript在lib/typescript.d.ts中提供了聲明文件,包含了Typescript抽象語法樹中所有的類型定義。
為什么這個抽象語法樹這么重要?使用AST我們可以使用可靠的編程接口來改變Typescript源文件。
得到SourceFile
SourceFile是Typescript AST的頂層節(jié)點。這是處理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()函數(shù)來得到文件Buffer。然后我們調(diào)用createSourceFile()方法,提供Typescript文件的路徑和內(nèi)容。
Import environment
回到創(chuàng)建AngularFire的ng-add schematic的任務(wù)上,下一步我們要完成importEnvironmentIntoRootModule()函數(shù)。這個函數(shù)負(fù)責(zé)使用標(biāo)準(zhǔn)es6 import語法將environment常量對象import到根AppModule中。
跟之前一樣,我們會使用一些@schematics/angular模塊提供的工具函數(shù)。
打開src/setup-project.ts并完成importEnvironmentIntoRootModule()函數(shù):
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;
};
}
細(xì)看一下:
- 我們會使用一些熟悉函數(shù)來得到有關(guān)workspace和執(zhí)行環(huán)境的信息
-
getAppModulePath()函數(shù)返回Angular工程中的app.module.ts的路徑字符串 - 使用我們的有關(guān)環(huán)境文件和根模塊的信息,我們做好了添加import語句的準(zhǔn)備
- 我們首先確認(rèn)
environment還沒有被import。如果被import的話,就到此為止 - 否則,我們需要插入import。我們使用
@angular/schematics模塊中導(dǎo)出的insertImport()函數(shù)來創(chuàng)建一個新的InsertChange對象 - 使用
change對象,我們開始并提交了app模塊的一個升級,使用了UpdateRecorder
Import模塊
最后一步是升級AppModule的@NgModule()裝飾器來import Firebase模塊。
打開src/setup-project.ts,完成addAngularFireModule()函數(shù):
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;
};
}
回看一下:
- 依然,用一些工具函數(shù)得到workspace和工程信息
- 首先我們使用
hasNgModuleImport()函數(shù)確認(rèn)模塊是否被引入。這是在@angular/cdk模塊中 - 然后我們使用函數(shù)
addModuleImportToRootModule()來將Firebase模塊添加到@NgModule()裝飾器的import數(shù)組中
結(jié)論
我希望這個對那些有興趣學(xué)習(xí)構(gòu)建schematics的人有用,不僅是為了自己的工程或組織,還是為了Angular社區(qū)。
我已經(jīng)在很多會議上做過有關(guān)構(gòu)建Angular schematics的演講,我被問到的最多的問題之一是:
你怎么知道去哪里去找這么工具函數(shù)?
通過學(xué)習(xí)Angular CLI,Angular Material和Angular CDK的schematics我學(xué)到了很多別人用來構(gòu)建schematics的工具函數(shù)。我推薦你們?nèi)タ纯茨切﹕chematics的源文件來幫助你們構(gòu)建schematics和發(fā)現(xiàn)他們提供并使用的眾多工具函數(shù)。