iT邦幫忙

第 11 屆 iT 邦幫忙鐵人賽

DAY 15
0
Modern Web

高效 Coding 術:Angular Schematics 實戰三十天系列 第 15

[高效 Coding 術:Angular Schematics 實戰三十天] Day14 - 實戰 ng add

相信經過昨天的原理說明之後,大家一定迫不及待地想要趕快實作了!!

廢話不囉嗦,不管你是要新開一個 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 專案內。

先來看看原作者說的五個步驟:

  1. 使用 ng new 建立新專案。
  2. 使用 yarn 或是 npm 安裝 Font Awesome 套件。
  3. FortAwesomeModule 加到 AppModuleimports 裡。
  4. 新增 Font Awesome 的 CSS Class 。
  5. 使用 ng serve 將專案跑起來。

在這五個步驟中,除了第一個與第五個步驟之外,其他三個步驟其實都可以交由 Schematic 來完成,使其從個步驟濃縮成個步驟。

因此,我們就來做這個吧!

增加對 Angular 專案的判斷

首先先暫時跳過第二個步驟,我們先來看第三個步驟:

FortAwesomeModule 加到 AppModuleimports 裡。

這件事之前就做過了,還記得嗎?我們可以先從之前的練習把一些已經做過的程式碼複製過來:

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

新增 NgAddSchema

先把 /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.tsNgAddSchema 套上:

// 將 _options 宣告為 NgAddSchema
export default function (_options: NgAddSchema): Rule {
  return (_tree: Tree, _context: SchematicContext) => {
    // 略
  };
}

addImportToModule 的應用

再來是實際將 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);
  });
});

驗證結果:

Imgur

第一次重構

當已經有測試保護我們的程式碼之後,重構就變得非常輕鬆、愜意了。由於之後還需要重複讀檔,所以我們將先將讀取檔案這塊抽出來,讓之後重複使用時更方便:

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;
  };
}

結果:

Imgur

再來要幫 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;
  };
}

結果:

Imgur

這個步驟的最後,是在 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;
  };
}

結果:

Imgur

用測試驗證(二)

跟前面第三個步驟一樣,這個步驟做完之後,一樣加上測試來驗證結果並保護我們的程式碼:

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;
  };
}

結果

Imgur

安裝依賴的 Package

最後一個步驟,也是最重要的一個步驟、本篇的重點,就是如何透過 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 在畫面上:

Imgur

用測試驗證(三)

前面都有寫測試來驗證,最後當然也不免俗地要加上測試來驗證結果與保護程式碼囉:

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 的相關資訊,敬請期待!

參考資料


上一篇
[高效 Coding 術:Angular Schematics 實戰三十天] Day13 - ng add?
下一篇
[高效 Coding 術:Angular Schematics 實戰三十天] Day15 - ng update?
系列文
高效 Coding 術:Angular Schematics 實戰三十天32

尚未有邦友留言

立即登入留言