UMI 4 新特性源码解读系列二:微生成器

本文是 UMI 4 源码主题阅读的第二篇。有些同学可能对 UMI 4 新特性还不太熟悉,推荐看一下 www.yuque.com/antfe/featu...

背景

这里要解答一个问题,什么是微生成器。微生成器实际上就是一系列小型脚手架。传统脚手架工具,例如 create-react-appvue-cliyeoman-generator 以及 create-umi,一般都用于快速搭建一个工程,但实际开发中会遇到需要重复编写很多模板代码的场景,例如创建各种组件、页面、路由配置、Mock 数据、Jest 测试用例等,这时候就需要有更细粒度的脚手架来生成这些模板代码,提升开发效率,微生成器就是在这个背景下诞生的。

写代码的最高境界,就是用代码生成代码

如何使用微生成器

在看源码之前,最好先熟悉文档,这样可以更好地理解背后的设计理念。UMI 4 的微生成器有很多,这里主要介绍组件生成器,毕竟这些功能都是类似的。

首先微生成器也是一种 CLI 工具,支持下面两个命令:

bash 复制代码
$ umi generate
# 或者
$ umi g

如果要生成组件就可以这样:

bash 复制代码
$umi g component
✔ Please input you component Name ... foo
Write: src/components/Foo/index.ts
Write: src/components/Foo/Foo.tsx

上例中,如果不指定组件名称,就会出现一个交互式命令询问组件名,然后默认都在 src/components/ 目录下生成。如果指定组件名,则直接生成:

bash 复制代码
$umi g component bar
Write: src/components/Bar/index.ts
Write: src/components/Bar/Bar.tsx

也可以嵌套生成:

bash 复制代码
$umi g component group/subgroup/baz
Write: src/components/group/subgroup/Baz/index.ts
Write: src/components/group/subgroup/Baz/Baz.tsx

批量生成:

bash 复制代码
$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 目录:

bash 复制代码
# 将内置的模板暴露到项目 `/templates/component` 目录
$umi g component --eject

使用模板变量:

bash 复制代码
# 将自定义变量传递给模板
$umi g component foo --msg "Hello World"

如果不想用自定义模板,还可以回退:

bash 复制代码
$umi g component foo --fallback

以上就是整体使用流程,下面来看源码分析。

源码分析

从上例中我们可以看出,微生成器就是一个 CLI 工具,既然是 CLI 工具,那就需要有一个包注册 umi 命令。开发过 NPM 包的同学应该都知道,注册命令就是 package.json 中的 bin 字段。

按照这个思路,我们很容易就找到了一个叫 umi 的包:

json 复制代码
// /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 可执行文件。那就顺藤摸瓜,找到这个文件:

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 文件:

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 类,看一下核心流程:

ts 复制代码
// /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 内置的插件都在这里注册,其中包含微生成器相关命令:

ts 复制代码
// /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 路径,确实都找到了微生成器相关插件的代码。以组件生成器为例,看下核心流程:

ts 复制代码
// /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 方法是外部传入的:

ts 复制代码
// /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

ts 复制代码
// /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 看一下:

ts 复制代码
// /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 的逻辑,确实如此:

ts 复制代码
// /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 类实现该方法的时候,却没有用到 awaitthis.copyTpl()this.copyDirectory() 全都是同步 IO,这也导致批量复制的时候,用 Promise.all() 作用不大,基本等同于串行了。

这篇主要介绍 UMI 4 微生成器中的组件生成器逻辑,如果你对其他生成器逻辑感兴趣,可以自行阅读相关源码。

相关推荐
正小安1 小时前
如何在微信小程序中实现分包加载和预下载
前端·微信小程序·小程序
_.Switch3 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
一路向前的月光3 小时前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js
长路 ㅤ   3 小时前
vite学习教程06、vite.config.js配置
前端·vite配置·端口设置·本地开发
长路 ㅤ   3 小时前
vue-live2d看板娘集成方案设计使用教程
前端·javascript·vue.js·live2d
Fan_web3 小时前
jQuery——事件委托
开发语言·前端·javascript·css·jquery
安冬的码畜日常3 小时前
【CSS in Depth 2 精译_044】第七章 响应式设计概述
前端·css·css3·html5·响应式设计·响应式
莹雨潇潇4 小时前
Docker 快速入门(Ubuntu版)
java·前端·docker·容器
Jiaberrr4 小时前
Element UI教程:如何将Radio单选框的圆框改为方框
前端·javascript·vue.js·ui·elementui
Tiffany_Ho5 小时前
【TypeScript】知识点梳理(三)
前端·typescript