UMI 4 新特性源码解读系列二:微生成器
本文是 UMI 4 源码主题阅读的第二篇。有些同学可能对 UMI 4 新特性还不太熟悉,推荐看一下 www.yuque.com/antfe/featu…
背景
这里要解答一个问题,什么是微生成器。微生成器实际上就是一系列小型脚手架。传统脚手架工具,例如 create-react-app
、vue-cli
、yeoman-generator
以及 create-umi
,一般都用于快速搭建一个工程,但实际开发中会遇到需要重复编写很多模板代码的场景,例如创建各种组件、页面、路由配置、Mock 数据、Jest 测试用例等,这时候就需要有更细粒度的脚手架来生成这些模板代码,提升开发效率,微生成器就是在这个背景下诞生的。
写代码的最高境界,就是用代码生成代码
如何使用微生成器
在看源码之前,最好先熟悉文档,这样可以更好地理解背后的设计理念。UMI 4 的微生成器有很多,这里主要介绍组件生成器,毕竟这些功能都是类似的。
首先微生成器也是一种 CLI 工具,支持下面两个命令:
$ umi generate
# 或者
$ umi g
如果要生成组件就可以这样:
$umi g component
✔ Please input you component Name … foo
Write: src/components/Foo/index.ts
Write: src/components/Foo/Foo.tsx
上例中,如果不指定组件名称,就会出现一个交互式命令询问组件名,然后默认都在 src/components/
目录下生成。如果指定组件名,则直接生成:
$umi g component bar
Write: src/components/Bar/index.ts
Write: src/components/Bar/Bar.tsx
也可以嵌套生成:
$umi g component group/subgroup/baz
Write: src/components/group/subgroup/Baz/index.ts
Write: src/components/group/subgroup/Baz/Baz.tsx
批量生成:
$umi g component apple banana orange
Write: src/components/Apple/index.ts
Write: src/components/Apple/Apple.tsx
Write: src/components/Banana/index.ts
Write: src/components/Banana/Banana.tsx
Write: src/components/Orange/index.ts
Write: src/components/Orange/Orange.tsx
微生成器还支持 eject
,即支持对模板内容自定义。首先,将原始模板写入到项目的 /templates/component
目录:
# 将内置的模板暴露到项目 `/templates/component` 目录
$umi g component --eject
使用模板变量:
# 将自定义变量传递给模板
$umi g component foo --msg "Hello World"
如果不想用自定义模板,还可以回退:
$umi g component foo --fallback
以上就是整体使用流程,下面来看源码分析。
源码分析
从上例中我们可以看出,微生成器就是一个 CLI 工具,既然是 CLI 工具,那就需要有一个包注册 umi
命令。开发过 NPM 包的同学应该都知道,注册命令就是 package.json
中的 bin
字段。
按照这个思路,我们很容易就找到了一个叫 umi
的包:
// /packages/umi/package.json
{
"name": "umi",
"version": "4.0.26",
"description": "umi",
"homepage": "https://github.com/umijs/umi/tree/master/packages/umi#readme",
"bugs": "https://github.com/umijs/umi/issues",
"repository": {
"type": "git",
"url": "https://github.com/umijs/umi"
},
"license": "MIT",
"main": "dist/index.js",
"types": "index.d.ts",
"bin": {
"umi": "bin/umi.js"
},
}
在上面的配置中,确实注册了一个 umi
命令,并且指向了 bin/umi.js
可执行文件。那就顺藤摸瓜,找到这个文件:
// /packages/umi/bin/umi.js
#!/usr/bin/env node
// 此处省略一些代码
require('../dist/cli/cli')
.run()
.catch((e) => {
console.error(e);
process.exit(1);
});
从这里我们可以看出,这边加载了 dist/cli/cli
模块,很显然是引用了编译产物中的模块。由于 UMI 用 father 打包,father 打包默认会保留原始目录结构,所以我们可以直接在 src
目录下找到 src/cli/cli.ts
文件:
// /packages/umi/src/cli/cli.ts
export async function run(opts?: IOpts) {
checkNodeVersion();
checkLocal();
setNodeTitle();
setNoDeprecation();
// 解析命令行参数
const args = yParser(process.argv.slice(2), {
alias: {
version: ['v'],
help: ['h'],
},
boolean: ['version'],
});
// 拿到参数中第一个命令
const command = args._[0];
if ([DEV_COMMAND, 'setup'].includes(command)) {
process.env.NODE_ENV = 'development';
} else if (command === 'build') {
process.env.NODE_ENV = 'production';
}
if (opts?.presets) {
process.env.UMI_PRESETS = opts.presets.join(',');
}
if (command === DEV_COMMAND) {
dev();
} else {
try {
// 初始化核心 Service 类
await new Service().run2({
name: args._[0],
args,
});
} catch (e: any) {
logger.fatal(e);
printHelp.exit();
process.exit(1);
}
}
}
上面的代码中,初始化了一个核心 Service
类,看一下核心流程:
// /packages/umi/src/service/service.ts
export class Service extends CoreService {
constructor(opts?: any) {
process.env.UMI_DIR = dirname(require.resolve('../../package'));
const cwd = getCwd();
require('./requireHook');
super({
...opts,
env: process.env.NODE_ENV,
cwd,
defaultConfigFiles: opts?.defaultConfigFiles || DEFAULT_CONFIG_FILES,
frameworkName: opts?.frameworkName || FRAMEWORK_NAME,
presets: [require.resolve('@umijs/preset-umi'), ...(opts?.presets || [])],
plugins: [
existsSync(join(cwd, 'plugin.ts')) && join(cwd, 'plugin.ts'),
existsSync(join(cwd, 'plugin.js')) && join(cwd, 'plugin.js'),
].filter(Boolean),
});
}
async run2(opts: { name: string; args?: any }) {
let name = opts.name;
if (opts?.args.version || name === 'v') {
name = 'version';
} else if (opts?.args.help || !name || name === 'h') {
name = 'help';
}
return await this.run({ ...opts, name });
}
}
看到这里,有些同学可能思路一下断掉了,因为这里没有任何跟脚手架有关的代码,但是不要忘了,UMI 是一个插件化、可扩展的框架,既然是插件化,那就肯定有各种插件、各种 preset。我们看上面的代码,确实加载了一个 @umijs/preset-umi
,在 UMI 代码仓库中,可以找到一个 preset-umi
的包,在 src/index.ts
文件可以看到 UMI 内置的插件都在这里注册,其中包含微生成器相关命令:
// /packages/preset-umi/src/index.ts
export default () => {
return {
plugins: [
// 此次省略一些代码
// commands
require.resolve('./commands/generators/page'),
require.resolve('./commands/generators/prettier'),
require.resolve('./commands/generators/tsconfig'),
require.resolve('./commands/generators/jest'),
require.resolve('./commands/generators/tailwindcss'),
require.resolve('./commands/generators/dva'),
require.resolve('./commands/generators/component'),
require.resolve('./commands/generators/mock'),
require.resolve('./commands/generators/cypress'),
require.resolve('./commands/generators/api'),
],
};
};
看到这里,思路又开始清晰了,按照 commands/generators
路径,确实都找到了微生成器相关插件的代码。以组件生成器为例,看下核心流程:
// /packages/preset-umi/src/commands/generators/component.ts
import { GeneratorType } from '@umijs/core';
import { generateFile, lodash } from '@umijs/utils';
import { join, parse } from 'path';
import { TEMPLATES_DIR } from '../../constants';
import { IApi } from '../../types';
import { GeneratorHelper } from './utils';
export default (api: IApi) => {
api.describe({
key: 'generator:component',
});
api.registerGenerator({
key: 'component',
name: 'Generate Component',
description: 'Generate component boilerplate code',
type: GeneratorType.generate,
fn: async (options) => {
// 获取 helper 函数
const h = new GeneratorHelper(api);
options.generateFile;
// 从命令行参数获取组件名,注意是一个数组
let componentNames = options.args._.slice(1);
// 如果用户没有指定组件名,则通过交互式命令进行询问
if (componentNames.length === 0) {
let name: string = '';
name = await h.ensureVariableWithQuestion(name, {
type: 'text',
message: 'Please input you component Name',
hint: 'foo',
initial: 'foo',
format: (s) => s?.trim() || '',
});
componentNames = [name];
}
// 遍历数组,生成组件代码
// 个人觉得这里改用 `Promise.all()` 应该更好
for (const cn of componentNames) {
await new ComponentGenerator({
srcPath: api.paths.absSrcPath,
appRoot: api.paths.cwd,
generateFile,
componentName: cn,
}).run();
}
},
});
};
上面涉及到一个 ComponentGenerator
类,但是其实内部只是做了些路径拼接工作,核心的 generateFile
方法是外部传入的:
// /packages/preset-umi/src/commands/generators/component.ts
export class ComponentGenerator {
private readonly name: string;
private readonly dir: string;
constructor(
readonly opts: {
componentName: string;
srcPath: string;
appRoot: string;
generateFile: typeof generateFile;
},
) {
const { name, dir } = parse(this.opts.componentName);
this.name = name;
this.dir = dir;
}
async run() {
const { generateFile, appRoot } = this.opts;
// 组件名首字母大写
const capitalizeName = lodash.capitalize(this.name);
// 生成组件目录的路径
const base = join(
this.opts.srcPath,
'components',
this.dir,
capitalizeName,
);
// 生成 index.ts 路径
const indexFile = join(base, 'index.ts');
// 生成组件代码路径
const compFile = join(base, `${capitalizeName}.tsx`);
// 根据模板生成 index.ts
await generateFile({
target: indexFile,
path: INDEX_TPL,
baseDir: appRoot,
data: { compName: capitalizeName },
});
// 根据模板生成组件代码
// 个人认为这两个串行好像也没啥必要,可以直接 `Promise.all()`
await generateFile({
target: compFile,
path: COMP_TPL,
baseDir: appRoot,
data: { compName: capitalizeName },
});
}
}
const INDEX_TPL = join(TEMPLATES_DIR, 'generate/component/index.ts.tpl');
const COMP_TPL = join(TEMPLATES_DIR, 'generate/component/component.tsx.tpl');
接下来值得一看的就是 generateFile
,这块逻辑位于 @umijs/utils
:
// /packages/utils/src/BaseGenerator/generateFile.ts
import prompts from '../../compiled/prompts';
import BaseGenerator from './BaseGenerator';
const generateFile = async ({
path,
target,
baseDir,
data,
questions,
}: {
path: string;
target: string;
baseDir?: string;
data?: any;
questions?: prompts.PromptObject[];
}) => {
const generator = new BaseGenerator({
path,
target,
baseDir,
data,
questions,
});
await generator.run();
};
export default generateFile;
发现这里其实没多少逻辑,就是初始化了 BaseGenerator
类,然后调用 run
方法。那就进到 BaseGenerator
看一下:
// /packages/utils/src/BaseGenerator/BaseGenerator.ts
import { copyFileSync, statSync } from 'fs';
import { dirname } from 'path';
import fsExtra from '../../compiled/fs-extra';
import prompts from '../../compiled/prompts';
import Generator from '../Generator/Generator';
interface IOpts {
path: string;
target: string;
baseDir?: string;
data?: any;
questions?: prompts.PromptObject[];
}
export default class BaseGenerator extends Generator {
path: string;
target: string;
data: any;
questions: prompts.PromptObject[];
constructor({ path, target, data, questions, baseDir }: IOpts) {
super({ baseDir: baseDir || target, args: data });
this.path = path;
this.target = target;
this.data = data;
this.questions = questions || [];
}
prompting() {
return this.questions;
}
async writing() {
const context = {
...this.data,
...this.prompts,
};
if (statSync(this.path).isDirectory()) {
// 如果源路径是目录,就调用 `this.copyDirectory()` 复制目录
// 与普通复制目录区别,如果文件夹内部存在模板文件,则会调用 `this.copyTpl()` 复制
this.copyDirectory({
context,
path: this.path,
target: this.target,
});
} else {
if (this.path.endsWith('.tpl')) {
// 如果路径后缀是 `.tpl`,则调用 `this.copyTpl()` 复制模板文件
// 与复制普通文件区别,`this.copyTpl()` 会给模板传递变量
this.copyTpl({
templatePath: this.path,
target: this.target,
context,
});
} else {
const absTarget = this.target;
// `mkdirpSync()` 是 `ensureDirSync()` 的别名
// 用于确保需要复制的目录存在,如果目录不存在则会创建一个
fsExtra.mkdirpSync(dirname(absTarget));
// 复制普通文件
copyFileSync(this.path, absTarget);
}
}
}
}
我们可以看到,BaseGenerator
继承了 Generator
类,因此我们之所以在实例上可以调用 run()
、copyDirectory()
、copyTpl()
,其实都是从 Generator
继承过来的,我们可以看下 Generator
的逻辑,确实如此:
// /packages/utils/src/Generator/Generator.ts
import { copyFileSync, readFileSync, statSync, writeFileSync } from 'fs';
import { dirname, join, relative } from 'path';
import chalk from '../../compiled/chalk';
import fsExtra from '../../compiled/fs-extra';
import glob from '../../compiled/glob';
import Mustache from '../../compiled/mustache';
import prompts from '../../compiled/prompts';
import yParser from '../../compiled/yargs-parser';
interface IOpts {
baseDir: string;
args: yParser.Arguments;
}
class Generator {
baseDir: string;
args: yParser.Arguments;
prompts: any;
constructor({ baseDir, args }: IOpts) {
this.baseDir = baseDir;
this.args = args;
this.prompts = {};
}
async run() {
const questions = this.prompting();
this.prompts = await prompts(questions, {
onCancel() {
process.exit(1);
},
});
// 这里的 `writing` 方法来自实现类
await this.writing();
}
prompting() {
return [] as any;
}
/**
* 这里 `writing` 是个抽象方法
* 需要用实现类继承 `Generator`,然后实现该方法
*/
async writing() {}
copyTpl(opts: { templatePath: string; target: string; context: object }) {
const tpl = readFileSync(opts.templatePath, 'utf-8');
// 用 Mustache 模板引擎,给模板暴露变量
const content = Mustache.render(tpl, opts.context);
// 用于确保需要复制的目录存在,如果目录不存在则会创建一个
fsExtra.mkdirpSync(dirname(opts.target));
console.log(
`${chalk.green('Write:')} ${relative(this.baseDir, opts.target)}`,
);
// 写入渲染之后的模板内容
writeFileSync(opts.target, content, 'utf-8');
}
copyDirectory(opts: { path: string; context: object; target: string }) {
// 为啥不直接用 `fsExtra.copy()`,因为要识别出文件夹中的模板文件
// 如果存在模板文件,则调用 `this.copyTpl()` 复制,同时给模板暴露变量
const files = glob.sync('**/*', {
cwd: opts.path,
dot: true,
ignore: ['**/node_modules/**'],
});
files.forEach((file: any) => {
const absFile = join(opts.path, file);
if (statSync(absFile).isDirectory()) return;
if (file.endsWith('.tpl')) {
this.copyTpl({
templatePath: absFile,
target: join(opts.target, file.replace(/\.tpl$/, '')),
context: opts.context,
});
} else {
console.log(`${chalk.green('Copy: ')} ${file}`);
const absTarget = join(opts.target, file);
fsExtra.mkdirpSync(dirname(absTarget));
copyFileSync(absFile, absTarget);
}
});
}
}
export default Generator;
看到这里整体逻辑都比较清晰了,初始化了 BaseGenerator
类之后,我们调用的 generator.run()
实际上来自 Generator
类;而 Generator
类的 writing
是抽象方法,来自 BaseGenerator
实现类。
不过值得吐槽的一点是,writing
明明是个异步方法,但是 BaseGenerator
类实现该方法的时候,却没有用到 await
,this.copyTpl()
和 this.copyDirectory()
全都是同步 IO,这也导致批量复制的时候,用 Promise.all()
作用不大,基本等同于串行了。
这篇主要介绍 UMI 4 微生成器中的组件生成器逻辑,如果你对其他生成器逻辑感兴趣,可以自行阅读相关源码。
转载自:https://juejin.cn/post/7288166472131264546