相信經過昨天的原理說明之後,大家一定迫不及待地想要趕快實作了!!
廢話不囉嗦,不管你是要新開一個 Schematics 或是用原本的專案都行,筆者會用原本的專案來跟大家分享。
首先先在 /src/collection.json
裡加入以下程式碼:
{
"$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
"schematics": {
"//": "略"
"ng-add": {
"description": "Add something to your Angular project.",
"factory": "./ng-add"
}
}
}
注意!這個名稱一定要是
ng-add
,不能改成其他名字。
接著在 /src/ng-add
底下新增 index.ts
檔,檔案內容跟一開始的一樣:
import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics';
export default function(_options: HelloWorldSchema): Rule {
return (_tree: Tree, _context: SchematicContext) => {
return _tree;
};
}
到這邊之後,可以使用
npm run build && schematics .:ng-add
來驗證看看有沒有任何錯誤。另外,要注意的是上述的函式並沒有名稱,而且加上了
default
宣告,這樣才能跟collection.json
裡的設定對齊。當然,也可以將其改成與
hello-world
一樣的表達方式,只要對的上即可。
那麼我們的 ng-add
要做什麼才好呢?
剛好筆者最近有看到一篇還不錯的文章: Angular + Font Awesome in 5 Easy Steps With angular-fontawesome 。這篇文章主要是在教大家如何在五個步驟內將 Font Awesome 加到自己的 Angular 專案內。
先來看看原作者說的五個步驟:
ng new
建立新專案。yarn
或是 npm
安裝 Font Awesome 套件。FortAwesomeModule
加到 AppModule
的 imports
裡。ng serve
將專案跑起來。在這五個步驟中,除了第一個與第五個步驟之外,其他三個步驟其實都可以交由 Schematic 來完成,使其從五個步驟濃縮成三個步驟。
因此,我們就來做這個吧!
首先先暫時跳過第二個步驟,我們先來看第三個步驟:
將
FortAwesomeModule
加到AppModule
的imports
裡。
這件事之前就做過了,還記得嗎?我們可以先從之前的練習把一些已經做過的程式碼複製過來:
import { Rule, SchematicContext, Tree, SchematicsException } from '@angular-devkit/schematics';
import { buildDefaultPath } from '@schematics/angular/utility/project';
export default function (_options: HelloWorldSchema): Rule {
return (_tree: Tree, _context: SchematicContext) => {
// 如果不是 Angular 專案則拋出錯誤
const workspaceConfigBuffer = _tree.read('angular.json');
if ( !workspaceConfigBuffer ) {
throw new SchematicsException('Not an Angular CLI workspace');
}
// 取得 project 的根目錄路徑
const workspaceConfig = JSON.parse(workspaceConfigBuffer.toString());
const projectName = _options.project || workspaceConfig.defaultProject;
const project = workspaceConfig.projects[projectName];
const defaultProjectPath = buildDefaultPath(project);
return _tree;
};
}
因為一樣有可能在一個 Angular 專案內會有多個 project
,因此我們也希望這個 Schematic 可以用 --project
參數指定要在哪一個 project
新增。
另外,由於這個 Schematic 不需要 name
這個參數,所以我們來新增一個 ng-add
專用的 schema.json
。
先把 /src/hello-world/schema.json
複製到 /src/ng-add/schema.json
,然後再稍微修改一下:
{
"$schema": "http://json-schema.org/schema",
"id": "NgAddSchema",
"title": "Ng-Add Schema",
"type": "object",
"description": "Add Font-Awesome Into the project.",
"properties": {
"project": {
"type": "string",
"description": "Add Font-Awesome in specific Angular CLI workspace project"
}
}
}
接著使用 npx -p dtsgenerator dtsgen src/ng-add/schema.json -o src/ng-add/schema.d.ts
產生定義檔後,再回到 /src/ng-add/index.ts
將 NgAddSchema
套上:
// 將 _options 宣告為 NgAddSchema
export default function (_options: NgAddSchema): Rule {
return (_tree: Tree, _context: SchematicContext) => {
// 略
};
}
再來是實際將 FortAwesomeModule
加進 AppModule
的部份,先引入需要用到的東西:
import { addImportToModule } from '@schematics/angular/utility/ast-utils';
import { InsertChange } from '@schematics/angular/utility/change';
import * as ts from '@schematics/angular/third_party/github.com/Microsoft/TypeScript/lib/typescript';
接著是實作的程式碼:
export default function (_options: NgAddSchema): Rule {
return (_tree: Tree, _context: SchematicContext) => {
// 上略
const modulePath = `${defaultProjectPath}/app.module.ts`;
const sourceFile = ts.createSourceFile(
'test.ts',
(_tree.read(modulePath) || []).toString(),
ts.ScriptTarget.Latest,
true
);
const importPath = '@fortawesome/angular-fontawesome';
const moduleName = 'FontAwesomeModule';
const declarationChanges = addImportToModule(sourceFile, modulePath, moduleName, importPath);
const declarationRecorder = _tree.beginUpdate(modulePath);
for (const change of declarationChanges) {
if (change instanceof InsertChange) {
declarationRecorder.insertLeft(change.pos, change.toAdd);
}
}
_tree.commitUpdate(declarationRecorder);
return _tree;
};
}
然後將 /src/hello-world/index_spec.ts
複製到 /src/ng-add/index_spec.ts
,再稍微調整一下就可以用測試來驗證這個步驟有沒有做對:
import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';
import { Schema as ApplicationOptions, Style } from '@schematics/angular/application/schema';
import { Schema as WorkspaceOptions } from '@schematics/angular/workspace/schema';
import * as path from 'path';
const collectionPath = path.join(__dirname, '../collection.json');
describe('hello-world', () => {
const runner = new SchematicTestRunner('schematics', collectionPath);
const workspaceOptions: WorkspaceOptions = {
name: 'workspace',
newProjectRoot: 'projects',
version: '6.0.0',
};
const appOptions: ApplicationOptions = {
name: 'hello',
inlineStyle: false,
inlineTemplate: false,
routing: false,
style: Style.Css,
skipTests: false,
skipPackageJson: false,
};
let appTree: UnitTestTree;
beforeEach(async () => {
appTree = await runner.runExternalSchematicAsync(
'@schematics/angular',
'workspace',
workspaceOptions
).toPromise();
appTree = await runner.runExternalSchematicAsync(
'@schematics/angular',
'application',
appOptions,
appTree
).toPromise();
});
it('成功在預設專案路徑底下新增Font-awesome', async () => {
const tree = await runner.runSchematicAsync('ng-add', {}, appTree).toPromise();
const moduleContent = tree.readContent('/projects/hello/src/app/app.module.ts');
expect(moduleContent).toMatch(/import.*FontAwesomeModule.*from '@fortawesome\/angular-fontawesome'/);
expect(moduleContent).toMatch(/imports:\s*\[[^\]]+?,\r?\n\s+FontAwesomeModule\r?\n/m);
});
it('成功在 "world" 專案路徑底下新增 Font-awesome', async () => {
appTree = await runner.runExternalSchematicAsync(
'@schematics/angular',
'application',
{ ...appOptions, name: 'world' },
appTree
).toPromise();
const options: NgAddSchema = { project: 'world' };
const tree = await runner.runSchematicAsync('ng-add', options, appTree).toPromise();
const moduleContent = tree.readContent('/projects/world/src/app/app.module.ts');
expect(moduleContent).toMatch(/import.*FontAwesomeModule.*from '@fortawesome\/angular-fontawesome'/);
expect(moduleContent).toMatch(/imports:\s*\[[^\]]+?,\r?\n\s+FontAwesomeModule\r?\n/m);
});
});
驗證結果:
當已經有測試保護我們的程式碼之後,重構就變得非常輕鬆、愜意了。由於之後還需要重複讀檔,所以我們將先將讀取檔案這塊抽出來,讓之後重複使用時更方便:
function readIntoSourceFile(host: Tree, modulePath: string): ts.SourceFile {
const text = host.read(modulePath);
if (text === null) {
throw new SchematicsException(`File ${modulePath} does not exist.`);
}
const sourceText = text.toString('utf-8');
return ts.createSourceFile(modulePath, sourceText, ts.ScriptTarget.Latest, true);
}
調整一下原本的程式碼:
export default function (_options: NgAddSchema): Rule {
return (_tree: Tree, _context: SchematicContext) => {
// 上略
const sourceFile = readIntoSourceFile(_tree, modulePath);
// 下略
};
}
接著是第四步:新增 Font Awesome 的 CSS Class。
這步也是屬於修改程式碼的部份,如按照原作者的步驟來做的話:
先將某個
icon
引入到app.component.ts
裡之後,然後再到app.component.html
裡使用它。
先處理引入 icon
的部份:
export default function (_options: NgAddSchema): Rule {
return (_tree: Tree, _context: SchematicContext) => {
// 上略
// 獲取 app.component.ts 的 AST
const componentPath = `${defaultProjectPath}/app.component.ts`;
const componentSourceFile = readIntoSourceFile(_tree, componentPath);
// 取得所有的 ImpotDeclaration
const allImports = componentSourceFile.statements.filter( node => ts.isImportDeclaration(node) )! as ts.ImportDeclaration[];
// 找到最後一個 ImpotDeclaration
let lastImport: ts.Node | undefined;
for (const importNode of allImports) {
if ( !lastImport || importNode.getStart() > lastImport.getStart() ) {
lastImport = importNode;
}
}
const importFaCoffee = '\nimport { faCoffee } from \'@fortawesome/free-solid-svg-icons\';';
// 將 importFaCoffee 字串插入到最後一個 ImpotDeclaration 的後面
const componentRecorder = _tree.beginUpdate(componentPath);
componentRecorder.insertLeft(lastImport!.end, importFaCoffee);
_tree.commitUpdate(componentRecorder);
// 印出結果
console.log(readIntoSourceFile(_tree, componentPath).text);
return _tree;
};
}
結果:
再來要幫 AppComponent
加個屬性:
export default function (_options: NgAddSchema): Rule {
return (_tree: Tree, _context: SchematicContext) => {
// 上略
// 找到 ClassDeclaration
const classDeclaration = componentSourceFile.statements.find( node => ts.isClassDeclaration(node) )! as ts.ClassDeclaration;
// 取得所有的 property
const allProperties = classDeclaration.members.filter( node => ts.isPropertyDeclaration(node) )! as ts.PropertyDeclaration[];
// 取得最後一個 propery
let lastProperty: ts.Node | undefined;
for (const propertyNode of allProperties) {
if ( !lastProperty || propertyNode.getStart() > propertyNode.getStart() ) {
lastProperty = propertyNode;
}
}
const componentRecorder = _tree.beginUpdate(componentPath);
const importFaCoffee = '\nimport { faCoffee } from \'@fortawesome/free-solid-svg-icons\';';
componentRecorder.insertLeft(lastImport!.end, importFaCoffee);
// 組合欲插入的程式碼字串
const faCoffeeProperty= 'faCoffee = faCoffee;'
const changeText = lastProperty ? lastProperty.getFullText() : '';
let toInsert = '';
if (changeText.match(/^\r?\r?\n/)) {
toInsert = `${changeText.match(/^\r?\n\s*/)![0]}${faCoffeeProperty}`;
} else {
toInsert = `\n ${faCoffeeProperty}\n`;
}
// 插入字串
if (lastProperty) {
componentRecorder.insertLeft(lastProperty!.end, toInsert);
} else {
componentRecorder.insertLeft(classDeclaration.end - 1, toInsert);
}
_tree.commitUpdate(componentRecorder);
// 印出結果
console.log(readIntoSourceFile(_tree, componentPath).text);
return _tree;
};
}
結果:
這個步驟的最後,是在 app.component.html
裡加上 <fa-icon [icon]="faCoffee"></fa-icon>
:
export default function (_options: NgAddSchema): Rule {
return (_tree: Tree, _context: SchematicContext) => {
// 上略
const htmlPath = `${defaultProjectPath}/app.component.html`;
const htmlStr = `\n<fa-icon [icon]="faCoffee"></fa-icon>\n`;
const htmlSourceFile = readIntoSourceFile(_tree, htmlPath);
const htmlRecorder = _tree.beginUpdate(htmlPath);
htmlRecorder.insertLeft(htmlSourceFile.end, htmlStr);
_tree.commitUpdate(htmlRecorder);
// 印出結果
console.log(readIntoSourceFile(_tree, componentPath).text);
return _tree;
};
}
結果:
跟前面第三個步驟一樣,這個步驟做完之後,一樣加上測試來驗證結果並保護我們的程式碼:
it('成功在預設專案路徑底下新增Font-awesome', async () => {
// 上略
// 驗證 Component 的內容
const componentContent = tree.readContent('/projects/hello/src/app/app.component.ts');
expect(componentContent).toMatch(/import.*faCoffee.*from '@fortawesome\/free-solid-svg-icons'/);
expect(componentContent).toContain('faCoffee = faCoffee;');
// 驗證 HTML 的內容
const htmlContent = tree.readContent('/projects/hello/src/app/app.component.html');
expect(htmlContent).toContain('<fa-icon [icon]="faCoffee"></fa-icon>');
});
it('成功在 "world" 專案路徑底下新增 Font-awesome', async () => {
// 上略
// 驗證 Component 的內容
const componentContent = tree.readContent('/projects/world/src/app/app.component.ts');
expect(componentContent).toMatch(/import.*faCoffee.*from '@fortawesome\/free-solid-svg-icons'/);
expect(componentContent).toContain('faCoffee = faCoffee;');
// 驗證 HTML 的內容
const htmlContent = tree.readContent('/projects/world/src/app/app.component.html');
expect(htmlContent).toContain('<fa-icon [icon]="faCoffee"></fa-icon>');
});
package.json
第三、第四個步驟都做完之後,最後就只剩下第二個步驟:使用 yarn
或是 npm
安裝 Font Awesome 套件。
需要安裝的套件如下:
@fortawesome/fontawesome-svg-core
@fortawesome/free-solid-svg-icons
@fortawesome/angular-fontawesome
首先我們可以先增加兩個函式,讓我們可以比較方便地修改 package.json
:
function addPackageToPackageJson(host: Tree, pkg: string, version: string): Tree {
if (host.exists('package.json')) {
const sourceText = host.read('package.json')!.toString('utf-8');
const json = JSON.parse(sourceText);
if (!json.dependencies) {
json.dependencies = {};
}
if (!json.dependencies[pkg]) {
json.dependencies[pkg] = version;
json.dependencies = sortObjectByKeys(json.dependencies);
}
host.overwrite('package.json', JSON.stringify(json, null, 2));
}
return host;
}
function sortObjectByKeys(obj: any) {
return Object.keys(obj).sort().reduce((result, key) => (result[key] = obj[key]) && result, {} as any);
}
接著只要加上以下程式碼就能修改 package.json
:
export default function (_options: NgAddSchema): Rule {
return (_tree: Tree, _context: SchematicContext) => {
// 上略
const dependencies = [
{ name: '@fortawesome/fontawesome-svg-core', version: '~1.2.25' },
{ name: '@fortawesome/free-solid-svg-icons', version: '~5.11.2' },
{ name: '@fortawesome/angular-fontawesome', version: '~0.5.0' }
];
dependencies.forEach(dependency => {
addPackageToPackageJson(
_tree,
dependency.name,
dependency.version
);
});
// 印出結果
console.log(readIntoSourceFile(_tree, 'package.json').text);
return _tree;
};
}
結果
最後一個步驟,也是最重要的一個步驟、本篇的重點,就是如何透過 Schematic 安裝依賴的 Package 。關於這點, Angular Schematics 非常貼心地已經幫我們準備好了一支 API - NodePackageInstallTask
。
只要透過這支 API ,就可以非常簡單地做到安裝 Package 的功能:
export default function (_options: NgAddSchema): Rule {
return (_tree: Tree, _context: SchematicContext) => {
// 上略
_context.addTask(
new NodePackageInstallTask({
packageName: dependencies.map(d => d.name).join(' ')
})
);
return _tree;
};
}
是不是超級簡單?!
至此,這個 ng-add
的 schematic 就已經將原作者所說的三個步驟濃縮成一個步驟了,而且還是只要透過一行短短指令就能完成,超讚的!而且可以找個 Angular 專案玩玩看,並且將它跑起來,看是不是真的會有個咖啡杯的 icon 在畫面上:
前面都有寫測試來驗證,最後當然也不免俗地要加上測試來驗證結果與保護程式碼囉:
it('成功在預設專案路徑底下新增Font-awesome', async () => {
// 上略
// 驗證 package.json 的內容
const packageJson = JSON.parse(tree.readContent('/package.json'));
const dependencies = packageJson.dependencies;
expect(dependencies['@fortawesome/fontawesome-svg-core']).toBeDefined();
expect(dependencies['@fortawesome/free-solid-svg-icons']).toBeDefined();
expect(dependencies['@fortawesome/angular-fontawesome']).toBeDefined();
// 驗證是否有執行安裝 package 的 task
expect(runner.tasks.some(task => task.name === 'node-package')).toBe(true);
});
it('成功在 "world" 專案路徑底下新增 Font-awesome', async () => {
// 上略
// 驗證 package.json 的內容
const packageJson = JSON.parse(tree.readContent('/package.json'));
const dependencies = packageJson.dependencies;
expect(dependencies['@fortawesome/fontawesome-svg-core']).toBeDefined();
expect(dependencies['@fortawesome/free-solid-svg-icons']).toBeDefined();
expect(dependencies['@fortawesome/angular-fontawesome']).toBeDefined();
// 驗證是否有執行安裝 package 的 task
expect(runner.tasks.some(task => task.name === 'node-package')).toBe(true);
});
今天的程式碼: https://github.com/leochen0818/angular-schematics-30days-challenge/tree/day14
相信經過今天扎實的實戰練習後,大家都已經學會如何製作一個支援 ng add
的 Schematic 了!
如果你有任何的問題,都可以在底下留言,我會抽空一一回覆。
明天筆者將介紹 ng update
的相關資訊,敬請期待!