NG Add Schematic

本文翻譯自鏈接
本文目的是學(xué)習(xí)如何構(gòu)建一個Angular schematic來添加庫

起步教程

如果你對于Angular Schematics是完全的新手,我推薦你從Angular Schematics Tutorial開始

AngularFire Schematics

安裝AngularFire很容易:

  1. 使用npm或者yarn來安裝firebase@angular/fire模塊
  2. 把你的Firebase工程的配置加到Angular的enviroment.ts文件中
  3. 將AngularFire模塊import到根模塊中

盡管這個很簡單,但是新建一個schematic幫你做所有的事情,包括為你的Firebase配置進(jìn)行命令行提示,會很有趣。

ng add

試一下這個:

$ ng new demo
$ cd demo
$ ng add angular-fire-schematics

demo:


ng-add-angular-fire-schematics.gif

目標(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"]
    }
  }
}

下面這些屬性是之前都說明了的:

  • description schematic的描述
  • factory 作為schematic的入口函數(shù)調(diào)用的函數(shù)。 在這里是src/ng-add/index.js文件的默認(rèn)輸出函數(shù)
  • schema
  • aliases schematic的別名們。這就是為什么你可以在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
}

值得注意的選項:

  • type Typescript中的類型
  • 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()是一個接收TreeSchematicContext對象的,返回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/firefirebase依賴都被添加進(jìn)了沙盒引用的package.json文件中

ng-add-schematic-add-package-dependency.gif

安裝依賴

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é)果如下:


ng-add-schematic-install-package-dependency.gif

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

計劃是:

  1. environment.ts文件中添加配置信息
  2. enviroment.ts import到app.moduls.ts文件中
  3. 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
  );
}

首先我們使用Treeread()函數(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ù)。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容