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

225 天前
 moment082

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

首先上地址:create-neat

如果对你有帮助,欢迎 star ,如果你想参与开发或者想学习,那么这个项目是你学习前端工程化一个非常不错的项目,个人微信 yunmz777

架构升级背景

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

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

架构俯瞰

架构设计

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

暂时无法在飞书文档外展示此内容

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

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

目录结构设计

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

├─ 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 # 工具函数包

架构解析

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

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

项目创建主流程

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

命令行调用

我们通过 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 中的执行过程。

暂时无法在飞书文档外展示此内容

插件与 GeneratorAPI

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

bashplugin-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 的配置扩展,插入一些文件配置、插件依赖等。

tsclass 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 函数并执行。

ts  // 单独处理一个插件相关文件
  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 类,不过一些方法存在一些耦合,后续考虑进行一些抽象。

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

暂时无法在飞书文档外展示此内容

架构优化方向

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

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

最后

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

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

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

1226 次点击
所在节点    程序员
1 条回复
rookie2luochao
225 天前
其实轻量脚手架挺好的,但是大家要么用官方的,要么就是直接找个开源项目来 T 代码,而且加上业务沉淀,脚手架感觉是一次性产物,我自己也基于官方的加了点 npm 包自己用,比如我这个项目例子: https://github.com/rookie-luochao/openapi-ui

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/1038350

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX