玩過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,mono有「單個」的意思,也就是單個倉庫(全部項目放在一個倉庫下管理),對應的就是polyrepo,也就是正常一個項目一個倉庫。以下圖所示:github
更多關於monorepo的簡介,能夠閱讀如下文章:typescript
先貼一張腦圖,一個一個講解schematic的相關概念:shell
前面提到Nx plugin包括了builder(自動化構建)和schematic(自動化項目代碼的增刪改查)。一個成型的Nx plugin可使用Nx內置命令執行。json
對於文章要介紹的schematics,能夠認爲它是自動化代碼生成腳本,甚至能夠做爲腳手架生成整個項目結構。redux
Schematics的出現優化了開發者的體驗,提高了效率,主要體如今如下幾個方面:數組
Schematics的開發「感受」上是同步的,也就是說每一個操做輸入都是同步的,可是輸出則多是異步的,不過開發者能夠不用關注這個,直到上一個操做的結果完成前,下一個操做都不會執行。
一個schematic由不少操做步驟組成,只要「步驟」劃分合理,擴展只須要往裏面新增步驟便可,或者刪除原來的步驟。同時,一個完整的schematic也能夠看作是一個大步驟,做爲另外一個schematic的前置或後置步驟,例如要開發一個生成Application的schematic,就能夠複用原來的生成Component的schematic,做爲其步驟之一。
傳統的一些腳本,當其中一個步驟發生錯誤,因爲以前步驟的更改已經應用到文件系統上,會形成許多「反作用」,須要咱們手動FIX。可是schematic對於每項操做都是記錄在運行內存中,當其中一項步驟確認無誤後,也只會更新其內部建立的一個虛擬文件系統,只有當全部步驟確認無誤後,纔會一次性更新文件系統,而當其中之一有誤時,會撤銷以前所作的全部更改,對文件系統不會有「反作用」。
接下來咱們瞭解下和schematic有關的概念。
在瞭解相關概念前,先看看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包含了一組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
的介紹:
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,知道確認無誤後,纔會一次性對文件系統作出變動。
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.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
,上面已經提到過,對於Schematics,是在一個名叫SchematicContext
的Context下執行,其中包含了一些默認的工具,例如context.logger
,咱們可使用其打印一些終端信息。
下面的全部代碼都可以在個人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項目,則直接在項目根目錄下使用如下命令建立一個plugin:
$ nx g @nrwl/nx-plugin:plugin [pluginName]
若是是剛使用Nx,也可使用下面的命令快速新建一個項目,並自動添加一個plugin:
$ npx create-nx-plugin my-org --pluginName my-plugin
如今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; }
模板文件就是經過一些模板變量來生成真正的文件。每個頁面默認有兩個文件,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/
下。
咱們一共須要作四件事:
app/src/routers/config.ts
。先來實現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
移動到對應的目錄下便可。
來到了最重要也是比較難的一個步驟,咱們還須要修改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()
轉換成對象字面量,而後進行簡單追加,後面發現這段內容裏還可能包含註釋,因此只能經過正則匹配肯定字面量的「尾部部分」,而後進行匹配追加。
操做完成後咱們可使用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!