You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
// tools/index.jsconstcac=require('cac');constcli=cac('Template Cli');// 这里放 cli.command cli.help();(async()=>{try{// Parse CLI args without running the commandcli.parse(process.argv,{run: false});// Run the command yourself// You only need `await` when your command action returns a Promiseawaitcli.runMatchedCommand();}catch(error){// Handle error here..// e.g.console.error(error.stack);process.exit(1);}})();
我们要定义几个 command:
serve: 开发运行
build: 打包编译
generate: 生成项目
release: 发布上线
// tools/serve.jsmodule.exports=function(cli){constdefaultOptions={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){thrownewError(`The serve template name is not provided. Example: npm run serve -- --name=<name>`);}// ...code});};
module.exports.validateSchema=asyncfunctionvalidateSchema(json){constvalidator=awaitcompile(jsonSchema);const{ success, errors, data }=awaitvalidator(json);if(!success){thrownewSchemaValidationException(errors);}returntransform(jsonSchema,data);}
关于验证,ajv 自带验证方法,我们只需要使用即可:
// 执行compile后validate可以多次使用constcompile=asyncfunction(schema){ajv.removeSchema(schema);letvalidator;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(!(einstanceofAjv.MissingRefError)){throwe;}validator=awaitajv.compileAsync(schema);}returnasync(data)=>{// Validate using ajvtry{constsuccess=awaitvalidator.call(undefined,data);if(!success){return{ data, success,errors: validator.errors??[]};}}catch(error){if(errorinstanceofAjv.ValidationError){return{ data,success: false,errors: error.errors};}throwerror;}return{
data,success: true,}};};
exports.buildWebpack=asyncfunction(options,context,transforms={}){constspinner=newSpinner();try{spinner.start('Building for production...');// 获取 webpack 通用配置const{ config, projectRoot, projectSourceRoot }=awaitgenerateWebpackConfigFromContext(options,context,(wco)=>[getCommonConfig(wco),getStylesConfig(wco),getInjectHTMLConfig(wco,context.templateParameters),]);letwebpackConfig=config;// 处理 cli webpack 配置if(typeoftransforms.webpackConfiguration==="function"){webpackConfig=awaittransforms.webpackConfiguration(webpackConfig);}if(webpackConfig==null||typeofwebpackConfig!=="object"){thrownewError("transforms.webpackConfiguration return must be defined webpack.Configuration");}// 用户自定义 webpack 配置webpackConfig=awaitmergeCustomWebpackConfig(webpackConfig,options,context);// 检查 entry 是否存在checkWebpackConfigEntry(webpackConfig);// 启动 webpack dev serverconstresult=awaitrunWebpack(webpackConfig,context,{});spinner.succeed();returnresult;}catch(error){spinner.fail();throwerror;}};exports.serveWebpack=asyncfunction(options,context,transforms={}){constspinner=newSpinner();try{spinner.start('Starting development server...');// 获取 webpack 通用配置const{ config, projectRoot, projectSourceRoot }=awaitgenerateWebpackConfigFromContext(options,context,(wco)=>[getDevServerConfig(wco),getCommonConfig(wco),getStylesConfig(wco),getInjectHTMLConfig(wco,context.templateParameters),]);letwebpackConfig=config;// 处理 cli webpack 配置if(typeoftransforms.webpackConfiguration==="function"){webpackConfig=awaittransforms.webpackConfiguration(webpackConfig);}if(webpackConfig==null||typeofwebpackConfig!=="object"){thrownewError("transforms.webpackConfiguration return must be defined webpack.Configuration");}// 用户自定义 webpack 配置webpackConfig=awaitmergeCustomWebpackConfig(webpackConfig,options,context);// 检查 entry 是否存在checkWebpackConfigEntry(webpackConfig);if(!webpackConfig.devServer){thrownewError('Webpack Dev Server configuration was not set.');}// 启动 webpack dev serverconstresult=awaitrunWebpackDevServer(webpackConfig,context,{devServerConfig: webpackConfig.devServer,});spinner.succeed('Browser application bundle generation complete.');returnresult;}catch(error){spinner.fail();throwerror;}};
CLI 工具功能实现
核心的构建功能已经完成,接下来就该完成 CLi 工具
serve
module.exports=function(cli){constdefaultOptions={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){thrownewError(`The serve template name is not provided. Example: npm run serve -- --name=<name>`);}// 选择平台if(typeofoptions.platform==="string"){options.platform=["all","pc","mobile"].includes(options.platform)
? options.platform
: defaultOptions.platform;}else{options.platform=defaultOptions.platform;}// 获取 project.jsonconstprojectJson=awaitgetProjectJson(options.name);// 处理 project.json 变成配置数据 constbuilderSchema=awaitvalidateSchema(projectJson);// 获取项目配置const{options: buildOptions, context }=getProject(builderSchema,options.platform,'development');try{constresult=awaitserveWebpack(buildOptions,context,{webpackConfiguration: (webpackConfigOptions)=>{// cli 自定义 webpack 配置returnwebpackConfigOptions;}});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);}});};
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){thrownewError("The build template name is not provided. Example: npm run build -- --name=<name>");}// 获取 project.jsonconstprojectJson=awaitgetProjectJson(options.name);// 处理 project.json 变成配置数据 constbuilderSchema=awaitvalidateSchema(projectJson);// 获取 project.json#projects 里所有的项目配置constprojects=getProjectAll(builderSchema);try{for(const{options: buildOptions, context }ofprojects){awaitbuildWebpackBrowser(buildOptions,context,{webpackConfiguration: (webpackConfigOptions)=>{addBuildReleaseZip(webpackConfigOptions,buildOptions,context);returnwebpackConfigOptions;}});}}catch(error){console.error(error);}});};
addBuildReleaseZip 就是把 dist 文件夹里项目打包成 zip 文件,方便上传。
npm run build -- --name=<name>
generate
module.exports=function(cli){constdefaultOptions={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){thrownewError("The generate template name is not provided. Example: npm run generate -- --name=<name>");}// 检查平台if(typeofoptions.platform==="string"){options.platform=["all","multi","pc","mobile"].includes(options.platform)
? options.platform
: defaultOptions.platform;}else{options.platform=defaultOptions.platform;}// 检查是否需要设置代理if(typeofoptions.proxy!=null){options.proxy=toBoolean(options.proxy);}else{options.proxy=defaultOptions.proxy;}// 查重try{awaitgetPackage(options.name);thrownewError(`Template ${options.name} already existed`);}catch(error){// console.log('getPackage', error);}// 拼装 project.jsonconstprojectJson={};// 创建项目文件// fs.mkdir(projectJson.root)// fs.writeJson('project.json', projectJson)// fs.mkdir(projectJson.sourceRoot) // 根据 project.json#projects 生成入口文件 index (js, css,ejs)})}
platform 这里平台会多一个 multi,是为了方便处理 pc 和 mobile 同时存在,有时候又只需要一个,方便处理。
npm run generate -- --name=<name>
release
module.exports=function(cli){constdefaultOptions={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){thrownewError(`The release template name is not provided. Example: npm run release -- --name=<name>`);}constform=newFormData();constzip=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`constformHeaders=form.getHeaders();axios.post('http://example.com',form,{headers: {
...formHeaders,},}).then(response=>response).catch(error=>error)});};
去年圣诞节格外冷,第二天要上班,早早洗洗睡了。半夜 10 点,老板打电话来说有个推广页要换谷歌代码。跟他说明天早上去了,就去改。凌晨 0 点,又打电话来了,说还需要审核几个小时。事不过三,这哪能拒绝了,穿好衣服爬起来,开了电脑远程公司(疫情以来,公司电脑没有关过,长年开机候命)。下载老板给的代码,找到对应的项目,一看不知道,一看吓一跳,20 多个页面了。推广页面,比较简单,一开始才就 1,2 个页面,交给其他同事负责完成。改了代码提交发布一气呵成,前后不到 10 分钟。
破局
看到项目膨胀,到公司咨询一下推广和运维,还有负责项目的同事。目前这个推广项目页面会越来越多,还是有些重复,现在是按推广域名和推广页面文件夹挂钩,都在一个
git
仓库里,每次提交代码发布都是整个项目一起发布。开发只需要把代码提交git
仓库即可,剩下交给运维处理发布问题。由于都是静态文件,里面包含一些谷歌等推广协议相关页面,为了应对审核,这些协议修改也是常有事情,在编辑器可以批量替换,这可能导致错误。为了安全起见只能一个一个替换。这种方式在当前现代前端工业化水平,那相当原始钻木取火水平。想要改变就要破局,话不多说,进入正题。
思考
先看项目结构:
每个域名文件夹大概结构:
功能比较简单,
js
中没有引入过多第三方库,比如:jQuery
,简单特效直接使用js
操作DOM
实现(不用考虑不兼容ECMA 5
的浏览器)。发现一个问题:
pc
和m
两个文件夹,不说相信大家都应该懂了,都是做前端的。app
的服务js
SDK,基本用的域名下面都有这个代码。还有就是处理rem
的,也是每个域名文件都有这个代码。css 就更不要举例了,html 重复一样。html
文件问题外的思考:
结合问题外的思考,我和运维同事捣鼓一个后台管理,使用 Nestjs 搭建,终于体验一把全干工程师。发布部署都是运维搞定,运维把他们的域名相关东西也放到这个管理系统中,这样就解决 2 问题。
通过定时任务去每天检查域名是否过期,定时去检查下载域名是否正常访问。对于异常域名通过钉钉发生给运维去处理。
一直在思考问题 1 怎么解决,那就做一个推广页管理,推广页和域名直接强制关联,自动分配下载地址,开发上传页面模板(接了下的重点),推广管理推广相关的内容。修改对应参数(这些参数都是文字变量,对于图片相关处理,替换图片这种需求不是很常见,开发改代码更快),直接点击发布即可。
每次下载链接自动被替换,都会通过钉钉机器人发给测试去确定。
关于页面模板,这个也让我思考很久,最终决定使用 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 等开源项目。如果想要构建
Monorepo
项目,使用Lerna
肯定是不够的,那么接下来我们就来从零开始构建一个Monorepo
项目CLI
工具。Monorepo CLi
解析命令参数
Node.js
为我们提供了 process.argv 来读取命令行参数,作为一个工具,我们不应该手动解析这些参数,有 2 个包 commander 和 cac 推荐,这里我使用cac
。其他相关工具:
还有一些其他好用工具,这里暂时不一一列举了,后面介绍时用上在科普。
创建一个
Monorepo
工作区:创建入口(从 cac 官网实例开始):
我们要定义几个
command
:在
tools/index.js
的cli.command
位置引入即可,其他几个command
类似,这里不一一贴代码。项目配置
所有项目都存放在在
projects
文件夹里,那么有很多项目,如果项目有不一样配置该如何操作了,你可能要说if/else
, 这一块可以学习一下 angular-cli 设计思想,构建配置分离。不同的命令对于对于不同的构建器,构建器使用当前的配置。这是我们每个项目的目录结构:
不过在
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
没有印象,那你一定用过webpack
,它里面的loader
和plugin
输入参数配置验证就是采用json-schema
。当你看完中文文档,有种跃跃欲试冲动,怎么快速构建一个
project.schema.json
呢?我们要站在巨人肩上参考 angular.json 。
我这里大概结构:
以上就是
project.json
要输入的内容:projects
?因为一个项目可能包含pc
和m
2 个子项目,默认推荐响应式一站式。css
强制使用scss
预处理器处理,包括postcss
等处理。project
只会有一个index.html
,有些project
为了应对审查(谷歌广告)需要有多余的免责申明,隐私政策等页面。postcss
、babel
、browserslist
等配置是全局共享。定义
json-schema
规范,那就该验证输入数据是否靠谱。我们采用:ajv
自带ajv-formats
拓展一些字符串格式限制属性,比如常见的:date
、uri
、hostname
等。ajv-keywords
是辅助Ajv
自定义验证属性,一些常用的属性,比如常见的:typeof
、instanceof
、range
、regexp
等。关于验证
json-schema
逻辑并不复杂:接下来只需要对外暴露一个方法,这个方法完成验证和转换。
关于验证,
ajv
自带验证方法,我们只需要使用即可:ajv
默认是没有转换功能,只做json-schema
验证。这个转换是什么意思,在json-schema
规则里有一些属性选填但有默认值,但是我们project.json
是没有提供这些属性,实际 js 取值过程就需要去先判断这个值是否存在,如果转换之后,就可以省略这个操作。关于转换函数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()
运行就可以在
cac
对应的方法里面调用对应的run
方法。configFactory
看起来不错,实际写的一坨代码包在一个函数里面,如果要修改一个基准配置,找脑壳痛。我们可以把
configFactory
合理拆分:这里就不贴代码了,太长了,主要参考
angular-cli
里面一些配置,精简一些不需要。html
这个就没有参考了,如果做单页应用,就一个 html,我这个需要特殊处理一下,开发时候是需要编译成.html
,打包的时候还是保留.ejs
,方便服务端处理。简单理解就是把大函数拆分成小函数,这样就方便组合使用。现在需要提取 2 个新的方法来组合这些配置:
runWebpack
runWebpackDevServer
buildWebpack
和serveWebpack
区别就是,是否使用dev-server
,其他一样。简单秀一下这 2 个函数:
CLI 工具功能实现
核心的构建功能已经完成,接下来就该完成 CLi 工具
serve
通过
options.name
获取project.json
,然后通过options.platform
获取当前运行项目的配置。其他注释都已经说明了,
这里重点说一下:
getProject
和webpackConfiguration
webpackConfiguration
在这里有什么用,这里和project.json
里那个自定义webpack
配置,这里cli
自定义webpack
配置。这里你看到没什么意义代码,接下来 build 里,你就看到它的用处。getProject
为了保证在serveWebpack
以及后需要功能中使用更加方便,这里统一数据结构,通过环境来生成一个项目配置,最终交给serveWebpack
。接下来你只需要运行:
build
build
跟serve
一样,唯一区别,serve
一次只能运行一个project
(这也是为什么需要platform
参数的原因),build
需要打包projects
所有的项目addBuildReleaseZip
就是把dist
文件夹里项目打包成zip
文件,方便上传。generate
generate
没有说明复杂的,根据命令行参数,去生成project.json
, 按照项目配置生成对应文件和写入简单示例代码。platform
这里平台会多一个multi
,是为了方便处理pc
和mobile
同时存在,有时候又只需要一个,方便处理。release
release
就简单了,里面代码和build
一样,借助 axios 和 form-data 把dist.zip
传到服务器上。主要方便项目开发者使用,
config
自动更新模板变量到数据库,publish
自动发布该项目。Nx 一个现代 Monorepo 工具
前面我们做了很多事情,主要还原我想要做一个
Monorepo
项目工具,最基础需要哪些东西:前面 2 个,我上面都已经实现了,自定义扩展方便确暂时无法实现,原因我的构建和 CLI 完全耦合,无法分离,我想老项目使用
webpack
, 新项目使用新潮流vite
按照现在设计完全不可能。接下来我们介绍 Nx,它将完全实现这个不可能。
Nx
一开始的Angular-cli
的扩展,它的作者成员也是Angular
核心开发者。我从
Nx v8
开始使用它,一度放弃Angular-cli
,因为它包含Angular-cli
,还支持React
、Nestjs
、Nextjs
,暂不支持Vue
。创建的一个 nx workspace 就可以开始构建 Monorepo 项目了。
在
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
,那就一行代码事情。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
。这是简单的自定义功能,如果想要借助别的更底层
executor
和generate
呢,我这里一篇定制 nest-mvc 的插件,有兴趣可以看一下,如果有疑问,欢迎跟我交流。写到最后
说起
Angular
,很多人都不喜欢,可以不用Angular
,但是它的工程化思想,可以借鉴学习,在目前前端界,说二没有敢说一,也为你以后做构建轮子提供思路,你不想折腾,那只能呵呵。今天就到这里吧,伙计们,玩得开心,祝你好运
谢谢你读到这里。下面是你接下来可以做的一些事情:
The text was updated successfully, but these errors were encountered: