封装一个NestJs中的自动导入模块的方法
要做什么?
- 我们先来看几张代码
- 不知道大家看了这两张代码是什么感受呢? 反正我的感受是不太好的😄, 每次添加一个模块都要在app.module中引入并且注册, 这样是很麻烦的, 我们今天就来解决这个问题.
为什么做?
- 最近姐夫找到我让我做一个xx官网, 我一听那肯定没问题呀, 很爽快就接了下来, 并且拍着胸脯保证 说着: 没问题~没问题~. 正好姐夫也不是太着急并且是我一个人做, 所以这一次就想用一点激进一点的技术或者说是陌生一点的技术, 这其中就有NestJs来作为后端的框架使用, 前端使用的是Quasar CLI 搭建的项目.
- NestJs和Quasar在之前作者都是用过的, 为什么说陌生呢? 因为之前在使用过程中, 也仅仅是使用, 并没有刨根问底的学习, 仅仅为了使用而使用, 没有任何意义, 所以这一次相趁这次机会好好探索一下其中的奥妙 (不知道为什么, 近期非常喜欢看源码).
第一步: 思路
- 我们想要在
node
中引入(导入)一个模块, 一般会经常用到两个方法(require(), import())
和一个关键字(import)
, 因为我们做的是动态导入或者说编程式导入, 所以其中的关键字肯定是用不了了, 所以我们能选择的就只剩下两个方法了, 这里我们选用import()
方法来做, 因为我们是要做一个ts版的包, 用require()
的话不太好, 而且import()
方法返回Promise
, 更好控制一点 (最后差点因为Promise被害死). - 我们想写一个导入方法就涉及到文件以及文件夹的操作(文件操作), 不过我们这里只需要读取即可.
- 我们需要用户传入一些东西: 文件夹路径、忽略规则、筛选规则、当前的文件路径, 而我们最后给用户返回一个
key
为路径value
为文件内容的Map<path, content>
即可. - 上面几点都确认后, 就可以开始写我们自己的导入方法了.
第二步: 粗略实现
- 我们先来写一点伪代码, 嘿嘿😁, 我在想这个方法的时候脑子中是这样想的.
// 1.定义一个用来导入的方法, 这里暂且就叫他single吧
const single = () => { /* 导入单文件 */ }
// 2.定义一个主方法用来调用single方法来批量导入文件, 这里暂且叫他requireAll
const requireAll = () => { /* 调用single批量导入文件 */ }
requireAll({ /* 配置 */ })
- 这里的single方法非常的好实现, 我们先把他给实现了
export const requireSingle = (fullpath: string) => {
return new Promise((resolve, reject) => {
import(fullpath)
.then(data => resolve(data))
.catch(error => reject(error));
});
}
这里接受一个文件路径返回一个promise
, 在promise
中resolve
方法和reject
方法分别对应import
的then
和catch
方法.
- 接着, 我们再来实现一下
requireAll
方法.
// 合并模块
export const mergeMap = (targetMap: Map<any, any>, subMap: Map<any, any>) => {
for (let key of subMap.keys()) {
targetMap.set(key, subMap.get(key));
}
}
// 忽略以.开头的文件、文件夹、node_modules、.d.ts、.d.js文件
const DEFAULT_IGNORE_RULE = /(^\.|^node_modules|(\.d\..*)$)/;
// 只取.ts或者.js结尾的文件
const DEFULAT_FILTER = /^([^\.].*)\.(ts|js)$/;
// 当前文件路径
const DEFAULT_CURRENT_FILE = __filename;
// 定义requireAll的参数类型
interface IOptions {
dirname: string;
ignore: RegExp | Function;
filter: RegExp | Function;
currentFile: string;
}
// Partial接口的作用就是把传递的泛型类型中的所有属性定义为可选属性
export const requireAll = (options: Partial<IOptions>) => {
// (必传)拿到文件夹路径
const dirname = options.dirname;
// 拿到忽略规则, 如果没有就用默认的
const ignore = options.ignore || DEFAULT_IGNORE_RULE;
// 用来保存导入的模块
const modules: Map<any, any> = new Map();
// 拿到筛选规则, 如果没有就用默认的
const filter = options.filter || DEFULAT_FILTER;
// (必传)执行此方法的文件路径
const currentFile = options.currentFile || DEFAULT_CURRENT_FILE;
// 拿到文件夹下的文件夹名称或者文件名称
const files = readdirSync(dirname);
// 定义一个判断文件是否为忽略文件的方法, 这里暂且叫ignoreFile, true: 为忽略文件, false: 不是忽略文件
const ignoreFile: (f: string) => boolean = (fullpath: string) => {
if (typeof ignore === 'function') {
return ignore(fullpath);
} else {
return fullpath === currentFile || !!fullpath.match(ignore as RegExp);
}
}
// 定义一个判断文件是否符合筛选条件的方法, 这里暂且叫filterFile, true: 符合筛选条件, false: 不符合筛选条件
const filterFile = (fullpath: string) => {
if (typeof filter === 'function') {
return filter(fullpath);
} else {
return filter.test(fullpath);
}
}
files.forEach(async filename => {
// 因为readdirSync方法拿到的都是文件名或者文件夹名, 并非是完整的路径, 所以这里做一下完整路径拼接
const fullpath = join(dirname, filename);
// 如果为true, 则为忽略文件
if (ignoreFile(fullpath)) return;
// 如果是文件夹, 重新调用requireAll方法即可
if (statSync(fullpath).isDirectory()) {
const subModules = requireAll({
dirname: fullpath,
ignore: ignore
});
// 合并现有的模块和子模块
mergeMap(modules, subModules);
} else {
// 如果是文件
try {
// 如果符合筛选条件, 则读取该文件, 并添加到modules中
if (filterFile(fullpath)) {
// requireSingle方法加载文件, 上面已经实现过
const data = await requireSingle(fullpath);
modules.set(fullpath, data);
}
} catch (error) {
throw error;
}
}
});
return modules;
}
上面的代码中, 有几个重点需要强调:
- 我们可以看到
files.forEach
中的回调方法用的是async修饰, 这代表我面里面可能会用到await
, 事实也是这样, 我们确实在执行requireSingle
方法的时候用到了await
. - 这样每一个
forEach
的回调方法都变成了异步方法, 其中的requireSingle
方法确实可以得到执行了, 但是我们来看最后一句代码return modules
, 当我们把forEach
的回调置为异步后,return modules
是拿不到返回值的.
那我们该怎么解决呢?
这里我们把forEach
方法来改写一下:
await Promise.all(
files.forEach(async filename => {
// 因为readdirSync方法拿到的都是文件名或者文件夹名, 并非是完整的路径, 所以这里做一下完整路径拼接
const fullpath = join(dirname, filename);
// 如果为true, 则为忽略文件
if (ignoreFile(fullpath)) return;
// 如果是文件夹, 重新调用requireAll方法即可
if (statSync(fullpath).isDirectory()) {
const subModules = requireAll({
dirname: fullpath,
ignore: ignore
});
// 合并现有的模块和子模块
mergeMap(modules, subModules);
} else {
// 如果是文件
try {
// 如果符合筛选条件, 则读取该文件, 并添加到modules中
if (filterFile(fullpath)) {
// requireSingle方法加载文件, 上面已经实现过
const data = await requireSingle(fullpath);
modules.set(fullpath, data);
}
} catch (error) {
throw error;
}
}
})
);
- 将
forEach
方法改为map方法. - 给
map
方法外面套上一层await Promise.all
这样就解决了我们上面因为异步,return modules
获取不到数据的问题.
但是这里要注意,
requireAll
方法也要加上一个async
修饰符哟
requireAll完整代码
// 合并模块
export const mergeMap = (targetMap: Map<any, any>, subMap: Map<any, any>) => {
for (let key of subMap.keys()) {
targetMap.set(key, subMap.get(key));
}
}
// 忽略以.开头的文件、文件夹、node_modules、.d.ts、.d.js文件
const DEFAULT_IGNORE_RULE = /(^\.|^node_modules|(\.d\..*)$)/;
// 只取.ts或者.js结尾的文件
const DEFULAT_FILTER = /^([^\.].*)\.(ts|js)$/;
// 当前文件路径
const DEFAULT_CURRENT_FILE = __filename;
// 定义requireAll的参数类型
interface IOptions {
dirname: string;
ignore: RegExp | Function;
filter: RegExp | Function;
currentFile: string;
}
// Partial接口的作用就是把传递的泛型类型中的所有属性定义为可选属性
export const requireAll = (options: Partial<IOptions>) => {
// (必传)拿到文件夹路径
const dirname = options.dirname;
// 拿到忽略规则, 如果没有就用默认的
const ignore = options.ignore || DEFAULT_IGNORE_RULE;
// 用来保存导入的模块
const modules: Map<any, any> = new Map();
// 拿到筛选规则, 如果没有就用默认的
const filter = options.filter || DEFULAT_FILTER;
// (必传)执行此方法的文件路径
const currentFile = options.currentFile || DEFAULT_CURRENT_FILE;
// 拿到文件夹下的文件夹名称或者文件名称
const files = readdirSync(dirname);
// 定义一个判断文件是否为忽略文件的方法, 这里暂且叫ignoreFile, true: 为忽略文件, false: 不是忽略文件
const ignoreFile: (f: string) => boolean = (fullpath: string) => {
if (typeof ignore === 'function') {
return ignore(fullpath);
} else {
return fullpath === currentFile || !!fullpath.match(ignore as RegExp);
}
}
// 定义一个判断文件是否符合筛选条件的方法, 这里暂且叫filterFile, true: 符合筛选条件, false: 不符合筛选条件
const filterFile = (fullpath: string) => {
if (typeof filter === 'function') {
return filter(fullpath);
} else {
return filter.test(fullpath);
}
}
await Promise.all(
files.forEach(async filename => {
// 因为readdirSync方法拿到的都是文件名或者文件夹名, 并非是完整的路径, 所以这里做一下完整路径拼接
const fullpath = join(dirname, filename);
// 如果为true, 则为忽略文件
if (ignoreFile(fullpath)) return;
// 如果是文件夹, 重新调用requireAll方法即可
if (statSync(fullpath).isDirectory()) {
const subModules = requireAll({
dirname: fullpath,
ignore: ignore
});
// 合并现有的模块和子模块
mergeMap(modules, subModules);
} else {
// 如果是文件
try {
// 如果符合筛选条件, 则读取该文件, 并添加到modules中
if (filterFile(fullpath)) {
// requireSingle方法加载文件, 上面已经实现过
const data = await requireSingle(fullpath);
modules.set(fullpath, data);
}
} catch (error) {
throw error;
}
}
})
);
return modules;
}
到这里我们的requireAll
方法就算是完成了, 但是此时的requireAll
只能用于加载普通的ts文件, 还不足以达到给NestJs
应用的程度.
应用示例
我们先来看一下目录结构
├── a.ts
├── b.ts
└── children
└── c.ts
文件内容
调用方式
让我们来看一下输出结果
嘿嘿😁, 是不是很cool?
第三步: 精益求精
- 我们刚刚已经说过了, 现在的
requireAll
方法还远远达不到给NestJs
使用的程度, 我们后面应该怎么做呢? 这里可能需要一点ts装饰器和Reflect-metadata
的基础, 如果大家没有了解过的话, 欢迎看一下作者的这几篇文章: - TypeScript装饰器官网笔记: juejin.cn/post/697242…
- Reflect Metadata(元数据)学习笔记: juejin.cn/post/697242…
- Proxy&Reflect官网笔记: juejin.cn/post/697390…
- vue3中的元编程&代码抽离: juejin.cn/post/697457…
- 这里要强调一下, 如果确实没有了解过这部分知识的话, 需要按作者给出的顺序阅读(从上到下)
我们先来打开一个NestJs的项目看一下(新创建的项目即可)
这里附上NestJs中文网的地址, 按照官网操作即可docs.nestjs.cn/
我们先来看一下目录结构, 这里作者把目录结构稍稍改变了一下, 不过这个并不重要:
├── assets
├── main.ts
├── modules
│ ├── app
│ │ ├── app.controller.ts
│ │ ├── app.module.ts
│ │ └── app.service.ts
│ ├── config
│ │ ├── config.controller.ts
│ │ ├── config.module.ts
│ │ └── config.service.ts
│ └── user
│ ├── user.controller.ts
│ ├── user.module.ts
│ └── user.service.ts
├── types
└── utils
├── requreAll.ts
└── utils.ts
再来看一下最开始说过的app.module.ts文件:
我们可以看到, 所有的模块都是要在
app.module
中的imports
中以数组项的形式注册的, 这里只是2个模块, 我们操作起来尚可接受, 如果我们在做一个大项目, 有几十个甚至上百个模块的时候, 我们需要把每一个模块都引入一遍就比较麻烦了.
那我们怎么通过requireAll
方法来解决这个问题呢?
- 首先我们知道, app.module中的
imports
目前只接受module(模块), 并不接受controller和service, 而现在的requireAll
方法不管是module还是controller还是service都会一股脑的导入到imports
里面, 这样即使不报错也势必不合乎情理. - 那我们怎样来区分导入的文件是module还是controller还是service呢? 我们先来看一下这三者他们的区别.
config.controller.ts
config.service.ts
config.module.ts
- 我们可以看到除了类名是不一样的以外, 还有一个明显的差别, 那就是每个类的装饰器不同, 例如ConfigController的装饰器是@Controller(), ConfigServeice的装饰器是@Injectable(), 而ConfigModule的装饰器是@Module(), 我们所要做的就是根据装饰器不同来区分这个类到底是module还是controller还是service
- 但我们知道, 我们通过类本身是没有办法知道该类都被哪些装饰器所修饰的(至少目前没有找到), 所以我们只能采用一个折中的办法, 我们自己定义一个装饰器, 来记录当前的类被哪些装饰器所修饰, 我们来看一下代码实现.
export function defineDecorator (metadata: string[]) {
return function (target: any) {
Reflect.defineMetadata('decorator', metadata, target);
}
}
我们定义了一个defineDecorator
方法, 意为定义装饰器, 该装饰器是一个工厂装饰器 (如果不明白什么是工厂装饰器的话, 可以看一下作者上面提到的装饰器的文章), 接受一个元数据, 该数据是一个字符串类型的数组, 返回一个装饰器, 该装饰器是一个类装饰器, 接受target
参数, 然后给target
定义一个key
为decorator
的元数据, 元数据的值就是传进来的metadata
参数.
我们来看一下怎么应用:
以上代码引用了
defineDecorator
方法给ConfigModule
类进行装饰, metadata
为['Module']
(这里为什么是从v-require-all中导入的方法呢? 因为作者已经将封装好的方法发布到了npm上面, 后面大家想用或者查看源码的话, 可以直接npm i v-required-all --save
来使用), 这样一来有什么作用呢? 大家来看一下这一段代码:
这段代码使用
Reflect.getMetadata('decorator', ConfigModule)
来获取到了我们所定义的元数据, 有了这个, 我们就能区分当前导入的文件是module还是controller还是service了. 具体是怎么做的呢? 我们继续往下看.
第四步: 尘埃落定
我们刚刚说到, 通过defineDecorator
装饰类之后, 我们就能区分这个类是否是module了, 那么到底是怎么区分的呢?
- 首先, 我们需要在
requireAll
方法的基础上再封装一个方法, 该方法单独给NestJs使用, 暂且命名为requireAllInNest
.
// 读取属性
export const readKeys = (map: Object, callback: (key: any, value: any) => void) => {
const results = [];
if (map instanceof Map) {
for (let key of map.keys()) {
const returns = callback(key, map.get(key));
if (returns !== undefined) {
results.push(returns);
}
}
} else {
for (let key of Reflect.ownKeys(map)) {
// @ts-ignore
const returns = callback(key, map[key]);
if (returns !== undefined) {
results.push(returns);
}
}
}
if (results.length > 0) {
return results;
}
}
export const requireAllInNest = (options: Partial<IOptions>, type: 'Controller' | 'Injectable' | 'Module' = 'Module') => {
return new Promise((resolve, reject) => {
if (type === 'Controller') {
options.filter = /^([^\.].*)\.controller\.ts$/;
} else if (type === 'Injectable') {
options.filter = /^([^\.].*)\.service\.ts$/;
} else {
options.filter = /^([^\.].*)\.module\.ts$/;
}
requireAll(options).then(modules => {
try {
const importsModule: any[] = readKeys(modules, (key, value) => {
return readKeys(value, (vKey, target) => {
if (typeof target === 'function') {
const metadata = Reflect.getMetadata('decorator', target);
if (Array.isArray(metadata) && metadata.length > 0 && metadata.indexOf(type) !== -1) {
return target;
}
}
});
});
const results: any[] = [];
importsModule.forEach((chunk: any[]) => results.push(...chunk));
resolve(results);
} catch (error) {
reject(error);
}
}).catch(error => reject(error));
})
}
我们来看一下这个方法做了什么事情:
- 首先该方法有两个参数, 第一个参数
options
等同于requireAll的options
参数, 而第二个参数用来指定需要获取module还是controller还是service. - 通过
type
参数来改写filter
属性(文件的筛选条件) - 执行
requireAll
方法, 获取到返回的modules, 我们返回的modules是一个Map<string, Object>
的形式, 所以我们这里定义了一个readKeys
方法来处理一下(该方法类似forEach
方法), 这里为什么要用两层readKeys
方法呢? 因为我们获取到modules
的value
是一个对象形式的{ a: 'xxx', default: xxx }
, 第一层readKeys
用来获取到modules
的value
, 第二层用来获取到value
中的内容. - 通过判断
value
的内容是否为function
来进行下一步操作, 如果是function并且该方法含有decorator
元数据key
, 并且元数据中包含传进来的type('Controller' | 'Injectable' | 'Module')
, 则断定该内容就是我们需要的module(例如我们这里需要的是module), 将该内容返回即可. - 我们最后获取到的
modules
会是一个多维的数组嵌套, 我们还需要把它结构一下, 所以有了这两句代码.
const results: any[] = [];
importsModule.forEach((chunk: any[]) => results.push(...chunk));
至此, requireAllInNest
方法就算是封装完了, 好了, 我们来看一下怎样使用这个方法.
我们可以看到我们的想法是非常好的, 但是无奈NestJs的
@Module
装饰器中的imports不接受Promise
类型的参数, 这里就有了我们之前差点被Promise
害死的伏笔. 那我们该怎么解决呢? 其实很简单, 我们可以重写或者说自定义一个Module装饰器, 这个装饰器的作用就是给被装饰的类添加imports, controllers, providers这三个元数据, 那我们也可以自己来实现一下.
type IDepend = Promise<any> | Promise<any>[] | any[];
type IPrototypeClass<T = any> = new (...args: any[]) => T;
// 约束一下Module的参数
interface IMetadata {
imports: IDepend;
controllers: IDepend;
providers: IDepend;
}
const metadataHandler = {
setImports: (imports: any, target: IPrototypeClass) => Reflect.defineMetadata('imports', toArray(imports), target),
setControllers: (controllers: any, target: IPrototypeClass) => Reflect.defineMetadata('controllers', toArray(controllers), target),
setProviders: (providers: any, target: IPrototypeClass) => Reflect.defineMetadata('providers', toArray(providers), target),
handler(data: any, target: IPrototypeClass, method: (v:any, t:IPrototypeClass) => void) {
const single = (value: any) => (value && isPromise(value)) ? (value as Promise<any>).then(d => method(d, target)) : method(value, target);
isArray(data) ? (data as any[]).forEach(v => single(v)) : single(data);
},
imports(imports: IDepend, target: IPrototypeClass) {
this.handler(imports, target, this.setImports);
},
controllers(controllers: IDepend, target: IPrototypeClass) {
this.handler(controllers, target, this.setControllers);
},
providers(providers: IDepend, target: IPrototypeClass) {
this.handler(providers, target, this.setProviders);
}
}
// 基本算是重写@Module
export function Module (metadata: Partial<IMetadata>) {
return function (target: IPrototypeClass) {
metadataHandler.imports(metadata.imports, target);
metadataHandler.controllers(metadata.controllers, target);
metadataHandler.providers(metadata.providers, target);
}
}
我们可以看到Module
方法本身也是一个工厂装饰器, 其作用就是调用metadataHandler
对象给target
添加元数据, 这里metadataHandler
就不解释了, 就是判断是否为数组、是否为promise
、是否为普通数据然后给target
添加元数据的一个方法集合.
我们来看一下使用方法:
其实可以看到, 非常非常的简单, 只要把原来的
Module
方法改为自己定义的Module
方法即可.
效果:
嘿嘿😁, 是不是很cool!
最后一步: 发布到npm
发布到npm, 大家参考一下这两篇文章即可, 其中有一些注意的地方会在下面说到.
- 怎样发布一个npm包?: cloud.tencent.com/developer/a…
- 将ts编写的代码上传到npm: blog.csdn.net/Dilomen/art…
- 这里我们要强调一下, 在做以上的操作之前, 如果本地的npm源是淘宝源或者其他源的话, 要把他们改成默认的, 或者直接删除 .npmrc文件即可, 作者就是直接删除了 .npmrc文件.
总结
到这里该方法就已经封装完成了, 如要使用此方法, 只需在npm搜索v-require-all即可.
有的朋友问我为什么要取这个名字, 为什么要有一个v-的前缀呢? 因为本来想取require-all和@require-all的, 但是都用不了了, 所以就用了v-为前缀, v为作者网名veloma的首字母.