前言
玩过Angular的同学都晓得Angular作为一个Framework,领有一套齐备的生态,还集成了弱小的CLI。而React则仅仅是一个轻量级的Library,官网社区只定义了一套组件的周期规定,而周边社区能够基于此规定实现本人的组件,React并不会提供给你一套开箱即用的计划,而须要本人在第三方市场筛选称心的组件造成“全家桶”,这也是React社区沉闷的起因之一。
最近工作中在思考应用monorepo对我的项目进行治理,发现了一套dev toolkit叫做Nx,Nx应用monorepo的形式对我的项目进行治理,其外围开发者vsavkin同时也是Angular我的项目的晚期核心成员之一,他把Angular CLI这套货色拿到Nx,使其不仅能够反对Angular我的项目的开发,当初还反对React我的项目。
Nx反对开发本人的plugin,一个plugin包含schematics和builders(这两个概念也别离来自Angular的schematics以及cli-builders),schematics按字面意思了解就是“大纲”的意思,也就是能够基于一些模板自动化生成所需的文件;而builders就是能够自定义构建流程。
明天要讲的就是如何开发一个属于本人的Nx plugin (蕴含schematics),我会应用它来自动化创立一个页面组件,同时更新router配置,主动将其退出react router的config。
对于Monorepo
这篇文章不会具体介绍什么是monorepo,mono有“单个”的意思,也就是单个仓库(所有我的项目放在一个仓库下治理),对应的就是polyrepo,也就是失常一个我的项目一个仓库。如下图所示:
更多对于monorepo的简介,能够浏览以下文章:
- Advantages of monorepos
- How to develop React apps like Facebook, Microsoft, and Google
- Misconceptions about Monorepos: Monorepo != Monolith
对于Nx plugin
先贴一张脑图,一个一个解说schematic的相干概念:
后面提到Nx plugin包含了builder(自动化构建)和schematic(自动化我的项目代码的增删改查)。一个成型的Nx plugin能够应用Nx内置命令执行。
对于文章要介绍的schematics,能够认为它是自动化代码生成脚本,甚至能够作为脚手架生成整个我的项目构造。
Schematics要实现的指标
Schematics的呈现优化了开发者的体验,晋升了效率,次要体现在以下几个方面:
- 同步式的开发体验,无需晓得外部的异步流程
Schematics的开发“感觉”上是同步的,也就是说每个操作输出都是同步的,然而输入则可能是异步的,不过开发者能够不必关注这个,直到上一个操作的后果实现前,下一个操作都不会执行。
- 开发好的schematics具备高扩展性和高重用性
一个schematic由很多操作步骤组成,只有“步骤”划分正当,扩大只须要往里面新增步骤即可,或者删除原来的步骤。同时,一个残缺的schematic也能够看做是一个大步骤,作为另一个schematic的前置或后置步骤,例如要开发一个生成Application的schematic,就能够复用原来的生成Component的schematic,作为其步骤之一。
- 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
的名字执行指令外,还能够应用schema1
。description
示意一段可选的形容内容。
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的两个外围概念:Tree和Rule
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
是一个函数,接管Tree
和Context
作为参数,返回一个新的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主函数,咱们在函数中返回一个Rule
,Rule
的操作是新建一个默认名为hello
的文件,文件中蕴含一个字符串world
,最初将这个Tree返回。
// src/schematics/your-schema/index.tsimport { 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.json
和src/schematics/page/schema.d.ts
,前者作为JSON Schema被Nx plugin应用,后者作为类型定义,开发时用到。
对于page,咱们须要提供两个必须选项:name和对应的project,两个可选选项:connect(是否connect to redux)、classComponent(应用类组件还是函数组件)。
上面别离是schema.json
和schema.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.ts
和index.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/
下。
基于模板文件创建所需文件和目录
咱们一共须要做四件事:
- 格式化选项(把schematic默认的选项进行加工,加工成咱们所需的全副选项)。
- 基于模板文件创建所需文件和目录。
- 更新
app/src/routers/config.ts
。 - 应用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自带的mergeWith
的Rule
,接管一个Source
,Source
的定义如下:
A source is a function that generates a Tree from a specific context.
也就是说Source()
会生成一棵新的Tree
。而后将其和原来的Tree
合并。
因为咱们须要从模板文件中加载,首先须要应用url
加载文件,url
接管文件或文件夹的绝对地址,返回一个Source
,而后咱们应用apply
对url
加载模板文件后的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如下(只含根节点信息):
咱们来层层剖析:
- 从申明到赋值,整段语句作为
Variable Statement
。 - 因为
routers
是被导出的,蕴含了ExportKeyword
。 - 从
routers = xxx
作为VariableDeclarationList
中的惟一一个VariableDeclaration
。 - 最初是
Identifier
“routers”,再到字面量表达式作为它的value。 - ...
因为上面代码用到了Initializer
,上述的对象字面量表达式ObjectLiteralExpression
就是routers
这个VariableDeclaration
的Initializer
:
看懂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 factoryexport 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!