jiayisheji / blog

没事写写文章,喜欢的话请点star,想订阅点watch,千万别fork!

Home Page:https://jiayisheji.github.io/blog/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

从零构建一个 Monorepo 项目工程

jiayisheji opened this issue · comments

commented

image

去年圣诞节格外冷,第二天要上班,早早洗洗睡了。半夜 10 点,老板打电话来说有个推广页要换谷歌代码。跟他说明天早上去了,就去改。凌晨 0 点,又打电话来了,说还需要审核几个小时。事不过三,这哪能拒绝了,穿好衣服爬起来,开了电脑远程公司(疫情以来,公司电脑没有关过,长年开机候命)。下载老板给的代码,找到对应的项目,一看不知道,一看吓一跳,20 多个页面了。推广页面,比较简单,一开始才就 1,2 个页面,交给其他同事负责完成。改了代码提交发布一气呵成,前后不到 10 分钟。

破局

看到项目膨胀,到公司咨询一下推广和运维,还有负责项目的同事。目前这个推广项目页面会越来越多,还是有些重复,现在是按推广域名和推广页面文件夹挂钩,都在一个 git 仓库里,每次提交代码发布都是整个项目一起发布。开发只需要把代码提交 git 仓库即可,剩下交给运维处理发布问题。

由于都是静态文件,里面包含一些谷歌等推广协议相关页面,为了应对审核,这些协议修改也是常有事情,在编辑器可以批量替换,这可能导致错误。为了安全起见只能一个一个替换。这种方式在当前现代前端工业化水平,那相当原始钻木取火水平。想要改变就要破局,话不多说,进入正题。

思考

先看项目结构:

---- root
  |
  |
  |-- .git
  |
  |-- www.a.com
  |
  |-- www.b.com
  |
  |-- www.c.com
  |
  |-- www.d.com
  |
  |-- 更多...

每个域名文件夹大概结构:

---- www.a.com
  |-- index.html
  |-- style.css
  |-- index.js
  |-- images
  |-- robots.txt
  |-- 各种 meta icon 图标
  |-- 可能包含:协议.html 其他页面

功能比较简单,js 中没有引入过多第三方库,比如:jQuery,简单特效直接使用 js 操作 DOM 实现(不用考虑不兼容 ECMA 5 的浏览器)。

发现一个问题:

  1. 有些域名有 pcm 两个文件夹,不说相信大家都应该懂了,都是做前端的。
  2. 包含重复图片,比如:logo,icon
  3. 包含重复代码,比如:用了一个第三方的移动端安装 app 的服务 js SDK,基本用的域名下面都有这个代码。还有就是处理 rem 的,也是每个域名文件都有这个代码。css 就更不要举例了,html 重复一样。
  4. 重复协议 html 文件

问题外的思考:

  1. 每次谷歌修改代码、推广 SEO 相关等非核心代码修改,都需要拉代码,修改代码,提交发布。这繁琐的操作需要开发人员来做吗?
  2. 推广里面下载链接因为域名问题导致用户下载全是旧版本 app,这些操作也需要运维和开发人同时来做?

结合问题外的思考,我和运维同事捣鼓一个后台管理,使用 Nestjs 搭建,终于体验一把全干工程师。发布部署都是运维搞定,运维把他们的域名相关东西也放到这个管理系统中,这样就解决 2 问题。

通过定时任务去每天检查域名是否过期,定时去检查下载域名是否正常访问。对于异常域名通过钉钉发生给运维去处理。

一直在思考问题 1 怎么解决,那就做一个推广页管理,推广页和域名直接强制关联,自动分配下载地址,开发上传页面模板(接了下的重点),推广管理推广相关的内容。修改对应参数(这些参数都是文字变量,对于图片相关处理,替换图片这种需求不是很常见,开发改代码更快),直接点击发布即可。

每次下载链接自动被替换,都会通过钉钉机器人发给测试去确定。

通过这个小项目也学到一些 nodejs 平常用的比较少的功能,及数据库设计等相关后台知识。

关于页面模板,这个也让我思考很久,最终决定使用 EJS,语法简单,通用性广。lodash.template 也是使用类似语法。前端开发需要上传对于的文件模板,比如 pcm 两个文件夹,需要上传 2 个 zip 包,只有一个只需要上传一个。

在服务端使用 node-stream-zip 解压 zip 包,使用 ejs.render 把模板和推广相关数据编译成 html。 运维又要求给他生成一些运维相关配置,比如 nginx 配置和 httpsssl 证书,最后执行运维提供 shell 脚本,做到一键部署。这些操作过程中,我又把每步操作实时返回给前端页面,有点类似 Jenkins 发布那个界面。

正所谓万事俱备,只欠东方,其他准备都已经完成,现在只缺模板 zip 包。

Monorepo

在构建这个模板项目时,前面的思考已经让我有了一些想法,使用 Monorepo 来构建项目。长时间使用 Angular 开发,对于 Monorepo 并不陌生,并且经常使用这个特性完成开发工作。

关于 Monorepo 这里有篇博客介绍 what-is-monorepo

在前端有个比较有名 JavaScriptMonorepo 包管理器 Lerna,一些耳熟能详开源项目都是使用它,例如: BabelJest 等开源项目。

Lerna 是一个快速的现代构建系统,用于管理和发布来自同一存储库的多个 JavaScript/Typescript 软件包。

如果想要构建 Monorepo 项目,使用 Lerna 肯定是不够的,那么接下来我们就来从零开始构建一个 Monorepo 项目 CLI 工具。

Monorepo CLi

解析命令参数

Node.js 为我们提供了 process.argv 来读取命令行参数,作为一个工具,我们不应该手动解析这些参数,有 2 个包 commandercac 推荐,这里我使用 cac

其他相关工具:

  • inquirer:交互命令输入插件
  • chalk: 美化命令行的模块
  • ora:优雅的终端加载提示器
  • shelljs:Node.js 执行 Unix shell 命令
  • fs-extra:Node.js 的 fs 增强版
  • lodash:Node.js 的工具库

还有一些其他好用工具,这里暂时不一一列举了,后面介绍时用上在科普。

创建一个 Monorepo 工作区:

---- root
  |
  |-- .git
  |
  |-- projects 项目集合以及公共依赖(通用脚本,资源等)
  |
  |-- tools 核心 CLI 实现
  |
  |-- package.json
  |
  |-- README.md
  | 
  |-- 更多工程配置文件...

创建入口(从 cac 官网实例开始):

// tools/index.js

const cac = require('cac');

const cli = cac('Template Cli');

// 这里放 cli.command 

cli.help();

(async () => {
  try {
    // Parse CLI args without running the command
    cli.parse(process.argv, { run: false });
    // Run the command yourself
    // You only need `await` when your command action returns a Promise
    await cli.runMatchedCommand();
  } catch (error) {
    // Handle error here..
    // e.g.
    console.error(error.stack);
    process.exit(1);
  }
})();

我们要定义几个 command

  • serve: 开发运行
  • build: 打包编译
  • generate: 生成项目
  • release: 发布上线
// tools/serve.js
module.exports = function (cli) {
  const defaultOptions = {
    platform: "all",
  };

  cli
    .command("serve [project]", "Serve a project", {
      allowUnknownOptions: true,
    })
    .option("--name <name>", "The name of the project")
    .option("--platform <platform>", "Choose a platform type", {
      default: "all",
    })
    .alias("s")
    .action(async (_, options) => {
      if (options.name == null) {
        throw new Error(
          `The serve template name is not provided. Example: npm run serve -- --name=<name>`
        );
      }

      // ...code
    });
};

tools/index.jscli.command 位置引入即可,其他几个 command 类似,这里不一一贴代码。

这里的 cli 没有做成 -g 命令模式,只是简单 nodejs 执行脚本形式

项目配置

所有项目都存放在在 projects 文件夹里,那么有很多项目,如果项目有不一样配置该如何操作了,你可能要说 if/else, 这一块可以学习一下 angular-cli 设计**,构建配置分离。不同的命令对于对于不同的构建器,构建器使用当前的配置。

这是我们每个项目的目录结构:

---- project
  |
  |-- src 源码目录
  |
  |-- project.json 项目配置
  |
  |-- README.md 
  | 
  |-- 其他配置文件,比如 eslint 

本项目采用 js,并没有使用 ts 开发。

不过在 Angular 里项目配置 angular.json 随着项目不断增长会导致这个 json 文件过于庞大。我采用 project.json 为每个项目单独配置,互不影响,这样方便管理,增删改查都方便。

angular-cli 默认使用 webpack 构建项目,这里我们采用主流 webpack,你可能会说我们为什么不使用大火的 Vite 呢?这个先按下不表,后面会有更简单方式来使用它。

我这里用的最新版 webpack5。我**就是利用 project.json 通过构建处理成 webpack.configuration 传递给 webpack 完成整个工程构建过程。

JSON Schema

project.json 里面该写点什么,怎么保证里面配置符合预期。这个引入 json-schema 概念。关于 schema 有哪些,你可以点击下载查看,关于 json-schema-validation 标准介绍。

JSON Schema 是用于验证 JSON 数据结构的强大工具,Schema 可以理解为模式或者规则。

如果你对 json-schema 没有印象,那你一定用过 webpack,它里面的 loaderplugin 输入参数配置验证就是采用 json-schema

当你看完中文文档,有种跃跃欲试冲动,怎么快速构建一个 project.schema.json 呢?

我们要站在巨人肩上参考 angular.json

我这里大概结构:

```json
{
  "$schema": "http://json-schema.org/draft-07/schema",
  "$id": "tools/project.schema.json",
  "title": "Project Options Schema",
  "description": "JSON Schema for `project.json` description file",
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "$schema": {
      "type": "string"
    },
    "root": {
      "description": "该项目文件的根文件夹,相对于工作区文件夹。",
      "type": "string"
    },
    "projects": {
      "type": "object",
      "description": "项目配置",
      "patternProperties": {
        "^(?:@[a-zA-Z0-9_-]+/)?[a-zA-Z0-9_-]+$": {
          "type": "object",
          "properties": {
            "sourceRoot": {
              "description": "放置源码的路径",
              "type": "string"
            },
            "targets": {
              "type": "object",
              "properties": {
                "build": {
                  "type": "object",
                  "properties": {
                    "options": {
                      "description": "构建生产服务配置选项",
                      "type": "object",
                      "properties": {
                        "assets": {
                          "type": "array",
                          "description": "静态应用程序资源列表",
                          "default": [],
                          "items": {
                            "oneOf": [
                              {
                                "type": "object",
                                "description": "包含资源文件对象,相对于工作区文件夹",
                                "properties": {
                                  "glob": {
                                    "type": "string",
                                    "description": "匹配的模式"
                                  },
                                  "input": {
                                    "type": "string",
                                    "description": "要应用 'glob' 的输入目录路径。默认为项目根目录。"
                                  },
                                  "ignore": {
                                    "description": "要忽略的 globs 数组",
                                    "type": "array",
                                    "items": {
                                      "type": "string"
                                    }
                                  },
                                  "output": {
                                    "type": "string",
                                    "description": "输出的绝对路径"
                                  }
                                },
                                "additionalProperties": false,
                                "required": ["glob", "input", "output"]
                              },
                              {
                                "description": "包含资源文件路径,相对于源码文件夹",
                                "type": "string"
                              }
                            ]
                          }
                        },
                        "main": {
                          "type": "string",
                          "description": "应用程序主入口点的完整路径,相对于当前工作区"
                        },
                        "index": {
                          "description": "配置应用程序 index.html 的生成",
                          "oneOf": [
                            {
                              "type": "string",
                              "description": "应用程序生成的 `index.html` 文件的输出路径。将使用提供的完整路径,并将相对于应用程序配置的输出路径进行考虑。用于应用程序HTML索引的文件的路径。指定路径的文件名将用于生成的文件,并将创建在应用程序配置的输出路径的根目录中。"
                            },
                            {
                              "type": "object",
                              "description": "",
                              "properties": {
                                "input": {
                                  "type": "string",
                                  "minLength": 1,
                                  "description": "用于应用程序生成的 `index.html` 的文件的路径"
                                },
                                "output": {
                                  "type": "string",
                                  "minLength": 1,
                                  "default": "index.html",
                                  "description": "应用程序生成的HTML索引文件的输出路径。将使用提供的完整路径,并将相对于应用程序配置的输出路径进行考虑。"
                                }
                              },
                              "required": ["input"]
                            },
                            {
                              "type": "array",
                              "description": "",
                              "minItems": 2,
                              "items": {
                                "type": "object",
                                "description": "",
                                "properties": {
                                  "input": {
                                    "type": "string",
                                    "minLength": 1,
                                    "description": "用于应用程序生成的 `output.html` 的文件的路径"
                                  },
                                  "entry": {
                                    "type": "string",
                                    "minLength": 1,
                                    "description": "用于应用程序生成的 webpack.entry 入口配置 key"
                                  },
                                  "main": {
                                    "type": "string",
                                    "minLength": 1,
                                    "description": "用于应用程序生成的 webpack.entry 入口配置 value"
                                  },
                                  "output": {
                                    "type": "string",
                                    "minLength": 1,
                                    "description": "应用程序生成的 HTML 文件的输出路径。将使用提供的完整路径,并将相对于应用程序配置的输出路径进行考虑。"
                                  }
                                },
                                "required": ["input", "output"]
                              }
                            }
                          ]
                        },
                        "polyfills": {
                          "type": "string",
                          "description": "相对于当前工作区,自定义 polyfills 文件的完整路径。"
                        },
                        "outputPath": {
                          "type": "string",
                          "description": "相对于当前工作区,新输出目录的完整路径。默认情况下,将输出写入当前项目中名为 dist/ 的文件夹。"
                        },
                        "extractCss": {
                          "type": "boolean",
                          "description": "将 css 提取到 .css 文件中",
                          "default": false
                        },
                        "externalDependencies": {
                          "description": "将列出的外部依赖项排除在捆绑到捆绑包中。相反,创建的包依赖于这些依赖项,以便在运行时可用。",
                          "type": "array",
                          "items": {
                            "type": "string"
                          },
                          "default": []
                        },
                        "optimization": {
                          "description": "启用构建输出的优化。包括压缩 script、style 和 image 及摇树优化。",
                          "default": true,
                          "oneOf": [
                            {
                              "type": "object",
                              "properties": {
                                "scripts": {
                                  "type": "boolean",
                                  "description": "启用 script 压缩优化",
                                  "default": true
                                },
                                "styles": {
                                  "type": "boolean",
                                  "description": "启用 style 压缩优化",
                                  "default": true
                                },
                                "images": {
                                  "type": "boolean",
                                  "description": "启用 image 压缩优化",
                                  "default": true
                                }
                              },
                              "additionalProperties": false
                            },
                            {
                              "type": "boolean"
                            }
                          ]
                        },
                        "vendorChunk": {
                          "type": "boolean",
                          "description": "生成一个单独的包,其中只包含库的单独的包使用的代码。",
                          "default": true
                        },
                        "commonChunk": {
                          "type": "boolean",
                          "description": "生成一个单独的包,其中包含跨多个包使用的代码。",
                          "default": true
                        },
                        "baseHref": {
                          "type": "string",
                          "description": "正在构建的应用程序的"
                        },
                        "outputHashing": {
                          "type": "string",
                          "description": "定义输出文件名缓存 hash 模式。",
                          "default": "none",
                          "enum": ["none", "all", "media", "bundles"]
                        },
                        "deployUrl": {
                          "type": "string",
                          "description": "将部署文件的URL"
                        },
                        "verbose": {
                          "type": "boolean",
                          "description": "为输出日志记录添加更多详细信息",
                          "default": false
                        },
                        "progress": {
                          "type": "boolean",
                          "description": "在构建时将进度记录到控制台",
                          "default": true
                        },
                        "webpackConfig": {
                          "type": "string",
                          "description": "一个函数的文件路径,该函数接受 webpack 配置、上下文并返回 webpack 配置结果。"
                        }
                      },
                      "required": ["outputPath"],
                      "additionalProperties": false
                    }
                  },
                  "required": ["options"]
                },
                "serve": {
                  "type": "object",
                  "properties": {
                    "options": {
                      "description": "构建开发服务配置选项",
                      "type": "object",
                      "properties": {
                        "port": {
                          "type": "number",
                          "description": "端口号",
                          "default": 8080
                        },
                        "host": {
                          "type": "string",
                          "description": "主机",
                          "default": "localhost"
                        },
                        "proxyConfig": {
                          "type": "string",
                          "description": "代理配置文件"
                        },
                        "open": {
                          "type": "boolean",
                          "description": "在默认浏览器中打开url",
                          "default": false
                        },
                        "verbose": {
                          "type": "boolean",
                          "description": "为输出日志记录添加更多详细信息"
                        },
                        "liveReload": {
                          "type": "boolean",
                          "description": "是否在更改时重新加载页面,使用实时重新加载",
                          "default": true
                        },
                        "hmr": {
                          "type": "boolean",
                          "description": "启用模块热替换",
                          "default": true
                        },
                        "watch": {
                          "type": "boolean",
                          "description": "监视模式默认",
                          "default": true
                        },
                        "poll": {
                          "type": "number",
                          "description": "启用并定义文件监视轮询时间段(以毫秒为单位)"
                        },
                        "watchOptions": {
                          "type": "object",
                          "description": "用于自定义监视模式的一组选项",
                          "properties": {
                            "aggregateTimeout": {
                              "type": "integer"
                            },
                            "ignored": {
                              "oneOf": [
                                {
                                  "type": "array",
                                  "items": {
                                    "type": "string"
                                  }
                                },
                                {
                                  "type": "string"
                                }
                              ]
                            },
                            "poll": {
                              "type": "integer"
                            },
                            "followSymlinks": {
                              "type": "boolean"
                            },
                            "stdin": {
                              "type": "boolean"
                            }
                          }
                        }
                      },
                      "additionalProperties": false
                    }
                  },
                  "required": ["options"]
                }
              },
              "required": ["build", "serve"]
            }
          },
          "required": ["targets", "sourceRoot"]
        }
      },
      "additionalProperties": false
    },
    "templateParameters": {
      "type": "array",
      "uniqueItemProperties": ["key"],
      "description": "项目模板变量",
      "minItems": 1,
      "items": {
        "type": "object",
        "properties": {
          "key": {
            "type": "string",
            "description": "模板变量属性名"
          },
          "value": {
            "type": "string",
            "description": "模板变量属性值"
          },
          "type": {
            "type": "string",
            "enum": ["string", "number", "null", "boolean", "json"],
            "default": "string",
            "description": "模板变量属性类型"
          },
          "remark": {
            "type": "string",
            "description": "模板变量属性描述"
          }
        },
        "required": ["key", "value", "type"]
      }
    }
  },
  "required": ["root", "projects", "templateParameters"]
}
```

以上就是 project.json 要输入的内容:

  • 为什么会有 projects?因为一个项目可能包含 pcm 2 个子项目,默认推荐响应式一站式。
  • css 强制使用 scss 预处理器处理,包括 postcss 等处理。
  • 正常情况下一个 project 只会有一个 index.html,有些 project 为了应对审查(谷歌广告)需要有多余的免责申明,隐私政策等页面。
  • 关于 postcssbabelbrowserslist 等配置是全局共享。

定义 json-schema 规范,那就该验证输入数据是否靠谱。我们采用:

npm install -D ajv ajv-keywords

ajv 自带 ajv-formats 拓展一些字符串格式限制属性,比如常见的:dateurihostname等。
ajv-keywords 是辅助 Ajv 自定义验证属性,一些常用的属性,比如常见的:typeofinstanceofrangeregexp等。

关于验证 json-schema 逻辑并不复杂:

// 引入包
const Ajv = require("ajv");
const addFormats = require("ajv-formats");
const addKeywords = require("ajv-keywords");
// 配置 ajv
const ajv = new Ajv({
  allErrors: true,
  passContext: true,
  validateFormats: true,
  messages: true,
});
addFormats(ajv);
addKeywords(ajv, ["range"]);
// 引入 json-schema 规则
const jsonSchema = require("../project.schema.json");

接下来只需要对外暴露一个方法,这个方法完成验证和转换。

module.exports.validateSchema = async function validateSchema(json) {
  const validator = await compile(jsonSchema);
  const { success, errors, data } = await validator(json);
  if (!success) {
    throw new SchemaValidationException(errors);
  }

  return transform(jsonSchema, data);
}

关于验证,ajv 自带验证方法,我们只需要使用即可:

// 执行compile后validate可以多次使用
const compile = async function (schema) {
  ajv.removeSchema(schema);
  let validator;
  try {
    validator = ajv.compile(schema);
  } catch (e) {
    // This should eventually be refactored so that we we handle race condition where the same schema is validated at the same time.
    if (!(e instanceof Ajv.MissingRefError)) {
      throw e;
    }
    validator = await ajv.compileAsync(schema);
  }
  return async (data) => {
    // Validate using ajv
    try {
      const success = await validator.call(undefined, data);

      if (!success) {
        return { data, success, errors: validator.errors ?? [] };
      }
    } catch (error) {
      if (error instanceof Ajv.ValidationError) {
        return { data, success: false, errors: error.errors };
      }

      throw error;
    }

    return {
      data,
      success: true,
    }
  };
};

ajv 默认是没有转换功能,只做 json-schema 验证。这个转换是什么意思,在 json-schema 规则里有一些属性选填但有默认值,但是我们 project.json 是没有提供这些属性,实际 js 取值过程就需要去先判断这个值是否存在,如果转换之后,就可以省略这个操作。关于转换函数 transform 这里就不贴代码,原理写法跟递归深拷贝类似,如果你写出来,值得反思一下。

如果你实在没有思路,angular-cli 中有个 transform 方法,可以参考借鉴一下。

webpack 配置

我们上面已经拿到每个项目的的配置,可以根据不同命令生成不同的 webpack 配置,主要开发和生产,正好对应 Webpack Mode

webpack 使用方式有很多,一般作为 CLI 工具时都是使用 Node Api 来灵活定制功能。

webpack 提供 Webpack 方法将配置转换成 Compiler,就可以直接调用 run(),相当于命令行输入 webpack build 运行。这种方式生产发布就完全够用了,但是在开发时,还需要有启动服务器,接口代理,热更新等。这个时候就需要 WebpackDevServer(DevServerOptions, Compiler) 类,实例化之后调用方法 startCallback() 即可完成开发启动,相当于命令行输入 webpack serve 运行。

对于 Webpack 配置,可以参考文档,但是文档毕竟很长很多,想要站在巨人肩上,我们可以借助一些开源的配置,快速生成。

比如 create-react-app 的配置。它把 webpack 配置包装在一个配置工厂函数 configFactory(webpackEnv),传递一个环境标识,根据这个环境标识去生产哪些配置在 development 运行,哪些在 productionwebpack 配置里面很多都是数组,需要使用 [].filter(Boolean) 来过滤 undefined,从而避免 webpack 读取配置时报错。configFactory() 函数拿到配置不是最终配置,只是一个基准配置,后面可以根据 validateSchema 处理之后 project.json 配置来做合并,这样一来,每个项目就可以定制不同的配置。

对外包装 2 个 run 方法:

  • runWebpackconfigFactory('production') 生成配置,调用 Webpack(Config).run() 运行
  • runWebpackDevServerconfigFactory('development') 生成配置,调用 WebpackDevServer(DevServerOptions, Compiler).startCallback() 运行
/**
 *
 * @param {webpack.Configuration} config
 * @param {*} context
 * @param {*} transforms
 */
exports.runWebpack = (config, context, transforms) => {
  const logging =
    transforms.logger ??
    ((stats, config) => context.logger.info(stats.toString(config.stats)));

  return new Promise((resolve, reject) => {
    try {
      const webpackCompiler = webpack(config);

      webpackCompiler.run((err, stats) => {
        if (err) {
          return reject(err);
        }

        if (!stats) {
          return;
        }

        // 日志数据
        logging(stats, config);

        const statsOptions =
          typeof config.stats === "boolean" ? undefined : config.stats;
        const result = {
          success: !stats.hasErrors(),
          webpackStats: stats.toJson(statsOptions),
          emittedFiles: getEmittedFiles(stats.compilation),
          outputPath: stats.compilation.outputOptions.path,
        };

        webpackCompiler.close(() => {
          resolve(result);
        });
      });
    } catch (err) {
      if (err) {
        context.logger.error(
          `\nAn error occurred during the build:\n${
            err instanceof Error ? err.stack : err
          }`
        );
      }
      reject(err);
    }
  });
};

/**
 *
 * @param {webpack.Configuration} config
 * @param {*} context
 * @param {*} transforms
 */
exports.runWebpackDevServer = (config, context, transforms) => {
  const logging =
    transforms.loader ??
    ((stats, config) => context.logger.info(stats.toString(config.stats)));

  const devServerConfig = transforms.devServerConfig || config.devServer || {};

  if (devServerConfig.host == null) {
    devServerConfig.host = "localhost";
  }

  return new Promise((resolve, reject) => {
    let result;

    const webpackCompiler = webpack(config);

    webpackCompiler.hooks.done.tap("build-webpack", (stats) => {
      logging(stats, config);
      resolve({
        ...result,
        emittedFiles: getEmittedFiles(stats.compilation),
        success: !stats.hasErrors(),
        outputPath: stats.compilation.outputOptions.path,
      });
    });

    const devServer = new webpackDevServer(devServerConfig, webpackCompiler);
    devServer.startCallback((err) => {
      if (err) {
        return reject(err);
      }

      const address = devServer.server?.address();
      if (!address) {
        reject(new Error(`Dev-server address info is not defined.`));
        return;
      }

      result = {
        success: true,
        port: typeof address === "string" ? 0 : address.port,
        family: typeof address === "string" ? "" : address.family,
        address: typeof address === "string" ? address : address.address,
      };
    });
  });
};

就可以在 cac 对应的方法里面调用对应的 run 方法。

configFactory 看起来不错,实际写的一坨代码包在一个函数里面,如果要修改一个基准配置,找脑壳痛。

我们可以把 configFactory 合理拆分:

  • common:基础配置(包括 js)
  • style: 处理 css 配置
  • html: 处理 html 配置
  • image: 处理 img 配置
  • dev-server: 开发 DevServerOptions 配置

这里就不贴代码了,太长了,主要参考 angular-cli 里面一些配置,精简一些不需要。

  • common
  • style
  • dev-server
  • image 这个没有参考,只是参考 webpack 这个图片压缩的插件,跟上面 3 个写法类似
  • html 这个就没有参考了,如果做单页应用,就一个 html,我这个需要特殊处理一下,开发时候是需要编译成 .html,打包的时候还是保留 .ejs,方便服务端处理。

简单理解就是把大函数拆分成小函数,这样就方便组合使用。现在需要提取 2 个新的方法来组合这些配置:

  • buildWebpack:组合之后调用 runWebpack
  • serveWebpack:组合之后调用 runWebpackDevServer

buildWebpackserveWebpack 区别就是,是否使用 dev-server,其他一样。

简单秀一下这 2 个函数:

exports.buildWebpack = async function (options, context, transforms = {}) {
  const spinner = new Spinner();
    try {
      spinner.start('Building for production...');
      // 获取 webpack 通用配置
      const { config, projectRoot, projectSourceRoot } =
        await generateWebpackConfigFromContext(options, context, (wco) => [
          getCommonConfig(wco),
          getStylesConfig(wco),
          getInjectHTMLConfig(wco, context.templateParameters),
        ]);

      let webpackConfig = config;

      // 处理 cli webpack 配置
      if (typeof transforms.webpackConfiguration === "function") {
        webpackConfig = await transforms.webpackConfiguration(webpackConfig);
      }

      if (webpackConfig == null || typeof webpackConfig !== "object") {
        throw new Error(
          "transforms.webpackConfiguration return must be defined webpack.Configuration"
        );
      }

      // 用户自定义 webpack 配置
      webpackConfig = await mergeCustomWebpackConfig(
        webpackConfig,
        options,
        context
      );

      // 检查 entry 是否存在
      checkWebpackConfigEntry(webpackConfig);

      // 启动 webpack dev server
      const result = await runWebpack(webpackConfig, context, {});
      spinner.succeed();
      return result;
    } catch (error) {
      spinner.fail();
      throw error;
    }
};

exports.serveWebpack = async function (options, context, transforms = {}) {
  const spinner = new Spinner();
  try {
    spinner.start('Starting development server...');
    // 获取 webpack 通用配置
    const { config, projectRoot, projectSourceRoot } =
      await generateWebpackConfigFromContext(options, context, (wco) => [
        getDevServerConfig(wco),
        getCommonConfig(wco),
        getStylesConfig(wco),
        getInjectHTMLConfig(wco, context.templateParameters),
      ]);

    let webpackConfig = config;

    // 处理 cli webpack 配置
    if (typeof transforms.webpackConfiguration === "function") {
      webpackConfig = await transforms.webpackConfiguration(webpackConfig);
    }

    if (webpackConfig == null || typeof webpackConfig !== "object") {
      throw new Error(
        "transforms.webpackConfiguration return must be defined webpack.Configuration"
      );
    }

    // 用户自定义 webpack 配置
    webpackConfig = await mergeCustomWebpackConfig(
      webpackConfig,
      options,
      context
    );

    // 检查 entry 是否存在
    checkWebpackConfigEntry(webpackConfig);

    if (!webpackConfig.devServer) {
      throw new Error('Webpack Dev Server configuration was not set.');
    }

    // 启动 webpack dev server
    const result = await runWebpackDevServer(webpackConfig, context, {
      devServerConfig: webpackConfig.devServer,
    });
    spinner.succeed('Browser application bundle generation complete.');
    return result;
  } catch (error) {
    spinner.fail();
    throw error;
  }
};

CLI 工具功能实现

核心的构建功能已经完成,接下来就该完成 CLi 工具

serve

module.exports = function (cli) {
  const defaultOptions = {
    platform: "all",
  };

  cli
    .command("serve [project]", "Serve a Template", {
      allowUnknownOptions: true,
    })
    .option("--name <name>", "The name of the Template")
    .option("--platform <platform>", "Choose a platform type", {
      default: "all",
    })
    .alias("s")
    .action(async (_, options) => {
      if (options.name == null) {
        throw new Error(
          `The serve template name is not provided. Example: npm run serve -- --name=<name>`
        );
      }

      // 选择平台
      if (typeof options.platform === "string") {
        options.platform = ["all", "pc", "mobile"].includes(
          options.platform
        )
          ? options.platform
          : defaultOptions.platform;
      } else {
        options.platform = defaultOptions.platform;
      }
      // 获取 project.json
      const projectJson = await getProjectJson(options.name);
      // 处理 project.json 变成配置数据  
      const builderSchema = await validateSchema(projectJson);
      // 获取项目配置
      const { options: buildOptions, context } = getProject(
        builderSchema,
        options.platform,
        'development'
      );
      
      try {
        const result = await serveWebpack(buildOptions, context, {
          webpackConfiguration: (webpackConfigOptions) => {
            // cli 自定义 webpack 配置
            return webpackConfigOptions;
          }
        });
        
        if(result.success) {
          console.log(`App running at: ` + chalk.cyan(`http://${result.address === '127.0.0.1' ? 'localhost' : result.address}:${result.port}`));
        } else {
          console.log(result);
        }
      } catch (error) {
        console.error(error);
      }
    });
};

通过 options.name 获取 project.json,然后通过 options.platform 获取当前运行项目的配置。

其他注释都已经说明了,

这里重点说一下:getProjectwebpackConfiguration

webpackConfiguration 在这里有什么用,这里和 project.json 里那个自定义 webpack 配置,这里 cli 自定义 webpack 配置。这里你看到没什么意义代码,接下来 build 里,你就看到它的用处。

getProject 为了保证在 serveWebpack 以及后需要功能中使用更加方便,这里统一数据结构,通过环境来生成一个项目配置,最终交给 serveWebpack

/**
 *
 * @param {*} builderSchema
 * @param {"all" | "pc" | "mobile"} platform
 * @param {'development' | 'production'} environment
 * @returns { options: Object, context: Object }
 */
function getProject(builderSchema, platform, environment) {
  const { sourceRoot, targets } = builderSchema.projects[platform];
  const metadata = {
    ...targets,
    root: builderSchema.root,
    sourceRoot,
  };
  // require("webpack/lib/logging/runtime")
  logging.configureDefaultLogger({
    level: "log",
  });

  const options =
    environment === "production"
      ? { templateParameters: null, template: true }
      : Object.assign({}, targets.serve.options, {
          optimization: false,
          sourceMap: false,
          template: false,
          templateParameters: getTemplateParameters(
            builderSchema.templateParameters
          ),
        });

  return {
    options: Object.assign({ environment }, targets.build.options, options),
    context: {
      logger: logging.getLogger(platform),
      workspaceRoot: cwd,
      projectRoot: builderSchema.root,
      sourceRoot,
      target: {
        project: platform,
        metadata,
      },
    },
  };
}

接下来你只需要运行:

npm run serve -- --name=<name>

build

module.exports = function (cli) {
  cli
    .command("build [project]", "Build a Template", {
      allowUnknownOptions: true,
    })
    .option("--name <name>", "The name of the Template")
    .alias("b")
    .action(async (_, options) => {
      if (options.name == null) {
        throw new Error(
          "The build template name is not provided. Example: npm run build -- --name=<name>"
        );
      }
      // 获取 project.json
      const projectJson = await getProjectJson(options.name);
      // 处理 project.json 变成配置数据  
      const builderSchema = await validateSchema(projectJson);
      // 获取 project.json#projects 里所有的项目配置
      const projects = getProjectAll(builderSchema);

      try {
        for (const { options: buildOptions, context } of projects) {
          await buildWebpackBrowser(
            buildOptions,
            context,
            {
              webpackConfiguration: (webpackConfigOptions) => {
                addBuildReleaseZip(webpackConfigOptions, buildOptions, context);
                return webpackConfigOptions;
              }
            }
          );
        }
      } catch (error) {
        console.error(error);
      }
    });
};

buildserve 一样,唯一区别, serve 一次只能运行一个 project(这也是为什么需要 platform 参数的原因),build 需要打包 projects 所有的项目

addBuildReleaseZip 就是把 dist 文件夹里项目打包成 zip 文件,方便上传。

npm run build -- --name=<name>

generate

module.exports = function (cli) {
  const defaultOptions = {
    platform: "all",
    proxy: false,
  };

  cli
    .command("generate [project]", "Generate a new Template", {
      allowUnknownOptions: true,
    })
    .option("--name <name>", "The name of the Template")
    .option("--platform <platform>", "Choose a platform type", {
      default: "all",
    })
    .option("--proxy <proxy>", "Whether support proxy")
    .alias("g")
    .action(async (_, options) => {
      if (options.name == null) {
        throw new Error(
          "The generate template name is not provided. Example: npm run generate -- --name=<name>"
        );
      }
      // 检查平台
      if (typeof options.platform === "string") {
        options.platform = ["all", "multi", "pc", "mobile"].includes(
          options.platform
        )
          ? options.platform
          : defaultOptions.platform;
      } else {
        options.platform = defaultOptions.platform;
      }

      // 检查是否需要设置代理
      if (typeof options.proxy != null) {
        options.proxy = toBoolean(options.proxy);
      } else {
        options.proxy = defaultOptions.proxy;
      }

      // 查重
      try {
        await getPackage(options.name);
        throw new Error(`Template ${options.name} already existed`);
      } catch (error) {
        // console.log('getPackage', error);
      }

      // 拼装 project.json
      const projectJson = {};
      // 创建项目文件
      // fs.mkdir(projectJson.root)
      // fs.writeJson('project.json', projectJson)
      // fs.mkdir(projectJson.sourceRoot)     
      // 根据 project.json#projects 生成入口文件 index (js, css,ejs)
    })
}

generate 没有说明复杂的,根据命令行参数,去生成 project.json, 按照项目配置生成对应文件和写入简单示例代码。

platform 这里平台会多一个 multi,是为了方便处理 pcmobile 同时存在,有时候又只需要一个,方便处理。

npm run generate -- --name=<name>

release

module.exports = function (cli) {
  const defaultOptions = {
    platform: "all",
    config: "patch",
    publish: true,
  };

  cli
    .command("release [project]", "Release a Template", {
      allowUnknownOptions: true,
    })
    .option("--name <name>", "The name of the Template")
    .option("--config <config>", "Whether to upload new variables")
    .option("--publish <publish>", "Whether to publish the project")
    .alias("r")
    .action(async (_, options) => {
      if (options.name == null) {
        throw new Error(`The release template name is not provided. Example: npm run release -- --name=<name>`);
      }

      const form = new FormData();
      const zip = fs.createReadStream(PATH_TO_FILE);

      form.append('zip', zip);

      // In Node.js environment you need to set boundary in the header field 'Content-Type' by calling method `getHeaders`
      const formHeaders = form.getHeaders();

      axios.post('http://example.com', form, {
        headers: {
          ...formHeaders,
        },
      })
      .then(response => response)
      .catch(error => error)
    });
};

release 就简单了,里面代码和 build 一样,借助 axiosform-datadist.zip 传到服务器上。

主要方便项目开发者使用,config 自动更新模板变量到数据库,publish 自动发布该项目。

npm run release -- --name=<name>

我的想法是能程序自动处理,就自动处理。这是我对 nodejs 仅停留在做个小工具,方便小伙伴早下班。

Nx 一个现代 Monorepo 工具

前面我们做了很多事情,主要还原我想要做一个 Monorepo 项目工具,最基础需要哪些东西:

  • 一个配置文件(项目之间互不影响)
  • 一整套构建脚本运行命令
  • 方便自定义扩展

前面 2 个,我上面都已经实现了,自定义扩展方便确暂时无法实现,原因我的构建和 CLI 完全耦合,无法分离,我想老项目使用 webpack, 新项目使用新潮流 vite 按照现在设计完全不可能。

接下来我们介绍 Nx,它将完全实现这个不可能。

Nx 一开始的 Angular-cli 的扩展,它的作者成员也是 Angular 核心开发者。

我从 Nx v8 开始使用它,一度放弃 Angular-cli,因为它包含 Angular-cli,还支持 ReactNestjsNextjs,暂不支持 Vue

可能 vueer 要歧义,为什么不支持,因为 vue-cli 还不错,create-react-app 就比较拉跨,老外不知道那个什么米,我也是用了 Nx 才开始写 React,最近正在写一个 Nextjs 公司项目。

创建的一个 nx workspace 就可以开始构建 Monorepo 项目了。

npx create-nx-workspace projectName

VS code 里推荐下载 Nx Console 插件。

你创建项目以后,用 VS code 打开它就会提示你安装这个插件,安装以后方便很多。

用它来写 generate 就方便的多,只需要把模板,挡在 files 文件夹里,nx g xxxx -name=xxx 就可以愉快玩耍了,这个 nger 很眼熟吧,你没看错,底层就是和 Angular-cli 一套实现。之前版本一直都是 ng g,最近几个版本才换成 nx

我的 project.json 和它 project.json 基本类似,唯一区别它有个 executor,这玩意就可以方便实现自定义扩展,想要切换 webpackvite,那就一行代码事情。

{
  ...
  {
  -  "executor": "@nrwl/web:dev-server",  
  +  "executor": "@nrwl/vite:dev-server",
  }

}

Nx 强大之处,nx-plugin 可以让你自己写 executorgenerateNx 虽然不支持 Vue,但是有人写了插件

Nx插件组织里面有几类:

  • 基础构建插件:executor,例如:webpack,esbuild,vite 等构建工具
  • 辅助功能插件:generate,例如:nest,next 等生成工具
  • 包装构建插件:executorgenerate,例如 Angular,React,Vue 等生成工具

Nx 里你可以使用 runExecutor 运行已经在 project.json 存在的 executor,比如,有多个项目,需要 build,但是它们参数各不一样,如果你是统一部署的,只希望传递一个命令和对应项目名即可,就可以写一个 deploy 的命令和对应的 executor,里面使用 runExecutor 调用 build

export default async function deployExecutor(
  options: deployExecutor,
  context: ExecutorContext
): Promise<{ success: boolean }> {
  return await runExecutor(
    { project: context.projectName, target: 'build', configuration: 'production', ...options },
    context
  );
}

这是简单的自定义功能,如果想要借助别的更底层 executorgenerate 呢,我这里一篇定制 nest-mvc 的插件,有兴趣可以看一下,如果有疑问,欢迎跟我交流。

写到最后

说起 Angular,很多人都不喜欢,可以不用 Angular,但是它的工程化**,可以借鉴学习,在目前前端界,说二没有敢说一,也为你以后做构建轮子提供思路,你不想折腾,那只能呵呵。


今天就到这里吧,伙计们,玩得开心,祝你好运

谢谢你读到这里。下面是你接下来可以做的一些事情:

  • 找到错字了?下面评论
  • 如果有问题吗?下面评论
  • 对你有用吗?表达你的支持并分享它。