从零构建一个 Monorepo 项目工程
jiayisheji opened this issue · 0 comments
去年圣诞节格外冷,第二天要上班,早早洗洗睡了。半夜 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
的浏览器)。
发现一个问题:
- 有些域名有
pc
和m
两个文件夹,不说相信大家都应该懂了,都是做前端的。 - 包含重复图片,比如:logo,icon
- 包含重复代码,比如:用了一个第三方的移动端安装
app
的服务js
SDK,基本用的域名下面都有这个代码。还有就是处理rem
的,也是每个域名文件都有这个代码。css 就更不要举例了,html 重复一样。 - 重复协议
html
文件
问题外的思考:
- 每次谷歌修改代码、推广 SEO 相关等非核心代码修改,都需要拉代码,修改代码,提交发布。这繁琐的操作需要开发人员来做吗?
- 推广里面下载链接因为域名问题导致用户下载全是旧版本 app,这些操作也需要运维和开发人同时来做?
结合问题外的思考,我和运维同事捣鼓一个后台管理,使用 Nestjs 搭建,终于体验一把全干工程师。发布部署都是运维搞定,运维把他们的域名相关东西也放到这个管理系统中,这样就解决 2 问题。
通过定时任务去每天检查域名是否过期,定时去检查下载域名是否正常访问。对于异常域名通过钉钉发生给运维去处理。
一直在思考问题 1 怎么解决,那就做一个推广页管理,推广页和域名直接强制关联,自动分配下载地址,开发上传页面模板(接了下的重点),推广管理推广相关的内容。修改对应参数(这些参数都是文字变量,对于图片相关处理,替换图片这种需求不是很常见,开发改代码更快),直接点击发布即可。
每次下载链接自动被替换,都会通过钉钉机器人发给测试去确定。
通过这个小项目也学到一些
nodejs
平常用的比较少的功能,及数据库设计等相关后台知识。
关于页面模板,这个也让我思考很久,最终决定使用 EJS,语法简单,通用性广。lodash.template 也是使用类似语法。前端开发需要上传对于的文件模板,比如 pc
和 m
两个文件夹,需要上传 2 个 zip 包,只有一个只需要上传一个。
在服务端使用 node-stream-zip 解压 zip
包,使用 ejs.render
把模板和推广相关数据编译成 html
。 运维又要求给他生成一些运维相关配置,比如 nginx
配置和 https
的 ssl
证书,最后执行运维提供 shell
脚本,做到一键部署。这些操作过程中,我又把每步操作实时返回给前端页面,有点类似 Jenkins
发布那个界面。
正所谓万事俱备,只欠东方,其他准备都已经完成,现在只缺模板 zip
包。
Monorepo
在构建这个模板项目时,前面的思考已经让我有了一些想法,使用 Monorepo
来构建项目。长时间使用 Angular 开发,对于 Monorepo
并不陌生,并且经常使用这个特性完成开发工作。
关于 Monorepo
这里有篇博客介绍 what-is-monorepo。
在前端有个比较有名 JavaScript
的 Monorepo
包管理器 Lerna,一些耳熟能详开源项目都是使用它,例如: Babel,Jest 等开源项目。
Lerna 是一个快速的现代构建系统,用于管理和发布来自同一存储库的多个 JavaScript/Typescript 软件包。
如果想要构建 Monorepo
项目,使用 Lerna
肯定是不够的,那么接下来我们就来从零开始构建一个 Monorepo
项目 CLI
工具。
Monorepo CLi
解析命令参数
Node.js
为我们提供了 process.argv 来读取命令行参数,作为一个工具,我们不应该手动解析这些参数,有 2 个包 commander 和 cac 推荐,这里我使用 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.js
的 cli.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
,它里面的 loader
和 plugin
输入参数配置验证就是采用 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
?因为一个项目可能包含pc
和m
2 个子项目,默认推荐响应式一站式。 css
强制使用scss
预处理器处理,包括postcss
等处理。- 正常情况下一个
project
只会有一个index.html
,有些project
为了应对审查(谷歌广告)需要有多余的免责申明,隐私政策等页面。 - 关于
postcss
、babel
、browserslist
等配置是全局共享。
定义 json-schema
规范,那就该验证输入数据是否靠谱。我们采用:
npm install -D ajv ajv-keywords
ajv
自带 ajv-formats
拓展一些字符串格式限制属性,比如常见的:date
、uri
、hostname
等。
ajv-keywords
是辅助 Ajv
自定义验证属性,一些常用的属性,比如常见的:typeof
、instanceof
、range
、regexp
等。
关于验证 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
运行,哪些在 production
。webpack
配置里面很多都是数组,需要使用 [].filter(Boolean)
来过滤 undefined
,从而避免 webpack
读取配置时报错。configFactory()
函数拿到配置不是最终配置,只是一个基准配置,后面可以根据 validateSchema
处理之后 project.json
配置来做合并,这样一来,每个项目就可以定制不同的配置。
对外包装 2 个 run 方法:
runWebpack
由configFactory('production')
生成配置,调用Webpack(Config).run()
运行runWebpackDevServer
由configFactory('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
buildWebpack
和 serveWebpack
区别就是,是否使用 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
获取当前运行项目的配置。
其他注释都已经说明了,
这里重点说一下:getProject
和 webpackConfiguration
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);
}
});
};
build
跟 serve
一样,唯一区别, 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
,是为了方便处理 pc
和 mobile
同时存在,有时候又只需要一个,方便处理。
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
一样,借助 axios 和 form-data 把 dist.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
,还支持 React
、Nestjs
、Nextjs
,暂不支持 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
,这玩意就可以方便实现自定义扩展,想要切换 webpack
和 vite
,那就一行代码事情。
{
...
{
- "executor": "@nrwl/web:dev-server",
+ "executor": "@nrwl/vite:dev-server",
}
}
Nx
强大之处,nx-plugin 可以让你自己写 executor
和 generate
,Nx
虽然不支持 Vue
,但是有人写了插件。
Nx
的插件组织里面有几类:
- 基础构建插件:
executor
,例如:webpack,esbuild,vite 等构建工具 - 辅助功能插件:
generate
,例如:nest,next 等生成工具 - 包装构建插件:
executor
和generate
,例如 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
);
}
这是简单的自定义功能,如果想要借助别的更底层 executor
和 generate
呢,我这里一篇定制 nest-mvc 的插件,有兴趣可以看一下,如果有疑问,欢迎跟我交流。
写到最后
说起 Angular
,很多人都不喜欢,可以不用 Angular
,但是它的工程化**,可以借鉴学习,在目前前端界,说二没有敢说一,也为你以后做构建轮子提供思路,你不想折腾,那只能呵呵。
今天就到这里吧,伙计们,玩得开心,祝你好运
谢谢你读到这里。下面是你接下来可以做的一些事情:
- 找到错字了?下面评论
- 如果有问题吗?下面评论
- 对你有用吗?表达你的支持并分享它。