NG Add Schematic

本文翻译自链接
本文目的是学习如何构建一个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配置进行命令行提示,会很有趣。

ng add

试一下这个:

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

demo:


ng-add-angular-fire-schematics.gif

目标

我的目标是你能学到:

  • 如何新建一个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()是一个接收TreeSchematicContext对象的,返回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/firefirebase依赖都被添加进了沙盒引用的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来完成我们的要求就很简单。

接下来要构建和运行schematic,结果如下:


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

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

计划是:

  1. environment.ts文件中添加配置信息
  2. enviroment.ts import到app.moduls.ts文件中
  3. 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()工具函数来得到SourceFileAST中的一个数组的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
  );
}

首先我们使用Treeread()函数来得到文件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和发现他们提供并使用的众多工具函数。

你可能感兴趣的:(NG Add Schematic)