🎉🎉🎉 重构三个月,我们设计出了一个轻量且非常灵活的前端脚手架架构

发布于:2024-05-08 ⋅ 阅读:(32) ⋅ 点赞:(0)

好消息!create-neat 在沉淀几个月后,终于迎来了架构的升级,这篇文档将会从各方面详细地解读重构后的架构设计思路,以便大家能对我们的项目有更深刻的认知,在此先感谢这段时间为重构付出力量的每一位同学!

这篇文章将会给大家带来 create-neat 的架构设计介绍,如果您感兴趣参与到我们的项目贡献中(现在 issue 很多哦~),请务必看完这篇文章。
如果这篇文章对您有帮助,期待能给个 star,想参与开发或者学习的同学可以加我的微信:Tongxx_yj。
Github:

架构升级背景

首先用简短的话来介绍一下 create-neat:一个轻量级脚手架,期望用户能快速且灵活地搭建高定制化项目。

此前,每个框架和相关的插件(如 typescript、eslint 等)存在强耦合,内容的生成也不够灵活,因此有了这次的重构计划,目的将框架、构建工具、插件三个个体进行拆分,提供给用户自由选择,这样大大地提高了项目的开发难度、耦合程度。

架构俯瞰

架构设计

整体的执行过程就是基于用户预设不断为目标项目中注入相关的文件,大致流程如下:

image.png

整体过程可以拆分为两部分:

  • 项目创建主流程
  • 生成器执行流程

其中,生成器的执行流程是整个项目的核心,接下来也会着重讲解这一块的设计。

目录结构设计

为了保证读者更好地上手项目,我们还需要对目录结构进行一个详细的介绍

create-neat
├─ packages
│  ├─ @plugin # 存放插件内容
│  │  ├─ plugin-babel
│  │  ├─ plugin-eslint
│  │  ├─ plugin-prettier
│  │  └─ plugin-typescript
│  │     ├─ generator # 插件的生成器配置
│  │     │  ├─ index.cjs
│  │     │  └─ template
│  │     │     └─ tsconfig.json
│  │     ├─ index.cjs # 插件的构建工具扩展配置
│  │     └─ package.json
│  ├─ core # 核心包
│  │  ├─ README.md
│  │  ├─ package.json
│  │  ├─ src
│  │  │  ├─ configs # 存放配置、选项等内容
│  │  │  │  └─ npmRegistries.ts
│  │  │  ├─ index.ts # 入口文件
│  │  │  ├─ models # 存放相关类
│  │  │  │  ├─ ConfigTransform.ts
│  │  │  │  ├─ Generator.ts # 生成器
│  │  │  │  ├─ GeneratorAPI.ts
│  │  │  │  ├─ PackageAPI.ts
│  │  │  │  └─ TemplateAPI.ts
│  │  │  ├─ types # 存放类型
│  │  │  │  └─ index.ts
│  │  │  └─ utils # 相关工具函数
│  │  │     ├─ ast.ts
│  │  │     ├─ constants.ts
│  │  │     ├─ createApp.ts # 命令调用入口函数
│  │  │     ├─ createFiles.ts
│  │  │     ├─ createSuccessInfo.ts
│  │  │     ├─ dependenciesInstall.ts
│  │  │     ├─ fileController.ts
│  │  │     ├─ getnpmSource.ts
│  │  │     ├─ gitCheck.ts
│  │  │     ├─ preset.ts
│  │  │     └─ select.ts
│  │  ├─ template # 存放框架、json、md 等内容
│  │  │  ├─ README-EN.md
│  │  │  ├─ README.md
│  │  │  ├─ husky.json
│  │  │  ├─ template-test
│  │  │  │  └─ src
│  │  │  │     ├─ index.json
│  │  │  │     └─ index.vue
│  │  │  ├─ vite.config.js
│  │  │  └─ webpack.config.js
│  │  ├─ tsconfig.dev.json
│  │  └─ tsconfig.json
│  └─ utils # 工具函数包

架构解析

这一部分我们将基于两个流程分别进行介绍,首先我们要从项目创建主流程开始,对整体有一个清晰的认知,再深入去了解生成器的执行逻辑

为保证每位读者的阅读质量,我们先对一些相关名词进行介绍:

  • 插件

    • 此插件非比构建工具插件,指的是项目生成中可扩展的相关能力。
    • 常见的插件有 eslint、prettier、typescript 等。
    • 后续考虑为特别的框架选择不同的插件,如 VueX、React-router 等。
  • 构建工具

    • 用于自动化软件项目构建的软件工具,简化开发流程,提高效率。
    • 目前我们打算支持的构建工具有 webpack、vite、rollup,后续会逐渐补充
  • 框架

    • 提供了一系列预定义的功能和组件,用于简化和加速软件开发过程。
    • 目前我们打算支持的框架有 Vue、React,同时还有普通的类库或工具库,后续也会逐渐补充。
  • 生成器

    • 与 JS 中的 function* 不同,我们将项目生成的核心命名为生成器,是因为这一步骤涉及到核心文件、配置的生成。

    • 为方便沟通,我们称之为 Generator,它是一个类,包含了处理 框架、构建工具、插件 的核心方法,具体的逻辑我们会在后面介绍。

项目创建主流程

这一流程涉及到命令行的调用、用户预设获取、项目生成等一系列工作。

命令行调用

我们通过 commander 对命令行脚本进行设置,实现代码如下:

const program = new Command();

program
  .version(chalk.greenBright(version))
  .arguments("<project-name>")
  .description("Create a directory for your project files")
  .option("-f, --force", "Overwrite target directory if it exists")
  .option("--dev", "Use development mode")
  .action((name: string, options: Record<string, any>) => {
    createApp(name, options);
  });

program
  .command("add <plugin> [pluginOptions]")
  .description("Install a plugin and invoke its generator in an existing project")
  .option("--registry <url>", "Specify an npm registry URL for installing dependencies (npm only)")
  .allowUnknownOption()
  .action((plugin) => {
    const pluginOptions = minimist(process.argv.slice(3));
    // addPlugin(plugin, pluginOptions)
    console.log(plugin, pluginOptions);
  });

在默认命令 create-neat <project-name> 中,调用了 createApp,这将会触发后续的项目构建流程。

其中,我们可以调用相关的 option 来注入相关的选项,比如 --dev 可以让我们进行 dev 环境的项目创建。

我们还可以看到下面的 add 命令,不过目前并没有相关的逻辑执行,这个命令预计实现将插件注入已生成的项目当中,考虑在未来实现。

用户预设获取

通过命令调用 createApp 后,会进入一段用户交互中。

首先会判断是否存在同名文件夹,用户可以选择是覆盖还是终止流程。紧接着就是用户预设的选择,这涉及到了:

  • 框架
  • 构建工具
  • 插件
  • 包管理工具
  • 镜像源

在获取用户预设后,相关的数据会存入 Preset,在后续创建中非常重要。

这一步仍然有可以优化的地方,比如:

  • 获取用户创建预设的缓存
  • 不同的框架可以有更多特化的插件

项目生成

这一步,会基于 Preset 进行相关文件的创建,同时进行一些必要配置,具体的步骤如下:

  1. 创建项目文件夹。

  2. 创建 pacakge.json 文件、构建工具配置文件,将预设的插件注入 package.json。

  3. 初始化 Git 仓库。

  4. 安装插件依赖(prod 环境)。

  5. 调用生成器。

  6. 安装在生成器执行后,注入 package.json 的额外依赖,这一般是插件、框架所需的依赖。

  7. 其他剩余操作,如创建 md 文档,或其他操作。

每一步的具体执行逻辑还需读者结合代码自己去理解,这边不做详细介绍。下面我们再基于生成器执行流程进行介绍

生成器执行流程

在这个流程中,处理了框架、构建工具、插件等内容,是整个项目创建的核心,框架、插件都需要借助生成器Generator,将相关的文件、配置进行注入,而构建工具目前并没有一套独立的配置方案,仍然存在一定的耦合,这是后续优化的一个重点。

为承载这一流程执行,我们设计了一个 Generator 类,相关的执行逻辑都在 Generator.generate 中执行。

Generator

我们先通过一个流程图了解 Generator.generate 中的执行过程。

image.png

插件与 GeneratorAPI

插件是这个流程中最关键的部分,每个插件的设计结构如下:

plugin-xxx
├── README.md
├── generator
│   └── template # 存放插件需要的相关文件,用 ejs 渲染
│   └── index.cjs  # GeneratorAPI 调用入口
├── index.cjs      # 扩展配置至对应的构建工具中
└── package.json

我们依次来介绍一下几个文件的作用:

入口 index

当这个插件需要在构建工具中注入相关配置时,可以在这个文件中进行注册,下面是一个例子:

const buildToolConfigs = {
  // 支持拓展 loader 和 plugin
  webpack: () => {
    return {
      rules: [
        {
          test: /.js$/, // 匹配所有以 .js 结尾的文件
          exclude: /node_modules/, // 排除 node_modules 目录
          use: {
            loader: "babel-loader", // 指定 Babel Loader
          },
        },
      ],
      plugins: [{ name: "a", params: [], import: { name: "a", from: "b" } }],
    };
  },
  vite: () => {
    return {};
  },
  // 添加其他构建工具的配置...
};

const pluginBabel = (buildTool) => {
  const configHandler = buildToolConfigs[buildTool];

  if (configHandler) {
    return configHandler();
  } else {
    console.warn(`Unsupported build tool: ${buildTool}`);
  }
};

module.exports = pluginBabel;

然而我们并不建议开发时基于某个插件注入构建工具过多的配置,考虑到如下原因:

  • 我们应该把更细节的配置交给用户去选择。
  • 目前的架构,每种构建工具都需要独立配置,当涉及到一些插件时,会因为构建工具生态不同等问题出现偏差。

generator/index

这是插件的核心文件,在这个文件中,通过调用 GeneratorAPI 的方法,实现一系列的操作,基于目前的需求,仅需要支持 package.json 的配置扩展,插入一些文件配置、插件依赖等。

class GeneratorAPI {
  private generator: Generator;

  constructor(generator: Generator) {
    this.generator = generator;
  }

  /**
   * 扩展项目的 package.json 内容
   * @param fields 合并内容
   * @param {object} [options] 操作选项
   */
  extendPackage(fields: object, options: object = {}) {
    // 合并扩展 package 字段
    // 注入 package
  }
}

export default GeneratorAPI;

至于插件的 generator/index 是如何被调用的?

我们在主流程中执行了 Generator.generate,首先会判断 typescript 插件是否存在,控制 isTs 这个环境变量,会影响后续生成文件的格式,紧接着就会遍历 Preset 中的 plugins 属性,为每个 plugin 实例化 GeneratorAPI 实例,通过 loadModule 获取插件导件的 generator/index 函数并执行。

  // 单独处理一个插件相关文件
  async pluginGenerate(pluginName: string) {
    const generatorAPI = new GeneratorAPI(this);

    // pluginGenerator 是一个函数,接受一个 GeneratorAPI 实例作为参数
    let pluginGenerator: (generatorAPI: GeneratorAPI) => Promise<void>;

    // 根据环境变量加载插件
    if (process.env.NODE_ENV === "DEV") {
      const pluginPathInDev = `packages/@plugin/plugin-${pluginName}/generator/index.cjs`;
      pluginGenerator = await loadModule(
        pluginPathInDev,
        path.resolve(__dirname, relativePathToRoot),
      );
    } else if (process.env.NODE_ENV === "PROD") {
      const pluginPathInProd = `node_modules/${pluginName}-plugin-test-ljq`;
      pluginGenerator = await loadModule(pluginPathInProd, this.rootDirectory);
    } else {
      throw new Error("NODE_ENV is not set");
    }

    if (pluginGenerator && typeof pluginGenerator === "function") {
      await pluginGenerator(generatorAPI);
    }

    // 其他操作
  }

generator/template

在 template 中的文件,会通过 ejs 渲染写入目标项目中,如 tsconfig,可以通过相关的 options,结合 ejs 的渲染规则,生成我们预期的文件并写入项目。

然而这一步和 generator/index 存在一定的重复,我们的逻辑实现中,可以通过插入 pacakge.json,再通过 ConfigTransform 结合 extractConfigFiles 把文件提取出。

这两种方式同时存在于项目中,各有优劣,当下仍然在研究优化方向,统一出一个处理文件生成的最优方案。

构建工具

在插件执行 generate 时,会调用插件的入口 index 文件进行插件的注入,在调用时,将多个插件定义的配置与初始配置利用 AST(抽象语法树)进行合并,最终写入 config 文件。

// 处理 webpack config
if (this.buildToolConfig.buildTool === "webpack") {
  const { rules, plugins } = pluginEntry(this.buildToolConfig.buildTool);
  mergeWebpackConfigAst(rules, plugins, this.buildToolConfig.ast);
  // 把ast转 转换成代码,写入文件
  const result = generator(this.buildToolConfig.ast).code;
  fs.writeFileSync(
    path.resolve(this.rootDirectory, `${this.buildToolConfig.buildTool}.config.js`),
    result,
  );
}

由于每种构建工具的配置不同,AST 合并的方法也不同,需要独立设置,我们考虑过使用 unplugin 这类库处理构建配置的适配,但仍然存在一些灵活性问题,如果大家有好的 idea,欢迎来交流 🎉。

框架

其实框架的注入非常简单,我们在 core/template 中创建相关的文件夹,通过 ejs 语法配置好相关的框架结构,在 Generator.generate 最后调用 esj 渲染,即可得到框架内容。

但目前这一步没有支持 typescript 插件,生成的文件格式还是固定的,后续考虑通过维护一个文件树的数据结构来解决文件后缀问题。

框架这一部分我们同样设计了一个 TemplateAPI 类,不过一些方法存在一些耦合,后续考虑进行一些抽象。

最后再结合架构图看看,梳理一下整体的思路:

image.png

架构优化方向

尽管目前流程已经顺利跑通,我们的架构还存在很多可以优化的方向,大致如下:

  • 构建工具和插件之间仍然存在一些耦合。
  • 构建工具的 AST 转换部分逻辑开发存在一定难度,也可以考虑构建工具配置的进一步抽象。
  • 文件的渲染需要统一方案,同时需要项目支持 ts 类型文件。
  • 相关插件的配置目前还不成熟,需要结合实际进一步补充。
  • 可以提供可视化搭建方案。
  • 尝试优化开发前工作流程(pnpm dev、npm link……)。
  • 获取用户创建预设的缓存。
  • 不同的框架可以有更多特化的插件。
  • 插件可以通过 add 命令于后续加入。
  • 考虑支持 monorepo 搭建。
  • ……

如果大家对整体流程的逻辑存在一些优化方向,欢迎交流!

最后

这次的架构介绍淡化了一些具体逻辑的实现,更多聚焦于全流程的介绍,以便大家对我们的项目有更全局的认知。

在这一次重构下来,感受最明显的就是——做什么都要先做成一坨,再去慢慢优化。像现在,仍然有很多需要优化的地方,

期待大家在对整体架构有一定理解后,能更好地参与到项目中,create-neat 的发展离不开大家的支持,感谢贡献!