如何寫一個Nx schematic plugin?

前言

玩過Angular的同窗都知道Angular做爲一個Framework,擁有一套完備的生態,還集成了強大的CLI。而React則僅僅是一個輕量級的Library,官方社區只定義了一套組件的週期規則,而周邊社區能夠基於此規則實現本身的組件,React並不會提供給你一套開箱即用的方案,而須要本身在第三方市場挑選滿意的組件造成「全家桶」,這也是React社區活躍的緣由之一。css

最近工做中在考慮使用monorepo對項目進行管理,發現了一套dev toolkit叫作Nx,Nx使用monorepo的方式對項目進行管理,其核心開發者vsavkin同時也是Angular項目的早期核心成員之一,他把Angular CLI這套東西拿到Nx,使其不只能夠支持Angular項目的開發,如今還支持React項目。node

Nx支持開發本身的plugin,一個plugin包括schematics和builders(這兩個概念也分別來自Angular的schematics以及cli-builders),schematics按字面意思理解就是「綱要」的意思,也就是能夠基於一些模板自動化生成所需的文件;而builders就是能夠自定義構建流程。react

今天要講的就是如何開發一個屬於本身的Nx plugin (包含schematics),我會使用它來自動化建立一個頁面組件,同時更新router配置,自動將其加入react router的config。git

關於Monorepo

這篇文章不會詳細介紹什麼是monorepo,mono有「單個」的意思,也就是單個倉庫(全部項目放在一個倉庫下管理),對應的就是polyrepo,也就是正常一個項目一個倉庫。以下圖所示:github

Polyrepo vs Monorepo

更多關於monorepo的簡介,能夠閱讀如下文章:typescript

  1. Advantages of monorepos
  2. How to develop React apps like Facebook, Microsoft, and Google
  3. Misconceptions about Monorepos: Monorepo != Monolith

關於Nx plugin

先貼一張腦圖,一個一個講解schematic的相關概念:shell

Nx plugin mindmap

前面提到Nx plugin包括了builder(自動化構建)和schematic(自動化項目代碼的增刪改查)。一個成型的Nx plugin可使用Nx內置命令執行。json

對於文章要介紹的schematics,能夠認爲它是自動化代碼生成腳本,甚至能夠做爲腳手架生成整個項目結構。redux

Schematics要實現的目標

Schematics的出現優化了開發者的體驗,提高了效率,主要體如今如下幾個方面:數組

  1. 同步式的開發體驗,無需知道內部的異步流程

    Schematics的開發「感受」上是同步的,也就是說每一個操做輸入都是同步的,可是輸出則多是異步的,不過開發者能夠不用關注這個,直到上一個操做的結果完成前,下一個操做都不會執行。

  2. 開發好的schematics具備高擴展性和高重用性

    一個schematic由不少操做步驟組成,只要「步驟」劃分合理,擴展只須要往裏面新增步驟便可,或者刪除原來的步驟。同時,一個完整的schematic也能夠看作是一個大步驟,做爲另外一個schematic的前置或後置步驟,例如要開發一個生成Application的schematic,就能夠複用原來的生成Component的schematic,做爲其步驟之一。

  3. schematic是原子操做

    傳統的一些腳本,當其中一個步驟發生錯誤,因爲以前步驟的更改已經應用到文件系統上,會形成許多「反作用」,須要咱們手動FIX。可是schematic對於每項操做都是記錄在運行內存中,當其中一項步驟確認無誤後,也只會更新其內部建立的一個虛擬文件系統,只有當全部步驟確認無誤後,纔會一次性更新文件系統,而當其中之一有誤時,會撤銷以前所作的全部更改,對文件系統不會有「反作用」。

接下來咱們瞭解下和schematic有關的概念。

Schematics的相關概念

在瞭解相關概念前,先看看Nx生成的初始plugin目錄:

your-plugin
    |--.eslintrc
    |--builders.json
    |--collection.json
    |--jest.config.js
    |--package.json
    |--tsconfig.json
    |--tsconfig.lib.json
    |--tsconfig.spec.json
    |--README.md
    |--src
        |--builders
        |--schematics
            |--your-schema
                |--your-schema.ts
                |--your-schema.spec.ts
                |--schema.json
                |--schema.d.ts

Collection

Collection包含了一組Schematics,定義在plugin主目錄下的collection.json

{
  "$schema": "../../node_modules/@angular-devkit/schematics/collection-schema.json",
  "name": "your-plugin",
  "version": "0.0.1",
  "schematics": {
    "your-schema": {
      "factory": "./src/schematics/your-schema/your-schema",
      "schema": "./src/schematics/your-schema/schema.json",
      "aliases": ["schema1"],
      "description": "Create foo"
    }
  }
}

上面的json文件使用@angular-devkit/schematics下的collection schema來校驗格式,其中最重要的是schematics字段,在這裏面定義全部本身寫的schematics,好比這裏定義了一個叫作"your-schema"的schematic,每一個schematic下須要聲明一個rule factory(關於rule以後介紹),該factory指向一個文件中的默認導出函數,若是不使用默認導出,還可使用your-schema#foo的格式指定當前文件中導出的foo函數。

aliases聲明瞭當前schematic的別名,除了使用your-schema的名字執行指令外,還可使用schema1description表示一段可選的描述內容。

schema定義了當前schematic的schema json定義,nx執行該schematic指令時能夠讀取裏面設置的默認選項,進行終端交互提示等等,下面是一份schema.json

{
  "$schema": "http://json-schema.org/schema",
  "id": "your-schema",
  "title": "Create foo",
  "examples": [
    {
      "command": "g your-schema --project=my-app my-foo",
      "description": "Generate foo in apps/my-app/src/my-foo"
    }
  ],
  "type": "object",
  "properties": {
    "project": {
      "type": "string",
      "description": "The name of the project.",
      "alias": "p",
      "$default": {
        "$source": "projectName"
      },
      "x-prompt": "What is the name of the project for this foo?"
    },
    "name": {
      "type": "string",
      "description": "The name of the schema.",
      "$default": {
        "$source": "argv",
        "index": 0
      },
      "x-prompt": "What name would you like to use for the schema?"
    },
    "prop3": {
      "type": "boolean",
      "description": "prop3 description",
      "default": true
    }
  },
  "required": ["name", "project"]
}

properties表示schematic指令執行時的選項,第一個選項project表示項目名,別名p,使用$default表示Angular內置的一些操做,例如$source: projectName則表示若是沒有聲明project,會使用Angular workspaceSchema(nx中爲workspace.json)中的defaultProject選項,而第二個選項的$default則代表使用命令時的第一個參數做爲name

x-prompt會在用戶不鍵入選項值時的交互,用來提示用戶輸入,用戶能夠不用預先知道全部選項也能完成操做,更復雜的x-prompt配置請查閱官網

說了這麼多,如下是幾個直觀交互的例子,幫助你們理解:

nx使用generate選項來調用plugin中的schematic或者builder,和Angular的ng generate一致:

# 表示在 apps/app1/src/ 下生成一個名爲bar的文件

$ nx g your-plugin:your-schema bar -p=app1
# 或者
$ nx g your-plugin:your-schema -name=bar -project app1

若是使用交互(不鍵入選項)

# 表示在 apps/app1/src/ 下生成一個名爲bar的文件

$ nx g your-plugin:your-schema
? What is the name of the project for this foo?
$ app1
? What name would you like to use for the schema?
$ bar

接下來看看Schematics的兩個核心概念:TreeRule

Tree

根據官方對Tree的介紹:

The virtual file system is represented by a Tree. The Tree data structure contains a base (a set of files that already exists) and a staging area (a list of changes to be applied to the base). When making modifications, you don't actually change the base, but add those modifications to the staging area.

Tree這一結構包含了兩個部分:VFS和Staging area,VFS是當前文件系統的一個虛擬結構,Staging area則存放schematics中所作的更改。值得注意的是,當作出更改時,並非對文件系統的及時更改,而只是將這些操做放在Staging area,以後會把更改逐步同步到VFS,知道確認無誤後,纔會一次性對文件系統作出變動。

Rule

A Rule object defines a function that takes a Tree, applies transformations, and returns a new Tree. The main file for a schematic, index.ts, defines a set of rules that implement the schematic's logic.

Rule是一個函數,接收TreeContext做爲參數,返回一個新的Tree,在schematics的主文件index.ts中,能夠定義一系列的Rule,最後將這些Rule做爲一個綜合的Rule在主函數中返回,就完成了一個schematic。下面是Tree的完整定義:

export declare type Rule = (tree: Tree, context: SchematicContext) => Tree | Observable<Tree> | Rule | Promise<void> | Promise<Rule> | void;

來看看一個簡單的schematic主函數,咱們在函數中返回一個RuleRule的操做是新建一個默認名爲hello的文件,文件中包含一個字符串world,最後將這個Tree返回。

// src/schematics/your-schema/index.ts

import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics';

// You don't have to export the function as default. You can also have more than one rule factory
// per file.
export function myComponent(options: any): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    tree.create(options.name || 'hello', 'world');
    return tree;
  };
}

Context

最後是Context,上面已經提到過,對於Schematics,是在一個名叫SchematicContext的Context下執行,其中包含了一些默認的工具,例如context.logger,咱們可使用其打印一些終端信息。

如何開發一個Nx Schematic?

下面的全部代碼都可以在個人GitHub裏下載查看,以爲不錯的話,歡迎你們star。

接下來進入正題,咱們開發一個nx plugin schematic,使用它來建立咱們的頁面組件,同時更新路由配置。

假設咱們的項目目錄結構以下:

apps
    |...
    |--my-blog
        |...
        |--src
            |--components
            |--pages
                |--home
                    |--index.ts
                    |--index.scss
                |--about
            |--routers
                |--config.ts
                |--index.ts
            |...

router/config.ts文件內容以下:

export const routers = {
  // 首頁
  '/': 'home',
  // 我的主頁
  '/about': 'about'
};

如今咱們要新增一個博客頁,很多同窗可能就直接新建一個目錄,複製首頁代碼,最後手動添加一條路由配置,對於這個例子卻是還好,可是若是須要更改的地方不少,就很浪費時間了,學習了Nx plugin schematics,這一切均可以用Schematic實現。

搭建Nx環境並使用Nx默認的Schematic建立一個plugin

若是以前已經有了Nx項目,則直接在項目根目錄下使用如下命令建立一個plugin:

$ nx g @nrwl/nx-plugin:plugin [pluginName]

若是是剛使用Nx,也可使用下面的命令快速新建一個項目,並自動添加一個plugin:

$ npx create-nx-plugin my-org --pluginName my-plugin

設置好Schematic選項定義

如今Nx爲咱們建立了一個默認的plugin,首先更改packages/plugin/collection.json,爲schema取名叫作「page」

{
  "$schema": "../../node_modules/@angular-devkit/schematics/collection-schema.json",
  "name": "plugin",
  "version": "0.0.1",
  "schematics": {
    "page": {
      "factory": "./src/schematics/page/page",
      "schema": "./src/schematics/page/schema.json",
      "description": "Create page component"
    }
  }
}

接下來定義咱們提供的schema option,這裏須要修改src/schematics/page/schema.jsonsrc/schematics/page/schema.d.ts,前者做爲JSON Schema被Nx plugin使用,後者做爲類型定義,開發時用到。

對於page,咱們須要提供兩個必須選項:name和對應的project,兩個可選選項:connect(是否connect to redux)、classComponent(使用類組件仍是函數組件)。

下面分別是schema.jsonschema.d.ts

{
  "$schema": "http://json-schema.org/draft-07/schema",
  "id": "page",
  "title": "Create page component",
  "type": "object",
  "properties": {
    "name": {
      "type": "string",
      "description": "The name of the page component",
      "$default": {
        "$source": "argv",
        "index": 0
      },
      "x-prompt": "What name would you like to use?"
    },
    "project": {
      "type": "string",
      "description": "The project of the page component",
      "$default": {
        "$source": "projectName"
      },
      "alias": "p",
      "x-prompt": "Which projcet would you like to add to?"
    },
    "classComponent": {
      "type": "boolean",
      "alias": "C",
      "description": "Use class components instead of functional component.",
      "default": false
    },
    "connect": {
      "type": "boolean",
      "alias": "c",
      "description": "Create a connected redux component",
      "default": false
    }
  },
  "required": ["name", "project"]
}
export interface PageSchematicSchema {
  name: string;
  project: string;
  classComponent: boolean;
  connected: boolean;
}

開發Schematic

建立所需模板文件

模板文件就是經過一些模板變量來生成真正的文件。每個頁面默認有兩個文件,index.tsindex.scss,所以建立模板文件以下:

index.ts.template

<% if (classComponent) { %>
import React, { Component } from 'react';
<% } else { %>
import React from 'react';
<% } %>
<% if (connect) { %>
import { connect } from 'react-redux';
import { IRootState, Dispatch } from '../../store';
<% } %>

import { RouteComponentProps } from 'react-router-dom';
import './index.scss';

<% if (connect) { %>
type StateProps = ReturnType<typeof mapState>;
type DispatchProps = ReturnType<typeof mapDispatch>;
type Props = StateProps & DispatchProps & RouteComponentProps;
<% } else { %>
type Props = RouteComponentProps;
<% } %>


<% if (classComponent) { %>
class <%= componentName %> extends Component<Props> {
  render() {
    return (
      <div className="<% className %>">
        Welcome to <%= componentName %>!
      </div>
    );
  }
}
<% } else { %>
const <%= componentName %> = (props: Props) => {
  return (
    <div className="<%= className %>">
      Welcome to <%= componentName %>!
    </div>
  );
};
<% } %>

<% if (connect) { %>
function mapState(state: IRootState) {
  return {

  }
}

function mapDispatch(dispatch: Dispatch) {
  return {

  }
}
<% } %>

<% if (connect) { %>
export default connect<StateProps, DispatchProps, {}>(mapState, mapDispatch)(<%= componentName %>);
<% } else { %>
export default <%= componentName %>;
<% } %>

index.scss.template

.<%= className %> {
    
}

咱們將模板文件放到src/schematics/page/files/下。

基於模板文件建立所需文件和目錄

咱們一共須要作四件事:

  1. 格式化選項(把schematic默認的選項進行加工,加工成咱們所需的所有選項)。
  2. 基於模板文件建立所需文件和目錄。
  3. 更新app/src/routers/config.ts
  4. 使用eslint格式化排版。

先來實現1和2:

page.ts:

import { PageSchematicSchema } from './schema';
import { names } from '@nrwl/workspace';
import { getProjectConfig } from '@nrwl/workspace/src/utils/ast-utils';

interface NormalizedSchema extends PageSchematicSchema {
  /** element className */
  className: string;
  componentName: string;
  fileName: string;
  projectSourceRoot: Path;
}

/** 加工選項 */
function normalizeOptions(
  host: Tree,
  options: PageSchematicSchema
): NormalizedSchema {
  const { name, project } = options;
  const { sourceRoot: projectSourceRoot } = getProjectConfig(host, project);
  // kebab-case fileName and UpperCamelCase className
  const { fileName, className } = names(name);
  return {
    ...options,
    // element className
    className: `${project}-${fileName}`,
    projectSourceRoot,
    componentName: className,
    fileName,
  };
}

接下來使用模板文件:

page.ts

import { join } from '@angular-devkit/core';
import {
  Rule,
  SchematicContext,
  mergeWith,
  apply,
  url,
  move,
  applyTemplates,
} from '@angular-devkit/schematics';
import { PageSchematicSchema } from './schema';
import { names } from '@nrwl/workspace';
import { getProjectConfig } from '@nrwl/workspace/src/utils/ast-utils';

interface NormalizedSchema extends PageSchematicSchema {
  /** element className */
  className: string;
  componentName: string;
  fileName: string;
  projectSourceRoot: Path;
}

/** 基於模板建立文件 */
function createFiles(options: NormalizedSchema): Rule {
  const { projectSourceRoot, fileName } = options;
  const targetDir = join(projectSourceRoot, pagePath, fileName);
  return mergeWith(
    apply(url('./files'), [applyTemplates(options), move(targetDir)])
  );
}

原理是使用Angular Schematics自帶的mergeWithRule,接收一個SourceSource的定義以下:

A source is a function that generates a Tree from a specific context.

也就是說Source()會生成一棵新的Tree。而後將其和原來的Tree合併。

因爲咱們須要從模板文件中加載,首先須要使用url加載文件,url接收文件或文件夾的相對地址,返回一個Source,而後咱們使用applyurl加載模板文件後的Source進行加工,apply接收一個Source和一個Rule的數組,將Rule[]應用後返回一個新的Source

這裏咱們須要進行兩種「加工」,首先使用options替換模板文件中的變量,最後將這些文件使用move移動到對應的目錄下便可。

更新router config

來到了最重要也是比較難的一個步驟,咱們還須要修改src/routers/config.ts中的routers變量,在裏面增長咱們剛加上的page component。

因爲這裏是TS文件,因此須要分析TS的AST (Abstract Syntax Tree),而後修改AST,最後使用修改的AST對原來內容進行覆蓋便可。

修改AST可使用TS官方的Compiler API結合TypeScript AST Viewer進行。不過因爲AST的複雜結構,TS Compiler API也不太友好,直接使用API對AST進行操做很是困難。例如AST的每一個節點都有position信息,作一個新的插入時,還須要對position進行計算,API並無人性化的操做方式。

因爲上面的緣由,我最終選擇了ts-morph,ts-morph之前也叫作ts-simple-ast,它封裝了TS Compiler API,讓操做AST變得簡單易懂。

看代碼以前,咱們先使用TS AST Viewer分析一下routers/config.ts這段代碼的AST:

export const routers = {
  // 首頁
  '/': 'home',
  // 第二頁
  '/about': 'about'
};

AST以下(只含根節點信息):

咱們來層層分析:

  1. 從聲明到賦值,整段語句做爲Variable Statement
  2. 因爲routers是被導出的,包含了ExportKeyword
  3. routers = xxx做爲VariableDeclarationList中的惟一一個VariableDeclaration
  4. 最後是Identifier「routers」,再到字面量表達式做爲它的value。
  5. ...

因爲下面代碼用到了Initializer,上述的對象字面量表達式ObjectLiteralExpression就是routers這個VariableDeclarationInitializer

看懂AST後,更新router後的代碼就容易理解了:

import { join, Path } from '@angular-devkit/core';
import {
  Rule,
  Tree,
  chain,
  SchematicContext,
  mergeWith,
  apply,
  url,
  move,
  applyTemplates,
} from '@angular-devkit/schematics';
import { PageSchematicSchema } from './schema';
import { formatFiles, names } from '@nrwl/workspace';
import { getProjectConfig } from '@nrwl/workspace/src/utils/ast-utils';
import { Project } from 'ts-morph';

/** 更新路由配置 */
function updateRouterConfig(options: NormalizedSchema): Rule {
  return (host: Tree, context: SchematicContext) => {
    const { projectSourceRoot, fileName } = options;
    const filePath = join(projectSourceRoot, routerConfigPath);
    const srcContent = host.read(filePath).toString('utf-8');
    
    // 使用ts-morph的project對AST進行操做
    const project = new Project();
    const srcFile = project.createSourceFile(filePath, srcContent, {
      overwrite: true,
    });
    try {
      // 根據變量標識符拿到對應的VariableDeclaration
      const decl = srcFile.getVariableDeclarationOrThrow(
        routerConfigVariableName
      );
      // 獲取initializer並轉換成string
      const initializer = decl.getInitializer().getText();
      // 使用正則匹配對象字面量的最後一部分並作插入
      const newInitializer = initializer.replace(
        /,?\s*}$/,
        `,'/${fileName}': '${fileName}' }`
      );
      // 更新initializer
      decl.setInitializer(newInitializer);
      // 獲取最新的TS文件內容對源文件進行覆蓋
      host.overwrite(filePath, srcFile.getFullText());
    } catch (e) {
      context.logger.error(e.message);
    }
  };
}

在如何對Initializer進行操做時,我最開始想到的是將其使用JSON.parse()轉換成對象字面量,而後進行簡單追加,後面發現這段內容裏還可能包含註釋,因此只能經過正則匹配肯定字面量的「尾部部分」,而後進行匹配追加。

使用eslint作好排版

操做完成後咱們可使用Nx workspace提供的formatFiles將全部文件排版有序。最後咱們只須要在默認導出函數裏將上述Rule經過chain這個Rule進行彙總。來看看最終代碼:

import { join, Path } from '@angular-devkit/core';
import {
  Rule,
  Tree,
  chain,
  SchematicContext,
  mergeWith,
  apply,
  url,
  move,
  applyTemplates,
} from '@angular-devkit/schematics';
import { PageSchematicSchema } from './schema';
import { formatFiles, names } from '@nrwl/workspace';
import { getProjectConfig } from '@nrwl/workspace/src/utils/ast-utils';
import { Project } from 'ts-morph';

interface NormalizedSchema extends PageSchematicSchema {
  /** element className */
  className: string;
  componentName: string;
  fileName: string;
  projectSourceRoot: Path;
}

// 頁面組件目錄
const pagePath = 'pages';
// 路由配置目錄
const routerConfigPath = 'routers/config.ts';
// 路由配置文件中須要修改的變量名
const routerConfigVariableName = 'routers';

/** 加工選項 */
function normalizeOptions(
  host: Tree,
  options: PageSchematicSchema
): NormalizedSchema {
  const { name, project } = options;
  const { sourceRoot: projectSourceRoot } = getProjectConfig(host, project);
  // kebab-case fileName and UpperCamelCase className
  const { fileName, className } = names(name);
  return {
    ...options,
    // element className
    className: `${project}-${fileName}`,
    projectSourceRoot,
    componentName: className,
    fileName,
  };
}

/** 基於模板建立文件 */
function createFiles(options: NormalizedSchema): Rule {
  const { projectSourceRoot, fileName } = options;
  const targetDir = join(projectSourceRoot, pagePath, fileName);
  return mergeWith(
    apply(url('./files'), [applyTemplates(options), move(targetDir)])
  );
}

/** 更新路由配置 */
function updateRouterConfig(options: NormalizedSchema): Rule {
  return (host: Tree, context: SchematicContext) => {
    const { projectSourceRoot, fileName } = options;
    const filePath = join(projectSourceRoot, routerConfigPath);
    const srcContent = host.read(filePath).toString('utf-8');
    const project = new Project();

    const srcFile = project.createSourceFile(filePath, srcContent, {
      overwrite: true,
    });
    try {
      const decl = srcFile.getVariableDeclarationOrThrow(
        routerConfigVariableName
      );
      const initializer = decl.getInitializer().getText();
      const newInitializer = initializer.replace(
        /,?\s*}$/,
        `,'/${fileName}': '${fileName}' }`
      );
      decl.setInitializer(newInitializer);
      host.overwrite(filePath, srcFile.getFullText());
    } catch (e) {
      context.logger.error(e.message);
    }
  };
}

// 默認的rule factory
export default function (schema: PageSchematicSchema): Rule {
  return function (host: Tree, context: SchematicContext) {
    const options = normalizeOptions(host, schema);
    return chain([
      createFiles(options),
      updateRouterConfig(options),
      formatFiles({ skipFormat: false }),
    ]);
  };
}

測試

寫好了schematic,別忘了進行測試,測試代碼以下:

page.spec.ts

import { Tree, Rule } from '@angular-devkit/schematics';
import { SchematicTestRunner } from '@angular-devkit/schematics/testing';
import { createEmptyWorkspace } from '@nrwl/workspace/testing';
import { join } from 'path';
import { PageSchematicSchema } from './schema';
import { updateWorkspace, names } from '@nrwl/workspace';

const testRunner = new SchematicTestRunner(
  '@plugindemo/plugin',
  join(__dirname, '../../../collection.json')
);

export function callRule(rule: Rule, tree: Tree) {
  return testRunner.callRule(rule, tree).toPromise();
}

export async function createFakeApp(tree: Tree, appName: string): Promise<Tree> {
  const { fileName } = names(appName);

  const appTree = await callRule(
    updateWorkspace((workspace) => {
      workspace.projects.add({
        name: fileName,
        root: `apps/${fileName}`,
        projectType: 'application',
        sourceRoot: `apps/${fileName}/src`,
        targets: {},
      });
    }),
    tree
  );
  appTree.create(
    'apps/app1/src/routers/config.ts',
    `
    export const routers = {
      // 首頁
      '/': 'home',
      // 我的主頁
      '/about': 'about'
    };
  `
  );
  return Promise.resolve(appTree);
}

describe('plugin schematic', () => {
  let appTree: Tree;
  const options: PageSchematicSchema = { name: 'myPage', project: 'app1' };

  beforeEach(async () => {
    appTree = createEmptyWorkspace(Tree.empty());
    appTree = await createFakeApp(appTree, 'app1');
  });

  it('should run successfully', async () => {
    const tree = await testRunner.runSchematicAsync('page', options, appTree).toPromise();
    // file exist
    expect(
      tree.exists('apps/app1/src/pages/my-page/index.tsx')
    ).toBeTruthy();
    expect(
      tree.exists('apps/app1/src/pages/my-page/index.scss')
    ).toBeTruthy();

    // router modified correctly
    const configContent = tree.readContent('apps/app1/src/routers/config.ts');
    expect(configContent).toMatch(/,\s*'\/my-page': 'my-page'/);
  });
});

測試這塊能用的輪子也比較多,我這裏簡單建立了一個假的App(符合上面說的目錄結構),而後進行了一下簡單測試。測試可使用以下指令對plugin中的單個schematic進行測試:

$ nx test plugin --testFile page.spec.ts

若是寫的plugin比較複雜,建議再進行一遍end2end測試,Nx對e2e的支持也很好。

發佈

最後到了發佈環節,使用Nx build以後即可以自行發佈了。

$ nx build plugin

上述全部代碼都可以在個人GitHub裏下載查看,同時代碼裏還增長了一個真實開發環境下的App-demo,裏面將plugin引入了正常的開發流程,更能感覺到其帶來的便捷性。以爲不錯的話,歡迎你們star。

總結

其實要寫好這類對文件系統「增刪改查」的工具,關鍵仍是要理解文件內容,好比上面的難點就在於理解TS文件的AST。使用ts-morph還能夠作不少事情,好比咱們每增長一個文件,可能須要在出口index.ts中導出一次,使用ts-morph就一句話的事情:

const exportDeclaration = sourceFile.addExportDeclaration({
    namedExports: ["MyClass"],
    moduleSpecifier: "./file",
});

固然,Nx和Angular提供了這一套生態,能用的工具和方法很是多,可是也須要咱們耐心查閱,合理使用。目前來講Nx封裝的方法沒有詳細的文檔,可能用起來須要直接查閱d.ts文件,沒那麼方便。

工欲善其事,必先利其器。Happy Coding!

相關文章
相關標籤/搜索