前言
玩过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 | Rule | Promise | Promise | 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
最后是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;
type DispatchProps = ReturnType;
type Props = StateProps & DispatchProps & RouteComponentProps;
<% } else { %>
type Props = RouteComponentProps;
<% } %>
<% if (classComponent) { %>
class <%= componentName %> extends Component {
render() {
return (
Welcome to <%= componentName %>!
);
}
}
<% } else { %>
const <%= componentName %> = (props: Props) => {
return (
Welcome to <%= componentName %>!
);
};
<% } %>
<% if (connect) { %>
function mapState(state: IRootState) {
return {
}
}
function mapDispatch(dispatch: Dispatch) {
return {
}
}
<% } %>
<% if (connect) { %>
export default connect(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 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 {
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!